diff --git a/.github/workflows/fetch_georelays.yml b/.github/workflows/fetch_georelays.yml index 6aaf6693..ad5f0bb4 100644 --- a/.github/workflows/fetch_georelays.yml +++ b/.github/workflows/fetch_georelays.yml @@ -1,42 +1,228 @@ -name: Fetch GeoRelays Data +name: Propose GeoRelay Data Update on: schedule: - - cron: '0 6 * * 0' + - cron: "0 6 * * 0" workflow_dispatch: +# Default to read-only. The publishing job receives only the scopes required +# to push its branch and publish either a PR or a tracking issue. permissions: - contents: write - pull-requests: write + contents: read + +concurrency: + group: georelay-data-update + cancel-in-progress: false + +env: + SOURCE_REPOSITORY: https://github.com/permissionlesstech/georelays.git + UPDATE_BRANCH: automation/georelay-data + TRACKING_ISSUE_TITLE: GeoRelay update awaiting pull request jobs: - update-relay-data: + propose-relay-data: + name: Validate and propose relay data runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + issues: write steps: - - name: Checkout repository - uses: actions/checkout@v4 + - name: Checkout reviewed base + # Pinned actions/checkout v5 so a mutable action tag cannot change the + # code that receives this job's write-capable token. + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: - token: ${{ secrets.GITHUB_TOKEN }} + ref: main fetch-depth: 0 + # Do not expose the write token to fetch/validation subprocesses. + persist-credentials: false - - name: Fetch GeoRelays + - name: Test GeoRelay validator run: | - wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv - mv nostr_relays.csv ./relays/online_relays_gps.csv + set -euo pipefail + python3 -m unittest discover -s scripts/tests -p "test_*.py" -v - - name: Check for changes - id: git-check + - name: Fetch candidate over pinned HTTPS policy + id: upstream run: | - git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT - - - name: Commit and push changes - if: steps.git-check.outputs.changes == 'true' + set -euo pipefail + source_commit=$(git ls-remote --refs "$SOURCE_REPOSITORY" refs/heads/main | awk 'NR == 1 { print $1 }') + if [[ ! "$source_commit" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::Could not resolve an immutable upstream commit" + exit 1 + fi + source_url="https://raw.githubusercontent.com/permissionlesstech/georelays/$source_commit/nostr_relays.csv" + effective_url=$(curl --fail --show-error --silent --location --proto "=https" --proto-redir "=https" --tlsv1.2 --max-time 60 --retry 3 --retry-all-errors --output "$RUNNER_TEMP/georelays-candidate.csv" --write-out "%{url_effective}" "$source_url") + if [[ "$effective_url" != "$source_url" ]]; then + echo "::error::Unexpected GeoRelay redirect target: $effective_url" + exit 1 + fi + echo "source_commit=$source_commit" >> "$GITHUB_OUTPUT" + echo "source_url=$source_url" >> "$GITHUB_OUTPUT" + + - name: Validate candidate against reviewed baseline + id: validation run: | - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" - git add relays/online_relays_gps.csv - git commit -m "Automated update of relay data - $(date -u)" - git push + set -euo pipefail + python3 scripts/validate_georelays.py --input "$RUNNER_TEMP/georelays-candidate.csv" --baseline relays/online_relays_gps.csv --output relays/online_relays_gps.csv --github-output "$GITHUB_OUTPUT" + + - name: Check for a reviewed-file change + id: changes + run: | + set -euo pipefail + if git diff --quiet -- relays/online_relays_gps.csv; then + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "Upstream GeoRelay data already matches main." >> "$GITHUB_STEP_SUMMARY" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + git diff --stat -- relays/online_relays_gps.csv + fi + + - name: Push automation branch and publish review request + if: steps.changes.outputs.changed == 'true' env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GH_TOKEN: ${{ github.token }} + SOURCE_COMMIT: ${{ steps.upstream.outputs.source_commit }} + SOURCE_URL: ${{ steps.upstream.outputs.source_url }} + DATA_ROWS: ${{ steps.validation.outputs.data_rows }} + UNIQUE_RELAYS: ${{ steps.validation.outputs.unique_relays }} + DATA_SHA256: ${{ steps.validation.outputs.sha256 }} + run: | + set -euo pipefail + + # Scope credential exposure to this final publishing step. + gh auth setup-git + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git switch -C "$UPDATE_BRANCH" + git add -- relays/online_relays_gps.csv + git diff --cached --quiet && { + echo "::error::Expected a staged GeoRelay data change" + exit 1 + } + git commit -m "Update reviewed georelay directory" -m "Upstream-commit: $SOURCE_COMMIT" + + remote_ref="refs/remotes/origin/$UPDATE_BRANCH" + if git fetch --no-tags origin "+refs/heads/$UPDATE_BRANCH:$remote_ref" 2>/dev/null; then + remote_sha=$(git rev-parse "$remote_ref") + git push --force-with-lease="refs/heads/$UPDATE_BRANCH:$remote_sha" origin "HEAD:refs/heads/$UPDATE_BRANCH" + else + git push origin "HEAD:refs/heads/$UPDATE_BRANCH" + fi + + body_file="$RUNNER_TEMP/georelay-pr-body.md" + { + echo "## Automated GeoRelay data proposal" + echo + echo "- Source: $SOURCE_URL" + echo "- Upstream commit: $SOURCE_COMMIT" + echo "- Data rows: $DATA_ROWS" + echo "- Unique normalized relays: $UNIQUE_RELAYS" + echo "- SHA-256: $DATA_SHA256" + echo + echo "The candidate passed strict UTF-8, schema, size, row-count, secure-host, coordinate, duplicate-conflict, and baseline-delta validation." + echo + echo "This PR is intentionally not auto-merged. Review the relay additions/removals before merging." + } > "$body_file" + + existing_pr=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --base main --head "$UPDATE_BRANCH" --json number --jq '.[0].number // empty') + pr_error="$RUNNER_TEMP/georelay-pr-error.txt" + pr_url="" + if [[ -n "$existing_pr" ]]; then + if gh pr edit "$existing_pr" --repo "$GITHUB_REPOSITORY" --title "Update reviewed GeoRelay directory" --body-file "$body_file" 2> "$pr_error"; then + pr_url=$(gh pr view "$existing_pr" --repo "$GITHUB_REPOSITORY" --json url --jq .url) + fi + else + if created_pr_url=$(gh pr create --repo "$GITHUB_REPOSITORY" --base main --head "$UPDATE_BRANCH" --title "Update reviewed GeoRelay directory" --body-file "$body_file" 2> "$pr_error"); then + pr_url="$created_pr_url" + fi + fi + + tracking_issue_numbers=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "\"$TRACKING_ISSUE_TITLE\" in:title" --limit 100 --json number,title --jq ".[] | select(.title == \"$TRACKING_ISSUE_TITLE\") | .number") + tracking_issues=() + if [[ -n "$tracking_issue_numbers" ]]; then + mapfile -t tracking_issues <<< "$tracking_issue_numbers" + fi + + if [[ -n "$pr_url" ]]; then + for issue_number in "${tracking_issues[@]}"; do + gh issue close "$issue_number" --repo "$GITHUB_REPOSITORY" --comment "A pull request is now available at $pr_url; closing this fallback tracking issue." + done + echo "Published GeoRelay review PR: $pr_url" >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + echo "::warning::GITHUB_TOKEN could not create or update the GeoRelay pull request; publishing the issues-write fallback." + if [[ -s "$pr_error" ]]; then + cat "$pr_error" >&2 + fi + + compare_url="https://github.com/${GITHUB_REPOSITORY}/compare/main...${UPDATE_BRANCH}?expand=1" + issue_body_file="$RUNNER_TEMP/georelay-tracking-issue-body.md" + { + echo "## Validated GeoRelay update awaiting review" + echo + echo "The automation branch was updated, but this workflow token could not create or update the pull request. Use the compare link below to create it manually." + echo + echo "- Compare and create PR: $compare_url" + echo "- Automation branch: $UPDATE_BRANCH" + echo "- Source: $SOURCE_URL" + echo "- Upstream commit: $SOURCE_COMMIT" + echo "- Data rows: $DATA_ROWS" + echo "- Unique normalized relays: $UNIQUE_RELAYS" + echo "- SHA-256: $DATA_SHA256" + echo + echo "The snapshot passed the repository's strict validator before the branch was pushed." + } > "$issue_body_file" + + if (( ${#tracking_issues[@]} > 0 )); then + primary_issue="${tracking_issues[0]}" + gh issue edit "$primary_issue" --repo "$GITHUB_REPOSITORY" --title "$TRACKING_ISSUE_TITLE" --body-file "$issue_body_file" + issue_url=$(gh issue view "$primary_issue" --repo "$GITHUB_REPOSITORY" --json url --jq .url) + for duplicate_issue in "${tracking_issues[@]:1}"; do + gh issue close "$duplicate_issue" --repo "$GITHUB_REPOSITORY" --comment "Closing duplicate GeoRelay automation tracking issue; #$primary_issue is canonical." + done + else + issue_url=$(gh issue create --repo "$GITHUB_REPOSITORY" --title "$TRACKING_ISSUE_TITLE" --body-file "$issue_body_file") + fi + + # Do not claim success until the fallback issue was confirmed. + [[ -n "$issue_url" ]] + echo "Published GeoRelay tracking issue fallback: $issue_url" >> "$GITHUB_STEP_SUMMARY" + + - name: Clean obsolete automation review state + if: steps.changes.outputs.changed == 'false' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh auth setup-git + + existing_pr=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --base main --head "$UPDATE_BRANCH" --json number --jq '.[0].number // empty') + if [[ -n "$existing_pr" ]]; then + gh pr close "$existing_pr" --repo "$GITHUB_REPOSITORY" --comment "Upstream now matches the reviewed file on main; closing this obsolete automation proposal." + echo "Closed obsolete PR #$existing_pr." >> "$GITHUB_STEP_SUMMARY" + fi + + tracking_issue_numbers=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "\"$TRACKING_ISSUE_TITLE\" in:title" --limit 100 --json number,title --jq ".[] | select(.title == \"$TRACKING_ISSUE_TITLE\") | .number") + if [[ -n "$tracking_issue_numbers" ]]; then + while IFS= read -r issue_number; do + gh issue close "$issue_number" --repo "$GITHUB_REPOSITORY" --comment "Upstream now matches the reviewed file on main; closing this obsolete automation tracker." + echo "Closed obsolete tracking issue #$issue_number." >> "$GITHUB_STEP_SUMMARY" + done <<< "$tracking_issue_numbers" + fi + + if git ls-remote --exit-code --heads origin "refs/heads/$UPDATE_BRANCH" > /dev/null; then + git push origin --delete "$UPDATE_BRANCH" + echo "Deleted obsolete automation branch $UPDATE_BRANCH." >> "$GITHUB_STEP_SUMMARY" + else + ls_remote_status=$? + if (( ls_remote_status != 2 )); then + echo "::error::Could not inspect the obsolete automation branch" + exit "$ls_remote_status" + fi + fi diff --git a/.github/workflows/source-manifest.yml b/.github/workflows/source-manifest.yml new file mode 100644 index 00000000..e05ae34f --- /dev/null +++ b/.github/workflows/source-manifest.yml @@ -0,0 +1,112 @@ +name: Source manifest + +# Publishes a hash manifest for every tagged release so a copy of the source +# obtained from somewhere other than this repository can be checked against it. +# +# This exists because the repository has been the target of takedown demands. +# When that succeeds, mirrors appear, and without a manifest there is no way to +# tell a faithful mirror from a modified one. The manifest is attested to this +# workflow run, so its own provenance is verifiable with `gh attestation verify`. +# +# Scope, stated plainly: this verifies SOURCE. It does not verify any compiled +# app. See docs/VERIFYING-A-BUILD.md. + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + ref: + description: 'Tag or commit to produce a manifest for' + required: true + +permissions: + contents: read + +jobs: + manifest: + runs-on: ubuntu-latest + permissions: + contents: write # attach the manifest to the release + id-token: write # provenance attestation + attestations: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.ref || github.ref }} + # Full history so the commit the tag names can be recorded exactly. + fetch-depth: 0 + + - name: Build manifest + id: build + run: | + set -euo pipefail + ref_name="${{ github.event.inputs.ref || github.ref_name }}" + commit="$(git rev-parse HEAD)" + tree="$(git rev-parse HEAD^{tree})" + + # Hash every tracked file, in a stable order, with NUL separation so + # paths containing spaces or newlines cannot shift the columns. + git ls-files -z \ + | sort -z \ + | xargs -0 sha256sum \ + > files.sha256 + + { + echo "# bitchat source manifest" + echo "#" + echo "# ref: ${ref_name}" + echo "# commit: ${commit}" + echo "# tree: ${tree}" + echo "# files: $(wc -l < files.sha256 | tr -d ' ')" + echo "#" + echo "# Verify a checkout of this ref with:" + echo "# shasum -a 256 -c files.sha256" + echo "# Hash checking alone ignores files this manifest does not list, and" + echo "# the Xcode project compiles any source file present in the tree. So" + echo "# also confirm nothing extra is present:" + echo "# git status --porcelain --ignored # git checkout: must print nothing" + echo "# or, for a tarball, diff this manifest's path list against find(1)." + echo "# Full instructions: docs/VERIFYING-A-BUILD.md" + echo "# The git tree hash above is the single value covering all tracked content:" + echo "# git rev-parse HEAD^{tree}" + echo "#" + } > SOURCE-MANIFEST.txt + cat files.sha256 >> SOURCE-MANIFEST.txt + + echo "commit=${commit}" >> "$GITHUB_OUTPUT" + echo "tree=${tree}" >> "$GITHUB_OUTPUT" + + - name: Self-check the manifest + run: | + set -euo pipefail + # A manifest that does not validate against the tree it was made from + # is worse than none, so fail loudly rather than publishing it. + grep -v '^#' SOURCE-MANIFEST.txt > check.sha256 + sha256sum -c check.sha256 > /dev/null + echo "manifest validates against this checkout" + + - name: Attest the manifest + uses: actions/attest-build-provenance@v1 + with: + subject-path: SOURCE-MANIFEST.txt + + - uses: actions/upload-artifact@v4 + with: + name: source-manifest + path: SOURCE-MANIFEST.txt + + - name: Attach to release + if: startsWith(github.ref, 'refs/tags/') + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + # A tag may be pushed before its release exists; only attach when + # there is a release to attach to, and never fail the run over it. + if gh release view "${{ github.ref_name }}" >/dev/null 2>&1; then + gh release upload "${{ github.ref_name }}" SOURCE-MANIFEST.txt --clobber + else + echo "no release for ${{ github.ref_name }} yet; manifest is available as a workflow artifact" + fi diff --git a/.github/workflows/swift-tests.yml b/.github/workflows/swift-tests.yml index 81856b24..81c42eb2 100644 --- a/.github/workflows/swift-tests.yml +++ b/.github/workflows/swift-tests.yml @@ -94,6 +94,24 @@ jobs: kill "$watchdog_pid" 2>/dev/null || true exit "$status" + # Read coverage before the serial benchmark command below rebuilds the + # test binary without instrumentation. Reporting against that newer + # binary makes llvm-cov reject the profile as out of date. + # Informational only: there is deliberately no percentage threshold, but + # a broken/missing report is a CI configuration error and must be visible. + - name: Coverage summary + run: | + BIN_PATH=$(swift build --show-bin-path --package-path ${{ matrix.path }}) + PROF="$BIN_PATH/codecov/default.profdata" + XCTEST=$(find "$BIN_PATH" -maxdepth 1 -name '*.xctest' | head -1) + BINARY="$XCTEST/Contents/MacOS/$(basename "$XCTEST" .xctest)" + if [ ! -f "$PROF" ] || [ ! -f "$BINARY" ]; then + echo "::error::Coverage profile or test binary is missing" + exit 1 + fi + xcrun llvm-cov report "$BINARY" -instr-profile "$PROF" \ + -ignore-filename-regex='(Tests|\.build|checkouts|Mocks|_PreviewHelpers)' + # Benchmarks run serially on an otherwise idle runner for stable # numbers; BITCHAT_PERF_LOG captures the PERF[...] lines for the gate. - name: Run performance benchmarks (serial) @@ -115,22 +133,6 @@ jobs: timeout-minutes: 10 run: ./scripts/check-perf-floors.sh perf-output.log - # Informational only: surfaces per-file and total line coverage in the - # job log so coverage trends are visible on every PR. No thresholds — - # this must never be the reason a build goes red. - - name: Coverage summary - run: | - BIN_PATH=$(swift build --show-bin-path --package-path ${{ matrix.path }}) - PROF="$BIN_PATH/codecov/default.profdata" - XCTEST=$(find "$BIN_PATH" -maxdepth 1 -name '*.xctest' | head -1) - BINARY="$XCTEST/Contents/MacOS/$(basename "$XCTEST" .xctest)" - if [ -f "$PROF" ] && [ -f "$BINARY" ]; then - xcrun llvm-cov report "$BINARY" -instr-profile "$PROF" \ - -ignore-filename-regex='(Tests|\.build|checkouts|Mocks|_PreviewHelpers)' || true - else - echo "No coverage data found; skipping summary." - fi - # SPM tests do not link the shipping app targets. This job covers the # iOS-conditional paths and both universal Release link configurations. ios-build: @@ -142,6 +144,9 @@ jobs: - name: Checkout code uses: actions/checkout@v5 + - name: Check clean recipe safety + run: bash scripts/check-just-clean-safety.sh + - name: Build iOS (simulator, no signing) # Build both simulator architectures so CI validates every vendored # Arti simulator slice and the configuration that ships. @@ -169,6 +174,96 @@ jobs: CODE_SIGNING_ALLOWED=NO \ build + # The SwiftPM matrix runs on macOS and cannot execute UIKit/CoreBluetooth + # conditional tests. Build the shared iOS test target and run it on the first + # available iPhone simulator from the runner image instead of hard-coding a + # model that changes when GitHub updates Xcode. The suite intentionally runs + # in one test runner: a number of integration tests exercise process-global + # stores and notification centers, so overlapping workers can corrupt each + # other's fixtures and turn sub-second tests into multi-minute timeouts. + ios-tests: + name: Run iOS simulator tests + runs-on: macos-latest + timeout-minutes: 20 + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + # Some runner images list only placeholder destinations (rows carrying + # "error:" or "unavailable") or ship the selected Xcode without a + # matching iOS simulator runtime, so this walks three paths in order: + # a usable xcodebuild destination, an existing simctl device, and + # finally creating a device from the newest installed iOS runtime. + - name: Select available iPhone simulator + id: destination + run: | + set -uo pipefail + destinations=$(xcodebuild -project bitchat.xcodeproj -scheme "bitchat (iOS)" -showdestinations 2>/dev/null || true) + destination_id=$(awk -F'id:' ' + /platform:iOS Simulator/ && /name:iPhone/ \ + && !/error/ && !/unavailable/ && !found { + value=$2 + sub(/,.*/, "", value) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", value) + print value + found=1 + } + ' <<< "$destinations") + + if [ -n "$destination_id" ]; then + echo "Selected destination via xcodebuild -showdestinations: $destination_id" + else + echo "No usable iPhone destination in -showdestinations output; falling back to simctl" + destination_id=$(xcrun simctl list devices available --json | jq -r ' + [.devices | to_entries[] + | select(.key | contains("iOS")) + | .value[] + | select(.isAvailable and (.name | startswith("iPhone")))] + | first.udid // empty') + if [ -n "$destination_id" ]; then + echo "Selected existing simctl device: $destination_id" + else + echo "No available iPhone simulator device; creating one" + # Newest installed iOS runtime plus an iPhone device type that + # runtime itself reports as supported, so the pair always match. + create_spec=$(xcrun simctl list runtimes --json | jq -r ' + [.runtimes[] | select(.platform == "iOS" and .isAvailable)] + | sort_by(.version | split(".") | map(tonumber)) + | last // empty + | .identifier as $runtime + | ([(.supportedDeviceTypes // [])[] + | select(.productFamily == "iPhone" + or (.name // "" | startswith("iPhone")))] + | first.identifier // empty) as $devicetype + | "\($devicetype) \($runtime)"') + read -r devicetype runtime <<< "$create_spec" || true + if [ -z "${devicetype:-}" ] || [ -z "${runtime:-}" ]; then + echo "::error::No iPhone simulator destination found and none creatable (no installed iOS runtime with an iPhone device type)" + exit 1 + fi + destination_id=$(xcrun simctl create ci-iphone "$devicetype" "$runtime") || { + echo "::error::simctl create failed for $devicetype on $runtime" + exit 1 + } + echo "Created simulator $destination_id ($devicetype, $runtime)" + fi + fi + + echo "Using iPhone simulator destination id: $destination_id" + echo "id=$destination_id" >> "$GITHUB_OUTPUT" + + - name: Run iOS tests + run: | + set -o pipefail + xcodebuild -project bitchat.xcodeproj \ + -scheme "bitchat (iOS)" \ + -sdk iphonesimulator \ + -destination "platform=iOS Simulator,id=${{ steps.destination.outputs.id }}" \ + -parallel-testing-enabled NO \ + CODE_SIGNING_ALLOWED=NO \ + test + # Advisory only: SwiftLint reports style violations without ever failing the # build. Runs in a pinned container (no Xcode plugin, no pbxproj changes) so # it can never break the documented xcodebuild path or block a merge. diff --git a/.gitignore b/.gitignore index df91a858..ac9e4d89 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,4 @@ build.log # Local configs Local.xcconfig +*.profraw diff --git a/Configs/Local.xcconfig.example b/Configs/Local.xcconfig.example index 899c5a62..a934cb56 100644 --- a/Configs/Local.xcconfig.example +++ b/Configs/Local.xcconfig.example @@ -3,3 +3,6 @@ DEVELOPMENT_TEAM = ABC123 // Unique bundle id to be able to register and run locally PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.$(DEVELOPMENT_TEAM) + +// App and share extension must use an App Group registered to your team. +APP_GROUP_ID = group.chat.bitchat.$(DEVELOPMENT_TEAM) diff --git a/Justfile b/Justfile index 88cb3895..9b1d3a31 100644 --- a/Justfile +++ b/Justfile @@ -1,107 +1,66 @@ -# BitChat macOS Build Justfile -# Handles temporary modifications needed to build and run on macOS +# BitChat developer commands +# +# Builds use a repository-local, ignored DerivedData directory. No recipe +# patches, restores, or removes tracked project/configuration files. + +project := "bitchat.xcodeproj" +macos_scheme := "bitchat (macOS)" +ios_scheme := "bitchat (iOS)" +derived_data := ".DerivedData" -# Default recipe - shows available commands default: - @echo "BitChat macOS Build Commands:" - @echo " just run - Build and run the macOS app" - @echo " just build - Build the macOS app only" - @echo " just clean - Clean build artifacts and restore original files" - @echo " just check - Check prerequisites" - @echo "" - @echo "Original files are preserved - modifications are temporary for builds only" + @echo "BitChat developer commands:" + @echo " just run Build and run the macOS app" + @echo " just build Build the macOS app without signing" + @echo " just test Run the SwiftPM test suite" + @echo " just test-ios Run tests on the iPhone 17 simulator" + @echo " just clean Remove repo-local build artifacts only" + @echo " just nuke Also remove nested package build caches" + @echo " just check Validate the development environment" -# Check prerequisites -check: +# Static guard against reintroducing source-restoring or source-deleting clean +# behavior. CI runs the same script directly. +check-clean-safety: + @bash scripts/check-just-clean-safety.sh + +check: check-clean-safety @echo "Checking prerequisites..." - @command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install Xcode from App Store" && exit 1) - @xcode-select -p | grep -q "Xcode.app" || (echo "❌ Full Xcode required, not just command line tools. Install from App Store and run:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1) - @test -d "/Applications/Xcode.app" || (echo "❌ Xcode.app not found in Applications folder. Install from App Store" && exit 1) - @xcodebuild -version >/dev/null 2>&1 || (echo "❌ Xcode not properly configured. Try:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1) - @security find-identity -v -p codesigning | grep -q "Apple Development\|Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0) - @echo "✅ All prerequisites met" + @command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install full Xcode." && exit 1) + @developer_dir="$$(xcode-select -p 2>/dev/null)"; case "$$developer_dir" in *.app/Contents/Developer) ;; *) echo "❌ Full Xcode is not selected. Run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer"; exit 1;; esac + @xcodebuild -version + @echo "✅ Development environment ready (a signing identity is not required for just build)" -# 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 +build: check @echo "Building BitChat for macOS..." - @xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build + @xcodebuild -project "{{project}}" -scheme "{{macos_scheme}}" -configuration Debug -derivedDataPath "{{derived_data}}" CODE_SIGNING_ALLOWED=NO build -# Run the macOS app run: build - @echo "Launching BitChat..." - @find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}" + @app="{{derived_data}}/Build/Products/Debug/bitchat.app"; test -d "$$app" || (echo "❌ Built app not found at $$app" && exit 1); open "$$app" -# Clean build artifacts and restore original files -clean: restore - @echo "Cleaning build artifacts..." - @rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true - @# Only remove the generated project if we have a backup, otherwise use git - @if [ -f bitchat.xcodeproj/project.pbxproj.backup ]; then \ - rm -rf bitchat.xcodeproj; \ - else \ - git checkout -- bitchat.xcodeproj/project.pbxproj 2>/dev/null || echo "⚠️ Could not restore project.pbxproj"; \ - fi - @rm -f project-macos.yml 2>/dev/null || true - @echo "✅ Cleaned and restored original files" +# Backward-compatible alias for the old quick-run recipe. +dev-run: run -# Quick run without cleaning (for development) -dev-run: check - @echo "Quick development build..." - @xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build - @find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}" +test: + @swift test + +test-ios: check + @xcodebuild -project "{{project}}" -scheme "{{ios_scheme}}" -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' -derivedDataPath "{{derived_data}}" test + +# Artifact-only cleanup. In particular, this recipe never invokes Git and +# never writes, moves, restores, or removes source/configuration files. +clean: + @echo "Cleaning repo-local build artifacts..." + @rm -rf -- "{{derived_data}}" ".build" + @echo "✅ Cleaned {{derived_data}} and .build; tracked files were untouched" + +# Retain the familiar command, but keep it artifact-only as well. +nuke: clean + @echo "Cleaning nested package build caches..." + @find localPackages -type d -name .build -prune -exec rm -rf -- {} + + @rm -rf -- ".cache" + @echo "✅ Removed repository build caches; tracked files were untouched" -# Show app info info: - @echo "BitChat - Decentralized Mesh Messaging" - @echo "======================================" - @echo "• Native macOS SwiftUI app" - @echo "• Bluetooth LE mesh networking" - @echo "• End-to-end encryption" - @echo "• No internet required" - @echo "• Works offline with nearby devices" - @echo "" - @echo "Requirements:" - @echo "• macOS 13.0+ (Ventura)" - @echo "• Bluetooth LE capable Mac" - @echo "• Physical device (no simulator support)" - @echo "" - @echo "Usage:" - @echo "• Set nickname and start chatting" - @echo "• Use /join #channel for group chats" - @echo "• Use /msg @user for private messages" - @echo "• Triple-tap logo for emergency wipe" - -# Force clean everything (nuclear option) -nuke: - @echo "🧨 Nuclear clean - removing all build artifacts and backups..." - @rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true - @rm -rf bitchat.xcodeproj 2>/dev/null || true - @rm -f bitchat.xcodeproj/project.pbxproj.backup 2>/dev/null || true - @rm -f bitchat/Info.plist.backup 2>/dev/null || true - @# Restore iOS-specific files if they were moved - @if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi - @git checkout bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Not a git repo or no changes to restore" - @echo "✅ Nuclear clean complete" + @echo "BitChat - decentralized mesh messaging" + @echo "macOS 13+ and iOS 16+" + @echo "Bluetooth mesh behavior requires physical Bluetooth-capable devices" diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md index 93edc3b6..74285c0b 100644 --- a/PRIVACY_POLICY.md +++ b/PRIVACY_POLICY.md @@ -17,12 +17,12 @@ bitchat is designed for private, account-free communication. This policy describ 1. **Identity and cryptographic keys** - Noise, signing, group, prekey, and optional Nostr identity material is generated locally. - - Secret keys are stored in the system keychain. Public keys are shared when required for messaging, verification, groups, or Nostr events. - - Keys remain until they are rotated, removed by the relevant feature, erased with panic wipe, or removed with the app. + - Secret keys are stored in the system keychain as device-only items. Public keys are shared when required for messaging, verification, groups, or Nostr events. + - Keys remain until they are rotated, removed by the relevant feature, or erased with panic wipe. Because operating-system keychains can outlive an uninstall, bitchat records a non-secret install marker and deletes surviving app keys before use after a later reinstall. 2. **Nickname, preferences, and relationships** - Your nickname, settings, favorites, petnames, read-receipt identifiers, and bounded operational metadata are stored locally. - - The share extension briefly places content you choose to share in the app-group preferences so the main app can import it. + - 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. 3. **Private group state** - Group names, rosters, creator identity, and key epoch are stored as protected files in Application Support. @@ -34,13 +34,13 @@ bitchat is designed for private, account-free communication. This policy describ - A panic wipe deletes both stores. 5. **Recent public mesh messages and notices** - - Signed public mesh messages may be kept in a protected local gossip archive for up to 15 minutes so they can cross mesh partitions and survive a short relaunch. + - Signed public mesh messages may be kept in a protected local gossip archive for up to 6 hours so they can cross mesh partitions and survive a relaunch. - Public bulletin-board posts and deletion tombstones persist until the post's author-selected expiry, at most seven days. Both stores are bounded and panic-wipeable. - These items are public to the mesh or board where they are posted; they are not confidential messages. 6. **Media attachments** - Voice notes and images you send or receive can be stored under Application Support so they remain playable while referenced by the app. - - Incoming media is subject to a 100 MB quota with oldest-file eviction. Media is deleted by panic wipe or app removal; some outgoing media can otherwise remain on disk. + - Incoming media is subject to a 100 MB quota with oldest-file eviction. All stored media, sent and received, is also deleted once it is more than seven days old, and immediately by panic wipe or app removal. 7. **Optional location-channel state** - Your selected geohash channel, bookmarks, teleport flags, and bookmark display names are stored locally so the UI can restore them. @@ -73,7 +73,7 @@ Private group members receive the group's name, roster, key epoch, and encrypted Internet-backed features are optional. When enabled or used: -- Private fallback messages use encrypted NIP-17 gift wraps. Relays can observe event and network metadata but not the message plaintext. +- Private fallback messages use BitChat's app-specific encrypted envelopes. This format is not NIP-17, NIP-44, or NIP-59 compatible. Relays can observe the recipient public-key tag, event timing and size, and network metadata, but not the message plaintext or stable sender identity. - Public location-channel messages, notes, notices, and presence include a geohash tag, event kind, timestamp, and a public key. A geohash reveals an approximate area; finer precision reveals a smaller area. - The optional mesh bridge publishes bridge-enabled public mesh messages and presence to a neighborhood rendezvous cell. Those messages are public to participants and relays for that cell. A per-message “nearby only” choice prevents that message from crossing the bridge. - Bridge courier drops contain opaque end-to-end encrypted envelopes and a rotating recipient tag. Relays still observe timing and network metadata. @@ -81,6 +81,8 @@ Internet-backed features are optional. When enabled or used: Nostr relays are operated by third parties. Their retention, logging, availability, and privacy practices are outside the project's control. Public events and encrypted events may remain on relays according to each relay's policy. +You can add relays yourself in settings, including `.onion` addresses. Added relays are stored locally, are limited in number, and are erased by panic wipe. Tor routing is on by default; while it is off, every relay you connect to can see your IP address, including relays carrying your private messages. + ## Location and Apple Services Location permission is optional and requested as when-in-use access. It is used to compute geohash channels, bridge rendezvous cells, and nearby place labels. @@ -103,7 +105,7 @@ Private and public features use different protections: - Mesh private sessions use Noise XX with X25519, ChaCha20-Poly1305, and SHA-256. - Private group messages use ChaCha20-Poly1305; group state and relevant mesh packets use Ed25519 signatures. -- Nostr events use secp256k1 Schnorr signatures. NIP-44 v2 private payloads use secp256k1 key agreement, HKDF-SHA256, and XChaCha20-Poly1305. +- Nostr events use secp256k1 Schnorr signatures. BitChat private envelopes use secp256k1 key agreement, HKDF-SHA256, and XChaCha20-Poly1305. The envelope format is proprietary, only interoperates with BitChat clients, and does not provide forward secrecy against later compromise of the recipient's static Nostr private key. - The persistent private-message outbox uses ChaCha20-Poly1305 with a key held in the keychain. Some other protected local identity state uses AES-GCM. - Public mesh, bridge, geohash, and board content is signed or authenticated as appropriate but is intentionally not confidential. @@ -114,14 +116,17 @@ No cryptographic system can protect content after a recipient reads, copies, scr - **In-memory chat timelines and active connections:** until the app closes or state is cleared. - **Queued outgoing private messages:** until acknowledged, dropped by bounded policy, or 24 hours, whichever comes first. - **Opaque courier envelopes:** until handed off, evicted by bounded policy, or 24 hours, whichever comes first. -- **Recent public mesh gossip:** up to 15 minutes. +- **Recent public mesh gossip:** up to 6 hours. - **Public board posts and tombstones:** until expiry, at most seven days. -- **Groups, favorites, preferences, identity keys, bookmarks, and media:** until removed by the feature, panic wipe, quota eviction where applicable, or app removal. +- **Media:** seven days, or sooner by quota eviction, panic wipe, or app removal. +- **Groups, favorites, preferences, identity keys, and bookmarks:** until removed by the feature, panic wipe, or app removal. - **Nostr data:** according to the policies of the relays that receive it. ## Your Controls -- **Panic wipe:** Triple-tap the logo to clear local keys, sessions, preferences, groups, queues, carried mail, public archives, board data, and media managed by the app. +- **Panic wipe:** Triple-tap the logo to synchronously cancel in-flight media work and clear local keys, sessions, preferences, groups, queues, carried mail, public archives, board data, and media managed by the app. +- **Notification previews:** Hidden by default, so lock-screen alerts do not show message text, sender names, or geohashes. Full previews can be turned on in settings. +- **Clearing a conversation:** Clearing the mesh timeline also deletes the recent public gossip this device had stored on disk. - **Feature controls:** Location channels, mesh bridge, internet gateway, and related internet behaviors can be disabled in the app. Some already-published relay data cannot be recalled. - **System permissions:** Bluetooth, location, microphone, camera, and photo-library access can be revoked in system settings. - **No account:** The project operates no account record for you to request or export. diff --git a/Package.swift b/Package.swift index 7d447630..2bb83521 100644 --- a/Package.swift +++ b/Package.swift @@ -63,7 +63,11 @@ let package = Package( // Only the vector fixture: declaring the whole "Noise" // directory would claim its .swift test files as resources // and silently drop them from compilation. - .process("Noise/NoiseTestVectors.json") + .process("Noise/NoiseTestVectors.json"), + // Frozen envelopes produced by the released iOS (733098bb) + // and Android (b7f0b33d) private-DM implementations; prove + // receive compatibility independently of the local generator. + .process("Nostr/Fixtures") ] ) ] diff --git a/README.md b/README.md index eef7bb40..fc9313c1 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,12 @@ A decentralized peer-to-peer messaging app with dual transport architecture: loc 📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622) +### Getting a copy you can trust + +Install from the App Store, or build from source you have verified. A compiled build from anywhere else cannot be verified — see [Verifying bitchat](docs/VERIFYING-A-BUILD.md) for how to check source against the per-release hash manifest, and for what to do if that is the only build you can get. + +This matters more than it usually would: this repository has been the target of takedown demands, and when a repository or releases page disappears, mirrors appear that nobody can check. + ## License This project is released into the public domain. See the [LICENSE](LICENSE) file for details. @@ -18,8 +24,8 @@ This project is released into the public domain. See the [LICENSE](LICENSE) file - **Location-Based Channels**: Geographic chat rooms using geohash coordinates over global Nostr relays - **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback) - **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE -- **Privacy First**: No accounts, no phone numbers, no persistent identifiers -- **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, NIP-17 for Nostr +- **Privacy First**: No accounts, no phone numbers, no servers. Note that the mesh does use a persistent per-device identifier derived from your identity key — see [the whitepaper](WHITEPAPER.md) on identity and metadata for what a nearby radio can observe +- **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, BitChat private envelopes for Nostr fallback - **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface - **Universal App**: Native support for iOS and macOS - **Emergency Wipe**: Triple-tap to instantly clear all data @@ -34,7 +40,7 @@ BitChat uses a **hybrid messaging architecture** with two complementary transpor - **Local Communication**: Direct peer-to-peer within Bluetooth range - **Multi-hop Relay**: Messages route through nearby devices (max 7 hops) - **No Internet Required**: Works completely offline in disaster scenarios -- **Noise Protocol Encryption**: End-to-end encryption with forward secrecy +- **Noise Protocol Encryption**: End-to-end encryption, with forward secrecy for live sessions (store-and-forward mail is sealed without it — see the whitepaper) - **Binary Protocol**: Compact packet format optimized for Bluetooth LE constraints - **Automatic Discovery**: Peer discovery and connection management - **Adaptive Power**: Battery-optimized duty cycling @@ -44,9 +50,15 @@ BitChat uses a **hybrid messaging architecture** with two complementary transpor - **Global Reach**: Connect with users worldwide via internet relays - **Location Channels**: Geographic chat rooms using geohash coordinates - **290+ Relay Network**: Distributed across the globe for reliability -- **NIP-17 Encryption**: Gift-wrapped private messages for internet privacy +- **BitChat Private Envelopes**: App-specific encrypted private messages over Nostr relays - **Ephemeral Keys**: Fresh cryptographic identity per geohash area +BitChat's private-envelope format is proprietary and is **not** NIP-17, +NIP-44, or NIP-59 compatible. It uses Nostr as a relay transport but only +interoperates with BitChat clients: private payloads travel inside kind-1059 +events whose `v2:`-prefixed content is a BitChat-specific XChaCha20-Poly1305 +construction, not NIP-44 encryption. + ### Channel Types #### `mesh #bluetooth` @@ -80,7 +92,7 @@ Private messages use **intelligent transport selection**: 2. **Nostr Fallback** (when Bluetooth unavailable) - Uses recipient's Nostr public key - - NIP-17 gift-wrapping for privacy + - BitChat's app-specific private-envelope encryption - Routes through global relay network 3. **Smart Queuing** (when neither available) @@ -93,30 +105,62 @@ For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.m ### Option 1: Using Xcode - ```bash - cd bitchat - open bitchat.xcodeproj - ``` +```bash +open bitchat.xcodeproj +``` - To run on a device there're a few steps to prepare the code: - - Clone the local configs: `cp Configs/Local.xcconfig.example Configs/Local.xcconfig` - - Add your Developer Team ID into the newly created `Configs/Local.xcconfig` - - Bundle ID would be set to `chat.bitchat.` (unless you set to something else) - - Entitlements need to be updated manually (TODO: Automate): - - Search and replace `group.chat.bitchat` with `group.` (e.g. `group.chat.bitchat.ABC123`) +For a signed device build, create your ignored local configuration and replace +the example team ID with your Apple Developer Team ID: + +```bash +cp Configs/Local.xcconfig.example Configs/Local.xcconfig +``` + +`Local.xcconfig.example` derives unique app and App Group identifiers from that +team ID. The entitlement files already reference `$(APP_GROUP_ID)`, so tracked +project or entitlement files do not need to be edited. + +Useful command-line checks from the repository root: + +```bash +# macOS Debug build without signing +xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" \ + -configuration Debug CODE_SIGNING_ALLOWED=NO build + +# Full SwiftPM test suite +swift test + +# iOS simulator tests +xcodebuild -project bitchat.xcodeproj -scheme "bitchat (iOS)" \ + -sdk iphonesimulator \ + -destination 'platform=iOS Simulator,name=iPhone 17' test +``` + +If `iPhone 17` is unavailable, choose an installed simulator from: + +```bash +xcodebuild -showdestinations -project bitchat.xcodeproj -scheme "bitchat (iOS)" +``` ### Option 2: Using `just` - ```bash - brew install just - ``` +```bash +brew install just +just check +just run +``` -Want to try this on macos: `just run` will set it up and run from source. -Run `just clean` afterwards to restore things to original state for mobile app building and development. +`just build` and `just run` use the current `bitchat (macOS)` scheme and keep +Xcode output in the ignored `.DerivedData/` directory. They never patch source, +project, configuration, or entitlement files. + +`just clean` removes only `.DerivedData/` and `.build/`. It does not invoke Git +or restore tracked files, so uncommitted work is preserved. `just test` runs the +SwiftPM suite and `just test-ios` runs the iPhone 17 simulator suite. ## Localization -- Base app resources live under `bitchat/Localization/Base.lproj/`. Add new copy to `Localizable.strings` and plural rules to `Localizable.stringsdict`. -- Share extension strings are separate in `bitchatShareExtension/Localization/Base.lproj/Localizable.strings`. +- App localizations live in `bitchat/Localizable.xcstrings`. +- Share extension strings are separate in `bitchatShareExtension/Localization/Localizable.xcstrings`. - Prefer keys that describe intent (`app_info.features.offline.title`) and reuse existing ones where possible. - Run `xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGNING_ALLOWED=NO build` to compile-check any localization updates. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..c53c92c5 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,45 @@ +# Security policy + +bitchat is a security-focused messenger, and reports about its security are taken seriously. This page says how to report, what counts as a vulnerability here, and what to expect. + +## Reporting a vulnerability + +**Use GitHub's private vulnerability reporting:** [Report a vulnerability](https://github.com/permissionlesstech/bitchat/security/advisories/new) (Security tab → "Report a vulnerability"). + +Please do not open a public issue for anything that could put people at risk before a fix ships. bitchat is used by people in hostile network environments; a public proof-of-concept can be acted on faster than a patch can reach them. + +A useful report says what an attacker can do, against which build (App Store version or commit hash), and how to reproduce it. A failing test or a packet capture is worth more than speculation about impact. + +## What to expect + +This is a volunteer-maintained project. The aim is to acknowledge reports within a week and to move on confirmed vulnerabilities immediately — historically, confirmed protocol and key-handling issues have been fixed within days. You'll be kept in the loop in the advisory thread, and credited in the fix unless you'd rather not be. There is no bug bounty. + +## Supported versions + +Fixes ship to the latest App Store release and `main`. Older releases are not patched; the fix is to update. + +## Scope + +In scope — the properties the app promises: + +- Confidentiality and integrity of private messages and media (Noise sessions over BLE; over Nostr, bitchat's own ephemeral private-envelope format — a proprietary scheme, *not* NIP-17/NIP-44/NIP-59, see `WHITEPAPER.md`) +- Identity: key handling, verification, impersonation, session binding +- The panic wipe actually destroying what it claims to destroy +- Metadata exposure beyond what the documentation already discloses (see `PRIVACY_POLICY.md` and `docs/privacy-assessment.md`) +- Downgrade paths: anything that silently moves traffic from an encrypted path to a plaintext one +- Tor routing: anything that makes traffic bypass Tor while the Tor preference is on +- Supply-chain integrity of the source and its vendored binaries (see `docs/VERIFYING-A-BUILD.md`) + +Out of scope — documented design properties, not vulnerabilities: + +- Public visibility of mesh announces and geohash channels: broadcast content, nicknames, and public keys are public by design +- Bluetooth proximity being observable: anyone in radio range can tell a BLE device is present +- Mesh flooding/relay behavior inherent to a broadcast mesh (rate limits exist; the topology is what it is) +- Behavior of third-party Nostr relays +- Denial of service requiring physical proximity, and battery-drain attacks in general + +If you're unsure whether something is in scope, report it privately anyway — a false alarm costs a few minutes; a real issue reported publicly can cost much more. + +## Verifying what you're running + +If your concern is that the app or source you have has been tampered with, that has its own document: `docs/VERIFYING-A-BUILD.md`. diff --git a/WHITEPAPER.md b/WHITEPAPER.md index 7716850c..75e346fb 100644 --- a/WHITEPAPER.md +++ b/WHITEPAPER.md @@ -18,14 +18,14 @@ bitchat is a decentralized, peer-to-peer messaging application for secure, priva * **Authentication:** peers are identified by cryptographic keys; announcements are signed and verified. * **Resilience:** the network functions in lossy, low-bandwidth, partitioned environments with churning membership. * **Eventual delivery:** a message to an out-of-range peer should still arrive — relayed by the mesh, carried by a moving person, or resting on an internet relay — within a bounded retention window. -* **Ephemerality by default:** no plaintext message content is ever written to disk. Everything the store-and-forward stack persists is either sealed ciphertext or already-public broadcast traffic, and all of it dies with the panic wipe. +* **Ephemerality by default:** conversation timelines live in memory only. Everything the store-and-forward stack persists is either sealed ciphertext or already-public broadcast traffic, and all of it dies with the panic wipe. Media is the exception: accepted images and voice notes are written to disk unsealed, protected by the platform's data-protection class rather than by app-layer encryption, and bounded by a storage quota. ## 2. Architecture Overview Two transports implement a common `Transport` interface and are coordinated by a `MessageRouter`: * **BLE mesh** — every device is simultaneously a GATT central and peripheral, relaying packets in a controlled flood. No infrastructure, pairing, or accounts. -* **Nostr** — private messages to mutual favorites travel as NIP-17 gift-wrapped events over public relays (over Tor where enabled), bridging separate meshes through the internet. +* **Nostr** — private messages to mutual favorites travel in BitChat's app-specific encrypted envelopes over public relays (over Tor where enabled), bridging separate meshes through the internet. The router prefers a live mesh link, falls back to Nostr, and engages the courier system when neither can deliver promptly. @@ -36,13 +36,17 @@ Each device holds two long-term key pairs in the Keychain: * a **Curve25519 static key** for Noise key agreement — its SHA-256 fingerprint is the peer's stable identity, and * an **Ed25519 signing key** for packet signatures. -On the mesh, peers appear under short ephemeral IDs derived per session; favoriting pins the full Noise public key so identity survives across sessions. Mutual favorites also exchange Nostr public keys for the internet path. Optional QR verification binds a nickname to a fingerprint in person. +On the mesh, peers appear under a short 8-byte peer ID. That ID is **not ephemeral**: it is the first 8 bytes of the SHA-256 fingerprint of the device's Noise static key, so it is stable across sessions, reboots, and reinstalls that preserve the keychain, and it changes only when the identity itself is replaced by a panic wipe. Favoriting pins the full Noise public key so identity survives across sessions. Mutual favorites also exchange Nostr public keys for the internet path. Optional QR verification binds a nickname to a fingerprint in person. + +Signed announcements additionally carry the nickname, the Noise static public key, and the Ed25519 signing public key in cleartext (§4.5), so a passive receiver in radio range can link a device across time and place regardless of the peer ID. Unlinkable presence is not a property this protocol currently provides; see §9. ## 4. BLE Mesh Layer ### 4.1 Packet Format -A compact binary header (version, type, TTL, timestamp, flags) is followed by an 8-byte sender ID, an optional 8-byte recipient ID, the payload, and an optional Ed25519 signature. Version 2 packets may carry an explicit source route. Signatures exclude the TTL byte so relays can decrement it without invalidating them. Packets other than fragments are padded toward uniform sizes. +A compact binary header (version, type, TTL, timestamp, flags) is followed by an 8-byte sender ID, an optional 8-byte recipient ID, the payload, and an optional Ed25519 signature. Version 2 packets may carry an explicit source route. Signatures exclude the TTL byte so relays can decrement it without invalidating them. + +Only `noiseEncrypted` and `noiseHandshake` packets are padded, toward 256/512/1024/2048-byte buckets; every other type — public messages, announcements, board posts, group messages, fragments, files, and voice frames — goes out at its natural length. Padding is PKCS#7-style with pad bytes equal to the pad length, and because that length must fit one byte, a frame needing more than 255 bytes to reach its bucket is emitted unpadded. Payload length is therefore observable for most traffic. ### 4.2 Flood Control @@ -78,7 +82,9 @@ Courier envelopes are sealed to the recipient's *static* key with the one-way No ### 5.3 Nostr Path -Private messages to mutual favorites are wrapped per NIP-17/NIP-59: a rumor (kind 14) sealed (kind 13) and gift-wrapped (kind 1059) under a throwaway ephemeral key, so relays learn neither sender nor content. +Private messages to mutual favorites use BitChat's proprietary private-envelope protocol. An unsigned inner message (kind 14) is encrypted and placed in a sender-signed seal (kind 13); that seal is encrypted again inside a public envelope (kind 1059) signed by a one-time key, so relays learn neither the stable sender identity nor the content. Each encrypted content field is `v2:` followed by base64url of a 24-byte nonce, XChaCha20-Poly1305 ciphertext, and its 16-byte tag. Keys come from secp256k1 ECDH and HKDF-SHA256 (the derivation reuses a "nip44-v2" info label but is not the NIP-44 key schedule). + +This format reuses NIP-17/NIP-59 kind numbers but 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 plaintext and stable sender identity remain inside authenticated ciphertext. Public seal and envelope timestamps are randomized by up to ±15 minutes, while the actual message timestamp is encrypted. The protocol does not provide forward secrecy: compromise of the recipient's static Nostr private key can expose stored envelopes addressed to that key. ## 6. Store and Forward @@ -105,7 +111,7 @@ Public broadcast messages are cached (1000 packets) and reconciled between peers ### 6.4 Nostr Mailboxes -Gift-wrapped messages rest on Nostr relays; clients re-subscribe with a 24-hour lookback on reconnect, covering the both-devices-offline case for mutual favorites whenever either side touches the internet. +BitChat private envelopes rest on Nostr relays; clients re-subscribe with a 24-hour lookback on reconnect, covering the both-devices-offline case for mutual favorites whenever either side touches the internet. ### 6.5 Delivery Metrics @@ -122,12 +128,12 @@ Bare local counters (deposits, handovers, sprays, opens, outbox flushes and drop ## 8. Security Considerations -* **Relay nodes** cannot read private traffic; they forward padded, opaque ciphertext. +* **Relay nodes** cannot read private traffic; they forward opaque ciphertext. Padding applies to Noise frames only (§4.1), so other packet types relay at their natural length. * **Couriers** are quota-bounded mailbags. A malicious courier can drop mail (redundant copies and deposit retry mitigate this) but cannot read it, link it across days, or amplify it — copy budgets are capped and every envelope is validated against size and lifetime policy on deposit. * **Flooding abuse** is bounded by TTL clamps, deduplication, per-depositor quotas, connect-rate limits, and announce-rate limiting. * **Replay** of public broadcasts is bounded by the 6-hour acceptance window plus deduplication; private payloads are protected by Noise nonces. -* **Metadata.** BLE proximity is inherently observable; ephemeral IDs and daily-rotating courier tags limit long-term correlation. Nostr traffic can ride Tor. -* **No forward secrecy for sealed mail** (§5.2) is the main cryptographic trade-off of the offline path. +* **Metadata is the weakest part of this design, and the peer ID does not help.** The 8-byte sender ID in every packet header is derived from a never-rotating key (§3), and announcements publish the static keys and nickname in cleartext, so a passive listener can enumerate participants and follow a device between places. Announcements also carry up to ten direct-neighbor IDs (§4.3), which hands a single sniffer the local adjacency graph. Origin packets leave at the default TTL, so hop distance identifies the originator. Daily-rotating courier tags do limit correlation of carried mail, and Nostr traffic can ride Tor. Addressing the radio-layer exposure is future work (§9). +* **No forward secrecy for sealed mail or Nostr private envelopes** (§5.2–5.3) means compromise of a recipient's static key can expose retained ciphertext addressed to that key. ## 9. Future Work @@ -135,6 +141,9 @@ Bare local counters (deposits, handovers, sprays, opens, outbox flushes and drop * Couriered media beyond the 16 KiB text cap. * Probabilistic relay and edge-of-network TTL boosting for very dense and very sparse graphs. * Multi-hop courier routing informed by encounter history. +* **Rotating on-air identity.** Epoch-rotating peer IDs, with static-key disclosure moved inside the encrypted handshake and mutual favorites recognising each other through a tag derived from their shared secret, so presence stops being linkable across sessions (§3, §8). +* **Padding for non-Noise packet types**, and closing the gap where a frame needing more than 255 bytes of padding is emitted unpadded (§4.1). +* Making the neighbor list in announcements optional, or restricted to authenticated links (§4.3). --- diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index a6e3b6bd..e0738afc 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -70,6 +70,7 @@ A6E32D1B2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchatShareExtension" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + Services/SharedContentHandoff.swift, Services/TransportConfig.swift, ); target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */; diff --git a/bitchat/App/AppArchitecture.swift b/bitchat/App/AppArchitecture.swift index 72b43b7d..233544eb 100644 --- a/bitchat/App/AppArchitecture.swift +++ b/bitchat/App/AppArchitecture.swift @@ -2,11 +2,6 @@ import BitFoundation import Combine import Foundation -enum SharedContentKind: String, Sendable, Equatable { - case text - case url -} - enum RuntimeScenePhase: String, Sendable, Equatable { case active case inactive @@ -18,6 +13,8 @@ enum TorLifecycleEvent: String, Sendable, Equatable { case willRestart case didBecomeReady case preferenceChanged + /// Bootstrap ran out its deadline without completing. + case bootstrapDidStall } enum AppEvent: Sendable, Equatable { @@ -25,7 +22,7 @@ enum AppEvent: Sendable, Equatable { case startupCompleted case scenePhaseChanged(RuntimeScenePhase) case openedURL(String) - case sharedContentAccepted(SharedContentKind) + case sharedContentReadyForReview(SharedContentKind) case notificationOpened(peerID: PeerID?) case deepLinkOpened(String) case torLifecycleChanged(TorLifecycleEvent) diff --git a/bitchat/App/AppChromeModel.swift b/bitchat/App/AppChromeModel.swift index 9db9e51e..227536ca 100644 --- a/bitchat/App/AppChromeModel.swift +++ b/bitchat/App/AppChromeModel.swift @@ -20,13 +20,22 @@ final class AppChromeModel: ObservableObject { @Published var showScreenshotPrivacyWarning = false private let chatViewModel: ChatViewModel + private let onPanicWipe: () -> Void private var cancellables = Set() + /// The composer owns capture state above ChatViewModel. ContentView + /// installs this hook so both panic entry points synchronously stop it. + private var prepareForPanic: (@MainActor () -> Void)? /// Bulletin-board coordinator, created on first use of the board sheet. private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService) - init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) { + init( + chatViewModel: ChatViewModel, + privateInboxModel: PrivateInboxModel, + onPanicWipe: @escaping () -> Void = {} + ) { self.chatViewModel = chatViewModel + self.onPanicWipe = onPanicWipe self.nickname = chatViewModel.nickname bind(privateInboxModel: privateInboxModel) @@ -97,7 +106,13 @@ final class AppChromeModel: ObservableObject { showScreenshotPrivacyWarning = true } + func setPanicPreparation(_ preparation: (@MainActor () -> Void)?) { + prepareForPanic = preparation + } + func panicClearAllData() { + prepareForPanic?() + onPanicWipe() chatViewModel.panicClearAllData() } diff --git a/bitchat/App/AppRuntime.swift b/bitchat/App/AppRuntime.swift index 4c1e20a0..b7c20511 100644 --- a/bitchat/App/AppRuntime.swift +++ b/bitchat/App/AppRuntime.swift @@ -27,6 +27,7 @@ final class AppRuntime: ObservableObject { let peerListModel: PeerListModel let appChromeModel: AppChromeModel let boardAlertsModel: BoardAlertsModel + let sharedContentImportModel: SharedContentImportModel private let idBridge: NostrIdentityBridge private var cancellables = Set() @@ -41,7 +42,8 @@ final class AppRuntime: ObservableObject { init( keychain: KeychainManagerProtocol = KeychainManager.makeDefault(), - idBridge: NostrIdentityBridge = NostrIdentityBridge() + idBridge: NostrIdentityBridge = NostrIdentityBridge(), + sharedContentStore: SharedContentStore? = nil ) { self.idBridge = idBridge let conversations = ConversationStore() @@ -84,9 +86,20 @@ final class AppRuntime: ObservableObject { peerIdentityStore: peerIdentityStore, locationPresenceStore: locationPresenceStore ) + let resolvedSharedContentStore: SharedContentStore? + if let sharedContentStore { + resolvedSharedContentStore = sharedContentStore + } else if let sharedDefaults = UserDefaults(suiteName: BitchatApp.groupID) { + resolvedSharedContentStore = SharedContentStore(defaults: sharedDefaults) + } else { + resolvedSharedContentStore = nil + } + let sharedContentImportModel = SharedContentImportModel(store: resolvedSharedContentStore) + self.sharedContentImportModel = sharedContentImportModel self.appChromeModel = AppChromeModel( chatViewModel: self.chatViewModel, - privateInboxModel: self.privateInboxModel + privateInboxModel: self.privateInboxModel, + onPanicWipe: { sharedContentImportModel.discardAll() } ) let chatViewModel = self.chatViewModel self.boardAlertsModel = BoardAlertsModel( @@ -106,13 +119,15 @@ final class AppRuntime: ObservableObject { } ) ) - - GeoRelayDirectory.shared.prefetchIfNeeded() + if chatViewModel.networkActivationAllowed { + GeoRelayDirectory.shared.prefetchIfNeeded() + } bindRuntimeObservers() NotificationDelegate.shared.runtime = self } func start() { + guard chatViewModel.networkActivationAllowed else { return } guard !started else { checkForSharedContent() return @@ -137,11 +152,21 @@ final class AppRuntime: ObservableObject { NetworkActivationService.shared.start() GeohashPresenceService.shared.start() checkForSharedContent() + expireAgedMedia() record(.launched) record(.startupCompleted) } + /// Drops media that has outlived the retention window. Off the main thread + /// and best-effort: the sweep walks the media tree, and nothing at launch + /// depends on its result. + private func expireAgedMedia() { + Task(priority: .utility) { + BLEIncomingFileStore().expireAgedMedia() + } + } + func handleOpenURL(_ url: URL) { record(.openedURL(url.absoluteString)) @@ -151,12 +176,14 @@ final class AppRuntime: ObservableObject { } func handleDidBecomeActiveNotification() { + guard chatViewModel.networkActivationAllowed else { return } chatViewModel.handleDidBecomeActive() checkForSharedContent() } #if os(macOS) func handleMacDidBecomeActiveNotification() { + guard chatViewModel.networkActivationAllowed else { return } record(.scenePhaseChanged(.active)) chatViewModel.handleDidBecomeActive() checkForSharedContent() @@ -175,6 +202,7 @@ final class AppRuntime: ObservableObject { didEnterBackground = true case .active: + guard chatViewModel.networkActivationAllowed else { return } record(.scenePhaseChanged(.active)) chatViewModel.meshService.startServices() TorManager.shared.setAppForeground(true) @@ -222,6 +250,7 @@ final class AppRuntime: ObservableObject { actionIdentifier: String = UNNotificationDefaultActionIdentifier, userInfo: [AnyHashable: Any] ) { + guard chatViewModel.networkActivationAllowed else { return } if actionIdentifier == NotificationService.waveActionID { chatViewModel.sendMeshWave() return @@ -273,6 +302,8 @@ private extension AppRuntime { NotificationCenter.default.publisher(for: .TorWillRestart) .receive(on: DispatchQueue.main) .sink { [weak self] _ in + guard self?.chatViewModel.networkActivationAllowed == true + else { return } self?.record(.torLifecycleChanged(.willRestart)) self?.chatViewModel.handleTorWillRestart() } @@ -281,6 +312,8 @@ private extension AppRuntime { NotificationCenter.default.publisher(for: .TorDidBecomeReady) .receive(on: DispatchQueue.main) .sink { [weak self] _ in + guard self?.chatViewModel.networkActivationAllowed == true + else { return } self?.record(.torLifecycleChanged(.didBecomeReady)) self?.chatViewModel.handleTorDidBecomeReady() } @@ -289,14 +322,28 @@ private extension AppRuntime { NotificationCenter.default.publisher(for: .TorWillStart) .receive(on: DispatchQueue.main) .sink { [weak self] _ in + guard self?.chatViewModel.networkActivationAllowed == true + else { return } self?.record(.torLifecycleChanged(.willStart)) self?.chatViewModel.handleTorWillStart() } .store(in: &cancellables) + NotificationCenter.default.publisher(for: .TorBootstrapDidStall) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard self?.chatViewModel.networkActivationAllowed == true + else { return } + self?.record(.torLifecycleChanged(.bootstrapDidStall)) + self?.chatViewModel.handleTorBootstrapDidStall() + } + .store(in: &cancellables) + NotificationCenter.default.publisher(for: .TorUserPreferenceChanged) .receive(on: DispatchQueue.main) .sink { [weak self] notification in + guard self?.chatViewModel.networkActivationAllowed == true + else { return } self?.record(.torLifecycleChanged(.preferenceChanged)) self?.chatViewModel.handleTorPreferenceChanged(notification) } @@ -313,44 +360,22 @@ private extension AppRuntime { } func checkForSharedContent() { - guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else { return } - let clearSharedContent = { - userDefaults.removeObject(forKey: "sharedContent") - userDefaults.removeObject(forKey: "sharedContentType") - userDefaults.removeObject(forKey: "sharedContentDate") + let previousID = sharedContentImportModel.offer?.id + guard let payload = sharedContentImportModel.refresh( + destination: currentSharedContentDestination + ) else { return } + + if previousID != payload.id { + record(.sharedContentReadyForReview(payload.kind)) } + } - guard let sharedContent = userDefaults.string(forKey: "sharedContent"), - let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else { - // A partial or malformed handoff must not linger in the shared - // app-group container indefinitely. - clearSharedContent() - return - } - - guard Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds else { - clearSharedContent() - return - } - - let contentKind = SharedContentKind(rawValue: userDefaults.string(forKey: "sharedContentType") ?? "") ?? .text - - clearSharedContent() - - switch contentKind { - case .url: - if let data = sharedContent.data(using: .utf8), - let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String], - let url = urlData["url"] { - chatViewModel.sendMessage(url) - } else { - chatViewModel.sendMessage(sharedContent) - } - case .text: - chatViewModel.sendMessage(sharedContent) - } - - record(.sharedContentAccepted(contentKind)) + var currentSharedContentDestination: SharedContentDestination { + SharedContentDestination.resolve( + selectedPrivatePeerID: privateConversationModel.selectedPeerID, + privateDisplayName: privateConversationModel.selectedHeaderState?.displayName, + activeChannel: locationChannelsModel.selectedChannel + ) } func handleNostrRelayConnectionChanged(_ isConnected: Bool) { @@ -359,7 +384,9 @@ private extension AppRuntime { let becameConnected = isConnected && !lastNostrRelayConnectedState lastNostrRelayConnectedState = isConnected - guard started, becameConnected else { return } + guard chatViewModel.networkActivationAllowed, + started, + becameConnected else { return } let isInitialConnection = !didHandleInitialNostrConnection didHandleInitialNostrConnection = true diff --git a/bitchat/App/ConversationStore.swift b/bitchat/App/ConversationStore.swift index 8128cd38..eb174120 100644 --- a/bitchat/App/ConversationStore.swift +++ b/bitchat/App/ConversationStore.swift @@ -39,15 +39,17 @@ final class Conversation: ObservableObject, Identifiable { @Published private(set) var messages: [BitchatMessage] = [] @Published private(set) var isUnread: Bool = false - /// Incrementally-maintained message-ID → index map for O(1) dedup and - /// delivery-status lookup. Kept in sync on every mutation: - /// - tail append: single insert - /// - out-of-order insert: suffix reindex from the insertion point - /// - trim: full rebuild — `removeFirst(k)` is already O(n), so the - /// rebuild does not change the asymptotics, and trim only happens once - /// the cap (1337) is reached. Simple and correct beats the - /// offset-tracking alternative here. + /// Incrementally-maintained message-ID → logical-index map for O(1) + /// dedup and delivery-status lookup. Logical indexes are physical array + /// indexes plus `indexOffset`; trimming from the head advances the offset + /// instead of rewriting every surviving dictionary entry. This matters + /// after the 1337-message cap is reached, when every steady-state tail + /// append evicts one old row. + /// + /// Out-of-order inserts and middle removals still reindex only the + /// affected suffix. Full filtering resets the offset while rebuilding. private var indexByMessageID: [String: Int] = [:] + private var indexOffset = 0 fileprivate init(id: ConversationID, cap: Int) { self.id = id @@ -61,7 +63,7 @@ final class Conversation: ObservableObject, Identifiable { } func message(withID messageID: String) -> BitchatMessage? { - guard let index = indexByMessageID[messageID] else { return nil } + guard let index = physicalIndex(forMessageID: messageID) else { return nil } return messages[index] } @@ -101,7 +103,7 @@ final class Conversation: ObservableObject, Identifiable { reindex(from: index) } else { messages.append(message) - indexByMessageID[message.id] = messages.count - 1 + indexByMessageID[message.id] = indexOffset + messages.count - 1 } return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded()) @@ -111,7 +113,7 @@ final class Conversation: ObservableObject, Identifiable { /// timeline position (in-place updates like media progress reuse the /// original timestamp); a new message goes through ordered insertion. fileprivate func upsert(_ message: BitchatMessage) -> UpsertOutcome { - if let index = indexByMessageID[message.id] { + if let index = physicalIndex(forMessageID: message.id) { messages[index] = message return .updated } @@ -125,7 +127,7 @@ final class Conversation: ObservableObject, Identifiable { /// `.read` is never downgraded to `.delivered` or `.sent`. /// Returns `true` when the status was applied. fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool { - guard let index = indexByMessageID[messageID] else { return false } + guard let index = physicalIndex(forMessageID: messageID) else { return false } let message = messages[index] guard !Self.shouldSkipStatusUpdate(current: message.deliveryStatus, new: status) else { return false } @@ -142,7 +144,7 @@ final class Conversation: ObservableObject, Identifiable { /// observers still need an @Published emission to re-render. @discardableResult fileprivate func republishMessage(withID messageID: String) -> Bool { - guard let index = indexByMessageID[messageID] else { return false } + guard let index = physicalIndex(forMessageID: messageID) else { return false } messages[index] = messages[index] return true } @@ -157,10 +159,14 @@ final class Conversation: ObservableObject, Identifiable { /// Removes a single message by ID. Returns the removed message, or /// `nil` when no message with that ID exists. fileprivate func remove(messageID: String) -> BitchatMessage? { - guard let index = indexByMessageID[messageID] else { return nil } + guard let index = physicalIndex(forMessageID: messageID) else { return nil } let removed = messages.remove(at: index) indexByMessageID.removeValue(forKey: messageID) - reindex(from: index) + if index == 0 { + indexOffset += 1 + } else { + reindex(from: index) + } return removed } @@ -177,6 +183,7 @@ final class Conversation: ObservableObject, Identifiable { for id in removedIDs { indexByMessageID.removeValue(forKey: id) } + indexOffset = 0 reindex(from: 0) return removedIDs } @@ -184,6 +191,7 @@ final class Conversation: ObservableObject, Identifiable { fileprivate func clearMessages() { messages.removeAll() indexByMessageID.removeAll() + indexOffset = 0 } // MARK: Diagnostics @@ -205,9 +213,10 @@ final class Conversation: ObservableObject, Identifiable { let message = messages[position] // Count equality + every message resolving to its own position // proves the index is exactly the inverse map (no stale extras). - if let index = indexByMessageID[message.id] { - if index != position { - violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(index)") + if let logicalIndex = indexByMessageID[message.id] { + let expectedIndex = indexOffset + position + if logicalIndex != expectedIndex { + violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(logicalIndex - indexOffset)") } } else { violations.append("\(label): message \(message.id.prefix(8))… at \(position) missing from index") @@ -269,10 +278,17 @@ final class Conversation: ObservableObject, Identifiable { private func reindex(from start: Int) { for index in start.. Int? { + guard let logicalIndex = indexByMessageID[messageID] else { return nil } + let index = logicalIndex - indexOffset + guard messages.indices.contains(index) else { return nil } + return index + } + /// Trims oldest messages over the cap; returns the trimmed message IDs. private func trimIfNeeded() -> [String] { guard messages.count > cap else { return [] } @@ -282,7 +298,7 @@ final class Conversation: ObservableObject, Identifiable { indexByMessageID.removeValue(forKey: id) } messages.removeFirst(overflow) - reindex(from: 0) + indexOffset += overflow return trimmedIDs } } @@ -426,6 +442,40 @@ final class ConversationStore: ObservableObject { @discardableResult func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool { 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 + ) -> 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 + ) -> Bool { + guard !ids.isEmpty else { return false } var applied = false var skipped: [ConversationID] = [] for id in ids { @@ -844,8 +894,8 @@ extension Conversation { /// (positions 0 and 1 swap their index entries). Requires >= 2 messages. func _testCorruptIndexEntries() { guard messages.count >= 2 else { return } - indexByMessageID[messages[0].id] = 1 - indexByMessageID[messages[1].id] = 0 + indexByMessageID[messages[0].id] = indexOffset + 1 + indexByMessageID[messages[1].id] = indexOffset } /// Drops a message's index entry entirely (count mismatch + missing). @@ -859,8 +909,8 @@ extension Conversation { func _testCorruptOrderingPreservingIndex() { guard messages.count >= 2 else { return } messages.swapAt(0, messages.count - 1) - indexByMessageID[messages[0].id] = 0 - indexByMessageID[messages[messages.count - 1].id] = messages.count - 1 + indexByMessageID[messages[0].id] = indexOffset + indexByMessageID[messages[messages.count - 1].id] = indexOffset + messages.count - 1 } } @@ -900,7 +950,7 @@ extension ConversationStore { extension Conversation { fileprivate func _testAppendBypassingTrim(_ message: BitchatMessage) { messages.append(message) - indexByMessageID[message.id] = messages.count - 1 + indexByMessageID[message.id] = indexOffset + messages.count - 1 } } #endif diff --git a/bitchat/App/ConversationUIModel.swift b/bitchat/App/ConversationUIModel.swift index 683a9dee..91f0afef 100644 --- a/bitchat/App/ConversationUIModel.swift +++ b/bitchat/App/ConversationUIModel.swift @@ -12,6 +12,7 @@ final class ConversationUIModel: ObservableObject { @Published private(set) var currentNickname: String @Published private(set) var isBatchingPublic = false @Published private(set) var canSendMediaInCurrentContext = true + @Published private(set) var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest? /// Who is talking live in the public mesh channel right now (floor /// courtesy: the composer mic tints "busy" while someone holds the floor). @Published private(set) var activeLiveVoiceTalker: String? @@ -153,6 +154,13 @@ final class ConversationUIModel: ObservableObject { chatViewModel.sendVoiceNote(at: url) } + func resolveLegacyPrivateMediaConsent(requestID: UUID, approved: Bool) { + chatViewModel.resolveLegacyPrivateMediaConsent( + requestID: requestID, + approved: approved + ) + } + /// Capture backend for the mic gesture: live PTT when the current DM /// peer can hear it now, classic voice note otherwise. func makeVoiceCaptureSession() -> VoiceCaptureSession { @@ -193,6 +201,10 @@ final class ConversationUIModel: ObservableObject { .receive(on: DispatchQueue.main) .assign(to: &$activeLiveVoiceTalker) + chatViewModel.$legacyPrivateMediaConsentRequest + .receive(on: DispatchQueue.main) + .assign(to: &$legacyPrivateMediaConsentRequest) + conversations.$activeChannel .receive(on: DispatchQueue.main) .sink { [weak self] channel in diff --git a/bitchat/App/NearbyNotesCounter.swift b/bitchat/App/NearbyNotesCounter.swift index ae173bbd..d1863970 100644 --- a/bitchat/App/NearbyNotesCounter.swift +++ b/bitchat/App/NearbyNotesCounter.swift @@ -33,15 +33,25 @@ final class NearbyNotesCounter: ObservableObject { private let locationManager: LocationChannelManager private let managerFactory: @MainActor (String) -> LocationNotesManager private let releaseManager: @MainActor (LocationNotesManager?) -> Void + private let locationNotesEnabled: @MainActor () -> Bool + private let locationNotesSettingsPublisher: AnyPublisher init( locationManager: LocationChannelManager = .shared, managerFactory: @escaping @MainActor (String) -> LocationNotesManager = { LocationNotesPool.shared.acquire($0) }, - releaseManager: @escaping @MainActor (LocationNotesManager?) -> Void = { LocationNotesPool.shared.release($0) } + releaseManager: @escaping @MainActor (LocationNotesManager?) -> Void = { LocationNotesPool.shared.release($0) }, + locationNotesEnabled: @escaping @MainActor () -> Bool = { LocationNotesSettings.enabled }, + locationNotesSettings: AnyPublisher? = nil ) { self.locationManager = locationManager self.managerFactory = managerFactory self.releaseManager = releaseManager + self.locationNotesEnabled = locationNotesEnabled + self.locationNotesSettingsPublisher = locationNotesSettings + ?? NotificationCenter.default + .publisher(for: LocationNotesSettings.didChangeNotification) + .map { _ in () } + .eraseToAnyPublisher() } /// Whether the empty-timeline "check for notes" hint should render. @@ -53,7 +63,7 @@ final class NearbyNotesCounter: ObservableObject { /// passes its own observed permission state so the hint re-renders when /// authorization changes. func offersRevealHint(permissionState: LocationChannelManager.PermissionState) -> Bool { - !revealed && LocationNotesSettings.enabled && permissionState == .authorized + !revealed && locationNotesEnabled() && permissionState == .authorized } /// Marks the one explicit act that lets the counter subscribe. Sticky for @@ -83,8 +93,7 @@ final class NearbyNotesCounter: ObservableObject { .sink { [weak self] _ in self?.retarget() } // The app-info kill switch must take effect immediately, not on the // next location change or remount. - settingCancellable = NotificationCenter.default - .publisher(for: LocationNotesSettings.didChangeNotification) + settingCancellable = locationNotesSettingsPublisher .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.retarget() } retarget() @@ -105,7 +114,7 @@ final class NearbyNotesCounter: ObservableObject { private func retarget() { guard activeHolders > 0, revealed, - LocationNotesSettings.enabled, + locationNotesEnabled(), locationManager.permissionState == .authorized, let geohash = locationManager.availableChannels .first(where: { $0.level == .building })?.geohash diff --git a/bitchat/App/PrivacyScreen.swift b/bitchat/App/PrivacyScreen.swift new file mode 100644 index 00000000..7e422057 --- /dev/null +++ b/bitchat/App/PrivacyScreen.swift @@ -0,0 +1,100 @@ +// +// PrivacyScreen.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +#if os(iOS) +import UIKit + +/// Covers the window while the app is not frontmost, so the snapshot iOS takes +/// for the app switcher shows a placeholder instead of the open conversation. +/// +/// The cover is added on `willResignActive` and removed on `didBecomeActive`. +/// Both are deliberately UIKit notifications rather than SwiftUI's `scenePhase`: +/// the snapshot is captured shortly after `willResignActive`, and adding an +/// opaque subview to the window synchronously in that callback is the only way +/// to guarantee it is in the render tree before the capture. A SwiftUI overlay +/// driven by state may not have been laid out yet. +/// +/// Panic wipe separately deletes any snapshots already on disk; this keeps new +/// ones from containing anything worth deleting. +final class PrivacyScreen { + static let shared = PrivacyScreen() + + private var cover: UIView? + private var observers: [NSObjectProtocol] = [] + + private init() {} + + /// Idempotent: repeated calls do not stack observers. + /// + /// `queue: nil` is required, not incidental. Passing an `OperationQueue` + /// would enqueue the handler to run in a later runloop turn, which the + /// snapshot can beat; with no queue the block runs synchronously on the + /// thread that posted the notification — the main thread, for UIApplication + /// lifecycle notifications. + func install() { + guard observers.isEmpty else { return } + let center = NotificationCenter.default + observers = [ + center.addObserver( + forName: UIApplication.willResignActiveNotification, + object: nil, + queue: nil + ) { _ in + PrivacyScreen.shared.show() + }, + center.addObserver( + forName: UIApplication.didBecomeActiveNotification, + object: nil, + queue: nil + ) { _ in + PrivacyScreen.shared.hide() + } + ] + } + + private func show() { + guard cover == nil, let window = Self.activeWindow() else { return } + + // Opaque rather than a blur: blurred large text can stay partly + // legible, and the snapshot is stored on disk. + let view = UIView(frame: window.bounds) + view.backgroundColor = .systemBackground + view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + + let label = UILabel() + label.text = "bitchat" + label.font = .monospacedSystemFont(ofSize: 22, weight: .medium) + label.textColor = .secondaryLabel + label.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(label) + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: view.centerXAnchor), + label.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + + window.addSubview(view) + cover = view + } + + private func hide() { + cover?.removeFromSuperview() + cover = nil + } + + private static func activeWindow() -> UIWindow? { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .first { $0.isKeyWindow } ?? + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .first + } +} +#endif diff --git a/bitchat/App/PrivateConversationModels.swift b/bitchat/App/PrivateConversationModels.swift index d9920646..57c93afd 100644 --- a/bitchat/App/PrivateConversationModels.swift +++ b/bitchat/App/PrivateConversationModels.swift @@ -265,10 +265,10 @@ final class PrivateConversationModel: ObservableObject { let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID) let peer = chatViewModel.getPeer(byID: headerPeerID) let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer) - // Geo DMs are always routed over Nostr (NIP-17); their nostr_ keys - // never resolve to a reachable mesh peer, so resolveAvailability would - // report .offline. Report .nostrAvailable so the header shows the - // globe instead of a misleading "offline" tag. + // Geo DMs are always routed through BitChat private envelopes over + // Nostr; their nostr_ keys never resolve to a reachable mesh peer, so + // resolveAvailability would report .offline. Report .nostrAvailable + // so the header shows the globe instead of a misleading "offline" tag. let availability = conversationPeerID.isGeoDM ? .nostrAvailable : resolveAvailability(for: headerPeerID, peer: peer) diff --git a/bitchat/App/SharedContentImportModel.swift b/bitchat/App/SharedContentImportModel.swift new file mode 100644 index 00000000..68e20aa1 --- /dev/null +++ b/bitchat/App/SharedContentImportModel.swift @@ -0,0 +1,119 @@ +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 + } +} diff --git a/bitchat/BitchatApp.swift b/bitchat/BitchatApp.swift index c1a39fff..cf2e1bd1 100644 --- a/bitchat/BitchatApp.swift +++ b/bitchat/BitchatApp.swift @@ -41,6 +41,7 @@ struct BitchatApp: App { .environmentObject(runtime.peerListModel) .environmentObject(runtime.appChromeModel) .environmentObject(runtime.boardAlertsModel) + .environmentObject(runtime.sharedContentImportModel) .onAppear { appDelegate.runtime = runtime runtime.start() @@ -73,7 +74,10 @@ final class AppDelegate: NSObject, UIApplicationDelegate { weak var runtime: AppRuntime? func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { - true + // Installed before the first resign-active so the app-switcher snapshot + // never captures an open conversation. + PrivacyScreen.shared.install() + return true } func applicationWillTerminate(_ application: UIApplication) { diff --git a/bitchat/Features/voice/VoiceCaptureSession.swift b/bitchat/Features/voice/VoiceCaptureSession.swift index d2f4f8c3..b49c3beb 100644 --- a/bitchat/Features/voice/VoiceCaptureSession.swift +++ b/bitchat/Features/voice/VoiceCaptureSession.swift @@ -26,6 +26,8 @@ protocol VoiceCaptureSession: AnyObject { /// nothing valid was captured. func finish() async -> URL? func cancel() async + /// Stops capture and suppresses every later send before returning. + func panicCancelSynchronously() } /// The classic record-then-send backend, wrapping the shared `VoiceRecorder`. @@ -55,6 +57,10 @@ final class VoiceNoteCaptureSession: VoiceCaptureSession { func cancel() async { await recorder.cancelRecording(owner: owner) } + + func panicCancelSynchronously() { + recorder.panicCancelSynchronously(owner: owner) + } } /// Testable surface of the live capture engine. Production uses @@ -216,6 +222,13 @@ final class PTTLiveVoiceSession: VoiceCaptureSession { } } + func panicCancelSynchronously() { + // Do not emit a canceled packet: it would itself be pre-panic + // conversation data racing the emergency transport reset. + completed = true + capture.cancel() + } + private func sendControlPacket(_ kind: VoiceBurstPacket.Kind) { guard let packet = VoiceBurstPacket(burstID: burstID, seq: stream.packetizer.nextSeq, kind: kind) else { return } sendPacket(packet.encode()) diff --git a/bitchat/Features/voice/VoiceRecorder.swift b/bitchat/Features/voice/VoiceRecorder.swift index eb1b6af2..80412856 100644 --- a/bitchat/Features/voice/VoiceRecorder.swift +++ b/bitchat/Features/voice/VoiceRecorder.swift @@ -246,6 +246,21 @@ actor VoiceRecorder { currentURL = nil } + /// Panic is a synchronous security boundary: the caller must know the + /// microphone, audio-session lease, and partial file are gone before it + /// rotates identities or deletes the media tree. VoiceRecorder is an + /// independent actor and this cleanup path never hops to MainActor, so a + /// short semaphore join is safe even when invoked by the UI actor. + nonisolated + func panicCancelSynchronously(owner: RecordingOwner) { + let finished = DispatchSemaphore(value: 0) + Task { + await cancelRecording(owner: owner) + finished.signal() + } + finished.wait() + } + /// The audio session was interrupted (call, Siri) or reconfigured: stop /// the recorder but keep `recorder`/`currentURL` so the caller's pending /// `stopRecording()` still returns the partial note. diff --git a/bitchat/Identity/IdentityModels.swift b/bitchat/Identity/IdentityModels.swift index 0d8dc475..8a72fcba 100644 --- a/bitchat/Identity/IdentityModels.swift +++ b/bitchat/Identity/IdentityModels.swift @@ -14,18 +14,22 @@ /// /// ## Overview /// BitChat's identity system separates concerns across three distinct layers: -/// 1. **Ephemeral Identity**: Short-lived, rotatable peer IDs for privacy +/// 1. **Network Identity**: the 8-byte peer ID seen on air /// 2. **Cryptographic Identity**: Long-term Noise static keys for security -/// 3. **Social Identity**: User-assigned names and trust relationships +/// 3. **Social Identity**: assigned names and trust relationships /// -/// This separation allows users to maintain stable cryptographic identities -/// while frequently rotating their network identifiers for privacy. +/// The layers are separate concerns, but they are not independent: the network +/// identity is *derived* from the cryptographic one, so it does not provide +/// unlinkability. Rotating peer IDs would be a change to this model, not a +/// description of it — see the note below. /// /// ## Three-Layer Architecture /// -/// ### Layer 1: Ephemeral Identity -/// - Random 8-byte peer IDs that rotate periodically -/// - Provides network-level privacy and prevents tracking +/// ### Layer 1: Network Identity +/// - 8-byte peer ID = first 8 bytes of the Noise static key fingerprint +/// - **Not ephemeral and not rotating.** It is stable across sessions and +/// reboots, and changes only when the underlying identity is replaced by a +/// panic wipe. A passive observer can use it to track a device. /// - Changes don't affect cryptographic relationships /// - Includes handshake state tracking /// @@ -33,7 +37,7 @@ /// - Based on Noise Protocol static key pairs /// - Fingerprint derived from SHA256 of public key /// - Enables end-to-end encryption and authentication -/// - Persists across peer ID rotations +/// - The root of the peer ID above, and never rotated on a schedule /// /// ### Layer 3: Social Identity /// - User-assigned names (petnames) for contacts @@ -44,10 +48,13 @@ /// ## Privacy Design /// The model is designed with privacy-first principles: /// - No mandatory persistent storage -/// - Optional identity caching with user consent -/// - Ephemeral IDs prevent long-term tracking +/// - Optional identity caching with explicit consent /// - Social mappings stored locally only /// +/// It does **not** currently prevent long-term tracking by a passive radio +/// observer: the peer ID is stable (Layer 1) and signed announcements carry the +/// static keys and nickname in cleartext. +/// /// ## Trust Model /// Four levels of trust: /// 1. **Unknown**: New or unverified peers @@ -56,17 +63,17 @@ /// 4. **Verified**: Cryptographic verification completed /// /// ## Identity Resolution -/// When a peer rotates their ephemeral ID: +/// When a peer's ID changes (a panic wipe on their side, or a future rotation): /// 1. Cryptographic handshake reveals their fingerprint /// 2. System looks up social identity by fingerprint -/// 3. UI seamlessly maintains user relationships +/// 3. UI seamlessly maintains existing relationships /// 4. Historical messages remain properly attributed /// /// ## Conflict Resolution /// Handles edge cases like: /// - Multiple peers claiming same nickname /// - Nickname changes and conflicts -/// - Identity rotation during active chats +/// - Identity replacement during active chats /// - Network partitions and rejoins /// /// ## Usage Example @@ -85,8 +92,12 @@ import BitFoundation // MARK: - Three-Layer Identity Model -/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy. -/// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships. +/// Represents the network layer of identity — the peer ID seen on air, plus the +/// handshake state tracked against it. +/// +/// Named "ephemeral" for historical reasons; the peer ID is in fact stable, +/// being derived from the Noise static key fingerprint. It does not rotate and +/// does not prevent tracking. struct EphemeralIdentity { var handshakeState: HandshakeState } @@ -99,8 +110,9 @@ enum HandshakeState { } /// Represents the cryptographic layer of identity - the stable Noise Protocol static key pair. -/// This identity persists across ephemeral ID rotations and enables secure communication. -/// The fingerprint serves as the permanent identifier for a peer's cryptographic identity. +/// This identity outlives any change to a peer's network ID and enables secure communication. +/// The fingerprint serves as the permanent identifier for a peer's cryptographic identity, and +/// its first 8 bytes are the peer ID broadcast on the mesh. struct CryptographicIdentity: Codable { let fingerprint: String // SHA256 of public key let publicKey: Data // Noise static public key @@ -161,24 +173,24 @@ struct VouchRecord: Codable, Equatable { struct IdentityCache: Codable { // Fingerprint -> Social mapping var socialIdentities: [String: SocialIdentity] = [:] - + // Nickname -> [Fingerprints] reverse index // Multiple fingerprints can claim same nickname var nicknameIndex: [String: Set] = [:] - + // Verified fingerprints (cryptographic proof) var verifiedFingerprints: Set = [] - + // Last interaction timestamps (privacy: optional) - var lastInteractions: [String: Date] = [:] - + var lastInteractions: [String: Date] = [:] + // Blocked Nostr pubkeys (lowercased hex) for geohash chats var blockedNostrPubkeys: Set = [] // Vouching (transitive verification). All three fields are Optional so - // caches persisted before this feature decode cleanly — the synthesized - // decoder uses decodeIfPresent for optionals, and a missing key must not - // trip the "unreadable cache" recovery path that discards everything. + // caches persisted before this feature decode cleanly — decodeIfPresent + // is used below, and a missing key must not trip the "unreadable cache" + // recovery path that discards everything. // Vouchee fingerprint -> accepted vouches (capped per vouchee) var vouchesByVouchee: [String: [VouchRecord]]? = nil @@ -189,6 +201,50 @@ struct IdentityCache: Codable { // Fingerprint -> when we verified it (orders outgoing vouch batches; // entries verified before this field exists sort as oldest) var verifiedAt: [String: Date]? = nil + + // Stable Noise fingerprints that proved encrypted private-media support + // inside an authenticated Noise session. Optional for decoding caches + // written before this migration. Entries are monotonic until a panic wipe + // so an old/replayed announce cannot silently downgrade a peer. + var privateMediaCapableFingerprints: Set? = nil + + // Noise-fingerprint -> Ed25519 announcement key, learned only from the + // authenticated peer-state payload. This prevents a self-signed announce + // containing a copied public Noise key from replacing a previously bound + // public-message signing identity. Optional for old cache compatibility. + var authenticatedSigningKeysByFingerprint: [String: Data]? = nil + + // Fingerprint -> Cryptographic identity (noise + pinned signing key). + // Persisting the signing-key pin is security-critical: it must survive + // app restarts so an attacker cannot replay a known peer's + // noiseKey/peerID with their own signing key and be treated as first + // contact (TOFU downgrade). + var cryptographicIdentities: [String: CryptographicIdentity] = [:] + + // Schema version for future migrations + var version: Int = 1 + + init() {} + + // Custom decoding so caches written by older builds (missing newer keys + // such as `cryptographicIdentities` or the vouching fields) still load + // instead of being discarded. Every field uses decodeIfPresent so a + // missing key falls back to its default rather than throwing. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + socialIdentities = try container.decodeIfPresent([String: SocialIdentity].self, forKey: .socialIdentities) ?? [:] + nicknameIndex = try container.decodeIfPresent([String: Set].self, forKey: .nicknameIndex) ?? [:] + verifiedFingerprints = try container.decodeIfPresent(Set.self, forKey: .verifiedFingerprints) ?? [] + lastInteractions = try container.decodeIfPresent([String: Date].self, forKey: .lastInteractions) ?? [:] + blockedNostrPubkeys = try container.decodeIfPresent(Set.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.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 + } } // diff --git a/bitchat/Identity/SecureIdentityStateManager.swift b/bitchat/Identity/SecureIdentityStateManager.swift index 7c68a01b..966f210a 100644 --- a/bitchat/Identity/SecureIdentityStateManager.swift +++ b/bitchat/Identity/SecureIdentityStateManager.swift @@ -140,6 +140,14 @@ protocol SecureIdentityStateManagerProtocol { func markVouchBatchSent(to fingerprint: String, at date: Date) func signingPublicKey(forFingerprint fingerprint: String) -> Data? func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] + + // MARK: Noise-authenticated announcement identity + func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String) + func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data? + + // MARK: Private-media downgrade protection + func markPrivateMediaCapable(fingerprint: String) + func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool } /// Singleton manager for secure identity state persistence and retrieval. @@ -152,18 +160,30 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { // In-memory state private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:] - private var cryptographicIdentities: [String: CryptographicIdentity] = [:] + // Cryptographic identities (including pinned signing keys) live inside + // `cache` so they persist across app restarts; see IdentityCache. private var cache: IdentityCache = IdentityCache() // Thread safety private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent) + private let queueSpecificKey = DispatchSpecificKey() // Pending-save coalescing flag. Reads/writes are serialized on `queue`. - // Persistence is done with a fire-and-forget `queue.async(.barrier)` rather - // than a retained DispatchSourceTimer: a lingering, never-cancelled timer - // keeps the dispatch machinery alive and prevents the unit-test process from - // exiting. (The original code used Timer.scheduledTimer on a GCD queue with - // no run loop, so saves never actually fired.) + // + // Persistence is SYNCHRONOUS: every mutating API runs its mutate + encrypt + // + keychain write inside `queue.sync(flags: .barrier)`, so when the call + // returns the write is already complete and NOTHING is left scheduled on + // the queue. This is deliberate — a retained DispatchSourceTimer (the + // original design) kept the dispatch machinery alive and prevented the + // unit-test process from exiting, and fire-and-forget `queue.async(.barrier)` + // (a later design) left a backlog of instrumented barrier saves still + // draining when LLVM's `--enable-code-coverage` `atexit` handler dumped + // `.profraw`, deadlocking the process at teardown on the constrained CI + // runner. Synchronous persistence has zero outstanding dispatch at exit, so + // neither failure mode is possible. `pendingSave` is now effectively always + // false after any mutation (saveIdentityCache persists inline and clears + // it); it remains only as a belt-and-suspenders flag read by `forceSave` + // and `deinit`. private var pendingSave = false // Encryption key @@ -214,6 +234,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { self.encryptionKey = loadedKey self.encryptionKeyIsEphemeral = keyIsEphemeral + queue.setSpecific(key: queueSpecificKey, value: 1) // Only read the persisted cache when we hold the real key; with an // ephemeral key the decrypt would fail and discard the real cache. @@ -223,7 +244,22 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { } deinit { - forceSave() + // Do NOT dispatch onto `queue` here. `deinit` can run on any thread + // (including one draining `queue`), and the object is being + // deallocated: a `queue.sync` risks a re-entrant same-queue wait + // (deadlock) and a `queue.async` schedules work that resurrects `self` + // and may not drain before process exit. + // + // A flush here is redundant anyway: every mutating API already + // persists inline within its own barrier, so the keychain is already + // up to date. As a queue-free best-effort belt-and-suspenders, only + // flush if something is still pending. This is a direct read of + // in-hand state — safe because a deallocating object has no other + // live references, so nothing can be mutating `cache` concurrently. + if pendingSave { + pendingSave = false + persist(snapshot: cache) + } } // MARK: - Secure Loading/Saving @@ -248,21 +284,27 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { } } - /// Persists the cache. Always invoked on `queue` under a barrier (its callers - /// run inside `queue.async(.barrier)`), so it simply marks the cache dirty - /// and persists it on the same serialized context — no timer, nothing left - /// scheduled to keep the process alive. + /// Persists the cache. Always invoked on `queue` under a barrier (its + /// callers run inside `queue.sync(flags: .barrier)`), so `cache` is read + /// while serialized. The encode + keychain write are done here (already on + /// the exclusive barrier context), synchronously, so no separate hop is + /// scheduled and nothing is left to keep the process alive. private func saveIdentityCache() { pendingSave = true - performSave() + // On the barrier context already: snapshot is trivially consistent. + persist(snapshot: cache) + pendingSave = false } - /// Writes the cache to the keychain. Must run on `queue` with exclusive - /// (barrier) access. - private func performSave() { - guard pendingSave else { return } - pendingSave = false - + /// Encodes, seals, and writes a *snapshot* of the cache to the keychain. + /// + /// Takes the cache by value so callers can capture a consistent snapshot + /// under `queue` and then encode without holding it. Reading `cache` + /// concurrently with a barrier writer would be a data race on the + /// dictionary storage, which — because `JSONEncoder` walks that storage — + /// can spin forever (observed as a CI test-suite hang), so the snapshot + /// must be taken on `queue`, never off it. + private func persist(snapshot: IdentityCache) { // Never persist under an ephemeral key — it would overwrite the real // cache with data the next launch cannot decrypt. guard !encryptionKeyIsEphemeral else { @@ -271,7 +313,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { } do { - let data = try JSONEncoder().encode(cache) + let data = try JSONEncoder().encode(snapshot) let sealedBox = try AES.GCM.seal(data, using: encryptionKey) let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey) if saved { @@ -282,14 +324,26 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { } } - // Force immediate save (for app termination / lifecycle events). Mutations - // already persist synchronously via saveIdentityCache, so this is normally a - // no-op (performSave early-returns when nothing is pending). Runs directly on - // the caller's thread — deliberately NOT a `queue.sync(barrier)`, which is - // reachable from `deinit` and from async tests on the swift-concurrency - // cooperative pool where a blocking barrier-sync can starve/deadlock it. + // Force a flush (for app-termination / lifecycle events — NOT from + // `deinit`, which persists inline; see the deinit note). Every mutating + // API already persists inline inside its own barrier via + // `saveIdentityCache`, so by the time this is called the keychain is + // already up to date and this is normally a no-op; it exists as a + // belt-and-suspenders flush of any `pendingSave` left set. + // + // Runs synchronously inside a `queue.sync(flags: .barrier)`: the barrier + // makes the `cache` read race-free (a plain off-queue read races in-flight + // barrier writers — JSONEncoder walking a concurrently-mutated dictionary + // can spin forever, which surfaced as a CI hang), and being synchronous it + // leaves nothing scheduled to keep the process alive at teardown. Safe + // against re-entrant deadlock because this is never invoked from `deinit` + // (the only path that can run *on* `queue`). func forceSave() { - performSave() + queue.sync(flags: .barrier) { + guard pendingSave else { return } + pendingSave = false + persist(snapshot: cache) + } } // MARK: - Social Identity Management @@ -303,15 +357,33 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { // MARK: - Cryptographic Identities /// Insert or update a cryptographic identity and optionally persist its signing key and claimed nickname. + /// + /// TOFU signing-key pinning: once a signing key has been persisted for a + /// fingerprint, an update carrying a *different* signing key is refused in + /// full (including the claimed-nickname update) and security-logged. This + /// mirrors `BLEPeerRegistry.upsertVerifiedAnnounce` — without it, an + /// attacker replaying a victim's noiseKey/peerID with their own signing + /// key could overwrite the victim's persisted identity while the victim is + /// offline or after an app restart. The refusal is permanent: there is + /// currently no targeted in-app way to reset the pin (`setVerified` does + /// not touch it). Recovering from a legitimate signing re-key requires the + /// peer to establish a new noise identity (new peerID) or the local user + /// to wipe all identity data (`clearAllIdentityData`, e.g. panic wipe). /// - Parameters: /// - fingerprint: SHA-256 hex of the Noise static public key /// - noisePublicKey: Noise static public key data /// - signingPublicKey: Optional Ed25519 signing public key for authenticating public messages /// - claimedNickname: Optional latest claimed nickname to persist into social identity func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String? = nil) { - queue.async(flags: .barrier) { + queue.sync(flags: .barrier) { let now = Date() - if var existing = self.cryptographicIdentities[fingerprint] { + if var existing = self.cache.cryptographicIdentities[fingerprint] { + if let pinnedSigningKey = existing.signingPublicKey, + let announcedSigningKey = signingPublicKey, + pinnedSigningKey != announcedSigningKey { + SecureLogger.warning("🚨 Refusing to replace pinned signing key for \(fingerprint.prefix(8))… (possible impersonation attempt)", category: .security) + return + } // Update keys if changed if existing.publicKey != noisePublicKey { existing = CryptographicIdentity( @@ -320,11 +392,11 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { signingPublicKey: signingPublicKey ?? existing.signingPublicKey, firstSeen: existing.firstSeen ) - self.cryptographicIdentities[fingerprint] = existing + self.cache.cryptographicIdentities[fingerprint] = existing } else { // Update signing key existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey - self.cryptographicIdentities[fingerprint] = existing + self.cache.cryptographicIdentities[fingerprint] = existing } // Persist updated state (already assigned in branches above) } else { @@ -335,7 +407,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { signingPublicKey: signingPublicKey, firstSeen: now ) - self.cryptographicIdentities[fingerprint] = entry + self.cache.cryptographicIdentities[fingerprint] = entry } // Optionally persist claimed nickname into social identity @@ -367,12 +439,72 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { queue.sync { // Defensive: ensure hex and correct length guard peerID.isShort else { return [] } - return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) } + return cache.cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) } + } + } + + // MARK: - Private-media downgrade protection + + func markPrivateMediaCapable(fingerprint: String) { + guard !fingerprint.isEmpty else { return } + let insertAndPersist = { + var pinned = self.cache.privateMediaCapableFingerprints ?? [] + guard pinned.insert(fingerprint).inserted else { return } + self.cache.privateMediaCapableFingerprints = pinned + self.saveIdentityCache() + } + // Downgrade decisions can run immediately after an authenticated + // announce. Make the pin visible before returning; merely enqueueing a + // barrier leaves a cross-queue window where a replay can look legacy. + // The queue-specific fast path prevents self-deadlock if a future + // identity-state mutation records the capability from inside `queue`. + if DispatchQueue.getSpecific(key: queueSpecificKey) != nil { + insertAndPersist() + } else { + queue.sync(flags: .barrier, execute: insertAndPersist) + } + } + + func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool { + guard !fingerprint.isEmpty else { return false } + return queue.sync { + cache.privateMediaCapableFingerprints?.contains(fingerprint) == true + } + } + + // MARK: - Noise-authenticated announcement identity + + func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String) { + guard signingPublicKey.count == AuthenticatedPeerStatePacket.signingPublicKeyLength, + !fingerprint.isEmpty else { return } + let bindAndPersist = { + var bindings = self.cache.authenticatedSigningKeysByFingerprint ?? [:] + let bindingChanged = bindings[fingerprint] != signingPublicKey + bindings[fingerprint] = signingPublicKey + self.cache.authenticatedSigningKeysByFingerprint = bindings + if var cryptoIdentity = self.cache.cryptographicIdentities[fingerprint] { + cryptoIdentity.signingPublicKey = signingPublicKey + self.cache.cryptographicIdentities[fingerprint] = cryptoIdentity + } + guard bindingChanged else { return } + self.saveIdentityCache() + } + if DispatchQueue.getSpecific(key: queueSpecificKey) != nil { + bindAndPersist() + } else { + queue.sync(flags: .barrier, execute: bindAndPersist) + } + } + + func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data? { + guard !fingerprint.isEmpty else { return nil } + return queue.sync { + cache.authenticatedSigningKeysByFingerprint?[fingerprint] } } func updateSocialIdentity(_ identity: SocialIdentity) { - queue.async(flags: .barrier) { + queue.sync(flags: .barrier) { let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname self.cache.socialIdentities[identity.fingerprint] = identity @@ -408,7 +540,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { } func setFavorite(_ fingerprint: String, isFavorite: Bool) { - queue.async(flags: .barrier) { + queue.sync(flags: .barrier) { if var identity = self.cache.socialIdentities[fingerprint] { identity.isFavorite = isFavorite self.cache.socialIdentities[fingerprint] = identity @@ -446,7 +578,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { func setBlocked(_ fingerprint: String, isBlocked: Bool) { SecureLogger.info("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: .security) - queue.async(flags: .barrier) { + queue.sync(flags: .barrier) { if var identity = self.cache.socialIdentities[fingerprint] { identity.isBlocked = isBlocked if isBlocked { @@ -480,7 +612,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) { let key = pubkeyHexLowercased.lowercased() - queue.async(flags: .barrier) { + queue.sync(flags: .barrier) { if isBlocked { self.cache.blockedNostrPubkeys.insert(key) } else { @@ -503,7 +635,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { } func updateHandshakeState(peerID: PeerID, state: HandshakeState) { - queue.async(flags: .barrier) { + queue.sync(flags: .barrier) { self.ephemeralSessions[peerID]?.handshakeState = state // If handshake completed, update last interaction @@ -519,11 +651,10 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { func clearAllIdentityData() { SecureLogger.warning("Clearing all identity data", category: .security) - queue.async(flags: .barrier) { + queue.sync(flags: .barrier) { self.cache = IdentityCache() self.ephemeralSessions.removeAll() - self.cryptographicIdentities.removeAll() - + // Delete from keychain let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey) SecureLogger.logKeyOperation(.delete, keyType: "identity cache", success: deleted) @@ -531,7 +662,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { } func removeEphemeralSession(peerID: PeerID) { - queue.async(flags: .barrier) { + queue.sync(flags: .barrier) { self.ephemeralSessions.removeValue(forKey: peerID) } } @@ -541,7 +672,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { func setVerified(fingerprint: String, verified: Bool) { SecureLogger.info("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: .security) - queue.async(flags: .barrier) { + queue.sync(flags: .barrier) { if verified { self.cache.verifiedFingerprints.insert(fingerprint) var verifiedAt = self.cache.verifiedAt ?? [:] @@ -709,7 +840,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { /// The peer's announce-bound Ed25519 signing key, if seen this session. func signingPublicKey(forFingerprint fingerprint: String) -> Data? { - queue.sync { cryptographicIdentities[fingerprint]?.signingPublicKey } + queue.sync { cache.cryptographicIdentities[fingerprint]?.signingPublicKey } } /// Verified fingerprints ordered most recently verified first (entries diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index 950e472c..d4298398 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -1,6 +1,2088 @@ { "sourceLanguage" : "en", "strings" : { + "app_info.settings.relays.add" : { + "comment" : "Button that adds the typed relay address to the list", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إضافة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "যোগ করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "hinzufügen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "add" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "añadir" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "افزودن" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "idagdag" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ajouter" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הוסף" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "जोड़ें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "tambah" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "aggiungi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "追加" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "추가" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "tambah" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "थप" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "toevoegen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "dodaj" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "adicionar" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "adicionar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "добавить" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "lägg till" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "சேர்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เพิ่ม" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ekle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "додати" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "شامل کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "thêm" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "加入" + } + } + } + }, + "app_info.settings.relays.built_in" : { + "comment" : "Label marking a relay as one of the built-in relays, which cannot be removed", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "مدمج" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "বিল্ট-ইন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "eingebaut" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "built in" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "integrado" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "داخلی" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "built in" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "intégré" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מובנה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "अंतर्निर्मित" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "bawaan" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "integrato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "組み込み" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기본 제공" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "terbina dalam" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "पूर्वनिर्मित" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "ingebouwd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wbudowany" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "integrado" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "integrado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "встроенное" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "inbyggt" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "உள்ளமைந்தது" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "มาพร้อมแอป" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "yerleşik" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "вбудований" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "بلٹ اِن" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "tích hợp" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "内置" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "內建" + } + } + } + }, + "app_info.settings.relays.error.duplicate" : { + "comment" : "Error shown when the typed relay address is already in the list", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "هذا المرحّل موجود في القائمة بالفعل." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এই রিলে আগেই তালিকায় আছে।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "dieses relay steht schon in der liste." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "that relay is already in the list." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "ese relay ya está en la lista." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "این رله از قبل در فهرست است." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "nasa listahan na ang relay na iyon." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ce relais est déjà dans la liste." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הממסר הזה כבר ברשימה." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "वह रिले पहले से सूची में है।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "relay itu sudah ada di daftar." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "quel relay è già nell'elenco." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "そのリレーはすでにリストにあります。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "그 릴레이는 이미 목록에 있습니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "relay itu sudah ada dalam senarai." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "त्यो रिले पहिले नै सूचीमा छ।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "die relay staat al in de lijst." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "ten relay już jest na liście." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "esse relé já está na lista." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "esse relay já está na lista." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "это реле уже в списке." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "det reläet finns redan i listan." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அந்த ரிலே ஏற்கெனவே பட்டியலில் உள்ளது." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "รีเลย์นี้อยู่ในรายการแล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bu röle listede zaten var." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "цей релей уже в списку." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "یہ ریلے پہلے ہی فہرست میں ہے۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "relay đó đã có trong danh sách." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "该中继已在列表中。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "該中繼已在清單中。" + } + } + } + }, + "app_info.settings.relays.error.limit" : { + "comment" : "Error shown when the relay list is already at its maximum size; %d is that maximum", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يمكنك إضافة %d مرحّلات كحد أقصى." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "আপনি সর্বোচ্চ %d টি রিলে যোগ করতে পারেন।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "du kannst bis zu %d relays hinzufügen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you can add up to %d relays." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "puedes añadir hasta %d relays." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "می‌توانید تا %d رله اضافه کنید." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "hanggang %d relay lang ang maaari mong idagdag." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "tu peux ajouter jusqu'à %d relais." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אפשר להוסיף עד %d ממסרים." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "आप %d रिले तक जोड़ सकते हैं।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "kamu bisa menambahkan hingga %d relay." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "puoi aggiungere fino a %d relay." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "リレーは最大%d件まで追加できます。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "릴레이는 최대 %d개까지 추가할 수 있습니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "anda boleh menambah sehingga %d relay." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "तिमी %d रिलेसम्म थप्न सक्छौ।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "je kunt tot %d relays toevoegen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "możesz dodać maksymalnie %d relay." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "podes adicionar até %d relés." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "você pode adicionar até %d relays." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "можно добавить до %d реле." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "du kan lägga till upp till %d reläer." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d ரிலேகள் வரை சேர்க்கலாம்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เพิ่มรีเลย์ได้สูงสุด %d รายการ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "en fazla %d röle ekleyebilirsiniz." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "можна додати до %d релеїв." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "آپ %d ریلے تک شامل کر سکتے ہیں۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "bạn có thể thêm tối đa %d relay." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最多可以添加 %d 个中继。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "最多可以加入 %d 個中繼。" + } + } + } + }, + "app_info.settings.relays.error.malformed" : { + "comment" : "Error shown when a typed relay address cannot be parsed", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لا يبدو هذا عنوان مرحّل. جرّب wss://host." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এটি রিলের ঠিকানার মতো মনে হচ্ছে না। wss://host দিয়ে চেষ্টা করুন।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "das sieht nicht wie eine relay-adresse aus. versuch wss://host." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "that doesn't look like a relay address. try wss://host." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "eso no parece una dirección de relay. prueba wss://host." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "این شبیه نشانی رله نیست. wss://host را امتحان کنید." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mukhang hindi ito address ng relay. subukan ang wss://host." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ça ne ressemble pas à une adresse de relais. essaie wss://host." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "זה לא נראה כמו כתובת של ממסר. נסה wss://host." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "यह रिले का पता नहीं लगता। wss://host आज़माएँ।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "itu tidak tampak seperti alamat relay. coba wss://host." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "non sembra l'indirizzo di un relay. prova wss://host." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "リレーのアドレスではないようです。wss://host を試してください。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "릴레이 주소가 아닌 것 같습니다. wss://host 형식으로 시도하세요." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "itu tidak kelihatan seperti alamat relay. cuba wss://host." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "यो रिलेको ठेगाना जस्तो देखिँदैन। wss://host प्रयास गर।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "dat lijkt niet op een relay-adres. probeer wss://host." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "to nie wygląda na adres relay. spróbuj wss://host." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "isso não parece um endereço de relé. tenta wss://host." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "isso não parece um endereço de relay. tente wss://host." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "это не похоже на адрес реле. попробуй wss://host." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "det ser inte ut som en reläadress. prova wss://host." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இது ரிலே முகவரி போல் தெரியவில்லை. wss://host முயற்சிக்கவும்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ดูเหมือนไม่ใช่ที่อยู่รีเลย์ ลอง wss://host" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bu bir röle adresine benzemiyor. wss://host deneyin." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "це не схоже на адресу релея. спробуй wss://host." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "یہ ریلے کا پتہ نہیں لگتا۔ wss://host آزمائیں۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "đó không giống địa chỉ relay. hãy thử wss://host." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "这看起来不像中继地址。试试 wss://host。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "這看起來不像中繼位址。試試 wss://host。" + } + } + } + }, + "app_info.settings.relays.placeholder" : { + "comment" : "Placeholder text in the field for adding a relay address", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "wss://relay.example.com" + } + } + } + }, + "app_info.settings.relays.remove" : { + "comment" : "Accessibility label for the button that removes an added relay", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إزالة المرحّل" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "রিলে সরান" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "relay entfernen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "remove relay" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "quitar relay" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "حذف رله" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "alisin ang relay" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "retirer le relais" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הסר ממסר" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "रिले हटाएँ" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "hapus relay" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "rimuovi relay" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "リレーを削除" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "릴레이 제거" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "buang relay" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "रिले हटाउ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "relay verwijderen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "usuń relay" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "remover relé" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "remover relay" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "удалить реле" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "ta bort relä" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "ரிலேயை நீக்கு" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ลบรีเลย์" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "röleyi kaldır" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "видалити релей" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "ریلے ہٹائیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "xóa relay" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "移除中继" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "移除中繼" + } + } + } + }, + "app_info.settings.relays.subtitle" : { + "comment" : "Subtitle explaining what the relay list is for and why someone would add a relay", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "عندما لا تصل شبكة mesh إلى شخص، تنتقل الرسائل الخاصة عبر هذه المرحّلات. المرحّلات المدمجة عناوين معروفة يمكن لمرشّح الشبكة حجبها — لذا يمكنك إضافة مرحّلاتك الخاصة، بما في ذلك عناوين .onion." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "মেশ কারও কাছে পৌঁছাতে না পারলে ব্যক্তিগত বার্তা এই রিলেগুলোর মধ্য দিয়ে যায়। বিল্ট-ইন রিলেগুলো সুপরিচিত ঠিকানা, যা নেটওয়ার্ক ফিল্টার আটকে দিতে পারে — তাই আপনি নিজের রিলে যোগ করতে পারেন, .onion ঠিকানাও।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "wenn das mesh jemanden nicht erreicht, laufen private nachrichten über diese relays. die eingebauten sind bekannte adressen, die ein netzwerkfilter blockieren kann — du kannst also eigene hinzufügen, auch .onion-adressen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "when the mesh can't reach someone, private messages travel through these relays. the built-in ones are well-known addresses that a network filter can block, so you can add your own — including .onion addresses." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "cuando el mesh no llega a alguien, los mensajes privados viajan por estos relays. los integrados son direcciones muy conocidas que un filtro de red puede bloquear, así que puedes añadir los tuyos — incluidas direcciones .onion." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "وقتی مش به کسی نمی‌رسد، پیام‌های خصوصی از این رله‌ها می‌گذرند. رله‌های داخلی نشانی‌های شناخته‌شده‌ای هستند که یک صافی شبکه می‌تواند مسدودشان کند — پس می‌توانید رله‌های خودتان را اضافه کنید، از جمله نشانی‌های .onion." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "kapag hindi maabot ng mesh ang isang tao, dumadaan ang mga pribadong mensahe sa mga relay na ito. ang mga built-in ay kilalang-kilalang address na kayang harangin ng filter ng network — kaya maaari kang magdagdag ng sarili mo, kasama ang mga .onion address." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "quand le mesh ne joint pas quelqu'un, les messages privés passent par ces relais. ceux intégrés sont des adresses bien connues qu'un filtre réseau peut bloquer — tu peux donc ajouter les tiens, y compris des adresses .onion." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "כשה-mesh לא מגיע למישהו, הודעות פרטיות עוברות דרך הממסרים האלה. הממסרים המובנים הם כתובות מוכרות שמסנן רשת יכול לחסום — אפשר להוסיף ממסרים משלך, כולל כתובות .onion." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "जब मेश किसी तक नहीं पहुँच पाता, तब निजी संदेश इन रिले से होकर जाते हैं। अंतर्निर्मित रिले जाने-पहचाने पते हैं जिन्हें नेटवर्क फ़िल्टर रोक सकता है — इसलिए आप अपने रिले जोड़ सकते हैं, .onion पते भी।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "saat mesh tidak bisa menjangkau seseorang, pesan pribadi lewat relay ini. relay bawaan adalah alamat yang sudah dikenal luas dan bisa diblokir filter jaringan — jadi kamu bisa menambahkan relay sendiri, termasuk alamat .onion." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "quando la mesh non raggiunge qualcuno, i messaggi privati passano da questi relay. quelli integrati sono indirizzi molto noti che un filtro di rete può bloccare — puoi quindi aggiungere i tuoi, anche indirizzi .onion." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "meshで相手に届かないとき、プライベートメッセージはこれらのリレーを通ります。組み込みのリレーはよく知られたアドレスで、ネットワークのフィルターにブロックされることがあります — 自分のリレー、.onionアドレスも追加できます。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh로 상대에게 닿지 않으면 비공개 메시지는 이 릴레이를 거칩니다. 기본 릴레이는 널리 알려진 주소여서 네트워크 필터가 차단할 수 있습니다 — 그래서 .onion 주소를 포함해 직접 릴레이를 추가할 수 있습니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "apabila mesh tidak dapat mencapai seseorang, pesan peribadi bergerak melalui relay ini. relay terbina dalam ialah alamat yang terkenal dan boleh dihalang oleh penapis rangkaian — jadi anda boleh menambah relay sendiri, termasuk alamat .onion." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh कसैसम्म पुग्न सक्दैन भने निजी सन्देश यी रिले मार्फत जान्छन्। पूर्वनिर्मित रिले सबैलाई थाहा भएका ठेगाना हुन्, जसलाई नेटवर्क फिल्टरले रोक्न सक्छ — त्यसैले तिमी आफ्नै रिले थप्न सक्छौ, .onion ठेगाना पनि।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "als de mesh iemand niet bereikt, gaan privéberichten via deze relays. de ingebouwde relays zijn bekende adressen die een netwerkfilter kan blokkeren — je kunt dus je eigen relays toevoegen, ook .onion-adressen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "gdy mesh nie dosięga kogoś, wiadomości prywatne idą przez te relay. wbudowane to powszechnie znane adresy, które filtr sieciowy może zablokować — możesz więc dodać własne, także adresy .onion." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "quando a mesh não chega a alguém, as mensagens privadas seguem por estes relés. os integrados são endereços bem conhecidos que um filtro de rede pode bloquear — por isso podes acrescentar os teus, incluindo endereços .onion." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "quando o mesh não alcança alguém, as mensagens privadas passam por estes relays. os integrados são endereços conhecidos que um filtro de rede pode bloquear — então você pode adicionar os seus, inclusive endereços .onion." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "когда mesh не достаёт до человека, приватные сообщения идут через эти реле. встроенные — это широко известные адреса, которые может заблокировать сетевой фильтр, поэтому можно добавить свои, в том числе .onion-адреса." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "när mesh inte når fram till någon går privata meddelanden via dessa reläer. de inbyggda är välkända adresser som ett nätverksfilter kan blockera — du kan därför lägga till egna, även .onion-adresser." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh ஒருவரை எட்ட முடியாதபோது, தனிப்பட்ட செய்திகள் இந்த ரிலேகள் வழியாகச் செல்கின்றன. உள்ளமைந்த ரிலேகள் நன்கு அறியப்பட்ட முகவரிகள், அவற்றை நெட்வொர்க் வடிகட்டி தடுக்க முடியும் — எனவே .onion முகவரிகள் உட்பட உங்கள் சொந்த ரிலேகளைச் சேர்க்கலாம்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เมื่อ mesh ไปไม่ถึงใคร ข้อความส่วนตัวจะเดินทางผ่านรีเลย์เหล่านี้ รีเลย์ที่มาพร้อมแอปเป็นที่อยู่ที่รู้จักกันดีและตัวกรองเครือข่ายปิดกั้นได้ — คุณจึงเพิ่มรีเลย์ของตัวเองได้ รวมถึงที่อยู่ .onion" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh birine ulaşamadığında özel mesajlar bu rölelerden geçer. yerleşik röleler herkesin bildiği adreslerdir ve bir ağ filtresi bunları engelleyebilir — bu yüzden .onion adresleri de dahil kendi rölelerinizi ekleyebilirsiniz." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "коли mesh не дотягується до людини, приватні повідомлення йдуть через ці релеї. вбудовані — це добре відомі адреси, які може заблокувати мережевий фільтр, тож можна додати власні, зокрема .onion-адреси." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "جب mesh کسی تک نہ پہنچ سکے تو نجی پیغامات ان ریلے سے گزرتے ہیں۔ بلٹ اِن ریلے مشہور پتے ہیں جنہیں نیٹ ورک فلٹر روک سکتا ہے — اس لیے آپ اپنے ریلے شامل کر سکتے ہیں، .onion پتے بھی۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "khi mesh không tới được ai đó, tin nhắn riêng đi qua các relay này. các relay tích hợp là những địa chỉ ai cũng biết nên bộ lọc mạng có thể chặn — vì vậy bạn có thể thêm relay của mình, kể cả địa chỉ .onion." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当 mesh 无法触达某人时,私密消息会经这些中继传递。内置中继是众所周知的地址,网络过滤可以封锁它们 — 所以你可以添加自己的中继,包括 .onion 地址。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "當 mesh 無法觸及某人時,私密訊息會經這些中繼傳遞。內建中繼是眾所周知的位址,網路過濾可以封鎖它們 — 所以你可以加入自己的中繼,包括 .onion 位址。" + } + } + } + }, + "app_info.settings.relays.title" : { + "comment" : "Title of the relay list editor in settings", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "مرحّلات الرسائل الخاصة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ব্যক্তিগত বার্তার রিলে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "relays für private nachrichten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "private message relays" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "relays para mensajes privados" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "رله‌های پیام خصوصی" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mga relay para sa pribadong mensahe" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "relais pour messages privés" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "ממסרים להודעות פרטיות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "निजी संदेश रिले" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "relay pesan pribadi" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "relay per messaggi privati" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "プライベートメッセージのリレー" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "비공개 메시지 릴레이" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "relay pesan peribadi" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "निजी सन्देशका रिले" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "relays voor privéberichten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "relay dla wiadomości prywatnych" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "relés para mensagens privadas" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "relays para mensagens privadas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "реле для приватных сообщений" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "reläer för privata meddelanden" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "தனிப்பட்ட செய்திகளுக்கான ரிலேகள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "รีเลย์สำหรับข้อความส่วนตัว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "özel mesaj röleleri" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "релеї для приватних повідомлень" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "نجی پیغامات کے ریلے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "relay cho tin nhắn riêng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "私密消息中继" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "私密訊息中繼" + } + } + } + }, + "app_info.settings.tor.off_warning" : { + "comment" : "Warning shown under the tor toggle while tor is switched off, stating that relay operators can see the device IP address", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor متوقف: كل مرحّل تتصل به يمكنه رؤية عنوان IP الخاص بك، بما في ذلك المرحّلات التي تحمل رسائلك الخاصة." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tor বন্ধ: আপনি যে রিলেতেই সংযুক্ত হন সেটি আপনার IP ঠিকানা দেখতে পায়, আপনার ব্যক্তিগত বার্তা বহনকারী রিলেগুলোও।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor ist aus: jedes relay, mit dem du dich verbindest, kann deine ip-adresse sehen, auch die relays, die deine privaten nachrichten tragen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor is off: every relay you connect to can see your IP address, including relays carrying your private messages." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tor está desactivado: cada relay al que te conectas puede ver tu dirección IP, incluidos los relays que llevan tus mensajes privados." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tor خاموش است: هر رله‌ای که به آن وصل می‌شوید می‌تواند نشانی IP شما را ببیند، از جمله رله‌هایی که پیام‌های خصوصی شما را می‌برند." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "naka-off ang tor: nakikita ng bawat relay na kinokonekta mo ang iyong IP address, kasama ang mga relay na nagdadala ng iyong mga pribadong mensahe." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor est désactivé : chaque relais auquel tu te connectes voit ton adresse ip, y compris les relais qui transportent tes messages privés." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor כבוי: כל ממסר שאתה מתחבר אליו רואה את כתובת ה-ip שלך, כולל ממסרים שנושאים את ההודעות הפרטיות שלך." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tor बंद है: आप जिस भी रिले से जुड़ते हैं वह आपका IP पता देख सकता है, उनमें वे रिले भी शामिल हैं जो आपके निजी संदेश ले जाते हैं।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor nonaktif: setiap relay yang kamu hubungi bisa melihat alamat ip-mu, termasuk relay yang membawa pesan pribadimu." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor è disattivato: ogni relay a cui ti colleghi può vedere il tuo indirizzo ip, compresi i relay che trasportano i tuoi messaggi privati." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "torはオフです: 接続するすべてのリレーがあなたのipアドレスを見られます。プライベートメッセージを運ぶリレーも同じです。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor가 꺼져 있습니다: 연결하는 모든 릴레이가 IP 주소를 볼 수 있고, 비공개 메시지를 운반하는 릴레이도 마찬가지입니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor tidak aktif: setiap relay yang anda sambung boleh melihat alamat ip anda, termasuk relay yang membawa pesan peribadi anda." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor बन्द छ: तिमी जोडिने हरेक रिलेले तिम्रो ip ठेगाना देख्न सक्छ, तिम्रा निजी सन्देश बोक्ने रिले पनि।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor staat uit: elke relay waarmee je verbindt kan je IP-adres zien, ook de relays die je privéberichten vervoeren." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor jest wyłączony: każdy relay, z którym się łączysz, widzi twój adres IP, także te przenoszące twoje wiadomości prywatne." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "o Tor está desligado: cada relé a que te ligas consegue ver o teu endereço IP, incluindo os relés que transportam as tuas mensagens privadas." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "o tor está desligado: cada relay a que você se conecta consegue ver seu endereço ip, inclusive os relays que carregam suas mensagens privadas." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor выключен: каждое реле, к которому ты подключаешься, видит твой ip-адрес, включая реле, через которые идут твои приватные сообщения." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor är av: varje relä du ansluter till kan se din IP-adress, även reläerna som bär dina privata meddelanden." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor அணைந்திருக்கிறது: நீங்கள் இணைக்கும் ஒவ்வொரு ரிலேயும் உங்கள் IP முகவரியைப் பார்க்க முடியும், உங்கள் தனிப்பட்ட செய்திகளை எடுத்துச் செல்லும் ரிலேகளும் அதில் அடங்கும்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor ปิดอยู่: ทุกรีเลย์ที่คุณเชื่อมต่อเห็นที่อยู่ IP ของคุณ รวมถึงรีเลย์ที่ส่งข้อความส่วนตัวของคุณ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tor kapalı: bağlandığınız her röle IP adresinizi görebilir, özel mesajlarınızı taşıyan röleler de dahil." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor вимкнено: кожен релей, до якого ти підключаєшся, бачить твою ip-адресу, включно з релеями, що несуть твої приватні повідомлення." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor بند ہے: آپ جس ریلے سے بھی جڑتے ہیں وہ آپ کا IP پتہ دیکھ سکتا ہے، اُن ریلے سمیت جو آپ کے نجی پیغامات لے جاتے ہیں۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor đang tắt: mọi relay bạn kết nối đều thấy địa chỉ IP của bạn, kể cả những relay mang tin nhắn riêng của bạn." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor 已关闭:你连接的每个中继都能看到你的 IP 地址,包括承载你私密消息的中继。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor 已關閉:你連線的每個中繼都能看到你的 IP 位址,包括承載你私密訊息的中繼。" + } + } + } + }, + "app_info.settings.tor.subtitle" : { + "comment" : "Subtitle for the tor routing toggle in settings, explaining what it covers", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يرسل حركة الإنترنت عبر tor، فيرى مشغّلو المرحّلات عنوان tor بدلاً من عنوانك. يشمل قنوات الموقع والرسائل الخاصة المسلَّمة عبر الإنترنت. الموصى به: تشغيل." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ইন্টারনেট ট্রাফিক Tor দিয়ে পাঠায়, তাই রিলে পরিচালকেরা আপনার ঠিকানার বদলে Tor-এর ঠিকানা দেখে। লোকেশন চ্যানেল ও ইন্টারনেটে পাঠানো ব্যক্তিগত বার্তা এর আওতায় পড়ে। সুপারিশ: চালু।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "sendet internetverkehr über tor, sodass relay-betreiber die adresse von tor statt deiner sehen. gilt für standortkanäle und private nachrichten, die über das internet zugestellt werden. empfohlen: an." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "sends internet traffic through tor, so relay operators see tor's address instead of yours. covers location channels and private messages delivered over the internet. recommended: on." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "envía el tráfico de internet a través de Tor, así quienes gestionan los relays ven la dirección de Tor en lugar de la tuya. cubre los canales de ubicación y los mensajes privados entregados por internet. recomendado: activado." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "ترافیک اینترنت را از طریق Tor می‌فرستد، بنابراین گردانندگان رله به‌جای نشانی شما نشانی Tor را می‌بینند. کانال‌های موقعیت و پیام‌های خصوصی که از اینترنت تحویل می‌شوند را پوشش می‌دهد. توصیه: روشن." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "ipinapadala ang trapiko sa internet sa pamamagitan ng tor, kaya nakikita ng mga nagpapatakbo ng relay ang address ng tor at hindi ang sa iyo. saklaw nito ang mga channel ng lokasyon at ang mga pribadong mensaheng ipinapadala sa internet. inirerekomenda: naka-on." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "envoie le trafic internet par tor, donc ceux qui gèrent les relais voient l'adresse de tor et pas la tienne. couvre les canaux de localisation et les messages privés livrés par internet. recommandé : activé." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "שולח את תעבורת האינטרנט דרך tor, כך שמפעילי ממסרים רואים את הכתובת של tor במקום שלך. חל על ערוצי מיקום ועל הודעות פרטיות שנשלחות דרך האינטרנט. מומלץ: פעיל." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "इंटरनेट ट्रैफ़िक Tor से भेजता है, जिससे रिले चलाने वालों को आपके पते की जगह Tor का पता दिखता है। यह लोकेशन चैनलों और इंटरनेट से पहुँचाए जाने वाले निजी संदेशों पर लागू होता है। अनुशंसित: चालू।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "mengirim lalu lintas internet lewat tor, jadi pengelola relay melihat alamat tor bukan alamatmu. berlaku untuk kanal lokasi dan pesan pribadi yang dikirim lewat internet. disarankan: aktif." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "invia il traffico internet attraverso tor, così chi gestisce i relay vede l'indirizzo di tor invece del tuo. riguarda i canali posizione e i messaggi privati consegnati via internet. consigliato: attivo." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "インターネット通信をtor経由で送るため、リレーの運営者にはあなたの代わりにtorのアドレスが見えます。ロケーションチャンネルと、インターネット経由で届くプライベートメッセージが対象です。推奨: オン" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "인터넷 트래픽을 tor를 통해 보내므로 릴레이를 운영하는 쪽에는 내 주소가 아니라 tor의 주소가 보입니다. 위치 채널과 인터넷으로 전달되는 비공개 메시지에 적용됩니다. 권장: 켜기." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "menghantar trafik internet melalui tor, jadi pengendali relay melihat alamat tor dan bukan alamat anda. ia merangkumi kanal lokasi dan pesan peribadi yang dihantar melalui internet. disarankan: aktif." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "इन्टरनेट ट्राफिक tor मार्फत पठाउँछ, त्यसैले रिले चलाउनेहरूले तिम्रो ठेगानाको सट्टा tor को ठेगाना देख्छन्। यो स्थान च्यानल र इन्टरनेटबाट पुग्ने निजी सन्देशमा लागू हुन्छ। सिफारिस: अन।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "stuurt internetverkeer via tor, zodat relaybeheerders het adres van tor zien in plaats van het jouwe. geldt voor locatiekanalen en privéberichten die via internet worden bezorgd. aanbevolen: aan." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wysyła ruch internetowy przez tor, więc osoby prowadzące relay widzą adres tor, a nie twój. dotyczy kanałów lokalizacji i wiadomości prywatnych dostarczanych przez internet. zalecane: włączone." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "envia o tráfego de internet através do Tor, por isso quem opera os relés vê o endereço do Tor em vez do teu. abrange os canais de localização e as mensagens privadas entregues pela internet. recomendado: ligado." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "envia o tráfego de internet pelo tor, então quem opera os relays vê o endereço do tor em vez do seu. vale para os canais de localização e as mensagens privadas entregues pela internet. recomendado: ligado." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "отправляет интернет-трафик через tor, поэтому операторы реле видят адрес tor, а не твой. распространяется на каналы локации и приватные сообщения, доставляемые через интернет. рекомендуем включить." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "skickar internettrafik via tor, så de som driver reläer ser tors adress i stället för din. gäller platskanaler och privata meddelanden som levereras över internet. rekommenderas: på." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இணைய போக்குவரத்தை tor வழியாக அனுப்புகிறது, அதனால் ரிலேகளை நடத்துபவர்கள் உங்கள் முகவரிக்குப் பதிலாக tor இன் முகவரியைப் பார்க்கிறார்கள். இட சேனல்களுக்கும் இணையம் வழியாக வழங்கப்படும் தனிப்பட்ட செய்திகளுக்கும் இது பொருந்தும். பரிந்துரை: இயக்கவும்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ส่งทราฟฟิกอินเทอร์เน็ตผ่าน tor ผู้ดูแลรีเลย์จึงเห็นที่อยู่ของ tor แทนที่อยู่ของคุณ ครอบคลุมช่องตามตำแหน่งและข้อความส่วนตัวที่ส่งผ่านอินเทอร์เน็ต แนะนำให้เปิด" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "internet trafiğini Tor üzerinden gönderir; böylece röle işletenler sizin adresiniz yerine Tor'un adresini görür. konum kanallarını ve internet üzerinden iletilen özel mesajları kapsar. önerilen: açık." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "надсилає інтернет-трафік через tor, тож оператори релеїв бачать адресу tor, а не твою. охоплює канали локації та приватні повідомлення, що доставляються через інтернет. рекомендовано ввімкнути." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "انٹرنیٹ ٹریفک tor کے ذریعے بھیجتا ہے، اس لیے ریلے چلانے والوں کو آپ کے پتے کی جگہ tor کا پتہ نظر آتا ہے۔ اس میں لوکیشن چینلز اور انٹرنیٹ سے پہنچائے جانے والے نجی پیغامات شامل ہیں۔ تجویز: آن رکھیں۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "gửi lưu lượng internet qua tor, nên bên vận hành relay thấy địa chỉ của tor thay vì địa chỉ của bạn. áp dụng cho kênh vị trí và tin nhắn riêng được gửi qua internet. khuyến nghị: bật." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "通过 tor 发送互联网流量,中继运营方看到的是 tor 的地址而不是你的。适用于位置频道和经互联网投递的私密消息。推荐:开启。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "透過 tor 傳送網際網路流量,中繼營運方看到的是 tor 的位址而不是你的。適用於位置頻道和經網際網路投遞的私密訊息。推薦:開啟。" + } + } + } + }, + "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" : { "comment" : "Title of the notification action button that sends a friendly wave back to a nearby person", "extractionState" : "manual", @@ -12487,6 +14569,564 @@ } } }, + "app_info.settings.hide_previews.subtitle" : { + "comment" : "Subtitle explaining what hiding notification message previews does", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تقول الإشعارات إن شيئًا ما وصل دون إظهار الرسالة ولا من أرسلها ولا قناة الموقع التي جاءت منها. من يحمل هاتفك المقفل لا يعرف شيئًا من شاشة القفل. مفعّل افتراضيًا." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "নোটিফিকেশন শুধু জানায় যে কিছু এসেছে — বার্তা, কে পাঠিয়েছে বা কোন লোকেশন চ্যানেল থেকে এসেছে তা দেখায় না। আপনার লক করা ফোন যার হাতেই থাকুক, লক স্ক্রিন থেকে কিছুই জানতে পারবে না। ডিফল্টভাবে চালু।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "benachrichtigungen sagen nur, dass etwas angekommen ist — ohne die nachricht, ohne wer sie geschickt hat und ohne den standortkanal, aus dem sie kam. wer dein gesperrtes handy in der hand hält, erfährt vom sperrbildschirm nichts. standardmäßig an." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "notifications say that something arrived without showing the message, who sent it, or which location channel it came from. anyone holding your locked phone learns nothing from the lock screen. on by default." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "las notificaciones dicen que ha llegado algo sin mostrar el mensaje, quién lo envió ni de qué canal de ubicación viene. quien tenga tu teléfono bloqueado en la mano no aprende nada de la pantalla de bloqueo. activado por defecto." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "اعلان‌ها فقط می‌گویند چیزی رسیده است، بدون نشان دادن متن پیام، فرستنده یا کانال موقعیتی که از آن آمده. هر کسی گوشی قفل‌شده‌ات را در دست بگیرد، از صفحهٔ قفل چیزی نمی‌فهمد. به‌طور پیش‌فرض روشن." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "sinasabi lang ng mga notification na may dumating, hindi ang mensahe, hindi kung sino ang nagpadala, at hindi kung saang channel ng lokasyon ito nanggaling. kahit sino ang may hawak ng naka-lock na telepono mo, wala siyang malalaman sa lock screen. naka-on bilang default." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "les notifications disent que quelque chose est arrivé sans montrer le message, qui l'a envoyé, ni de quel canal de localisation il vient. quiconque tient ton téléphone verrouillé n'apprend rien depuis l'écran de verrouillage. activé par défaut." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "ההתראות מודיעות שמשהו הגיע בלי להציג את ההודעה, מי שלח אותה או מאיזה ערוץ מיקום היא באה. מי שמחזיק את הטלפון הנעול שלך לא לומד דבר ממסך הנעילה. פעיל כברירת מחדל." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "नोटिफिकेशन सिर्फ बताते हैं कि कुछ आया है — न संदेश, न भेजने वाला, न वह लोकेशन चैनल जहाँ से वह आया। आपका लॉक किया हुआ फोन किसी के हाथ में हो, लॉक स्क्रीन से उसे कुछ पता नहीं चलेगा। डिफ़ॉल्ट रूप से चालू।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "notifikasi hanya bilang ada sesuatu yang masuk, tanpa menampilkan pesannya, siapa pengirimnya, atau dari kanal lokasi mana. siapa pun yang memegang ponselmu yang terkunci tidak mendapat apa pun dari layar kunci. aktif secara bawaan." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "le notifiche dicono che è arrivato qualcosa senza mostrare il messaggio, chi l'ha inviato o da quale canale posizione arriva. chi tiene in mano il tuo telefono bloccato non scopre nulla dalla schermata di blocco. attivo per impostazione predefinita." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "通知は何か届いたことだけを伝え、メッセージの内容も、送り主も、どのロケーションチャンネルから来たかも表示しません。ロックしたスマホを誰が手にしても、ロック画面からは何もわかりません。初期設定はオンです。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "알림은 무언가 도착했다는 사실만 알리고 메시지 내용, 보낸 사람, 어느 위치 채널에서 왔는지는 보여주지 않습니다. 잠긴 휴대폰을 누가 들고 있어도 잠금 화면에서 알 수 있는 것은 없습니다. 기본값은 켜짐입니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "notifikasi hanya memberitahu ada sesuatu masuk, tanpa menunjukkan mesejnya, siapa yang menghantar, atau dari kanal lokasi mana. sesiapa yang memegang telefonmu yang berkunci tidak tahu apa-apa daripada skrin kunci. hidup secara lalai." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सूचनाहरूले केवल कुनै कुरा आयो भन्छन्, सन्देश, पठाउने व्यक्ति वा कुन स्थान च्यानलबाट आयो भन्ने देखाउँदैनन्। तिम्रो लक भएको फोन जोसुकैको हातमा परे पनि लक स्क्रिनबाट केही थाहा पाउँदैन। पूर्वनिर्धारित रूपमा अन।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "meldingen zeggen alleen dat er iets is binnengekomen, zonder het bericht, de afzender of het locatiekanaal waaruit het komt. wie je vergrendelde telefoon vasthoudt, komt niets te weten via het vergrendelscherm. standaard aan." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "powiadomienia mówią tylko, że coś przyszło — bez treści, bez nadawcy i bez kanału lokalizacji, z którego przyszło. ktokolwiek trzyma twój zablokowany telefon, nie dowie się niczego z ekranu blokady. domyślnie włączone." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "as notificações dizem que chegou algo sem mostrar a mensagem, quem a enviou nem de que canal de localização vem. quem tem o teu telemóvel bloqueado na mão não fica a saber nada pelo ecrã de bloqueio. ligado por predefinição." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "as notificações dizem que algo chegou sem mostrar a mensagem, quem enviou nem de qual canal de localização veio. quem estiver com seu celular bloqueado na mão não descobre nada pela tela de bloqueio. ligado por padrão." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "уведомления сообщают только, что что-то пришло — без текста, без отправителя и без канала локации, откуда оно. кто бы ни держал твой заблокированный телефон, с экрана блокировки он не узнает ничего. по умолчанию включено." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "aviseringar säger bara att något kommit in, utan meddelandet, vem som skickade det eller vilken platskanal det kom från. den som håller i din låsta telefon får inget veta från låsskärmen. på som standard." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அறிவிப்புகள் ஏதோ வந்ததை மட்டும் சொல்லும் — செய்தியையும், அனுப்பியவரையும், எந்த இட சேனலிலிருந்து வந்ததையும் காட்டாது. உங்கள் பூட்டிய தொலைபேசியை யார் கையில் வைத்திருந்தாலும், பூட்டுத் திரையிலிருந்து எதுவும் தெரியாது. இயல்பாக இயக்கத்தில் உள்ளது." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "การแจ้งเตือนจะบอกแค่ว่ามีอะไรเข้ามา โดยไม่แสดงข้อความ ไม่บอกว่าใครส่ง และไม่บอกว่ามาจากช่องตามตำแหน่งใด ใครถือโทรศัพท์ที่ล็อกอยู่ของคุณก็ไม่รู้อะไรจากหน้าจอล็อก เปิดไว้เป็นค่าเริ่มต้น" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bildirimler yalnızca bir şeyin geldiğini söyler; mesajı, kimin gönderdiğini ya da hangi konum kanalından geldiğini göstermez. kilitli telefonunu elinde tutan kişi kilit ekranından hiçbir şey öğrenemez. varsayılan olarak açık." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "повідомлення лише кажуть, що щось надійшло — без тексту, без відправника й без каналу локації, звідки воно. хто б не тримав твій заблокований телефон, з екрана блокування він не дізнається нічого. типово ввімкнено." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "اطلاعات صرف یہ بتاتی ہیں کہ کچھ آیا ہے، نہ پیغام دکھاتی ہیں، نہ بھیجنے والا، نہ یہ کہ کس لوکیشن چینل سے آیا۔ آپ کا مقفل فون جس کے ہاتھ میں بھی ہو، لاک اسکرین سے اسے کچھ معلوم نہیں ہوتا۔ بطور طے شدہ آن ہے۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "thông báo chỉ cho biết có gì vừa đến, không hiện nội dung tin nhắn, ai gửi, hay đến từ kênh vị trí nào. ai đang giữ chiếc điện thoại đã khoá của bạn cũng không biết được gì từ màn hình khoá. bật theo mặc định." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "通知只告诉你有新消息抵达,不显示内容、发送者,也不显示来自哪个位置频道。谁拿着你锁定的手机,都无法从锁屏上得知任何信息。默认开启。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "通知只告訴你有新訊息抵達,不顯示內容、發送者,也不顯示來自哪個位置頻道。誰拿著你鎖定的手機,都無法從鎖定畫面得知任何資訊。預設開啟。" + } + } + } + }, + "app_info.settings.hide_previews.title" : { + "comment" : "Title of the setting that keeps message text, sender names, and geohashes out of lock-screen notifications", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إخفاء معاينات الرسائل" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "বার্তার প্রিভিউ লুকান" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "nachrichtenvorschau ausblenden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "hide message previews" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "ocultar vistas previas de mensajes" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "پنهان کردن پیش‌نمایش پیام‌ها" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "itago ang preview ng mensahe" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "masquer les aperçus de messages" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הסתרת תצוגה מקדימה של הודעות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "संदेश प्रीव्यू छुपाएँ" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "sembunyikan pratinjau pesan" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "nascondi le anteprime dei messaggi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "メッセージのプレビューを隠す" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메시지 미리보기 숨기기" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "sembunyikan pratonton mesej" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सन्देशको प्रिभ्यु लुकाउ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "berichtvoorbeelden verbergen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "ukryj podglądy wiadomości" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "ocultar pré-visualizações de mensagens" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "ocultar prévias de mensagens" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "скрывать превью сообщений" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "dölj förhandsvisning av meddelanden" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "செய்தி முன்தோற்றத்தை மறை" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ซ่อนตัวอย่างข้อความ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesaj önizlemelerini gizle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "приховувати попередній перегляд повідомлень" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "پیغام کی جھلک چھپائیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ẩn nội dung xem trước tin nhắn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "隐藏消息预览" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "隱藏訊息預覽" + } + } + } + }, + "app_info.settings.privacy.title" : { + "comment" : "Section header (uppercase) for privacy settings such as hiding notification previews", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "خصوصية" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "গোপনীয়তা" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "PRIVATSPHÄRE" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "PRIVACY" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "PRIVACIDAD" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "حریم خصوصی" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "PRIBASYA" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "CONFIDENTIALITÉ" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "פרטיות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "गोपनीयता" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "PRIVASI" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "PRIVACY" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "プライバシー" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "개인정보 보호" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "PRIVASI" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "गोपनीयता" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "PRIVACY" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "PRYWATNOŚĆ" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "PRIVACIDADE" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "PRIVACIDADE" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "КОНФИДЕНЦИАЛЬНОСТЬ" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "PRIVAT" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "தனியுரிமை" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ความเป็นส่วนตัว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "GİZLİLİK" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "КОНФІДЕНЦІЙНІСТЬ" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "رازداری" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "QUYỀN RIÊNG TƯ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "隐私" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "隱私" + } + } + } + }, "app_info.tab.info" : { "extractionState" : "manual", "localizations" : { @@ -30681,6 +33321,378 @@ } } }, + "content.delivery.reason.private_media_capability_unresolved" : { + "comment" : "Failure reason shown when the peer's support for encrypted media could not be confirmed before sending", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تعذّر تأكيد دعم الوسائط المشفّرة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "এনক্রিপ্ট করা মিডিয়া সমর্থন নিশ্চিত করা যায়নি" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Unterstützung für verschlüsselte Medien konnte nicht bestätigt werden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Could not confirm encrypted media support" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "No se pudo confirmar la compatibilidad con multimedia cifrada" + } + }, + "fa" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "پشتیبانی از رسانه رمزنگاری‌شده تأیید نشد" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Hindi makumpirma ang suporta sa naka-encrypt na media" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Impossible de confirmer la prise en charge des médias chiffrés" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "לא ניתן לאמת תמיכה במדיה מוצפנת" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "एन्क्रिप्टेड मीडिया समर्थन की पुष्टि नहीं हो सकी" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Tidak dapat memastikan dukungan media terenkripsi" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Impossibile confermare il supporto dei contenuti multimediali cifrati" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "暗号化メディアの対応を確認できませんでした" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "암호화된 미디어 지원을 확인할 수 없습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Tidak dapat mengesahkan sokongan media tersulit" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "एन्क्रिप्टेड मिडिया समर्थन पुष्टि गर्न सकिएन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ondersteuning voor versleutelde media kon niet worden bevestigd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Nie udało się potwierdzić obsługi zaszyfrowanych multimediów" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Não foi possível confirmar o suporte a multimédia cifrada" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Não foi possível confirmar o suporte a mídia criptografada" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Не удалось подтвердить поддержку зашифрованных медиа" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Det gick inte att bekräfta stöd för krypterade medier" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "மறைகுறியாக்கப்பட்ட மீடியா ஆதரவை உறுதிப்படுத்த முடியவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ไม่สามารถยืนยันการรองรับสื่อที่เข้ารหัสได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Şifreli medya desteği doğrulanamadı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Не вдалося підтвердити підтримку зашифрованих медіа" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "خفیہ کردہ میڈیا کی معاونت کی تصدیق نہیں ہو سکی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Không thể xác nhận hỗ trợ phương tiện được mã hóa" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "无法确认加密媒体支持" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "無法確認加密媒體支援" + } + } + } + }, + "content.delivery.reason.private_media_delivery_unconfirmed" : { + "comment" : "Failure reason shown when an encrypted media message was sent but its delivery was never confirmed", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تعذّر تأكيد التسليم" + } + }, + "bn" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ডেলিভারি নিশ্চিত করা যায়নি" + } + }, + "de" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Zustellung konnte nicht bestätigt werden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delivery could not be confirmed" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "No se pudo confirmar la entrega" + } + }, + "fa" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "تحویل تأیید نشد" + } + }, + "fil" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Hindi makumpirma ang paghahatid" + } + }, + "fr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Impossible de confirmer la remise" + } + }, + "he" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "לא ניתן לאמת את המסירה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "डिलीवरी की पुष्टि नहीं हो सकी" + } + }, + "id" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Pengiriman tidak dapat dipastikan" + } + }, + "it" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Impossibile confermare la consegna" + } + }, + "ja" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "配信を確認できませんでした" + } + }, + "ko" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "전달을 확인할 수 없습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Penghantaran tidak dapat disahkan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "डेलिभरी पुष्टि गर्न सकिएन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Bezorging kon niet worden bevestigd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Nie udało się potwierdzić dostarczenia" + } + }, + "pt" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Não foi possível confirmar a entrega" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Não foi possível confirmar a entrega" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Не удалось подтвердить доставку" + } + }, + "sv" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Leveransen kunde inte bekräftas" + } + }, + "ta" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "விநியோகத்தை உறுதிப்படுத்த முடியவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ไม่สามารถยืนยันการส่งได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Teslimat doğrulanamadı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Не вдалося підтвердити доставлення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ترسیل کی تصدیق نہیں ہو سکی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Không thể xác nhận việc gửi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "无法确认送达" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "無法確認送達" + } + } + } + }, "content.delivery.reason.not_delivered" : { "comment" : "Failure reason shown when the router gave up delivering a message", "extractionState" : "manual", @@ -50097,191 +53109,6 @@ } } }, - "location_channels.tor.subtitle" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "يخفي ip لقنوات الموقع. الموصى به: تشغيل." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "লোকেশন চ্যানেলের জন্য আপনার IP লুকায়। সুপারিশ: চালু রাখুন।" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "verbirgt deine ip für standortkanäle. empfohlen: an." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "hides your IP for location channels. recommended: on." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "oculta tu IP para los canales de ubicación. Recomendado: activado." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "IP شما را برای کانال‌های موقعیت پنهان می‌کند. توصیه: روشن." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "itinatago ang iyong IP para sa mga channel ng lokasyon. inirerekomenda: naka-on." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "cache ton ip pour les canaux localisation. recommandé : activé." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "מסתיר את ה-ip שלך לערוצי מיקום. מומלץ: פעיל." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "लोकेशन चैनलों के लिए आपका IP छुपाता है। अनुशंसित: चालू रखें।" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "menyembunyikan ip-mu untuk kanal lokasi. disarankan: aktif." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "nasconde il tuo ip per i canali posizione. consigliato: attivo." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ロケーションチャンネル用にipを隠します。推奨: オン" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "위치 채널에서 IP를 숨깁니다. 권장: 켜기." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "menyembunyikan ip-mu untuk kanal lokasi. disarankan: aktif." - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "स्थान च्यानलका लागि तिम्रो ip लुकाउँछ। सिफारिस: अन।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "verbergt je IP voor locatiekanalen. aanbevolen: aan." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "ukrywa twój IP dla kanałów lokalizacji. zalecane: włączone." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "oculta o teu IP para canais de localização. recomendado: ligado." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "oculta seu ip para canais de localização. recomendado: ligado." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "скрывает твой ip для каналов локации. рекомендуем включить." - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "döljer din IP för platskanaler. rekommenderas: på." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "இட சேனல்களுக்கு உங்கள் IP ஐ மறைக்கும். பரிந்துரை: இயக்கப்பட்டது." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ซ่อน IP ของคุณสำหรับช่องตำแหน่ง แนะนำให้เปิด" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "konum kanalları için IP'nizi gizler. önerilen: açık." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "приховує твій ip для каналів локації. рекомендовано ввімкнути." - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "لوکیشن چینلز کیلئے آپ کا IP چھپاتا ہے۔ تجویز: آن رکھیں۔" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "ẩn IP của bạn cho kênh vị trí. khuyến nghị: bật." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "为位置频道隐藏你的 IP。推荐:开启。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "為位置頻道隱藏你的 IP。推薦:開啟。" - } - } - } - }, "location_channels.tor.title" : { "extractionState" : "manual", "localizations" : { @@ -60494,6 +63321,750 @@ } } }, + "notification.redacted.body" : { + "comment" : "Lock-screen notification body shown in place of the message text when message previews are hidden", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "افتح bitchat للقراءة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "পড়তে bitchat খুলুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "öffne bitchat zum lesen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "open bitchat to read" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "abre bitchat para leer" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "برای خواندن bitchat را باز کن" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "buksan ang bitchat para makita" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ouvre bitchat pour lire" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "פתח את bitchat כדי לקרוא" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "पढ़ने के लिए bitchat खोलें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "buka bitchat untuk membaca" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "apri bitchat per leggere" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "読むには bitchat を開いてください" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "읽으려면 bitchat을 열어보세요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "buka bitchat untuk membaca" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "पढ्न bitchat खोल" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "open bitchat om te lezen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "otwórz bitchat, aby przeczytać" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "abre o bitchat para ler" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "abra o bitchat para ler" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "открой bitchat, чтобы прочитать" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "öppna bitchat för att läsa" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "படிக்க bitchat ஐத் திறக்கவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เปิด bitchat เพื่ออ่าน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "okumak için bitchat'i aç" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "відкрий bitchat, щоб прочитати" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "پڑھنے کے لیے bitchat کھولیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mở bitchat để đọc" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打开 bitchat 查看" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "開啟 bitchat 查看" + } + } + } + }, + "notification.redacted.dm.title" : { + "comment" : "Lock-screen notification title for a received direct message when message previews are hidden; deliberately names neither the sender nor the content", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 رسالة خاصة جديدة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 নতুন ডিএম" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 neue pn" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 new dm" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 nuevo md" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 پیام خصوصی جدید" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 bagong dm" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 nouveau mp" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 הודעה פרטית חדשה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 नया dm" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 dm baru" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 nuovo dm" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 新しいdm" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 새 dm" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 dm baru" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 नयाँ dm" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 nieuwe dm" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 nowy dm" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 nova mensagem privada" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 novo dm" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 новое личное сообщение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 nytt dm" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 புதிய dm" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 dm ใหม่" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 yeni dm" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 нове приватне повідомлення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 نیا نجی پیغام" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 dm mới" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 新的 dm" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 新的 dm" + } + } + } + }, + "notification.redacted.geohash.title" : { + "comment" : "Lock-screen notification title for activity in a location channel when message previews are hidden; deliberately omits the geohash", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 نشاط جديد قريب منك" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 আশেপাশে নতুন কার্যকলাপ" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 neue aktivität in der nähe" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 new activity nearby" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 nueva actividad cerca" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 فعالیت جدید در نزدیکی" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 may bagong aktibidad sa paligid" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 nouvelle activité à proximité" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 פעילות חדשה בסביבה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 आसपास नई गतिविधि" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 aktivitas baru di sekitar" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 nuova attività nelle vicinanze" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 近くで新しい動き" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 근처에 새 활동" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 aktiviti baru berdekatan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 नजिकै नयाँ गतिविधि" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 nieuwe activiteit in de buurt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 nowa aktywność w okolicy" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 nova atividade por perto" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 nova atividade por perto" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 новая активность рядом" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 ny aktivitet i närheten" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 அருகில் புதிய செயல்பாடு" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 มีความเคลื่อนไหวใหม่ใกล้คุณ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 yakınında yeni hareket" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 нова активність поблизу" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 قریب نئی سرگرمی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 hoạt động mới gần bạn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 附近有新动态" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 附近有新動態" + } + } + } + }, + "notification.redacted.mention.title" : { + "comment" : "Lock-screen notification title telling someone they were mentioned when message previews are hidden; deliberately omits who mentioned them", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 تمّت الإشارة إليك" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 আপনাকে মেনশন করা হয়েছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 du wurdest erwähnt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 you were mentioned" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 te han mencionado" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 منشن شدی" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 nabanggit ka" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 on t'a mentionné" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 הוזכרת" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 आपको मेंशन किया गया" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 kamu disebut" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 ti hanno menzionato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 メンションされました" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 멘션되었습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 kamu disebut" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 तिमीलाई उल्लेख गरियो" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 je bent vermeld" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 wspomniano o tobie" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 mencionaram-te" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 mencionaram você" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 тебя упомянули" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 du har omnämnts" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 உங்களைக் குறிப்பிட்டுள்ளனர்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 คุณถูกกล่าวถึง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 senden bahsedildi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 тебе згадали" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 آپ کا ذکر ہوا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 bạn được nhắc tới" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 你被提及" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "🫵 你被提及" + } + } + } + }, "recording %@" : { "comment" : "Voice note recording duration indicator", "localizations" : { @@ -68084,6 +71655,192 @@ } } }, + "system.tor.blocked" : { + "comment" : "System message shown when Tor bootstrap runs out its deadline without connecting, which is what a network that blocks Tor looks like", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لم يتمكن tor من الاتصال — قد تكون هذه الشبكة تحجبه. المراسلة عبر mesh لا تزال تعمل؛ أما قنوات الموقع والتسليم عبر الإنترنت فمتوقفة حتى ينجح tor." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tor সংযোগ করতে পারল না — এই নেটওয়ার্ক হয়তো এটি আটকাচ্ছে। মেশে বার্তা পাঠানো এখনও কাজ করে; Tor চালু না হওয়া পর্যন্ত লোকেশন চ্যানেল ও ইন্টারনেটে পাঠানো থেমে থাকবে।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor konnte sich nicht verbinden — dieses netzwerk blockiert es möglicherweise. mesh-nachrichten funktionieren weiter; standortkanäle und zustellung über das internet pausieren, bis tor durchkommt." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor could not connect — this network may be blocking it. mesh messaging still works; location channels and internet delivery are paused until tor gets through." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tor no pudo conectarse — puede que esta red lo esté bloqueando. la mensajería por mesh sigue funcionando; los canales de ubicación y la entrega por internet quedan en pausa hasta que Tor pase." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tor نتوانست وصل شود — شاید این شبکه آن را مسدود کرده است. پیام‌رسانی مش همچنان کار می‌کند؛ کانال‌های موقعیت و تحویل از طریق اینترنت تا عبور Tor متوقف است." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "hindi makakonekta ang tor — maaaring hinaharangan ito ng network na ito. gumagana pa rin ang pagmemensahe sa mesh; nakahinto ang mga channel ng lokasyon at ang paghatid sa internet hanggang makalusot ang tor." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor n'a pas pu se connecter — ce réseau le bloque peut-être. la messagerie mesh fonctionne toujours ; les canaux de localisation et la livraison par internet sont en pause jusqu'à ce que tor passe." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor לא הצליח להתחבר — ייתכן שהרשת הזאת חוסמת אותו. הודעות ב-mesh עדיין עובדות; ערוצי מיקום ומשלוח דרך האינטרנט מושהים עד ש-tor יעבור." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tor कनेक्ट नहीं हो सका — यह नेटवर्क इसे रोक रहा हो सकता है। मेश पर संदेश भेजना अभी भी काम करता है; Tor जुड़ने तक लोकेशन चैनल और इंटरनेट से डिलीवरी रुकी रहेगी।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor tidak bisa terhubung — jaringan ini mungkin memblokirnya. perpesanan mesh tetap jalan; kanal lokasi dan pengiriman lewat internet dijeda sampai tor tembus." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor non è riuscito a connettersi — questa rete potrebbe bloccarlo. i messaggi sulla mesh funzionano ancora; canali posizione e consegna via internet restano in pausa finché tor non passa." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "torが接続できませんでした — このネットワークがブロックしている可能性があります。meshでのやり取りは使えます。ロケーションチャンネルとインターネット経由の配信は、torがつながるまで停止します。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor가 연결되지 않았습니다 — 이 네트워크가 차단하고 있을 수 있습니다. mesh 메시지는 계속 작동하며, 위치 채널과 인터넷 전달은 tor가 연결될 때까지 멈춥니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor tidak dapat menyambung — rangkaian ini mungkin menghalangnya. pemesejan mesh masih berfungsi; kanal lokasi dan penghantaran melalui internet dijeda sehingga tor berjaya." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor जडान हुन सकेन — यो नेटवर्कले रोकेको हुन सक्छ। mesh मा सन्देश पठाउने काम अझै चल्छ; tor नजोडिँदासम्म स्थान च्यानल र इन्टरनेटबाट पठाउने काम रोकिन्छ।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor kon geen verbinding maken — dit netwerk blokkeert het misschien. berichten via de mesh werken nog; locatiekanalen en bezorging via internet staan stil tot tor erdoor komt." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor nie mógł się połączyć — ta sieć może go blokować. wiadomości w mesh nadal działają; kanały lokalizacji i dostarczanie przez internet są wstrzymane, dopóki tor nie przejdzie." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "o Tor não conseguiu ligar-se — esta rede pode estar a bloqueá-lo. as mensagens pela mesh continuam a funcionar; os canais de localização e a entrega pela internet ficam em pausa até o Tor passar." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "o tor não conseguiu conectar — esta rede pode estar bloqueando. as mensagens pelo mesh continuam funcionando; os canais de localização e a entrega pela internet ficam pausados até o tor passar." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor не смог подключиться — возможно, эта сеть его блокирует. сообщения по mesh работают; каналы локации и доставка через интернет приостановлены, пока tor не пробьётся." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor kunde inte ansluta — nätverket kan blockera det. meddelanden över mesh fungerar fortfarande; platskanaler och leverans över internet pausas tills tor kommer igenom." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor இணைய முடியவில்லை — இந்த நெட்வொர்க் அதைத் தடுக்கலாம். mesh வழி செய்திகள் இன்னும் வேலை செய்கின்றன; tor இணையும் வரை இட சேனல்களும் இணையம் வழி வழங்கலும் நிறுத்தப்படும்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor เชื่อมต่อไม่ได้ — เครือข่ายนี้อาจปิดกั้นอยู่ การส่งข้อความผ่าน mesh ยังใช้ได้ ช่องตามตำแหน่งและการส่งผ่านอินเทอร์เน็ตจะหยุดไว้จนกว่า tor จะเชื่อมต่อได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tor bağlanamadı — bu ağ onu engelliyor olabilir. mesh üzerinden mesajlaşma çalışmaya devam ediyor; konum kanalları ve internet üzerinden iletim, Tor geçene kadar duraklatıldı." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor не зміг підключитися — можливо, ця мережа його блокує. повідомлення через mesh працюють; канали локації та доставка через інтернет на паузі, доки tor не проб'ється." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor جڑ نہیں سکا — یہ نیٹ ورک اسے روک رہا ہو سکتا ہے۔ mesh پر پیغام رسانی اب بھی کام کرتی ہے؛ tor کے جڑنے تک لوکیشن چینلز اور انٹرنیٹ سے ڈیلیوری رکی رہے گی۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor không kết nối được — mạng này có thể đang chặn. tin nhắn qua mesh vẫn hoạt động; kênh vị trí và việc gửi qua internet tạm dừng đến khi tor kết nối được." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor 无法连接 — 此网络可能正在封锁它。mesh 消息仍可使用;位置频道和经互联网的投递会暂停,直到 tor 连上。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor 無法連線 — 此網路可能正在封鎖它。mesh 訊息仍可使用;位置頻道和經網際網路的投遞會暫停,直到 tor 連上。" + } + } + } + }, "system.tor.dev_bypass" : { "extractionState" : "manual", "localizations" : { @@ -72717,6 +76474,114 @@ } } } + }, + "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" diff --git a/bitchat/Models/NoisePayload.swift b/bitchat/Models/NoisePayload.swift index 9d88a991..2d2f3c67 100644 --- a/bitchat/Models/NoisePayload.swift +++ b/bitchat/Models/NoisePayload.swift @@ -30,7 +30,7 @@ struct NoisePayload { // Safely get the first byte let firstByte = data[data.startIndex] - guard let type = NoisePayloadType(rawValue: firstByte) else { + guard let type = NoisePayloadType.decoded(rawValue: firstByte) else { return nil } diff --git a/bitchat/Noise/NoiseSecurityConstants.swift b/bitchat/Noise/NoiseSecurityConstants.swift index 67a722b1..ac98eaf6 100644 --- a/bitchat/Noise/NoiseSecurityConstants.swift +++ b/bitchat/Noise/NoiseSecurityConstants.swift @@ -6,14 +6,60 @@ // For more information, see // +import BitFoundation import Foundation enum NoiseSecurityConstants { // Maximum message size to prevent memory exhaustion static let maxMessageSize = 65535 // 64KB as per Noise spec + + /// The extracted transport nonce (4 bytes) and Poly1305 tag (16 bytes) + /// added by `NoiseCipherState` around every transport plaintext. + static let transportCiphertextOverhead = 20 + + /// Private files are an explicit BitChat extension to the ordinary Noise + /// message-size ceiling. They remain bounded by the same framed-file cap + /// used by the binary and fragment decoders. Only the `.privateFile` + /// typed-payload path is allowed to use this larger budget. + private static let privateFileOuterPacketOverhead = + (BinaryProtocol.v1HeaderSize + 2) // v2 adds two length bytes + + BinaryProtocol.senderIDSize + + BinaryProtocol.recipientIDSize + static let maxPrivateFilePlaintextSize = FileTransferLimits.maxFramedFileBytes + - privateFileOuterPacketOverhead + - transportCiphertextOverhead + static let maxPrivateFileCiphertextSize = + maxPrivateFilePlaintextSize + transportCiphertextOverhead // Maximum handshake message size static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern + + // Noise XX message 1 contains only the initiator's 32-byte ephemeral key. + static let xxInitialMessageSize = 32 + + // Bounds an ordinary initiator whose message 1 or 2 is lost. + static let ordinaryHandshakeTimeout: TimeInterval = 10 + + // Bounds the receive-only rollback quarantine created by an unauthenticated + // inbound message 1. A lost message 3 must not strand outbound traffic. + static let ordinaryResponderHandshakeTimeout: TimeInterval = 20 + + // A released client may immediately retry after both crossed initiators + // yielded. Give that unilateral retry a brief head start before the + // patched side spends its one bounded recovery. + static let handshakeCollisionRecoveryDelay: TimeInterval = 0.2 + + // Rate-limited recovery remains actionable without spinning. + static let handshakeRateLimitRecoveryDelay: TimeInterval = 60 + + // Covers only reordering between a winning message 3 and the losing + // crossed message 1. + static let recentInitiatorCompletionGracePeriod: TimeInterval = 1 + + // After unauthenticated responder rollback, reject another attempt long + // enough that paced message 1 traffic cannot keep outbound paused. A + // legitimate peer converges through the one manager-owned local retry. + static let ordinaryReconnectRollbackCooldown: TimeInterval = 60 // Session timeout - sessions older than this should be renegotiated static let sessionTimeout: TimeInterval = 86400 // 24 hours diff --git a/bitchat/Noise/NoiseSecurityValidator.swift b/bitchat/Noise/NoiseSecurityValidator.swift index 355d8fd7..e9028199 100644 --- a/bitchat/Noise/NoiseSecurityValidator.swift +++ b/bitchat/Noise/NoiseSecurityValidator.swift @@ -14,6 +14,19 @@ struct NoiseSecurityValidator { static func validateMessageSize(_ data: Data) -> Bool { return data.count <= NoiseSecurityConstants.maxMessageSize } + + static func validateCiphertextSize(_ data: Data) -> Bool { + data.count <= NoiseSecurityConstants.maxMessageSize + + NoiseSecurityConstants.transportCiphertextOverhead + } + + static func validatePrivateFileMessageSize(_ data: Data) -> Bool { + data.count <= NoiseSecurityConstants.maxPrivateFilePlaintextSize + } + + static func validatePrivateFileCiphertextSize(_ data: Data) -> Bool { + data.count <= NoiseSecurityConstants.maxPrivateFileCiphertextSize + } /// Validate handshake message size static func validateHandshakeMessageSize(_ data: Data) -> Bool { diff --git a/bitchat/Noise/NoiseSessionError.swift b/bitchat/Noise/NoiseSessionError.swift index 24e172e7..ad098bbc 100644 --- a/bitchat/Noise/NoiseSessionError.swift +++ b/bitchat/Noise/NoiseSessionError.swift @@ -11,4 +11,11 @@ enum NoiseSessionError: Error, Equatable { case notEstablished case sessionNotFound case alreadyEstablished + case peerIdentityMismatch +} + +/// The manager owns the exact attempt's one bounded recovery. Packet handling +/// must not launch its historical second, immediate restart for this failure. +struct NoiseManagedHandshakeFailure: Error { + let underlying: Error } diff --git a/bitchat/Noise/NoiseSessionManager.swift b/bitchat/Noise/NoiseSessionManager.swift index 619f84ac..e8609aed 100644 --- a/bitchat/Noise/NoiseSessionManager.swift +++ b/bitchat/Noise/NoiseSessionManager.swift @@ -11,16 +11,107 @@ import CryptoKit import Foundation import BitFoundation +struct NoiseHandshakeProcessingResult { + let response: Data? + let didEstablishAuthenticatedSession: Bool +} + +struct NoiseHandshakeInitiation: Equatable, Sendable { + let payload: Data + let attemptID: UUID +} + +struct NoiseHandshakeRecoveryRequest: Equatable, Sendable { + let peerID: PeerID + fileprivate let recoveryID: UUID +} + +enum NoiseHandshakeRecoveryPreparation { + case ordinary(NoiseHandshakeInitiation) + case transferred +} + +/// Why a quarantined transport became the active session again. +enum NoiseSessionRestoreReason: Equatable, Sendable { + /// The replacement attempt failed terminally (claimed-identity mismatch, + /// or a failure that owns no convergence retry). The counterpart never + /// finished replacement keys, so the restored generation is immediately + /// valid for outbound traffic. + case terminal + /// The responder window expired — or a recoverable failure occurred — + /// and this manager owns one mandatory convergence retry. The counterpart + /// may already hold replacement keys that discarded the restored ones, so + /// outbound queue drains must wait for the retry to conclude. + case pendingConvergence +} + final class NoiseSessionManager { private var sessions: [PeerID: NoiseSession] = [:] + /// Opaque identity for each exact entry in `sessions`. The generation is + /// created and removed under the same barrier as the session itself, so a + /// caller can never authenticate data with one session and lease another. + private var sessionGenerations: [PeerID: UUID] = [:] + /// One-time handoff tokens prevent a prepared XX message 1 from leaving + /// after an inbound collision has already changed this peer's role. + private var ordinaryInitiationIDs: [PeerID: UUID] = [:] + private var ordinaryInitiatorTimeouts: [PeerID: DispatchWorkItem] = [:] + private var ordinaryInitiatorRetryNotifications: [PeerID: Bool] = [:] + private var ordinaryResponderTimeouts: [PeerID: DispatchWorkItem] = [:] + private var ordinaryResponderDeadlines: [PeerID: DispatchTime] = [:] + private var ordinaryResponderRetryNotifications: [PeerID: Bool] = [:] + private var ordinaryRespondersCreatedByYield: Set = [] + private var recentOrdinaryInitiatorCompletions: [PeerID: Date] = [:] + private struct QuarantinedTransport { + let session: NoiseSession + let generation: UUID + /// Duplicate message 1 packets replace the incomplete responder but + /// never extend the original rollback window indefinitely. + let rollbackDeadline: DispatchTime + } + /// An unauthenticated inbound message 1 cannot keep old sending keys live, + /// but it also must not permanently destroy a victim session. The old + /// transport remains receive-only while the ordinary responder proves the + /// claimed static identity, then is discarded on success or restored on + /// bounded failure. + private var quarantinedTransports: [PeerID: QuarantinedTransport] = [:] + private var quarantineRollbackCooldownUntil: [PeerID: Date] = [:] + private var suppressedInitiationRecoveryTimeouts: [PeerID: DispatchWorkItem] = [:] + private var delayedHandshakeRecoveryWorkItems: [PeerID: DispatchWorkItem] = [:] + private var handshakeRecoveryCallbackIDs: [PeerID: UUID] = [:] + private var pendingHandshakeRecoveryIDs: [PeerID: UUID] = [:] private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession + private let localPeerID: PeerID + private let ordinaryHandshakeTimeout: TimeInterval + private let ordinaryResponderHandshakeTimeout: TimeInterval + private let recentInitiatorCompletionGracePeriod: TimeInterval + private let ordinaryReconnectRollbackCooldown: TimeInterval private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent) // Callbacks - var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)? + var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey, UUID) -> Void)? + var onSessionRestored: ((PeerID, UUID, NoiseSessionRestoreReason) -> Void)? var onSessionFailed: ((PeerID, Error) -> Void)? + var onHandshakeRecoveryRequired: ((NoiseHandshakeRecoveryRequest) -> Void)? - init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) { + init( + localStaticKey: Curve25519.KeyAgreement.PrivateKey, + keychain: KeychainManagerProtocol, + ordinaryHandshakeTimeout: TimeInterval = + NoiseSecurityConstants.ordinaryHandshakeTimeout, + ordinaryResponderHandshakeTimeout: TimeInterval = + NoiseSecurityConstants.ordinaryResponderHandshakeTimeout, + recentInitiatorCompletionGracePeriod: TimeInterval = + NoiseSecurityConstants.recentInitiatorCompletionGracePeriod, + ordinaryReconnectRollbackCooldown: TimeInterval = + NoiseSecurityConstants.ordinaryReconnectRollbackCooldown + ) { + self.localPeerID = PeerID(publicKey: localStaticKey.publicKey.rawRepresentation) + self.ordinaryHandshakeTimeout = ordinaryHandshakeTimeout + self.ordinaryResponderHandshakeTimeout = ordinaryResponderHandshakeTimeout + self.recentInitiatorCompletionGracePeriod = + recentInitiatorCompletionGracePeriod + self.ordinaryReconnectRollbackCooldown = + ordinaryReconnectRollbackCooldown self.sessionFactory = { peerID, role in SecureNoiseSession( peerID: peerID, @@ -33,10 +124,25 @@ final class NoiseSessionManager { #if DEBUG init( - localStaticKey _: Curve25519.KeyAgreement.PrivateKey, + localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain _: KeychainManagerProtocol, + ordinaryHandshakeTimeout: TimeInterval = + NoiseSecurityConstants.ordinaryHandshakeTimeout, + ordinaryResponderHandshakeTimeout: TimeInterval = + NoiseSecurityConstants.ordinaryResponderHandshakeTimeout, + recentInitiatorCompletionGracePeriod: TimeInterval = + NoiseSecurityConstants.recentInitiatorCompletionGracePeriod, + ordinaryReconnectRollbackCooldown: TimeInterval = + NoiseSecurityConstants.ordinaryReconnectRollbackCooldown, sessionFactory: @escaping (PeerID, NoiseRole) -> NoiseSession ) { + self.localPeerID = PeerID(publicKey: localStaticKey.publicKey.rawRepresentation) + self.ordinaryHandshakeTimeout = ordinaryHandshakeTimeout + self.ordinaryResponderHandshakeTimeout = ordinaryResponderHandshakeTimeout + self.recentInitiatorCompletionGracePeriod = + recentInitiatorCompletionGracePeriod + self.ordinaryReconnectRollbackCooldown = + ordinaryReconnectRollbackCooldown self.sessionFactory = sessionFactory } #endif @@ -48,21 +154,180 @@ final class NoiseSessionManager { return sessions[peerID] } } + + /// Whether this peer has an inbound ordinary XX responder that still + /// needs message 3 before its receive keys can become authoritative. + func isAwaitingResponderHandshakeCompletion(for peerID: PeerID) -> Bool { + managerQueue.sync { + guard let session = sessions[peerID] else { return false } + return session.role == .responder + && session.getState() == .handshaking + } + } + + /// Transfers one bounded recovery generation to whatever ordinary XX + /// handshake currently owns the peer, or starts the generation's single + /// retry. The request token prevents stale transport callbacks from + /// creating additional attempts. + func prepareHandshakeRecovery( + _ request: NoiseHandshakeRecoveryRequest, + authorizeAttempt: () throws -> Void + ) throws -> NoiseHandshakeRecoveryPreparation? { + try managerQueue.sync(flags: .barrier) { + let peerID = request.peerID + guard pendingHandshakeRecoveryIDs[peerID] == request.recoveryID else { + return nil + } + + if let current = sessions[peerID], + current.getState() == .handshaking { + if current.role == .initiator { + scheduleOrdinaryInitiatorTimeoutLocked( + current, + for: peerID, + notifyOnTimeout: true + ) + } else { + // A recovery may transfer to a responder that raced ahead + // of the callback. Preserve an existing quarantine deadline + // so duplicate unauthenticated message 1 packets cannot + // extend the outbound pause. + scheduleOrdinaryResponderTimeoutLocked( + current, + for: peerID, + notifyOnTimeout: true, + createdByYield: true, + rearmDeadline: quarantinedTransports[peerID] == nil + ) + } + return .transferred + } + + do { + try authorizeAttempt() + } catch { + redispatchHandshakeRecoveryLocked( + request, + after: NoiseSecurityConstants.handshakeRateLimitRecoveryDelay + ) + throw error + } + + let next = sessionFactory(peerID, .initiator) + let payload: Data + do { + payload = try next.startHandshake() + } catch { + next.reset() + redispatchHandshakeRecoveryLocked( + request, + after: NoiseSecurityConstants.handshakeCollisionRecoveryDelay + ) + throw error + } + + // Start before retiring a working transport. If preparation fails, + // the established session and its generation are untouched. + if let current = sessions.removeValue(forKey: peerID) { + current.reset() + } + if let quarantined = quarantinedTransports.removeValue(forKey: peerID) { + quarantined.session.reset() + } + sessionGenerations.removeValue(forKey: peerID) + ordinaryInitiationIDs.removeValue(forKey: peerID) + cancelOrdinaryInitiatorTimeoutLocked(for: peerID) + cancelOrdinaryResponderTimeoutLocked(for: peerID) + cancelSuppressedInitiationRecoveryLocked(for: peerID) + recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID) + quarantineRollbackCooldownUntil.removeValue(forKey: peerID) + + let attemptID = UUID() + sessions[peerID] = next + sessionGenerations[peerID] = UUID() + ordinaryInitiationIDs[peerID] = attemptID + // This is the one retry owned by `request`; it may time out but + // must not recursively mint another generation. + scheduleOrdinaryInitiatorTimeoutLocked( + next, + for: peerID, + notifyOnTimeout: false + ) + consumeHandshakeRecoveryLocked(request) + return .ordinary( + NoiseHandshakeInitiation( + payload: payload, + attemptID: attemptID + ) + ) + } + } + + func cancelHandshakeRecovery(_ request: NoiseHandshakeRecoveryRequest) { + managerQueue.sync(flags: .barrier) { + consumeHandshakeRecoveryLocked(request) + } + } func removeSession(for peerID: PeerID) { managerQueue.sync(flags: .barrier) { - if let session = sessions.removeValue(forKey: peerID) { - session.reset() // Clear sensitive data before removing - } + removeSessionLocked(for: peerID) } } + private func removeSessionLocked(for peerID: PeerID) { + if let session = sessions.removeValue(forKey: peerID) { + session.reset() // Clear sensitive data before removing + } + if let quarantined = quarantinedTransports.removeValue(forKey: peerID) { + quarantined.session.reset() + } + sessionGenerations.removeValue(forKey: peerID) + ordinaryInitiationIDs.removeValue(forKey: peerID) + cancelOrdinaryInitiatorTimeoutLocked(for: peerID) + cancelOrdinaryResponderTimeoutLocked(for: peerID) + recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID) + quarantineRollbackCooldownUntil.removeValue(forKey: peerID) + cancelSuppressedInitiationRecoveryLocked(for: peerID) + cancelDelayedHandshakeRecoveryLocked(for: peerID) + } + func removeAllSessions() { managerQueue.sync(flags: .barrier) { for (_, session) in sessions { session.reset() } + for (_, quarantined) in quarantinedTransports { + quarantined.session.reset() + } + for (_, timeout) in ordinaryInitiatorTimeouts { + timeout.cancel() + } + for (_, timeout) in ordinaryResponderTimeouts { + timeout.cancel() + } + for (_, timeout) in suppressedInitiationRecoveryTimeouts { + timeout.cancel() + } + for (_, timeout) in delayedHandshakeRecoveryWorkItems { + timeout.cancel() + } sessions.removeAll() + sessionGenerations.removeAll() + ordinaryInitiationIDs.removeAll() + ordinaryInitiatorTimeouts.removeAll() + ordinaryInitiatorRetryNotifications.removeAll() + ordinaryResponderTimeouts.removeAll() + ordinaryResponderDeadlines.removeAll() + ordinaryResponderRetryNotifications.removeAll() + ordinaryRespondersCreatedByYield.removeAll() + recentOrdinaryInitiatorCompletions.removeAll() + quarantinedTransports.removeAll() + quarantineRollbackCooldownUntil.removeAll() + suppressedInitiationRecoveryTimeouts.removeAll() + delayedHandshakeRecoveryWorkItems.removeAll() + handshakeRecoveryCallbackIDs.removeAll() + pendingHandshakeRecoveryIDs.removeAll() } } @@ -78,109 +343,896 @@ final class NoiseSessionManager { // Remove any existing non-established session if let existingSession = sessions[peerID], !existingSession.isEstablished() { - _ = sessions.removeValue(forKey: peerID) + removeSessionLocked(for: peerID) } + recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID) // Create new initiator session let session = sessionFactory(peerID, .initiator) sessions[peerID] = session + sessionGenerations[peerID] = UUID() do { let handshakeData = try session.startHandshake() + scheduleOrdinaryInitiatorTimeoutLocked( + session, + for: peerID, + notifyOnTimeout: false + ) + cancelDelayedHandshakeRecoveryLocked(for: peerID) return handshakeData } catch { // Clean up failed session _ = sessions.removeValue(forKey: peerID) + sessionGenerations.removeValue(forKey: peerID) + ordinaryInitiationIDs.removeValue(forKey: peerID) + cancelOrdinaryInitiatorTimeoutLocked(for: peerID) + session.reset() SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription)) throw error } } } - - func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? { - // Process everything within the synchronized block to prevent race conditions - return try managerQueue.sync(flags: .barrier) { - var shouldCreateNew = false - var existingSession: NoiseSession? = nil - - if let existing = sessions[peerID] { - // If we have an established session, the peer must have cleared their session - // for a good reason (e.g., decryption failure, restart, etc.) - // We should accept the new handshake to re-establish encryption - if existing.isEstablished() { - SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session) - _ = sessions.removeValue(forKey: peerID) - shouldCreateNew = true - } else { - // If we're in the middle of a handshake and receive a new initiation, - // reset and start fresh (the other side may have restarted) - if existing.getState() == .handshaking && message.count == 32 { - _ = sessions.removeValue(forKey: peerID) - shouldCreateNew = true - } else { - existingSession = existing - } - } - } else { - shouldCreateNew = true + + /// Atomically starts an ordinary initiator only when no other session + /// already owns the peer. BLE discovery can report the same peer on + /// multiple links; combining the absence check, authorization, creation, + /// and handoff token prevents two different message 1 packets escaping. + func initiateHandshakeIfAbsent( + with peerID: PeerID, + notifyOnTimeout: Bool, + authorize: () throws -> Void + ) throws -> NoiseHandshakeInitiation? { + try managerQueue.sync(flags: .barrier) { + guard sessions[peerID] == nil, + quarantinedTransports[peerID] == nil else { + return nil } - - // Get or create session - let session: NoiseSession - if shouldCreateNew { - let newSession = sessionFactory(peerID, .responder) - sessions[peerID] = newSession - session = newSession - } else { - session = existingSession! - } - - // Process the handshake message within the synchronized block + try authorize() + + let session = sessionFactory(peerID, .initiator) do { - let response = try session.processHandshakeMessage(message) - - // Check if session is established after processing - if session.isEstablished() { - if let remoteKey = session.getRemoteStaticPublicKey() { - // Schedule callback outside the synchronized block to prevent deadlock - DispatchQueue.global().async { [weak self] in - self?.onSessionEstablished?(peerID, remoteKey) - } - } - } - - return response + let payload = try session.startHandshake() + let attemptID = UUID() + sessions[peerID] = session + sessionGenerations[peerID] = UUID() + ordinaryInitiationIDs[peerID] = attemptID + recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID) + quarantineRollbackCooldownUntil.removeValue(forKey: peerID) + scheduleOrdinaryInitiatorTimeoutLocked( + session, + for: peerID, + notifyOnTimeout: notifyOnTimeout + ) + cancelDelayedHandshakeRecoveryLocked(for: peerID) + return NoiseHandshakeInitiation( + payload: payload, + attemptID: attemptID + ) } catch { - // Reset the session on handshake failure so next attempt can start fresh - _ = sessions.removeValue(forKey: peerID) - - // Schedule callback outside the synchronized block to prevent deadlock - DispatchQueue.global().async { [weak self] in - self?.onSessionFailed?(peerID, error) - } - - SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription)) + session.reset() + SecureLogger.error( + .handshakeFailed( + peerID: peerID.id, + error: error.localizedDescription + ) + ) throw error } } } + + /// Prepares an ordinary reconnect before atomically retiring the current + /// transport. Authorization and handshake-start failure leave the working + /// session untouched. Once this returns, encryption observes only the new + /// handshaking session and must queue until it establishes. + func initiateReconnectHandshake( + with peerID: PeerID, + notifyOnTimeout: Bool, + authorize: () throws -> Void + ) throws -> NoiseHandshakeInitiation { + try managerQueue.sync(flags: .barrier) { + guard let established = sessions[peerID], + established.isEstablished() else { + throw NoiseSessionError.notEstablished + } + try authorize() + + let next = sessionFactory(peerID, .initiator) + let payload: Data + do { + payload = try next.startHandshake() + } catch { + next.reset() + SecureLogger.error( + .handshakeFailed( + peerID: peerID.id, + error: error.localizedDescription + ) + ) + throw error + } + + // Retire only after the replacement initiator has successfully + // produced message 1. Do not use the broad removal helper here: + // this transition owns the exact new session installed below. + _ = sessions.removeValue(forKey: peerID) + sessionGenerations.removeValue(forKey: peerID) + ordinaryInitiationIDs.removeValue(forKey: peerID) + cancelOrdinaryInitiatorTimeoutLocked(for: peerID) + cancelOrdinaryResponderTimeoutLocked(for: peerID) + cancelSuppressedInitiationRecoveryLocked(for: peerID) + cancelDelayedHandshakeRecoveryLocked(for: peerID) + recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID) + quarantineRollbackCooldownUntil.removeValue(forKey: peerID) + established.reset() + + let attemptID = UUID() + sessions[peerID] = next + sessionGenerations[peerID] = UUID() + ordinaryInitiationIDs[peerID] = attemptID + scheduleOrdinaryInitiatorTimeoutLocked( + next, + for: peerID, + notifyOnTimeout: notifyOnTimeout + ) + return NoiseHandshakeInitiation( + payload: payload, + attemptID: attemptID + ) + } + } + + /// Claims a prepared message 1 exactly once. A crossed inbound initiation + /// that already made this peer a responder invalidates the token. + func claimHandshakeInitiation( + _ initiation: NoiseHandshakeInitiation, + for peerID: PeerID + ) -> Data? { + managerQueue.sync(flags: .barrier) { + guard ordinaryInitiationIDs[peerID] == initiation.attemptID, + let session = sessions[peerID], + session.role == .initiator, + session.getState() == .handshaking else { + return nil + } + ordinaryInitiationIDs.removeValue(forKey: peerID) + let notifyOnTimeout = + ordinaryInitiatorRetryNotifications[peerID] ?? false + // Preparation and on-wire exchange have independent bounds. Once + // BLE owns these exact bytes, give the peer the full response + // window without changing retry ownership. + scheduleOrdinaryInitiatorTimeoutLocked( + session, + for: peerID, + notifyOnTimeout: notifyOnTimeout + ) + return initiation.payload + } + } + + func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? { + try handleIncomingHandshakeWithResult( + from: peerID, + message: message + ).response + } + + /// Processes one exact handshake candidate and reports whether that + /// candidate completed authenticated establishment. The peer's retained + /// session may already be established while a replacement is only on + /// message one, so callers must not infer candidate completion from the + /// peer-level session table. + func handleIncomingHandshakeWithResult( + from peerID: PeerID, + message: Data + ) throws -> NoiseHandshakeProcessingResult { + // Process everything within the synchronized block to prevent race conditions. + // Return establishment metadata and publish the callback only after the + // manager barrier is released, avoiding both a deadlock and a window in + // which `processHandshakeMessage` returns before authentication state. + let result: ( + response: Data?, + establishedSession: ( + remoteKey: Curve25519.KeyAgreement.PublicKey, + generation: UUID + )? + ) = try managerQueue.sync(flags: .barrier) { + var yieldedInitiatorShouldRetry = false + var didYieldLocalInitiator = false + var inheritedResponderShouldRetry = false + var inheritedResponderWasCreatedByYield = false + let existingAtIngress = sessions[peerID] + let isFreshInitiation = + message.count == NoiseSecurityConstants.xxInitialMessageSize + || existingAtIngress == nil + || ( + existingAtIngress?.isEstablished() == true + && message.count + > NoiseSecurityConstants.xxInitialMessageSize + ) + + if isFreshInitiation { + if let cooldownUntil = quarantineRollbackCooldownUntil[peerID] { + if cooldownUntil > Date(), + sessions[peerID]?.isEstablished() == true { + SecureLogger.debug( + "Ignoring unauthenticated reconnect initiation during rollback cooldown for \(peerID)", + category: .session + ) + return (nil, nil) + } + quarantineRollbackCooldownUntil.removeValue(forKey: peerID) + } + + if suppressedInitiationRecoveryTimeouts[peerID] != nil, + localPeerID < peerID.toShort(), + sessions[peerID]?.isEstablished() == true { + SecureLogger.debug( + "Coalescing duplicate initiation while convergence recovery is pending for \(peerID)", + category: .session + ) + return (nil, nil) + } + + if let completedAt = recentOrdinaryInitiatorCompletions[peerID] { + let stillInGrace = Date().timeIntervalSince(completedAt) + < recentInitiatorCompletionGracePeriod + if stillInGrace, + localPeerID < peerID.toShort(), + let established = sessions[peerID], + established.isEstablished() { + recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID) + scheduleSuppressedInitiationRecoveryLocked( + established, + completedAt: completedAt, + for: peerID + ) + SecureLogger.debug( + "Deferring delayed crossed initiation from \(peerID) after initiator completion", + category: .session + ) + return (nil, nil) + } + recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID) + } + + if let ordinaryInitiator = sessions[peerID], + ordinaryInitiator.role == .initiator, + ordinaryInitiator.getState() == .handshaking { + if localPeerID < peerID.toShort() { + SecureLogger.debug( + "Ignoring crossed ordinary initiation from \(peerID); keeping deterministic initiator role", + category: .session + ) + return (nil, nil) + } + yieldedInitiatorShouldRetry = + ordinaryInitiatorRetryNotifications[peerID] ?? false + didYieldLocalInitiator = true + _ = sessions.removeValue(forKey: peerID) + sessionGenerations.removeValue(forKey: peerID) + ordinaryInitiationIDs.removeValue(forKey: peerID) + cancelOrdinaryInitiatorTimeoutLocked(for: peerID) + cancelOrdinaryResponderTimeoutLocked(for: peerID) + ordinaryInitiator.reset() + } + } + + let session: NoiseSession + + if let existing = sessions[peerID] { + if existing.isEstablished(), + !isFreshInitiation { + // An established ordinary XX transport has no remaining + // handshake messages to consume. Unauthenticated garbage + // must not tear down its working keys. + SecureLogger.debug( + "Ignoring non-initial handshake bytes for established peer \(peerID)", + category: .session + ) + return (nil, nil) + } + if isFreshInitiation { + if existing.isEstablished(), + let generation = sessionGenerations[peerID] { + // Message 1 is unauthenticated. Remove the old + // transport from every outbound/generation API now, + // retaining it only as receive-only rollback state. + _ = sessions.removeValue(forKey: peerID) + sessionGenerations.removeValue(forKey: peerID) + ordinaryInitiationIDs.removeValue(forKey: peerID) + cancelOrdinaryInitiatorTimeoutLocked(for: peerID) + cancelOrdinaryResponderTimeoutLocked(for: peerID) + if let prior = quarantinedTransports.updateValue( + QuarantinedTransport( + session: existing, + generation: generation, + rollbackDeadline: .now() + + ordinaryResponderHandshakeTimeout + ), + forKey: peerID + ) { + prior.session.reset() + } + } else { + inheritedResponderShouldRetry = + ordinaryResponderRetryNotifications[peerID] ?? false + inheritedResponderWasCreatedByYield = + ordinaryRespondersCreatedByYield.contains(peerID) + _ = sessions.removeValue(forKey: peerID) + sessionGenerations.removeValue(forKey: peerID) + ordinaryInitiationIDs.removeValue(forKey: peerID) + // Keep the first responder/quarantine deadline. A + // repeated message 1 may refresh message 2, but never + // refreshes the attacker's outbound-pause budget. + cancelOrdinaryResponderTimeoutLocked( + for: peerID, + preserveDeadline: true + ) + existing.reset() + } + + let replacement = sessionFactory(peerID, .responder) + sessions[peerID] = replacement + sessionGenerations[peerID] = UUID() + session = replacement + } else { + session = existing + } + } else { + let newSession = sessionFactory(peerID, .responder) + sessions[peerID] = newSession + sessionGenerations[peerID] = UUID() + session = newSession + } + + do { + if isFreshInitiation, + session.role == .responder { + scheduleOrdinaryResponderTimeoutLocked( + session, + for: peerID, + notifyOnTimeout: + yieldedInitiatorShouldRetry + || inheritedResponderShouldRetry, + createdByYield: + didYieldLocalInitiator + || inheritedResponderWasCreatedByYield + ) + } + + let response = try session.processHandshakeMessage(message) + + // Check the exact session that processed this message. A + // preserved peer-level session can remain established while a + // replacement candidate is still unauthenticated. + var establishedSession: ( + remoteKey: Curve25519.KeyAgreement.PublicKey, + generation: UUID + )? + if session.isEstablished() { + guard let remoteKey = session.getRemoteStaticPublicKey(), + authenticatedRemoteKey(remoteKey, matches: peerID) else { + throw NoiseSessionError.peerIdentityMismatch + } + + if let quarantined = quarantinedTransports.removeValue( + forKey: peerID + ) { + quarantined.session.reset() + } + ordinaryInitiationIDs.removeValue(forKey: peerID) + cancelOrdinaryInitiatorTimeoutLocked(for: peerID) + cancelOrdinaryResponderTimeoutLocked(for: peerID) + quarantineRollbackCooldownUntil.removeValue(forKey: peerID) + if session.role == .initiator { + recentOrdinaryInitiatorCompletions[peerID] = Date() + } else { + recentOrdinaryInitiatorCompletions.removeValue( + forKey: peerID + ) + } + cancelSuppressedInitiationRecoveryLocked(for: peerID) + cancelDelayedHandshakeRecoveryLocked(for: peerID) + guard let generation = sessionGenerations[peerID] else { + throw NoiseEncryptionError.sessionNotEstablished + } + establishedSession = (remoteKey, generation) + } + + return (response, establishedSession) + } catch { + var shouldRequestRecovery = false + var shouldSuppressImmediateHandlerRestart = false + if session.role == .initiator { + shouldRequestRecovery = + ordinaryInitiatorRetryNotifications[peerID] ?? false + shouldSuppressImmediateHandlerRestart = true + } else { + shouldRequestRecovery = + ordinaryResponderRetryNotifications[peerID] ?? false + shouldSuppressImmediateHandlerRestart = + ordinaryRespondersCreatedByYield.contains(peerID) + } + + if let storedSession = sessions[peerID], + storedSession === session { + _ = sessions.removeValue(forKey: peerID) + sessionGenerations.removeValue(forKey: peerID) + } + ordinaryInitiationIDs.removeValue(forKey: peerID) + cancelOrdinaryInitiatorTimeoutLocked(for: peerID) + cancelOrdinaryResponderTimeoutLocked(for: peerID) + recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID) + session.reset() + + let isIdentityMismatch = + (error as? NoiseSessionError) == .peerIdentityMismatch + + let restoredGeneration: UUID? + if let quarantined = quarantinedTransports.removeValue(forKey: peerID) { + sessions[peerID] = quarantined.session + sessionGenerations[peerID] = quarantined.generation + restoredGeneration = quarantined.generation + markRollbackCooldownLocked(for: peerID) + } else { + restoredGeneration = nil + } + + // An identity mismatch is terminal: the counterpart failed to + // prove the claimed static key, so it never finished keys that + // could have replaced the restored ones. Every other restore + // that owns (or joins) a convergence retry must keep transport + // queues parked until that retry concludes — the counterpart + // may already have discarded the restored sending keys. + let restoreReason: NoiseSessionRestoreReason = + !isIdentityMismatch + && (shouldRequestRecovery + || pendingHandshakeRecoveryIDs[peerID] != nil) + ? .pendingConvergence + : .terminal + + // Schedule callback outside the synchronized block to prevent deadlock + DispatchQueue.global().async { [weak self] in + if let restoredGeneration { + self?.onSessionRestored?(peerID, restoredGeneration, restoreReason) + } + self?.onSessionFailed?(peerID, error) + } + if pendingHandshakeRecoveryIDs[peerID] != nil { + shouldSuppressImmediateHandlerRestart = true + } + if shouldRequestRecovery, !isIdentityMismatch { + requestHandshakeRecovery( + for: peerID, + after: NoiseSecurityConstants + .handshakeCollisionRecoveryDelay + ) + } else if isIdentityMismatch, + let recoveryID = pendingHandshakeRecoveryIDs[peerID] { + redispatchHandshakeRecoveryLocked( + NoiseHandshakeRecoveryRequest( + peerID: peerID, + recoveryID: recoveryID + ), + after: NoiseSecurityConstants + .handshakeCollisionRecoveryDelay + ) + } + + SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription)) + if shouldSuppressImmediateHandlerRestart, !isIdentityMismatch { + throw NoiseManagedHandshakeFailure(underlying: error) + } + throw error + } + } + + if let established = result.establishedSession { + onSessionEstablished?(peerID, established.remoteKey, established.generation) + } + return NoiseHandshakeProcessingResult( + response: result.response, + didEstablishAuthenticatedSession: result.establishedSession != nil + ) + } + + private func scheduleOrdinaryInitiatorTimeoutLocked( + _ session: NoiseSession, + for peerID: PeerID, + notifyOnTimeout: Bool + ) { + cancelOrdinaryInitiatorTimeoutLocked(for: peerID) + ordinaryInitiatorRetryNotifications[peerID] = notifyOnTimeout + let timeout = DispatchWorkItem(flags: .barrier) { [weak self, weak session] in + guard let self, + let session, + let current = self.sessions[peerID], + current === session, + current.role == .initiator, + current.getState() == .handshaking else { + return + } + + _ = self.sessions.removeValue(forKey: peerID) + self.sessionGenerations.removeValue(forKey: peerID) + self.ordinaryInitiationIDs.removeValue(forKey: peerID) + self.ordinaryInitiatorTimeouts.removeValue(forKey: peerID) + self.ordinaryInitiatorRetryNotifications.removeValue(forKey: peerID) + self.recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID) + session.reset() + SecureLogger.debug( + "Ordinary initiator handshake with \(peerID) timed out", + category: .session + ) + + if notifyOnTimeout { + self.requestHandshakeRecovery(for: peerID) + } + } + ordinaryInitiatorTimeouts[peerID] = timeout + managerQueue.asyncAfter( + deadline: .now() + ordinaryHandshakeTimeout, + execute: timeout + ) + } + + private func cancelOrdinaryInitiatorTimeoutLocked(for peerID: PeerID) { + ordinaryInitiatorTimeouts.removeValue(forKey: peerID)?.cancel() + ordinaryInitiatorRetryNotifications.removeValue(forKey: peerID) + } + + private func scheduleOrdinaryResponderTimeoutLocked( + _ session: NoiseSession, + for peerID: PeerID, + notifyOnTimeout: Bool, + createdByYield: Bool, + rearmDeadline: Bool = false + ) { + let previousDeadline = ordinaryResponderDeadlines[peerID] + cancelOrdinaryResponderTimeoutLocked( + for: peerID, + preserveDeadline: true + ) + ordinaryResponderRetryNotifications[peerID] = notifyOnTimeout + if createdByYield { + ordinaryRespondersCreatedByYield.insert(peerID) + } + + let deadline: DispatchTime + if let rollbackDeadline = + quarantinedTransports[peerID]?.rollbackDeadline { + deadline = rollbackDeadline + } else if !rearmDeadline, let previousDeadline { + deadline = previousDeadline + } else { + deadline = .now() + ordinaryResponderHandshakeTimeout + } + ordinaryResponderDeadlines[peerID] = deadline + + let timeout = DispatchWorkItem(flags: .barrier) { [weak self, weak session] in + guard let self, + let session, + let current = self.sessions[peerID], + current === session, + current.role == .responder, + current.getState() == .handshaking else { + return + } + + _ = self.sessions.removeValue(forKey: peerID) + self.sessionGenerations.removeValue(forKey: peerID) + self.ordinaryInitiationIDs.removeValue(forKey: peerID) + self.ordinaryResponderTimeouts.removeValue(forKey: peerID) + self.ordinaryResponderDeadlines.removeValue(forKey: peerID) + self.ordinaryResponderRetryNotifications.removeValue(forKey: peerID) + self.ordinaryRespondersCreatedByYield.remove(peerID) + self.recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID) + session.reset() + + let restored: QuarantinedTransport? + if let quarantined = + self.quarantinedTransports.removeValue(forKey: peerID) { + self.sessions[peerID] = quarantined.session + self.sessionGenerations[peerID] = quarantined.generation + self.markRollbackCooldownLocked(for: peerID) + restored = quarantined + } else { + restored = nil + } + + SecureLogger.debug( + restored == nil + ? "Ordinary responder handshake with \(peerID) timed out" + : "Ordinary responder handshake with \(peerID) timed out; restored quarantined transport", + category: .session + ) + if let restored { + // The mandatory convergence retry below owns the outbound + // resume: the timed-out counterpart may hold replacement keys + // that already discarded the restored generation's, so queue + // drains under it would be silently undecryptable. + DispatchQueue.global().async { [weak self] in + self?.onSessionRestored?( + peerID, + restored.generation, + .pendingConvergence + ) + } + } + + // A rollback always owns one local convergence attempt. The retry + // retires the restored session atomically, so an attacker cannot + // pace unauthenticated message 1 packets to pause outbound forever. + if notifyOnTimeout || restored != nil { + self.requestHandshakeRecovery(for: peerID) + } + } + ordinaryResponderTimeouts[peerID] = timeout + managerQueue.asyncAfter(deadline: deadline, execute: timeout) + } + + private func cancelOrdinaryResponderTimeoutLocked( + for peerID: PeerID, + preserveDeadline: Bool = false + ) { + ordinaryResponderTimeouts.removeValue(forKey: peerID)?.cancel() + if !preserveDeadline { + ordinaryResponderDeadlines.removeValue(forKey: peerID) + } + ordinaryResponderRetryNotifications.removeValue(forKey: peerID) + ordinaryRespondersCreatedByYield.remove(peerID) + } + + private func markRollbackCooldownLocked(for peerID: PeerID) { + quarantineRollbackCooldownUntil[peerID] = + Date().addingTimeInterval(ordinaryReconnectRollbackCooldown) + } + + private func scheduleSuppressedInitiationRecoveryLocked( + _ establishedSession: NoiseSession, + completedAt: Date, + for peerID: PeerID + ) { + cancelSuppressedInitiationRecoveryLocked(for: peerID) + let elapsed = max(0, Date().timeIntervalSince(completedAt)) + let remainingGrace = max( + 0, + recentInitiatorCompletionGracePeriod - elapsed + ) + let timeout = DispatchWorkItem(flags: .barrier) { + [weak self, weak establishedSession] in + guard let self, + let establishedSession, + let current = self.sessions[peerID], + current === establishedSession, + current.isEstablished() else { + return + } + + self.suppressedInitiationRecoveryTimeouts.removeValue( + forKey: peerID + ) + self.requestHandshakeRecovery(for: peerID) + } + suppressedInitiationRecoveryTimeouts[peerID] = timeout + managerQueue.asyncAfter( + deadline: .now() + remainingGrace, + execute: timeout + ) + } + + private func cancelSuppressedInitiationRecoveryLocked(for peerID: PeerID) { + suppressedInitiationRecoveryTimeouts.removeValue(forKey: peerID)? + .cancel() + } + + private func requestHandshakeRecovery( + for peerID: PeerID, + after delay: TimeInterval = 0 + ) { + cancelDelayedHandshakeRecoveryLocked(for: peerID) + let request = NoiseHandshakeRecoveryRequest( + peerID: peerID, + recoveryID: UUID() + ) + pendingHandshakeRecoveryIDs[peerID] = request.recoveryID + scheduleHandshakeRecoveryCallbackLocked(request, after: delay) + } + + private func redispatchHandshakeRecoveryLocked( + _ request: NoiseHandshakeRecoveryRequest, + after delay: TimeInterval + ) { + guard pendingHandshakeRecoveryIDs[request.peerID] + == request.recoveryID else { + return + } + scheduleHandshakeRecoveryCallbackLocked(request, after: delay) + } + + private func scheduleHandshakeRecoveryCallbackLocked( + _ request: NoiseHandshakeRecoveryRequest, + after delay: TimeInterval + ) { + let peerID = request.peerID + guard pendingHandshakeRecoveryIDs[peerID] == request.recoveryID else { + return + } + + delayedHandshakeRecoveryWorkItems.removeValue(forKey: peerID)?.cancel() + let callbackID = UUID() + handshakeRecoveryCallbackIDs[peerID] = callbackID + let callback = DispatchWorkItem(flags: .barrier) { [weak self] in + guard let self, + self.pendingHandshakeRecoveryIDs[peerID] + == request.recoveryID, + self.handshakeRecoveryCallbackIDs[peerID] == callbackID else { + return + } + self.delayedHandshakeRecoveryWorkItems.removeValue(forKey: peerID) + self.handshakeRecoveryCallbackIDs.removeValue(forKey: peerID) + let handler = self.onHandshakeRecoveryRequired + DispatchQueue.global().async { + handler?(request) + } + } + delayedHandshakeRecoveryWorkItems[peerID] = callback + managerQueue.asyncAfter( + deadline: .now() + max(0, delay), + execute: callback + ) + } + + private func consumeHandshakeRecoveryLocked( + _ request: NoiseHandshakeRecoveryRequest + ) { + let peerID = request.peerID + guard pendingHandshakeRecoveryIDs[peerID] == request.recoveryID else { + return + } + delayedHandshakeRecoveryWorkItems.removeValue(forKey: peerID)?.cancel() + handshakeRecoveryCallbackIDs.removeValue(forKey: peerID) + pendingHandshakeRecoveryIDs.removeValue(forKey: peerID) + } + + private func cancelDelayedHandshakeRecoveryLocked(for peerID: PeerID) { + delayedHandshakeRecoveryWorkItems.removeValue(forKey: peerID)?.cancel() + handshakeRecoveryCallbackIDs.removeValue(forKey: peerID) + pendingHandshakeRecoveryIDs.removeValue(forKey: peerID) + } + + /// Mesh handshakes normally use a 16-hex wire ID. Full Noise-key IDs are + /// also accepted by internal callers when they exactly match the static + /// key. Non-wire identifiers remain available to protocol test harnesses; + /// BLE packet ingress always supplies a short hexadecimal ID. + private func authenticatedRemoteKey( + _ remoteKey: Curve25519.KeyAgreement.PublicKey, + matches claimedPeerID: PeerID + ) -> Bool { + let rawKey = remoteKey.rawRepresentation + if claimedPeerID.isShort { + return PeerID(publicKey: rawKey) == claimedPeerID + } + if let claimedNoiseKey = claimedPeerID.noiseKey { + return claimedNoiseKey == rawKey + } + return true + } // MARK: - Encryption/Decryption func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data { - guard let session = getSession(for: peerID) else { - throw NoiseSessionError.sessionNotFound + try managerQueue.sync { + guard let session = sessions[peerID] else { + throw NoiseSessionError.sessionNotFound + } + return try session.encrypt(plaintext) + } + } + + /// Encrypts only if `expected` still names the current established entry. + /// A rekey between capability proof and media encryption therefore fails + /// closed instead of sending on an unproven reconnect session. + func encrypt( + _ plaintext: Data, + for peerID: PeerID, + expectedSessionGeneration expected: UUID + ) throws -> Data { + try managerQueue.sync { + guard let session = sessions[peerID], + session.isEstablished(), + sessionGenerations[peerID] == expected else { + throw NoiseEncryptionError.sessionNotEstablished + } + return try session.encrypt(plaintext) } - - return try session.encrypt(plaintext) } func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data { - guard let session = getSession(for: peerID) else { - throw NoiseSessionError.sessionNotFound + try decryptWithSessionGeneration(ciphertext, from: peerID).plaintext + } + + func sessionGeneration(for peerID: PeerID) -> UUID? { + managerQueue.sync { + guard sessions[peerID]?.isEstablished() == true else { return nil } + return sessionGenerations[peerID] + } + } + + /// Decrypts while holding the manager's read lease. Session promotion and + /// removal require its barrier, so the returned generation always names + /// the exact session object that authenticated these bytes. + func decryptWithSessionGeneration( + _ ciphertext: Data, + from peerID: PeerID, + establishedGenerationIsReady: (UUID) -> Bool = { _ in true }, + authorizeDecrypt: () throws -> Void = {} + ) throws -> (plaintext: Data, sessionGeneration: UUID) { + try managerQueue.sync { + if let session = sessions[peerID], + session.isEstablished(), + let generation = sessionGenerations[peerID] { + // Keep the generation lease across the transport-readiness + // check and decrypt. Promotion/restoration needs this queue's + // barrier, so no new receive nonce can be consumed before BLE + // installs state for the exact generation. + guard establishedGenerationIsReady(generation) else { + throw NoiseEncryptionError.transportGenerationNotReady + } + try authorizeDecrypt() + return (try session.decrypt(ciphertext), generation) + } + + // Quarantine is receive-only: old keys cannot encrypt, advertise + // an established generation, or authorize outbound state, but + // legitimate in-flight ciphertext from the retained peer may + // still advance and later resume on rollback. + if let responder = sessions[peerID], + responder.role == .responder, + responder.getState() == .handshaking, + let quarantined = quarantinedTransports[peerID] { + try authorizeDecrypt() + return ( + try quarantined.session.decrypt(ciphertext), + quarantined.generation + ) + } + + if sessions[peerID] == nil, quarantinedTransports[peerID] == nil { + throw NoiseSessionError.sessionNotFound + } + throw NoiseEncryptionError.sessionNotEstablished + } + } + + func hasReceiveSession(for peerID: PeerID) -> Bool { + managerQueue.sync { + if sessions[peerID]?.isEstablished() == true { + return true + } + guard let responder = sessions[peerID], + responder.role == .responder, + responder.getState() == .handshaking else { + return false + } + return quarantinedTransports[peerID]?.session.isEstablished() == true + } + } + + /// Runs a state commit under a read lease for the exact established + /// session. Rekey, reconnect, and removal all need the same barrier. + func withCurrentSessionGeneration( + for peerID: PeerID, + expected: UUID, + _ body: () -> Result + ) -> Result? { + managerQueue.sync { + guard sessions[peerID]?.isEstablished() == true, + sessionGenerations[peerID] == expected else { return nil } + return body() } - - return try session.decrypt(ciphertext) } // MARK: - Key Management @@ -207,11 +1259,11 @@ final class NoiseSessionManager { } } - func initiateRekey(for peerID: PeerID) throws { - // Remove old session - removeSession(for: peerID) - - // Initiate new handshake - _ = try initiateHandshake(with: peerID) + func initiateRekey(for peerID: PeerID) throws -> NoiseHandshakeInitiation { + try initiateReconnectHandshake( + with: peerID, + notifyOnTimeout: true, + authorize: {} + ) } } diff --git a/bitchat/Noise/SecureNoiseSession.swift b/bitchat/Noise/SecureNoiseSession.swift index 0204b108..8f95842c 100644 --- a/bitchat/Noise/SecureNoiseSession.swift +++ b/bitchat/Noise/SecureNoiseSession.swift @@ -24,8 +24,12 @@ final class SecureNoiseSession: NoiseSession { throw NoiseSecurityError.sessionExhausted } - // Validate message size - guard NoiseSecurityValidator.validateMessageSize(plaintext) else { + // Ordinary Noise messages keep the protocol ceiling. Finalized media + // is the sole typed-payload extension and remains under the framed-file + // cap enforced again at the service and file-decoder layers. + let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: plaintext.first) + && NoiseSecurityValidator.validatePrivateFileMessageSize(plaintext) + guard NoiseSecurityValidator.validateMessageSize(plaintext) || isPrivateFile else { throw NoiseSecurityError.messageTooLarge } @@ -42,8 +46,11 @@ final class SecureNoiseSession: NoiseSession { throw NoiseSecurityError.sessionExpired } - // Validate message size - guard NoiseSecurityValidator.validateMessageSize(ciphertext) else { + // The payload type is encrypted, so a large candidate can only be + // bounded here; `NoiseEncryptionService.decrypt` authenticates it and + // then requires the resulting type to be `.privateFile`. + guard NoiseSecurityValidator.validateCiphertextSize(ciphertext) + || NoiseSecurityValidator.validatePrivateFileCiphertextSize(ciphertext) else { throw NoiseSecurityError.messageTooLarge } diff --git a/bitchat/Nostr/GeoRelayDirectory.swift b/bitchat/Nostr/GeoRelayDirectory.swift index 55a25e4d..ca115536 100644 --- a/bitchat/Nostr/GeoRelayDirectory.swift +++ b/bitchat/Nostr/GeoRelayDirectory.swift @@ -32,6 +32,23 @@ struct GeoRelayDirectoryDependencies { var retrySleep: (TimeInterval) async -> Void var activeNotificationName: Notification.Name? var autoStart: Bool + var validationPolicy: GeoRelayDirectoryValidationPolicy +} + +struct GeoRelayDirectoryValidationPolicy: Sendable { + let maximumBytes: Int + let maximumRows: Int + let maximumEntries: Int + let minimumRemoteEntries: Int + let minimumRetainedFraction: Double + + static let live = GeoRelayDirectoryValidationPolicy( + maximumBytes: 512 * 1024, + maximumRows: 5_000, + maximumEntries: 5_000, + minimumRemoteEntries: 50, + minimumRetainedFraction: 0.5 + ) } private extension GeoRelayDirectoryDependencies { @@ -44,21 +61,57 @@ private extension GeoRelayDirectoryDependencies { #else let activeNotificationName: Notification.Name? = nil #endif + let validationPolicy = GeoRelayDirectoryValidationPolicy.live return Self( userDefaults: .standard, notificationCenter: .default, now: Date.init, - remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!, + // Runtime refreshes only from bitchat's reviewed copy. Upstream + // georelays/main is imported by a validator-backed pull request, + // so an upstream mutation cannot immediately retarget clients. + remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/bitchat/refs/heads/main/relays/online_relays_gps.csv")!, fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds, refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds, retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds, retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds, - awaitTorReady: { await TorManager.shared.awaitReady() }, + // Only wait for Tor when Tor is switched on. With it off, the fetch + // is meant to go direct through the same unproxied session the relay + // sockets already use — and `TorManager` has been shut down, so + // awaiting readiness would spend the whole bootstrap timeout on + // every refresh and freeze the directory on its cached copy. + // + // Deliberately keyed on the preference rather than live readiness: + // if Tor is wanted but not ready, this must keep returning false so + // the fetch is skipped instead of silently leaking the IP. + awaitTorReady: { + guard NetworkActivationService.persistedTorPreference() else { return true } + return await TorManager.shared.awaitReady() + }, makeFetchData: { let session = TorURLSession.shared.session return { request in - let (data, _) = try await session.data(for: request) + let (bytes, response) = try await session.bytes(for: request) + guard let response = response as? HTTPURLResponse, + (200...299).contains(response.statusCode), + response.url == request.url else { + throw URLError(.badServerResponse) + } + + let maximumBytes = validationPolicy.maximumBytes + guard response.expectedContentLength <= Int64(maximumBytes) else { + throw URLError(.dataLengthExceedsMaximum) + } + var data = Data() + if response.expectedContentLength > 0 { + data.reserveCapacity(Int(response.expectedContentLength)) + } + for try await byte in bytes { + guard data.count < maximumBytes else { + throw URLError(.dataLengthExceedsMaximum) + } + data.append(byte) + } return data } }, @@ -76,7 +129,11 @@ private extension GeoRelayDirectoryDependencies { ) let dir = base.appendingPathComponent("bitchat", isDirectory: true) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - return dir.appendingPathComponent("georelays_cache.csv") + // v2 ignores caches populated from the old direct-upstream + // trust path and subjects every load to strict validation. + let legacyCache = dir.appendingPathComponent("georelays_cache.csv") + try? FileManager.default.removeItem(at: legacyCache) + return dir.appendingPathComponent("georelays_cache_v2.csv") } catch { return nil } @@ -94,7 +151,8 @@ private extension GeoRelayDirectoryDependencies { try? await Task.sleep(nanoseconds: nanoseconds) }, activeNotificationName: activeNotificationName, - autoStart: true + autoStart: true, + validationPolicy: validationPolicy ) } } @@ -125,7 +183,7 @@ final class GeoRelayDirectory { } private enum DetachedFetchOutcome: Sendable { - case success(entries: [Entry], csv: String) + case success(entries: [Entry], csv: Data) case torNotReady case invalidData case network(String) @@ -212,6 +270,8 @@ final class GeoRelayDirectory { ) let awaitTorReady = dependencies.awaitTorReady let fetchData = dependencies.makeFetchData() + let validationPolicy = dependencies.validationPolicy + let baselineEntries = Set(entries) Task { [weak self] in guard let self else { return } @@ -219,7 +279,9 @@ final class GeoRelayDirectory { let outcome = await Self.fetchRemoteOutcome( request: request, awaitTorReady: awaitTorReady, - fetchData: fetchData + fetchData: fetchData, + validationPolicy: validationPolicy, + baselineEntries: baselineEntries ) switch outcome { @@ -238,7 +300,9 @@ final class GeoRelayDirectory { nonisolated private static func fetchRemoteOutcome( request: URLRequest, awaitTorReady: @escaping @Sendable () async -> Bool, - fetchData: @escaping @Sendable (URLRequest) async throws -> Data + fetchData: @escaping @Sendable (URLRequest) async throws -> Data, + validationPolicy: GeoRelayDirectoryValidationPolicy, + baselineEntries: Set ) async -> DetachedFetchOutcome { await Task.detached(priority: .utility) { let ready = await awaitTorReady() @@ -246,16 +310,16 @@ final class GeoRelayDirectory { do { let data = try await fetchData(request) - guard let text = String(data: data, encoding: .utf8) else { + guard let parsed = Self.validatedEntries( + from: data, + policy: validationPolicy, + minimumEntries: validationPolicy.minimumRemoteEntries, + baselineEntries: baselineEntries + ) else { return .invalidData } - let parsed = Self.parseCSV(text) - guard !parsed.isEmpty else { - return .invalidData - } - - return .success(entries: parsed, csv: text) + return .success(entries: parsed, csv: data) } catch { return .network(error.localizedDescription) } @@ -269,7 +333,7 @@ final class GeoRelayDirectory { } @MainActor - private func handleFetchSuccess(entries parsed: [Entry], csv: String) { + private func handleFetchSuccess(entries parsed: [Entry], csv: Data) { entries = parsed persistCache(csv) dependencies.userDefaults.set(dependencies.now(), forKey: lastFetchKey) @@ -321,9 +385,8 @@ final class GeoRelayDirectory { cleanupState.retryTask = nil } - private func persistCache(_ text: String) { + private func persistCache(_ data: Data) { guard let url = dependencies.cacheURL() else { return } - guard let data = text.data(using: .utf8) else { return } do { try dependencies.writeData(data, url) } catch { @@ -336,9 +399,12 @@ final class GeoRelayDirectory { // Prefer cached file if present if let cache = dependencies.cacheURL(), let data = dependencies.readData(cache), - let text = String(data: data, encoding: .utf8) { - let arr = Self.parseCSV(text) - if !arr.isEmpty { return arr } + let entries = Self.validatedEntries( + from: data, + policy: dependencies.validationPolicy, + minimumEntries: 1 + ) { + return entries } // Try bundled resource(s) @@ -346,36 +412,157 @@ final class GeoRelayDirectory { for url in bundleCandidates { if let data = dependencies.readData(url), - let text = String(data: data, encoding: .utf8) { - let arr = Self.parseCSV(text) - if !arr.isEmpty { return arr } + let entries = Self.validatedEntries( + from: data, + policy: dependencies.validationPolicy, + minimumEntries: 1 + ) { + return entries } } // Try filesystem path (development/test) if let cwd = dependencies.currentDirectoryPath(), let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")), - let text = String(data: data, encoding: .utf8) { - return Self.parseCSV(text) + let entries = Self.validatedEntries( + from: data, + policy: dependencies.validationPolicy, + minimumEntries: 1 + ) { + return entries } SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session) return [] } - nonisolated static func parseCSV(_ text: String) -> [Entry] { - var result: Set = [] - let lines = text.split(whereSeparator: { $0.isNewline }) - for (idx, raw) in lines.enumerated() { - guard let line = raw.trimmedOrNilIfEmpty else { continue } - if idx == 0 && line.lowercased().contains("relay url") { continue } - let parts = line.split(separator: ",").map { $0.trimmed } - guard parts.count >= 3 else { continue } - guard let host = NostrRelayURL.directoryAddress(parts[0]) else { continue } - guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue } - result.insert(Entry(host: host, lat: lat, lon: lon)) + /// Parses the fixed three-column format as an all-or-nothing trust unit. + /// One malformed or conflicting row rejects the complete dataset rather + /// than silently shrinking or partially replacing the current directory. + nonisolated static func validatedEntries( + from data: Data, + policy: GeoRelayDirectoryValidationPolicy, + minimumEntries: Int, + baselineEntries: Set? = nil + ) -> [Entry]? { + guard !data.isEmpty, data.count <= policy.maximumBytes, + let text = String(data: data, encoding: .utf8), + !text.hasPrefix("\u{feff}") else { + return nil } - return Array(result) + + let lines = text.split(whereSeparator: { $0.isNewline }) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + guard let header = lines.first, + lines.count - 1 <= policy.maximumRows else { + return nil + } + + let headerParts = header + .split(separator: ",", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + let supportedHeaders = [ + ["relay url", "latitude", "longitude"], + ["relay url", "lat", "lon"] + ] + guard supportedHeaders.contains(headerParts) else { + return nil + } + + var entriesByHost: [String: Entry] = [:] + for line in lines.dropFirst() { + let parts = line + .split(separator: ",", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + guard parts.count == 3, + let host = validatedDirectoryAddress(parts[0]), + let latitude = Double(parts[1]), latitude.isFinite, + (-90.0...90.0).contains(latitude), + let longitude = Double(parts[2]), longitude.isFinite, + (-180.0...180.0).contains(longitude) else { + return nil + } + + let entry = Entry(host: host, lat: latitude, lon: longitude) + if let existing = entriesByHost[host], existing != entry { + // One endpoint cannot truthfully occupy two coordinates. Do + // not let row ordering choose which location clients trust. + return nil + } + entriesByHost[host] = entry + guard entriesByHost.count <= policy.maximumEntries else { return nil } + } + + let parsedEntries = Set(entriesByHost.values) + guard parsedEntries.count >= minimumEntries else { return nil } + + if let baselineEntries { + guard (0...1).contains(policy.minimumRetainedFraction) else { return nil } + let requiredOverlap = Int( + ceil(Double(baselineEntries.count) * policy.minimumRetainedFraction) + ) + guard parsedEntries.intersection(baselineEntries).count >= requiredOverlap else { + return nil + } + } + + return parsedEntries.sorted { + ($0.host, $0.lat, $0.lon) < ($1.host, $1.lat, $1.lon) + } + } + + nonisolated private static func validatedDirectoryAddress(_ rawValue: String) -> String? { + let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty, + value.unicodeScalars.allSatisfy({ + $0.isASCII && !CharacterSet.controlCharacters.contains($0) + }) else { + return nil + } + + let candidate = value.contains("://") ? value : "wss://\(value)" + guard let components = URLComponents(string: candidate), + let scheme = components.scheme?.lowercased(), + scheme == "wss" || scheme == "https", + components.user == nil, + components.password == nil, + components.query == nil, + components.fragment == nil, + components.path.isEmpty || components.path == "/", + let rawHost = components.host else { + return nil + } + + let host = rawHost.lowercased() + guard !host.isEmpty, host.count <= 253, + host.unicodeScalars.allSatisfy({ $0.isASCII }), + !host.hasSuffix("."), + host != "localhost", + !host.hasSuffix(".localhost"), + !host.hasSuffix(".local"), + !host.hasSuffix(".internal") else { + return nil + } + + let labels = host.split(separator: ".", omittingEmptySubsequences: false) + let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789-") + guard labels.count >= 2, + !labels.allSatisfy({ $0.allSatisfy(\.isNumber) }), + labels.allSatisfy({ label in + (1...63).contains(label.count) && + label.first != "-" && + label.last != "-" && + label.unicodeScalars.allSatisfy { allowed.contains($0) } + }) else { + return nil + } + + if let port = components.port { + guard (1...65_535).contains(port) else { return nil } + if port != 443 { return "\(host):\(port)" } + } + return host } // MARK: - Observers & Timers diff --git a/bitchat/Nostr/NostrIdentity.swift b/bitchat/Nostr/NostrIdentity.swift index 7dedfd08..9d41ca54 100644 --- a/bitchat/Nostr/NostrIdentity.swift +++ b/bitchat/Nostr/NostrIdentity.swift @@ -1,7 +1,8 @@ import Foundation import P256K -/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging +/// Manages the secp256k1 identity used by BitChat's Nostr relay features, +/// including the proprietary private-envelope transport. struct NostrIdentity: Codable { let privateKey: Data let publicKey: Data diff --git a/bitchat/Nostr/NostrProtocol.swift b/bitchat/Nostr/NostrProtocol.swift index 46aa7c07..bfef85e2 100644 --- a/bitchat/Nostr/NostrProtocol.swift +++ b/bitchat/Nostr/NostrProtocol.swift @@ -7,16 +7,26 @@ import Security // Note: This file depends on Data extension from BinaryEncodingUtils.swift // Make sure BinaryEncodingUtils.swift is included in the target -/// NIP-17 Protocol Implementation for Private Direct Messages +/// BitChat's private-envelope protocol transported over Nostr relays. +/// +/// This construction is deliberately BitChat-specific and is **not** NIP-17, +/// NIP-44, or NIP-59 compatible, even though it historically reuses those +/// NIPs' kind numbers (1059/13/14) and a `v2:` content prefix. It uses Nostr +/// events and secp256k1 identities, but the XChaCha20-Poly1305 payload layout +/// and key derivation are proprietary and interoperate only with BitChat +/// clients. struct NostrProtocol { - + /// Nostr event kinds enum EventKind: Int { case metadata = 0 case textNote = 1 - case dm = 14 // NIP-17 DM rumor kind - case seal = 13 // NIP-17 sealed event - case giftWrap = 1059 // NIP-59 gift wrap + // BitChat's proprietary private-envelope layers. These reuse the + // NIP-17/NIP-59 kind numbers (14/13/1059) for historical reasons, but + // the encrypted payloads are BitChat-specific and not NIP-compatible. + case dm = 14 // unsigned inner message (inside ciphertext) + case seal = 13 // sender-signed seal (inside ciphertext) + case giftWrap = 1059 // public outer envelope (one-time key) case ephemeralEvent = 20000 case geohashPresence = 20001 case deletion = 5 // NIP-09 event deletion request @@ -25,29 +35,46 @@ struct NostrProtocol { /// its NIP-40 expiration — the whole point is store-and-forward. case courierDrop = 1401 } - - /// Create a NIP-17 private message + + /// Bound work before Base64-decoding either encrypted layer of an inbound + /// private envelope, and before parsing each decrypted nested JSON layer. + /// Real envelopes are normally a few KiB; 64 KiB leaves ample headroom + /// without letting an addressed relay event drive unbounded allocation. + static let maximumPrivateEnvelopeCiphertextBytes = 64 * 1024 + + /// Create a BitChat private envelope for relay transport (outer kind 1059). static func createPrivateMessage( content: String, recipientPubkey: String, senderIdentity: NostrIdentity ) throws -> NostrEvent { - - // Creating private message - - // 1. Create the rumor (unsigned event) + try createPrivateMessage( + content: content, + recipientPubkey: recipientPubkey, + senderIdentity: senderIdentity, + messageTags: [] + ) + } + + private static func createPrivateMessage( + content: String, + recipientPubkey: String, + senderIdentity: NostrIdentity, + messageTags: [[String]] + ) throws -> NostrEvent { + // 1. Create the rumor (unsigned inner event) let rumor = NostrEvent( pubkey: senderIdentity.publicKeyHex, createdAt: Date(), - kind: .dm, // NIP-17: DM rumor kind 14 - tags: [], + kind: .dm, + tags: messageTags, content: content ) - + // 2. Seal the rumor (encrypt to recipient) and sign it with the SENDER'S - // real identity key. NIP-17 requires the seal be signed by the sender - // so the recipient can authenticate who sent the message; signing with - // a throwaway key leaves DMs forgeable/impersonatable. + // real identity key so the recipient can authenticate who sent the + // message; signing with a throwaway key leaves DMs + // forgeable/impersonatable. let senderKey = try senderIdentity.schnorrSigningKey() let sealedEvent = try createSeal( rumor: rumor, @@ -55,28 +82,39 @@ struct NostrProtocol { senderKey: senderKey ) - // 3. Gift wrap the sealed event with a throwaway ephemeral key (the wrap + // 3. Wrap the sealed event with a throwaway ephemeral key (the wrap // layer hides the sender's identity from relays; createGiftWrap mints // its own ephemeral key internally). let giftWrap = try createGiftWrap( seal: sealedEvent, recipientPubkey: recipientPubkey ) - - // Created gift wrap - + return giftWrap } - - /// Decrypt a received NIP-17 message - /// Returns the content, sender pubkey, and the actual message timestamp (not the randomized gift wrap timestamp) + + /// Decrypt a received BitChat private envelope. + /// Returns the content, sender pubkey, and the actual message timestamp (not the randomized outer timestamp) static func decryptPrivateMessage( giftWrap: NostrEvent, recipientIdentity: NostrIdentity ) throws -> (content: String, senderPubkey: String, timestamp: Int) { - - // Starting decryption - + + // 0. Validate the untrusted outer envelope before any decryption work. + // Every BitChat client (released iOS and current Android) publishes + // exactly one outer recipient `p` tag on a validly signed kind-1059 + // wrap; anything else is malformed or misbound. + guard giftWrap.content.utf8.count <= maximumPrivateEnvelopeCiphertextBytes else { + SecureLogger.error("❌ Rejecting DM: oversized outer envelope ciphertext", category: .session) + throw NostrError.invalidCiphertext + } + guard giftWrap.kind == EventKind.giftWrap.rawValue, + giftWrap.tags == [["p", recipientIdentity.publicKeyHex]], + giftWrap.isValidSignature() else { + SecureLogger.error("❌ Rejecting DM: malformed or misbound outer envelope", category: .session) + throw NostrError.invalidEvent + } + // 1. Unwrap the gift wrap let seal: NostrEvent do { @@ -91,10 +129,13 @@ struct NostrProtocol { } // 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) + // identity key; without this check a DM is forgeable by anyone who + // knows the recipient's npub. Every BitChat sender emits a tagless + // kind-13 seal, so bind the decrypted layer to that exact shape. + guard seal.kind == EventKind.seal.rawValue, + seal.tags.isEmpty, + seal.isValidSignature() else { + SecureLogger.error("❌ Rejecting DM: seal is malformed or its signature is missing/invalid", category: .session) throw NostrError.invalidEvent } @@ -111,11 +152,16 @@ struct NostrProtocol { 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) + // 4. The rumor is intentionally unsigned; sender authentication comes + // from the seal. The sender claimed inside the rumor must match the + // key that actually signed the seal, otherwise the sender field is + // unauthenticated and spoofable. Also bind the inner kind and tag + // shape to what BitChat clients actually emit. + guard rumor.kind == EventKind.dm.rawValue, + validInnerMessageTags(rumor.tags, recipientPubkey: recipientIdentity.publicKeyHex), + rumor.sig == nil, + seal.pubkey == rumor.pubkey else { + SecureLogger.error("❌ Rejecting DM: rumor is malformed or does not match seal signer", category: .session) throw NostrError.invalidEvent } @@ -123,6 +169,17 @@ struct NostrProtocol { return (content: rumor.content, senderPubkey: seal.pubkey, timestamp: rumor.created_at) } + /// Released iOS envelopes use no inner tags, while current Android + /// envelopes place exactly the authenticated recipient's `p` tag on the + /// unsigned inner event. Accept only those two historical shapes; + /// alternate recipients, duplicate tags, and extra tags are rejected. + private static func validInnerMessageTags( + _ tags: [[String]], + recipientPubkey: String + ) -> Bool { + tags.isEmpty || tags == [["p", recipientPubkey]] + } + #if DEBUG static func createPrivateMessageWithInvalidSealSignatureForTesting( content: String, @@ -165,6 +222,23 @@ struct NostrProtocol { ) return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey) } + + /// Reproduces historical wire shapes (current Android places exactly one + /// recipient `p` tag on the unsigned inner event) without making the + /// production encoder depend on that quirk. + static func createPrivateMessageWithInnerTagsForTesting( + content: String, + recipientPubkey: String, + senderIdentity: NostrIdentity, + innerMessageTags: [[String]] + ) throws -> NostrEvent { + try createPrivateMessage( + content: content, + recipientPubkey: recipientPubkey, + senderIdentity: senderIdentity, + messageTags: innerMessageTags + ) + } #endif /// Create a geohash-scoped ephemeral public message (kind 20000) @@ -468,14 +542,19 @@ struct NostrProtocol { recipientKey: recipientKey ) + // Check UTF-8 size before allocating Data or invoking the general + // JSON parser on attacker-influenced plaintext. + guard decrypted.utf8.count <= maximumPrivateEnvelopeCiphertextBytes else { + throw NostrError.invalidCiphertext + } guard let data = decrypted.data(using: .utf8), let sealDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw NostrError.invalidEvent } - + let seal = try NostrEvent(from: sealDict) // Unwrapped seal - + return seal } @@ -490,16 +569,23 @@ struct NostrProtocol { recipientKey: recipientKey ) + guard decrypted.utf8.count <= maximumPrivateEnvelopeCiphertextBytes else { + throw NostrError.invalidCiphertext + } guard let data = decrypted.data(using: .utf8), let rumorDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw NostrError.invalidEvent } - + return try NostrEvent(from: rumorDict) } - - // MARK: - Encryption (NIP-44 v2) - + + // MARK: - BitChat private-envelope encryption + // + // Not NIP-44: the `v2:` prefix, base64url(nonce24 || ciphertext || tag) + // layout, XChaCha20-Poly1305 cipher, and HKDF parameters are all + // BitChat-specific. + private static func encrypt( plaintext: String, recipientPubkey: String, @@ -510,22 +596,25 @@ struct NostrProtocol { throw NostrError.invalidPublicKey } - // Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned) - // Derive shared secret let sharedSecret = try deriveSharedSecret( privateKey: senderKey, publicKey: recipientPubkeyData ) - // Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info) - let key = try deriveNIP44V2Key(from: sharedSecret) - + // Derive the BitChat private-envelope symmetric key (HKDF-SHA256) + let key = try derivePrivateEnvelopeKey(from: sharedSecret) + // 24-byte random nonce for XChaCha20-Poly1305 var nonce24 = Data(count: 24) - _ = nonce24.withUnsafeMutableBytes { ptr in + let randomStatus = nonce24.withUnsafeMutableBytes { ptr in SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!) } - + // Never encrypt with an unrandomized nonce: nonce reuse under the same + // key breaks XChaCha20-Poly1305 confidentiality and authenticity. + guard randomStatus == errSecSuccess else { + throw NostrError.cryptographicFailure + } + let pt = Data(plaintext.utf8) let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24) @@ -542,8 +631,12 @@ struct NostrProtocol { senderPubkey: String, recipientKey: P256K.Schnorr.PrivateKey ) throws -> String { - // Expect NIP-44 v2 format - guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext } + // Expect BitChat's historical `v2:` private-envelope framing, and + // bound work before Base64 decoding attacker-sized input. + guard ciphertext.utf8.count <= maximumPrivateEnvelopeCiphertextBytes, + ciphertext.hasPrefix("v2:") else { + throw NostrError.invalidCiphertext + } let encoded = String(ciphertext.dropFirst(3)) guard let data = Base64URLCoding.decode(encoded), data.count > (24 + 16), @@ -559,7 +652,7 @@ struct NostrProtocol { // Try decryption with even-Y then odd-Y when sender pubkey is x-only func attemptDecrypt(using pubKeyData: Data) throws -> Data { let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData) - let key = try deriveNIP44V2Key(from: ss) + let key = try derivePrivateEnvelopeKey(from: ss) return try XChaCha20Poly1305Compat.open( ciphertext: Data(ct), tag: Data(tag), @@ -569,18 +662,25 @@ struct NostrProtocol { } // If 32 bytes (x-only) try both parities, otherwise single try + let plaintext: Data if senderPubkeyData.count == 32 { let even = Data([0x02]) + senderPubkeyData if let pt = try? attemptDecrypt(using: even) { - return String(data: pt, encoding: .utf8) ?? "" + plaintext = pt + } else { + let odd = Data([0x03]) + senderPubkeyData + plaintext = try attemptDecrypt(using: odd) } - let odd = Data([0x03]) + senderPubkeyData - let pt = try attemptDecrypt(using: odd) - return String(data: pt, encoding: .utf8) ?? "" } else { - let pt = try attemptDecrypt(using: senderPubkeyData) - return String(data: pt, encoding: .utf8) ?? "" + plaintext = try attemptDecrypt(using: senderPubkeyData) } + + // Authenticated plaintext that is not valid UTF-8 is a malformed + // envelope, not an empty message. + guard let decoded = String(data: plaintext, encoding: .utf8) else { + throw NostrError.invalidCiphertext + } + return decoded } private static func deriveSharedSecret( @@ -640,7 +740,8 @@ struct NostrProtocol { let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) } // ECDH shared secret derived - // Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key + // Return raw ECDH shared secret; HKDF is applied by + // derivePrivateEnvelopeKey return sharedSecretData } @@ -794,12 +895,17 @@ enum NostrError: Error { case invalidPublicKey case invalidEvent case invalidCiphertext + case cryptographicFailure } -// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305) +// MARK: - BitChat private-envelope key derivation private extension NostrProtocol { - static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data { + /// The HKDF info string retains the historical "nip44-v2" label for wire + /// compatibility with deployed clients, but this is not the NIP-44 key + /// schedule: NIP-44 derives a conversation key via HKDF-extract with that + /// label as the *salt* and uses ChaCha20 with per-message expanded keys. + static func derivePrivateEnvelopeKey(from sharedSecretData: Data) throws -> Data { let derivedKey = HKDF.deriveKey( inputKeyMaterial: SymmetricKey(data: sharedSecretData), salt: Data(), diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index 9864ab21..fca24465 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -48,7 +48,14 @@ private struct URLSessionAdapter: NostrRelaySessionProtocol { let base: URLSession func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol { - URLSessionWebSocketTaskAdapter(base: base.webSocketTask(with: url)) + let task = base.webSocketTask(with: url) + // Byte bound per inbound frame; without it the per-relay buffer cap + // (nostrInboundPerRelayBufferCap) bounds FRAMES but not BYTES, and a + // hostile relay could pile up cap × 1 MiB (URLSession default) per + // connection. See TransportConfig.nostrInboundMaxFrameBytes for the + // sizing rationale. Oversized frames fail the receive with an error. + task.maximumMessageSize = TransportConfig.nostrInboundMaxFrameBytes + return URLSessionWebSocketTaskAdapter(base: task) } } @@ -69,6 +76,18 @@ struct NostrRelayManagerDependencies { /// Uniform random value in [0, 1) used to jitter reconnect backoff. /// Injectable so tests can pin or sweep the jitter deterministically. var jitterUnit: () -> Double + /// Where relay-settings changes are observed. Injectable so a test can use + /// its own center instead of racing the process-wide one. + var notificationCenter: NotificationCenter = .default + /// Relays added by hand, merged with the built-in set. Injectable so tests + /// do not have to write to shared preferences. + var customRelays: () -> [String] = { NostrRelaySettings.customRelays() } + /// Whether a location channel is currently open. Mirrors the third arm of + /// `NetworkActivationService`'s gate: teleporting into a geohash needs no + /// location permission, and without this the relays would stay filtered out + /// for someone who denied location and has no mutual favorites. + var isInLocationChannel: () -> Bool = { false } + var selectedChannelPublisher: AnyPublisher = Empty().eraseToAnyPublisher() } private extension NostrRelayManagerDependencies { @@ -97,7 +116,12 @@ private extension NostrRelayManagerDependencies { DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action) }, now: Date.init, - jitterUnit: { Double.random(in: 0..<1) } + jitterUnit: { Double.random(in: 0..<1) }, + isInLocationChannel: { + if case .location = LocationChannelManager.shared.selectedChannel { return true } + return false + }, + selectedChannelPublisher: LocationChannelManager.shared.$selectedChannel.eraseToAnyPublisher() ) } } @@ -106,7 +130,10 @@ private extension NostrRelayManagerDependencies { @MainActor final class NostrRelayManager: ObservableObject { static let shared = NostrRelayManager() - // Track gift-wraps (kind 1059) we initiated so we can log OK acks at info + // Track gift-wraps (kind 1059) we initiated so we can log OK acks at info. + // Entries are removed only on OK acks (or panic wipe); relays that never + // ack leave entries behind for the process lifetime. Observability-only + // state, bounded in practice by outbound DM volume. private(set) static var pendingGiftWrapIDs = Set() static func registerPendingGiftWrap(id: String) { pendingGiftWrapIDs.insert(id) @@ -124,15 +151,40 @@ final class NostrRelayManager: ObservableObject { var nextReconnectTime: Date? } - // Default relays carry NIP-17 gift wraps, so avoid relays known to reject kind 1059. - private static let defaultRelays = [ + // Built-in relays carry private-message envelopes, so avoid relays known to + // reject the kinds they use. + private static let builtInRelays = [ "wss://relay.damus.io", "wss://nos.lol", "wss://relay.primal.net", "wss://offchain.pub" // For local testing, you can add: "ws://localhost:8080" ] - private static let defaultRelaySet = Set(defaultRelays.compactMap { NostrRelayURL.normalized($0) }) + private static let builtInRelaySet = Set(builtInRelays.compactMap { NostrRelayURL.normalized($0) }) + + /// The relays private messages target: the built-in set plus any added by + /// hand. Four hardcoded hostnames are four names for a censor to block, so + /// the added ones are what keeps this reachable without a new build. + /// + /// Cached rather than computed per access: `allowedRelayList` consults the + /// set once per candidate URL, and recomputing would mean a `UserDefaults` + /// read and a fresh normalize-and-dedupe pass inside that loop. Refreshed + /// from `reloadDefaultRelays()` on construction and whenever the relay + /// settings change. + private var defaultRelays: [String] = [] + private var defaultRelaySet: Set = [] + + private func reloadDefaultRelays() { + var seen = Set() + defaultRelays = (Self.builtInRelays + dependencies.customRelays()) + .compactMap { NostrRelayURL.normalized($0) } + .filter { seen.insert($0).inserted } + defaultRelaySet = Set(defaultRelays) + } + + /// Exposed so the relay settings UI can reject re-adding a built-in. + /// `nonisolated` because it is an immutable constant with no actor state. + nonisolated static var builtInRelayURLs: Set { builtInRelaySet } @Published private(set) var relays: [Relay] = [] @Published private(set) var isConnected = false @@ -217,7 +269,7 @@ final class NostrRelayManager: ObservableObject { private var messageQueue: [PendingSend] = [] private let messageQueueLock = NSLock() /// Non-queued sends whose callers require relay durability. A WebSocket - /// write only proves bytes left this process; NIP-20 OK is the relay's + /// write only proves bytes left this process; NIP-01 `OK` is the relay's /// accept/reject acknowledgment. private struct ConfirmedSendState { let token: UUID @@ -239,38 +291,42 @@ final class NostrRelayManager: ObservableObject { // Bump generation to invalidate scheduled reconnects when we reset/disconnect private var connectionGeneration: Int = 0 - - init() { - self.dependencies = .live() - hasMutualFavorites = dependencies.hasMutualFavorites() - hasLocationPermission = dependencies.hasLocationPermission() - applyDefaultRelayPolicy(force: true) - // Deterministic JSON shape for outbound requests - self.encoder.outputFormatting = .sortedKeys - dependencies.mutualFavoritesPublisher - .receive(on: DispatchQueue.main) - .sink { [weak self] favorites in - guard let self = self else { return } - self.hasMutualFavorites = !favorites.isEmpty - self.applyDefaultRelayPolicy() - } - .store(in: &cancellables) - dependencies.locationPermissionPublisher - .receive(on: DispatchQueue.main) - .sink { [weak self] state in - guard let self = self else { return } - let authorized = (state == .authorized) - if authorized == self.hasLocationPermission { return } - self.hasLocationPermission = authorized - self.applyDefaultRelayPolicy() - } - .store(in: &cancellables) + + // Per-relay off-main inbound pipeline: raw socket frames are parsed and + // Schnorr-verified in arrival order OFF the main actor (this is the single + // signature verification for the whole inbound path — downstream handlers + // receive only verified events), then hop back to the main actor for dedup + // recording and handler dispatch. + // + // Each relay connection owns its OWN AsyncStream + consumer task, so N + // relays verify in parallel while every relay's frames stay in arrival + // order (a single subscription's events for a relay all arrive on that + // relay's socket, so per-relay ordering preserves per-subscription + // ordering). A burst of EVENT frames from one busy/malicious relay only + // blocks that relay's own verification backlog — DMs, OKs, EOSEs, and + // events from every other relay keep flowing on their own pipelines. + // + // Each stream is bounded (`.bufferingNewest`) so a relay flooding faster + // than its verification drains sheds its own oldest frames instead of + // growing memory without bound; it can never starve other relays. + // + // Continuations live in a lock-guarded, `Sendable` router (see + // `InboundFrameRouter` at file scope) so the raw socket receive callback + // (which is NOT main-actor isolated) can route a frame to the right relay + // stream without a per-frame main hop, while the main actor owns pipeline + // creation/teardown. The expensive work (Schnorr verify) is what runs + // off-main; the yield stays cheap. + private let inboundRouter = InboundFrameRouter() + + convenience init() { + self.init(dependencies: .live()) } internal init(dependencies: NostrRelayManagerDependencies) { self.dependencies = dependencies hasMutualFavorites = dependencies.hasMutualFavorites() hasLocationPermission = dependencies.hasLocationPermission() + reloadDefaultRelays() applyDefaultRelayPolicy(force: true) // Deterministic JSON shape for outbound requests self.encoder.outputFormatting = .sortedKeys @@ -292,8 +348,93 @@ final class NostrRelayManager: ObservableObject { self.applyDefaultRelayPolicy() } .store(in: &cancellables) + dependencies.selectedChannelPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.applyDefaultRelayPolicy() + } + .store(in: &cancellables) + // Adding or removing a relay by hand changes the target set, so + // reconcile connections now rather than at the next send. + dependencies.notificationCenter + .publisher(for: NostrRelaySettings.didChangeNotification) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self else { return } + // Reconcile against the previous set: a removed relay is no + // longer in `defaultRelays`, so nothing downstream would ever + // close its socket or drop its queued sends. + let previous = self.defaultRelaySet + self.reloadDefaultRelays() + self.dropRelays(previous.subtracting(self.defaultRelaySet)) + self.applyDefaultRelayPolicy(force: true) + } + .store(in: &cancellables) } - + + deinit { + inboundRouter.finishAll() + } + + /// Ensure a serial off-main consumer pipeline exists for a relay. Called on + /// the main actor when a socket is (re)armed for receiving. Idempotent. + /// + /// Ordering within the relay is deliberate and security/performance-critical: + /// 1. `precheckInboundEvent` (main hop): per-relay stats plus a cheap + /// duplicate LOOKUP — duplicate fan-in from multiple relays dominates + /// real traffic and must never pay for Schnorr verification. + /// 2. `isValidSignature()` runs here, off the main actor — the ONLY + /// signature verification on the inbound path (JSON re-serialization + + /// SHA-256 + secp256k1 Schnorr per event). + /// 3. `deliverVerifiedInboundEvent` (main hop): authoritative + /// check-and-RECORD plus handler dispatch. Recording only after + /// verification means a forged-signature copy can never poison the + /// dedup cache and suppress the genuine event. + private func ensureRelayInboundPipeline(for relayUrl: String) { + let started = inboundRouter.startPipeline(for: relayUrl) { [weak self] stream in + Task.detached(priority: .userInitiated) { + for await frame in stream { + guard let parsed = ParsedInbound(frame.message) else { continue } + guard let self else { return } + switch parsed { + case .event(let subId, let event): + guard await self.precheckInboundEvent( + subscriptionID: subId, + eventID: event.id, + relayUrl: relayUrl + ) else { + continue + } + guard event.isValidSignature() else { + SecureLogger.warning( + "⚠️ Dropped invalid Nostr event id=\(event.id.prefix(16))… sub=\(subId) relay=\(relayUrl)", + category: .session + ) + continue + } + await self.deliverVerifiedInboundEvent(subscriptionID: subId, event: event, from: relayUrl) + case .eose, .ok, .notice: + await self.handleParsedMessage(parsed, from: relayUrl) + } + } + } + } + if started { + SecureLogger.debug("🧵 Started inbound verify pipeline for \(relayUrl)", category: .session) + } + } + + /// Tear down a relay's inbound pipeline (socket gone or state wiped). The + /// consumer drains any already-buffered frames before finishing, so + /// in-flight verified events are still delivered. + private func teardownRelayInboundPipeline(for relayUrl: String) { + inboundRouter.finishPipeline(for: relayUrl) + } + + private func teardownAllRelayInboundPipelines() { + inboundRouter.finishAll() + } + /// Connect to all configured relays func connect() { // Global network policy gate @@ -308,6 +449,8 @@ final class NostrRelayManager: ObservableObject { task.cancel(with: .goingAway, reason: nil) } connections.removeAll() + // Sockets are gone; drop every relay's inbound verify pipeline. + teardownAllRelayInboundPipelines() markRelaySocketsClosed(resetState: false) // Sockets are gone, so per-relay subscription state is cleared — but // durable intent (subscriptionRequestState, messageHandlers, parked @@ -340,6 +483,7 @@ final class NostrRelayManager: ObservableObject { task.cancel(with: .goingAway, reason: nil) } connections.removeAll() + teardownAllRelayInboundPipelines() markRelaySocketsClosed(resetState: true) subscriptions.removeAll() pendingSubscriptions.removeAll() @@ -407,13 +551,13 @@ final class NostrRelayManager: ObservableObject { // event locally so it survives a slow bootstrap (queued sends flush // when relays connect), then kick off connection setup, which itself // waits for Tor readiness. - let targetRelays = allowedRelayList(from: relayUrls ?? Self.defaultRelays) + let targetRelays = allowedRelayList(from: relayUrls ?? defaultRelays) guard !targetRelays.isEmpty else { return } enqueuePendingSend(event, pendingRelays: Set(targetRelays)) ensureConnections(to: targetRelays) return } - let requestedRelays = relayUrls ?? Self.defaultRelays + let requestedRelays = relayUrls ?? defaultRelays let targetRelays = allowedRelayList(from: requestedRelays) guard !targetRelays.isEmpty else { return } ensureConnections(to: targetRelays) @@ -433,8 +577,8 @@ final class NostrRelayManager: ObservableObject { } /// Attempts an event only on currently connected target relays and - /// reports whether at least one relay explicitly accepted it via NIP-20 - /// OK. A successful WebSocket write alone is not durable acceptance. + /// reports whether at least one relay explicitly accepted it via NIP-01 + /// `OK`. A successful WebSocket write alone is not durable acceptance. /// Unlike `sendEvent`, this never enters the process-local pending queue; /// callers use it when success unlocks durable state or user-visible /// delivery progress. @@ -452,7 +596,7 @@ final class NostrRelayManager: ObservableObject { return } - let requestedRelays = relayUrls ?? Self.defaultRelays + let requestedRelays = relayUrls ?? defaultRelays let targetRelays = allowedRelayList(from: requestedRelays) let connectedTargets = targetRelays.compactMap { relayUrl -> (String, NostrRelayConnectionProtocol)? in guard let connection = connectedConnection(for: relayUrl) else { return nil } @@ -621,7 +765,7 @@ final class NostrRelayManager: ObservableObject { // SecureLogger.debug("📋 Subscription filter JSON: \(messageString.prefix(200))...", category: .session) // Target specific relays if provided; else default. Filter permanently failed relays. - let baseUrls = relayUrls ?? Self.defaultRelays + let baseUrls = relayUrls ?? defaultRelays let urls = allowedRelayList(from: baseUrls).filter { !isPermanentlyFailed($0) } let requestState = SubscriptionRequestState(messageString: messageString, relayURLs: Set(urls)) if subscriptionRequestState[id] == requestState, subscriptionStateExists(id: id, requestState: requestState) { @@ -663,49 +807,60 @@ final class NostrRelayManager: ObservableObject { } private func applyDefaultRelayPolicy(force: Bool = false) { - let shouldAllow = hasMutualFavorites || hasLocationPermission + let shouldAllow = hasMutualFavorites || hasLocationPermission || dependencies.isInLocationChannel() if !force && shouldAllow == allowDefaultRelays { return } allowDefaultRelays = shouldAllow if shouldAllow { var existing = Set(relays.map { $0.url }) - for url in Self.defaultRelays where !existing.contains(url) { + for url in defaultRelays where !existing.contains(url) { relays.append(Relay(url: url)) existing.insert(url) } if dependencies.activationAllowed() { - ensureConnections(to: Self.defaultRelays) + ensureConnections(to: defaultRelays) } } else { - for url in Self.defaultRelays { - if let connection = connections[url] { - connection.cancel(with: .goingAway, reason: nil) - } - connections.removeValue(forKey: url) - subscriptions.removeValue(forKey: url) - pendingSubscriptions.removeValue(forKey: url) - } - messageQueueLock.lock() - for index in (0..) { + guard !urls.isEmpty else { return } + for url in urls { + if let connection = connections[url] { + connection.cancel(with: .goingAway, reason: nil) + } + connections.removeValue(forKey: url) + teardownRelayInboundPipeline(for: url) + subscriptions.removeValue(forKey: url) + pendingSubscriptions.removeValue(forKey: url) + } + messageQueueLock.lock() + for index in (0.. [String] { var seen = Set() var result: [String] = [] for rawURL in urls { guard let url = NostrRelayURL.normalized(rawURL) else { continue } - if !allowDefaultRelays && Self.defaultRelaySet.contains(url) { continue } + if !allowDefaultRelays && defaultRelaySet.contains(url) { continue } if seen.insert(url).inserted { result.append(url) } @@ -1020,7 +1175,11 @@ final class NostrRelayManager: ObservableObject { connections[urlString] = task task.resume() - + + // Bring up this relay's own serial verify pipeline before arming the + // socket, so inbound frames have somewhere to land. + ensureRelayInboundPipeline(for: urlString) + // Start receiving messages receiveMessage(from: task, relayUrl: urlString) @@ -1095,15 +1254,14 @@ final class NostrRelayManager: ObservableObject { switch result { case .success(let message): - // Parse off-main to reduce UI jank, then hop back for state updates - Task.detached(priority: .utility) { - guard let parsed = ParsedInbound(message) else { return } - await MainActor.run { - guard self.connections[relayUrl] === task else { return } - self.handleParsedMessage(parsed, from: relayUrl) - } - } - + // Hand the raw frame to this relay's serial inbound pipeline: + // parsing and signature verification run off-main, in arrival + // order, independently of every other relay's pipeline. Routing + // through the lock-guarded router keeps this off the main actor + // (no per-frame main hop). + self.inboundRouter.yield(InboundFrame(message: message), to: relayUrl) + + // Continue receiving Task { @MainActor in guard self.connections[relayUrl] === task else { return } @@ -1122,35 +1280,55 @@ final class NostrRelayManager: ObservableObject { // Note: declared at file scope below to avoid MainActor isolation inside this class // and keep parsing off the main actor. - // Handle parsed message on MainActor (state updates and handlers) + /// First main-actor hop for an inbound EVENT: per-relay stats plus a cheap + /// duplicate LOOKUP (no recording) so duplicate fan-in from multiple + /// relays never pays for Schnorr verification. Recording happens only + /// after the signature verifies (`deliverVerifiedInboundEvent`), so a + /// forged-signature copy can never poison the dedup cache and suppress + /// the genuine event. + private func precheckInboundEvent(subscriptionID: String, eventID: String, relayUrl: String) -> Bool { + if let index = relays.firstIndex(where: { $0.url == relayUrl }) { + relays[index].messagesReceived += 1 + } + guard !eventID.isEmpty else { return true } + let key = InboundEventKey(subscriptionID: subscriptionID, eventID: eventID) + if recentInboundEventKeys.contains(key) { + recordDuplicateInboundEventDrop(subscriptionID: subscriptionID) + return false + } + return true + } + + /// Second main-actor hop, after off-main signature verification: + /// authoritative check-and-record (the serial pipeline means the same + /// event is never in flight twice, but the record must stay atomic with + /// delivery) and handler dispatch. + private func deliverVerifiedInboundEvent(subscriptionID subId: String, event: NostrEvent, from relayUrl: String) { + guard shouldDeliverInboundEvent(subscriptionID: subId, eventID: event.id) else { + return + } + if event.kind != 1059 { + // Per-event logging floods dev builds in busy geohashes; sample it. + inboundEventLogCount += 1 + if inboundEventLogCount == 1 || inboundEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) { + SecureLogger.debug("📥 Event #\(inboundEventLogCount) kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session) + } + } + if let handler = self.messageHandlers[subId] { + handler(event) + } else { + SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session) + } + } + + // Handle parsed non-EVENT messages on MainActor (state updates and handlers) private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) { switch parsed { - case .event(let subId, let event): - if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) { - self.relays[index].messagesReceived += 1 - } - guard event.isValidSignature() else { - SecureLogger.warning( - "⚠️ Dropped invalid Nostr event id=\(event.id.prefix(16))… sub=\(subId) relay=\(relayUrl)", - category: .session - ) - return - } - guard shouldDeliverInboundEvent(subscriptionID: subId, eventID: event.id) else { - return - } - if event.kind != 1059 { - // Per-event logging floods dev builds in busy geohashes; sample it. - inboundEventLogCount += 1 - if inboundEventLogCount == 1 || inboundEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) { - SecureLogger.debug("📥 Event #\(inboundEventLogCount) kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session) - } - } - if let handler = self.messageHandlers[subId] { - handler(event) - } else { - SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session) - } + case .event: + // Events flow through the serial inbound pipeline (precheck → + // off-main signature verification → deliverVerifiedInboundEvent) + // and never reach this fallback. + assertionFailure("inbound EVENT bypassed the verified pipeline") case .eose(let subId): if var tracker = eoseTrackers[subId] { // An EOSE proves the relay received the REQ even if the local @@ -1240,7 +1418,7 @@ final class NostrRelayManager: ObservableObject { isConnected = relays.contains { $0.isConnected } // Relay URLs are normalized before entries are created, so direct // set membership is sound. - isDMRelayConnected = relays.contains { $0.isConnected && Self.defaultRelaySet.contains($0.url) } + isDMRelayConnected = relays.contains { $0.isConnected && defaultRelaySet.contains($0.url) } } /// A relay that drops before sending EOSE must not stall initial-load @@ -1285,6 +1463,7 @@ final class NostrRelayManager: ObservableObject { ) { if let connection, connections[relayUrl] !== connection { return } connections.removeValue(forKey: relayUrl) + teardownRelayInboundPipeline(for: relayUrl) subscriptions.removeValue(forKey: relayUrl) let awaitingConfirmation = confirmedSends.compactMap { eventID, state in state.awaitingRelays.contains(relayUrl) ? eventID : nil @@ -1379,8 +1558,9 @@ final class NostrRelayManager: ObservableObject { if let connection = connections[normalizedRelayUrl] { connection.cancel(with: .goingAway, reason: nil) connections.removeValue(forKey: normalizedRelayUrl) + teardownRelayInboundPipeline(for: normalizedRelayUrl) } - + // Attempt immediate reconnection connectToRelay(normalizedRelayUrl) } @@ -1473,6 +1653,77 @@ final class NostrRelayManager: ObservableObject { // MARK: - Off-main inbound parsing helpers (file scope, non-isolated) +/// A single raw socket frame awaiting off-main parse + Schnorr verification. +private struct InboundFrame: Sendable { + let message: URLSessionWebSocketTask.Message +} + +/// Lock-guarded registry of per-relay inbound streams. +/// +/// The raw WebSocket receive callback is not main-actor isolated, so it needs a +/// `Sendable` path to route a frame to the correct relay's stream without a +/// per-frame hop onto the main actor. Pipeline lifecycle (start/finish) is +/// driven from the main actor; frame delivery (`yield`) can come from any +/// thread. All access is serialized by a single lock — contention is negligible +/// because the guarded critical section is only a dictionary lookup + yield. +private final class InboundFrameRouter: @unchecked Sendable { + private let lock = NSLock() + private var continuations: [String: AsyncStream.Continuation] = [:] + private var tasks: [String: Task] = [:] + + /// Start a relay's stream + consumer if one does not already exist. + /// Returns true when a new pipeline was created. The bounded + /// `.bufferingNewest` policy makes a single relay shed its OWN oldest + /// frames under a flood, never other relays' frames. Buffered memory per + /// relay is bounded (not eliminated) at the frame cap times the per-frame + /// byte cap (`maximumMessageSize`) — see TransportConfig. + func startPipeline( + for relayUrl: String, + makeConsumer: (AsyncStream) -> Task + ) -> Bool { + lock.lock() + defer { lock.unlock() } + if continuations[relayUrl] != nil { return false } + let (stream, continuation) = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(TransportConfig.nostrInboundPerRelayBufferCap) + ) + continuations[relayUrl] = continuation + tasks[relayUrl] = makeConsumer(stream) + return true + } + + /// Route a frame to a relay's stream. No-op if the relay has no live + /// pipeline (socket already torn down) — the frame is simply dropped, which + /// is safe for best-effort Nostr inbound. + func yield(_ frame: InboundFrame, to relayUrl: String) { + lock.lock() + let continuation = continuations[relayUrl] + lock.unlock() + continuation?.yield(frame) + } + + /// Finish a relay's stream. The consumer drains any already-buffered frames + /// before exiting, so in-flight verified events are still delivered. + func finishPipeline(for relayUrl: String) { + lock.lock() + let continuation = continuations.removeValue(forKey: relayUrl) + tasks.removeValue(forKey: relayUrl) + lock.unlock() + continuation?.finish() + } + + func finishAll() { + lock.lock() + let allContinuations = continuations + continuations.removeAll() + tasks.removeAll() + lock.unlock() + for continuation in allContinuations.values { + continuation.finish() + } + } +} + private enum ParsedInbound { case event(subId: String, event: NostrEvent) case ok(eventId: String, success: Bool, reason: String) diff --git a/bitchat/Nostr/NostrRelaySettings.swift b/bitchat/Nostr/NostrRelaySettings.swift new file mode 100644 index 00000000..19cf1c46 --- /dev/null +++ b/bitchat/Nostr/NostrRelaySettings.swift @@ -0,0 +1,92 @@ +// +// NostrRelaySettings.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitLogger +import Foundation + +/// Relays someone has added by hand, alongside the built-in set. +/// +/// The built-in relays are four well-known clearnet hostnames, so a censor +/// blocking four names ends internet-delivered private messages for everyone. +/// Adding relays — including `.onion` addresses, or a relay run by whoever +/// needs it — is the escape hatch that does not require shipping a new build. +/// +/// Stored normalized so comparisons against connection keys and the built-in +/// set are exact, and bounded so a long list cannot turn every send into a +/// fan-out across dozens of sockets. +enum NostrRelaySettings { + /// Enough to add a personal relay, an onion address, and a couple of + /// regional fallbacks without letting the connection fan-out grow unbounded. + static let maxCustomRelays = 8 + + private static let storageKey = "nostr.customRelays" + + static let didChangeNotification = Notification.Name("bitchat.nostrRelaySettingsDidChange") + + enum AddFailure: Error, Equatable { + case malformed + case alreadyPresent + case limitReached + } + + /// Normalized relay URLs, in the order they were added. + static func customRelays(in defaults: UserDefaults = .standard) -> [String] { + let stored = defaults.stringArray(forKey: storageKey) ?? [] + // Re-normalize on read: a value written by an older build, or edited + // outside the app, must not reach the connection layer unchecked. + var seen = Set() + return stored.compactMap { NostrRelayURL.normalized($0) } + .filter { seen.insert($0).inserted } + } + + /// Adds a relay, returning the normalized URL or why it was rejected. + @discardableResult + static func add( + _ rawValue: String, + builtIn: Set, + in defaults: UserDefaults = .standard + ) -> Result { + // Bare hostnames are the common way people quote a relay, and wss is + // the only sensible assumption for one. + guard let normalized = NostrRelayURL.normalized(rawValue, defaultScheme: "wss") else { + return .failure(.malformed) + } + + var current = customRelays(in: defaults) + guard !current.contains(normalized), !builtIn.contains(normalized) else { + return .failure(.alreadyPresent) + } + guard current.count < maxCustomRelays else { + return .failure(.limitReached) + } + + current.append(normalized) + write(current, in: defaults) + return .success(normalized) + } + + static func remove(_ url: String, in defaults: UserDefaults = .standard) { + // Same default scheme as `add`, so a relay entered as a bare hostname + // can be removed the way it was typed. + guard let normalized = NostrRelayURL.normalized(url, defaultScheme: "wss") else { return } + let remaining = customRelays(in: defaults).filter { $0 != normalized } + write(remaining, in: defaults) + } + + /// Panic-wipe hook: an added relay names somewhere someone chose to route + /// through, which is exactly the kind of trace a wipe should not leave. + static func reset(in defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: storageKey) + NotificationCenter.default.post(name: didChangeNotification, object: nil) + } + + private static func write(_ relays: [String], in defaults: UserDefaults) { + defaults.set(relays, forKey: storageKey) + NotificationCenter.default.post(name: didChangeNotification, object: nil) + } +} diff --git a/bitchat/Nostr/NostrRelayURL.swift b/bitchat/Nostr/NostrRelayURL.swift index be9a6fb6..eb2e2afc 100644 --- a/bitchat/Nostr/NostrRelayURL.swift +++ b/bitchat/Nostr/NostrRelayURL.swift @@ -39,13 +39,4 @@ enum NostrRelayURL { return components.string } - - static func directoryAddress(_ rawValue: String) -> String? { - guard var normalized = normalized(rawValue, defaultScheme: "wss") else { return nil } - for prefix in ["wss://", "ws://"] where normalized.hasPrefix(prefix) { - normalized.removeFirst(prefix.count) - break - } - return normalized - } } diff --git a/bitchat/Protocols/BitchatFilePacket.swift b/bitchat/Protocols/BitchatFilePacket.swift index 6002744d..7745b6c4 100644 --- a/bitchat/Protocols/BitchatFilePacket.swift +++ b/bitchat/Protocols/BitchatFilePacket.swift @@ -154,3 +154,90 @@ struct BitchatFilePacket { ) } } + +/// Wire-compatible identity for private media exchanged by clients using the +/// current iOS entropy-bearing filenames, without extending the deployed file +/// TLV. Android clients reject unknown file tags, so eligible senders and +/// receivers derive the receipt key from fields already on the wire. +/// +/// Locally-created image and voice-note filenames contain a UUID or live-voice +/// burst ID. Including the normalized direction keeps a reused filename +/// distinct across chats while allowing short and full Noise-key peer IDs to +/// converge. Android and older-iOS timestamp-only names remain ineligible and +/// retain their legacy random local IDs (transfer-compatible, no receipts). +enum PrivateMediaMessageIdentity { + private static let domain = Data("bitchat-private-media-message-v1".utf8) + private static let idPrefix = "media-" + private static let digestHexLength = 32 + + static func isStableID(_ candidate: String) -> Bool { + guard candidate.hasPrefix(idPrefix) else { return false } + let digest = candidate.dropFirst(idPrefix.count) + guard digest.utf8.count == digestHexLength else { return false } + return digest.utf8.allSatisfy { byte in + (UInt8(ascii: "0")...UInt8(ascii: "9")).contains(byte) + || (UInt8(ascii: "a")...UInt8(ascii: "f")).contains(byte) + } + } + + static func stableID( + senderPeerID: PeerID, + recipientPeerID: PeerID, + fileName: String? + ) -> String? { + guard let fileName, !fileName.isEmpty else { return nil } + let leafName = (fileName as NSString).lastPathComponent + guard leafName == fileName else { return nil } + + let path = leafName as NSString + let stem = path.deletingPathExtension + let fileExtension = path.pathExtension.lowercased() + switch true { + case stem.hasPrefix("img_"): + guard fileExtension == "jpg" || fileExtension == "jpeg" else { return nil } + case stem.hasPrefix("voice_"): + guard fileExtension == "m4a" else { return nil } + default: + return nil + } + let entropyToken = stem.split(separator: "_").last.map(String.init) + let hasUUIDEntropy = entropyToken.flatMap(UUID.init(uuidString:)) != nil + let voiceBurstID = stem.hasPrefix("voice_") + ? String(stem.dropFirst("voice_".count)) + : "" + let hasBurstEntropy = voiceBurstID.count == 16 + && voiceBurstID.allSatisfy(\.isHexDigit) + guard hasUUIDEntropy || hasBurstEntropy else { + return nil + } + + let fields = [ + Data(senderPeerID.toShort().bare.utf8), + Data(recipientPeerID.toShort().bare.utf8), + Data(leafName.utf8) + ] + var input = domain + for field in fields { + guard let length = UInt32(exactly: field.count) else { return nil } + var bigEndianLength = length.bigEndian + withUnsafeBytes(of: &bigEndianLength) { + input.append(contentsOf: $0) + } + input.append(field) + } + + return "\(idPrefix)\(input.sha256Hex().prefix(digestHexLength))" + } + + static func stableID( + for packet: BitchatFilePacket, + senderPeerID: PeerID, + recipientPeerID: PeerID + ) -> String? { + stableID( + senderPeerID: senderPeerID, + recipientPeerID: recipientPeerID, + fileName: packet.fileName + ) + } +} diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index 6c259f5f..608e85c2 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -38,11 +38,15 @@ /// 7. **Decoding**: Binary data parsed back to message objects /// /// ## Security Considerations -/// - Message padding (to 256/512/1024/2048-byte blocks) obscures actual content length +/// - Noise frames are padded (to 256/512/1024/2048-byte blocks) to obscure +/// content length; other packet types are not padded, so their payload +/// length is observable /// - Randomized relay jitter reduces the traffic-analysis signal; there is no /// cover traffic or per-message timing obfuscation /// - Integration with Noise Protocol for E2E encryption -/// - No persistent identifiers in protocol headers +/// - The 8-byte sender ID in every header IS a persistent identifier: it is +/// derived from the long-lived Noise static key and rotates only on a panic +/// wipe. Treat headers as linkable across sessions. /// /// ## Message Types /// - **Announce/Leave**: Peer presence notifications @@ -79,12 +83,35 @@ enum NoisePayloadType: UInt8 { case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update) // Live voice (push-to-talk) case voiceFrame = 0x08 // One live voice-burst packet (see VoiceBurstPacket) + // Finalized private media. `0x20` is the value already deployed by the + // Android client. The complete BitchatFilePacket is encrypted inside + // Noise before the outer noiseEncrypted packet is fragmented. + case privateFile = 0x20 + // Versioned peer state authenticated by the surrounding Noise session. + // This is intentionally distinct from the public announce: announce + // capabilities are discovery hints, while this payload proves possession + // of the advertised Noise static key before downgrade state is pinned. + case authenticatedPeerState = 0x21 // Verification (QR-based OOB binding) case verifyChallenge = 0x10 // Verification challenge case verifyResponse = 0x11 // Verification response // Transitive verification (web of trust) case vouch = 0x12 // Batch of vouch attestations + /// #1434 briefly used 0x09 before release. Accept it while prerelease + /// builds age out, but never emit it. Decoders canonicalize both values to + /// `.privateFile` so the compatibility alias cannot leak into app logic. + static let prereleasePrivateFileRawValue: UInt8 = 0x09 + + static func decoded(rawValue: UInt8) -> NoisePayloadType? { + rawValue == prereleasePrivateFileRawValue ? .privateFile : Self(rawValue: rawValue) + } + + static func isPrivateFile(rawValue: UInt8?) -> Bool { + guard let rawValue else { return false } + return rawValue == privateFile.rawValue || rawValue == prereleasePrivateFileRawValue + } + var description: String { switch self { case .privateMessage: return "privateMessage" @@ -93,6 +120,8 @@ enum NoisePayloadType: UInt8 { case .groupInvite: return "groupInvite" case .groupKeyUpdate: return "groupKeyUpdate" case .voiceFrame: return "voiceFrame" + case .privateFile: return "privateFile" + case .authenticatedPeerState: return "authenticatedPeerState" case .verifyChallenge: return "verifyChallenge" case .verifyResponse: return "verifyResponse" case .vouch: return "vouch" diff --git a/bitchat/Protocols/Packets.swift b/bitchat/Protocols/Packets.swift index 1f691dd4..d45f7323 100644 --- a/bitchat/Protocols/Packets.swift +++ b/bitchat/Protocols/Packets.swift @@ -156,6 +156,89 @@ struct AnnouncementPacket { } } +/// State that is authoritative only because it is carried inside an +/// established Noise session. The public announce remains useful for +/// discovery, but its self-signature cannot prove possession of the copied +/// Noise public key it contains. +/// +/// Wire format (v1): +/// `[version=0x01][type][length][value]...` +/// - TLV `0x01`: canonical minimal little-endian `PeerCapabilities` +/// - TLV `0x02`: 32-byte Ed25519 signing public key +/// +/// Unknown TLVs are skipped for forward compatibility. Unknown versions, +/// duplicates, non-canonical capability fields, and malformed lengths are +/// rejected without changing authenticated state. +struct AuthenticatedPeerStatePacket: Equatable { + static let currentVersion: UInt8 = 1 + static let signingPublicKeyLength = 32 + + let capabilities: PeerCapabilities + let signingPublicKey: Data + + private enum TLVType: UInt8 { + case capabilities = 0x01 + case signingPublicKey = 0x02 + } + + func encode() -> Data? { + guard signingPublicKey.count == Self.signingPublicKeyLength else { return nil } + let capabilityBytes = capabilities.encoded() + guard !capabilityBytes.isEmpty, capabilityBytes.count <= 8 else { return nil } + + var data = Data([Self.currentVersion]) + data.append(TLVType.capabilities.rawValue) + data.append(UInt8(capabilityBytes.count)) + data.append(capabilityBytes) + data.append(TLVType.signingPublicKey.rawValue) + data.append(UInt8(signingPublicKey.count)) + data.append(signingPublicKey) + return data + } + + static func decode(from data: Data) -> AuthenticatedPeerStatePacket? { + guard data.first == Self.currentVersion else { return nil } + + var offset = 1 + var capabilities: PeerCapabilities? + var signingPublicKey: Data? + + while offset < data.count { + guard offset + 2 <= data.count else { return nil } + let typeRaw = data[offset] + let length = Int(data[offset + 1]) + offset += 2 + guard offset + length <= data.count else { return nil } + let value = Data(data[offset..<(offset + length)]) + offset += length + + guard let type = TLVType(rawValue: typeRaw) else { + continue + } + switch type { + case .capabilities: + guard capabilities == nil, + !value.isEmpty, + value.count <= 8 else { return nil } + let decoded = PeerCapabilities(encoded: value) + guard decoded.encoded() == value else { return nil } + capabilities = decoded + + case .signingPublicKey: + guard signingPublicKey == nil, + value.count == Self.signingPublicKeyLength else { return nil } + signingPublicKey = value + } + } + + guard let capabilities, let signingPublicKey else { return nil } + return AuthenticatedPeerStatePacket( + capabilities: capabilities, + signingPublicKey: signingPublicKey + ) + } +} + struct PrivateMessagePacket { let messageID: String let content: String diff --git a/bitchat/Protocols/PeerCapabilities+Local.swift b/bitchat/Protocols/PeerCapabilities+Local.swift index 819464b9..d48891d7 100644 --- a/bitchat/Protocols/PeerCapabilities+Local.swift +++ b/bitchat/Protocols/PeerCapabilities+Local.swift @@ -3,5 +3,11 @@ import BitFoundation extension PeerCapabilities { /// Capabilities this build advertises in its announce packets. /// Each feature adds its bit here when it ships. - static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups] + static let localSupported: PeerCapabilities = [ + .vouch, + .prekeys, + .groups, + .privateMedia, + .privateMediaReceipts + ] } diff --git a/bitchat/Services/BLE/BLEAnnounceHandler.swift b/bitchat/Services/BLE/BLEAnnounceHandler.swift index 306831ce..08b603a4 100644 --- a/bitchat/Services/BLE/BLEAnnounceHandler.swift +++ b/bitchat/Services/BLE/BLEAnnounceHandler.swift @@ -14,8 +14,17 @@ struct BLEAnnounceHandlerEnvironment { let messageTTL: UInt8 /// Current time source. let now: () -> Date - /// Noise public key already recorded for the peer, if any (registry read). - let existingNoisePublicKey: (PeerID) -> Data? + /// Noise and signing public keys already recorded for the peer, if any + /// (single registry read so both come from one consistent snapshot). + let existingPeerKeys: (PeerID) -> (noisePublicKey: Data?, signingPublicKey: Data?) + /// Signing key from the persisted cryptographic identity for the peer, if + /// any. Registry pins do not survive app restarts or offline-peer + /// eviction; this fallback keeps the TOFU signing-key pin effective for + /// returning peers. + let persistedSigningPublicKey: (PeerID) -> Data? + /// Ed25519 key previously bound to this Noise identity by an authenticated + /// peer-state payload, if any (persistent identity-state read). + let authenticatedSigningPublicKey: (_ noisePublicKey: Data) -> Data? /// Verifies the packet signature against the announced signing key. let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool /// Direct link state for the peer (BLE-queue read). @@ -29,13 +38,15 @@ struct BLEAnnounceHandlerEnvironment { /// Runs the registry mutation phase under the collections barrier. let withRegistryBarrier: (() -> Void) -> Void /// Upserts the verified announce into the peer registry. + /// Returns `nil` when the registry refuses the announce because it carries + /// a signing key different from the one already pinned for this peer. /// Must only be called from inside `withRegistryBarrier`. let upsertVerifiedAnnounce: ( _ peerID: PeerID, _ announcement: AnnouncementPacket, _ isConnected: Bool, _ now: Date - ) -> BLEPeerAnnounceUpdate + ) -> BLEPeerAnnounceUpdate? /// Debounced reconnect-log decision. /// Must only be called from inside `withRegistryBarrier`. let shouldEmitReconnectLog: (_ peerID: PeerID, _ now: Date) -> Bool @@ -115,7 +126,16 @@ final class BLEAnnounceHandler { // Suppress announce logs to reduce noise // Precompute signature verification outside barrier to reduce contention - let existingNoisePublicKey = env.existingNoisePublicKey(peerID) + var existingPeerKeys = env.existingPeerKeys(peerID) + if existingPeerKeys.signingPublicKey == nil { + // The registry entry (and its signing-key pin) is dropped on app + // restart and offline-peer eviction, but the persisted + // cryptographic identity survives both. Fall back to it so a + // returning peer is not treated as first contact — otherwise an + // attacker could replay the peer's noiseKey/peerID with their own + // signing key and re-pin the identity (TOFU downgrade). + existingPeerKeys.signingPublicKey = env.persistedSigningPublicKey(peerID) + } let hasSignature = packet.signature != nil let signatureValid: Bool if hasSignature { @@ -129,13 +149,27 @@ final class BLEAnnounceHandler { let trustDecision = BLEAnnounceTrustPolicy.evaluate( hasSignature: hasSignature, signatureValid: signatureValid, - existingNoisePublicKey: existingNoisePublicKey, - announcedNoisePublicKey: announcement.noisePublicKey + existingNoisePublicKey: existingPeerKeys.noisePublicKey, + announcedNoisePublicKey: announcement.noisePublicKey, + existingSigningPublicKey: existingPeerKeys.signingPublicKey, + authenticatedSigningPublicKey: env.authenticatedSigningPublicKey( + announcement.noisePublicKey + ), + announcedSigningPublicKey: announcement.signingPublicKey ) if case .reject(.keyMismatch) = trustDecision { SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security) } - let verifiedAnnounce = trustDecision.isVerified + if case .reject(.signingKeyMismatch) = trustDecision { + SecureLogger.warning("🚨 Announce signing-key mismatch for \(peerID.id.prefix(8))… — refusing to replace pinned signing key (possible impersonation attempt)", category: .security) + } + if case .reject(.authenticatedSigningKeyMismatch) = trustDecision { + SecureLogger.warning( + "⚠️ Announce signing-key replacement rejected for Noise-authenticated peer \(peerID.id.prefix(8))…", + category: .security + ) + } + var verifiedAnnounce = trustDecision.isVerified var isNewPeer = false var isReconnectedPeer = false @@ -172,12 +206,22 @@ final class BLEAnnounceHandler { return } - let update = env.upsertVerifiedAnnounce( + // The registry re-checks the signing-key pin inside the barrier. + // The pre-barrier trust check reads the registry outside the + // barrier, so this closes the race where two announces for the + // same peer are evaluated concurrently. + guard let update = env.upsertVerifiedAnnounce( peerID, announcement, hasPeripheralConnection || hasCentralSubscription || (isDirectAnnounce && !linkBoundToOtherPeer), now - ) + ) else { + SecureLogger.warning("🚨 Registry refused announce for \(peerID.id.prefix(8))… — signing key differs from pinned key", category: .security) + verifiedAnnounce = false + isNewPeer = false + isReconnectedPeer = false + return + } isNewPeer = update.isNewPeer isReconnectedPeer = update.wasDisconnected diff --git a/bitchat/Services/BLE/BLEAnnounceHandlingPolicy.swift b/bitchat/Services/BLE/BLEAnnounceHandlingPolicy.swift index 9e81d9ce..67712046 100644 --- a/bitchat/Services/BLE/BLEAnnounceHandlingPolicy.swift +++ b/bitchat/Services/BLE/BLEAnnounceHandlingPolicy.swift @@ -56,6 +56,8 @@ enum BLEAnnounceTrustRejection: Equatable { case missingSignature case invalidSignature case keyMismatch + case signingKeyMismatch + case authenticatedSigningKeyMismatch } enum BLEAnnounceTrustDecision: Equatable { @@ -72,12 +74,34 @@ enum BLEAnnounceTrustPolicy { hasSignature: Bool, signatureValid: Bool, existingNoisePublicKey: Data?, - announcedNoisePublicKey: Data + announcedNoisePublicKey: Data, + existingSigningPublicKey: Data? = nil, + authenticatedSigningPublicKey: Data? = nil, + announcedSigningPublicKey: Data ) -> BLEAnnounceTrustDecision { if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey { return .reject(.keyMismatch) } + // Strongest binding first: an Ed25519 key bound to this Noise identity + // inside an authenticated Noise session can never be replaced by a + // merely self-signed announce. + if let authenticatedSigningPublicKey, + announcedSigningPublicKey != authenticatedSigningPublicKey { + return .reject(.authenticatedSigningKeyMismatch) + } + + // TOFU signing-key pinning. The packet signature only proves the + // announce is self-consistent — it is verified against the Ed25519 key + // carried *inside the same announce*. Since peerIDs derive from the + // broadcast (public) noise key, an attacker can replay a victim's + // peerID+noiseKey with their own signing key and a valid + // self-signature. Once we have bound a signing key to this peer, + // refuse to silently replace it. + if let existingSigningPublicKey, existingSigningPublicKey != announcedSigningPublicKey { + return .reject(.signingKeyMismatch) + } + guard hasSignature else { return .reject(.missingSignature) } diff --git a/bitchat/Services/BLE/BLEAnnounceThrottle.swift b/bitchat/Services/BLE/BLEAnnounceThrottle.swift index 6bd214b3..d6bf5490 100644 --- a/bitchat/Services/BLE/BLEAnnounceThrottle.swift +++ b/bitchat/Services/BLE/BLEAnnounceThrottle.swift @@ -1,6 +1,13 @@ import Foundation -struct BLEAnnounceThrottle { +/// Thread-safe announce admission state. +/// +/// Announce requests originate from the Bluetooth delegate queue, the +/// concurrent message queue, and the maintenance timer. Keeping the timestamp +/// behind a lock makes admission and maintenance snapshots atomic when those +/// request sources race. +final class BLEAnnounceThrottle: @unchecked Sendable { + private let lock = NSLock() private var lastSent: Date private let normalMinimumInterval: TimeInterval private let forcedMinimumInterval: TimeInterval @@ -16,16 +23,18 @@ struct BLEAnnounceThrottle { } func elapsed(since now: Date) -> TimeInterval { - now.timeIntervalSince(lastSent) + lock.withLock { now.timeIntervalSince(lastSent) } } - mutating func shouldSend(force: Bool, now: Date) -> Bool { - let minimumInterval = force ? forcedMinimumInterval : normalMinimumInterval - guard elapsed(since: now) >= minimumInterval else { - return false - } + func shouldSend(force: Bool, now: Date) -> Bool { + lock.withLock { + let minimumInterval = force ? forcedMinimumInterval : normalMinimumInterval + guard now.timeIntervalSince(lastSent) >= minimumInterval else { + return false + } - lastSent = now - return true + lastSent = now + return true + } } } diff --git a/bitchat/Services/BLE/BLEFileTransferHandler.swift b/bitchat/Services/BLE/BLEFileTransferHandler.swift index 015a1967..f451411c 100644 --- a/bitchat/Services/BLE/BLEFileTransferHandler.swift +++ b/bitchat/Services/BLE/BLEFileTransferHandler.swift @@ -16,6 +16,8 @@ struct BLEFileTransferHandlerEnvironment { let peersSnapshot: () -> [PeerID: BLEPeerInfo] /// Verifies a packet's signature against a candidate signing key (registry path). let verifyPacketSignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool + /// Local signing key used to authenticate our own gossip-sync replays. + let localSigningPublicKey: () -> Data /// Resolves a display name from a verified packet signature for peers missing from the registry. let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String? /// Tracks the broadcast file packet for gossip sync. @@ -30,10 +32,85 @@ struct BLEFileTransferHandlerEnvironment { _ fallbackExtension: String?, _ defaultPrefix: String ) -> URL? + /// Resolves the durable receiver decision for a stable private-media ID. + let privateMediaReceiptState: ( + _ messageID: String + ) -> BLEPrivateMediaReceiptState + /// Atomically records a stable private-media ID after the payload save. + let commitPrivateMediaFile: (_ messageID: String, _ storedURL: URL) -> Bool + /// Rolls back a saved payload when its durable receipt commit fails. + let removeIncomingFile: (_ storedURL: URL) -> Void + /// Releases the allocator's save-to-UI ownership guard after synchronous + /// conversation insertion has completed. + let finishIncomingFileDelivery: (_ storedURL: URL) -> Void + /// Checks the authenticated sender before any private-media disk work. + let isPrivateMediaSenderBlocked: (PeerID) -> Bool /// Updates the registry last-seen timestamp for the peer (async barrier write). let updatePeerLastSeen: (PeerID) -> Void - /// Delivers `.messageReceived` to the UI as one main-actor hop. - let deliverMessage: (BitchatMessage) -> Void + /// Acknowledges stable private media only after its synchronous + /// conversation delivery has completed. + let acknowledgePrivateMedia: (_ messageID: String, _ peerID: PeerID) -> Void + /// Delivers `.messageReceived` as one main-actor hop while + /// `shouldDeliver` remains true before and after the synchronous sink. + /// The completion authorizes the stable-media ACK. Finalization runs after + /// every delivery attempt, including rejection, so allocator ownership + /// cannot leak indefinitely. + let deliverMessage: ( + _ message: BitchatMessage, + _ shouldDeliver: @escaping () -> Bool, + _ completion: @escaping () -> Void, + _ finalization: @escaping (TransportEventDeliveryOutcome) -> Void + ) -> Void +} + +/// Process-lifetime reservation cache for stable private-media IDs. +/// +/// The first arrival reserves its ID before quota enforcement. Concurrent +/// arrivals remain coalesced in memory, while accepted state is resolved from +/// the durable ID-to-file ledger so it survives relaunch and becomes retryable +/// if quota cleanup removed the file. +private final class PrivateMediaArrivalDeduplicator { + enum Reservation { + case reserved + case pending + case accepted(URL) + case tombstoned + case unavailable + } + + private let lock = NSLock() + private var pending: Set = [] + + func reserve( + _ messageID: String, + receiptState: () -> BLEPrivateMediaReceiptState + ) -> Reservation { + lock.lock() + defer { lock.unlock() } + if pending.contains(messageID) { + return .pending + } + + switch receiptState() { + case .accepted(let existingURL): + return .accepted(existingURL) + case .tombstoned: + return .tombstoned + case .unavailable: + return .unavailable + case .absent: + break + } + + pending.insert(messageID) + return .reserved + } + + func finish(_ messageID: String) { + lock.lock() + defer { lock.unlock() } + pending.remove(messageID) + } } /// Orchestrates inbound file transfers: self-echo policy, sender display-name @@ -41,61 +118,206 @@ struct BLEFileTransferHandlerEnvironment { /// and UI delivery. final class BLEFileTransferHandler { private let environment: BLEFileTransferHandlerEnvironment + private let privateMediaArrivals = PrivateMediaArrivalDeduplicator() init(environment: BLEFileTransferHandlerEnvironment) { self.environment = environment } - /// Returns `false` when the packet fails sender authentication and must - /// not be relayed onward. Every other outcome returns `true`: files - /// directed to another peer are forwarded untouched, and local-only drops - /// (malformed payload, quota, save failure) don't affect multi-hop - /// delivery to nodes that may handle them fine. + /// Returns `false` when the raw packet fails sender authentication (or is + /// a live self-echo) and must not be relayed onward. Authentication runs + /// before the routing decision, so a forged directed packet cannot use a + /// node that is not its recipient as an unsigned forwarding hop. @discardableResult func handle(_ packet: BitchatPacket, from peerID: PeerID) -> Bool { let env = environment - if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return true } - - guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: env.localPeerID()) else { - return true - } - + let localPeerID = env.localPeerID() let peersSnapshot = env.peersSnapshot() - guard let senderNickname = resolveSenderNickname( + + guard let senderNickname = authenticatedRawSenderNickname( packet: packet, from: peerID, - isBroadcast: !deliveryPlan.isPrivateMessage, peers: peersSnapshot, env: env ) else { - SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security) + SecureLogger.warning("🚫 Dropping raw file transfer with missing/invalid signature from \(peerID.id.prefix(8))…", category: .security) return false } + if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: localPeerID) { + return false + } + + guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: localPeerID) else { + return true + } + if deliveryPlan.shouldTrackForSync { env.trackPacketSeen(packet) } + _ = storeIncomingPayload( + packet.payload, + from: peerID, + senderNickname: senderNickname, + timestamp: Date(timeIntervalSince1970: Double(packet.timestamp) / 1000), + isPrivate: deliveryPlan.isPrivateMessage, + usesDurableReceipts: false, + env: env + ) + // Once authenticated, a local decode/quota/save failure is not proof + // that downstream nodes should be denied the valid signed packet. + return true + } + + /// Accepts a file packet only after it has been authenticated and + /// decrypted by the peer's Noise session. The inner packet deliberately + /// has no redundant signature: Noise supplies sender authentication and + /// confidentiality, while this handler retains the same validation, + /// quota, persistence, and UI-delivery behavior as public files. + @discardableResult + func handlePrivatePayload(_ payload: Data, from peerID: PeerID, timestamp: Date) -> Bool { + let env = environment + let peers = env.peersSnapshot() + let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer( + peerID: peerID, + localPeerID: env.localPeerID(), + localNickname: env.localNickname(), + peers: peers, + allowConnectedUnverified: true + ) ?? BLEPeerSenderDisplayName.anonymousNickname(for: peerID) + + return storeIncomingPayload( + payload, + from: peerID, + senderNickname: senderNickname, + timestamp: timestamp, + isPrivate: true, + // Every authenticated Noise private-file keeps the stable ID/ACK + // contract introduced with capability bit 8. Bit 9 advertises + // sender-side automatic retry support; it must not downgrade + // prior iOS clients to random IDs or single-check delivery. + usesDurableReceipts: true, + env: env + ) + } + + private func storeIncomingPayload( + _ payload: Data, + from peerID: PeerID, + senderNickname: String, + timestamp: Date, + isPrivate: Bool, + usesDurableReceipts: Bool, + env: BLEFileTransferHandlerEnvironment + ) -> Bool { + + let localPeerID = env.localPeerID() let filePacket: BitchatFilePacket let mime: MimeType - switch BLEIncomingFileValidator.validate(payload: packet.payload) { + switch BLEIncomingFileValidator.validate(payload: payload) { case .success(let acceptance): filePacket = acceptance.filePacket mime = acceptance.mime case .failure(.malformedPayload): SecureLogger.error("❌ Failed to decode file transfer payload", category: .session) - return true + return false case .failure(.payloadTooLarge(let bytes)): SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security) - return true + return false case .failure(.unsupportedMime(let mimeType, let bytes)): SecureLogger.warning("🚫 MIME REJECT: '\(mimeType ?? "")' not supported. Size=\(bytes)b from \(peerID.id.prefix(8))...", category: .security) - return true + return false case .failure(.magicMismatch(let mime, let bytes, let prefixHex)): SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(bytes)b prefix=[\(prefixHex)] from \(peerID.id.prefix(8))...", category: .security) + return false + } + + if isPrivate, env.isPrivateMediaSenderBlocked(peerID) { + SecureLogger.debug( + "🚫 Dropping private media from blocked peer \(peerID.id.prefix(8))… before disk write", + category: .security + ) return true } + let messageID = usesDurableReceipts + ? PrivateMediaMessageIdentity.stableID( + for: filePacket, + senderPeerID: peerID, + recipientPeerID: localPeerID + ) + : nil + if let messageID { + switch privateMediaArrivals.reserve( + messageID, + receiptState: { env.privateMediaReceiptState(messageID) } + ) { + case .reserved: + break + case .pending: + // The first arrival has not reached durable storage yet. + // Coalesce this retry without ACKing so a failed first save + // remains retryable by the sender. + SecureLogger.debug( + "📁 Coalesced in-flight private media id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))…", + category: .session + ) + return true + case .accepted(let existingFile): + env.updatePeerLastSeen(peerID) + let message = incomingMessage( + messageID: messageID, + senderNickname: senderNickname, + timestamp: timestamp, + isPrivate: true, + peerID: peerID, + destination: existingFile, + category: storedMediaCategory( + for: existingFile, + fallback: mime.category + ), + env: env + ) + SecureLogger.debug( + "📁 Restored durable private media duplicate id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))… -> \(existingFile.lastPathComponent)", + category: .session + ) + deliverStableMessage( + message, + messageID: messageID, + peerID: peerID, + expectedURL: existingFile, + env: env + ) + return true + case .tombstoned: + // Explicit deletion is a durable terminal receiver decision. + env.updatePeerLastSeen(peerID) + env.acknowledgePrivateMedia(messageID, peerID) + SecureLogger.debug( + "📁 Dropped explicitly deleted private media id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))…", + category: .session + ) + return true + case .unavailable: + // Never turn an unreadable ledger into an empty ledger. A + // directory-level failure clears on retry; a quarantined + // record keeps exactly this ID fail-closed while every other + // payload still flows. + SecureLogger.warning( + "📁 Withholding private media id=\(messageID.prefix(12))… while durable receipt state is unavailable", + category: .session + ) + return true + } + } + defer { + if let messageID { + privateMediaArrivals.finish(messageID) + } + } + // BCH-01-002: Enforce storage quota before saving env.enforceStorageQuota(filePacket.content.count) @@ -106,82 +328,175 @@ final class BLEFileTransferHandler { mime.defaultExtension, mime.category.rawValue ) else { - return true + return false } - if deliveryPlan.isPrivateMessage { + if let messageID, + !env.commitPrivateMediaFile(messageID, destination) { + // A payload without its durable ID mapping cannot safely suppress + // a retry after relaunch. Roll it back and withhold UI/ACK. + env.removeIncomingFile(destination) + return false + } + + if isPrivate { env.updatePeerLastSeen(peerID) } - let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) - let message = BitchatMessage( - sender: senderNickname, - content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)", - timestamp: ts, - isRelay: false, - originalSender: nil, - isPrivate: deliveryPlan.isPrivateMessage, - recipientNickname: nil, - senderPeerID: peerID, - // Received messages need an explicit status: BitchatMessage - // defaults private messages to .sending, which the media views - // render as an in-flight send (empty reveal mask, disabled tap). - deliveryStatus: deliveryPlan.isPrivateMessage - ? .delivered(to: env.localNickname(), at: ts) - : nil + let message = incomingMessage( + messageID: messageID, + senderNickname: senderNickname, + timestamp: timestamp, + isPrivate: isPrivate, + peerID: peerID, + destination: destination, + category: mime.category, + env: env ) SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session) - env.deliverMessage(message) + if let messageID { + deliverStableMessage( + message, + messageID: messageID, + peerID: peerID, + expectedURL: destination, + env: env + ) + } else { + env.deliverMessage( + message, + { true }, + {}, + { outcome in + if outcome == .rejected { + // Raw media has no durable receipt that can redeliver + // it later. Do not leave a newly saved, UI-unowned file + // available for a stale fallback path to misidentify. + env.removeIncomingFile(destination) + } else { + // Plain delegates are invoked without synchronous + // insertion confirmation. Preserve the payload for + // that supported delivery path. + env.finishIncomingFileDelivery(destination) + } + } + ) + } return true } - /// Resolves the authenticated display name for a file transfer's sender. - /// - /// Directed (private) transfers are addressed to us specifically and keep - /// the lenient connected-peer path. Broadcast transfers carry an - /// attacker-controllable `senderID` exactly like public messages and public - /// voice frames — registry membership alone is NOT proof of identity, so a - /// valid packet signature from the claimed sender is required before we - /// trust it. Without this, a peer that observed a public voice burst could - /// spoof a broadcast `voice_.m4a` note under the talker's ID and - /// overwrite the signature-verified live bubble with attacker audio. - private func resolveSenderNickname( + private func deliverStableMessage( + _ message: BitchatMessage, + messageID: String, + peerID: PeerID, + expectedURL: URL, + env: BLEFileTransferHandlerEnvironment + ) { + env.deliverMessage( + message, + { + guard case .accepted(let resolvedURL) = + env.privateMediaReceiptState(messageID) else { + return false + } + return resolvedURL.standardizedFileURL + == expectedURL.standardizedFileURL + }, + { + env.acknowledgePrivateMedia(messageID, peerID) + }, + { _ in + env.finishIncomingFileDelivery(expectedURL) + } + ) + } + + private func incomingMessage( + messageID: String?, + senderNickname: String, + timestamp: Date, + isPrivate: Bool, + peerID: PeerID, + destination: URL, + category: MimeType.Category, + env: BLEFileTransferHandlerEnvironment + ) -> BitchatMessage { + BitchatMessage( + id: messageID, + sender: senderNickname, + content: "\(category.messagePrefix)\(destination.lastPathComponent)", + timestamp: timestamp, + isRelay: false, + originalSender: nil, + isPrivate: isPrivate, + recipientNickname: nil, + senderPeerID: peerID, + // Received messages need an explicit status: BitchatMessage + // defaults private messages to .sending, which media views render + // as an in-flight send. + deliveryStatus: isPrivate + ? .delivered(to: env.localNickname(), at: timestamp) + : nil + ) + } + + /// The durable URL is authoritative during reconstruction. A sender that + /// reuses a stable filename with a different MIME type must not change how + /// the already-stored payload renders. + private func storedMediaCategory( + for url: URL, + fallback: MimeType.Category + ) -> MimeType.Category { + let mediaDirectory = url + .deletingLastPathComponent() + .deletingLastPathComponent() + .lastPathComponent + switch mediaDirectory { + case MimeType.Category.audio.mediaDir: + return .audio + case MimeType.Category.image.mediaDir: + return .image + case MimeType.Category.file.mediaDir: + return .file + default: + return fallback + } + } + + /// Every remaining raw file transfer is signed, regardless of whether it + /// is broadcast, addressed to us, or merely passing through. Registry + /// signing keys are preferred; persisted identities cover peers that have + /// rotated or are not currently present in the registry. + private func authenticatedRawSenderNickname( packet: BitchatPacket, from peerID: PeerID, - isBroadcast: Bool, peers: [PeerID: BLEPeerInfo], env: BLEFileTransferHandlerEnvironment ) -> String? { - guard isBroadcast else { - return BLEPeerSenderDisplayName.resolveKnownPeer( - peerID: peerID, - localPeerID: env.localPeerID(), - localNickname: env.localNickname(), - peers: peers, - allowConnectedUnverified: true - ) ?? env.signedSenderDisplayName(packet, peerID) - } + guard packet.signature != nil else { return nil } - // Our own broadcasts replayed back via gossip sync (ttl==0) are - // trivially authentic and cannot be verified against the peer registry - // or identity cache, so exempt self exactly as `BLEPublicMessageHandler` - // does. Verify against the signing key already in the - // (synchronously-updated) registry first, then fall back to the - // persisted-identity signature lookup for peers not yet cached there. - let isSelf = peerID == env.localPeerID() - let registrySigningKey = peers[peerID]?.signingPublicKey - let verifiedViaRegistry = !isSelf && (registrySigningKey.map { env.verifyPacketSignature(packet, $0) } ?? false) - let signedDisplayName = (isSelf || verifiedViaRegistry) ? nil : env.signedSenderDisplayName(packet, peerID) - guard isSelf || verifiedViaRegistry || signedDisplayName != nil else { return nil } + let localPeerID = env.localPeerID() + let candidateKey = peerID == localPeerID + ? env.localSigningPublicKey() + : peers[peerID]?.signingPublicKey + let verifiedWithKnownKey = candidateKey.map { + env.verifyPacketSignature(packet, $0) + } ?? false + let signedDisplayName = verifiedWithKnownKey + ? nil + : env.signedSenderDisplayName(packet, peerID) + guard verifiedWithKnownKey || signedDisplayName != nil else { return nil } return BLEPeerSenderDisplayName.resolveKnownPeer( peerID: peerID, - localPeerID: env.localPeerID(), + localPeerID: localPeerID, localNickname: env.localNickname(), peers: peers, - allowConnectedUnverified: false - ) ?? signedDisplayName + // The packet signature authenticates the announced peer; the old + // connected-but-unsigned leniency is not involved. + allowConnectedUnverified: true + ) ?? signedDisplayName ?? BLEPeerSenderDisplayName.anonymousNickname(for: peerID) } } diff --git a/bitchat/Services/BLE/BLEFragmentAssemblyBuffer.swift b/bitchat/Services/BLE/BLEFragmentAssemblyBuffer.swift index 9550e196..31656cb4 100644 --- a/bitchat/Services/BLE/BLEFragmentAssemblyBuffer.swift +++ b/bitchat/Services/BLE/BLEFragmentAssemblyBuffer.swift @@ -201,8 +201,11 @@ struct BLEFragmentAssemblyBuffer { } private static func assemblyLimit(for originalType: UInt8) -> Int { - if originalType == MessageType.fileTransfer.rawValue { + if originalType == MessageType.fileTransfer.rawValue + || originalType == MessageType.noiseEncrypted.rawValue { // Allow headroom for TLV metadata and binary framing overhead. + // A large noiseEncrypted packet can be an E2E-encrypted private + // file; its authenticated plaintext is validated after decrypt. return FileTransferLimits.maxFramedFileBytes } diff --git a/bitchat/Services/BLE/BLEIncomingFileStore.swift b/bitchat/Services/BLE/BLEIncomingFileStore.swift index 214c3ba2..826a391c 100644 --- a/bitchat/Services/BLE/BLEIncomingFileStore.swift +++ b/bitchat/Services/BLE/BLEIncomingFileStore.swift @@ -2,9 +2,137 @@ import BitLogger import BitFoundation import Foundation -struct BLEIncomingFileStore { - private static let quotaBytes: Int64 = 100 * 1024 * 1024 +struct PanicRecoveryIntent { + let fileMarkerEstablished: Bool + let externalMarkerEstablished: Bool + var hasDurableMarker: Bool { + fileMarkerEstablished || externalMarkerEstablished + } +} + +/// Small, dependency-injectable transaction surface used by ChatViewModel. +/// Production persists the same intent in two independent locations before +/// any application state is erased. Tests can inject an ephemeral operation +/// set without touching the developer's Application Support directory. +struct PanicRecoveryOperations { + let isPending: () throws -> Bool + let begin: () -> PanicRecoveryIntent + let wipeMedia: (PanicRecoveryIntent) throws -> Void + let complete: () throws -> Void + + static func ephemeral( + wipeMedia: @escaping () throws -> Void = {} + ) -> PanicRecoveryOperations { + PanicRecoveryOperations( + isPending: { false }, + begin: { + PanicRecoveryIntent( + fileMarkerEstablished: false, + externalMarkerEstablished: false + ) + }, + wipeMedia: { _ in try wipeMedia() }, + complete: {} + ) + } + + static func live( + fileStore: BLEIncomingFileStore = BLEIncomingFileStore(), + defaults: UserDefaults = .standard + ) -> PanicRecoveryOperations { + let defaultsKey = "bitchat.panicResetPending" + return PanicRecoveryOperations( + isPending: { + if defaults.bool(forKey: defaultsKey) { + return true + } + return try fileStore.isPanicRecoveryPending() + }, + begin: { + defaults.set(true, forKey: defaultsKey) + let externalMarkerEstablished = + defaults.synchronize() + && defaults.bool(forKey: defaultsKey) + + let fileMarkerEstablished: Bool + do { + try fileStore.markPanicRecoveryPending() + fileMarkerEstablished = true + } catch { + fileMarkerEstablished = false + SecureLogger.error( + "Failed to persist file panic-recovery marker: \(error)", + category: .security + ) + } + + return PanicRecoveryIntent( + fileMarkerEstablished: fileMarkerEstablished, + externalMarkerEstablished: externalMarkerEstablished + ) + }, + wipeMedia: { intent in + try fileStore.panicWipe( + hasDurablePendingMarker: intent.hasDurableMarker + ) + }, + complete: { + // Keep the independent defaults latch until the file marker + // has definitely cleared. Any failure therefore remains + // visible to the next launch. + try fileStore.completePanicRecovery() + defaults.removeObject(forKey: defaultsKey) + guard defaults.synchronize(), + !defaults.bool(forKey: defaultsKey) else { + throw BLEIncomingFileStore.PanicRecoveryError + .externalMarkerCommitFailed + } + } + ) + } +} + +struct BLEIncomingFileStore: @unchecked Sendable { + enum PanicRecoveryError: Error { + case externalMarkerCommitFailed + case markerWriteFailed(Error) + case markerWriteAndMediaWipeFailed( + markerError: Error, + mediaError: Error + ) + } + + struct PrivateMediaDeletionReservation: Sendable { + fileprivate let id: UUID + } + + private final class PayloadCoordination: @unchecked Sendable { + let lock = NSLock() + var pendingDeliveryPaths: Set = [] + var deletionReservations: [UUID: Set] = [:] + } + + private static let defaultQuotaBytes: Int64 = 100 * 1024 * 1024 + /// How long managed media may stay on disk. Bounds by age what the quota + /// only bounds by size; see `expireAgedMedia(retention:)`. + static let defaultMediaRetention: TimeInterval = 7 * 24 * 60 * 60 + /// Kept outside `files/` so deleting the media tree cannot erase the + /// fail-closed startup decision before the full panic has committed. + private static let panicRecoveryPendingMarkerFileName = + ".panic-recovery-pending" + /// Compatibility with a short-lived development build that used the + /// media-specific name for the same full-transaction latch. + private static let legacyPanicRecoveryPendingMarkerFileName = + ".panic-media-wipe-pending" + private static let mediaSubdirectories = [ + "voicenotes/incoming", + "voicenotes/outgoing", + "images/incoming", + "images/outgoing", + "files/incoming", + "files/outgoing" + ] /// Name prefix of in-flight live voice captures (progressively written by /// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern — /// deleting one mid-stream unlinks the inode under an open `FileHandle` @@ -17,11 +145,123 @@ struct BLEIncomingFileStore { let fileManager: FileManager private let baseDirectory: URL? private let dateProvider: () -> Date + private let panicMarkerWriter: (Data, URL) throws -> Void + private let quotaBytes: Int64 + private let privateMediaReceipts: BLEPrivateMediaReceiptStore + private let payloadCoordination: PayloadCoordination - init(fileManager: FileManager = .default, baseDirectory: URL? = nil, dateProvider: @escaping () -> Date = Date.init) { + init( + fileManager: FileManager = .default, + baseDirectory: URL? = nil, + dateProvider: @escaping () -> Date = Date.init, + panicMarkerWriter: @escaping (Data, URL) throws -> Void = { + try $0.write(to: $1, options: .atomic) + }, + quotaBytes: Int64 = Self.defaultQuotaBytes + ) { self.fileManager = fileManager self.baseDirectory = baseDirectory self.dateProvider = dateProvider + self.panicMarkerWriter = panicMarkerWriter + self.quotaBytes = max(0, quotaBytes) + self.privateMediaReceipts = BLEPrivateMediaReceiptStore( + fileManager: fileManager, + baseDirectory: baseDirectory, + now: dateProvider + ) + self.payloadCoordination = PayloadCoordination() + } + + /// Panic-wipe every managed incoming and outgoing media artifact before + /// returning. Recreating the directory tree keeps later capture/receive + /// paths usable without allowing a detached cleanup task to race them. + /// + /// Marker persistence and deletion are deliberately separate error + /// domains: even when both durable marker channels fail, deletion is + /// still attempted before this method reports the marker failure. + func panicWipe( + hasDurablePendingMarker: Bool = false + ) throws { + // The receipt index caches tombstones as well as accepted payloads, + // while payload coordination retains save/delete reservations. Always + // invalidate both on return, including partial-failure paths, so no + // pre-panic receiver decision survives after identity reset. + defer { + privateMediaReceipts.resetForPanic() + payloadCoordination.lock.lock() + payloadCoordination.pendingDeliveryPaths.removeAll( + keepingCapacity: false + ) + payloadCoordination.deletionReservations.removeAll( + keepingCapacity: false + ) + payloadCoordination.lock.unlock() + } + + let markerError: Error? + do { + try markPanicRecoveryPending() + markerError = nil + } catch { + markerError = error + SecureLogger.error( + "Could not persist file panic-recovery marker; attempting media deletion anyway: \(error)", + category: .security + ) + } + + do { + let filesDirectory = try rootDirectory() + .appendingPathComponent("files", isDirectory: true) + if fileManager.fileExists(atPath: filesDirectory.path) { + try fileManager.removeItem(at: filesDirectory) + } + for subdirectory in Self.mediaSubdirectories { + try fileManager.createDirectory( + at: filesDirectory.appendingPathComponent( + subdirectory, + isDirectory: true + ), + withIntermediateDirectories: true, + attributes: nil + ) + } + } catch { + if let markerError { + throw PanicRecoveryError.markerWriteAndMediaWipeFailed( + markerError: markerError, + mediaError: error + ) + } + throw error + } + + if let markerError, !hasDurablePendingMarker { + throw PanicRecoveryError.markerWriteFailed(markerError) + } + } + + func markPanicRecoveryPending() throws { + let markerURL = try panicRecoveryPendingMarkerURL() + try fileManager.createDirectory( + at: markerURL.deletingLastPathComponent(), + withIntermediateDirectories: true, + attributes: nil + ) + try panicMarkerWriter(Data([1]), markerURL) + } + + func isPanicRecoveryPending() throws -> Bool { + try panicRecoveryMarkerURLs().contains { + fileManager.fileExists(atPath: $0.path) + } + } + + func completePanicRecovery() throws { + for markerURL in try panicRecoveryMarkerURLs() + where fileManager.fileExists(atPath: markerURL.path) { + try fileManager.removeItem(at: markerURL) + } } /// Resolves (and creates) an incoming-media directory for callers that @@ -39,6 +279,9 @@ struct BLEIncomingFileStore { fallbackExtension: String?, defaultPrefix: String ) -> URL? { + payloadCoordination.lock.lock() + defer { payloadCoordination.lock.unlock() } + do { let base = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true) try fileManager.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil) @@ -47,8 +290,26 @@ struct BLEIncomingFileStore { defaultName: "\(defaultPrefix)_\(Self.timestampString(from: dateProvider()))", fallbackExtension: fallbackExtension ) - let destination = uniqueFileURL(in: base, fileName: sanitized) + let reservedPaths = privateMediaReceipts.reservedPayloadPaths() + let deletionPaths = payloadCoordination + .deletionReservations.values.reduce(into: Set()) { + $0.formUnion($1) + } + let allocationReservations = deletionPaths.union( + payloadCoordination.pendingDeliveryPaths + ) + let destination = uniqueFileURL( + in: base, + fileName: sanitized, + reservedPaths: (reservedPaths ?? []).union( + allocationReservations + ), + forceRandomizedName: reservedPaths == nil + ) try data.write(to: destination, options: .atomic) + payloadCoordination.pendingDeliveryPaths.insert( + destination.standardizedFileURL.path + ) return destination } catch { SecureLogger.error("❌ Failed to persist incoming media: \(error)", category: .session) @@ -56,12 +317,199 @@ struct BLEIncomingFileStore { } } + /// Drops THIS instance's in-memory receipt index after a panic wipe. + /// + /// `panicWipe` already resets the receipt store it runs on, but the + /// production wipe runs on the `PanicRecoveryOperations.live()` file + /// store while receipt lookups are served by `BLEService`'s own + /// `incomingFileStore`. The service's panic path must invalidate its own + /// cache explicitly or pre-panic decisions survive in memory. + func resetPrivateMediaReceiptsForPanic() { + privateMediaReceipts.resetForPanic() + } + + func privateMediaReceiptState( + messageID: String + ) -> BLEPrivateMediaReceiptState { + privateMediaReceipts.state(for: messageID) + } + + func commitPrivateMediaFile( + messageID: String, + storedURL: URL + ) -> Bool { + privateMediaReceipts.commitAccepted( + messageID: messageID, + storedURL: storedURL + ) + } + + /// Reserves every receipt/UI path before the asynchronous deletion + /// barrier. Allocation and reservation share one lock, so either an + /// in-flight raw arrival is observed and deletion fails closed, or the + /// arrival is forced onto a different filename. + func reservePrivateMediaDeletion( + messageIDs: [String], + payloadRelativePaths: [String: String] + ) -> PrivateMediaDeletionReservation? { + payloadCoordination.lock.lock() + defer { payloadCoordination.lock.unlock() } + + guard let paths = privateMediaReceipts + .prospectiveDeletionPayloadPaths( + messageIDs: messageIDs, + payloadRelativePaths: payloadRelativePaths + ), + paths.isDisjoint( + with: payloadCoordination.pendingDeliveryPaths + ) else { + return nil + } + + let reservation = PrivateMediaDeletionReservation(id: UUID()) + payloadCoordination.deletionReservations[reservation.id] = paths + return reservation + } + + func commitPrivateMediaDeletion( + reservation: PrivateMediaDeletionReservation, + messageIDs: [String], + payloadRelativePaths: [String: String], + protectedPayloadRelativePaths: Set + ) -> Bool { + payloadCoordination.lock.lock() + defer { + payloadCoordination.deletionReservations.removeValue( + forKey: reservation.id + ) + payloadCoordination.lock.unlock() + } + guard payloadCoordination.deletionReservations[reservation.id] != nil + else { + return false + } + return privateMediaReceipts.recordDeleted( + messageIDs: messageIDs, + payloadRelativePaths: payloadRelativePaths, + protectedPayloadRelativePaths: protectedPayloadRelativePaths + ) + } + + /// 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 + /// conversation insertion. Before this callback, a deletion transaction + /// may not infer ownership from a stale bubble that names the same path. + func finishIncomingFileDelivery(at storedURL: URL) { + payloadCoordination.lock.lock() + defer { payloadCoordination.lock.unlock() } + payloadCoordination.pendingDeliveryPaths.remove( + storedURL.standardizedFileURL.path + ) + } + + /// Best-effort rollback for a payload whose durable receipt commit failed. + func removeIncomingFile(at storedURL: URL) { + payloadCoordination.lock.lock() + defer { payloadCoordination.lock.unlock() } + payloadCoordination.pendingDeliveryPaths.remove( + storedURL.standardizedFileURL.path + ) + guard isURLInsideFilesDirectory(storedURL) else { return } + do { + try fileManager.removeItem(at: storedURL) + } catch { + SecureLogger.warning( + "⚠️ Failed to roll back uncommitted incoming media: \(error)", + category: .session + ) + } + } + /// Frees least-recently-modified incoming files until `reservingBytes` /// fits under the quota. Files named `voice_live_*` (in-flight live /// captures) are never evicted regardless of who triggers enforcement — /// a finalized transfer can arrive at quota while a burst is still /// streaming — but they still count toward usage. func enforceQuota(reservingBytes: Int) { + payloadCoordination.lock.lock() + defer { payloadCoordination.lock.unlock() } + do { let base = try filesDirectory() let incomingDirs = [ @@ -87,14 +535,26 @@ struct BLEIncomingFileStore { } let currentUsage = allFiles.reduce(0) { $0 + $1.size } - let targetUsage = Self.quotaBytes - Int64(reservingBytes) + let targetUsage = quotaBytes - Int64(reservingBytes) guard currentUsage > targetUsage else { return } let needToFree = currentUsage - targetUsage + let activeDeletionPaths = payloadCoordination + .deletionReservations.values.reduce(into: Set()) { + $0.formUnion($1) + } + let protectedPaths = activeDeletionPaths.union( + payloadCoordination.pendingDeliveryPaths + ) var freedSpace: Int64 = 0 for file in allFiles.sorted(by: { $0.modified < $1.modified }) { guard freedSpace < needToFree else { break } guard !file.url.lastPathComponent.hasPrefix(Self.liveCapturePrefix) else { continue } + guard !protectedPaths.contains( + file.url.standardizedFileURL.path + ) else { + continue + } do { try fileManager.removeItem(at: file.url) freedSpace += file.size @@ -112,16 +572,125 @@ struct BLEIncomingFileStore { } } + /// Deletes managed media older than `retention`, across both incoming and + /// outgoing directories, and reports how many files went away. + /// + /// The quota sweep above only bounds *size*, and only for incoming files, + /// so a received photo or a sent voice note could sit on disk unbounded in + /// time — long outliving the conversation it belonged to, which is what a + /// seized device gives up. This bounds media by age instead, on the same + /// principle as the courier envelope and gossip archive lifetimes. + /// + /// Honors the same exclusions as quota eviction: in-flight live captures + /// and files reserved by an in-progress delivery or deletion are left + /// alone regardless of age. + @discardableResult + func expireAgedMedia(retention: TimeInterval = Self.defaultMediaRetention) -> Int { + guard retention > 0 else { return 0 } + + payloadCoordination.lock.lock() + defer { payloadCoordination.lock.unlock() } + + let cutoff = dateProvider().addingTimeInterval(-retention) + let activeDeletionPaths = payloadCoordination + .deletionReservations.values.reduce(into: Set()) { + $0.formUnion($1) + } + let protectedPaths = activeDeletionPaths.union( + payloadCoordination.pendingDeliveryPaths + ) + + var removed = 0 + do { + let base = try filesDirectory() + for subdirectory in Self.mediaSubdirectories { + let dir = base.appendingPathComponent(subdirectory, isDirectory: true) + guard fileManager.fileExists(atPath: dir.path) else { continue } + guard let contents = try? fileManager.contentsOfDirectory( + at: dir, + includingPropertiesForKeys: [.contentModificationDateKey], + options: [.skipsHiddenFiles] + ) else { continue } + + for fileURL in contents { + guard let modified = try? fileURL.resourceValues( + forKeys: [.contentModificationDateKey] + ).contentModificationDate else { continue } + guard modified < cutoff else { continue } + guard !fileURL.lastPathComponent.hasPrefix(Self.liveCapturePrefix) else { continue } + guard !protectedPaths.contains( + fileURL.standardizedFileURL.path + ) else { continue } + + do { + try fileManager.removeItem(at: fileURL) + removed += 1 + } catch { + SecureLogger.warning( + "⚠️ Failed to expire aged media file: \(error)", + category: .security + ) + } + } + } + } catch { + SecureLogger.warning( + "⚠️ Could not expire aged media: \(error)", + category: .security + ) + return removed + } + + if removed > 0 { + SecureLogger.info( + "🗑️ Expired \(removed) media file(s) older than the retention window", + category: .security + ) + } + return removed + } + private func filesDirectory() throws -> URL { - let root = try baseDirectory ?? fileManager.url( + let filesDir = try rootDirectory().appendingPathComponent("files", isDirectory: true) + try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil) + return filesDir + } + + private func rootDirectory() throws -> URL { + try baseDirectory ?? fileManager.url( for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true ) - let filesDir = root.appendingPathComponent("files", isDirectory: true) - try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil) - return filesDir + } + + private func panicRecoveryPendingMarkerURL() throws -> URL { + try rootDirectory().appendingPathComponent( + Self.panicRecoveryPendingMarkerFileName, + isDirectory: false + ) + } + + private func panicRecoveryMarkerURLs() throws -> [URL] { + let root = try rootDirectory() + return [ + root.appendingPathComponent( + Self.panicRecoveryPendingMarkerFileName, + isDirectory: false + ), + root.appendingPathComponent( + Self.legacyPanicRecoveryPendingMarkerFileName, + isDirectory: false + ) + ] + } + + private func isURLInsideFilesDirectory(_ url: URL) -> Bool { + guard let filesDirectory = try? filesDirectory().standardizedFileURL else { + return false + } + return url.standardizedFileURL.path.hasPrefix(filesDirectory.path + "/") } private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String { @@ -151,11 +720,20 @@ struct BLEIncomingFileStore { return candidate.isEmpty ? defaultName : candidate } - private func uniqueFileURL(in directory: URL, fileName: String) -> URL { + private func uniqueFileURL( + in directory: URL, + fileName: String, + reservedPaths: Set, + forceRandomizedName: Bool + ) -> URL { let directoryPath = directory.standardizedFileURL.path func isInsideDirectory(_ url: URL) -> Bool { url.standardizedFileURL.path.hasPrefix(directoryPath + "/") } + func isAvailable(_ url: URL) -> Bool { + !reservedPaths.contains(url.standardizedFileURL.path) + && !fileManager.fileExists(atPath: url.path) + } var candidate = directory.appendingPathComponent(fileName) guard isInsideDirectory(candidate) else { @@ -163,19 +741,27 @@ struct BLEIncomingFileStore { return directory.appendingPathComponent("blocked_\(UUID().uuidString)") } - if !fileManager.fileExists(atPath: candidate.path) { + let baseName = (fileName as NSString).deletingPathExtension + let ext = (fileName as NSString).pathExtension + if forceRandomizedName { + let suffix = UUID().uuidString + let randomizedName = ext.isEmpty + ? "\(baseName)_\(suffix)" + : "\(baseName)_\(suffix).\(ext)" + return directory.appendingPathComponent(randomizedName) + } + + if isAvailable(candidate) { return candidate } - let baseName = (fileName as NSString).deletingPathExtension - let ext = (fileName as NSString).pathExtension for counter in 1..<100 { let newName = ext.isEmpty ? "\(baseName) (\(counter))" : "\(baseName) (\(counter)).\(ext)" candidate = directory.appendingPathComponent(newName) guard isInsideDirectory(candidate) else { return directory.appendingPathComponent("blocked_\(UUID().uuidString)") } - if !fileManager.fileExists(atPath: candidate.path) { + if isAvailable(candidate) { return candidate } } diff --git a/bitchat/Services/BLE/BLELocalIdentityStateStore.swift b/bitchat/Services/BLE/BLELocalIdentityStateStore.swift new file mode 100644 index 00000000..b379b0cd --- /dev/null +++ b/bitchat/Services/BLE/BLELocalIdentityStateStore.swift @@ -0,0 +1,55 @@ +import BitFoundation +import Foundation + +struct BLELocalIdentitySnapshot: Equatable, Sendable { + let peerID: PeerID + let peerIDData: Data + let nickname: String +} + +/// Lock-backed local identity state shared by the transport's message, +/// Bluetooth, maintenance, and main-actor entry points. +/// +/// `peerID` and its binary wire representation must change as one unit during +/// panic rotation. A snapshot also gives announce construction one consistent +/// view of the nickname and identity instead of reading three independently +/// mutable properties across queues. +final class BLELocalIdentityStateStore: @unchecked Sendable { + private let lock = NSLock() + private var state: BLELocalIdentitySnapshot + + init( + peerID: PeerID = PeerID(str: ""), + nickname: String = "anon" + ) { + state = BLELocalIdentitySnapshot( + peerID: peerID, + peerIDData: Data(hexString: peerID.id) ?? Data(), + nickname: nickname + ) + } + + func snapshot() -> BLELocalIdentitySnapshot { + lock.withLock { state } + } + + func setNickname(_ nickname: String) { + lock.withLock { + state = BLELocalIdentitySnapshot( + peerID: state.peerID, + peerIDData: state.peerIDData, + nickname: nickname + ) + } + } + + func replacePeerIdentity(with peerID: PeerID) { + lock.withLock { + state = BLELocalIdentitySnapshot( + peerID: peerID, + peerIDData: Data(hexString: peerID.id) ?? Data(), + nickname: state.nickname + ) + } + } +} diff --git a/bitchat/Services/BLE/BLENoisePacketHandler.swift b/bitchat/Services/BLE/BLENoisePacketHandler.swift index bddbc053..9314f666 100644 --- a/bitchat/Services/BLE/BLENoisePacketHandler.swift +++ b/bitchat/Services/BLE/BLENoisePacketHandler.swift @@ -1,7 +1,18 @@ import BitFoundation import BitLogger +import CryptoKit import Foundation +struct BLENoiseHandshakeHandlingResult { + let processed: Bool + let didEstablishAuthenticatedSession: Bool +} + +struct BLENoiseDecryptionResult { + let plaintext: Data + let sessionGeneration: UUID +} + /// Narrow environment for `BLENoisePacketHandler`. /// /// All queue hops (collections barrier writes, main-actor UI notification) @@ -16,10 +27,15 @@ struct BLENoisePacketHandlerEnvironment { let messageTTL: UInt8 /// Current time source. let now: () -> Date - /// Processes an inbound handshake message, returning an optional response payload (crypto). - let processHandshakeMessage: (_ peerID: PeerID, _ message: Data) throws -> Data? + /// Processes an inbound handshake message, returning its optional response + /// and whether that exact candidate authenticated (crypto). + let processHandshakeMessage: + (_ peerID: PeerID, _ message: Data) throws + -> NoiseHandshakeProcessingResult /// Whether any Noise session (established or pending) exists for the peer (crypto). let hasNoiseSession: (PeerID) -> Bool + /// Whether an inbound ordinary XX responder is waiting for message 3. + let isAwaitingResponderHandshakeCompletion: (PeerID) -> Bool /// Initiates a fresh Noise handshake with the peer (crypto + send). let initiateHandshake: (PeerID) -> Void /// Broadcasts a packet on the mesh (caller is already on the message queue). @@ -27,9 +43,16 @@ struct BLENoisePacketHandlerEnvironment { /// Updates the registry last-seen timestamp for the peer (async barrier write). let updatePeerLastSeen: (PeerID) -> Void /// Decrypts an encrypted payload from the peer (crypto). - let decrypt: (_ payload: Data, _ peerID: PeerID) throws -> Data + let decrypt: (_ payload: Data, _ peerID: PeerID) throws -> BLENoiseDecryptionResult /// Clears the peer's Noise session after an unrecoverable decrypt failure (crypto). let clearSession: (PeerID) -> Void + /// Consumes session-authenticated protocol state inside the transport. It + /// must never escape to UI or Nostr payload dispatch. + let handleAuthenticatedPeerState: ( + _ peerID: PeerID, + _ payload: Data, + _ sessionGeneration: UUID + ) -> Void /// Delivers `.noisePayloadReceived` to the UI as one main-actor hop. let deliverNoisePayload: ( _ peerID: PeerID, @@ -43,19 +66,55 @@ struct BLENoisePacketHandlerEnvironment { /// processing (with response), encrypted payload decryption and dispatch, /// and session recovery on decrypt failure. final class BLENoisePacketHandler { + private struct DeferredCiphertext { + let packet: BitchatPacket + let receivedAt: Date + } + + /// Early post-handshake packets are normally tiny control messages or + /// queued DMs. Keep the recovery surface deliberately small so an + /// unauthenticated half-handshake cannot create an unbounded memory queue. + private static let maxDeferredPacketsPerPeer = 4 + private static let maxDeferredPacketsGlobal = 32 + /// One legacy sender can immediately follow message 3 with the largest + /// valid private-file ciphertext and has no application-level retry. Keep + /// room for that packet plus a small control-message budget. + private static let maxDeferredBytes = + NoiseSecurityConstants.maxPrivateFileCiphertextSize + 256 * 1024 + private static let deferredLifetime = + NoiseSecurityConstants.ordinaryResponderHandshakeTimeout + private let environment: BLENoisePacketHandlerEnvironment + private let deferredLock = NSLock() + private var deferredCiphertexts: [PeerID: [DeferredCiphertext]] = [:] + private var deferredCiphertextBytes = 0 init(environment: BLENoisePacketHandlerEnvironment) { self.environment = environment } - func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) { + /// Returns true when the handshake message was processed successfully. + /// Callers use this to distinguish an authenticated reconnect completion + /// from a rejected ordinary responder while rollback state is restored. + @discardableResult + func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) -> Bool { + handleHandshakeWithResult(packet, from: peerID).processed + } + + func handleHandshakeWithResult( + _ packet: BitchatPacket, + from peerID: PeerID + ) -> BLENoiseHandshakeHandlingResult { let env = environment // Use NoiseEncryptionService for handshake processing if PeerID(hexData: packet.recipientID) == env.localPeerID() { // Handshake is for us do { - if let response = try env.processHandshakeMessage(peerID, packet.payload) { + let result = try env.processHandshakeMessage( + peerID, + packet.payload + ) + if let response = result.response { // Send response let responsePacket = BitchatPacket( type: MessageType.noiseHandshake.rawValue, @@ -70,19 +129,76 @@ final class BLENoisePacketHandler { env.broadcastPacket(responsePacket) } - // Session establishment will trigger onPeerAuthenticated callback - // which will send any pending messages at the right time + // The serialized authentication callback installs transport + // state before it drains any bounded early ciphertext. + return BLENoiseHandshakeHandlingResult( + processed: true, + didEstablishAuthenticatedSession: + result.didEstablishAuthenticatedSession + ) + } catch let managedFailure as NoiseManagedHandshakeFailure { + SecureLogger.error( + "Failed to process handshake; manager owns recovery: \(managedFailure.underlying)" + ) + return BLENoiseHandshakeHandlingResult( + processed: false, + didEstablishAuthenticatedSession: false + ) + } catch NoiseSessionError.peerIdentityMismatch { + // The responder was already discarded by the session manager. + // Do not let a spoofed claimed ID trigger a fresh outbound + // handshake or recreate state for the attacker-selected ID. + SecureLogger.warning( + "Rejected Noise handshake whose static key does not match \(peerID.id.prefix(8))…", + category: .security + ) + return BLENoiseHandshakeHandlingResult( + processed: false, + didEstablishAuthenticatedSession: false + ) } catch { SecureLogger.error("Failed to process handshake: \(error)") // Try initiating a new handshake if !env.hasNoiseSession(peerID) { env.initiateHandshake(peerID) } + return BLENoiseHandshakeHandlingResult( + processed: false, + didEstablishAuthenticatedSession: false + ) } } + return BLENoiseHandshakeHandlingResult( + processed: false, + didEstablishAuthenticatedSession: false + ) } func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) { + handleEncrypted(packet, from: peerID, isDeferredRetry: false) + } + + /// Called by the transport's serialized authentication callback after it + /// has installed state for the promoted or restored session generation. + func handleSessionAuthenticated(_ peerID: PeerID) { + drainDeferredCiphertextsIfReady(for: peerID) + } + + /// Synchronously discards ciphertext retained for a pre-panic Noise + /// generation. The handler survives the service's identity replacement, + /// so keeping this queue would replay old bytes after post-panic auth. + func resetForPanic() { + deferredLock.lock() + deferredCiphertexts.removeAll(keepingCapacity: false) + deferredCiphertextBytes = 0 + deferredLock.unlock() + } + + private func handleEncrypted( + _ packet: BitchatPacket, + from peerID: PeerID, + isDeferredRetry: Bool + ) { let env = environment guard let recipientID = PeerID(hexData: packet.recipientID) else { SecureLogger.warning("⚠️ Encrypted message has no recipient ID", category: .session) @@ -98,35 +214,231 @@ final class BLENoisePacketHandler { env.updatePeerLastSeen(peerID) do { - let decrypted = try env.decrypt(packet.payload, peerID) + let decryption = try env.decrypt(packet.payload, peerID) + let decrypted = decryption.plaintext guard decrypted.count > 0 else { return } // First byte indicates the payload type let payloadType = decrypted[0] let payloadData = decrypted.dropFirst() - guard let noisePayloadType = NoisePayloadType(rawValue: payloadType) else { + guard let noisePayloadType = NoisePayloadType.decoded(rawValue: payloadType) else { SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)") return } SecureLogger.debug("🔐 Decrypted noise payload type \(noisePayloadType.description) from \(peerID.id.prefix(8))…", category: .session) + if noisePayloadType == .authenticatedPeerState { + env.handleAuthenticatedPeerState( + peerID, + Data(payloadData), + decryption.sessionGeneration + ) + return + } + let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) env.deliverNoisePayload(peerID, noisePayloadType, Data(payloadData), ts) + } catch NoiseEncryptionError.transportGenerationNotReady { + if isDeferredRetry { + SecureLogger.warning( + "Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… because its authenticated transport generation changed again", + category: .session + ) + return + } + // The manager promoted or restored keys before BLE's serialized + // callback installed generation-bound transport state. The + // manager rejected this before decrypting, so replay is safe. + deferCiphertext(packet, from: peerID) } catch NoiseEncryptionError.sessionNotEstablished { + if isDeferredRetry { + SecureLogger.warning( + "Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… because the authenticated session is unavailable", + category: .session + ) + return + } // We received an encrypted message before establishing a session with this peer. - // Trigger a handshake so future messages can be decrypted. + // An initiator may already have sent message 3 followed by this + // ciphertext, with BLE delivering the ciphertext first. + if env.isAwaitingResponderHandshakeCompletion(peerID) { + deferCiphertext(packet, from: peerID) + return + } + // Otherwise trigger a handshake so future messages can decrypt. SecureLogger.debug("🔑 Encrypted message from \(peerID.id.prefix(8))… without session; initiating handshake") if !env.hasNoiseSession(peerID) { env.initiateHandshake(peerID) } } catch { + if isDeferredRetry { + // An early packet cannot tear down the authenticated session + // merely because its single bounded retry still fails. + SecureLogger.warning( + "Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… after retry failed: \(error)", + category: .session + ) + return + } + // A responder may retain an older transport as receive-only + // rollback state while ordinary XX waits for message 3. New-key + // ciphertext can fail against those retained receive keys first. + if env.isAwaitingResponderHandshakeCompletion(peerID) { + if isDeferrableEarlyHandshakeFailure(error) { + deferCiphertext(packet, from: peerID) + } else { + SecureLogger.warning( + "Dropping invalid Noise ciphertext from \(peerID.id.prefix(8))… while responder handshake is completing: \(error)", + category: .session + ) + } + return + } + if isDropOnlyCiphertextFailure(error) { + // The packet is attacker-controlled and did not prove a + // transport-state failure. Never let malformed, replayed, + // forged, oversized, or rate-limited bytes evict working keys. + SecureLogger.warning( + "Dropping rejected Noise ciphertext from \(peerID.id.prefix(8))… without clearing its session: \(error)", + category: .security + ) + return + } // Decryption failed - clear the corrupted session and re-initiate handshake - // This handles cases where session state got out of sync (nonce mismatch, etc.) + // Only local/session lifecycle failures reach this path. SecureLogger.error("❌ Failed to decrypt message from \(peerID.id.prefix(8))…: \(error) - clearing session and re-initiating handshake") env.clearSession(peerID) env.initiateHandshake(peerID) } } + + private func isDeferrableEarlyHandshakeFailure(_ error: Error) -> Bool { + if let noiseError = error as? NoiseError { + switch noiseError { + case .authenticationFailure, .replayDetected: + return true + default: + return false + } + } + if let cryptoError = error as? CryptoKitError, + case .authenticationFailure = cryptoError { + return true + } + return false + } + + private func isDropOnlyCiphertextFailure(_ error: Error) -> Bool { + if let securityError = error as? NoiseSecurityError { + switch securityError { + case .messageTooLarge, .rateLimitExceeded, .invalidPeerID: + return true + case .sessionExpired, .sessionExhausted: + return false + } + } + if let noiseError = error as? NoiseError { + switch noiseError { + case .invalidCiphertext, .authenticationFailure, .replayDetected: + return true + case .uninitializedCipher, .handshakeComplete, + .handshakeNotComplete, .missingLocalStaticKey, + .missingKeys, .invalidMessage, .invalidPublicKey, + .nonceExceeded: + return false + } + } + return error is CryptoKitError + } + + private func deferCiphertext(_ packet: BitchatPacket, from peerID: PeerID) { + guard NoiseSecurityValidator.validatePrivateFileCiphertextSize( + packet.payload + ) else { + SecureLogger.warning( + "Dropping oversized early Noise ciphertext from \(peerID.id.prefix(8))…", + category: .security + ) + return + } + + let now = environment.now() + deferredLock.lock() + defer { deferredLock.unlock() } + purgeExpiredCiphertextsLocked(now: now) + + let peerCount = deferredCiphertexts[peerID]?.count ?? 0 + let globalCount = deferredCiphertexts.values.reduce(0) { + $0 + $1.count + } + guard peerCount < Self.maxDeferredPacketsPerPeer, + globalCount < Self.maxDeferredPacketsGlobal, + deferredCiphertextBytes + packet.payload.count + <= Self.maxDeferredBytes else { + SecureLogger.warning( + "Dropping early Noise ciphertext from \(peerID.id.prefix(8))… because the handshake buffer is full", + category: .security + ) + return + } + + deferredCiphertexts[peerID, default: []].append( + DeferredCiphertext(packet: packet, receivedAt: now) + ) + deferredCiphertextBytes += packet.payload.count + SecureLogger.debug( + "Deferring early Noise ciphertext from \(peerID.id.prefix(8))… until responder handshake completion", + category: .session + ) + } + + private func drainDeferredCiphertextsIfReady(for peerID: PeerID) { + let env = environment + guard !env.isAwaitingResponderHandshakeCompletion(peerID), + env.hasNoiseSession(peerID) else { + return + } + + let now = env.now() + deferredLock.lock() + purgeExpiredCiphertextsLocked(now: now) + let deferred = deferredCiphertexts.removeValue(forKey: peerID) ?? [] + deferredCiphertextBytes -= deferred.reduce(0) { + $0 + $1.packet.payload.count + } + deferredLock.unlock() + + guard !deferred.isEmpty else { return } + SecureLogger.debug( + "Retrying \(deferred.count) early Noise ciphertext packet(s) from \(peerID.id.prefix(8))… after handshake completion", + category: .session + ) + for item in deferred { + handleEncrypted(item.packet, from: peerID, isDeferredRetry: true) + } + } + + private func purgeExpiredCiphertextsLocked(now: Date) { + for peerID in Array(deferredCiphertexts.keys) { + guard let items = deferredCiphertexts[peerID] else { continue } + let retained = items.filter { + now.timeIntervalSince($0.receivedAt) <= Self.deferredLifetime + } + guard retained.count != items.count else { continue } + + deferredCiphertextBytes -= items.reduce(0) { + $0 + $1.packet.payload.count + } + deferredCiphertextBytes += retained.reduce(0) { + $0 + $1.packet.payload.count + } + if retained.isEmpty { + deferredCiphertexts.removeValue(forKey: peerID) + } else { + deferredCiphertexts[peerID] = retained + } + } + } } diff --git a/bitchat/Services/BLE/BLENoisePayloadFactory.swift b/bitchat/Services/BLE/BLENoisePayloadFactory.swift index 0aac077f..a414487b 100644 --- a/bitchat/Services/BLE/BLENoisePayloadFactory.swift +++ b/bitchat/Services/BLE/BLENoisePayloadFactory.swift @@ -17,6 +17,16 @@ enum BLENoisePayloadFactory { typedPayload(.delivered, payload: Data(messageID.utf8)) } + static func privateFile(_ filePacket: BitchatFilePacket) -> Data? { + guard let payload = filePacket.encode() else { return nil } + return typedPayload(.privateFile, payload: payload) + } + + static func authenticatedPeerState(_ state: AuthenticatedPeerStatePacket) -> Data? { + guard let payload = state.encode() else { return nil } + return typedPayload(.authenticatedPeerState, payload: payload) + } + static func typedPayload(_ type: NoisePayloadType, payload: Data) -> Data { var typed = Data([type.rawValue]) typed.append(payload) diff --git a/bitchat/Services/BLE/BLENoiseReconnectPolicy.swift b/bitchat/Services/BLE/BLENoiseReconnectPolicy.swift new file mode 100644 index 00000000..99b748fb --- /dev/null +++ b/bitchat/Services/BLE/BLENoiseReconnectPolicy.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Bounds ordinary Noise revalidation to one attempt per physical-link epoch. +/// A live epoch may retry after the cooldown so a lost handshake cannot leave +/// the link permanently unauthenticated. +struct BLENoiseReconnectPolicy { + static let minimumRetryInterval: TimeInterval = 60 + + private var lastAttemptAt: [BLEIngressLinkID: Date] = [:] + + mutating func shouldRevalidate( + on link: BLEIngressLinkID, + hasEstablishedSession: Bool, + isNoiseAuthenticatedLink: Bool, + hasAuthenticatedPeerLink: Bool, + now: Date + ) -> Bool { + guard hasEstablishedSession, + !isNoiseAuthenticatedLink, + !hasAuthenticatedPeerLink else { + return false + } + if let previous = lastAttemptAt[link], + now.timeIntervalSince(previous) < Self.minimumRetryInterval { + return false + } + lastAttemptAt[link] = now + return true + } + + /// Link identifiers can be stable across CoreBluetooth reconnects, so a + /// disconnect explicitly starts a new epoch and permits one fresh attempt. + mutating func endLinkEpoch(_ link: BLEIngressLinkID) { + lastAttemptAt.removeValue(forKey: link) + } + + mutating func removeAll() { + lastAttemptAt.removeAll() + } +} diff --git a/bitchat/Services/BLE/BLENoiseSessionQueues.swift b/bitchat/Services/BLE/BLENoiseSessionQueues.swift index 84eeaba8..301e6bce 100644 --- a/bitchat/Services/BLE/BLENoiseSessionQueues.swift +++ b/bitchat/Services/BLE/BLENoiseSessionQueues.swift @@ -6,9 +6,16 @@ struct BLEPendingPrivateMessage: Equatable { let messageID: String } +struct BLEPendingTypedPayload: Equatable { + let payload: Data + /// Present for app-initiated media so handshake queuing preserves the + /// fragment scheduler's progress/cancellation identity. + let transferId: String? +} + struct BLENoiseSessionQueues { private var privateMessagesByPeerID: [PeerID: [BLEPendingPrivateMessage]] = [:] - private var typedPayloadsByPeerID: [PeerID: [Data]] = [:] + private var typedPayloadsByPeerID: [PeerID: [BLEPendingTypedPayload]] = [:] var isEmpty: Bool { privateMessagesByPeerID.isEmpty && typedPayloadsByPeerID.isEmpty @@ -34,13 +41,35 @@ struct BLENoiseSessionQueues { privateMessagesByPeerID[peerID, default: []].insert(contentsOf: messages, at: 0) } - mutating func appendTypedPayload(_ payload: Data, for peerID: PeerID) { - typedPayloadsByPeerID[peerID, default: []].append(payload) + mutating func appendTypedPayload(_ payload: Data, transferId: String? = nil, for peerID: PeerID) { + typedPayloadsByPeerID[peerID, default: []].append( + BLEPendingTypedPayload(payload: payload, transferId: transferId) + ) } - mutating func takeTypedPayloads(for peerID: PeerID) -> [Data] { + mutating func takeTypedPayloads(for peerID: PeerID) -> [BLEPendingTypedPayload] { let payloads = typedPayloadsByPeerID[peerID] ?? [] typedPayloadsByPeerID.removeValue(forKey: peerID) return payloads } + + func containsTypedPayload(transferId: String) -> Bool { + typedPayloadsByPeerID.values.contains { payloads in + payloads.contains { $0.transferId == transferId } + } + } + + @discardableResult + mutating func removeTypedPayload(transferId: String) -> Bool { + for peerID in Array(typedPayloadsByPeerID.keys) { + guard var payloads = typedPayloadsByPeerID[peerID], + let index = payloads.firstIndex(where: { $0.transferId == transferId }) else { + continue + } + payloads.remove(at: index) + typedPayloadsByPeerID[peerID] = payloads.isEmpty ? nil : payloads + return true + } + return false + } } diff --git a/bitchat/Services/BLE/BLEOutboundFragmentPlanner.swift b/bitchat/Services/BLE/BLEOutboundFragmentPlanner.swift index 9623c204..09a65616 100644 --- a/bitchat/Services/BLE/BLEOutboundFragmentPlanner.swift +++ b/bitchat/Services/BLE/BLEOutboundFragmentPlanner.swift @@ -17,6 +17,9 @@ struct BLEOutboundFragmentPlan { } enum BLEOutboundFragmentPlanner { + /// Current Android receivers reject fragment sets above 256. Private + /// media v1 treats that deployed ceiling as a cross-platform contract. + static let privateMediaV1MaxFragments = 256 private static let minimumChunkSize = 64 private static let fragmentIDLength = 8 @@ -71,6 +74,10 @@ enum BLEOutboundFragmentPlanner { ) } + static func isPrivateMediaV1Compatible(_ plan: BLEOutboundFragmentPlan) -> Bool { + plan.totalFragments <= privateMediaV1MaxFragments + } + private static func sizingPolicy( for packet: BitchatPacket, requestedMaxChunk: Int?, diff --git a/bitchat/Services/BLE/BLEOutboundFragmentTransferScheduler.swift b/bitchat/Services/BLE/BLEOutboundFragmentTransferScheduler.swift index 882e4978..722e55c4 100644 --- a/bitchat/Services/BLE/BLEOutboundFragmentTransferScheduler.swift +++ b/bitchat/Services/BLE/BLEOutboundFragmentTransferScheduler.swift @@ -29,8 +29,9 @@ struct BLEOutboundFragmentTransferRequest { } var resolvedTransferId: String? { + if let transferId { return transferId } guard packet.type == MessageType.fileTransfer.rawValue else { return nil } - return transferId ?? packet.payload.sha256Hex() + return packet.payload.sha256Hex() } /// Content identity independent of the caller-chosen transfer ID: the diff --git a/bitchat/Services/BLE/BLEPeerRegistry.swift b/bitchat/Services/BLE/BLEPeerRegistry.swift index 792107d8..679b419e 100644 --- a/bitchat/Services/BLE/BLEPeerRegistry.swift +++ b/bitchat/Services/BLE/BLEPeerRegistry.swift @@ -10,6 +10,9 @@ struct BLEPeerInfo: Equatable { var isVerifiedNickname: Bool var lastSeen: Date var capabilities: PeerCapabilities = [] + /// Distinguishes an old client that omitted the capabilities TLV from a + /// modern client that explicitly advertised a set without a given bit. + var capabilitiesWereExplicitlyAdvertised: Bool = false /// Rendezvous cell from the peer's announce when it advertises `.bridge`. var bridgeGeohash: String? } @@ -114,6 +117,10 @@ struct BLEPeerRegistry { peers[peerID.toShort()]?.capabilities ?? [] } + func capabilitiesWereExplicitlyAdvertised(for peerID: PeerID) -> Bool { + peers[peerID.toShort()]?.capabilitiesWereExplicitlyAdvertised == true + } + /// Peers whose last verified announce advertised the given capability. func peers(advertising capability: PeerCapabilities) -> [PeerID] { peers.values.filter { $0.capabilities.contains(capability) }.map(\.peerID) @@ -174,6 +181,22 @@ struct BLEPeerRegistry { peers[peerID] = peer } + /// Replaces the announcement signing key only after the surrounding Noise + /// session proved possession of this peer's static key. + mutating func bindAuthenticatedSigningPublicKey(_ key: Data, for peerID: PeerID) { + guard var peer = peers[peerID.toShort()] else { return } + peer.signingPublicKey = key + peers[peer.peerID] = peer + } + + /// Applies a verified announce to the registry. + /// + /// TOFU signing-key pinning: once a signing key has been bound to this + /// peer entry, an announce carrying a *different* signing key is refused + /// (returns `nil`) and the existing record is left untouched. PeerIDs are + /// derived from the (public) noise key, so without pinning an attacker + /// could replay a victim's noiseKey/peerID with their own signing key and + /// silently take over the victim's mesh identity and nickname. mutating func upsertVerifiedAnnounce( peerID: PeerID, nickname: String, @@ -181,10 +204,17 @@ struct BLEPeerRegistry { signingPublicKey: Data?, isConnected: Bool, now: Date, - capabilities: PeerCapabilities = [], + capabilities: PeerCapabilities? = nil, bridgeGeohash: String? = nil - ) -> BLEPeerAnnounceUpdate { + ) -> BLEPeerAnnounceUpdate? { let existing = peers[peerID] + + if let pinnedSigningKey = existing?.signingPublicKey, + let announcedSigningKey = signingPublicKey, + pinnedSigningKey != announcedSigningKey { + return nil + } + let update = BLEPeerAnnounceUpdate( isNewPeer: existing == nil, wasDisconnected: existing?.isConnected == false, @@ -196,10 +226,12 @@ struct BLEPeerRegistry { nickname: nickname, isConnected: isConnected, noisePublicKey: noisePublicKey, - signingPublicKey: signingPublicKey, + // Never drop an already-pinned signing key. + signingPublicKey: signingPublicKey ?? existing?.signingPublicKey, isVerifiedNickname: true, lastSeen: now, - capabilities: capabilities, + capabilities: capabilities ?? [], + capabilitiesWereExplicitlyAdvertised: capabilities != nil, bridgeGeohash: bridgeGeohash ) diff --git a/bitchat/Services/BLE/BLEPrivateMediaReceiptStore.swift b/bitchat/Services/BLE/BLEPrivateMediaReceiptStore.swift new file mode 100644 index 00000000..dc72af4f --- /dev/null +++ b/bitchat/Services/BLE/BLEPrivateMediaReceiptStore.swift @@ -0,0 +1,1143 @@ +import BitLogger +import Foundation + +enum BLEPrivateMediaReceiptState: Equatable { + /// No durable receiver decision exists for this stable message ID. + case absent + /// The payload is durably mapped to a file that still exists. + case accepted(URL) + /// The user explicitly deleted the payload; retries must not resurrect it. + case tombstoned + /// Durable state could not be read safely. Callers must fail closed and + /// must not save, deliver, or acknowledge the payload. + case unavailable +} + +/// Durable, per-message receiver decisions for stable private media. +/// +/// Each ID has its own atomic record so one hot lookup never rewrites or +/// decodes the entire ledger. The process-lifetime index is installed only +/// after a complete directory scan. A structural failure of the directory +/// itself (create/enumerate) — or of the single batch deletion journal — +/// remains globally fail-closed and retryable. An individual record that +/// 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 { + typealias DirectoryReader = (_ directory: URL) throws -> [URL] + typealias DataReader = (_ url: URL) throws -> Data + typealias DataWriter = ( + _ data: Data, + _ url: URL, + _ options: Data.WritingOptions + ) throws -> Void + typealias PayloadRemover = (_ url: URL) throws -> Void + private static let receiptDirectoryName = ".private-media-receipts" + private static let quarantinePathExtension = "corrupt" + private static let deletionJournalFileName = ".deletion-journal.json" + private static let maximumDeletionPathsPerMessage = 2 + + private struct ReceiptRecord: Codable, Equatable { + enum Kind: String, Codable { + case accepted + case tombstone + } + + let kind: Kind + /// Path below the app's `files/` root. Absolute application-container + /// prefixes are not stable across updates, restores, or reinstalls. + let relativePath: String? + let recordedAt: Date + } + + /// One atomic write of this journal is the commit point for an entire + /// explicit-deletion batch. Per-ID records and payload unlinks are + /// idempotent materialization performed only after that commit. + private struct DeletionJournalEntry: Codable, Equatable { + let relativePaths: [String] + let recordedAt: Date + } + + private struct DeletionJournal: Codable { + let version: Int + let entries: [String: DeletionJournalEntry] + } + + private final class Runtime: @unchecked Sendable { + let lock = NSLock() + 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 = [] + var deletionJournal: [String: DeletionJournalEntry]? + } + + private let fileManager: FileManager + private let baseDirectory: URL? + private let capacity: Int + private let ttl: TimeInterval + private let now: () -> Date + private let directoryReader: DirectoryReader? + private let dataReader: DataReader? + private let dataWriter: DataWriter + private let payloadRemover: PayloadRemover + private let runtime = Runtime() + + init( + fileManager: FileManager = .default, + baseDirectory: URL? = nil, + capacity: Int = TransportConfig.privateMediaReceivedLedgerCapacity, + ttl: TimeInterval = TransportConfig.privateMediaReceivedLedgerTTLSeconds, + now: @escaping () -> Date = Date.init, + directoryReader: DirectoryReader? = nil, + dataReader: DataReader? = nil, + dataWriter: @escaping DataWriter = { + try $0.write(to: $1, options: $2) + }, + payloadRemover: PayloadRemover? = nil + ) { + self.fileManager = fileManager + self.baseDirectory = baseDirectory + self.capacity = max(1, capacity) + self.ttl = max(0, ttl) + self.now = now + self.directoryReader = directoryReader + self.dataReader = dataReader + self.dataWriter = dataWriter + self.payloadRemover = payloadRemover ?? { + try fileManager.removeItem(at: $0) + } + } + + /// Drops process-lifetime decisions after the enclosing media directory + /// has been panic-wiped. A later lookup must rebuild from the durable + /// ledger instead of retaining an accepted receipt or tombstone whose + /// backing files no longer exist. + func resetForPanic() { + runtime.lock.lock() + runtime.records = nil + runtime.quarantined.removeAll(keepingCapacity: false) + runtime.deletionJournal = nil + runtime.lock.unlock() + } + + func state(for messageID: String) -> BLEPrivateMediaReceiptState { + guard PrivateMediaMessageIdentity.isStableID(messageID) else { + return .absent + } + + runtime.lock.lock() + defer { runtime.lock.unlock() } + + let date = now() + guard let directory = resolvedReceiptDirectory(), + loadDurableStateIfNeeded(from: directory, at: date) else { + return .unavailable + } + _ = 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 { + 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 record = records[messageID] else { return .absent } + + if record.kind == .tombstone, record.relativePath != nil { + guard scrubLegacyTombstone( + record, + messageID: messageID, + in: directory, + records: &records + ) else { + return .tombstoned + } + guard let scrubbed = records[messageID] else { + return .unavailable + } + record = scrubbed + } + + let retainsAcceptedPath = + record.kind == .accepted + && record.relativePath.flatMap(existingPayload) != nil + if isExpired(record.recordedAt, at: date), + !retainsAcceptedPath { + guard removeRecord( + messageID: messageID, + from: directory + ) else { + return record.kind == .tombstone + ? .tombstoned + : .unavailable + } + records.removeValue(forKey: messageID) + runtime.records = records + return .absent + } + + switch record.kind { + case .tombstone: + return .tombstoned + + case .accepted: + guard let relativePath = record.relativePath, + let existingURL = existingPayload(relativePath: relativePath) else { + // Quota cleanup is not explicit deletion. Remove the stale + // receipt so a sender retry can restore the payload and bubble. + guard removeRecord( + messageID: messageID, + from: directory + ) else { + return .unavailable + } + records.removeValue(forKey: messageID) + runtime.records = records + return .absent + } + return .accepted(existingURL) + } + } + + /// Records an accepted ID only after the payload is on disk. Callers must + /// roll the payload back and withhold UI delivery/ACK when this returns + /// false. + func commitAccepted(messageID: String, storedURL: URL) -> Bool { + guard PrivateMediaMessageIdentity.isStableID(messageID), + validExistingPayload(storedURL) != nil, + let relativePath = relativePath(for: storedURL) else { + return false + } + + runtime.lock.lock() + defer { runtime.lock.unlock() } + + let date = now() + guard let directory = resolvedReceiptDirectory(), + loadDurableStateIfNeeded(from: directory, at: date) else { + return false + } + _ = recoverDeletionJournal(in: directory) + guard runtime.deletionJournal?[messageID] == nil, + !runtime.quarantined.contains(messageID), + var records = runtime.records else { + return false + } + if runtime.deletionJournal?.values.contains(where: { + $0.relativePaths.contains(relativePath) + }) == true { + return false + } + if records.contains(where: { existingMessageID, record in + existingMessageID != messageID + && record.relativePath == relativePath + }) { + return false + } + if let existing = records[messageID], + existing.kind == .tombstone, + !isExpired(existing.recordedAt, at: date) { + return false + } + + let victim = capacityVictim( + for: .accepted, + replacing: messageID, + in: records + ) + if records[messageID]?.kind != .accepted, + records.values.lazy.filter({ $0.kind == .accepted }).count >= capacity, + victim == nil { + return false + } + + let record = ReceiptRecord( + kind: .accepted, + relativePath: relativePath, + recordedAt: date + ) + guard persist(record, messageID: messageID, to: directory) else { + return false + } + + records[messageID] = record + if let victim, victim != messageID { + records.removeValue(forKey: victim) + removeRecord(messageID: victim, from: directory) + } + runtime.records = records + return true + } + + /// Atomically commits explicit deletion for every stable ID in `messageIDs`. + /// + /// The journal is the single batch commit point: a failed write changes + /// neither durable nor in-memory receiver state, so callers must preserve + /// their bubbles. Once the write succeeds, every entry is tombstoned even + /// if the process exits before per-ID materialization or payload unlink. + /// Recovery retries both operations on the next lookup or launch. + func recordDeleted( + messageIDs: [String], + payloadRelativePaths: [String: String] = [:], + protectedPayloadRelativePaths: Set = [] + ) -> Bool { + let stableIDs = Array( + Set(messageIDs.filter(PrivateMediaMessageIdentity.isStableID)) + ).sorted() + guard !stableIDs.isEmpty else { return true } + guard stableIDs.count <= capacity else { return false } + + runtime.lock.lock() + defer { runtime.lock.unlock() } + + let date = now() + guard let directory = resolvedReceiptDirectory(), + loadDurableStateIfNeeded(from: directory, at: date) else { + return false + } + _ = recoverDeletionJournal(in: directory) + guard let records = runtime.records, + var journal = runtime.deletionJournal else { + 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 newIDs = stableIDs.filter { messageID in + if pendingIDs.contains(messageID) { return false } + if let current = records[messageID], + current.kind == .tombstone, + !isExpired(current.recordedAt, at: date) { + return false + } + return true + } + guard !newIDs.isEmpty else { + return true + } + guard Set(journal.keys).union(newIDs).count <= capacity else { + return false + } + + var newEntries: [String: DeletionJournalEntry] = [:] + for messageID in newIDs { + // Accepted receipts normally supply the exact stored path. The UI + // fallback is required when that receipt aged or was capacity + // evicted while its bubble and payload remain. They may differ + // after a retry selected a suffixed filename, so journal both. + // Never commit a new pathless tombstone: it could retire + // successfully while leaving an untracked payload behind. + let relativePaths = Array(Set([ + records[messageID]?.relativePath, + payloadRelativePaths[messageID] + ].compactMap { $0 })).sorted() + guard !relativePaths.isEmpty, + relativePaths.count <= + Self.maximumDeletionPathsPerMessage, + relativePaths.allSatisfy({ + isSafeDeletionTarget(relativePath: $0) + }), + protectedPayloadRelativePaths.isDisjoint( + with: relativePaths + ), + !records.contains(where: { otherMessageID, record in + otherMessageID != messageID + && !stableIDs.contains(otherMessageID) + && record.relativePath.map( + relativePaths.contains + ) == true + }), + !journal.contains(where: { otherMessageID, entry in + otherMessageID != messageID + && !stableIDs.contains(otherMessageID) + && !Set(entry.relativePaths).isDisjoint( + with: relativePaths + ) + }) else { + return false + } + newEntries[messageID] = DeletionJournalEntry( + // The journal retains every owned path so payload deletion + // remains recoverable across a crash or unlink failure. + relativePaths: relativePaths, + recordedAt: date + ) + } + journal.merge(newEntries) { _, new in new } + + // Do not install any process-local tombstone before this succeeds. + // A failed delete must continue to resolve to its prior accepted + // state, otherwise a retry could be falsely ACKed while UI remains. + guard persistDeletionJournal(journal, in: directory) else { + return false + } + + runtime.deletionJournal = journal + _ = recoverDeletionJournal(in: directory) + return true + } + + func recordDeleted(messageID: String) -> Bool { + guard PrivateMediaMessageIdentity.isStableID(messageID) else { + return false + } + return recordDeleted(messageIDs: [messageID]) + } + + /// Resolves every path a deletion transaction may target. The incoming + /// allocator reserves this set at the journal barrier so a concurrent raw + /// arrival cannot reuse a missing UI fallback. + func prospectiveDeletionPayloadPaths( + messageIDs: [String], + payloadRelativePaths: [String: String] + ) -> Set? { + let stableIDs = Set( + messageIDs.filter(PrivateMediaMessageIdentity.isStableID) + ) + guard !stableIDs.isEmpty else { return [] } + + runtime.lock.lock() + defer { runtime.lock.unlock() } + + let date = now() + guard let directory = resolvedReceiptDirectory(), + loadDurableStateIfNeeded(from: directory, at: date) else { + return nil + } + _ = recoverDeletionJournal(in: directory) + guard let journal = runtime.deletionJournal, + let records = runtime.records else { + return nil + } + + var paths: Set = [] + for messageID in stableIDs { + if let entry = journal[messageID] { + paths.formUnion(entry.relativePaths) + continue + } + if let relativePath = records[messageID]?.relativePath { + paths.insert(relativePath) + } + if let relativePath = payloadRelativePaths[messageID] { + paths.insert(relativePath) + } + } + guard paths.allSatisfy({ + candidatePayload(relativePath: $0) != nil + }) else { + return nil + } + return Set(paths.compactMap { + candidatePayload(relativePath: $0)? + .standardizedFileURL.path + }) + } + + /// Paths owned by accepted receipts, legacy pathful tombstones, or the + /// deletion journal. Incoming allocation must not reuse any of them for a + /// different ID. Quarantined records are unreadable, so any path they may + /// have owned cannot be reserved; their bytes remain preserved in the + /// `.corrupt` file for offline inspection. + func reservedPayloadPaths() -> Set? { + runtime.lock.lock() + defer { runtime.lock.unlock() } + + let date = now() + guard let directory = resolvedReceiptDirectory(), + loadDurableStateIfNeeded(from: directory, at: date) else { + return nil + } + _ = recoverDeletionJournal(in: directory) + guard let journal = runtime.deletionJournal, + let records = runtime.records else { + return nil + } + let recordPaths = records.values.compactMap(\.relativePath) + let journalPaths = journal.values.flatMap(\.relativePaths) + return Set((recordPaths + journalPaths).compactMap { relativePath in + candidatePayload(relativePath: relativePath)? + .standardizedFileURL.path + }) + } + + private func scrubLegacyTombstone( + _ tombstone: ReceiptRecord, + messageID: String, + in directory: URL, + records: inout [String: ReceiptRecord] + ) -> Bool { + guard tombstone.kind == .tombstone, + tombstone.relativePath != nil else { + return true + } + // Pre-journal tombstones cannot prove that the current file is still + // the payload they originally described. Older allocators did not + // reserve these paths, so a raw/public arrival may have reused the + // basename. Shed the ambiguous path without unlinking anything. + let pathless = ReceiptRecord( + kind: .tombstone, + relativePath: nil, + recordedAt: tombstone.recordedAt + ) + guard persist( + pathless, + messageID: messageID, + to: directory + ) else { + return false + } + records[messageID] = pathless + runtime.records = records + return true + } + + private func loadDurableStateIfNeeded( + from directory: URL, + at date: Date + ) -> Bool { + if runtime.records != nil, runtime.deletionJournal != nil { + return true + } + + do { + try fileManager.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: nil + ) + } catch { + SecureLogger.error( + "❌ Failed to create private-media receipt directory: \(error)", + category: .session + ) + return false + } + + let urls: [URL] + do { + if let directoryReader { + urls = try directoryReader(directory) + } else { + urls = try fileManager.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil, + options: [] + ) + } + } catch { + SecureLogger.error( + "❌ Failed to enumerate private-media receipts: \(error)", + category: .session + ) + return false + } + + var records: [String: ReceiptRecord] = [:] + var scannedRecords: [String: ReceiptRecord] = [:] + var quarantined: Set = [] + var expired: [String] = [] + var tombstones: [(messageID: String, record: ReceiptRecord)] = [] + 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 } + let messageID = url.deletingPathExtension().lastPathComponent + guard PrivateMediaMessageIdentity.isStableID(messageID) else { + continue + } + + let record: ReceiptRecord + do { + let data = try dataReader?(url) ?? Data(contentsOf: url) + record = try JSONDecoder().decode(ReceiptRecord.self, from: data) + } catch { + // Never delete or silently skip an unreadable stable-ID + // record: treating it as absent could resurrect accepted or + // deleted media. But never let it poison the whole ledger + // either — quarantine the file and fail only this ID closed. + quarantine(url, messageID: messageID, reason: "\(error)") + quarantined.insert(messageID) + continue + } + + guard isStructurallyValid(record) else { + quarantine( + url, + messageID: messageID, + reason: "structurally invalid" + ) + quarantined.insert(messageID) + continue + } + scannedRecords[messageID] = record + if record.kind == .tombstone { + tombstones.append((messageID, record)) + } + let retainsAcceptedPath = + record.kind == .accepted + && record.relativePath.flatMap(existingPayload) != nil + if isExpired(record.recordedAt, at: date), + !retainsAcceptedPath { + expired.append(messageID) + continue + } + 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) + for messageID in overflow { + records.removeValue(forKey: messageID) + } + + let journal: [String: DeletionJournalEntry] + let journalURL = deletionJournalURL(in: directory) + if fileManager.fileExists(atPath: journalURL.path) { + do { + let data = try dataReader?(journalURL) + ?? Data(contentsOf: journalURL) + let snapshot = try JSONDecoder().decode( + DeletionJournal.self, + from: data + ) + guard snapshot.version == 1, + snapshot.entries.count <= capacity, + snapshot.entries.allSatisfy({ messageID, entry in + PrivateMediaMessageIdentity.isStableID(messageID) + && !entry.relativePaths.isEmpty + && entry.relativePaths.count <= + Self.maximumDeletionPathsPerMessage + && Set(entry.relativePaths).count + == entry.relativePaths.count + && entry.relativePaths.allSatisfy { + candidatePayload(relativePath: $0) != nil + } + }) 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( + "❌ Invalid private-media deletion journal", + category: .session + ) + return false + } + journal = snapshot.entries + } catch { + // The journal is the all-ID commit record. It may never be + // skipped or treated as empty when unreadable. + SecureLogger.error( + "❌ Failed to read private-media deletion journal: \(error)", + category: .session + ) + return false + } + } else { + journal = [:] + } + + var protectedFromPruning: Set = [] + + // Old per-ID tombstones may still carry a payload path from before the + // deletion journal existed. That path is inherently ambiguous because + // old allocators did not reserve it. Convert it to a pathless + // tombstone without unlinking any current file. + for (messageID, tombstone) in tombstones + where journal[messageID] == nil + && tombstone.relativePath != nil { + let pathless = ReceiptRecord( + kind: .tombstone, + relativePath: nil, + recordedAt: tombstone.recordedAt + ) + guard persist( + pathless, + messageID: messageID, + to: directory + ) else { + records[messageID] = tombstone + protectedFromPruning.insert(messageID) + continue + } + scannedRecords[messageID] = pathless + if records[messageID] != nil { + records[messageID] = pathless + } + } + + for messageID in expired + overflow { + guard !protectedFromPruning.contains(messageID) else { continue } + if !removeRecord(messageID: messageID, from: directory), + let record = scannedRecords[messageID] { + records[messageID] = record + } + } + + // Install caches only after every per-ID record and the batch journal + // have been read and validated. Failed legacy cleanup remains indexed + // and path-reserved instead of blocking unrelated media. + runtime.records = records + runtime.quarantined = quarantined + runtime.deletionJournal = journal + return true + } + + /// Idempotently materializes the write-ahead journal. An entry leaves the + /// journal only after its per-ID tombstone is durable and every recorded + /// payload is absent. Any failure keeps the journal authoritative for a + /// later lookup or process restart. + @discardableResult + private func recoverDeletionJournal(in directory: URL) -> Bool { + guard let journal = runtime.deletionJournal, + !journal.isEmpty, + var records = runtime.records else { + return true + } + + let preservedMessageIDs = Set(journal.keys) + var remaining = journal + let orderedEntries = journal.sorted { lhs, rhs in + if lhs.value.recordedAt == rhs.value.recordedAt { + return lhs.key < rhs.key + } + return lhs.value.recordedAt < rhs.value.recordedAt + } + + for (messageID, journalEntry) in orderedEntries { + // Never materialize a per-ID record for a quarantined ID: the + // scanner ignores readable duplicates of a quarantined record, + // and quarantine is already permanently fail-closed + // (state == .unavailable, commitAccepted refuses it), which + // subsumes the tombstone's no-resurrection guarantee. Only the + // payload unlinks below still need to run before the entry can + // retire. + if !runtime.quarantined.contains(messageID) { + let pathlessTombstone = ReceiptRecord( + kind: .tombstone, + relativePath: nil, + recordedAt: journalEntry.recordedAt + ) + if records[messageID] != pathlessTombstone { + let victim = capacityVictim( + for: .tombstone, + replacing: messageID, + in: records, + preserving: preservedMessageIDs + ) + if records[messageID]?.kind != .tombstone, + records.values.lazy.filter({ + $0.kind == .tombstone + }).count >= capacity, + victim == nil { + continue + } + guard persist( + pathlessTombstone, + messageID: messageID, + to: directory + ) else { + continue + } + records[messageID] = pathlessTombstone + if let victim, victim != messageID { + records.removeValue(forKey: victim) + removeRecord(messageID: victim, from: directory) + } + } + } + + // Only the journal retains the paths. Once every unlink succeeds, + // the durable per-ID tombstone is pathless and cannot later + // delete a different payload that reused the basename. + let removedEveryPayload = journalEntry.relativePaths.allSatisfy { + removePayloadRecordedByTombstone(ReceiptRecord( + kind: .tombstone, + relativePath: $0, + recordedAt: journalEntry.recordedAt + )) + } + guard removedEveryPayload else { + continue + } + remaining.removeValue(forKey: messageID) + } + + runtime.records = records + guard remaining != journal else { return false } + + if remaining.isEmpty { + guard removeDeletionJournal(in: directory) else { return false } + } else { + guard persistDeletionJournal(remaining, in: directory) else { + return false + } + } + runtime.deletionJournal = remaining + return remaining.isEmpty + } + + private func persistDeletionJournal( + _ entries: [String: DeletionJournalEntry], + in directory: URL + ) -> Bool { + do { + try fileManager.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: nil + ) + let data = try JSONEncoder().encode( + DeletionJournal(version: 1, entries: entries) + ) + var options: Data.WritingOptions = [.atomic] + #if os(iOS) + options.insert( + .completeFileProtectionUntilFirstUserAuthentication + ) + #endif + try dataWriter(data, deletionJournalURL(in: directory), options) + return true + } catch { + SecureLogger.error( + "❌ Failed to persist private-media deletion journal: \(error)", + category: .session + ) + return false + } + } + + private func removeDeletionJournal(in directory: URL) -> Bool { + let url = deletionJournalURL(in: directory) + guard fileManager.fileExists(atPath: url.path) else { return true } + do { + try fileManager.removeItem(at: url) + return true + } catch { + SecureLogger.warning( + "⚠️ Failed to retire private-media deletion journal: \(error)", + category: .session + ) + return false + } + } + + private func deletionJournalURL(in directory: URL) -> URL { + directory.appendingPathComponent( + Self.deletionJournalFileName, + isDirectory: false + ) + } + + /// 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 { + switch record.kind { + case .tombstone: + guard let relativePath = record.relativePath else { return true } + return candidatePayload(relativePath: relativePath) != nil + case .accepted: + guard let relativePath = record.relativePath else { return false } + return candidatePayload(relativePath: relativePath) != nil + } + } + + private func isExpired(_ recordedAt: Date, at date: Date) -> Bool { + date.timeIntervalSince(recordedAt) > ttl + } + + private func overflowVictims( + in records: [String: ReceiptRecord] + ) -> [String] { + var victims: [String] = [] + for kind in [ReceiptRecord.Kind.accepted, .tombstone] { + let allMatching = records.filter { $0.value.kind == kind } + let overflow = allMatching.count - capacity + guard overflow > 0 else { continue } + let eligible = allMatching.filter { _, record in + guard kind == .accepted else { return true } + return record.relativePath.flatMap(existingPayload) == nil + } + victims.append(contentsOf: eligible.sorted { lhs, rhs in + if lhs.value.recordedAt == rhs.value.recordedAt { + return lhs.key < rhs.key + } + return lhs.value.recordedAt < rhs.value.recordedAt + } + .prefix(overflow) + .map(\.key)) + } + return victims + } + + /// Accepted receipts and tombstones have independent capacity. High media + /// volume cannot evict explicit deletion intent, and vice versa. + private func capacityVictim( + for incomingKind: ReceiptRecord.Kind, + replacing messageID: String, + in records: [String: ReceiptRecord], + preserving preservedMessageIDs: Set = [] + ) -> String? { + guard records[messageID]?.kind != incomingKind else { return nil } + // During a live session an accepted receipt is the only durable owner + // of its filename, even after quota removes the payload. Evicting it + // here could let another ID reuse the path while the old bubble still + // exists. The large capacity therefore acts as admission control. + guard incomingKind != .accepted else { return nil } + let matching = records.filter { + $0.key != messageID + && !preservedMessageIDs.contains($0.key) + && $0.value.kind == incomingKind + } + let currentCount = records.values.lazy.filter { + $0.kind == incomingKind + }.count + guard currentCount >= capacity else { return nil } + return matching.min { lhs, rhs in + if lhs.value.recordedAt == rhs.value.recordedAt { + return lhs.key < rhs.key + } + return lhs.value.recordedAt < rhs.value.recordedAt + }?.key + } + + private func persist( + _ record: ReceiptRecord, + messageID: String, + to directory: URL + ) -> Bool { + do { + try fileManager.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: nil + ) + let data = try JSONEncoder().encode(record) + var options: Data.WritingOptions = [.atomic] + #if os(iOS) + options.insert(.completeFileProtectionUntilFirstUserAuthentication) + #endif + let url = recordURL(messageID: messageID, in: directory) + try dataWriter(data, url, options) + return true + } catch { + SecureLogger.error( + "❌ Failed to persist private-media receipt \(messageID.prefix(12))…: \(error)", + category: .session + ) + return false + } + } + + @discardableResult + private func removeRecord( + messageID: String, + from directory: URL + ) -> Bool { + let url = recordURL(messageID: messageID, in: directory) + guard fileManager.fileExists(atPath: url.path) else { return true } + do { + try fileManager.removeItem(at: url) + return true + } catch { + SecureLogger.warning( + "⚠️ Failed to prune private-media receipt \(messageID.prefix(12))…: \(error)", + category: .session + ) + return false + } + } + + private func recordURL(messageID: String, in directory: URL) -> URL { + directory + .appendingPathComponent(messageID, isDirectory: false) + .appendingPathExtension("json") + } + + @discardableResult + private func removePayloadRecordedByTombstone( + _ record: ReceiptRecord + ) -> Bool { + guard record.kind == .tombstone, + let relativePath = record.relativePath, + let payload = candidatePayload(relativePath: relativePath), + fileManager.fileExists(atPath: payload.path) else { + return true + } + guard let values = try? payload.resourceValues( + forKeys: [.isRegularFileKey] + ), + values.isRegularFile == true else { + SecureLogger.warning( + "⚠️ Refusing to remove non-file private-media payload", + category: .session + ) + return false + } + do { + try payloadRemover(payload) + return !fileManager.fileExists(atPath: payload.path) + } catch { + SecureLogger.warning( + "⚠️ Failed to remove explicitly deleted private media: \(error)", + category: .session + ) + return false + } + } + + private func isSafeDeletionTarget(relativePath: String) -> Bool { + guard let payload = candidatePayload(relativePath: relativePath) else { + return false + } + guard fileManager.fileExists(atPath: payload.path) else { + return true + } + return (try? payload.resourceValues( + forKeys: [.isRegularFileKey] + ).isRegularFile) == true + } + + private func validExistingPayload(_ url: URL) -> URL? { + let standardized = url.standardizedFileURL + guard isInsideIncomingMediaDirectory(standardized) else { + return nil + } + var isDirectory: ObjCBool = false + guard fileManager.fileExists( + atPath: standardized.path, + isDirectory: &isDirectory + ), !isDirectory.boolValue else { + return nil + } + return standardized + } + + private func relativePath(for url: URL) -> String? { + guard let filesRoot = try? filesDirectory().standardizedFileURL else { + return nil + } + let prefix = filesRoot.path + "/" + let standardized = url.standardizedFileURL + guard standardized.path.hasPrefix(prefix) else { return nil } + let relativePath = String(standardized.path.dropFirst(prefix.count)) + return relativePath.isEmpty ? nil : relativePath + } + + private func existingPayload(relativePath: String) -> URL? { + guard let candidate = candidatePayload(relativePath: relativePath) else { + return nil + } + return validExistingPayload(candidate) + } + + private func candidatePayload(relativePath: String) -> URL? { + guard !relativePath.isEmpty, + let filesRoot = try? filesDirectory().standardizedFileURL else { + return nil + } + let candidate = filesRoot + .appendingPathComponent(relativePath, isDirectory: false) + .standardizedFileURL + guard isInsideIncomingMediaDirectory(candidate) else { return nil } + return candidate + } + + private func isInsideIncomingMediaDirectory(_ url: URL) -> Bool { + guard let filesRoot = try? filesDirectory().standardizedFileURL else { + return false + } + let parentPath = url.standardizedFileURL + .deletingLastPathComponent().path + return [ + "voicenotes/incoming", + "images/incoming", + "files/incoming" + ].contains { relativeDirectory in + filesRoot.appendingPathComponent( + relativeDirectory, + isDirectory: true + ) + .standardizedFileURL.path == parentPath + } + } + + private func resolvedReceiptDirectory() -> URL? { + return try? filesDirectory().appendingPathComponent( + Self.receiptDirectoryName, + isDirectory: true + ) + } + + private func filesDirectory() throws -> URL { + let root = try baseDirectory ?? fileManager.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let files = root.appendingPathComponent("files", isDirectory: true) + try fileManager.createDirectory( + at: files, + withIntermediateDirectories: true, + attributes: nil + ) + return files + } +} diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 1f919a4a..919c3820 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -7,6 +7,205 @@ import Combine import UIKit #endif +/// Linearizes app-private-media admission against cancellation before work is +/// handed to the fragment scheduler. A transfer starts here synchronously, +/// before its `messageQueue` work item is enqueued; cancel/delete can therefore +/// leave a tombstone that the deferred work must observe. +/// +/// Active admissions and cancellation tombstones have independent count +/// bounds. Tombstones may age out or evict older tombstones; active entries +/// are never evicted under pressure. A one-hour active timeout is reported as +/// an explicit transfer failure and removes any handshake-queued payload. +private final class BLEPrivateMediaTransferAdmissionRegistry { + enum BeginResult: Equatable { + case admitted + case alreadyKnown + case capacityExhausted + } + + private enum State: Equatable { + case active + case cancelled + } + + private struct Entry { + var state: State + var updatedAt: Date + } + + private let lock = NSLock() + private let maxActiveEntries = 512 + private let maxCancelledTombstones = 512 + private let lifetime: TimeInterval = 60 * 60 + private let onActiveExpired: (String) -> Void + private var entries: [String: Entry] = [:] + + init(onActiveExpired: @escaping (String) -> Void) { + self.onActiveExpired = onActiveExpired + } + + func begin(_ transferId: String, now: Date = Date()) -> BeginResult { + guard !transferId.isEmpty else { return .alreadyKnown } + lock.lock() + let expiredActive = pruneLocked(now: now) + // Transfer IDs are invocation-unique. Never revive a cancellation or + // admit a duplicate invocation that reused an in-flight identifier. + let result: BeginResult + if entries[transferId] != nil { + result = .alreadyKnown + } else if activeCountLocked >= maxActiveEntries { + // Never evict an admitted transfer: doing so strands its UI + // placeholder with no completion event. Reject the newcomer and + // let the caller surface the bounded-pressure failure instead. + result = .capacityExhausted + } else { + entries[transferId] = Entry(state: .active, updatedAt: now) + result = .admitted + } + lock.unlock() + notifyExpired(expiredActive) + return result + } + + func cancel(_ transferId: String, now: Date = Date()) { + guard !transferId.isEmpty else { return } + lock.lock() + // Cancel the requested active entry before expiry pruning so a user + // cancellation wins over a simultaneous timeout notification. + entries[transferId] = Entry(state: .cancelled, updatedAt: now) + let expiredActive = pruneLocked(now: now) + trimCancelledTombstonesLocked() + lock.unlock() + notifyExpired(expiredActive) + } + + func isActive(_ transferId: String, now: Date = Date()) -> Bool { + lock.lock() + let expiredActive = pruneLocked(now: now) + let active = entries[transferId]?.state == .active + if active { + entries[transferId]?.updatedAt = now + } + lock.unlock() + notifyExpired(expiredActive) + return active + } + + /// Runs `body` while holding the admission lock. Callers use this at the + /// collections-queue append/submit boundary so cancellation and admission + /// have one deterministic order: whichever acquires this lock first wins. + func withActive( + _ transferId: String, + now: Date = Date(), + _ body: () -> Result + ) -> Result? { + lock.lock() + let expiredActive = pruneLocked(now: now) + guard entries[transferId]?.state == .active else { + lock.unlock() + notifyExpired(expiredActive) + return nil + } + entries[transferId]?.updatedAt = now + let result = body() + lock.unlock() + notifyExpired(expiredActive) + return result + } + + func finish(_ transferId: String) { + lock.lock() + entries.removeValue(forKey: transferId) + lock.unlock() + } + + var count: Int { + lock.lock() + let expiredActive = pruneLocked(now: Date()) + let result = entries.count + lock.unlock() + notifyExpired(expiredActive) + return result + } + + func prune(now: Date = Date()) { + lock.lock() + let expiredActive = pruneLocked(now: now) + lock.unlock() + notifyExpired(expiredActive) + } + + private var activeCountLocked: Int { + entries.values.reduce(into: 0) { count, entry in + if entry.state == .active { count += 1 } + } + } + + /// Removes stale tombstones silently and stale active admissions with a + /// caller-visible timeout notification. Must be called with `lock` held; + /// notifications are delivered only after the lock is released. + private func pruneLocked(now: Date) -> [String] { + var expiredActive: [String] = [] + let expiredEntries = entries.filter { + now.timeIntervalSince($0.value.updatedAt) > lifetime + } + for (transferId, entry) in expiredEntries { + if entry.state == .active { + expiredActive.append(transferId) + } + entries.removeValue(forKey: transferId) + } + trimCancelledTombstonesLocked() + return expiredActive + } + + private func trimCancelledTombstonesLocked() { + let cancelled = entries + .filter { $0.value.state == .cancelled } + .sorted { $0.value.updatedAt < $1.value.updatedAt } + let overflow = max(0, cancelled.count - maxCancelledTombstones) + for victim in cancelled.prefix(overflow) { + entries.removeValue(forKey: victim.key) + } + } + + private func notifyExpired(_ transferIds: [String]) { + for transferId in transferIds { + onActiveExpired(transferId) + } + } +} + +private struct BLEAuthenticatedPeerStateObservation { + let fingerprint: String + let sessionGeneration: UUID + let capabilities: PeerCapabilities +} + +private struct BLEPrivateMediaProofTimeoutMarker { + let fingerprint: String + let sessionGeneration: UUID? +} + +private struct BLEPrivateMediaProofWatchdog { + let fingerprint: String + let sessionGeneration: UUID + let timeoutNonce: UUID +} + +private struct BLEPendingPrivateMediaPolicyResolution { + let fingerprint: String + var sessionGeneration: UUID? + var timeoutNonce: UUID + var completions: [UUID: @MainActor (PrivateMediaSendPolicy) -> Void] +} + +private struct BLEAuthenticatedPeerStateSendProgress { + let sessionGeneration: UUID + var sentInitial = false + var sentEcho = false +} + /// BLEService — Bluetooth Mesh Transport /// - Emits events exclusively via `BitchatDelegate` for UI. /// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`). @@ -42,6 +241,7 @@ final class BLEService: NSObject { // that the session was established *on this current ingress link*, not // merely that some session exists for the claimed ID. bleQueue-owned. private var noiseAuthenticatedLinkOwners: [BLEIngressLinkID: PeerID] = [:] + private var noiseReconnectPolicy = BLENoiseReconnectPolicy() // Rotation-rebind cooldown per link UUID (bleQueue-owned, like the link // store): entries older than the cooldown are pruned on insert. @@ -104,6 +304,21 @@ final class BLEService: NSObject { // Test-only tap on the outbound pipeline so multi-node tests can ferry // packets between in-process service instances. var _test_onOutboundPacket: ((BitchatPacket) -> Void)? + /// May block a synthetic CoreBluetooth receive callback immediately + /// before it hands a packet to `messageQueue`. + var _test_beforeReceivePacketHandoff: (() -> Void)? + var _test_onReceivePacketHandoff: (() -> Void)? + var _test_onPrivateMediaSessionReconciled: ((PeerID) -> Void)? + /// May block in tests to hold the serial message queue immediately before + /// the deferred private-media admission check. + var _test_beforePrivateMediaDeferredSend: ((String) -> Void)? + /// May block announce handling after verified-link rebind work is queued. + /// Tests use this boundary to prove rebind and reconnect are serialized. + var _test_afterVerifiedDirectRebindEnqueued: (() -> Void)? + /// May block the convergence-recovery callback on its global-queue thread + /// before it enqueues onto `messageQueue`. Tests use this boundary to + /// force the quarantine-restore handler to win the dispatch race. + var _test_beforeHandshakeRecoveryEnqueued: ((PeerID) -> Void)? #endif private var selfBroadcastTracker = BLESelfBroadcastTracker() private let meshTopology = MeshTopologyTracker() @@ -119,6 +334,7 @@ final class BLEService: NSObject { private struct PendingMeshPing { let peerID: PeerID let sentAt: Date + let lifecycleGeneration: UInt64 let completion: @MainActor (MeshPingResult?) -> Void let timeout: DispatchWorkItem } @@ -131,10 +347,22 @@ final class BLEService: NSObject { // 5. Fragment Reassembly (necessary for messages > MTU) private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer() private var outboundFragmentTransfers = BLEOutboundFragmentTransferScheduler() - private let incomingFileStore = BLEIncomingFileStore() + private lazy var privateMediaTransferAdmissions = BLEPrivateMediaTransferAdmissionRegistry { [weak self] transferId in + self?.handlePrivateMediaAdmissionExpiry(transferId) + } + // All six maps below are protected by `collectionsQueue`. A fresh Noise + // authentication rotates the generation UUID, so stale proof timers and + // proof packets cannot classify a replacement session. + private var privateMediaSessionGenerations: [PeerID: UUID] = [:] + private var authenticatedPeerStates: [PeerID: BLEAuthenticatedPeerStateObservation] = [:] + private var privateMediaProofTimeoutMarkers: [PeerID: BLEPrivateMediaProofTimeoutMarker] = [:] + private var privateMediaProofWatchdogs: [PeerID: BLEPrivateMediaProofWatchdog] = [:] + private var pendingPrivateMediaPolicyResolutions: [PeerID: BLEPendingPrivateMediaPolicyResolution] = [:] + private var authenticatedPeerStateSendProgress: [PeerID: BLEAuthenticatedPeerStateSendProgress] = [:] + private let incomingFileStore: BLEIncomingFileStore // Simple announce throttling - private var announceThrottle = BLEAnnounceThrottle() + private let announceThrottle = BLEAnnounceThrottle() // Application state tracking (thread-safe) #if os(iOS) @@ -155,16 +383,21 @@ final class BLEService: NSObject { private var centralManager: CBCentralManager? private var peripheralManager: CBPeripheralManager? private var characteristic: CBMutableCharacteristic? + private let shouldInitializeBluetoothManagers: Bool + private let panicLifecycleLock = NSLock() + private var _isPanicSuspended: Bool + private var panicLifecycleGeneration: UInt64 = 0 // MARK: - Identity private var noiseService: NoiseEncryptionService + /// Injected so tests can compress the quarantine/rollback window; + /// production always passes the security-constant default. + private let noiseResponderHandshakeTimeout: TimeInterval private let identityManager: SecureIdentityStateManagerProtocol private let keychain: KeychainManagerProtocol private let idBridge: NostrIdentityBridge - /// Binary form of `myPeerID`; same contract — mutated only inside a - /// `messageQueue` barrier via `refreshPeerIdentity()`. - private var myPeerIDData: Data = Data() + private let localIdentityState = BLELocalIdentityStateStore() // MARK: - Advertising Privacy // No Local Name by default for maximum privacy. No rotating alias. @@ -275,11 +508,22 @@ final class BLEService: NSObject { keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge, identityManager: SecureIdentityStateManagerProtocol, - initializeBluetoothManagers: Bool = true + initializeBluetoothManagers: Bool = true, + incomingFileStore: BLEIncomingFileStore = BLEIncomingFileStore(), + startSuspendedForPanicRecovery: Bool = false, + noiseResponderHandshakeTimeout: TimeInterval = + NoiseSecurityConstants.ordinaryResponderHandshakeTimeout ) { self.keychain = keychain self.idBridge = idBridge - noiseService = NoiseEncryptionService(keychain: keychain) + self.incomingFileStore = incomingFileStore + self.shouldInitializeBluetoothManagers = initializeBluetoothManagers + self._isPanicSuspended = startSuspendedForPanicRecovery + self.noiseResponderHandshakeTimeout = noiseResponderHandshakeTimeout + noiseService = NoiseEncryptionService( + keychain: keychain, + ordinaryResponderHandshakeTimeout: noiseResponderHandshakeTimeout + ) self.identityManager = identityManager super.init() @@ -327,37 +571,90 @@ final class BLEService: NSObject { // any access from another queue (cross-queue reads use readLinkState). linkStateStore.assumeOwnership(of: bleQueue) - if initializeBluetoothManagers { - // Initialize BLE on background queue to prevent main thread blocking. - #if os(iOS) - let centralOptions: [String: Any] = [ - CBCentralManagerOptionRestoreIdentifierKey: BLEService.centralRestorationID - ] - centralManager = CBCentralManager(delegate: self, queue: bleQueue, options: centralOptions) - - let peripheralOptions: [String: Any] = [ - CBPeripheralManagerOptionRestoreIdentifierKey: BLEService.peripheralRestorationID - ] - peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue, options: peripheralOptions) - #else - centralManager = CBCentralManager(delegate: self, queue: bleQueue) - peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue) - #endif + if !startSuspendedForPanicRecovery { + initializeBluetoothManagersIfNeeded() } // Single maintenance timer for all periodic tasks (dispatch-based for // determinism). Only run it when real Bluetooth managers exist. meshBackgroundEnabled = initializeBluetoothManagers - startMaintenanceTimer() + if !startSuspendedForPanicRecovery { + startMaintenanceTimer() + } // Publish initial empty state requestPeerDataPublish() // Initialize gossip sync manager - restartGossipManager() + if !startSuspendedForPanicRecovery { + restartGossipManager() + } + } + + private var isPanicSuspended: Bool { + panicLifecycleLock.lock() + defer { panicLifecycleLock.unlock() } + return _isPanicSuspended + } + + private func setPanicSuspended(_ suspended: Bool) { + panicLifecycleLock.lock() + if suspended { + panicLifecycleGeneration &+= 1 + } + _isPanicSuspended = suspended + panicLifecycleLock.unlock() + } + + private func capturePanicLifecycleGeneration() -> UInt64? { + panicLifecycleLock.lock() + defer { panicLifecycleLock.unlock() } + return _isPanicSuspended ? nil : panicLifecycleGeneration + } + + private func isCurrentPanicLifecycleGeneration(_ generation: UInt64) -> Bool { + panicLifecycleLock.lock() + defer { panicLifecycleLock.unlock() } + return !_isPanicSuspended && panicLifecycleGeneration == generation + } + + private func initializeBluetoothManagersIfNeeded() { + guard shouldInitializeBluetoothManagers, + centralManager == nil, + peripheralManager == nil, + !isPanicSuspended else { return } + + // Initialize BLE on its dedicated delegate queue. On iOS, retain the + // restoration identifiers even when construction was deferred by a + // pending panic-recovery latch. + #if os(iOS) + let centralOptions: [String: Any] = [ + CBCentralManagerOptionRestoreIdentifierKey: + BLEService.centralRestorationID + ] + centralManager = CBCentralManager( + delegate: self, + queue: bleQueue, + options: centralOptions + ) + + let peripheralOptions: [String: Any] = [ + CBPeripheralManagerOptionRestoreIdentifierKey: + BLEService.peripheralRestorationID + ] + peripheralManager = CBPeripheralManager( + delegate: self, + queue: bleQueue, + options: peripheralOptions + ) + #else + centralManager = CBCentralManager(delegate: self, queue: bleQueue) + peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue) + #endif } private func restartGossipManager() { + guard !isPanicSuspended else { return } // Stop existing gossipSyncManager?.stop() @@ -416,12 +713,62 @@ final class BLEService: NSObject { #endif } - func resetIdentityForPanic(currentNickname: String) { + /// Close radio admission before application state starts disappearing. + /// CoreBluetooth callbacks consult the same gate and cannot restart scan + /// or advertising while the full panic transaction is incomplete. + func suspendForPanicReset() { + setPanicSuspended(true) + noisePacketHandler.resetForPanic() + gossipSyncManager?.stop() + gossipSyncManager = nil + // Stop the radio and drain CoreBluetooth's delegate queue first. A + // callback may already have passed its initial suspension check; the + // bleQueue drain forces its final messageQueue handoff to happen + // before the receive barrier below. + stopServicesImmediatelyForPanic() + // Drain every receive/send submitted by callbacks that finished ahead + // of the radio stop. Later callbacks observe the closed lifecycle, and + // generation-bound handoffs that raced this barrier reject themselves. + // Clear the old identity's bounded early-ciphertext queue again after + // those callbacks drain so none can repopulate it after the first wipe. messageQueue.sync(flags: .barrier) { + noisePacketHandler.resetForPanic() + } + clearEmergencySessionState() + } + + /// Reopen the radio only after media deletion and recovery-marker commit. + func completePanicReset(restartServices: Bool) { + // The media wipe ran on the recovery operations' own file store; this + // service's store still caches pre-panic receipt decisions (and a + // callback drained during suspension may have re-read the pre-wipe + // ledger). Drop the cache before admission reopens so the next lookup + // rebuilds from the wiped directory. + incomingFileStore.resetPrivateMediaReceiptsForPanic() + setPanicSuspended(false) + guard restartServices else { return } + startServices() + sendAnnounce(forceSend: true) + } + + func resetIdentityForPanic( + currentNickname: String, + restartServices: Bool = true + ) { + gossipSyncManager?.stop() + gossipSyncManager = nil + // Discard deferred pre-panic ciphertext behind any in-flight receive + // handlers so none can repopulate the handler's bounded queue. + messageQueue.sync(flags: .barrier) { + noisePacketHandler.resetForPanic() + } + // pendingNoiseSessionQueues is owned by collectionsQueue everywhere + // else, so clear it there too rather than on messageQueue. + collectionsQueue.sync(flags: .barrier) { pendingNoiseSessionQueues.removeAll() } - let cancelledTransfers = collectionsQueue.sync(flags: .barrier) { + let panicReset = collectionsQueue.sync(flags: .barrier) { pendingPeripheralWrites.removeAll() pendingNotifications.removeAll() let transfers = outboundFragmentTransfers.removeAll() @@ -430,18 +777,28 @@ final class BLEService: NSObject { ingressLinks.removeAll() recentTrafficTracker.removeAll() scheduledRelays.cancelAll() + // These callbacks belong to pre-panic transfer state. Invoking + // them would let queued UI work recreate or resend wiped media. + pendingPrivateMediaPolicyResolutions.removeAll() + privateMediaSessionGenerations.removeAll() + authenticatedPeerStates.removeAll() + privateMediaProofTimeoutMarkers.removeAll() + privateMediaProofWatchdogs.removeAll() + authenticatedPeerStateSendProgress.removeAll() // Let the post-panic identity publish its fresh bundle promptly. lastPrekeyBundleSentAt = nil return transfers } - for entry in cancelledTransfers { + for entry in panicReset { entry.workItems.forEach { $0.cancel() } TransferProgressManager.shared.cancel(id: entry.id) } bleQueue.sync { pendingWriteBuffers.removeAll() + noiseAuthenticatedLinkOwners.removeAll() + noiseReconnectPolicy.removeAll() connectionScheduler.reset() } disconnectNotifyDebouncer.removeAll() @@ -455,21 +812,29 @@ final class BLEService: NSObject { noiseService.clearEphemeralStateForPanic() noiseService.clearPersistentIdentity() - let newNoise = NoiseEncryptionService(keychain: keychain) + let newNoise = NoiseEncryptionService( + keychain: keychain, + ordinaryResponderHandshakeTimeout: noiseResponderHandshakeTimeout + ) noiseService = newNoise configureNoiseServiceCallbacks(for: newNoise) refreshPeerIdentity() } - restartGossipManager() - - setNickname(currentNickname) - + // Keep the transport silent until the application-level transaction + // has also removed its media and committed both recovery markers. + // Set through the identity store directly (not setNickname(_:), which + // would force-send an announce and break that silence). + localIdentityState.setNickname(currentNickname) messageDeduplicator.reset() messageQueue.async(flags: .barrier) { [weak self] in self?.selfBroadcastTracker.removeAll() } requestPeerDataPublish() - startServices() + if restartServices { + restartGossipManager() + startServices() + sendAnnounce(forceSend: true) + } } // Ensure this runs on message queue to avoid main thread blocking @@ -481,6 +846,7 @@ final class BLEService: NSObject { } return } + guard !isPanicSuspended else { return } guard content.count <= maxMessageLength else { SecureLogger.error("Message too long: \(content.count) chars", category: .session) @@ -537,20 +903,17 @@ final class BLEService: NSObject { // MARK: Identity - /// Derived from the Noise identity fingerprint; rotated only via - /// `refreshPeerIdentity()` (e.g. panic reset), which performs the swap - /// inside a `messageQueue` barrier so concurrent queue work never sees a - /// half-updated identity. Externally read-only — no out-of-band mutation - /// may bypass that derivation. - private(set) var myPeerID = PeerID(str: "") - /// Externally read-only; mutate via `setNickname(_:)`, which also - /// broadcasts the change to peers. - private(set) var myNickname: String = "anon" + /// Derived from the Noise identity fingerprint. Reads can originate from + /// the main actor, message queue, Bluetooth queue, and maintenance timer, + /// so all three local identity fields live in one lock-backed snapshot. + var myPeerID: PeerID { localIdentityState.snapshot().peerID } + var myNickname: String { localIdentityState.snapshot().nickname } + private var myPeerIDData: Data { localIdentityState.snapshot().peerIDData } /// Sole mutator for `myNickname`: updates the stored value and force-sends /// an announce so peers learn the new name. func setNickname(_ nickname: String) { - self.myNickname = nickname + localIdentityState.setNickname(nickname) // Send announce to notify peers of nickname change (force send) sendAnnounce(forceSend: true) } @@ -562,7 +925,9 @@ final class BLEService: NSObject { /// `startServices()` — the latter matters after a panic reset, where /// `stopServices()` cancels and nils the timer. private func startMaintenanceTimer() { - guard meshBackgroundEnabled, maintenanceTimer == nil else { return } + guard !isPanicSuspended, + meshBackgroundEnabled, + maintenanceTimer == nil else { return } let timer = DispatchSource.makeTimerSource(queue: bleQueue) timer.schedule(deadline: .now() + TransportConfig.bleMaintenanceInterval, repeating: TransportConfig.bleMaintenanceInterval, @@ -575,6 +940,12 @@ final class BLEService: NSObject { } func startServices() { + guard let lifecycleGeneration = + capturePanicLifecycleGeneration() else { return } + initializeBluetoothManagersIfNeeded() + if gossipSyncManager == nil { + restartGossipManager() + } // Restart the maintenance timer if a prior stopServices() cancelled it // (e.g. the panic flow), otherwise periodic announces, peer reconciliation // and cache cleanup would never resume until app restart. @@ -591,15 +962,20 @@ final class BLEService: NSObject { // Send initial announce after services are ready // Use longer delay to avoid conflicts with other announces messageQueue.asyncAfter(deadline: .now() + TransportConfig.bleInitialAnnounceDelaySeconds) { [weak self] in - self?.sendAnnounce(forceSend: true) + guard let self, + self.isCurrentPanicLifecycleGeneration( + lifecycleGeneration + ) else { return } + self.sendAnnounce(forceSend: true) } } func stopServices() { + let localIdentity = localIdentityState.snapshot() // Send leave message synchronously to ensure delivery var leavePacket = BitchatPacket( type: MessageType.leave.rawValue, - senderID: myPeerIDData, + senderID: localIdentity.peerIDData, recipientID: nil, timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: Data(), @@ -659,26 +1035,62 @@ final class BLEService: NSObject { centralManager?.cancelPeripheralConnection(state.peripheral) } } + + /// Panic cannot spend its security boundary sending a signed LEAVE or + /// pumping the main run loop. Close the radio and timers immediately; + /// the identity/session cleanup follows synchronously. + private func stopServicesImmediatelyForPanic() { + collectionsQueue.sync(flags: .barrier) { + pendingNotifications.removeAll() + } + + maintenanceTimer?.cancel() + maintenanceTimer = nil + scanDutyTimer?.cancel() + scanDutyTimer = nil + + centralManager?.stopScan() + peripheralManager?.stopAdvertising() + + let peripheralsToDisconnect = bleQueue.sync { + linkStateStore.peripheralStates + } + for state in peripheralsToDisconnect { + centralManager?.cancelPeripheralConnection(state.peripheral) + } + } func emergencyDisconnectAll() { stopServices() + clearEmergencySessionState() + } + private func clearEmergencySessionState() { // Clear all sessions and peers - let cancelledTransfers: [(id: String, items: [DispatchWorkItem])] = collectionsQueue.sync(flags: .barrier) { - let entries = outboundFragmentTransfers.removeAll().map { ($0.id, $0.workItems) } + let cancelled = collectionsQueue.sync(flags: .barrier) { + let entries = outboundFragmentTransfers.removeAll().map { + (id: $0.id, items: $0.workItems) + } + let pingTimeouts = pendingMeshPings.values.map(\.timeout) + pendingMeshPings.removeAll() + meshPingResponseLimiter = SyncResponseRateLimiter( + maxResponses: TransportConfig.meshPingInboundMaxPerLink, + window: TransportConfig.meshPingInboundWindowSeconds + ) peerRegistry.removeAll() fragmentAssemblyBuffer.removeAll() sourceRouteFailures = BLESourceRouteFailureCache() // Also clear pending message queues to avoid stale state across sessions pendingNoiseSessionQueues.removeAll() pendingDirectedRelays.removeAll() - return entries + return (transfers: entries, pingTimeouts: pingTimeouts) } - for entry in cancelledTransfers { + for entry in cancelled.transfers { entry.items.forEach { $0.cancel() } TransferProgressManager.shared.cancel(id: entry.id) } + cancelled.pingTimeouts.forEach { $0.cancel() } // Clear processed messages messageDeduplicator.reset() @@ -687,6 +1099,7 @@ final class BLEService: NSObject { bleQueue.sync { linkStateStore.clearAll() noiseAuthenticatedLinkOwners.removeAll() + noiseReconnectPolicy.removeAll() connectionScheduler.reset() subscriptionAnnounceLimiter.removeAll() } @@ -732,6 +1145,275 @@ final class BLEService: NSObject { collectionsQueue.sync { peerRegistry.capabilities(for: peerID) } } + func authenticatedPrivateMediaReceiptSessionGeneration( + to peerID: PeerID + ) -> UUID? { + let normalizedPeerID = peerID.toShort() + let currentNoiseGeneration = + noiseService.sessionGeneration(for: normalizedPeerID) + return collectionsQueue.sync { + guard let generation = + privateMediaSessionGenerations[normalizedPeerID], + generation == currentNoiseGeneration, + let authenticated = + authenticatedPeerStates[normalizedPeerID], + authenticated.sessionGeneration == generation, + authenticated.capabilities.contains(.privateMedia), + authenticated.capabilities.contains( + .privateMediaReceipts + ) else { + return nil + } + return generation + } + } + + private func privateMediaPolicyFingerprint( + for peerID: PeerID, + expectedSessionGeneration: UUID? + ) -> String? { + let normalizedPeerID = peerID.toShort() + if let expectedSessionGeneration, + noiseService.sessionGeneration(for: normalizedPeerID) + == expectedSessionGeneration, + let fingerprint = noiseService.getPeerFingerprint(normalizedPeerID), + noiseService.sessionGeneration(for: normalizedPeerID) + == expectedSessionGeneration { + // The exact authenticated Noise static key is stronger than a + // registry entry populated by a public announce. + return fingerprint + } + return collectionsQueue.sync { + peerRegistry.info(for: normalizedPeerID)? + .noisePublicKey? + .sha256Fingerprint() + } + } + + func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { + let normalizedPeerID = peerID.toShort() + let state: ( + capabilities: PeerCapabilities, + fingerprint: String?, + sessionGeneration: UUID?, + authenticatedState: BLEAuthenticatedPeerStateObservation?, + timedOut: BLEPrivateMediaProofTimeoutMarker? + ) = collectionsQueue.sync { + let info = peerRegistry.info(for: normalizedPeerID) + return ( + info?.capabilities ?? [], + info?.noisePublicKey?.sha256Fingerprint(), + privateMediaSessionGenerations[normalizedPeerID], + authenticatedPeerStates[normalizedPeerID], + privateMediaProofTimeoutMarkers[normalizedPeerID] + ) + } + let currentNoiseGeneration = noiseService.sessionGeneration(for: normalizedPeerID) + + // A session replacement can happen before its authentication callback + // reaches messageQueue. Never reuse an observation from the previous + // transport generation during that window. + if state.sessionGeneration != currentNoiseGeneration { + return .awaitingCapabilityProof + } + + guard let fingerprint = privateMediaPolicyFingerprint( + for: normalizedPeerID, + expectedSessionGeneration: state.sessionGeneration + ) ?? state.fingerprint else { + // A raw fallback must be bound to the stable Noise key from a + // verified registry entry; a routing ID alone can rotate or be + // spoofed. Without that key neither proof nor safe migration state + // can be attributed. + return .blockedDowngrade + } + + let wasPreviouslyCapable = identityManager.hasObservedPrivateMediaCapability( + fingerprint: fingerprint + ) + + if let authenticated = state.authenticatedState, + authenticated.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame, + authenticated.sessionGeneration == state.sessionGeneration { + if authenticated.capabilities.contains(.privateMedia) { + return .encrypted + } + return wasPreviouslyCapable ? .blockedDowngrade : .legacyRequiresConsent + } + + if let timedOut = state.timedOut, + timedOut.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame, + timedOut.sessionGeneration == state.sessionGeneration { + return wasPreviouslyCapable ? .blockedDowngrade : .legacyRequiresConsent + } + + // The announce bit is a discovery hint only. It can trigger a Noise + // handshake, but it cannot select encrypted media or create a durable + // pin because anyone can copy a public Noise key into a self-signed + // announce. A prior pin also re-confirms on each replacement session + // so an authenticated no-bit response becomes a visible downgrade. + if state.capabilities.contains(.privateMedia) || wasPreviouslyCapable { + return .awaitingCapabilityProof + } + + // Old clients that never advertised the bit remain eligible only for + // the explicit, invocation-scoped legacy consent path. + return .legacyRequiresConsent + } + + func resolvePrivateMediaSendPolicy( + to peerID: PeerID, + completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void + ) { + let normalizedPeerID = peerID.toShort() + messageQueue.async { [weak self] in + guard let self else { return } + let immediate = self.privateMediaSendPolicy(to: normalizedPeerID) + guard immediate == .awaitingCapabilityProof else { + self.completePrivateMediaPolicyResolution([completion], with: immediate) + return + } + + let generation = self.collectionsQueue.sync { + self.privateMediaSessionGenerations[normalizedPeerID] + } + let fingerprint = self.privateMediaPolicyFingerprint( + for: normalizedPeerID, + expectedSessionGeneration: generation + ) + guard let fingerprint else { + self.completePrivateMediaPolicyResolution([completion], with: .blockedDowngrade) + return + } + + let requestID = UUID() + let registration = self.collectionsQueue.sync(flags: .barrier) { + () -> (registered: Bool, shouldSchedule: Bool, nonce: UUID, generation: UUID?) in + let generation = self.privateMediaSessionGenerations[normalizedPeerID] + if var pending = self.pendingPrivateMediaPolicyResolutions[normalizedPeerID] { + guard pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame, + pending.completions.count + < TransportConfig.privateMediaCapabilityProofWaitersPerPeerCap else { + return (false, false, UUID(), generation) + } + pending.completions[requestID] = completion + self.pendingPrivateMediaPolicyResolutions[normalizedPeerID] = pending + return (true, false, pending.timeoutNonce, pending.sessionGeneration) + } + + guard self.pendingPrivateMediaPolicyResolutions.count + < TransportConfig.privateMediaCapabilityProofPendingPeerCap else { + return (false, false, UUID(), generation) + } + let currentWatchdog = self.privateMediaProofWatchdogs[normalizedPeerID] + let reusesWatchdog = currentWatchdog?.fingerprint + .caseInsensitiveCompare(fingerprint) == .orderedSame + && currentWatchdog?.sessionGeneration == generation + let nonce: UUID + if reusesWatchdog, let currentWatchdog { + nonce = currentWatchdog.timeoutNonce + } else { + nonce = UUID() + } + self.pendingPrivateMediaPolicyResolutions[normalizedPeerID] = + BLEPendingPrivateMediaPolicyResolution( + fingerprint: fingerprint, + sessionGeneration: generation, + timeoutNonce: nonce, + completions: [requestID: completion] + ) + return (true, !reusesWatchdog, nonce, generation) + } + + guard registration.registered else { + self.completePrivateMediaPolicyResolution([completion], with: .blockedDowngrade) + return + } + if registration.shouldSchedule { + self.schedulePrivateMediaProofTimeout( + for: normalizedPeerID, + fingerprint: fingerprint, + sessionGeneration: registration.generation, + nonce: registration.nonce + ) + } + + if !self.noiseService.hasEstablishedSession(with: normalizedPeerID) { + self.initiateNoiseHandshake(with: normalizedPeerID) + } + } + } + + private func completePrivateMediaPolicyResolution( + _ completions: [@MainActor (PrivateMediaSendPolicy) -> Void], + with policy: PrivateMediaSendPolicy + ) { + guard !completions.isEmpty else { return } + notifyUI { + completions.forEach { $0(policy) } + } + } + + private func schedulePrivateMediaProofTimeout( + for peerID: PeerID, + fingerprint: String, + sessionGeneration: UUID?, + nonce: UUID + ) { + messageQueue.asyncAfter( + deadline: .now() + TransportConfig.privateMediaCapabilityProofTimeoutSeconds + ) { [weak self] in + self?.handlePrivateMediaProofTimeout( + for: peerID, + fingerprint: fingerprint, + sessionGeneration: sessionGeneration, + nonce: nonce + ) + } + } + + private func handlePrivateMediaProofTimeout( + for peerID: PeerID, + fingerprint: String, + sessionGeneration: UUID?, + nonce: UUID + ) { + let expiration = collectionsQueue.sync(flags: .barrier) { + () -> (expired: Bool, completions: [@MainActor (PrivateMediaSendPolicy) -> Void]) in + let pending = pendingPrivateMediaPolicyResolutions[peerID] + let pendingMatches = pending?.timeoutNonce == nonce + && pending?.sessionGeneration == sessionGeneration + && pending?.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame + let watchdog = privateMediaProofWatchdogs[peerID] + let watchdogMatches = sessionGeneration != nil + && watchdog?.timeoutNonce == nonce + && watchdog?.sessionGeneration == sessionGeneration + && watchdog?.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame + guard pendingMatches || watchdogMatches else { + return (false, []) + } + var completions: [@MainActor (PrivateMediaSendPolicy) -> Void] = [] + if pendingMatches, let pending { + completions = Array(pending.completions.values) + } + if pendingMatches { + pendingPrivateMediaPolicyResolutions.removeValue(forKey: peerID) + } + if watchdogMatches { + privateMediaProofWatchdogs.removeValue(forKey: peerID) + } + privateMediaProofTimeoutMarkers[peerID] = BLEPrivateMediaProofTimeoutMarker( + fingerprint: fingerprint, + sessionGeneration: sessionGeneration + ) + return (true, completions) + } + guard expiration.expired else { return } + let policy = privateMediaSendPolicy(to: peerID) + sendPendingNoisePayloadsAfterHandshake(for: peerID) + completePrivateMediaPolicyResolution(expiration.completions, with: policy) + } + /// Enables or disables a runtime-advertised capability bit (e.g. the /// internet-gateway toggle) and re-announces so peers learn promptly. /// Build-time bits stay in `PeerCapabilities.localSupported`. @@ -859,7 +1541,28 @@ final class BLEService: NSObject { // MARK: Messaging + private func handlePrivateMediaAdmissionExpiry(_ transferId: String) { + // Expiry can be discovered from the BLE maintenance queue or while a + // caller already owns collectionsQueue. Cleanup is therefore + // fire-and-forget; never synchronously re-enter the collections lock. + collectionsQueue.async(flags: .barrier) { [weak self] in + _ = self?.pendingNoiseSessionQueues.removeTypedPayload(transferId: transferId) + } + TransferProgressManager.shared.rejectBeforeStart( + id: transferId, + reason: String( + localized: "content.delivery.reason.private_media_admission_expired", + defaultValue: "Media transfer timed out before it could start", + comment: "Failure reason when private-media admission expires before fragment scheduling" + ) + ) + } + func cancelTransfer(_ transferId: String) { + // Cancellation must become visible synchronously. Scheduler/pending- + // Noise cleanup remains asynchronous, but deferred private-media work + // cannot pass another admission boundary after this returns. + privateMediaTransferAdmissions.cancel(transferId) collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } @@ -877,7 +1580,9 @@ final class BLEService: NSObject { SecureLogger.debug("🛑 Removed pending transfer \(id.prefix(8))… before start", category: .session) case .missing: - break + if self.pendingNoiseSessionQueues.removeTypedPayload(transferId: transferId) { + SecureLogger.debug("🛑 Removed handshake-queued transfer \(transferId.prefix(8))…", category: .session) + } } } } @@ -899,6 +1604,7 @@ final class BLEService: NSObject { func sendFileBroadcast(_ filePacket: BitchatFilePacket, transferId: String) { messageQueue.async { [weak self] in guard let self = self else { return } + guard !self.isPanicSuspended else { return } guard let payload = filePacket.encode() else { SecureLogger.error("❌ Failed to encode file packet for broadcast", category: .session) return @@ -933,40 +1639,324 @@ final class BLEService: NSObject { } func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) { + sendFilePrivate( + filePacket, + to: peerID, + transferId: transferId, + allowLegacyFallback: false + ) + } + + func sendFilePrivate( + _ filePacket: BitchatFilePacket, + to peerID: PeerID, + transferId: String, + allowLegacyFallback: Bool + ) { + sendFilePrivate( + filePacket, + to: peerID, + transferId: transferId, + allowLegacyFallback: allowLegacyFallback, + requiresAuthenticatedPrivateMediaReceipts: false + ) + } + + func sendFilePrivateReceiptRetry( + _ filePacket: BitchatFilePacket, + to peerID: PeerID, + transferId: String + ) { + sendFilePrivate( + filePacket, + to: peerID, + transferId: transferId, + allowLegacyFallback: false, + requiresAuthenticatedPrivateMediaReceipts: true + ) + } + + private func sendFilePrivate( + _ filePacket: BitchatFilePacket, + to peerID: PeerID, + transferId: String, + allowLegacyFallback: Bool, + requiresAuthenticatedPrivateMediaReceipts: Bool + ) { + // Register before enqueueing onto messageQueue. This closes the window + // where cancel/delete could run first, observe no scheduler state, and + // then be followed by a deferred clear-media send. + switch privateMediaTransferAdmissions.begin(transferId) { + case .admitted: + break + + case .alreadyKnown: + SecureLogger.debug( + "Private media admission already cancelled or duplicated for \(transferId.prefix(8))…", + category: .security + ) + return + + case .capacityExhausted: + SecureLogger.warning( + "Private media admission capacity exhausted for \(transferId.prefix(8))…", + category: .security + ) + TransferProgressManager.shared.rejectBeforeStart( + id: transferId, + reason: String( + localized: "content.delivery.reason.private_media_admission_full", + defaultValue: "Too many media transfers are waiting; try again shortly", + comment: "Failure reason when too many private-media transfers are awaiting admission" + ) + ) + return + } messageQueue.async { [weak self] in guard let self = self else { return } - guard let payload = filePacket.encode() else { - SecureLogger.error("❌ Failed to encode file packet for private send", category: .session) + #if DEBUG + self._test_beforePrivateMediaDeferredSend?(transferId) + #endif + guard !self.isPanicSuspended else { + self.privateMediaTransferAdmissions.finish(transferId) + return + } + guard self.privateMediaTransferAdmissions.isActive(transferId) else { + self.privateMediaTransferAdmissions.finish(transferId) return } - // Normalize to short form (SHA256-derived 16-hex) for wire protocol compatibility - // This ensures 64-hex Noise keys are converted to the canonical routing format let targetID = peerID.toShort() - guard let recipientData = Data(hexString: targetID.id) else { - SecureLogger.error("❌ Invalid recipient peer ID for file transfer: \(peerID.id.prefix(8))…", category: .session) + switch self.privateMediaSendPolicy(to: targetID) { + case .encrypted: + break + + case .awaitingCapabilityProof: + // The UI coordinator resolves this state before calling the + // transport. Keep the transport guard fail-closed for direct + // callers and for a session replacement that races the call. + SecureLogger.warning( + "Private media held pending authenticated capability proof for \(targetID.id.prefix(8))…", + category: .security + ) + TransferProgressManager.shared.rejectBeforeStart( + id: transferId, + reason: String( + localized: "content.delivery.reason.private_media_capability_unresolved", + defaultValue: "Could not confirm encrypted media support", + comment: "Failure reason when private-media capability negotiation did not resolve" + ) + ) + self.privateMediaTransferAdmissions.finish(transferId) + return + + case .legacyRequiresConsent: + guard allowLegacyFallback else { + SecureLogger.warning( + "Private media blocked pending explicit legacy-clear consent for \(targetID.id.prefix(8))…", + category: .security + ) + TransferProgressManager.shared.rejectBeforeStart( + id: transferId, + reason: String( + localized: "content.delivery.reason.legacy_media_consent_required", + defaultValue: "Confirmation required before sending without end-to-end encryption", + comment: "Failure reason when a legacy private-media send lacks per-send consent" + ) + ) + self.privateMediaTransferAdmissions.finish(transferId) + return + } + // Migration path accepted by current Android and used by older + // iOS releases: preserve the directed raw file-transfer wire + // shape, but require the signature the receive path verifies. + // The allow flag belongs to this invocation only and is + // consumed here; a retry must obtain fresh user consent. + self.sendSignedLegacyPrivateFile( + filePacket, + to: targetID, + transferId: transferId + ) + return + + case .blockedDowngrade: + SecureLogger.warning( + "Private media downgrade blocked for \(targetID.id.prefix(8))…", + category: .security + ) + TransferProgressManager.shared.rejectBeforeStart( + id: transferId, + reason: String( + localized: "content.delivery.reason.private_media_downgrade_blocked", + defaultValue: "Encrypted media required; ask this contact to upgrade", + comment: "Failure reason when a peer that previously supported encrypted media appears to downgrade" + ) + ) + self.privateMediaTransferAdmissions.finish(transferId) + return + } + if requiresAuthenticatedPrivateMediaReceipts, + self.authenticatedPrivateMediaReceiptSessionGeneration( + to: targetID + ) == nil { + SecureLogger.warning( + "Private media retry blocked without current authenticated receipt support for \(targetID.id.prefix(8))…", + category: .security + ) + TransferProgressManager.shared.rejectBeforeStart( + id: transferId, + reason: String( + localized: "content.delivery.reason.private_media_capability_unresolved", + defaultValue: "Could not confirm encrypted media support", + comment: "Failure reason when private-media capability negotiation did not resolve" + ) + ) + self.privateMediaTransferAdmissions.finish(transferId) + return + } + guard let typedPayload = BLENoisePayloadFactory.privateFile(filePacket) else { + SecureLogger.error("❌ Failed to encode file packet for private send", category: .session) + TransferProgressManager.shared.rejectBeforeStart( + id: transferId, + reason: String(localized: "content.delivery.reason.media_encoding_failed", defaultValue: "Failed to prepare media", comment: "Failure reason when private media cannot be encoded") + ) + self.privateMediaTransferAdmissions.finish(transferId) + return + } + guard self.noiseService.hasEstablishedSession(with: targetID) else { + if requiresAuthenticatedPrivateMediaReceipts { + // A retry belongs to one exact authenticated generation. + // Never let it enter the ordinary pending queue where a + // bit-8-only replacement session could later flush it. + TransferProgressManager.shared.rejectBeforeStart( + id: transferId, + reason: String( + localized: "content.delivery.reason.private_media_capability_unresolved", + defaultValue: "Could not confirm encrypted media support", + comment: "Failure reason when private-media capability negotiation did not resolve" + ) + ) + self.privateMediaTransferAdmissions.finish(transferId) + return + } + let queued = self.collectionsQueue.sync(flags: .barrier) { + self.privateMediaTransferAdmissions.withActive(transferId) { + self.pendingNoiseSessionQueues.appendTypedPayload( + typedPayload, + transferId: transferId, + for: targetID + ) + return true + } ?? false + } + guard queued else { + self.privateMediaTransferAdmissions.finish(transferId) + return + } + SecureLogger.debug("📥 Queued private file for \(targetID.id.prefix(8))… pending handshake", category: .session) + guard self.privateMediaTransferAdmissions.isActive(transferId) else { + self.collectionsQueue.sync(flags: .barrier) { + _ = self.pendingNoiseSessionQueues.removeTypedPayload(transferId: transferId) + } + self.privateMediaTransferAdmissions.finish(transferId) + return + } + self.initiateNoiseHandshake(with: targetID) return } - var packet = BitchatPacket( - type: MessageType.fileTransfer.rawValue, - senderID: self.myPeerIDData, - recipientID: recipientData, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: payload, - signature: nil, - ttl: self.messageTTL, - version: 2 - ) - - if let signed = self.noiseService.signPacket(packet) { - packet = signed + do { + guard self.privateMediaTransferAdmissions.isActive(transferId) else { + self.privateMediaTransferAdmissions.finish(transferId) + return + } + let packet = try self.makeEncryptedNoisePacket( + typedPayload, + to: targetID, + requiresAuthenticatedPrivateMediaReceipts: + requiresAuthenticatedPrivateMediaReceipts + ) + guard self.privateMediaTransferAdmissions.isActive(transferId) else { + self.privateMediaTransferAdmissions.finish(transferId) + return + } + SecureLogger.debug("📁 Sending encrypted private file to \(targetID.id.prefix(8))… plaintextBytes=\(typedPayload.count)", category: .session) + self.broadcastPacket( + packet, + transferId: transferId, + requiresPrivateMediaAdmission: true + ) + } catch { + SecureLogger.error("❌ Failed to encrypt private file for \(targetID.id.prefix(8))…: \(error)", category: .security) + TransferProgressManager.shared.rejectBeforeStart( + id: transferId, + reason: String(localized: "content.delivery.reason.encryption_failed", comment: "Failure reason shown when a message could not be encrypted for the peer") + ) + self.privateMediaTransferAdmissions.finish(transferId) } - - SecureLogger.debug("📁 Sending private file transfer to \(peerID.id.prefix(8))… bytes=\(payload.count)", category: .session) - self.broadcastPacket(packet, transferId: transferId) } } + /// Compatibility-only fallback for peers that have not advertised + /// encrypted private media. The payload is authenticated but visible to + /// relays, matching the pre-migration behavior until those clients upgrade. + private func sendSignedLegacyPrivateFile( + _ filePacket: BitchatFilePacket, + to targetID: PeerID, + transferId: String + ) { + guard privateMediaTransferAdmissions.isActive(transferId) else { + privateMediaTransferAdmissions.finish(transferId) + return + } + guard let payload = filePacket.encode(), + let recipientData = Data(hexString: targetID.id) else { + SecureLogger.error("❌ Failed to encode legacy private file transfer", category: .session) + TransferProgressManager.shared.rejectBeforeStart( + id: transferId, + reason: String(localized: "content.delivery.reason.media_encoding_failed", defaultValue: "Failed to prepare media", comment: "Failure reason when private media cannot be encoded") + ) + privateMediaTransferAdmissions.finish(transferId) + return + } + + let unsigned = BitchatPacket( + type: MessageType.fileTransfer.rawValue, + senderID: myPeerIDData, + recipientID: recipientData, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: messageTTL, + version: 2 + ) + guard let signed = noiseService.signPacket(unsigned) else { + SecureLogger.error("❌ Failed to sign legacy private file transfer", category: .security) + TransferProgressManager.shared.rejectBeforeStart( + id: transferId, + reason: String(localized: "content.delivery.reason.media_signing_failed", defaultValue: "Failed to authenticate media", comment: "Failure reason when a legacy private-media packet cannot be signed") + ) + privateMediaTransferAdmissions.finish(transferId) + return + } + + // Signing can be non-trivial; cancellation that won while it ran must + // still prevent the clear payload from reaching the broadcast path. + guard privateMediaTransferAdmissions.isActive(transferId) else { + privateMediaTransferAdmissions.finish(transferId) + return + } + + SecureLogger.warning( + "📁 Sending signed legacy private file to \(targetID.id.prefix(8))…; peer has not advertised E2E media", + category: .security + ) + broadcastPacket( + signed, + transferId: transferId, + requiresPrivateMediaAdmission: true + ) + } + func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { // Hop like sendMessage: callers are often on the main actor, and the @@ -1087,7 +2077,26 @@ final class BLEService: NSObject { // MARK: - Packet Broadcasting - private func broadcastPacket(_ packet: BitchatPacket, transferId: String? = nil) { + private func broadcastPacket( + _ packet: BitchatPacket, + transferId: String? = nil, + requiresPrivateMediaAdmission: Bool = false + ) { + guard !isPanicSuspended else { + if requiresPrivateMediaAdmission, let transferId { + privateMediaTransferAdmissions.finish(transferId) + } + return + } + if requiresPrivateMediaAdmission { + guard let transferId, + privateMediaTransferAdmissions.isActive(transferId) else { + if let transferId { + privateMediaTransferAdmissions.finish(transferId) + } + return + } + } // Apply route if recipient exists (centralized route application) let packetToSend: BitchatPacket if let recipientPeerID = PeerID(hexData: packet.recipientID) { @@ -1096,14 +2105,112 @@ final class BLEService: NSObject { packetToSend = packet } + // Encode once using a small per-type padding policy, then delegate by type + let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type) + + // The 256-fragment ceiling exists to protect *current Android* + // receivers, which only ever receive private media over the directed + // raw-file migration fallback (they do not implement the encrypted + // 0x20 path). Encrypted private media (`noiseEncrypted`) is sent only to + // peers that advertised the `.privateMedia` capability — modern clients + // that assemble up to the full receiver ceiling (see + // `BLEFragmentAssemblyBuffer`'s 10,000-fragment guard) — so forcing them + // down to Android's 256 cap would needlessly reject iOS→iOS photos in + // the ~120–512 KiB range that work today. Restrict the low cap to the + // migration fallback (directed `fileTransfer`); public media is + // unaffected. Run the same planner the scheduler will use, after route + // application, and reject before reserving a transfer slot or writing + // any fragment. + // TODO(#1434): negotiate an explicit per-peer fragment limit so a future + // Android client that adopts the encrypted 0x20 path but still caps its + // reassembler can advertise its own ceiling instead of relying on the + // capability/type proxy above. + if let transferId, + let recipientPeerID = PeerID(hexData: packetToSend.recipientID), + packetToSend.type == MessageType.fileTransfer.rawValue { + let compatibilityRequest = BLEOutboundFragmentTransferRequest( + packet: packetToSend, + pad: padForBLE, + maxChunk: nil, + directedPeer: recipientPeerID, + transferId: transferId + ) + guard let plan = BLEOutboundFragmentPlanner.makePlan( + for: compatibilityRequest, + defaultChunkSize: defaultFragmentSize, + bleMaxMTU: bleMaxMTU + ), BLEOutboundFragmentPlanner.isPrivateMediaV1Compatible(plan) else { + SecureLogger.warning( + "Private media rejected: exceeds cross-platform 256-fragment limit", + category: .security + ) + TransferProgressManager.shared.rejectBeforeStart( + id: transferId, + reason: String( + localized: "content.delivery.reason.private_media_too_many_fragments", + defaultValue: "File is too large for this contact's client (more than 256 mesh fragments)", + comment: "Failure reason when private media exceeds the Android-compatible fragment limit" + ) + ) + if requiresPrivateMediaAdmission { + privateMediaTransferAdmissions.finish(transferId) + } + return + } + } + + // Route planning and fragment preflight can take enough time for a + // user cancellation to win. Recheck before exposing even the test tap, + // then check atomically with scheduler admission below. + if requiresPrivateMediaAdmission { + guard let transferId, + privateMediaTransferAdmissions.isActive(transferId) else { + if let transferId { + privateMediaTransferAdmissions.finish(transferId) + } + return + } + } + #if DEBUG _test_onOutboundPacket?(packetToSend) #endif - - // Encode once using a small per-type padding policy, then delegate by type - let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type) + if packetToSend.type == MessageType.fileTransfer.rawValue { - sendFragmentedPacket(packetToSend, pad: padForBLE, maxChunk: nil, directedOnlyPeer: nil, transferId: transferId) + sendFragmentedPacket( + packetToSend, + pad: padForBLE, + maxChunk: nil, + directedOnlyPeer: nil, + transferId: transferId, + requiresPrivateMediaAdmission: requiresPrivateMediaAdmission + ) + return + } + // App-initiated private media is already one opaque Noise ciphertext. + // Always fragment that outer packet so the existing transfer scheduler + // retains progress/cancel behavior without exposing the file TLVs. + if packetToSend.type == MessageType.noiseEncrypted.rawValue, + let transferId, + let recipientPeerID = PeerID(hexData: packetToSend.recipientID) { + sendFragmentedPacket( + packetToSend, + pad: padForBLE, + maxChunk: nil, + directedOnlyPeer: recipientPeerID, + transferId: transferId, + requiresPrivateMediaAdmission: requiresPrivateMediaAdmission + ) + return + } + if requiresPrivateMediaAdmission { + if let transferId { + privateMediaTransferAdmissions.finish(transferId) + } + SecureLogger.error( + "Private media admission reached an unsupported non-directed packet shape", + category: .security + ) return } guard let data = packetToSend.toBinaryData(padding: padForBLE) else { @@ -1169,8 +2276,10 @@ final class BLEService: NSObject { } private func enqueuePendingNotification(data: Data, centrals: [CBCentral]?, context: String, attempt: Int = 0) { + guard !isPanicSuspended else { return } collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } + guard !self.isPanicSuspended else { return } let result = self.pendingNotifications.enqueue( data: data, targets: centrals, @@ -1271,6 +2380,7 @@ final class BLEService: NSObject { requireDirectPeerLink: Bool = false, requireNoiseAuthenticatedPeerLink: Bool = false ) -> Bool { + guard !isPanicSuspended else { return false } let ingressRecord = collectionsQueue.sync { ingressLinks.record(for: packet) } var excludedPeerLinks = links(to: ingressRecord?.peerID) if requireNoiseAuthenticatedPeerLink { @@ -1421,6 +2531,7 @@ final class BLEService: NSObject { } private func flushDirectedSpool() { + guard !isPanicSuspended else { return } // Move items out and attempt broadcast; if still no links, they'll be re-spooled let toSend = collectionsQueue.sync(flags: .barrier) { pendingDirectedRelays.drainUnexpired( @@ -1463,23 +2574,47 @@ final class BLEService: NSObject { gossipSyncManager?.removePublicMessages(from: peerID) } + /// Clearing the mesh timeline erases the archive behind it, so the cleared + /// history is gone from disk rather than merely hidden from the timeline. + func purgeAllArchivedPublicMessages() { + gossipSyncManager?.removeAllPublicMessages() + } + func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) { + guard let generation = capturePanicLifecycleGeneration() else { + return + } guard let sync = gossipSyncManager else { - Task { @MainActor in completion([]) } + notifyUI { [weak self] in + guard let self, + self.isCurrentPanicLifecycleGeneration(generation) else { + return + } + completion([]) + } return } sync.collectPublicMessagePackets { [weak self] packets in - guard let self = self else { - Task { @MainActor in completion([]) } + guard let self, + self.isCurrentPanicLifecycleGeneration(generation) else { return } // Signature verification and registry lookups run on messageQueue // like the live receive path. self.messageQueue.async { + guard self.isCurrentPanicLifecycleGeneration(generation) else { + return + } let decoded = packets .compactMap { self.decodeArchivedPublicMessage($0) } .sorted { $0.timestamp < $1.timestamp } - Task { @MainActor in completion(decoded) } + self.notifyUI { [weak self] in + guard let self, + self.isCurrentPanicLifecycleGeneration(generation) else { + return + } + completion(decoded) + } } } } @@ -1533,6 +2668,9 @@ final class BLEService: NSObject { verifyPacketSignature: { [weak self] packet, signingPublicKey in self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false }, + localSigningPublicKey: { [weak self] in + self?.noiseService.getSigningPublicKeyData() ?? Data() + }, signedSenderDisplayName: { [weak self] packet, peerID in self?.signedSenderDisplayName(for: packet, from: peerID) }, @@ -1551,12 +2689,63 @@ final class BLEService: NSObject { defaultPrefix: defaultPrefix ) }, + privateMediaReceiptState: { [weak self] messageID in + self?.incomingFileStore.privateMediaReceiptState( + messageID: messageID + ) ?? .unavailable + }, + commitPrivateMediaFile: { [weak self] messageID, storedURL in + self?.incomingFileStore.commitPrivateMediaFile( + messageID: messageID, + storedURL: storedURL + ) ?? false + }, + removeIncomingFile: { [weak self] storedURL in + self?.incomingFileStore.removeIncomingFile(at: storedURL) + }, + finishIncomingFileDelivery: { [weak self] storedURL in + // Serialize pending-owner release behind deletion barriers. + // If /clear snapshots before this UI insertion, its already + // queued barrier must still observe the path as pending. If + // insertion wins first, the next MainActor snapshot sees the + // new bubble and protects the path explicitly. + self?.messageQueue.async(flags: .barrier) { + self?.incomingFileStore.finishIncomingFileDelivery( + at: storedURL + ) + } + }, + isPrivateMediaSenderBlocked: { [weak self] peerID in + guard let self else { return false } + let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID) + ?? self.collectionsQueue.sync { + self.peerRegistry.info(for: peerID)?.noisePublicKey + } + guard let senderStaticKey else { return false } + return self.identityManager.isBlocked( + fingerprint: senderStaticKey.sha256Fingerprint() + ) + }, updatePeerLastSeen: { [weak self] peerID in self?.updatePeerLastSeen(peerID) }, - deliverMessage: { [weak self] message in - // Single main-actor hop delivering `.messageReceived`. - self?.emitTransportEvent(.messageReceived(message)) + acknowledgePrivateMedia: { [weak self] messageID, peerID in + guard let self, + let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID), + !self.identityManager.isBlocked( + fingerprint: senderStaticKey.sha256Fingerprint() + ) else { + return + } + self.sendDeliveryAck(for: messageID, to: peerID) + }, + deliverMessage: { [weak self] message, shouldDeliver, completion, finalization in + self?.emitTransportEvent( + .messageReceived(message), + shouldDeliver: shouldDeliver, + completion: completion, + finalization: finalization + ) } ) } @@ -1618,7 +2807,45 @@ final class BLEService: NSObject { } } - private func handleLeave(_: BitchatPacket, from peerID: PeerID) { + /// Accept a leave only when the claimed sender proves possession of the + /// signing key bound by a verified announce. The persisted identity cache + /// keeps delayed/relayed leaves verifiable after the live registry entry + /// has aged out. + private func handleLeave(_ packet: BitchatPacket, from peerID: PeerID) -> Bool { + let registrySigningKey = collectionsQueue.sync { + peerRegistry.info(for: peerID)?.signingPublicKey + } + let verifiedViaRegistry = registrySigningKey.map { + noiseService.verifyPacketSignature(packet, publicKey: $0) + } ?? false + let verifiedViaPersistedIdentity = !verifiedViaRegistry + && identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID).contains { identity in + PeerID(publicKey: identity.publicKey) == peerID + && identity.signingPublicKey.map { + noiseService.verifyPacketSignature(packet, publicKey: $0) + } == true + } + + guard verifiedViaRegistry || verifiedViaPersistedIdentity else { + SecureLogger.warning( + "🚫 Dropping leave with missing/invalid signature for claimed sender \(peerID.id.prefix(8))…", + category: .security + ) + return false + } + + // A valid departure retires transport state too; otherwise + // canDeliverSecurely could remain true for a peer we just removed. + clearNoiseSession(for: peerID) + readLinkState { _ in + let departedLinks = noiseAuthenticatedLinkOwners.compactMap { link, owner in + owner == peerID ? link : nil + } + for link in departedLinks { + noiseAuthenticatedLinkOwners.removeValue(forKey: link) + noiseReconnectPolicy.endLinkEpoch(link) + } + } _ = collectionsQueue.sync(flags: .barrier) { // Remove the peer when they leave peerRegistry.remove(peerID) @@ -1635,8 +2862,23 @@ final class BLEService: NSObject { self.deliverTransportEvent(.peerDisconnected(peerID)) self.deliverTransportEvent(.peerListUpdated(currentPeerIDs)) } + return true } private func sendAnnounce(forceSend: Bool = false) { + guard !isPanicSuspended else { return } + // Announce construction reads the replaceable Noise service and several + // related state snapshots. Serialize the whole operation with identity + // rotation instead of letting CoreBluetooth and maintenance callbacks + // execute it directly on their own queues. + messageQueue.async(flags: .barrier) { [weak self] in + self?.sendAnnounceNow(forceSend: forceSend) + } + } + + private func sendAnnounceNow(forceSend: Bool) { + // Re-check on the serialized queue: a panic suspend may have started + // after this announce was scheduled but before it runs. + guard !isPanicSuspended else { return } // Throttle announces to prevent flooding if !announceThrottle.shouldSend(force: forceSend, now: Date()) { return @@ -1656,8 +2898,9 @@ final class BLEService: NSObject { ) } + let localIdentity = localIdentityState.snapshot() let announcement = AnnouncementPacket( - nickname: myNickname, + nickname: localIdentity.nickname, noisePublicKey: noisePub, signingPublicKey: signingPub, directNeighbors: connectedPeerIDs, @@ -1673,7 +2916,7 @@ final class BLEService: NSObject { // Create packet with signature using the noise private key let packet = BitchatPacket( type: MessageType.announce.rawValue, - senderID: myPeerIDData, + senderID: localIdentity.peerIDData, recipientID: nil, timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: payload, @@ -1687,14 +2930,7 @@ final class BLEService: NSObject { return } - // Call directly if on messageQueue, otherwise dispatch - if DispatchQueue.getSpecific(key: messageQueueKey) != nil { - broadcastPacket(signedPacket) - } else { - messageQueue.async { [weak self] in - self?.broadcastPacket(signedPacket) - } - } + broadcastPacket(signedPacket) // Ensure our own announce is included in sync state gossipSyncManager?.onPublicPacketSeen(signedPacket) @@ -1846,6 +3082,13 @@ extension BLEService: CBCentralManagerDelegate { #if os(iOS) func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) { let restoredPeripherals = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? [] + guard !isPanicSuspended else { + central.stopScan() + restoredPeripherals.forEach { + central.cancelPeripheralConnection($0) + } + return + } let restoredServices = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? [] let restoredOptions = (dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]) ?? [:] let allowDuplicates = restoredOptions[CBCentralManagerScanOptionAllowDuplicatesKey] as? Bool @@ -1901,6 +3144,10 @@ extension BLEService: CBCentralManagerDelegate { switch central.state { case .poweredOn: + guard !isPanicSuspended else { + central.stopScan() + return + } // Links restored as connected have no characteristic in the new // process; without rediscovery they sit connected-but-unusable // until the peer disconnects. Runs here (not willRestoreState) @@ -1916,14 +3163,21 @@ extension BLEService: CBCentralManagerDelegate { startScanning() case .poweredOff: - // Bluetooth was turned off - stop scanning and clean up connection state + // CoreBluetooth has already transitioned out of poweredOn. Do + // not issue stop/cancel commands now; they are rejected as API + // misuse. Retire our link state locally instead. SecureLogger.info("📴 Bluetooth powered off - cleaning up central state", category: .session) - central.stopScan() - // Mark all peripheral connections as disconnected (they are now invalid) let peripheralStates = linkStateStore.peripheralStates let peerIDs: [PeerID] = peripheralStates.compactMap(\.peerID) for state in peripheralStates { - central.cancelPeripheralConnection(state.peripheral) + let peripheralID = state.peripheral.identifier.uuidString + collectionsQueue.sync(flags: .barrier) { + pendingPeripheralWrites.discardAll(for: peripheralID) + } + noiseAuthenticatedLinkOwners.removeValue( + forKey: .peripheral(peripheralID) + ) + noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID)) } _ = linkStateStore.clearPeripherals() // Notify UI of disconnections @@ -1936,7 +3190,6 @@ extension BLEService: CBCentralManagerDelegate { case .unauthorized: // User denied Bluetooth permission SecureLogger.warning("🚫 Bluetooth unauthorized - user denied permission", category: .session) - central.stopScan() _ = linkStateStore.clearPeripherals() case .unsupported: @@ -1957,7 +3210,8 @@ extension BLEService: CBCentralManagerDelegate { } private func startScanning() { - guard let central = centralManager, + guard !isPanicSuspended, + let central = centralManager, central.state == .poweredOn, !central.isScanning else { return } @@ -1978,6 +3232,7 @@ extension BLEService: CBCentralManagerDelegate { } func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) { + guard !isPanicSuspended else { return } let peripheralID = peripheral.identifier.uuidString let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? (peripheralID.prefix(6) + "…") let isConnectable = (advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber)?.boolValue ?? true @@ -2019,6 +3274,10 @@ extension BLEService: CBCentralManagerDelegate { } func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + guard !isPanicSuspended else { + central.cancelPeripheralConnection(peripheral) + return + } let peripheralID = peripheral.identifier.uuidString #if os(iOS) @@ -2078,6 +3337,7 @@ extension BLEService: CBCentralManagerDelegate { pendingPeripheralWrites.discardAll(for: peripheralID) } noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID)) + noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID)) _ = linkStateStore.removePeripheral(peripheralID) // A duplicate link can drop while the peer stays live on another // (the dual-role central link, or a second bound link after a @@ -2132,6 +3392,7 @@ extension BLEService: CBCentralManagerDelegate { pendingPeripheralWrites.discardAll(for: peripheralID) } noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID)) + noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID)) _ = linkStateStore.removePeripheral(peripheralID) SecureLogger.error("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: .session) @@ -2169,7 +3430,9 @@ private extension CBPeripheralState { extension BLEService { private func tryConnectFromQueue() { - guard let central = centralManager, central.state == .poweredOn else { return } + guard !isPanicSuspended, + let central = centralManager, + central.state == .poweredOn else { return } let decision = connectionScheduler.nextCandidate( connectedOrConnectingCount: linkStateStore.connectedOrConnectingPeripheralCount, @@ -2195,6 +3458,7 @@ extension BLEService { using central: CBCentralManager, logPrefix: String ) { + guard !isPanicSuspended else { return } let peripheral = candidate.peripheral let peripheralID = candidate.peripheralID linkStateStore.beginConnecting(to: peripheral, at: Date()) @@ -2236,6 +3500,7 @@ extension BLEService { self.pendingPeripheralWrites.discardAll(for: peripheralID) } self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID)) + self.noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID)) _ = self.linkStateStore.removePeripheral(peripheralID) self.connectionScheduler.recordConnectionTimeout(peripheralID: peripheralID, at: Date()) self.tryConnectFromQueue() @@ -2255,6 +3520,49 @@ private extension BLEService { #if DEBUG // Test-only helper to inject packets into the receive pipeline extension BLEService { + /// Queues an event through the same MainActor hop as production receive + /// handlers so panic-boundary tests can deterministically invalidate it. + func _test_emitTransportEvent(_ event: TransportEvent) { + emitTransportEvent(event) + } + + var _test_isPanicIngressOpen: Bool { + capturePanicLifecycleGeneration() != nil + } + + /// Queries the receipt store of the service's OWN incoming-file store — + /// the instance production lookups run against — so panic tests exercise + /// the real wiring instead of a same-instance shortcut. + func _test_privateMediaReceiptState( + messageID: String + ) -> BLEPrivateMediaReceiptState { + incomingFileStore.privateMediaReceiptState(messageID: messageID) + } + + /// Models a CoreBluetooth delegate callback without requiring a physical + /// peripheral. The callback itself runs on `bleQueue`, exactly where the + /// panic radio-stop barrier must linearize it. + func _test_handlePacketFromBLEQueue( + _ packet: BitchatPacket, + fromPeerID: PeerID + ) { + bleQueue.async { [weak self] in + self?.handleReceivedPacket(packet, from: fromPeerID) + } + } + + func _test_emitTransportEvent( + _ event: TransportEvent, + completion: @escaping () -> Void, + finalization: @escaping (TransportEventDeliveryOutcome) -> Void + ) { + emitTransportEvent( + event, + completion: completion, + finalization: finalization + ) + } + func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true, signingPublicKey: Data? = nil) { if preseedPeer { // Ensure the synthetic peer is known and marked verified for public-message tests @@ -2336,16 +3644,29 @@ extension BLEService { } } - func _test_seedConnectedPeer(_ peerID: PeerID, nickname: String) { + func _test_isNoiseAuthenticatedCentral(_ centralUUID: String, for peerID: PeerID) -> Bool { + bleQueue.sync { + noiseAuthenticatedLinkOwners[.central(centralUUID)] == peerID + } + } + + func _test_seedConnectedPeer( + _ peerID: PeerID, + nickname: String, + capabilities: PeerCapabilities? = nil, + noisePublicKey: Data? = nil + ) { collectionsQueue.sync(flags: .barrier) { peerRegistry.upsert(BLEPeerInfo( peerID: peerID, nickname: nickname, isConnected: true, - noisePublicKey: nil, + noisePublicKey: noisePublicKey, signingPublicKey: nil, isVerifiedNickname: true, - lastSeen: Date() + lastSeen: Date(), + capabilities: capabilities ?? [], + capabilitiesWereExplicitlyAdvertised: capabilities != nil )) } } @@ -2360,6 +3681,172 @@ extension BLEService { try noiseService.processHandshakeMessage(from: peerID, message: message) } + func _test_enqueuePendingPrivateMessage( + content: String, + messageID: String, + for peerID: PeerID + ) { + collectionsQueue.sync(flags: .barrier) { + pendingNoiseSessionQueues.appendPrivateMessage( + content: content, + messageID: messageID, + for: peerID + ) + } + } + + func _test_enqueuePendingNoisePayload( + _ payload: Data, + transferId: String, + for peerID: PeerID + ) { + guard privateMediaTransferAdmissions.begin(transferId) == .admitted else { return } + collectionsQueue.sync(flags: .barrier) { + pendingNoiseSessionQueues.appendTypedPayload( + payload, + transferId: transferId, + for: peerID + ) + } + } + + func _test_sendPendingNoisePayloadsAfterHandshake(for peerID: PeerID) { + sendPendingNoisePayloadsAfterHandshake(for: peerID) + } + + func _test_hasPendingPrivateMediaPolicyResolution(for peerID: PeerID) -> Bool { + collectionsQueue.sync { + pendingPrivateMediaPolicyResolutions[peerID.toShort()] != nil + } + } + + func _test_forcePrivateMediaProofTimeout(for peerID: PeerID) { + let normalizedPeerID = peerID.toShort() + let target = collectionsQueue.sync { + () -> (fingerprint: String, generation: UUID?, nonce: UUID)? in + if let watchdog = privateMediaProofWatchdogs[normalizedPeerID] { + return ( + watchdog.fingerprint, + watchdog.sessionGeneration, + watchdog.timeoutNonce + ) + } + if let pending = pendingPrivateMediaPolicyResolutions[normalizedPeerID] { + return ( + pending.fingerprint, + pending.sessionGeneration, + pending.timeoutNonce + ) + } + return nil + } + guard let target else { return } + handlePrivateMediaProofTimeout( + for: normalizedPeerID, + fingerprint: target.fingerprint, + sessionGeneration: target.generation, + nonce: target.nonce + ) + } + + func _test_privateMediaTransferState( + transferId: String + ) -> (admissionActive: Bool, pendingNoise: Bool, activeScheduler: Int, pendingScheduler: Int) { + let scheduler = collectionsQueue.sync { + ( + pendingNoiseSessionQueues.containsTypedPayload(transferId: transferId), + outboundFragmentTransfers.activeCount, + outboundFragmentTransfers.pendingCount + ) + } + return ( + privateMediaTransferAdmissions.isActive(transferId), + scheduler.0, + scheduler.1, + scheduler.2 + ) + } + + func _test_privateMediaAdmissionEntryCount() -> Int { + privateMediaTransferAdmissions.count + } + + @discardableResult + func _test_beginPrivateMediaAdmission(_ transferId: String, now: Date) -> Bool { + privateMediaTransferAdmissions.begin(transferId, now: now) == .admitted + } + + func _test_isPrivateMediaAdmissionActive(_ transferId: String, now: Date) -> Bool { + privateMediaTransferAdmissions.isActive(transferId, now: now) + } + + func _test_finishPrivateMediaAdmission(_ transferId: String) { + privateMediaTransferAdmissions.finish(transferId) + } + + func _test_drainPrivateMediaSendPipeline() async { + let collectionsQueue = self.collectionsQueue + await withCheckedContinuation { continuation in + messageQueue.async { + collectionsQueue.async(flags: .barrier) { + continuation.resume() + } + } + } + } + + func _test_broadcastPrivateMediaPacket( + _ packet: BitchatPacket, + transferId: String + ) { + broadcastPacket( + packet, + transferId: transferId, + requiresPrivateMediaAdmission: true + ) + } + + func _test_drainNoiseMessagePipeline() async { + let collectionsQueue = self.collectionsQueue + await withCheckedContinuation { continuation in + messageQueue.async(flags: .barrier) { + collectionsQueue.async(flags: .barrier) { + continuation.resume() + } + } + } + } + + /// Replays the current generation's ready callback. Restore tests use + /// this to prove same-generation reconciliation is idempotent. + func _test_reconcileCurrentNoiseSession(for peerID: PeerID) { + let normalizedPeerID = peerID.toShort() + messageQueue.async(flags: .barrier) { [weak self] in + guard let self, + let generation = self.noiseService.sessionGeneration( + for: normalizedPeerID + ), + let fingerprint = self.noiseService.getPeerFingerprint( + normalizedPeerID + ) else { + return + } + self.handleNoisePeerAuthenticated( + peerID: normalizedPeerID, + fingerprint: fingerprint, + sessionGeneration: generation + ) + } + } + + /// Builds an authenticated-session packet from an exact typed plaintext. + /// Compatibility tests use this to model Android's deployed 0x20 file + /// payload and the short-lived 0x09 prerelease payload without exposing a + /// production API that can emit the old value. + func _test_makeEncryptedNoisePacket(_ typedPayload: Data, to peerID: PeerID) throws -> BitchatPacket { + try makeEncryptedNoisePacket(typedPayload, to: peerID) + } + static func _test_shouldRediscoverBitChatService( invalidatedServiceUUIDs: [CBUUID], cachedServiceUUIDs: [CBUUID]? @@ -2376,6 +3863,7 @@ extension BLEService { extension BLEService: CBPeripheralDelegate { func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + guard !isPanicSuspended else { return } if let error = error { SecureLogger.error("❌ Error discovering services for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session) // Retry service discovery after a delay @@ -2402,6 +3890,7 @@ extension BLEService: CBPeripheralDelegate { } func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + guard !isPanicSuspended else { return } if let error = error { SecureLogger.error("❌ Error discovering characteristics for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session) return @@ -2449,6 +3938,7 @@ extension BLEService: CBPeripheralDelegate { } func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + guard !isPanicSuspended else { return } if let error = error { SecureLogger.error("❌ Error receiving notification: \(error.localizedDescription)", category: .session) return @@ -2566,6 +4056,7 @@ extension BLEService: CBPeripheralDelegate { } func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { + guard !isPanicSuspended else { return } // Resume queued writes for this peripheral - called when canSendWriteWithoutResponse becomes true again if logRateLimiter.shouldLog(key: "peripheral-ready:\(peripheral.identifier.uuidString)") { SecureLogger.debug("📤 Peripheral \(peripheral.name ?? peripheral.identifier.uuidString.prefix(8).description) ready for more writes", category: .session) @@ -2574,6 +4065,7 @@ extension BLEService: CBPeripheralDelegate { } func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) { + guard !isPanicSuspended else { return } SecureLogger.warning("⚠️ Services modified for \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session) let shouldRediscover = BLEService.shouldRediscoverBitChatService( @@ -2594,6 +4086,7 @@ extension BLEService: CBPeripheralDelegate { } func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { + guard !isPanicSuspended else { return } if let error = error { SecureLogger.error("❌ Error updating notification state: \(error.localizedDescription)", category: .session) } else { @@ -2617,6 +4110,12 @@ extension BLEService: CBPeripheralManagerDelegate { switch peripheral.state { case .poweredOn: + guard !isPanicSuspended else { + peripheral.stopAdvertising() + peripheral.removeAllServices() + characteristic = nil + return + } // Remove all services first to ensure clean state peripheral.removeAllServices() @@ -2639,8 +4138,19 @@ extension BLEService: CBPeripheralManagerDelegate { case .poweredOff: // Bluetooth was turned off - clean up peripheral state SecureLogger.info("📴 Bluetooth powered off - cleaning up peripheral state", category: .session) - peripheral.stopAdvertising() // Clear subscribed centrals (they are now invalid) + let centralSnapshot = linkStateStore.subscribedCentralSnapshot + for central in centralSnapshot.centrals { + let centralID = central.identifier.uuidString + noiseAuthenticatedLinkOwners.removeValue( + forKey: .central(centralID) + ) + noiseReconnectPolicy.endLinkEpoch(.central(centralID)) + } + collectionsQueue.sync(flags: .barrier) { + pendingNotifications.removeAll() + pendingWriteBuffers.removeAll() + } let centralPeerIDs = linkStateStore.clearCentrals() subscriptionAnnounceLimiter.removeAll() characteristic = nil @@ -2654,7 +4164,6 @@ extension BLEService: CBPeripheralManagerDelegate { case .unauthorized: // User denied Bluetooth permission SecureLogger.warning("🚫 Bluetooth unauthorized for peripheral role", category: .session) - peripheral.stopAdvertising() _ = linkStateStore.clearCentrals() subscriptionAnnounceLimiter.removeAll() characteristic = nil @@ -2677,6 +4186,12 @@ extension BLEService: CBPeripheralManagerDelegate { #if os(iOS) func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String: Any]) { + guard !isPanicSuspended else { + peripheral.stopAdvertising() + peripheral.removeAllServices() + characteristic = nil + return + } let restoredServices = (dict[CBPeripheralManagerRestoredStateServicesKey] as? [CBMutableService]) ?? [] let restoredAdvertisement = (dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any]) ?? [:] @@ -2703,6 +4218,10 @@ extension BLEService: CBPeripheralManagerDelegate { #endif func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) { + guard !isPanicSuspended else { + peripheral.stopAdvertising() + return + } if let error = error { SecureLogger.error("❌ Failed to add service: \(error.localizedDescription)", category: .session) return @@ -2718,6 +4237,7 @@ extension BLEService: CBPeripheralManagerDelegate { } func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) { + guard !isPanicSuspended else { return } let centralUUID = central.identifier.uuidString SecureLogger.debug("📥 Central subscribed: \(centralUUID.prefix(8))…", category: .session) linkStateStore.addSubscribedCentral(central) @@ -2756,10 +4276,11 @@ extension BLEService: CBPeripheralManagerDelegate { pendingNotifications.removeTarget { $0.identifier.uuidString == centralID } } noiseAuthenticatedLinkOwners.removeValue(forKey: .central(centralID)) + noiseReconnectPolicy.endLinkEpoch(.central(centralID)) let removedPeerID = linkStateStore.removeSubscribedCentral(central) // Ensure we're still advertising for other devices to find us - if peripheral.isAdvertising == false { + if !isPanicSuspended, peripheral.isAdvertising == false { SecureLogger.debug("📡 Restarting advertising after central unsubscribed", category: .session) peripheral.startAdvertising(buildAdvertisementData()) } @@ -2796,6 +4317,7 @@ extension BLEService: CBPeripheralManagerDelegate { } func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) { + guard !isPanicSuspended else { return } drainPendingNotifications(logPrefix: "✅ Sent") } @@ -2856,6 +4378,7 @@ extension BLEService: CBPeripheralManagerDelegate { for request in requests { peripheral.respond(to: request, withResult: .success) } + guard !isPanicSuspended else { return } // Process writes. For long writes, CoreBluetooth may deliver multiple CBATTRequest values with offsets. // Combine per-central request values by offset before decoding. @@ -2959,30 +4482,197 @@ extension BLEService { // No alias rotation or advertising restarts required. } +// MARK: - Private Media Deletion + +extension BLEService: PrivateMediaDeletionPersisting { + @MainActor + func persistDeletedPrivateMedia( + messageIDs: [String], + payloadRelativePaths: [String: String], + protectedPayloadRelativePaths: Set, + completion: @escaping @MainActor (Bool) -> Void + ) { + let fileStore = incomingFileStore + messageQueue.async(flags: .barrier) { + guard let reservation = fileStore + .reservePrivateMediaDeletion( + messageIDs: messageIDs, + payloadRelativePaths: payloadRelativePaths + ) else { + Task { @MainActor in + completion(false) + } + return + } + let persisted = fileStore + .commitPrivateMediaDeletion( + reservation: reservation, + messageIDs: messageIDs, + payloadRelativePaths: payloadRelativePaths, + protectedPayloadRelativePaths: + protectedPayloadRelativePaths + ) + Task { @MainActor in + completion(persisted) + } + } + } + + @MainActor + func removeLegacyPrivateMediaPayload(relativePath: String) { + let fileStore = incomingFileStore + messageQueue.async(flags: .barrier) { + fileStore.removeLegacyIncomingFile(relativePath: relativePath) + } + } +} + // MARK: - Private Helpers +enum TransportEventDeliveryOutcome: Equatable { + /// A synchronous sink inserted the message and revalidation succeeded. + case accepted + /// A supported plain delegate was invoked, but insertion cannot be + /// confirmed synchronously. + case invokedUnconfirmed + /// No sink accepted the event, or receipt revalidation rejected it. + case rejected +} + +enum TransportEventDeliveryGate { + /// Runs finalization exactly once for every attempted main-actor delivery, + /// including pre-insertion rejection, a missing/rejecting sink, and + /// post-insertion revalidation failure. Only a fully accepted delivery + /// runs `completion` (for example, a stable-media ACK). + @MainActor + static func attempt( + shouldDeliver: () -> Bool, + deliver: () -> TransportEventDeliveryOutcome, + completion: () -> Void, + finalization: (TransportEventDeliveryOutcome) -> Void + ) { + var outcome = TransportEventDeliveryOutcome.rejected + defer { finalization(outcome) } + guard shouldDeliver() else { + return + } + switch deliver() { + case .rejected: + return + case .invokedUnconfirmed: + outcome = .invokedUnconfirmed + return + case .accepted: + break + } + guard shouldDeliver() else { return } + outcome = .accepted + completion() + } +} + extension BLEService { /// Notify UI on the MainActor to satisfy Swift concurrency isolation private func notifyUI(_ block: @escaping @MainActor () -> Void) { - // Always hop onto the MainActor so calls to @MainActor delegates are safe - Task { @MainActor in + // Capture the panic lifecycle before queueing the MainActor hop. A + // receive callback can enqueue UI delivery immediately before panic + // clears application state; rechecking here prevents that stale work + // from repopulating the wiped conversation store afterward. + guard let generation = capturePanicLifecycleGeneration() else { + return + } + Task { @MainActor [weak self] in + guard let self, + self.isCurrentPanicLifecycleGeneration(generation) else { + return + } block() } } - private func emitTransportEvent(_ event: TransportEvent) { - notifyUI { [weak self] in - self?.deliverTransportEvent(event) + private func emitTransportEvent( + _ event: TransportEvent, + shouldDeliver: (() -> Bool)? = nil, + completion: (() -> Void)? = nil, + finalization: ((TransportEventDeliveryOutcome) -> Void)? = nil + ) { + guard let generation = capturePanicLifecycleGeneration() else { + Task { @MainActor in + finalization?(.rejected) + } + return + } + Task { @MainActor [weak self] in + guard let self, + self.isCurrentPanicLifecycleGeneration(generation) else { + finalization?(.rejected) + return + } + TransportEventDeliveryGate.attempt( + shouldDeliver: { + self.isCurrentPanicLifecycleGeneration(generation) + && (shouldDeliver?() ?? true) + }, + deliver: { + return self.deliverTransportEvent(event) + }, + completion: { completion?() }, + finalization: { finalization?($0) } + ) } } + /// Delivers a transport event to the installed delegates and reports + /// whether acceptance was confirmed. + /// + /// For `.messageReceived`, returns `true` only when a + /// `SynchronousMessageTransportEventDelegate` synchronously confirmed + /// acceptance of the message (duplicates count as accepted). Returns + /// `false` when acceptance cannot be confirmed: the sink blocked the + /// message, the content was empty, or only a non-synchronous delegate is + /// installed so delivery happens without confirmation. Downstream logic + /// MUST NOT treat `false` as safe to acknowledge — a `false` return + /// means do not ACK. + /// + /// For all other events, returns `true` when any delegate received the + /// event and `false` when no delegate is installed. @MainActor - private func deliverTransportEvent(_ event: TransportEvent) { + @discardableResult + private func deliverTransportEvent( + _ event: TransportEvent + ) -> TransportEventDeliveryOutcome { + if case .messageReceived(let message) = event { + if let synchronousDelegate = + eventDelegate as? SynchronousMessageTransportEventDelegate { + return synchronousDelegate + .didReceiveTransportMessageSynchronously(message) + ? .accepted + : .rejected + } + if let eventDelegate { + eventDelegate.didReceiveTransportEvent(event) + return .invokedUnconfirmed + } + if let synchronousDelegate = + delegate as? SynchronousMessageTransportEventDelegate { + return synchronousDelegate + .didReceiveTransportMessageSynchronously(message) + ? .accepted + : .rejected + } + } + if let eventDelegate { eventDelegate.didReceiveTransportEvent(event) + return .accepted } else { - delegate?.receiveTransportEvent(event) + guard let delegate else { return .rejected } + delegate.receiveTransportEvent(event) + if case .messageReceived = event { + return .invokedUnconfirmed + } + return .accepted } } @@ -3131,14 +4821,24 @@ extension BLEService { /// The completion fires exactly once on the main actor: with RTT/hops /// when the matching pong returns, or nil after the timeout window. func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) { + guard let generation = capturePanicLifecycleGeneration() else { + return + } messageQueue.async { [weak self] in guard let self, + self.isCurrentPanicLifecycleGeneration(generation), let recipientData = peerID.toShort().routingData, let payload = MeshPingPayload( nonce: Data((0.. ( + watchdog: (fingerprint: String, nonce: UUID)?, + rejected: [@MainActor (PrivateMediaSendPolicy) -> Void] + ) in + guard privateMediaSessionGenerations[normalizedPeerID] != generation else { + return (nil, []) + } + let watchdogNonce = UUID() + privateMediaSessionGenerations[normalizedPeerID] = generation + authenticatedPeerStates.removeValue(forKey: normalizedPeerID) + privateMediaProofTimeoutMarkers.removeValue(forKey: normalizedPeerID) + privateMediaProofWatchdogs[normalizedPeerID] = BLEPrivateMediaProofWatchdog( + fingerprint: fingerprint, + sessionGeneration: generation, + timeoutNonce: watchdogNonce + ) + authenticatedPeerStateSendProgress[normalizedPeerID] = + BLEAuthenticatedPeerStateSendProgress(sessionGeneration: generation) + + guard var pending = pendingPrivateMediaPolicyResolutions[normalizedPeerID] else { + return ((fingerprint, watchdogNonce), []) + } + guard pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame else { + pendingPrivateMediaPolicyResolutions.removeValue(forKey: normalizedPeerID) + return ((fingerprint, watchdogNonce), Array(pending.completions.values)) + } + pending.sessionGeneration = generation + pending.timeoutNonce = watchdogNonce + pendingPrivateMediaPolicyResolutions[normalizedPeerID] = pending + return ((pending.fingerprint, watchdogNonce), []) + } + } + ) else { return } + + guard let watchdog = transition.watchdog else { + // A quarantined transport restored the same cryptographic + // generation. Its capability proof and announce state never + // became stale; only work queued while outbound keys were paused + // needs one idempotent ready transition. Retrying the bounded + // early-ciphertext queue is receive-side and therefore always + // safe under the restored keys. + noisePacketHandler.handleSessionAuthenticated(normalizedPeerID) + #if DEBUG + _test_onPrivateMediaSessionReconciled?(normalizedPeerID) + #endif + if deferOutboundUntilConvergence { + // Timeout-restore: the counterpart may have completed the + // replacement handshake and discarded these keys, so + // encrypting the parked queues here would lose them silently. + // The restore's mandatory convergence retry — or any later + // handshake the reconnect policy initiates — re-enters this + // transition with a fresh generation and drains them under + // keys both sides hold. + return + } + sendPendingMessagesAfterHandshake(for: normalizedPeerID) + sendPendingNoisePayloadsAfterHandshake(for: normalizedPeerID) + return + } + + completePrivateMediaPolicyResolution(transition.rejected, with: .blockedDowngrade) + schedulePrivateMediaProofTimeout( + for: normalizedPeerID, + fingerprint: watchdog.fingerprint, + sessionGeneration: generation, + nonce: watchdog.nonce + ) + // Cross-link delivery can put ciphertext sent immediately after + // message 3 ahead of message 3 itself. Retry the bounded queue only + // after this generation's transport state has been fully installed. + noisePacketHandler.handleSessionAuthenticated(normalizedPeerID) + + if deferOutboundUntilConvergence { + // Timeout-restore: the session is back for receive purposes and + // the generation-bound protocol state above is rebuilt, but the + // counterpart may already hold replacement keys that discarded + // this generation's. Encrypting the pending queues here would + // lose them silently, so leave them parked: the restore's + // mandatory convergence retry — or any later handshake the + // reconnect policy initiates — re-enters this transition with a + // fresh generation and drains them under keys both sides hold. + #if DEBUG + _test_onPrivateMediaSessionReconciled?(normalizedPeerID) + #endif + return + } + + // `onPeerAuthenticated` can fire while the initiator is returning XX + // message 3. This callback is queued behind the handshake handler, so + // message 3 is broadcast first. Both peers also send one idempotent + // echo after receiving the other's state to recover cross-link races. + sendAuthenticatedPeerState(to: normalizedPeerID, echo: false) + #if DEBUG + _test_onPrivateMediaSessionReconciled?(normalizedPeerID) + #endif + sendPendingMessagesAfterHandshake(for: normalizedPeerID) + sendPendingNoisePayloadsAfterHandshake(for: normalizedPeerID) + sendAnnounce(forceSend: true) + } + + private func sendAuthenticatedPeerState(to peerID: PeerID, echo: Bool) { + let normalizedPeerID = peerID.toShort() + let shouldSend = collectionsQueue.sync(flags: .barrier) { + guard let generation = privateMediaSessionGenerations[normalizedPeerID], + var progress = authenticatedPeerStateSendProgress[normalizedPeerID], + progress.sessionGeneration == generation else { return false } + if echo { + guard !progress.sentEcho else { return false } + progress.sentEcho = true + } else { + guard !progress.sentInitial else { return false } + progress.sentInitial = true + } + authenticatedPeerStateSendProgress[normalizedPeerID] = progress + return true + } + guard shouldSend else { return } + + let capabilities = collectionsQueue.sync { + PeerCapabilities.localSupported.union(runtimeCapabilities) + } + let state = AuthenticatedPeerStatePacket( + capabilities: capabilities, + signingPublicKey: noiseService.getSigningPublicKeyData() + ) + guard let payload = BLENoisePayloadFactory.authenticatedPeerState(state) else { + SecureLogger.error("Failed to encode authenticated peer state", category: .security) + return + } + sendNoisePayload(payload, to: normalizedPeerID) + } + + private func handleAuthenticatedPeerState( + _ payload: Data, + from peerID: PeerID, + sessionGeneration generation: UUID + ) { + let normalizedPeerID = peerID.toShort() + guard let state = AuthenticatedPeerStatePacket.decode(from: payload) else { + SecureLogger.warning( + "Ignoring malformed authenticated peer state from \(normalizedPeerID.id.prefix(8))…", + category: .security + ) + return + } + guard let fingerprint = noiseService.getPeerFingerprint(normalizedPeerID), + let publicKey = noiseService.getPeerPublicKeyData(normalizedPeerID), + publicKey.sha256Fingerprint().caseInsensitiveCompare(fingerprint) == .orderedSame else { + SecureLogger.warning( + "Ignoring peer state without a matching authenticated Noise identity", + category: .security + ) + return + } + guard let application = noiseService.withCurrentSessionGeneration( + for: normalizedPeerID, + expected: generation, + { + () -> (accepted: Bool, completions: [@MainActor (PrivateMediaSendPolicy) -> Void]) in + guard collectionsQueue.sync(execute: { + privateMediaSessionGenerations[normalizedPeerID] == generation + }) else { + return (false, []) + } + + // The generation lease prevents rekey/session promotion from + // interleaving between validation and these durable mutations. + identityManager.bindAuthenticatedSigningPublicKey( + state.signingPublicKey, + fingerprint: fingerprint + ) + identityManager.upsertCryptographicIdentity( + fingerprint: fingerprint, + noisePublicKey: publicKey, + signingPublicKey: state.signingPublicKey, + claimedNickname: nil + ) + if state.capabilities.contains(.privateMedia) { + identityManager.markPrivateMediaCapable(fingerprint: fingerprint) + } + + let completions = collectionsQueue.sync(flags: .barrier) { + () -> [@MainActor (PrivateMediaSendPolicy) -> Void] in + guard privateMediaSessionGenerations[normalizedPeerID] == generation else { + return [] + } + peerRegistry.bindAuthenticatedSigningPublicKey( + state.signingPublicKey, + for: normalizedPeerID + ) + authenticatedPeerStates[normalizedPeerID] = BLEAuthenticatedPeerStateObservation( + fingerprint: fingerprint, + sessionGeneration: generation, + capabilities: state.capabilities + ) + privateMediaProofTimeoutMarkers.removeValue(forKey: normalizedPeerID) + privateMediaProofWatchdogs.removeValue(forKey: normalizedPeerID) + guard let pending = pendingPrivateMediaPolicyResolutions.removeValue( + forKey: normalizedPeerID + ), pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame, + pending.sessionGeneration == generation else { + return [] + } + return Array(pending.completions.values) + } + return (true, completions) + } + ), application.accepted else { return } + + // One bounded echo makes initiator/responder proof ordering converge + // even when message 3 and the first proof take different mesh links. + sendAuthenticatedPeerState(to: normalizedPeerID, echo: true) + let policy = privateMediaSendPolicy(to: normalizedPeerID) + sendPendingNoisePayloadsAfterHandshake(for: normalizedPeerID) + completePrivateMediaPolicyResolution(application.completions, with: policy) + } + + private func noteNoiseSessionCleared(for peerID: PeerID) { + let normalizedPeerID = peerID.toShort() + let reset = collectionsQueue.sync(flags: .barrier) { + () -> (fingerprint: String, nonce: UUID)? in + privateMediaSessionGenerations.removeValue(forKey: normalizedPeerID) + authenticatedPeerStates.removeValue(forKey: normalizedPeerID) + privateMediaProofTimeoutMarkers.removeValue(forKey: normalizedPeerID) + privateMediaProofWatchdogs.removeValue(forKey: normalizedPeerID) + authenticatedPeerStateSendProgress.removeValue(forKey: normalizedPeerID) + guard var pending = pendingPrivateMediaPolicyResolutions[normalizedPeerID] else { + return nil + } + let nonce = UUID() + pending.sessionGeneration = nil + pending.timeoutNonce = nonce + pendingPrivateMediaPolicyResolutions[normalizedPeerID] = pending + return (pending.fingerprint, nonce) + } + if let reset { + schedulePrivateMediaProofTimeout( + for: normalizedPeerID, + fingerprint: reset.fingerprint, + sessionGeneration: nil, + nonce: reset.nonce + ) + } + } + + private func clearNoiseSession(for peerID: PeerID) { + noiseService.clearSession(for: peerID) + noteNoiseSessionCleared(for: peerID) } /// Swaps `myPeerID`/`myPeerIDData` to match the current Noise identity. @@ -3358,8 +5474,9 @@ extension BLEService { private func refreshPeerIdentity() { let swap = { let fingerprint = self.noiseService.getIdentityFingerprint() - self.myPeerID = PeerID(str: fingerprint.prefix(16)) - self.myPeerIDData = Data(hexString: self.myPeerID.id) ?? Data() + self.localIdentityState.replacePeerIdentity( + with: PeerID(str: fingerprint.prefix(16)) + ) self.meshTopology.reset() } if DispatchQueue.getSpecific(key: messageQueueKey) != nil { @@ -3381,8 +5498,9 @@ extension BLEService { } return } - guard noiseService.hasSession(with: peerID) else { - // No session yet - queue the payload SYNCHRONOUSLY before initiating handshake + guard noiseService.hasEstablishedSession(with: peerID) else { + // No established session yet - queue the payload synchronously + // before initiating a handshake // to prevent race where fast handshake completion drains empty queue collectionsQueue.sync(flags: .barrier) { self.pendingNoiseSessionQueues.appendTypedPayload(typedPayload, for: peerID) @@ -3398,8 +5516,40 @@ extension BLEService { } } - private func makeEncryptedNoisePacket(_ typedPayload: Data, to peerID: PeerID) throws -> BitchatPacket { - let encrypted = try noiseService.encrypt(typedPayload, for: peerID) + private func makeEncryptedNoisePacket( + _ typedPayload: Data, + to peerID: PeerID, + requiresAuthenticatedPrivateMediaReceipts: Bool = false + ) throws -> BitchatPacket { + let encrypted: Data + let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: typedPayload.first) + if isPrivateFile { + let provenGeneration: UUID? = collectionsQueue.sync { + () -> UUID? in + guard let generation = privateMediaSessionGenerations[peerID], + let authenticated = authenticatedPeerStates[peerID], + authenticated.sessionGeneration == generation, + authenticated.capabilities.contains(.privateMedia) else { return nil } + if requiresAuthenticatedPrivateMediaReceipts { + guard authenticated.capabilities.contains( + .privateMediaReceipts + ) else { + return nil + } + } + return generation + } + guard let provenGeneration else { + throw NoiseEncryptionError.sessionNotEstablished + } + encrypted = try noiseService.encryptPrivateFilePayload( + typedPayload, + for: peerID, + sessionGeneration: provenGeneration + ) + } else { + encrypted = try noiseService.encrypt(typedPayload, for: peerID) + } return BitchatPacket( type: MessageType.noiseEncrypted.rawValue, senderID: myPeerIDData, @@ -3407,7 +5557,9 @@ extension BLEService { timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: encrypted, signature: nil, - ttl: messageTTL + ttl: messageTTL, + // v1 has a 16-bit payload length; finalized media can exceed it. + version: isPrivateFile ? 2 : 1 ) } @@ -3713,7 +5865,7 @@ extension BLEService { let store = courierStore let policy = courierDepositPolicy let metrics = sfMetrics - Task { @MainActor in + notifyUI { guard let tier = policy(depositorKey, isVerifiedPeer) else { SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (neither favorite nor verified)", category: .session) return @@ -3793,7 +5945,7 @@ extension BLEService { } } let policy = courierDepositPolicy - Task { @MainActor in + notifyUI { // Same trust gate as deposits: don't hand mail to a peer who // would reject it from us. guard policy(noiseKey, isVerifiedPeer) != nil else { return } @@ -4138,6 +6290,7 @@ extension BLEService { let uuid = peripheral.identifier.uuidString bleQueue.async { [weak self] in guard let self = self else { return } + guard !self.isPanicSuspended else { return } guard let state = self.linkStateStore.state(forPeripheralID: uuid), let ch = state.characteristic else { return } // Atomically take all pending items from the queue to avoid race conditions @@ -4228,7 +6381,10 @@ extension BLEService { slotReserve: Int = TransportConfig.bleBackgroundPendingConnectSlotReserve ) { bleQueue.async { [weak self] in - guard let self, let central = self.centralManager, central.state == .poweredOn else { return } + guard let self, + !self.isPanicSuspended, + let central = self.centralManager, + central.state == .poweredOn else { return } let budget = TransportConfig.bleMaxCentralLinks - slotReserve - self.linkStateStore.connectedOrConnectingPeripheralCount @@ -4285,6 +6441,7 @@ extension BLEService { self.pendingPeripheralWrites.discardAll(for: peripheralID) } self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID)) + self.noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID)) _ = self.linkStateStore.removePeripheral(peripheralID) cancelled += 1 } @@ -4350,27 +6507,80 @@ extension BLEService { } private func initiateNoiseHandshake(with peerID: PeerID) { - // Use NoiseEncryptionService for handshake - guard !noiseService.hasSession(with: peerID) else { return } - + let service = noiseService do { - let handshakeData = try noiseService.initiateHandshake(with: peerID) - - // Send handshake init - let packet = BitchatPacket( - type: MessageType.noiseHandshake.rawValue, - senderID: myPeerIDData, - recipientID: Data(hexString: peerID.id), - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: handshakeData, - signature: nil, - ttl: messageTTL - ) - broadcastPacket(packet) + guard let initiation = try service.initiateHandshakeIfNeeded( + with: peerID, + retryOnTimeout: true + ) else { + return + } + messageQueue.async(flags: .barrier) { + [weak self, weak service] in + guard let self, + let service, + self.noiseService === service, + let handshakeData = service.claimHandshakeInitiation( + initiation, + for: peerID + ) else { + return + } + self.broadcastNoiseHandshake(handshakeData, to: peerID) + } } catch { SecureLogger.error("Failed to initiate handshake: \(error)") } } + + private func broadcastNoiseHandshake(_ handshakeData: Data, to peerID: PeerID) { + let packet = BitchatPacket( + type: MessageType.noiseHandshake.rawValue, + senderID: myPeerIDData, + recipientID: Data(hexString: peerID.id), + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: handshakeData, + signature: nil, + ttl: messageTTL + ) + broadcastPacket(packet) + } + + /// Starts a wire-compatible ordinary XX reconnect. The manager prepares + /// the initiator before atomically retiring the cached transport; the + /// one-shot claim prevents a crossed inbound message from making a stale + /// message 1 leave after this peer has already become responder. + private func initiateNoiseReconnectHandshake(with peerID: PeerID) { + let service = noiseService + do { + let initiation = try service.initiateReconnectHandshake( + with: peerID, + retryOnTimeout: true + ) + messageQueue.async(flags: .barrier) { [weak self, weak service] in + guard let self, + let service, + self.noiseService === service else { + return + } + self.noteNoiseSessionCleared(for: peerID) + guard let handshakeData = service.claimHandshakeInitiation( + initiation, + for: peerID + ) else { + return + } + self.broadcastNoiseHandshake(handshakeData, to: peerID) + } + } catch NoiseSessionError.notEstablished { + initiateNoiseHandshake(with: peerID) + } catch { + SecureLogger.error( + "Failed to initiate ordinary reconnect: \(error)", + category: .session + ) + } + } private func sendPendingMessagesAfterHandshake(for peerID: PeerID) { // Atomically take all pending messages to process (prevents concurrent modification) @@ -4436,7 +6646,8 @@ extension BLEService { directedOnlyPeer: PeerID? = nil, transferId: String? = nil, requireDirectPeerLink: Bool = false, - requireNoiseAuthenticatedPeerLink: Bool = false + requireNoiseAuthenticatedPeerLink: Bool = false, + requiresPrivateMediaAdmission: Bool = false ) -> Bool { let request = BLEOutboundFragmentTransferRequest( packet: packet, @@ -4448,8 +6659,34 @@ extension BLEService { requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink ) - let result = collectionsQueue.sync(flags: .barrier) { - outboundFragmentTransfers.submit(request, maxConcurrentTransfers: TransportConfig.bleMaxConcurrentTransfers) + let result: BLEOutboundFragmentTransferScheduler.SubmitResult? = collectionsQueue.sync(flags: .barrier) { + if requiresPrivateMediaAdmission { + guard let transferId else { return nil } + // This lock is taken while the scheduler is already protected + // by collectionsQueue. Cancellation takes the admission lock + // synchronously but never waits on collectionsQueue, avoiding + // lock inversion while giving submit/cancel one linear order. + return privateMediaTransferAdmissions.withActive(transferId) { + outboundFragmentTransfers.submit( + request, + maxConcurrentTransfers: TransportConfig.bleMaxConcurrentTransfers + ) + } + } + return outboundFragmentTransfers.submit( + request, + maxConcurrentTransfers: TransportConfig.bleMaxConcurrentTransfers + ) + } + guard let result else { + if let transferId, requiresPrivateMediaAdmission { + privateMediaTransferAdmissions.finish(transferId) + } + return false + } + if let transferId, requiresPrivateMediaAdmission { + // The scheduler now owns normal cancellation (active or pending). + privateMediaTransferAdmissions.finish(transferId) } return handleFragmentTransferSubmitResult(result) } @@ -4525,14 +6762,24 @@ extension BLEService { } } - let transferIdentifier: String? = { - guard let id = reservedTransferId else { return nil } - collectionsQueue.sync(flags: .barrier) { - _ = self.outboundFragmentTransfers.activateReservedTransfer(id: id, totalFragments: plan.totalFragments, workItems: []) + let transferIdentifier: String? + if let id = reservedTransferId { + let activated = collectionsQueue.sync(flags: .barrier) { + self.outboundFragmentTransfers.activateReservedTransfer( + id: id, + totalFragments: plan.totalFragments, + workItems: [] + ) } + // Cancellation may remove the reservation between submit and plan + // construction. Treat that as cancellation, not as permission to + // schedule an untracked fragment train. + guard activated else { return false } TransferProgressManager.shared.start(id: id, totalFragments: plan.totalFragments) - return id - }() + transferIdentifier = id + } else { + transferIdentifier = nil + } let sendFragment: (BitchatPacket) -> Bool = { [weak self] fragmentPacket in guard let self else { return false } @@ -4679,14 +6926,59 @@ extension BLEService { // MARK: Packet Reception private func handleReceivedPacket(_ packet: BitchatPacket, from peerID: PeerID) { - // Call directly if already on messageQueue, otherwise dispatch + let isNoisePacket = packet.type == MessageType.noiseHandshake.rawValue + || packet.type == MessageType.noiseEncrypted.rawValue + + // Capture the panic lifecycle at the first off-messageQueue handoff. + // Noise packets still enter through a barrier so handshake promotion, + // quarantine, and encrypted delivery share one ordered session. if DispatchQueue.getSpecific(key: messageQueueKey) == nil { - messageQueue.async { [weak self] in - self?.handleReceivedPacket(packet, from: peerID) + guard let lifecycleGeneration = + capturePanicLifecycleGeneration() else { + return + } + #if DEBUG + _test_beforeReceivePacketHandoff?() + #endif + let flags: DispatchWorkItemFlags = isNoisePacket ? .barrier : [] + messageQueue.async(flags: flags) { [weak self] in + guard let self, + self.isCurrentPanicLifecycleGeneration( + lifecycleGeneration + ) else { + return + } + #if DEBUG + self._test_onReceivePacketHandoff?() + #endif + self.handleReceivedPacketOnQueue(packet, from: peerID) } return } + if isNoisePacket { + guard let lifecycleGeneration = + capturePanicLifecycleGeneration() else { + return + } + messageQueue.async(flags: .barrier) { [weak self] in + guard let self, + self.isCurrentPanicLifecycleGeneration( + lifecycleGeneration + ) else { + return + } + self.handleReceivedPacketOnQueue(packet, from: peerID) + } + } else { + handleReceivedPacketOnQueue(packet, from: peerID) + } + } + + private func handleReceivedPacketOnQueue( + _ packet: BitchatPacket, + from peerID: PeerID + ) { let context = BLEReceivePipeline.context(for: packet, localPeerID: myPeerID) let senderID = context.senderID let messageID = context.messageID @@ -4785,7 +7077,9 @@ extension BLEService { handleMeshPong(packet, from: senderID) case .leave: - handleLeave(packet, from: senderID) + // A forged leave must neither evict the claimed peer nor spread + // to downstream nodes. + guard handleLeave(packet, from: senderID) else { return } case .none: SecureLogger.warning("⚠️ Unknown message type: \(packet.type)", category: .session) @@ -4847,6 +7141,19 @@ extension BLEService { private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) { let result = announceHandler.handle(packet, from: peerID) + // A capability bit in the public announce is only a discovery hint. + // Start authentication promptly for a directly connected candidate, + // but never pin or pre-queue private bytes until encrypted 0x21 state + // arrives from the completed Noise session. + if let result, + result.isVerified, + result.isDirectAnnounce, + result.announcement.capabilities?.contains(.privateMedia) == true, + privateMediaSendPolicy(to: result.peerID) == .awaitingCapabilityProof, + !noiseService.hasSession(with: result.peerID) { + initiateNoiseHandshake(with: result.peerID) + } + // A verified announce is the moment a signing key becomes bound to this // owner's noise key: retry any prekey bundle that raced ahead of it. if let result, result.isVerified { @@ -4858,6 +7165,9 @@ extension BLEService { // consolidate duplicate same-role connections onto that link. if let result, result.isVerified, result.isDirectAnnounce { rebindLinkAfterVerifiedDirectAnnounce(packet, to: result.peerID) + #if DEBUG + _test_afterVerifiedDirectRebindEnqueued?() + #endif retireRedundantPeripheralLinks(packet, to: result.peerID) } @@ -4891,11 +7201,9 @@ extension BLEService { deliverCourierMailRemotely(to: result.peerID, noiseKey: noiseKey) if result.isDirectAnnounce, !hasCurrentNoiseAuthenticatedLink(to: result.peerID) { - if noiseService.hasEstablishedSession(with: result.peerID) { - // A session with no surviving authenticated link is stale; - // force the current link to prove possession again. - noiseService.clearSession(for: result.peerID) - } + // A cached session may predate this physical link. + // rebindLinkAfterVerifiedDirectAnnounce performs its atomic + // ordinary reconnect after the binding is published. if !noiseService.hasSession(with: result.peerID) { initiateNoiseHandshake(with: result.peerID) } @@ -4924,7 +7232,14 @@ extension BLEService { linkUUID = centralUUID previousPeerID = self.linkStateStore.peerID(forCentralUUID: centralUUID) } - guard let previousPeerID, previousPeerID != peerID else { return } + guard let previousPeerID else { return } + guard previousPeerID != peerID else { + self.refreshNoiseSessionForVerifiedDirectLink( + packet, + peerID: peerID + ) + return + } // The signature does not authenticate directness (TTL is excluded // from signing because relays mutate it), so a "verified direct" @@ -4951,12 +7266,20 @@ extension BLEService { // it across an announce-driven rebind, whose direct TTL is // replayable; the new owner must complete a fresh handshake. self.noiseAuthenticatedLinkOwners.removeValue(forKey: link) + self.noiseReconnectPolicy.endLinkEpoch(link) switch link { case .peripheral(let peripheralUUID): self.linkStateStore.bindPeripheral(peripheralUUID, to: peerID) case .central(let centralUUID): self.linkStateStore.bindCentral(centralUUID, to: peerID) } + // Keep the rebind and reconnect decision in one bleQueue critical + // section. No observer may see the new binding while a cached + // peer-level sender is still considered established. + self.refreshNoiseSessionForVerifiedDirectLink( + packet, + peerID: peerID + ) SecureLogger.debug("🔄 Rebinding link after peer-ID rotation: \(previousPeerID.id.prefix(8))… → \(peerID.id.prefix(8))…", category: .session) self.refreshLocalTopology() // The announce that triggered this rebind was upserted as @@ -5048,6 +7371,7 @@ extension BLEService { pendingPeripheralWrites.discardAll(for: uuid) } noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(uuid)) + noiseReconnectPolicy.endLinkEpoch(.peripheral(uuid)) _ = linkStateStore.removePeripheral(uuid) SecureLogger.info( "🔗 Retiring redundant link \(uuid.prefix(8))… bound to \(peerID.id.prefix(8))…\(keptUUID.map { " (keeping \($0.prefix(8))…)" } ?? "")", @@ -5116,9 +7440,26 @@ extension BLEService { }, messageTTL: messageTTL, now: { Date() }, - existingNoisePublicKey: { [weak self] peerID in + existingPeerKeys: { [weak self] peerID in + guard let self = self else { return (nil, nil) } + return self.collectionsQueue.sync { + let info = self.peerRegistry.info(for: peerID) + return (info?.noisePublicKey, info?.signingPublicKey) + } + }, + persistedSigningPublicKey: { [weak self] peerID in + // Same synchronous identity-manager read pattern as + // signedSenderDisplayName(for:from:); the manager serializes + // access on its own internal queue. guard let self = self else { return nil } - return self.collectionsQueue.sync { self.peerRegistry.info(for: peerID)?.noisePublicKey } + return self.identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID) + .compactMap { $0.signingPublicKey } + .first + }, + authenticatedSigningPublicKey: { [weak self] noisePublicKey in + self?.identityManager.authenticatedSigningPublicKey( + forFingerprint: noisePublicKey.sha256Fingerprint() + ) }, verifySignature: { [weak self] packet, signingPublicKey in self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false @@ -5150,16 +7491,24 @@ extension BLEService { }, upsertVerifiedAnnounce: { [weak self] peerID, announcement, isConnected, now in // Called from inside withRegistryBarrier; access registry directly. - self?.peerRegistry.upsertVerifiedAnnounce( + guard let self = self else { + return BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil) + } + return self.peerRegistry.upsertVerifiedAnnounce( peerID: peerID, nickname: announcement.nickname, noisePublicKey: announcement.noisePublicKey, signingPublicKey: announcement.signingPublicKey, isConnected: isConnected, + // Propagate `nil` (registry refused the announce because it + // carries a signing key different from the pinned one) so + // the handler's guard rejects it instead of overwriting the + // pinned identity. Main's capabilities/bridgeGeohash are + // preserved. now: now, - capabilities: announcement.capabilities ?? [], + capabilities: announcement.capabilities, bridgeGeohash: announcement.bridgeGeohash - ) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil) + ) }, shouldEmitReconnectLog: { [weak self] peerID, now in // Called from inside withRegistryBarrier; access debouncer directly. @@ -5425,9 +7774,17 @@ extension BLEService { } private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) { - let wasEstablished = noiseService.hasEstablishedSession(with: peerID) - noisePacketHandler.handleHandshake(packet, from: peerID) - if !wasEstablished, noiseService.hasEstablishedSession(with: peerID) { + let result = noisePacketHandler.handleHandshakeWithResult( + packet, + from: peerID + ) + // An inbound message 1 quarantines the old transport receive-only. + // Keep its generation-bound BLE state intact: the manager's new + // handshaking generation already gates every outbound policy, while + // a rollback can become ready again without repeating capability + // proof or announce side effects. Only the exact handshake candidate's + // authenticated completion may promote the physical ingress link. + if result.didEstablishAuthenticatedSession { markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID) } } @@ -5450,11 +7807,25 @@ extension BLEService { messageTTL: messageTTL, now: { Date() }, processHandshakeMessage: { [weak self] peerID, message in - try self?.noiseService.processHandshakeMessage(from: peerID, message: message) + guard let self else { + return NoiseHandshakeProcessingResult( + response: nil, + didEstablishAuthenticatedSession: false + ) + } + return try self.noiseService.processHandshakeMessageWithResult( + from: peerID, + message: message + ) }, hasNoiseSession: { [weak self] peerID in self?.noiseService.hasSession(with: peerID) ?? false }, + isAwaitingResponderHandshakeCompletion: { [weak self] peerID in + self?.noiseService.isAwaitingResponderHandshakeCompletion( + with: peerID + ) ?? false + }, initiateHandshake: { [weak self] peerID in self?.initiateNoiseHandshake(with: peerID) }, @@ -5466,12 +7837,41 @@ extension BLEService { }, decrypt: { [weak self] payload, peerID in guard let self = self else { throw NoiseEncryptionError.sessionNotEstablished } - return try self.noiseService.decrypt(payload, from: peerID) + let result = try self.noiseService.decryptWithSessionGeneration( + payload, + from: peerID, + establishedGenerationIsReady: { generation in + self.collectionsQueue.sync { + self.privateMediaSessionGenerations[ + peerID.toShort() + ] == generation + } + } + ) + return BLENoiseDecryptionResult( + plaintext: result.plaintext, + sessionGeneration: result.sessionGeneration + ) }, clearSession: { [weak self] peerID in - self?.noiseService.clearSession(for: peerID) + self?.clearNoiseSession(for: peerID) + }, + handleAuthenticatedPeerState: { [weak self] peerID, payload, generation in + self?.handleAuthenticatedPeerState( + payload, + from: peerID, + sessionGeneration: generation + ) }, deliverNoisePayload: { [weak self] peerID, type, payload, timestamp in + if type == .privateFile { + self?.fileTransferHandler.handlePrivatePayload( + payload, + from: peerID, + timestamp: timestamp + ) + return + } // Single main-actor hop delivering `.noisePayloadReceived`. self?.notifyUI { [weak self] in self?.deliverTransportEvent(.noisePayloadReceived( @@ -5488,16 +7888,83 @@ extension BLEService { // MARK: Helper Functions private func sendPendingNoisePayloadsAfterHandshake(for peerID: PeerID) { - let payloads = collectionsQueue.sync(flags: .barrier) { () -> [Data] in + let payloads = collectionsQueue.sync(flags: .barrier) { () -> [BLEPendingTypedPayload] in pendingNoiseSessionQueues.takeTypedPayloads(for: peerID) } guard !payloads.isEmpty else { return } SecureLogger.debug("📤 Sending \(payloads.count) pending noise payloads to \(peerID.id.prefix(8))… after handshake", category: .session) - for payload in payloads { + for pending in payloads { + let isPrivateMedia = NoisePayloadType.isPrivateFile(rawValue: pending.payload.first) + let privateMediaTransferId = isPrivateMedia ? pending.transferId : nil + + if isPrivateMedia { + switch privateMediaSendPolicy(to: peerID) { + case .encrypted: + break + + case .awaitingCapabilityProof: + // Handshake completion alone is insufficient. Put the + // exact payload back until authenticated 0x21 state + // arrives; that handler calls this drain again. + collectionsQueue.sync(flags: .barrier) { + pendingNoiseSessionQueues.appendTypedPayload( + pending.payload, + transferId: pending.transferId, + for: peerID + ) + } + continue + + case .legacyRequiresConsent, .blockedDowngrade: + if let transferId = pending.transferId { + TransferProgressManager.shared.rejectBeforeStart( + id: transferId, + reason: String( + localized: "content.delivery.reason.private_media_capability_unresolved", + defaultValue: "Could not confirm encrypted media support", + comment: "Failure reason when queued private media cannot be authenticated after handshake" + ) + ) + privateMediaTransferAdmissions.finish(transferId) + } + continue + } + } + if let transferId = privateMediaTransferId, + !privateMediaTransferAdmissions.isActive(transferId) { + privateMediaTransferAdmissions.finish(transferId) + continue + } do { - broadcastPacket(try makeEncryptedNoisePacket(payload, to: peerID)) + if let transferId = privateMediaTransferId, + !privateMediaTransferAdmissions.isActive(transferId) { + privateMediaTransferAdmissions.finish(transferId) + continue + } + let packet = try makeEncryptedNoisePacket(pending.payload, to: peerID) + if let transferId = privateMediaTransferId, + !privateMediaTransferAdmissions.isActive(transferId) { + privateMediaTransferAdmissions.finish(transferId) + continue + } + broadcastPacket( + packet, + transferId: pending.transferId, + requiresPrivateMediaAdmission: privateMediaTransferId != nil + ) } catch { SecureLogger.error("❌ Failed to send pending noise payload to \(peerID.id.prefix(8))…: \(error)") + if let transferId = pending.transferId { + TransferProgressManager.shared.rejectBeforeStart( + id: transferId, + reason: String( + localized: "content.delivery.reason.encryption_failed", + defaultValue: "Failed to encrypt media", + comment: "Failure reason shown when queued private media cannot be encrypted after handshake" + ) + ) + privateMediaTransferAdmissions.finish(transferId) + } } } } @@ -5526,8 +7993,7 @@ extension BLEService { let transportPeers: [TransportPeerSnapshot] = collectionsQueue.sync { peerRegistry.transportSnapshots(selfNickname: myNickname) } - // Notify UI on MainActor via delegate - Task { @MainActor [weak self] in + notifyUI { [weak self] in self?.peerEventsDelegate?.didUpdatePeerSnapshots(transportPeers) } } @@ -5535,6 +8001,7 @@ extension BLEService { // MARK: Consolidated Maintenance private func performMaintenance() { + guard !isPanicSuspended else { return } maintenanceCounter += 1 lastMaintenanceAt = Date() @@ -5658,6 +8125,11 @@ extension BLEService { private func performCleanup() { let now = Date() + + // Admission expiry is a visible transfer failure, never a silent + // eviction. The registry delivers notifications after releasing its + // lock, so this maintenance pass cannot deadlock a concurrent cancel. + privateMediaTransferAdmissions.prune(now: now) // Clean old processed messages efficiently messageDeduplicator.cleanup() diff --git a/bitchat/Services/Courier/CourierStore.swift b/bitchat/Services/Courier/CourierStore.swift index 77c7d100..565eef17 100644 --- a/bitchat/Services/Courier/CourierStore.swift +++ b/bitchat/Services/Courier/CourierStore.swift @@ -322,7 +322,7 @@ final class CourierStore { /// Envelopes eligible to park on relays as bridge courier drops. Merely /// offering one does not start its cooldown: the caller commits that only - /// after a relay explicitly accepts the event via NIP-20 OK. + /// after a relay explicitly accepts the event via NIP-01 `OK`. func envelopesForBridgePublish(cooldown: TimeInterval) -> [CourierEnvelope] { let date = now() return queue.sync { diff --git a/bitchat/Services/Courier/MessageOutboxStore.swift b/bitchat/Services/Courier/MessageOutboxStore.swift index 5543a8d0..9bb07be9 100644 --- a/bitchat/Services/Courier/MessageOutboxStore.swift +++ b/bitchat/Services/Courier/MessageOutboxStore.swift @@ -110,6 +110,10 @@ final class MessageOutboxStore { /// Delivery/read acknowledgments received before a deferred cold-load /// reveals the durable queue. Applied to every merge before persistence. private var pendingRemovalMessageIDs = Set() + /// Peer-scoped acknowledgments received before a deferred cold-load + /// reveals the durable queue. Unlike the legacy global tombstones above, + /// these must not remove a colliding message ID queued for another peer. + private var pendingScopedRemovalMessageIDs: [PeerID: Set] = [:] private var recoveryHandler: (@MainActor (Snapshot) -> Void)? /// Recovery loaded durable state that MessageRouter has not merged yet. /// While true, router saves must union with `cachedSnapshot` instead of @@ -195,7 +199,7 @@ final class MessageOutboxStore { ? (pendingSnapshot ?? [:]) : Self.merge(durable, pendingSnapshot ?? [:])) diskState = .loaded - if pendingSnapshot != nil || !pendingRemovalMessageIDs.isEmpty { + if pendingSnapshot != nil || hasPendingRemovalsLocked { if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) { pendingSnapshot = nil pendingSnapshotIsAuthoritative = false @@ -212,7 +216,7 @@ final class MessageOutboxStore { case .missing: cachedSnapshot = applyingPendingRemovalsLocked(pendingSnapshot ?? [:]) diskState = .loaded - if pendingSnapshot != nil || !pendingRemovalMessageIDs.isEmpty { + if pendingSnapshot != nil || hasPendingRemovalsLocked { if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) { pendingSnapshot = nil pendingSnapshotIsAuthoritative = false @@ -235,7 +239,7 @@ final class MessageOutboxStore { diskState = .loaded cachedSnapshot = applyingPendingRemovalsLocked(pendingSnapshot ?? [:]) SecureLogger.error("Failed to decode encrypted outbox: \(error)", category: .session) - if pendingSnapshot != nil || !pendingRemovalMessageIDs.isEmpty { + if pendingSnapshot != nil || hasPendingRemovalsLocked { if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) { pendingSnapshot = nil pendingSnapshotIsAuthoritative = false @@ -442,6 +446,25 @@ final class MessageOutboxStore { lock.unlock() } + /// Records an ack for only the supplied peer aliases. This preserves + /// another recipient's queued entry when message IDs happen to collide, + /// including while the durable snapshot is hidden by protected data. + func recordRemoval(messageID: String, for peerIDs: Set) { + guard !peerIDs.isEmpty else { return } + + lock.lock() + for peerID in peerIDs { + pendingScopedRemovalMessageIDs[peerID, default: []].insert(messageID) + } + cachedSnapshot = Self.removing(pendingScopedRemovalMessageIDs, from: cachedSnapshot) + unseenRecoveredSnapshot = Self.removing(pendingScopedRemovalMessageIDs, from: unseenRecoveredSnapshot) + recoveryRouterSnapshot = Self.removing(pendingScopedRemovalMessageIDs, from: recoveryRouterSnapshot) + if let pendingSnapshot { + self.pendingSnapshot = Self.removing(pendingScopedRemovalMessageIDs, from: pendingSnapshot) + } + lock.unlock() + } + /// Retries a deferred protected-data load. The returned snapshot includes /// both durable messages and any messages queued during the locked wake. @discardableResult @@ -478,7 +501,7 @@ final class MessageOutboxStore { : (pendingSnapshotIsAuthoritative ? known : Self.merge(durable, known))) cachedSnapshot = merged diskState = .loaded - if (pendingSnapshot == nil && pendingRemovalMessageIDs.isEmpty) || + if (pendingSnapshot == nil && !hasPendingRemovalsLocked) || persistSnapshotAndClearRemovalsLocked(merged) { pendingSnapshot = nil pendingSnapshotIsAuthoritative = false @@ -507,7 +530,7 @@ final class MessageOutboxStore { : known) cachedSnapshot = merged diskState = .loaded - if (pendingSnapshot == nil && pendingRemovalMessageIDs.isEmpty) || + if (pendingSnapshot == nil && !hasPendingRemovalsLocked) || persistSnapshotAndClearRemovalsLocked(merged) { pendingSnapshot = nil pendingSnapshotIsAuthoritative = false @@ -539,7 +562,7 @@ final class MessageOutboxStore { : known) cachedSnapshot = merged diskState = .loaded - if (pendingSnapshot == nil && pendingRemovalMessageIDs.isEmpty) || + if (pendingSnapshot == nil && !hasPendingRemovalsLocked) || persistSnapshotAndClearRemovalsLocked(merged) { pendingSnapshot = nil pendingSnapshotIsAuthoritative = false @@ -578,6 +601,7 @@ final class MessageOutboxStore { pendingSnapshot = nil pendingSnapshotIsAuthoritative = false pendingRemovalMessageIDs.removeAll() + pendingScopedRemovalMessageIDs.removeAll() recoveryDeliveryPending = false unseenRecoveryPendingPersistence = false unseenRecoveredSnapshot = [:] @@ -742,12 +766,21 @@ final class MessageOutboxStore { private func persistSnapshotAndClearRemovalsLocked(_ snapshot: Snapshot) -> Bool { guard persistSnapshotLocked(snapshot) else { return false } pendingRemovalMessageIDs.removeAll() + pendingScopedRemovalMessageIDs.removeAll() return true } /// Must be called with `lock` held. private func applyingPendingRemovalsLocked(_ snapshot: Snapshot) -> Snapshot { - Self.removing(pendingRemovalMessageIDs, from: snapshot) + Self.removing( + pendingScopedRemovalMessageIDs, + from: Self.removing(pendingRemovalMessageIDs, from: snapshot) + ) + } + + /// Must be read with `lock` held. + private var hasPendingRemovalsLocked: Bool { + !pendingRemovalMessageIDs.isEmpty || !pendingScopedRemovalMessageIDs.isEmpty } private static func removing(_ messageIDs: Set, from snapshot: Snapshot) -> Snapshot { @@ -760,9 +793,28 @@ final class MessageOutboxStore { return filtered } + private static func removing( + _ messageIDsByPeer: [PeerID: Set], + from snapshot: Snapshot + ) -> Snapshot { + guard !messageIDsByPeer.isEmpty else { return snapshot } + var filtered = snapshot + for (peerID, messageIDs) in messageIDsByPeer { + guard !messageIDs.isEmpty, let queue = filtered[peerID] else { continue } + let remaining = queue.filter { !messageIDs.contains($0.messageID) } + filtered[peerID] = remaining.isEmpty ? nil : remaining + } + return filtered + } + private static func excludingKnownMessages(from durable: Snapshot, known: Snapshot) -> Snapshot { - let knownIDs = Set(known.values.flatMap { $0.map(\.messageID) }) - return removing(knownIDs, from: durable) + var unseen: Snapshot = [:] + for (peerID, durableQueue) in durable { + let knownIDs = Set(known[peerID]?.map(\.messageID) ?? []) + let remaining = durableQueue.filter { !knownIDs.contains($0.messageID) } + if !remaining.isEmpty { unseen[peerID] = remaining } + } + return unseen } private static func merge(_ durable: Snapshot, _ pending: Snapshot) -> Snapshot { diff --git a/bitchat/Services/Gateway/BridgeCourierService.swift b/bitchat/Services/Gateway/BridgeCourierService.swift index f1f72c31..80a20a36 100644 --- a/bitchat/Services/Gateway/BridgeCourierService.swift +++ b/bitchat/Services/Gateway/BridgeCourierService.swift @@ -67,7 +67,7 @@ final class BridgeCourierService: ObservableObject { var relaysConnected: (@MainActor () -> Bool)? /// Publishes a signed drop event directly to connected default (DM) /// relays. Completion is true only after at least one relay explicitly - /// accepts the event via NIP-20 OK; this must never mean "queued in RAM" + /// accepts the event via NIP-01 `OK`; this must never mean "queued in RAM" /// or merely "written to a socket". var publishEvent: (@MainActor (NostrEvent, @escaping @MainActor (Bool) -> Void) -> Void)? /// (Re)opens the drop subscription for the given hex tags. @@ -106,13 +106,14 @@ final class BridgeCourierService: ObservableObject { dedupKey: String?, operationID: UUID? )] = [] - /// Message IDs already published as drops (sender-side dedup) and drop - /// event IDs already handled (multi-relay dedup). Both persist across - /// relaunches: relays hold drops for the full 24h NIP-40 window and the - /// persisted outbox keeps re-depositing, so in-memory-only dedup meant - /// every relaunch republished the same message as a fresh drop and every - /// gateway relaunch re-delivered the whole backlog (field-verified - /// amplification storm). Entries age out with the 24h drop window. + /// Opaque recipient/message keys already published as drops (sender-side + /// dedup) and drop event IDs already handled (multi-relay dedup). Both + /// persist across relaunches: relays hold drops for the full 24h NIP-40 + /// window and the persisted outbox keeps re-depositing, so in-memory-only + /// dedup meant every relaunch republished the same message as a fresh drop + /// and every gateway relaunch re-delivered the whole backlog + /// (field-verified amplification storm). Entries age out with the 24h drop + /// window. private var publishedDropKeys: ExpiringIDSet private var seenDropEventIDs: ExpiringIDSet private var subscriptionOpen = false @@ -126,10 +127,10 @@ final class BridgeCourierService: ObservableObject { } /// Sender operations queued locally or awaiting relay confirmation. /// 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] = [:] /// Held-envelope publishes have no sender message ID, but still need an - /// in-flight identity: repeated refreshes inside the NIP-20 wait window + /// in-flight identity: repeated refreshes inside the relay-OK wait window /// must not mint duplicate relay events for the same opaque envelope. private var heldDropOperations: [Data: UUID] = [:] /// Deterministically invalid envelopes are suppressed for this process, @@ -214,11 +215,46 @@ final class BridgeCourierService: ObservableObject { // 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 /// relay drop. Called by the message router alongside physical courier - /// deposits; idempotent per message ID. Completion becomes true only - /// after a real relay acceptance arrives, which is when the router may - /// show "carried". + /// deposits; idempotent per recipient/message pair. Completion becomes + /// true only after a real relay acceptance arrives, which is when the + /// router may show "carried". func depositDrop( content: String, messageID: String, @@ -229,9 +265,18 @@ final class BridgeCourierService: ObservableObject { completion(false) return } - guard !publishedDropKeys.contains(messageID, now: now()), - activeDropOperations[messageID] == nil, - !rejectedDropKeys.contains(messageID, now: now()) else { + let date = now() + let dedupKey = Self.senderDropKey( + messageID: messageID, + recipientNoiseKey: recipientNoiseKey + ) + guard !wasPublished( + legacyMessageID: messageID, + dedupKey: dedupKey, + now: date + ), + activeDropOperations[dedupKey] == nil, + !rejectedDropKeys.contains(dedupKey, now: date) else { completion(false) return } @@ -244,13 +289,13 @@ final class BridgeCourierService: ObservableObject { // of the sealing); suppress it in-memory so the retry sweep does not // churn, but never persist it as a published drop. guard let encoded = envelope.encode(), encoded.count <= Limits.maxDropEnvelopeBytes else { - rejectedDropKeys.insert(messageID, now: now()) + rejectedDropKeys.insert(dedupKey, now: date) completion(false) return } let operationID = UUID() - activeDropOperations[messageID] = ActiveDropOperation(id: operationID, completion: completion) - publishDrop(envelope, messageID: messageID, operationID: operationID) + activeDropOperations[dedupKey] = ActiveDropOperation(id: operationID, completion: completion) + publishDrop(envelope, dedupKey: dedupKey, operationID: operationID) } /// 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 - /// sender-side dedup key (nil for held/relayed envelopes we don't track); - /// it rides the pending queue so an evicted or failed drop can release its - /// in-flight slot. Completion reports actual NIP-20 relay acceptance. +/// Publishes a drop, or queues it when relays are down. `dedupKey` is the + /// opaque sender-side recipient/message key (nil for held/relayed + /// envelopes we don't track); it rides the pending queue so an evicted or + /// failed drop can release its in-flight slot. Completion reports actual + /// NIP-01 relay acceptance. private func publishDrop( _ envelope: CourierEnvelope, - messageID: String? = nil, + dedupKey: String? = nil, operationID: UUID? = nil, untrackedCompletion: (@MainActor (Bool) -> Void)? = nil ) { @@ -286,7 +332,7 @@ final class BridgeCourierService: ObservableObject { encoded.count <= Limits.maxDropEnvelopeBytes, !envelope.isExpired else { finishPublish( - messageID: messageID, + dedupKey: dedupKey, operationID: operationID, succeeded: false, untrackedCompletion: untrackedCompletion @@ -297,15 +343,15 @@ final class BridgeCourierService: ObservableObject { // Held mail remains in CourierStore and has no sender operation to // recover after an in-memory queue loss. Leave its cooldown unset // and let the next connected refresh offer it again. - guard messageID != nil else { + guard dedupKey != nil else { untrackedCompletion?(false) return } - pendingDrops.append((envelope, messageID, operationID)) + pendingDrops.append((envelope, dedupKey, operationID)) while pendingDrops.count > Limits.maxPendingDrops { let evicted = pendingDrops.removeFirst() finishPublish( - messageID: evicted.dedupKey, + dedupKey: evicted.dedupKey, operationID: evicted.operationID, succeeded: false ) @@ -321,7 +367,7 @@ final class BridgeCourierService: ObservableObject { ) else { SecureLogger.error("📦🌉 Failed to compose courier drop", category: .encryption) finishPublish( - messageID: messageID, + dedupKey: dedupKey, operationID: operationID, succeeded: false, untrackedCompletion: untrackedCompletion @@ -331,7 +377,7 @@ final class BridgeCourierService: ObservableObject { guard let publishEvent else { SecureLogger.error("📦🌉 Courier drop publisher is not configured", category: .session) finishPublish( - messageID: messageID, + dedupKey: dedupKey, operationID: operationID, succeeded: false, untrackedCompletion: untrackedCompletion @@ -341,7 +387,7 @@ final class BridgeCourierService: ObservableObject { publishEvent(event) { [weak self] succeeded in guard let self else { return } guard self.finishPublish( - messageID: messageID, + dedupKey: dedupKey, operationID: operationID, succeeded: succeeded, untrackedCompletion: untrackedCompletion @@ -356,23 +402,23 @@ final class BridgeCourierService: ObservableObject { @discardableResult private func finishPublish( - messageID: String?, + dedupKey: String?, operationID: UUID?, succeeded: Bool, untrackedCompletion: (@MainActor (Bool) -> Void)? = nil ) -> Bool { - guard let messageID else { + guard let dedupKey else { untrackedCompletion?(succeeded) return true } // Missing/mismatched means this callback was duplicated, invalidated // by panic wipe, or belongs to an older attempt for the same key. guard let operationID, - let operation = activeDropOperations[messageID], + let operation = activeDropOperations[dedupKey], operation.id == operationID else { return false } - activeDropOperations.removeValue(forKey: messageID) + activeDropOperations.removeValue(forKey: dedupKey) if succeeded { - publishedDropKeys.insert(messageID, now: now()) + publishedDropKeys.insert(dedupKey, now: now()) persistDedup() } operation.completion(succeeded) @@ -387,7 +433,7 @@ final class BridgeCourierService: ObservableObject { for item in queued { publishDrop( item.envelope, - messageID: item.dedupKey, + dedupKey: item.dedupKey, operationID: item.operationID ) } diff --git a/bitchat/Services/Gateway/BridgeDropDedupStore.swift b/bitchat/Services/Gateway/BridgeDropDedupStore.swift index 76a0fe28..0e34a3bd 100644 --- a/bitchat/Services/Gateway/BridgeDropDedupStore.swift +++ b/bitchat/Services/Gateway/BridgeDropDedupStore.swift @@ -64,11 +64,11 @@ struct ExpiringIDSet { /// fresh drop (fresh throwaway seal, undeduplicatable downstream) and every /// gateway relaunch re-delivered the whole backlog. Field-verified: ~20 /// 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 -/// ID per 24h regardless of relaunch count. +/// device freeze. Persisting both sides caps this at one drop per +/// recipient/message pair per 24h regardless of relaunch count. /// -/// Contents are opaque IDs (message UUIDs, relay event IDs) — no plaintext, -/// no peer identities — so until-first-unlock protection matches +/// Contents are opaque hashes and relay event IDs — no plaintext or peer +/// identities — so until-first-unlock protection matches /// `NostrProcessedEventStore`, and the file must load during a /// locked-background restoration relaunch. Wiped on panic with the rest of /// the courier state. diff --git a/bitchat/Services/GeohashPresenceService.swift b/bitchat/Services/GeohashPresenceService.swift index e311c5c4..15e8ade2 100644 --- a/bitchat/Services/GeohashPresenceService.swift +++ b/bitchat/Services/GeohashPresenceService.swift @@ -45,6 +45,9 @@ final class GeohashPresenceService: ObservableObject { private var subscriptions = Set() private var heartbeatTimer: GeohashPresenceTimerProtocol? + private var pendingBroadcastTasks: [UUID: Task] = [:] + private var heartbeatGeneration: UInt64 = 0 + private var started = false private let availableChannelsProvider: () -> [GeohashChannel] private let locationChanges: AnyPublisher<[GeohashChannel], Never> private let torReadyPublisher: AnyPublisher @@ -147,10 +150,25 @@ final class GeohashPresenceService: ObservableObject { /// Start the service (safe to call multiple times) func start() { + guard !started else { return } + started = true + heartbeatGeneration &+= 1 SecureLogger.info("Presence: service starting...", category: .session) scheduleNextHeartbeat() } + /// Stops the timer and every decorrelation task synchronously at the panic + /// boundary. Generation checks also protect against custom sleepers that + /// ignore task cancellation and return later. + func stopForPanic() { + started = false + heartbeatGeneration &+= 1 + heartbeatTimer?.invalidate() + heartbeatTimer = nil + pendingBroadcastTasks.values.forEach { $0.cancel() } + pendingBroadcastTasks.removeAll(keepingCapacity: false) + } + private func setupObservers() { // Monitor location channel changes locationChanges @@ -169,20 +187,26 @@ final class GeohashPresenceService: ObservableObject { } func handleLocationChange() { + guard started else { return } // When location changes, we trigger an immediate (but slightly delayed) heartbeat // to announce presence in the new zone, then reset the loop. SecureLogger.debug("Presence: location changed, scheduling update", category: .session) heartbeatTimer?.invalidate() // Small delay to allow location state to settle + let generation = heartbeatGeneration heartbeatTimer = scheduleTimer(5.0) { [weak self] in Task { @MainActor [weak self] in - self?.performHeartbeat() + guard let self, + self.started, + self.heartbeatGeneration == generation else { return } + self.performHeartbeat() } } } func handleConnectivityChange() { + guard started else { return } SecureLogger.debug("Presence: connectivity restored, triggering heartbeat", category: .session) // If we were waiting for network, do it now if heartbeatTimer == nil || !heartbeatTimer!.isValid { @@ -191,18 +215,29 @@ final class GeohashPresenceService: ObservableObject { } func scheduleNextHeartbeat() { + guard started else { return } heartbeatTimer?.invalidate() let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval) + let generation = heartbeatGeneration heartbeatTimer = scheduleTimer(interval) { [weak self] in Task { @MainActor [weak self] in - self?.performHeartbeat() + guard let self, + self.started, + self.heartbeatGeneration == generation else { return } + self.performHeartbeat() } } } func performHeartbeat() { + guard started else { return } + let generation = heartbeatGeneration // Always schedule next loop first ensures continuity even if this one fails/skips - defer { scheduleNextHeartbeat() } + defer { + if started, heartbeatGeneration == generation { + scheduleNextHeartbeat() + } + } // 1. Check preconditions guard torIsReady() else { @@ -228,14 +263,27 @@ final class GeohashPresenceService: ObservableObject { } // Launch independent task for each channel's delay - Task { @MainActor in + let taskID = UUID() + let sleeper = self.sleeper + let delay = TimeInterval.random( + in: burstMinDelay...burstMaxDelay + ) + let nanoseconds = UInt64(delay * 1_000_000_000) + let task = Task { @MainActor [weak self] in // Random delay for decorrelation - let delay = TimeInterval.random(in: self.burstMinDelay...self.burstMaxDelay) - let nanoseconds = UInt64(delay * 1_000_000_000) - await self.sleeper(nanoseconds) - + await sleeper(nanoseconds) + + guard let self else { return } + guard !Task.isCancelled, + self.started, + self.heartbeatGeneration == generation else { + self.pendingBroadcastTasks.removeValue(forKey: taskID) + return + } + self.pendingBroadcastTasks.removeValue(forKey: taskID) self.broadcastPresence(for: channel.geohash) } + pendingBroadcastTasks[taskID] = task } } diff --git a/bitchat/Services/KeychainManager.swift b/bitchat/Services/KeychainManager.swift index 98f5fa9e..abc86b10 100644 --- a/bitchat/Services/KeychainManager.swift +++ b/bitchat/Services/KeychainManager.swift @@ -11,6 +11,54 @@ import BitFoundation import Foundation import Security +enum KeychainInstallLifecycleAction: Equatable { + case markerPresent + case bootstrapMarker + case clearStaleKeys + case retryLater +} + +/// Process-local fail-closed gate for an unresolved install lifecycle. +/// +/// A blocked caller may perform one synchronous reconciliation attempt. +/// Concurrent callers fail closed instead of reading while that cleanup is +/// in flight. Once reconciliation succeeds, access remains open. +final class KeychainInstallAccessGate: @unchecked Sendable { + private let lock = NSLock() + private var blocked = false + private var reconciliationInProgress = false + + func block() { + lock.lock() + blocked = true + lock.unlock() + } + + func allowsAccess(reconcile: () -> Bool) -> Bool { + lock.lock() + if !blocked { + lock.unlock() + return true + } + guard !reconciliationInProgress else { + lock.unlock() + return false + } + reconciliationInProgress = true + lock.unlock() + + let completed = reconcile() + + lock.lock() + if completed { + blocked = false + } + reconciliationInProgress = false + lock.unlock() + return completed + } +} + final class KeychainManager: KeychainManagerProtocol { /// Default keychain for components that construct their own rather than /// having one injected. Under test this is an in-memory keychain: the @@ -41,53 +89,281 @@ final class KeychainManager: KeychainManagerProtocol { // Use consistent service name for all keychain items private let service = BitchatApp.bundleID private let appGroup = "group.\(BitchatApp.bundleID)" - + #if os(iOS) + private let installAccessGate = KeychainInstallAccessGate() + #endif + /// Every generic-password service owned by this app, including names used + /// by older releases. Keep custom services here so one-time security + /// migrations and panic deletion cannot silently miss them. + private static let additionalApplicationOwnedServices = [ + "chat.bitchat.nostr", + "chat.bitchat.favorites", + "chat.bitchat.outbox", + "com.bitchat.passwords", + "com.bitchat.deviceidentity", + "com.bitchat.noise.identity", + "chat.bitchat.passwords", + "bitchat.keychain", + "bitchat", + "com.bitchat" + ] // AfterFirstUnlock, not WhenUnlocked: the mesh keeps running with the // device locked (identity-cache saves failed with -25308 throughout // locked-phone testing), and a wake-on-proximity relaunch via BLE state // restoration must be able to read the noise keys before the user - // unlocks. Backup/sync semantics are unchanged (not ThisDeviceOnly). - private static let itemAccessibility = kSecAttrAccessibleAfterFirstUnlock + // unlocks. ThisDeviceOnly prevents private identities and group keys from + // migrating through device backups onto a second device. + private static let itemAccessibility = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly init() { #if os(iOS) - migrateAccessibilityIfNeeded() + if reconcileInstallLifecycle() { + migrateAccessibilityIfNeeded() + } else { + installAccessGate.block() + } #endif } + static func installLifecycleAction( + containerKnowsMarker: Bool, + cleanupPending: Bool = false, + markerRead: KeychainReadResult + ) -> KeychainInstallLifecycleAction { + // Once a reinstall cleanup has started, its container-local latch + // must win even if the keychain marker was deleted before a later + // keychain operation failed. Otherwise the next launch could mistake + // a partial cleanup for a fresh bootstrap and preserve stale secrets. + if cleanupPending { + return .clearStaleKeys + } + + switch markerRead { + case .success: + return containerKnowsMarker ? .markerPresent : .clearStaleKeys + case .itemNotFound: + return .bootstrapMarker + case .accessDenied, .deviceLocked, .authenticationFailed, .otherError: + return .retryLater + } + } + + static func applicationOwnedKeychainServices(primaryService: String) -> [String] { + var seen = Set() + return ([primaryService] + additionalApplicationOwnedServices).filter { + seen.insert($0).inserted + } + } + + /// Runs every service update even after one failure. Successful updates + /// are idempotent, while returning false keeps the one-time flag unset so + /// a later unlocked launch retries the incomplete migration. + static func migrateAccessibilityForApplicationOwnedServices( + primaryService: String, + updateService: (String) -> OSStatus + ) -> Bool { + var completed = true + for serviceName in applicationOwnedKeychainServices( + primaryService: primaryService + ) { + let status = updateService(serviceName) + if status != errSecSuccess && status != errSecItemNotFound { + completed = false + } + } + return completed + } + + /// Deletes every declared service even after one failure. An empty scope + /// is already clean, while any other status leaves the cleanup + /// incomplete so its durable retry marker remains set. + static func deleteApplicationOwnedKeychainServices( + primaryService: String, + deleteService: (String) -> OSStatus + ) -> Bool { + var completed = true + for serviceName in applicationOwnedKeychainServices( + primaryService: primaryService + ) { + let status = deleteService(serviceName) + if status != errSecSuccess && status != errSecItemNotFound { + completed = false + } + } + return completed + } + + /// The app currently has an application-group entitlement, not a + /// keychain-access-group entitlement. Keep the historical group cleanup + /// probe as best effort without making its expected -34018 response block + /// panic recovery forever. + static func completedApplicationGroupDelete(status: OSStatus) -> Bool { + status == errSecSuccess + || status == errSecItemNotFound + || status == -34018 + } + #if os(iOS) + + private static let installMarkerAccount = "install_lifecycle_marker" + private static let installMarkerDefaultsKey = "keychain.installLifecycleMarker.present" + private static let installCleanupPendingDefaultsKey = + "keychain.installLifecycleCleanup.pending" + + /// Keychain items can survive app removal while the app container and its + /// UserDefaults do not. The first version carrying this marker bootstraps + /// without deleting existing users' identities. On a later reinstall, a + /// surviving keychain marker plus a missing defaults marker proves the app + /// container was replaced, so stale secrets are removed before use. + @discardableResult + private func reconcileInstallLifecycle() -> Bool { + let defaults = UserDefaults.standard + let containerKnowsMarker = defaults.bool(forKey: Self.installMarkerDefaultsKey) + let cleanupPending = defaults.bool( + forKey: Self.installCleanupPendingDefaultsKey + ) + + let markerRead = retrieveDataWithResult(forKey: Self.installMarkerAccount) + switch Self.installLifecycleAction( + containerKnowsMarker: containerKnowsMarker, + cleanupPending: cleanupPending, + markerRead: markerRead + ) { + case .markerPresent: + defaults.set(true, forKey: Self.installMarkerDefaultsKey) + return true + + case .bootstrapMarker: + if case .success = saveDataWithResult(Data([1]), forKey: Self.installMarkerAccount) { + defaults.set(true, forKey: Self.installMarkerDefaultsKey) + } + // A missing marker is the intentional bootstrap path for both a + // fresh install and the first marker-carrying upgrade. Preserve + // existing users' identities even if marker creation must retry + // on a later construction. + return true + + case .clearStaleKeys: + // Establish a container-local retry latch before deleting the + // surviving keychain marker. If the process exits or any keychain + // operation fails, the next launch retries even when that marker + // can no longer be read. + defaults.set(true, forKey: Self.installCleanupPendingDefaultsKey) + guard defaults.synchronize(), + defaults.bool(forKey: Self.installCleanupPendingDefaultsKey) + else { + SecureLogger.error( + "Could not persist reinstall keychain-cleanup intent", + category: .security + ) + return false + } + + guard deleteAllKeychainData() else { + SecureLogger.error( + "Reinstall keychain cleanup incomplete; retry remains pending", + category: .security + ) + return false + } + + defaults.set(true, forKey: Self.installMarkerDefaultsKey) + defaults.removeObject( + forKey: Self.installCleanupPendingDefaultsKey + ) + guard defaults.synchronize(), + defaults.bool(forKey: Self.installMarkerDefaultsKey), + !defaults.bool( + forKey: Self.installCleanupPendingDefaultsKey + ) + else { + // Preserve the fail-closed state in memory and make one more + // best-effort persistence attempt before startup continues. + defaults.set( + true, + forKey: Self.installCleanupPendingDefaultsKey + ) + _ = defaults.synchronize() + SecureLogger.error( + "Could not commit reinstall keychain-cleanup state; retry remains pending", + category: .security + ) + return false + } + return true + + case .retryLater: + // Do not guess that a temporarily unreadable marker is absent. + // An established container may keep using ordinary protected-data + // semantics: reads fail while locked and recover after unlock. A + // container that has not committed the marker must stay blocked + // until the marker becomes readable and this state machine can + // distinguish bootstrap from reinstall. + return containerKnowsMarker + } + } + /// One-time upgrade of items created under WhenUnlocked. New saves get /// the right class on their own (saves are delete-then-add), but the /// long-lived identity keys are written once and would otherwise stay /// unreadable while the device is locked. private func migrateAccessibilityIfNeeded() { - let flag = "keychain.accessibility.afterFirstUnlock.migrated" + let flag = "keychain.accessibility.afterFirstUnlockThisDeviceOnly.migrated" guard !UserDefaults.standard.bool(forKey: flag) else { return } - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service - ] let update: [String: Any] = [ kSecAttrAccessible as String: Self.itemAccessibility ] - let status = SecItemUpdate(query as CFDictionary, update as CFDictionary) - switch status { - case errSecSuccess, errSecItemNotFound: - // Nothing to migrate on a fresh install; both are terminal. + let completed = Self.migrateAccessibilityForApplicationOwnedServices( + primaryService: service + ) { serviceName in + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName + ] + return SecItemUpdate( + query as CFDictionary, + update as CFDictionary + ) + } + if completed { + // Missing services on a fresh install are terminal, but the flag is + // set only after every application-owned service was considered. UserDefaults.standard.set(true, forKey: flag) - SecureLogger.info("Keychain accessibility migrated to AfterFirstUnlock (status \(status))", category: .keychain) - default: + SecureLogger.info( + "Keychain accessibility migrated to AfterFirstUnlockThisDeviceOnly", + category: .keychain + ) + } else { // Likely errSecInteractionNotAllowed (relaunched while locked) — // leave the flag unset so the next launch retries. - SecureLogger.warning("Keychain accessibility migration deferred (status \(status))", category: .keychain) + SecureLogger.warning( + "Keychain accessibility migration deferred for at least one application-owned service", + category: .keychain + ) } } #endif + private func installAccessAllowed() -> Bool { + #if os(iOS) + return installAccessGate.allowsAccess { [self] in + guard reconcileInstallLifecycle() else { return false } + migrateAccessibilityIfNeeded() + return true + } + #else + return true + #endif + } + // MARK: - Identity Keys func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { + guard installAccessAllowed() else { + SecureLogger.logKeyOperation(.save, keyType: key, success: false) + return false + } let fullKey = "identity_\(key)" let result = saveData(keyData, forKey: fullKey) SecureLogger.logKeyOperation(.save, keyType: key, success: result) @@ -95,11 +371,16 @@ final class KeychainManager: KeychainManagerProtocol { } func getIdentityKey(forKey key: String) -> Data? { + guard installAccessAllowed() else { return nil } let fullKey = "identity_\(key)" return retrieveData(forKey: fullKey) } func deleteIdentityKey(forKey key: String) -> Bool { + guard installAccessAllowed() else { + SecureLogger.logKeyOperation(.delete, keyType: key, success: false) + return false + } let result = delete(forKey: "identity_\(key)") SecureLogger.logKeyOperation(.delete, keyType: key, success: result) return result @@ -110,12 +391,14 @@ final class KeychainManager: KeychainManagerProtocol { /// Get identity key with detailed result for proper error handling /// Distinguishes between missing keys (expected) and critical failures func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult { + guard installAccessAllowed() else { return .accessDenied } let fullKey = "identity_\(key)" return retrieveDataWithResult(forKey: fullKey) } /// Save identity key with detailed result and retry logic for transient errors func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult { + guard installAccessAllowed() else { return .accessDenied } let fullKey = "identity_\(key)" return saveDataWithResult(keyData, forKey: fullKey) } @@ -385,114 +668,165 @@ final class KeychainManager: KeychainManagerProtocol { // Delete ALL keychain data for panic mode func deleteAllKeychainData() -> Bool { SecureLogger.warning("Panic mode - deleting all keychain data", category: .security) - - var totalDeleted = 0 - - // Search without service restriction to catch all items + + let ownedServices = Set( + Self.applicationOwnedKeychainServices( + primaryService: service + ) + ) + var enumerationCompleted = true let searchQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecMatchLimit as String: kSecMatchLimitAll, kSecReturnAttributes as String: true ] - var result: AnyObject? - let searchStatus = SecItemCopyMatching(searchQuery as CFDictionary, &result) - - if searchStatus == errSecSuccess, let items = result as? [[String: Any]] { + let searchStatus = SecItemCopyMatching( + searchQuery as CFDictionary, + &result + ) + switch searchStatus { + case errSecSuccess: + guard let items = result as? [[String: Any]] else { + enumerationCompleted = false + SecureLogger.error( + "Unable to decode application-owned keychain inventory", + category: .security + ) + break + } + + // Preserve the access-group sweep for custom services that are + // not yet in the declared legacy-service list. for item in items { - var shouldDelete = false - let account = item[kSecAttrAccount as String] as? String ?? "" - let service = item[kSecAttrService as String] as? String ?? "" - let accessGroup = item[kSecAttrAccessGroup as String] as? String - - // More precise deletion criteria: - // 1. Check for our specific app group - // 2. OR check for our exact service name - // 3. OR check for known legacy service names - if accessGroup == appGroup { - shouldDelete = true - } else if service == self.service { - shouldDelete = true - } else if [ - "com.bitchat.passwords", - "com.bitchat.deviceidentity", - "com.bitchat.noise.identity", - "chat.bitchat.passwords", - "bitchat.keychain", - "bitchat", - "com.bitchat" - ].contains(service) { - shouldDelete = true + let account = + item[kSecAttrAccount as String] as? String ?? "" + let itemService = + item[kSecAttrService as String] as? String ?? "" + let accessGroup = + item[kSecAttrAccessGroup as String] as? String + guard accessGroup == appGroup + || ownedServices.contains(itemService) + else { + continue } - - if shouldDelete { - // Build delete query with all available attributes for precise deletion - var deleteQuery: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword - ] - - if !account.isEmpty { - deleteQuery[kSecAttrAccount as String] = account - } - if !service.isEmpty { - deleteQuery[kSecAttrService as String] = service - } - - // Add access group if present - if let accessGroup = item[kSecAttrAccessGroup as String] as? String, - !accessGroup.isEmpty && accessGroup != "test" { - deleteQuery[kSecAttrAccessGroup as String] = accessGroup - } - - let deleteStatus = SecItemDelete(deleteQuery as CFDictionary) - if deleteStatus == errSecSuccess { - totalDeleted += 1 - SecureLogger.info("Deleted keychain item: \(account) from \(service)", category: .keychain) - } + + var deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword + ] + if !account.isEmpty { + deleteQuery[kSecAttrAccount as String] = account + } + if !itemService.isEmpty { + deleteQuery[kSecAttrService as String] = itemService + } + if let accessGroup, + !accessGroup.isEmpty, + accessGroup != "test" { + deleteQuery[kSecAttrAccessGroup as String] = accessGroup + } + + let status = SecItemDelete(deleteQuery as CFDictionary) + if status != errSecSuccess && status != errSecItemNotFound { + enumerationCompleted = false + SecureLogger.error( + NSError(domain: "Keychain", code: Int(status)), + context: "Unable to delete enumerated application-owned keychain item", + category: .keychain + ) } } + + case errSecItemNotFound: + break + + default: + enumerationCompleted = false + SecureLogger.error( + NSError(domain: "Keychain", code: Int(searchStatus)), + context: "Unable to enumerate application-owned keychain items", + category: .keychain + ) } - - // Also try to delete by known service names and app group - // This catches any items that might have been missed above - let knownServices = [ - self.service, // Current service name - "com.bitchat.passwords", - "com.bitchat.deviceidentity", - "com.bitchat.noise.identity", - "chat.bitchat.passwords", - "chat.bitchat.nostr", - "bitchat.keychain", - "bitchat", - "com.bitchat" - ] - - for serviceName in knownServices { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: serviceName - ] - - let status = SecItemDelete(query as CFDictionary) - if status == errSecSuccess { - totalDeleted += 1 + + // Bulk deletion by every application-owned service is authoritative + // and idempotent. It also verifies that every known service scope is + // empty even when the inventory pass found no items. + let servicesCompleted = + Self.deleteApplicationOwnedKeychainServices( + primaryService: service + ) { serviceName in + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName + ] + let status = SecItemDelete(query as CFDictionary) + if status != errSecSuccess && status != errSecItemNotFound { + SecureLogger.error( + NSError(domain: "Keychain", code: Int(status)), + context: "Unable to delete application-owned keychain service \(serviceName)", + category: .keychain + ) + } + return status } - } - - // Also delete by app group to ensure complete cleanup + + // Historical builds attempted this application-group identifier as a + // keychain access group. It is not currently entitled, so -34018 + // means the scope is inapplicable rather than partially deleted. let groupQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccessGroup as String: appGroup ] - let groupStatus = SecItemDelete(groupQuery as CFDictionary) - if groupStatus == errSecSuccess { - totalDeleted += 1 + let groupCompleted = Self.completedApplicationGroupDelete( + status: groupStatus + ) + if !groupCompleted { + SecureLogger.error( + NSError(domain: "Keychain", code: Int(groupStatus)), + context: "Unable to delete historical application-group keychain items", + category: .keychain + ) } - - SecureLogger.warning("Panic mode cleanup completed. Total items deleted: \(totalDeleted)", category: .keychain) - - return totalDeleted > 0 + + var markerCompleted = true + #if os(iOS) + // The non-secret marker is intentionally recreated after a panic so a + // later uninstall/reinstall can still be distinguished from an in-place + // upgrade. Do not commit the container-side marker here: reinstall + // reconciliation may still need to retry an incomplete cleanup. + if case .success = saveDataWithResult( + Data([1]), + forKey: Self.installMarkerAccount + ) { + markerCompleted = true + } else { + markerCompleted = false + SecureLogger.error( + "Unable to restore install-lifecycle keychain marker", + category: .security + ) + } + #endif + + let completed = + enumerationCompleted + && servicesCompleted + && groupCompleted + && markerCompleted + if completed { + SecureLogger.warning( + "Panic mode keychain cleanup completed", + category: .keychain + ) + } else { + SecureLogger.error( + "Panic mode keychain cleanup incomplete", + category: .security + ) + } + return completed } // MARK: - Security Utilities @@ -518,6 +852,7 @@ final class KeychainManager: KeychainManagerProtocol { // MARK: - Debug func verifyIdentityKeyExists() -> Bool { + guard installAccessAllowed() else { return false } let key = "identity_noiseStaticKey" return retrieveData(forKey: key) != nil } @@ -526,18 +861,40 @@ final class KeychainManager: KeychainManagerProtocol { /// Save data with a custom service name func save(key: String, data: Data, service customService: String, accessible: CFString?) { - var query: [String: Any] = [ + guard installAccessAllowed() else { return } + let primaryKeyQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: customService, - kSecAttrAccount as String: key, - kSecValueData as String: data + kSecAttrAccount as String: key ] - if let accessible = accessible { - query[kSecAttrAccessible as String] = accessible - } + var addQuery = primaryKeyQuery + addQuery.merge([ + kSecValueData as String: data, + kSecAttrAccessible as String: accessible ?? Self.itemAccessibility, + kSecAttrSynchronizable as String: false + ]) { _, new in new } - SecItemDelete(query as CFDictionary) - SecItemAdd(query as CFDictionary, nil) + // Delete by the item's primary key only. Value/accessibility fields + // are add attributes, not valid selectors for replacing an existing + // item; including them can leave the old item in place and make the + // subsequent add fail as a duplicate. + let deleteStatus = SecItemDelete(primaryKeyQuery as CFDictionary) + guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else { + SecureLogger.error( + NSError(domain: "Keychain", code: Int(deleteStatus)), + context: "Unable to replace custom-service keychain item", + category: .keychain + ) + return + } + let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + if addStatus != errSecSuccess { + SecureLogger.error( + NSError(domain: "Keychain", code: Int(addStatus)), + context: "Unable to save custom-service keychain item", + category: .keychain + ) + } } /// Load data from a custom service @@ -551,6 +908,7 @@ final class KeychainManager: KeychainManagerProtocol { /// Load custom-service data without collapsing `itemNotFound` and /// protected-data/keychain failures into the same nil result. func loadWithResult(key: String, service customService: String) -> KeychainReadResult { + guard installAccessAllowed() else { return .accessDenied } let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: customService, @@ -565,6 +923,7 @@ final class KeychainManager: KeychainManagerProtocol { /// Delete data from a custom service func delete(key: String, service customService: String) { + guard installAccessAllowed() else { return } let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: customService, @@ -576,6 +935,7 @@ final class KeychainManager: KeychainManagerProtocol { /// Delete every item stored under a custom service func deleteAll(service customService: String) { + guard installAccessAllowed() else { return } let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: customService, diff --git a/bitchat/Services/MeshEchoSettings.swift b/bitchat/Services/MeshEchoSettings.swift index 347c178a..824f78c7 100644 --- a/bitchat/Services/MeshEchoSettings.swift +++ b/bitchat/Services/MeshEchoSettings.swift @@ -10,9 +10,12 @@ import Foundation /// Watermark for "heard here earlier" echoes: clearing the mesh timeline /// (triple-tap or /clear) records the moment, and the next launch only -/// re-seeds archived messages heard after it. The archive itself is left -/// alone — the device keeps carrying those messages for peers; the user -/// just doesn't want to see them again. +/// re-seeds archived messages heard after it. +/// +/// Clearing also erases the archive itself, so cleared history is gone from +/// disk rather than merely hidden from the timeline. The watermark still +/// matters afterwards: it keeps messages this device hears again from peers, +/// which predate the clear, from reappearing as echoes. enum MeshEchoSettings { private static let clearedThroughKey = "meshEchoes.clearedThrough" diff --git a/bitchat/Services/MessageDeduplicationService.swift b/bitchat/Services/MessageDeduplicationService.swift index 66d7e57b..c6773461 100644 --- a/bitchat/Services/MessageDeduplicationService.swift +++ b/bitchat/Services/MessageDeduplicationService.swift @@ -172,8 +172,8 @@ final class MessageDeduplicationService { /// Cache for Nostr ACK deduplication (messageId:ackType:senderPubkey format) private let nostrAckCache: LRUDeduplicationCache - /// Optional cross-launch persistence for the Nostr event cache. NIP-59 - /// randomizes gift-wrap timestamps, so DM subscriptions look back 24h and + /// Optional cross-launch persistence for the Nostr event cache. BitChat + /// randomizes private-envelope timestamps, so DM subscriptions look back 24h and /// relays redeliver the same events on every launch; without this record /// each relaunch reprocesses old PMs and acks. Nil (tests, macOS callers /// that don't opt in) keeps the cache purely in-memory. @@ -314,7 +314,7 @@ final class MessageDeduplicationService { // MARK: - Clear /// Clears all caches. This is the wipe/panic path: the persisted - /// gift-wrap record goes with everything else. + /// private-envelope record goes with everything else. func clearAll() { contentCache.clear() nostrEventCache.clear() @@ -325,7 +325,7 @@ final class MessageDeduplicationService { /// Clears only the in-memory Nostr caches (events and ACKs). Runs on /// every geohash channel switch, so the disk record deliberately - /// survives — wiping it here would forfeit cross-launch gift-wrap dedup + /// survives — wiping it here would forfeit cross-launch private-envelope dedup /// each time the user changes channels (flagged by Codex on #1398). func clearNostrCaches() { nostrEventCache.clear() diff --git a/bitchat/Services/MessageRouter.swift b/bitchat/Services/MessageRouter.swift index fbc6d694..a070c7bf 100644 --- a/bitchat/Services/MessageRouter.swift +++ b/bitchat/Services/MessageRouter.swift @@ -34,6 +34,15 @@ struct CourierDirectory { final class MessageRouter { typealias QueuedMessage = MessageOutboxStore.QueuedMessage + private struct PeerMessageKey: Hashable { + // periphery:ignore - read only via the synthesized Hashable + // conformance (dictionary-key identity), which the indexer + // cannot attribute; see retain_codable_properties in .periphery.yml + // for the same class of false positive. + let peerID: PeerID + let messageID: String + } + private let transports: [Transport] private let now: () -> Date private let courierDirectory: CourierDirectory @@ -104,15 +113,25 @@ final class MessageRouter { } private var bridgeSweepTask: Task? - private var bridgeDepositsInFlight = Set() + private var bridgeDepositsInFlight = Set() 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() // Outbox limits to prevent unbounded memory growth private static let maxMessagesPerPeer = 100 private static let messageTTLSeconds: TimeInterval = 24 * 60 * 60 // 24 hours - // Bound resends of messages sent on a weak reachability signal that never - // get a delivery ack (e.g. peer on an old client that doesn't ack). + // Bound actual sends that never receive an ack, whether they used weak + // 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 // Redundant couriers improve delivery odds; receivers dedup by message ID. private static let maxCouriersPerMessage = 3 @@ -171,15 +190,28 @@ final class MessageRouter { // MARK: - Message Sending 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) { - // A live link that can complete an encrypted delivery is a - // strong delivery signal; trust it outright. + // Even an established Noise session can be stale after the peer + // 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) transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) return } - let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: now(), sendAttempts: 1) if let transport = connectedTransport(for: peerID) { // "Connected" without an established secure session is forgeable: // link bindings heal on signature-verified "direct" announces, but @@ -197,8 +229,9 @@ final class MessageRouter { // deposit is cleared on ack. Don't "optimize" the courier call // away. 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) + secureTransmissions.remove(PeerMessageKey(peerID: peerID, messageID: messageID)) + transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) attemptCourierDeposit(messageID: messageID, for: peerID) return } @@ -209,8 +242,8 @@ final class MessageRouter { // Send now, but retain a copy until a delivery/read ack clears it; // 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) - transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) enqueue(message, for: peerID) + transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) // "Reachable" without prompt delivery means the send only joined // a queue (Nostr with relays down): also hand a sealed copy to // any connected couriers rather than waiting for internet that @@ -333,11 +366,12 @@ final class MessageRouter { for peerID: PeerID, recipientKey: Data ) { + let inFlightKey = PeerMessageKey(peerID: peerID, messageID: message.messageID) 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 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 // relay confirmation was in flight; do not regress its UI state. guard succeeded, self.queuedMessage(message.messageID, for: peerID) != nil else { return } @@ -356,8 +390,23 @@ final class MessageRouter { // 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) { + clearRetainedMessage(messageID) + } + + /// 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) { + guard !peerIDs.isEmpty else { return } + _ = markDelivered(messageID, for: Array(peerIDs)) + } + + private func clearRetainedMessage(_ messageID: String) { var cleared = false for (peerID, queue) in outbox { let filtered = queue.filter { $0.messageID != messageID } @@ -365,6 +414,10 @@ final class MessageRouter { outbox[peerID] = filtered.isEmpty ? nil : filtered cleared = true } + let matchingSecureTransmissions = secureTransmissions.filter { + $0.messageID == messageID + } + secureTransmissions.subtract(matchingSecureTransmissions) // The durable snapshot may still be hidden by protected data. Record // the ack even when this cold-load view cannot find the message, then // persist the current view so the store retains a removal tombstone. @@ -375,6 +428,35 @@ final class MessageRouter { persistOutbox() } + /// A delivery or read ack authenticated to one account confirms receipt + /// only for that account's transport aliases. A colliding message ID + /// queued for another peer must remain retained. + @discardableResult + func markDelivered(_ messageID: String, for peerAliases: [PeerID]) -> Bool { + let peerIDs = Set(peerAliases) + guard !peerIDs.isEmpty else { return false } + + var cleared = false + for peerID in peerIDs { + guard let queue = outbox[peerID] else { continue } + let filtered = queue.filter { $0.messageID != messageID } + guard filtered.count != queue.count else { continue } + outbox[peerID] = filtered.isEmpty ? nil : filtered + cleared = true + } + for peerID in peerIDs { + secureTransmissions.remove(PeerMessageKey(peerID: peerID, messageID: messageID)) + } + // Preserve the scoped ack even when protected data hides the durable + // queue during a cold launch. + outboxStore?.recordRemoval(messageID: messageID, for: peerIDs) + if cleared { + metrics?.record(.outboxDelivered) + } + persistOutbox() + return cleared + } + private func enqueue(_ message: QueuedMessage, for peerID: PeerID) { var message = message var queue = outbox[peerID] ?? [] @@ -398,7 +480,34 @@ final class MessageRouter { 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) { + secureTransmissions.remove(PeerMessageKey(peerID: peerID, messageID: messageID)) metrics?.record(.outboxDropped) onMessageDropped?(messageID, peerID) } @@ -442,6 +551,7 @@ final class MessageRouter { /// Panic wipe: forget queued mail on disk and in memory. func wipeOutbox() { outbox.removeAll() + secureTransmissions.removeAll() outboxStore?.wipe() } @@ -469,26 +579,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() + var retriedMessageIDs = Set() + 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) { guard let queued = outbox[peerID], !queued.isEmpty else { return } SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session) let now = now() - var remaining: [QueuedMessage] = [] + var outboxChanged = false 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) 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) - dropMessage(message.messageID, for: peerID) + if removeQueuedMessage(message.messageID, for: peerID) { + dropMessage(message.messageID, for: peerID) + outboxChanged = true + } continue } 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) + secureTransmissions.insert( + PeerMessageKey(peerID: peerID, messageID: message.messageID) + ) transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID) metrics?.record(.outboxResent) + outboxChanged = incrementSendAttemptsIfQueued(message.messageID, for: peerID) || outboxChanged } else if let transport = connectedTransport(for: peerID) { // "Connected" without a secure session — possibly a stolen // binding from a replayed announce: send (a genuine link @@ -501,9 +745,11 @@ final class MessageRouter { // preserve. Retention stays bounded by the 24h outbox TTL // 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) + secureTransmissions.remove( + PeerMessageKey(peerID: peerID, messageID: message.messageID) + ) transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID) metrics?.record(.outboxResent) - remaining.append(message) } else if let transport = reachableTransport(for: peerID) { // Reachability without a connection is a freshness heuristic, // so the send can silently go nowhere: send but keep retaining @@ -511,26 +757,22 @@ final class MessageRouter { // 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) - dropMessage(message.messageID, for: peerID) + if removeQueuedMessage(message.messageID, for: peerID) { + dropMessage(message.messageID, for: peerID) + outboxChanged = true + } continue } 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) metrics?.record(.outboxResent) - var retained = message - retained.sendAttempts += 1 - remaining.append(retained) - } else { - remaining.append(message) + outboxChanged = incrementSendAttemptsIfQueued(message.messageID, for: peerID) || outboxChanged } } - if remaining.isEmpty { - outbox.removeValue(forKey: peerID) - } else { - outbox[peerID] = remaining + if outboxChanged { + persistOutbox() } - persistOutbox() } func flushAllOutbox() { diff --git a/bitchat/Services/NetworkActivationService.swift b/bitchat/Services/NetworkActivationService.swift index b8f5a7ba..acc1634b 100644 --- a/bitchat/Services/NetworkActivationService.swift +++ b/bitchat/Services/NetworkActivationService.swift @@ -42,13 +42,18 @@ final class NetworkActivationService: ObservableObject { private var cancellables = Set() private var started = false - private let torPreferenceKey = "networkActivationService.userTorEnabled" + /// Storage key for the Tor preference. Exposed as a `nonisolated` constant + /// so off-main callers can read the preference without hopping to the main + /// actor; see `persistedTorPreference(in:)`. + nonisolated static let torPreferenceKey = "networkActivationService.userTorEnabled" private var torAutoStartDesired: Bool = false private let storage: UserDefaults private let locationPermissionPublisher: AnyPublisher private let mutualFavoritesPublisher: AnyPublisher, Never> + private let selectedChannelPublisher: AnyPublisher private let permissionProvider: () -> LocationChannelManager.PermissionState private let mutualFavoritesProvider: () -> Set + private let locationChannelSelectedProvider: () -> Bool private let reachabilityMonitor: NetworkReachabilityMonitoring private let torController: NetworkActivationTorControlling // Resolved lazily: NostrRelayManager.init() reads NetworkActivationService.shared @@ -63,8 +68,13 @@ final class NetworkActivationService: ObservableObject { storage = .standard locationPermissionPublisher = LocationChannelManager.shared.$permissionState.eraseToAnyPublisher() mutualFavoritesPublisher = FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher() + selectedChannelPublisher = LocationChannelManager.shared.$selectedChannel.eraseToAnyPublisher() permissionProvider = { LocationChannelManager.shared.permissionState } mutualFavoritesProvider = { FavoritesPersistenceService.shared.mutualFavorites } + locationChannelSelectedProvider = { + if case .location = LocationChannelManager.shared.selectedChannel { return true } + return false + } reachabilityMonitor = NWPathReachabilityMonitor() torController = TorManager.shared relayControllerProvider = { NostrRelayManager.shared } @@ -78,6 +88,8 @@ final class NetworkActivationService: ObservableObject { mutualFavoritesPublisher: AnyPublisher, Never>, permissionProvider: @escaping () -> LocationChannelManager.PermissionState, mutualFavoritesProvider: @escaping () -> Set, + selectedChannelPublisher: AnyPublisher = Empty().eraseToAnyPublisher(), + locationChannelSelectedProvider: @escaping () -> Bool = { false }, reachabilityMonitor: NetworkReachabilityMonitoring, torController: NetworkActivationTorControlling, relayController: NetworkActivationRelayControlling, @@ -89,6 +101,8 @@ final class NetworkActivationService: ObservableObject { self.mutualFavoritesPublisher = mutualFavoritesPublisher self.permissionProvider = permissionProvider self.mutualFavoritesProvider = mutualFavoritesProvider + self.selectedChannelPublisher = selectedChannelPublisher + self.locationChannelSelectedProvider = locationChannelSelectedProvider self.reachabilityMonitor = reachabilityMonitor self.torController = torController self.relayControllerProvider = { relayController } @@ -96,11 +110,25 @@ final class NetworkActivationService: ObservableObject { self.notificationCenter = notificationCenter } + /// Whether Tor routing is switched on, read without main-actor isolation. + /// + /// This is the *preference*, not live Tor readiness. Background work that + /// must decide whether waiting for Tor is even meaningful needs the + /// preference: when someone has deliberately turned Tor off, requests are + /// intended to go direct, so waiting on a client that has been shut down + /// would only burn the bootstrap timeout. When the preference is on, callers + /// must still wait for readiness rather than falling back to clearnet. + nonisolated static func persistedTorPreference( + in defaults: UserDefaults = .standard + ) -> Bool { + defaults.object(forKey: torPreferenceKey) as? Bool ?? true + } + func start() { guard !started else { return } started = true - if let stored = storage.object(forKey: torPreferenceKey) as? Bool { + if let stored = storage.object(forKey: Self.torPreferenceKey) as? Bool { userTorEnabled = stored } else { userTorEnabled = true @@ -138,6 +166,16 @@ final class NetworkActivationService: ObservableObject { } .store(in: &cancellables) + // React to entering or leaving a location channel, which can flip the + // gate on its own for someone with no location permission and no + // mutual favorites. + selectedChannelPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.reevaluate() + } + .store(in: &cancellables) + // React to network reachability changes (debounced, unsatisfied-only). reachabilityMonitor.reachabilityPublisher .receive(on: DispatchQueue.main) @@ -154,10 +192,23 @@ final class NetworkActivationService: ObservableObject { .store(in: &cancellables) } + /// Stops all internet-facing work at the synchronous panic boundary. + /// `start()` may be called again only after the full wipe commits. + func stopForPanic() { + cancellables.removeAll() + started = false + reachabilityMonitor.stop() + activationAllowed = false + torAutoStartDesired = false + relayController.disconnect() + torController.setAutoStartAllowed(false) + applyTorState(torDesired: false) + } + func setUserTorEnabled(_ enabled: Bool) { guard enabled != userTorEnabled else { return } userTorEnabled = enabled - storage.set(enabled, forKey: torPreferenceKey) + storage.set(enabled, forKey: Self.torPreferenceKey) notificationCenter.post( name: .TorUserPreferenceChanged, object: nil, @@ -167,6 +218,7 @@ final class NetworkActivationService: ObservableObject { } private func reevaluate() { + guard started else { return } let allowed = effectiveAllowed() let torDesired = allowed && userTorEnabled let statusChanged = allowed != activationAllowed @@ -197,7 +249,14 @@ final class NetworkActivationService: ObservableObject { private func basePolicyAllowed() -> Bool { let permOK = permissionProvider() == .authorized let hasMutual = !mutualFavoritesProvider().isEmpty - return permOK || hasMutual + // Being in a location channel counts too. Teleporting into a geohash + // needs no location permission, so someone who denied location and has + // no mutual favorites could sit in a channel that never connects: the + // gate suppressed Tor and the relays, and nothing said why. The channel + // is itself an internet feature in active use, which is exactly what + // this gate is meant to detect. + let inLocationChannel = locationChannelSelectedProvider() + return permOK || hasMutual || inLocationChannel } /// Effective gate: base policy AND a usable network path. When there is diff --git a/bitchat/Services/NetworkReachabilityMonitor.swift b/bitchat/Services/NetworkReachabilityMonitor.swift index 53bb0677..a5ca134e 100644 --- a/bitchat/Services/NetworkReachabilityMonitor.swift +++ b/bitchat/Services/NetworkReachabilityMonitor.swift @@ -27,6 +27,8 @@ protocol NetworkReachabilityMonitoring: AnyObject { var reachabilityPublisher: AnyPublisher { get } /// Begin monitoring. Idempotent. func start() + /// Stop monitoring and discard pending debounce work. Idempotent. + func stop() } /// Pure debounce/decision logic for reachability, split out so it can be @@ -88,18 +90,6 @@ struct ReachabilityDebounce { } } -/// Always-reachable stub. Used as the default in tests and as the fallback on -/// platforms without the Network framework, so reachability never suppresses -/// startup by itself. -@MainActor -final class AlwaysReachableMonitor: NetworkReachabilityMonitoring { - var isReachable: Bool { true } - var reachabilityPublisher: AnyPublisher { - Empty(completeImmediately: false).eraseToAnyPublisher() - } - func start() {} -} - /// `NWPathMonitor`-backed reachability. All state lives on the main actor; the /// background path callback hops here before touching the debounce. @MainActor @@ -146,6 +136,18 @@ final class NWPathReachabilityMonitor: NetworkReachabilityMonitoring { #endif } + func stop() { + guard started else { return } + started = false + flushWorkItem?.cancel() + flushWorkItem = nil + #if canImport(Network) + monitor?.pathUpdateHandler = nil + monitor?.cancel() + monitor = nil + #endif + } + /// Feed an observation into the debounce and publish committed changes. /// Exposed internally so higher layers/tests could drive it if needed. func ingest(reachable: Bool) { diff --git a/bitchat/Services/NoiseEncryptionService.swift b/bitchat/Services/NoiseEncryptionService.swift index 3e0aae6b..1bf2b574 100644 --- a/bitchat/Services/NoiseEncryptionService.swift +++ b/bitchat/Services/NoiseEncryptionService.swift @@ -97,7 +97,7 @@ enum EncryptionStatus: Equatable { case noiseHandshaking // Currently establishing case noiseSecured // Established but not verified case noiseVerified // Established and verified - + var icon: String? { // Made optional to hide icon when no handshake switch self { case .none: @@ -165,7 +165,6 @@ final class NoiseEncryptionService { // Peer fingerprints (SHA256 hash of static public key) private var peerFingerprints: [PeerID: String] = [:] private var fingerprintToPeerID: [String: PeerID] = [:] - // Thread safety private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent) @@ -183,12 +182,27 @@ final class NoiseEncryptionService { // Callbacks private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication + private var onPeerAuthenticatedWithGenerationHandlers: [((PeerID, String, UUID) -> Void)] = [] var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake + /// Automatic rekey prepared XX message 1. The transport must claim the + /// exact attempt at its actual BLE handoff; a crossed inbound initiation + /// can invalidate the token before that point. + var onRekeyHandshakeReady: + ((_ peerID: PeerID, _ initiation: NoiseHandshakeInitiation) -> Void)? + var onHandshakeRecoveryRequired: + ((_ request: NoiseHandshakeRecoveryRequest) -> Void)? + /// An unauthenticated reconnect attempt failed or timed out and the + /// receive-only rollback session became the active transport again. + /// Transport queues may only be drained for this exact restored + /// generation when the reason is terminal; a restore that owns a pending + /// convergence retry must keep them parked until the retry concludes. + var onSessionRestoredWithGeneration: + ((_ peerID: PeerID, _ generation: UUID, _ reason: NoiseSessionRestoreReason) -> Void)? // Add a handler for peer authentication func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) { - serviceQueue.async(flags: .barrier) { [weak self] in - self?.onPeerAuthenticatedHandlers.append(handler) + serviceQueue.sync(flags: .barrier) { + onPeerAuthenticatedHandlers.append(handler) } } @@ -201,8 +215,30 @@ final class NoiseEncryptionService { } } } + + /// Generation-aware authentication notifications are used by protocols + /// whose state must be bound to one exact Noise transport session. + var onPeerAuthenticatedWithGeneration: ((PeerID, String, UUID) -> Void)? { + get { nil } + set { + guard let handler = newValue else { return } + serviceQueue.sync(flags: .barrier) { + onPeerAuthenticatedWithGenerationHandlers.append(handler) + } + } + } - init(keychain: KeychainManagerProtocol) { + init( + keychain: KeychainManagerProtocol, + ordinaryHandshakeTimeout: TimeInterval = + NoiseSecurityConstants.ordinaryHandshakeTimeout, + ordinaryResponderHandshakeTimeout: TimeInterval = + NoiseSecurityConstants.ordinaryResponderHandshakeTimeout, + recentInitiatorCompletionGracePeriod: TimeInterval = + NoiseSecurityConstants.recentInitiatorCompletionGracePeriod, + ordinaryReconnectRollbackCooldown: TimeInterval = + NoiseSecurityConstants.ordinaryReconnectRollbackCooldown + ) { self.keychain = keychain self.localPrekeys = LocalPrekeyStore(keychain: keychain) @@ -292,11 +328,31 @@ final class NoiseEncryptionService { self.signingPublicKey = signingKey.publicKey // Initialize session manager - self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain) + self.sessionManager = NoiseSessionManager( + localStaticKey: staticIdentityKey, + keychain: keychain, + ordinaryHandshakeTimeout: ordinaryHandshakeTimeout, + ordinaryResponderHandshakeTimeout: + ordinaryResponderHandshakeTimeout, + recentInitiatorCompletionGracePeriod: + recentInitiatorCompletionGracePeriod, + ordinaryReconnectRollbackCooldown: + ordinaryReconnectRollbackCooldown + ) // Set up session callbacks - sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in - self?.handleSessionEstablished(peerID: peerID, remoteStaticKey: remoteStaticKey) + sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey, generation in + self?.handleSessionEstablished( + peerID: peerID, + remoteStaticKey: remoteStaticKey, + sessionGeneration: generation + ) + } + sessionManager.onSessionRestored = { [weak self] peerID, generation, reason in + self?.onSessionRestoredWithGeneration?(peerID, generation, reason) + } + sessionManager.onHandshakeRecoveryRequired = { [weak self] request in + self?.onHandshakeRecoveryRequired?(request) } // Start session maintenance timer @@ -607,7 +663,7 @@ final class NoiseEncryptionService { guard let packetData = packet.toBinaryDataForSigning() else { return nil } - + // Sign with the noise private key (converted to Ed25519 for signing) guard let signature = signData(packetData) else { return nil @@ -661,9 +717,105 @@ final class NoiseEncryptionService { let handshakeData = try sessionManager.initiateHandshake(with: peerID) return handshakeData } + + /// Atomically admits and prepares one initial ordinary handshake. Returns + /// nil when another discovery callback already created a session. + func initiateHandshakeIfNeeded( + with peerID: PeerID, + retryOnTimeout: Bool = false + ) throws -> NoiseHandshakeInitiation? { + guard peerID.isValid else { + SecureLogger.warning(.authenticationFailed(peerID: peerID.id)) + throw NoiseSecurityError.invalidPeerID + } + + guard let initiation = try sessionManager.initiateHandshakeIfAbsent( + with: peerID, + notifyOnTimeout: retryOnTimeout, + authorize: { [rateLimiter] in + guard rateLimiter.allowHandshake(from: peerID) else { + SecureLogger.warning( + .authenticationFailed(peerID: "Rate limited: \(peerID)") + ) + throw NoiseSecurityError.rateLimitExceeded + } + } + ) else { + return nil + } + SecureLogger.info(.handshakeStarted(peerID: peerID.id)) + return initiation + } + + /// Atomically prepares an ordinary reconnect for a peer whose cached + /// transport belongs to an earlier physical link. Failed authorization or + /// handshake setup preserves the established session. + func initiateReconnectHandshake( + with peerID: PeerID, + retryOnTimeout: Bool = false + ) throws -> NoiseHandshakeInitiation { + guard peerID.isValid else { + SecureLogger.warning(.authenticationFailed(peerID: peerID.id)) + throw NoiseSecurityError.invalidPeerID + } + + return try sessionManager.initiateReconnectHandshake( + with: peerID, + notifyOnTimeout: retryOnTimeout, + authorize: { [rateLimiter] in + guard rateLimiter.allowHandshake(from: peerID) else { + SecureLogger.warning( + .authenticationFailed(peerID: "Rate limited: \(peerID)") + ) + throw NoiseSecurityError.rateLimitExceeded + } + } + ) + } + + func prepareHandshakeRecovery( + _ request: NoiseHandshakeRecoveryRequest + ) throws -> NoiseHandshakeRecoveryPreparation? { + try sessionManager.prepareHandshakeRecovery( + request, + authorizeAttempt: { [rateLimiter] in + guard rateLimiter.allowHandshake(from: request.peerID) else { + SecureLogger.warning( + .authenticationFailed( + peerID: "Rate limited: \(request.peerID)" + ) + ) + throw NoiseSecurityError.rateLimitExceeded + } + } + ) + } + + func cancelHandshakeRecovery(_ request: NoiseHandshakeRecoveryRequest) { + sessionManager.cancelHandshakeRecovery(request) + } + + func claimHandshakeInitiation( + _ initiation: NoiseHandshakeInitiation, + for peerID: PeerID + ) -> Data? { + sessionManager.claimHandshakeInitiation(initiation, for: peerID) + } /// Process an incoming handshake message func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? { + try processHandshakeMessageWithResult( + from: peerID, + message: message + ).response + } + + /// Process an incoming handshake message and report whether the exact + /// session that consumed it completed authenticated establishment. + func processHandshakeMessageWithResult( + from peerID: PeerID, + message: Data + ) throws -> NoiseHandshakeProcessingResult { // Validate peer ID guard peerID.isValid else { @@ -685,11 +837,14 @@ final class NoiseEncryptionService { // For handshakes, we process the raw data directly without NoiseMessage wrapper // The Noise protocol handles its own message format - let responsePayload = try sessionManager.handleIncomingHandshake(from: peerID, message: message) + let result = try sessionManager.handleIncomingHandshakeWithResult( + from: peerID, + message: message + ) // Return raw response without wrapper - return responsePayload + return result } /// Check if we have an established session with a peer @@ -701,6 +856,13 @@ final class NoiseEncryptionService { func hasSession(with peerID: PeerID) -> Bool { return sessionManager.getSession(for: peerID) != nil } + + /// True while an inbound ordinary XX responder is waiting for message 3. + /// A small amount of immediately-following ciphertext may arrive first + /// over BLE and must be retried only after responder promotion. + func isAwaitingResponderHandshakeCompletion(with peerID: PeerID) -> Bool { + sessionManager.isAwaitingResponderHandshakeCompletion(for: peerID) + } // MARK: - Encryption/Decryption @@ -725,25 +887,87 @@ final class NoiseEncryptionService { return try sessionManager.encrypt(data, for: peerID) } - - /// Decrypt data from a specific peer - func decrypt(_ data: Data, from peerID: PeerID) throws -> Data { - // Validate message size - guard NoiseSecurityValidator.validateMessageSize(data) else { + + /// Encrypts a finalized private-media packet. Ordinary Noise application + /// messages retain the 64 KiB ceiling; this purpose-specific path permits + /// the bounded `BitchatFilePacket` envelope and refuses every other typed + /// payload so the larger allocation budget cannot become a generic bypass. + func encryptPrivateFilePayload( + _ data: Data, + for peerID: PeerID, + sessionGeneration: UUID? = nil + ) throws -> Data { + guard NoisePayloadType.isPrivateFile(rawValue: data.first), + NoiseSecurityValidator.validatePrivateFileMessageSize(data) else { throw NoiseSecurityError.messageTooLarge } - - // Check rate limit + guard rateLimiter.allowMessage(from: peerID) else { throw NoiseSecurityError.rateLimitExceeded } - - // Check if we have an established session + guard hasEstablishedSession(with: peerID) else { + onHandshakeRequired?(peerID) + throw NoiseEncryptionError.handshakeRequired + } + + // `maxPrivateFilePlaintextSize` already subtracts the cipher's fixed + // nonce/tag overhead, so the result is bounded without a second copy. + if let sessionGeneration { + return try sessionManager.encrypt( + data, + for: peerID, + expectedSessionGeneration: sessionGeneration + ) + } + return try sessionManager.encrypt(data, for: peerID) + } + + /// Decrypt data from a specific peer + func decrypt(_ data: Data, from peerID: PeerID) throws -> Data { + try decryptWithSessionGeneration(data, from: peerID).plaintext + } + + func decryptWithSessionGeneration( + _ data: Data, + from peerID: PeerID, + establishedGenerationIsReady: (UUID) -> Bool = { _ in true } + ) throws -> (plaintext: Data, sessionGeneration: UUID) { + // Standard transport ciphertext has 20 bytes of nonce/tag overhead. + // A larger ciphertext is admitted only up to the framed-file ceiling; + // after authenticated decryption it must prove it is `.privateFile`. + let isStandardCiphertext = NoiseSecurityValidator.validateCiphertextSize(data) + let isAdmittedCiphertext = isStandardCiphertext + || NoiseSecurityValidator.validatePrivateFileCiphertextSize(data) + + // A quarantined transport is deliberately unavailable for outbound + // state, but remains receive-only until the responder proves identity + // or the bounded rollback restores it. + guard sessionManager.hasReceiveSession(for: peerID) else { throw NoiseEncryptionError.sessionNotEstablished } - return try sessionManager.decrypt(data, from: peerID) + let result = try sessionManager.decryptWithSessionGeneration( + data, + from: peerID, + establishedGenerationIsReady: + establishedGenerationIsReady, + authorizeDecrypt: { [rateLimiter] in + guard isAdmittedCiphertext else { + throw NoiseSecurityError.messageTooLarge + } + guard rateLimiter.allowMessage(from: peerID) else { + throw NoiseSecurityError.rateLimitExceeded + } + } + ) + if !isStandardCiphertext { + guard NoisePayloadType.isPrivateFile(rawValue: result.plaintext.first), + NoiseSecurityValidator.validatePrivateFileMessageSize(result.plaintext) else { + throw NoiseSecurityError.messageTooLarge + } + } + return result } // MARK: - Peer Management @@ -755,6 +979,25 @@ final class NoiseEncryptionService { } } + func sessionGeneration(for peerID: PeerID) -> UUID? { + sessionManager.sessionGeneration(for: peerID) + } + + /// Runs `body` while holding a read lease on the exact session generation. + /// Session insertion, replacement, and removal use the same manager + /// barrier, so they cannot interleave with an authenticated-state commit. + func withCurrentSessionGeneration( + for peerID: PeerID, + expected: UUID, + _ body: () -> Result + ) -> Result? { + sessionManager.withCurrentSessionGeneration( + for: peerID, + expected: expected, + body + ) + } + func clearEphemeralStateForPanic() { sessionManager.removeAllSessions() serviceQueue.sync(flags: .barrier) { @@ -777,24 +1020,36 @@ final class NoiseEncryptionService { // MARK: - Private Helpers - private func handleSessionEstablished(peerID: PeerID, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) { + private func handleSessionEstablished( + peerID: PeerID, + remoteStaticKey: Curve25519.KeyAgreement.PublicKey, + sessionGeneration: UUID + ) { // Calculate fingerprint let fingerprint = remoteStaticKey.rawRepresentation.sha256Fingerprint() - // Store fingerprint mapping - serviceQueue.sync(flags: .barrier) { + // Registering handlers is synchronous, and this barrier snapshots them + // with the fingerprint update. Invoke the snapshot outside the queue: + // parallel Swift Testing workers must not block behind queued callback + // registration or allow a handler to re-enter serviceQueue. + let handlers: ( + generationAware: [(PeerID, String, UUID) -> Void], + legacy: [(PeerID, String) -> Void] + ) = serviceQueue.sync(flags: .barrier) { peerFingerprints[peerID] = fingerprint fingerprintToPeerID[fingerprint] = peerID + return (onPeerAuthenticatedWithGenerationHandlers, onPeerAuthenticatedHandlers) } // Log security event SecureLogger.info(.handshakeCompleted(peerID: peerID.id)) - // Notify all handlers about authentication - serviceQueue.async { [weak self] in - self?.onPeerAuthenticatedHandlers.forEach { handler in - handler(peerID, fingerprint) - } + // Notify all handlers about authentication. + handlers.generationAware.forEach { handler in + handler(peerID, fingerprint, sessionGeneration) + } + handlers.legacy.forEach { handler in + handler(peerID, fingerprint) } } @@ -815,19 +1070,26 @@ final class NoiseEncryptionService { let sessionsNeedingRekey = sessionManager.getSessionsNeedingRekey() for (peerID, needsRekey) in sessionsNeedingRekey where needsRekey { - - // Attempt to rekey the session do { - try sessionManager.initiateRekey(for: peerID) - SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security) - - // Signal that handshake is needed - onHandshakeRequired?(peerID) + try initiateAutomaticRekey(for: peerID) } catch { SecureLogger.error(error, context: "Failed to initiate rekey for peer: \(peerID)", category: .session) } } } + + private func initiateAutomaticRekey(for peerID: PeerID) throws { + let initiation = try sessionManager.initiateRekey(for: peerID) + SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security) + onRekeyHandshakeReady?(peerID, initiation) + onHandshakeRequired?(peerID) + } + + #if DEBUG + func _test_initiateAutomaticRekey(for peerID: PeerID) throws { + try initiateAutomaticRekey(for: peerID) + } + #endif deinit { stopRekeyTimer() @@ -915,6 +1177,9 @@ struct NoiseMessage: Codable { enum NoiseEncryptionError: Error { case handshakeRequired case sessionNotEstablished + /// Manager keys are established or restored, but BLE has not installed + /// generation-bound transport state. No receive nonce was consumed. + case transportGenerationNotReady /// Envelope references a prekey ID we don't hold (never ours, already /// deleted after its grace window, or wiped in a panic). case unknownPrekey diff --git a/bitchat/Services/NostrProcessedEventStore.swift b/bitchat/Services/NostrProcessedEventStore.swift index 167d29d9..ee70f0e7 100644 --- a/bitchat/Services/NostrProcessedEventStore.swift +++ b/bitchat/Services/NostrProcessedEventStore.swift @@ -9,8 +9,9 @@ import BitLogger import Foundation -/// Disk persistence for processed gift-wrap event IDs. NIP-59 randomizes -/// gift-wrap timestamps, so DM subscriptions must look back generously (24h) +/// Disk persistence for processed private-envelope event IDs. BitChat +/// randomizes envelope timestamps, so DM subscriptions must look back +/// generously (24h) /// and relays redeliver the same events on every launch — without a /// cross-launch record, each relaunch reprocesses old PMs and acks /// (re-sent DELIVERED bursts, "delivered ack for unknown mid" noise). diff --git a/bitchat/Services/NotificationPrivacySettings.swift b/bitchat/Services/NotificationPrivacySettings.swift new file mode 100644 index 00000000..da67c30b --- /dev/null +++ b/bitchat/Services/NotificationPrivacySettings.swift @@ -0,0 +1,44 @@ +// +// NotificationPrivacySettings.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +/// Controls how much a delivered notification says while the device is locked. +/// +/// Notification content is rendered by the system on the lock screen, so it is +/// readable by anyone holding the phone without unlocking it. With previews +/// hidden, alerts still say that something arrived and stay tappable, but the +/// message body, the sender's nickname, and the geohash are withheld until the +/// app is opened. +/// +/// Defaults to hidden: a locked phone lying on a table or taken at a protest +/// should not narrate conversations, and someone who wants previews can say so. +enum NotificationPrivacySettings { + private static let hidePreviewsKey = "notifications.hideMessagePreviews" + + static var hideMessagePreviews: Bool { + get { hideMessagePreviews(in: .standard) } + set { setHideMessagePreviews(newValue, in: .standard) } + } + + /// Store-injecting forms, so tests can assert the default and both settings + /// without touching the shared preferences other tests read. + static func hideMessagePreviews(in defaults: UserDefaults) -> Bool { + defaults.object(forKey: hidePreviewsKey) as? Bool ?? true + } + + static func setHideMessagePreviews(_ hide: Bool, in defaults: UserDefaults) { + defaults.set(hide, forKey: hidePreviewsKey) + } + + /// Panic-wipe hook. Removing the key restores the hidden default, so a + /// wiped device cannot come back louder than a fresh install. + static func reset(in defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: hidePreviewsKey) + } +} diff --git a/bitchat/Services/NotificationService.swift b/bitchat/Services/NotificationService.swift index 4b437798..dc8ef193 100644 --- a/bitchat/Services/NotificationService.swift +++ b/bitchat/Services/NotificationService.swift @@ -95,6 +95,35 @@ final class NotificationService { static let nearbyCategoryID = "chat.bitchat.category.nearby" static let waveActionID = "chat.bitchat.action.wave" + /// Copy used when `NotificationPrivacySettings.hideMessagePreviews` is on. + /// These say that something arrived without naming who sent it, quoting it, + /// or disclosing which geohash it came from. + private enum Redacted { + static var directMessageTitle: String { + String(localized: "notification.redacted.dm.title", defaultValue: "🔒 new dm", comment: "Lock-screen notification title for a received direct message when message previews are hidden; deliberately names neither the sender nor the content") + } + static var mentionTitle: String { + String(localized: "notification.redacted.mention.title", defaultValue: "🫵 you were mentioned", comment: "Lock-screen notification title telling someone they were mentioned when message previews are hidden; deliberately omits who mentioned them") + } + static var geohashActivityTitle: String { + String(localized: "notification.redacted.geohash.title", defaultValue: "📍 new activity nearby", comment: "Lock-screen notification title for activity in a location channel when message previews are hidden; deliberately omits the geohash") + } + static var body: String { + String(localized: "notification.redacted.body", defaultValue: "open bitchat to read", comment: "Lock-screen notification body shown in place of the message text when message previews are hidden") + } + } + + /// Whether delivered alerts must withhold sender, content, and geohash. + /// + /// Injected rather than read from the preference directly so tests state + /// which behavior they are asserting instead of inheriting whatever the + /// shared preference happens to hold when they run. + private let hidePreviewsProvider: () -> Bool + + private var hidePreviews: Bool { + hidePreviewsProvider() + } + private let isRunningTestsProvider: () -> Bool private let authorizer: NotificationAuthorizing private let requestDeliverer: NotificationRequestDelivering @@ -106,6 +135,7 @@ final class NotificationService { } private init() { + self.hidePreviewsProvider = { NotificationPrivacySettings.hideMessagePreviews } self.isRunningTestsProvider = { let env = ProcessInfo.processInfo.environment return NSClassFromString("XCTestCase") != nil || @@ -130,12 +160,14 @@ final class NotificationService { isRunningTestsProvider: @escaping () -> Bool, authorizer: NotificationAuthorizing, requestDeliverer: NotificationRequestDelivering, - categoryRegistrar: NotificationCategoryRegistering = NoopNotificationCategoryRegistrar() + categoryRegistrar: NotificationCategoryRegistering = NoopNotificationCategoryRegistrar(), + hidePreviewsProvider: @escaping () -> Bool = { NotificationPrivacySettings.hideMessagePreviews } ) { self.isRunningTestsProvider = isRunningTestsProvider self.authorizer = authorizer self.requestDeliverer = requestDeliverer self.categoryRegistrar = categoryRegistrar + self.hidePreviewsProvider = hidePreviewsProvider } func requestAuthorization() { @@ -197,29 +229,34 @@ final class NotificationService { } func sendMentionNotification(from sender: String, message: String) { - let title = "🫵 you were mentioned by \(sender)" - let body = message + let title = hidePreviews ? Redacted.mentionTitle : "🫵 you were mentioned by \(sender)" + let body = hidePreviews ? Redacted.body : message let identifier = "mention-\(UUID().uuidString)" - + sendLocalNotification(title: title, body: body, identifier: identifier) } - + func sendPrivateMessageNotification(from sender: String, message: String, peerID: PeerID) { - let title = "🔒 DM from \(sender)" - let body = message + let title = hidePreviews ? Redacted.directMessageTitle : "🔒 DM from \(sender)" + let body = hidePreviews ? Redacted.body : message let identifier = "private-\(UUID().uuidString)" + // Routing payload, not display copy: `userInfo` never reaches the lock + // screen, and the conversation to open still has to be identifiable. let userInfo = ["peerID": peerID.id, "senderName": sender] - + sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo) } - + // Geohash public chat notification with deep link to a specific geohash func sendGeohashActivityNotification(geohash: String, titlePrefix: String = "#", bodyPreview: String) { - let title = "\(titlePrefix)\(geohash)" + // The geohash itself is location data, so hiding previews withholds it + // from the alert while leaving the deep link intact for the tap. + let title = hidePreviews ? Redacted.geohashActivityTitle : "\(titlePrefix)\(geohash)" + let body = hidePreviews ? Redacted.body : bodyPreview let identifier = "geo-activity-\(geohash)-\(Date().timeIntervalSince1970)" let deeplink = "bitchat://geohash/\(geohash)" let userInfo: [String: Any] = ["deeplink": deeplink] - sendLocalNotification(title: title, body: bodyPreview, identifier: identifier, userInfo: userInfo) + sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo) } func sendNetworkAvailableNotification(peerCount: Int) { diff --git a/bitchat/Services/SharedContentHandoff.swift b/bitchat/Services/SharedContentHandoff.swift new file mode 100644 index 00000000..0e3dce83 --- /dev/null +++ b/bitchat/Services/SharedContentHandoff.swift @@ -0,0 +1,212 @@ +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) + } + } +} diff --git a/bitchat/Services/TransferProgressManager.swift b/bitchat/Services/TransferProgressManager.swift index 4c6a34d8..13c4c254 100644 --- a/bitchat/Services/TransferProgressManager.swift +++ b/bitchat/Services/TransferProgressManager.swift @@ -11,6 +11,7 @@ final class TransferProgressManager { case updated(id: String, sentFragments: Int, totalFragments: Int) case completed(id: String, totalFragments: Int) case cancelled(id: String, sentFragments: Int, totalFragments: Int) + case rejected(id: String, reason: String) } private let subject = PassthroughSubject() @@ -49,6 +50,17 @@ final class TransferProgressManager { } } + /// Fails a preflight check while keeping the outgoing placeholder visible + /// with an actionable reason instead of treating policy/size rejection as + /// a user cancellation. + func rejectBeforeStart(id: String, reason: String) { + queue.async(flags: .barrier) { [weak self] in + guard let self = self else { return } + self.states.removeValue(forKey: id) + self.subject.send(.rejected(id: id, reason: reason)) + } + } + func snapshot(id: String) -> (sent: Int, total: Int)? { var result: (sent: Int, total: Int)? queue.sync { diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index c7094bc1..76e6406f 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -83,10 +83,52 @@ enum TransportEvent: @unchecked Sendable { case bluetoothStateUpdated(CBManagerState) } +/// Downgrade-safe decision for a private-media recipient. Callers ask before +/// prompting, and BLEService checks again when it consumes any one-shot +/// legacy consent. +enum PrivateMediaSendPolicy: Equatable { + case encrypted + /// A public announce hinted at encrypted media (or a prior authenticated + /// pin exists), but this exact Noise session has not yet supplied its + /// authenticated peer-state proof. Callers wait boundedly; they must not + /// pre-queue encrypted bytes or silently select the legacy path. + case awaitingCapabilityProof + case legacyRequiresConsent + case blockedDowngrade +} + +/// Receiver-only persistence surface for explicit private-media deletion. +/// Kept separate from `Transport` so sender retry branches can rebase without +/// inheriting or implementing receiver storage concerns. +protocol PrivateMediaDeletionPersisting: AnyObject { + @MainActor + func persistDeletedPrivateMedia( + messageIDs: [String], + payloadRelativePaths: [String: String], + protectedPayloadRelativePaths: Set, + 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 { @MainActor func didReceiveTransportEvent(_ event: TransportEvent) } +/// Optional typed-event contract for sinks that can synchronously decide +/// whether an inbound message was accepted. +protocol SynchronousMessageTransportEventDelegate: TransportEventDelegate { + @MainActor + func didReceiveTransportMessageSynchronously(_ message: BitchatMessage) -> Bool +} + protocol Transport: AnyObject { // Event sink var delegate: BitchatDelegate? { get set } @@ -163,6 +205,20 @@ protocol Transport: AnyObject { func sendDeliveryAck(for messageID: String, to peerID: PeerID) func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) + func sendFilePrivate( + _ packet: BitchatFilePacket, + to peerID: PeerID, + transferId: String, + allowLegacyFallback: Bool + ) + /// Automatic whole-file retry is admitted only while this exact Noise + /// generation authenticates bit 9. It must never queue across a session + /// replacement or enter the signed raw legacy path. + func sendFilePrivateReceiptRetry( + _ packet: BitchatFilePacket, + to peerID: PeerID, + transferId: String + ) func cancelTransfer(_ transferId: String) // Live voice / push-to-talk (mesh transports only): one encoded @@ -208,6 +264,16 @@ protocol Transport: AnyObject { /// Capabilities the peer advertised in its last verified announce; /// empty for peers that predate the capabilities TLV. func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities + func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy + /// The exact current Noise generation that authenticated both encrypted + /// private media (bit 8) and durable receipts/retry (bit 9). + func authenticatedPrivateMediaReceiptSessionGeneration( + to peerID: PeerID + ) -> UUID? + func resolvePrivateMediaSendPolicy( + to peerID: PeerID, + completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void + ) /// Sends an encoded vouch-attestation batch inside the Noise session. func sendVouchAttestations(_ payload: Data, to peerID: PeerID) /// Appends a peer-authenticated observer. Unlike @@ -227,6 +293,9 @@ protocol Transport: AnyObject { /// Drops any carried public messages from a (newly blocked) sender so /// they can't resurface as archived echoes on a later launch. func purgeArchivedPublicMessages(from peerID: PeerID) + /// Erases the whole carried public-message archive, on disk included, so + /// clearing the mesh timeline deletes that history rather than hiding it. + func purgeAllArchivedPublicMessages() } /// A carried public mesh message from the store-and-forward window, decoded @@ -278,6 +347,21 @@ extension Transport { func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {} func broadcastGroupMessage(_ envelope: Data) {} func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] } + func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { .blockedDowngrade } + func authenticatedPrivateMediaReceiptSessionGeneration( + to peerID: PeerID + ) -> UUID? { + nil + } + func resolvePrivateMediaSendPolicy( + to peerID: PeerID, + completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void + ) { + let policy = privateMediaSendPolicy(to: peerID) + Task { @MainActor in + completion(policy == .awaitingCapabilityProof ? .blockedDowngrade : policy) + } + } func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {} func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {} func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false } @@ -294,6 +378,20 @@ extension Transport { func currentMeshTopology() -> MeshTopologySnapshot? { nil } func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {} func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {} + func sendFilePrivate( + _ packet: BitchatFilePacket, + to peerID: PeerID, + transferId: String, + allowLegacyFallback: Bool + ) { + guard !allowLegacyFallback else { return } + sendFilePrivate(packet, to: peerID, transferId: transferId) + } + func sendFilePrivateReceiptRetry( + _ packet: BitchatFilePacket, + to peerID: PeerID, + transferId: String + ) {} func cancelTransfer(_ transferId: String) {} func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) { @@ -308,6 +406,7 @@ extension Transport { } func purgeArchivedPublicMessages(from peerID: PeerID) {} + func purgeAllArchivedPublicMessages() {} } protocol TransportPeerEventsDelegate: AnyObject { diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index a7503229..98029094 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -9,6 +9,19 @@ enum TransportConfig { static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends + // Bounded wait for the session-authenticated capability proof used by + // private-media migration. Expiry never auto-sends clear bytes; it only + // resolves to the existing one-shot consent or downgrade-blocked path. + static let privateMediaCapabilityProofTimeoutSeconds: TimeInterval = 5 + static let privateMediaCapabilityProofPendingPeerCap: Int = 64 + static let privateMediaCapabilityProofWaitersPerPeerCap: Int = 16 + /// Accepted private-media receipts and explicit-deletion tombstones each + /// receive this independent capacity. + static let privateMediaReceivedLedgerCapacity: Int = 4_096 + /// A bounded retry horizon prevents stable receipt state from growing into + /// permanent application history. + static let privateMediaReceivedLedgerTTLSeconds: TimeInterval = + 7 * 24 * 60 * 60 static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays // Fragment relay TTL in sparse graphs; matches messageTTLDefault so media @@ -87,6 +100,26 @@ enum TransportConfig { static let nostrMaxEventTags: Int = 64 static let nostrMaxEventTagValues: Int = 16 static let nostrMaxEventTagValueBytes: Int = 1024 + // Bounded per-relay inbound frame buffer. Each relay connection owns its + // own serial verify pipeline; if a relay floods faster than its Schnorr + // verification drains, the oldest buffered frames for THAT relay are + // dropped (bufferingNewest) so one relay cannot stall other relays. + // Nostr inbound is already best-effort (relays are redundant and events + // replay), so dropping a flooding relay's backlog is safe. Together with + // nostrInboundMaxFrameBytes this caps buffered inbound bytes at + // cap × maxFrameBytes (128 MiB) per hostile relay — bounded, not zero. + static let nostrInboundPerRelayBufferCap: Int = 256 + // Hard per-frame byte bound, applied as URLSessionWebSocketTask + // .maximumMessageSize (oversized frames fail the receive instead of + // buffering). BitChat's legitimate Nostr traffic is small: geohash chat / + // presence events (kind 20000/20001), kind-1 notes, and NIP-17 + // gift-wrapped DMs carrying text payloads or receipts are all a few KiB, + // and most public relays reject events beyond ~64–256 KiB anyway. 512 KiB + // leaves an order-of-magnitude margin over anything we produce or expect + // while halving the URLSession default (1 MiB), so a hostile relay's + // worst-case buffered pile-up per connection is + // nostrInboundPerRelayBufferCap × 512 KiB = 128 MiB instead of 256 MiB. + static let nostrInboundMaxFrameBytes: Int = 512 * 1024 // Conversation store diagnostics (field observability) // Sample interval for the periodic store-audit "OK" heartbeat line @@ -114,7 +147,6 @@ enum TransportConfig { // UI sleeps/delays static let uiStartupInitialDelaySeconds: TimeInterval = 1.0 static let uiStartupPhaseDurationSeconds: TimeInterval = 2.0 - static let uiAsyncShortSleepNs: UInt64 = 100_000_000 static let uiReadReceiptRetryShortSeconds: TimeInterval = 0.1 static let uiReadReceiptRetryLongSeconds: TimeInterval = 0.5 static let uiBatchDispatchStaggerSeconds: TimeInterval = 0.15 @@ -227,7 +259,7 @@ enum TransportConfig { // Fallback deadline for treating a subscription's initial fetch as complete // when a relay never sends EOSE (generous to cover Tor circuit setup). static let nostrSubscriptionEOSEFallbackSeconds: TimeInterval = 10.0 - // A bridge drop is durable only after NIP-20 OK. Relays that omit OK must + // A bridge drop is durable only after NIP-01 `OK`. Relays that omit OK must // not pin the router's in-flight state indefinitely. static let nostrConfirmedSendAckTimeoutSeconds: TimeInterval = 10.0 // After this long, a relay marked permanently failed gets another chance. @@ -326,7 +358,6 @@ enum TransportConfig { // Share extension static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0 - static let uiShareAcceptWindowSeconds: TimeInterval = 30.0 static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60 // Gossip Sync Configuration diff --git a/bitchat/Sync/GossipSyncManager.swift b/bitchat/Sync/GossipSyncManager.swift index 62f6394e..eeb1ff97 100644 --- a/bitchat/Sync/GossipSyncManager.swift +++ b/bitchat/Sync/GossipSyncManager.swift @@ -733,6 +733,26 @@ final class GossipSyncManager { } } + /// Drop every carried public message and clear the archive on disk. + /// + /// Used when someone clears the mesh timeline: the watermark already stops + /// cleared messages from being shown again, so anything left in the + /// archive is retained purely to serve other peers — and a person who + /// clears a timeline reasonably reads that as "this is gone from my + /// phone". The cost is that this device stops offering the recent public + /// backlog to peers until it hears fresh traffic. + func removeAllPublicMessages() { + queue.async { [weak self] in + guard let self else { return } + self.messages.remove { _ in true } + self.archiveDirty = true + // Persist now rather than waiting for maintenance: a relaunch in + // the gap would restore the purged messages from disk. + self.persistArchiveIfDirty() + self.archive?.wipe() + } + } + private func removeState(for peerID: PeerID) { // Deliberately keeps the peer's prekey bundle: bundles exist to reach // owners who left the mesh, and they age out on their own schedule. diff --git a/bitchat/ViewModels/ChatDeliveryCoordinator.swift b/bitchat/ViewModels/ChatDeliveryCoordinator.swift index f2fc13c0..dcaf95e6 100644 --- a/bitchat/ViewModels/ChatDeliveryCoordinator.swift +++ b/bitchat/ViewModels/ChatDeliveryCoordinator.swift @@ -21,6 +21,14 @@ protocol ChatDeliveryContext: AnyObject { /// message is unknown or no copy changed. @discardableResult 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 + ) -> Bool /// Current delivery status of the message in whichever conversation holds it. func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? /// Message IDs across all direct conversations (read-receipt pruning). @@ -33,6 +41,19 @@ protocol ChatDeliveryContext: AnyObject { func notifyUIChanged() /// Confirms receipt so the message router stops retaining the message for resend. func markMessageDelivered(_ messageID: String) + /// Peer-bound form for authenticated remote receipts. Only the supplied + /// conversation aliases may have retained state terminalized. This is the + /// router-side clear only: it is safe without a conversation lookup + /// because the router scopes removal to the acking peer's own queues. + func markMessageDelivered(_ messageID: String, from peerIDs: Set) + /// Releases the media reconnect retry for a private transfer. Unlike the + /// router clear above this is keyed only by the stable media message ID, + /// so callers must first bind the receipt to one of our outgoing + /// conversations for the acking peer. + func confirmPrivateMediaDelivery(_ messageID: String) + /// 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) -> Bool } extension ChatViewModel: ChatDeliveryContext { @@ -41,6 +62,19 @@ extension ChatViewModel: ChatDeliveryContext { conversations.setDeliveryStatus(status, forMessageID: messageID) } + @discardableResult + func setDeliveryStatus( + _ status: DeliveryStatus, + forMessageID messageID: String, + inDirectPeerAliases peerIDs: Set + ) -> Bool { + conversations.setDeliveryStatus( + status, + forMessageID: messageID, + inDirectPeerAliases: peerIDs + ) + } + func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? { conversations.deliveryStatus(forMessageID: messageID) } @@ -55,6 +89,27 @@ extension ChatViewModel: ChatDeliveryContext { func markMessageDelivered(_ messageID: String) { messageRouter.markDelivered(messageID) + mediaTransferCoordinator.confirmPrivateMediaDelivery( + messageID: messageID + ) + } + + func markMessageDelivered(_ messageID: String, from peerIDs: Set) { + messageRouter.markDelivered(messageID, from: peerIDs) + } + + func confirmPrivateMediaDelivery(_ messageID: String) { + mediaTransferCoordinator.confirmPrivateMediaDelivery( + messageID: messageID + ) + } + + func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set) -> Bool { + peerIDs.contains { peerID in + privateMessages(for: peerID).contains { message in + message.id == messageID && message.senderPeerID == myPeerID + } + } } } @@ -83,9 +138,10 @@ final class ChatDeliveryCoordinator { @MainActor func didReceiveReadReceipt(_ receipt: ReadReceipt) { - updateMessageDeliveryStatus( + updateAcknowledgedMessageDeliveryStatus( receipt.originalMessageID, - status: .read(by: receipt.readerNickname, at: receipt.timestamp) + status: .read(by: receipt.readerNickname, at: receipt.timestamp), + from: [receipt.readerID] ) } @@ -102,17 +158,59 @@ final class ChatDeliveryCoordinator { @MainActor @discardableResult func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool { + guard context.setDeliveryStatus(status, forMessageID: messageID) else { + return false + } switch status { case .delivered, .read: - // Confirmed receipt — stop retaining the message for resend. + // Terminalize only after the store accepted the transition. context.markMessageDelivered(messageID) default: break } + context.notifyUIChanged() + return true + } - guard context.setDeliveryStatus(status, forMessageID: messageID) else { + /// Applies an authenticated remote delivery/read receipt. The durable + /// retry state is always released for the acking peer's own aliases + /// (`markMessageDelivered(_:from:)` is peer-scoped on the router side, so + /// a receipt can only terminalize messages queued for that peer). Only the + /// UI status transition and the media-retry release are gated on the + /// in-memory conversation holding the message as one of ours: after a + /// force-quit → relaunch the durable outbox is restored + /// while the conversation may not be, and discarding the ack there would + /// re-send an already-delivered message on every flush/auth event until + /// the attempt cap marks it failed. Mirrors the Nostr path + /// (`ChatPrivateConversationCoordinator.handleDelivered`), which clears + /// retained state unconditionally. + @MainActor + @discardableResult + func updateAcknowledgedMessageDeliveryStatus( + _ messageID: String, + status: DeliveryStatus, + from peerIDAliases: Set + ) -> Bool { + switch status { + case .delivered, .read: + break + default: return false } + guard !peerIDAliases.isEmpty else { return false } + context.markMessageDelivered(messageID, from: peerIDAliases) + guard context.isOutgoingPrivateMessage(messageID, toAny: peerIDAliases), + context.setDeliveryStatus( + status, + forMessageID: messageID, + inDirectPeerAliases: peerIDAliases + ) else { + return false + } + // The receipt is now bound to one of our outgoing conversations for + // the acking peer; only then release the media reconnect retry, whose + // stable message ID is not peer-scoped. + context.confirmPrivateMediaDelivery(messageID) context.notifyUIChanged() return true } diff --git a/bitchat/ViewModels/ChatLiveVoiceCoordinator.swift b/bitchat/ViewModels/ChatLiveVoiceCoordinator.swift index 6c4c483c..c3ec5ce9 100644 --- a/bitchat/ViewModels/ChatLiveVoiceCoordinator.swift +++ b/bitchat/ViewModels/ChatLiveVoiceCoordinator.swift @@ -10,6 +10,7 @@ import Foundation @MainActor protocol ChatLiveVoiceContext: AnyObject { var nickname: String { get } + var myPeerID: PeerID { get } var selectedPrivateChatPeer: PeerID? { get } /// Whether the public mesh timeline is what's on screen (autoplay gate /// for public bursts). @@ -30,6 +31,12 @@ protocol ChatLiveVoiceContext: AnyObject { func upsertPublicMeshMessage(_ message: BitchatMessage) @discardableResult func removePrivateMessage(withID messageID: String) -> BitchatMessage? + /// Records and sends the finalized note's read receipt after a live + /// bubble adopts its wire-derivable message ID. + func hasSentReadReceipt(_ messageID: String) -> Bool + @discardableResult + func markReadReceiptSent(_ messageID: String) -> Bool + func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) /// Removes a message from whichever conversation holds it. func removeMessage(withID messageID: String, cleanupFile: Bool) /// Publishes who is currently talking live in the public mesh channel @@ -226,6 +233,21 @@ final class ChatLiveVoiceCoordinator { assemblies.values.contains { $0.messageID == message.id } } + /// Stop every live file handle/player before the panic media directory is + /// removed. This prevents an in-flight assembly from continuing to write + /// through an unlinked file after the wipe returns. + func resetForPanic() { + for assembly in Array(assemblies.values) { + cancelAssembly(assembly) + } + for player in drainingPlayers.values { + player.stop() + } + drainingPlayers.removeAll(keepingCapacity: false) + finishedBursts.removeAll(keepingCapacity: false) + updatePublicTalkerIndicator() + } + /// Called for every inbound private message: when it is the finalized /// voice note of a burst we assembled (matched by burst ID in the file /// name), swap it into the existing live bubble and report `true` so the @@ -257,8 +279,16 @@ final class ChatLiveVoiceCoordinator { guard let entry = finishedBursts.first(where: { matches($0.key) }) else { return false } let finished = entry.value + // A DM live bubble starts before the finalized file exists and + // therefore has a receiver-local random ID. Adopt the finalized + // message's deterministic ID so delivery/read ACKs address the same + // row as the sender's media placeholder. Public notes retain their + // live-bubble ID because public transfers have no private receipts. + let replacementID = finished.scope == .directMessage + ? message.id + : finished.messageID let replacement = BitchatMessage( - id: finished.messageID, + id: replacementID, sender: message.sender, content: message.content, timestamp: finished.messageTimestamp, @@ -272,7 +302,31 @@ final class ChatLiveVoiceCoordinator { ) switch finished.scope { case .directMessage: + // Capture read state before rekeying. The user may have read the + // live bubble and navigated away before the finalized .m4a lands. + let shouldSendAdoptedReadReceipt = + context.hasSentReadReceipt(finished.messageID) + || context.selectedPrivateChatPeer == finished.peerID + + // Insert first so replacing the only row in a DM never + // transiently deletes its conversation, unread state, or current + // selection. Then remove the receiver-local live-bubble alias. context.upsertPrivateMessage(replacement, in: finished.peerID) + if replacementID != finished.messageID { + context.removePrivateMessage(withID: finished.messageID) + } + // The live bubble may already have emitted a receiver-local READ + // before the sender created its finalized media row. Re-emit once + // for the adopted stable ID now that the file has arrived. + if shouldSendAdoptedReadReceipt, + context.markReadReceiptSent(replacementID) { + let receipt = ReadReceipt( + originalMessageID: replacementID, + readerID: context.myPeerID, + readerNickname: context.nickname + ) + context.sendMeshReadReceipt(receipt, to: finished.peerID) + } case .publicMesh: context.upsertPublicMeshMessage(replacement) } diff --git a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift index bf0009da..bad3a7fb 100644 --- a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift +++ b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift @@ -6,6 +6,59 @@ import Foundation import UIKit #endif +struct LegacyPrivateMediaConsentRequest: Identifiable, Equatable { + let id: UUID + let peerID: PeerID + let peerName: String + let transferId: String + let messageID: String +} + +struct PendingLegacyPrivateMediaConsent { + let request: LegacyPrivateMediaConsentRequest + let completion: @MainActor (Bool) -> Void +} + +struct PrivateMediaReconnectRetryLimits: Equatable { + var maxRetainedPackets = 8 + var maxRetainedBytes = 4 * 1024 * 1024 + var maxRetriesPerMessage = 2 + var retentionSeconds: TimeInterval = 120 + var maxRetriesPerReconnect = 2 +} + +private struct PrivateMediaReconnectRetryRecord { + let messageID: String + let peerID: PeerID + let packet: BitchatFilePacket + var receiptSessionGeneration: UUID + var createdAt: Date + var retryCount: Int + var activeTransferID: String? + var retryAfterCompletion: Bool + var idleOutcome: PrivateMediaReconnectRetryIdleOutcome + var deferredTerminalFailureReason: String? + var expiryToken: UUID? + + var retainedBytes: Int { + packet.content.count + } +} + +private enum PrivateMediaReconnectRetryIdleOutcome { + case none + case locallyCompleted + case cancelled + case rejected(reason: String) +} + +private struct PrivateMediaReconnectRetryCandidate { + let messageID: String + /// The receipt-capable Noise generation that owned this record when the + /// reconnect/authentication event captured it. + let receiptSessionGeneration: UUID +} + /// The narrow surface `ChatMediaTransferCoordinator` needs from its owner. /// /// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the @@ -33,7 +86,14 @@ protocol ChatMediaTransferContext: AnyObject { @discardableResult func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool func removeMessage(withID messageID: String, cleanupFile: Bool) + /// Removes a media bubble with direction-scoped cleanup instead of the + /// broad compatibility cleanup path. + func removeUntombstonedMediaMessage(withID messageID: String) + func removeOutgoingMediaMessage(withID messageID: 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()`). func notifyUIChanged() @@ -43,9 +103,41 @@ protocol ChatMediaTransferContext: AnyObject { func recordContentKey(_ key: String, timestamp: Date) // MARK: Mesh file transfer - func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) + func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy + func authenticatedPrivateMediaReceiptSessionGeneration(to peerID: PeerID) -> UUID? + func resolvePrivateMediaSendPolicy( + to peerID: PeerID, + completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void + ) + func requestLegacyPrivateMediaConsent( + for peerID: PeerID, + transferId: String, + messageID: String, + completion: @escaping @MainActor (Bool) -> Void + ) + func cancelLegacyPrivateMediaConsent(transferId: String, messageID: String) + func sendFilePrivate( + _ packet: BitchatFilePacket, + to peerID: PeerID, + transferId: String, + allowLegacyFallback: Bool + ) + func sendFilePrivateReceiptRetry( + _ packet: BitchatFilePacket, + to peerID: PeerID, + transferId: String + ) func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) func cancelTransfer(_ transferId: String) + /// Receiver-side stable-ID deletion commit. Implementations must invoke + /// completion only after the entire batch is durably tombstoned. + func persistDeletedPrivateMedia( + messageIDs: [String], + completion: @escaping @MainActor (Bool) -> Void + ) + /// Whether any current private-chat copy of this stable ID came from a + /// remote peer and therefore requires a receiver tombstone. + func requiresPrivateMediaTombstone(messageID: String) -> Bool } extension ChatViewModel: ChatMediaTransferContext { @@ -59,8 +151,70 @@ extension ChatViewModel: ChatMediaTransferContext { // other contexts or satisfied by existing `ChatViewModel` members. The // members below flatten mesh service accesses. - func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) { - meshService.sendFilePrivate(packet, to: peerID, transferId: transferId) + func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { + meshService.privateMediaSendPolicy(to: peerID) + } + + func authenticatedPrivateMediaReceiptSessionGeneration( + to peerID: PeerID + ) -> UUID? { + meshService.authenticatedPrivateMediaReceiptSessionGeneration( + to: peerID + ) + } + + func resolvePrivateMediaSendPolicy( + to peerID: PeerID, + completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void + ) { + meshService.resolvePrivateMediaSendPolicy(to: peerID, completion: completion) + } + + func requestLegacyPrivateMediaConsent( + for peerID: PeerID, + transferId: String, + messageID: String, + completion: @escaping @MainActor (Bool) -> Void + ) { + enqueueLegacyPrivateMediaConsent( + for: peerID, + transferId: transferId, + messageID: messageID, + completion: completion + ) + } + + func cancelLegacyPrivateMediaConsent(transferId: String, messageID: String) { + invalidateLegacyPrivateMediaConsent( + transferId: transferId, + messageID: messageID + ) + } + + func sendFilePrivate( + _ packet: BitchatFilePacket, + to peerID: PeerID, + transferId: String, + allowLegacyFallback: Bool + ) { + meshService.sendFilePrivate( + packet, + to: peerID, + transferId: transferId, + allowLegacyFallback: allowLegacyFallback + ) + } + + func sendFilePrivateReceiptRetry( + _ packet: BitchatFilePacket, + to peerID: PeerID, + transferId: String + ) { + meshService.sendFilePrivateReceiptRetry( + packet, + to: peerID, + transferId: transferId + ) } func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) { @@ -70,17 +224,318 @@ extension ChatViewModel: ChatMediaTransferContext { func cancelTransfer(_ transferId: String) { meshService.cancelTransfer(transferId) } + + func removeUntombstonedMediaMessage(withID messageID: String) { + let message = conversations.conversationsByID.values.lazy + .flatMap(\.messages) + .first { $0.id == messageID } + if let message, !isIncomingPrivateMessage(message) { + mediaTransferCoordinator.cleanupOutgoingLocalFile( + forMessage: message + ) + } + 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) { + let message = conversations.conversationsByID.values.lazy + .flatMap(\.messages) + .first { $0.id == messageID } + if let message { + mediaTransferCoordinator.cleanupOutgoingLocalFile( + forMessage: message + ) + } + removeMessage(withID: messageID, cleanupFile: false) + } + + func persistDeletedPrivateMedia( + messageIDs: [String], + completion: @escaping @MainActor (Bool) -> Void + ) { + guard !messageIDs.isEmpty else { + completion(true) + return + } + guard let persistence = + meshService as? any PrivateMediaDeletionPersisting else { + completion(false) + return + } + let requestedIDs = Set(messageIDs) + let incomingPathReferences = Array( + conversations.conversationsByID.values + .lazy + .flatMap(\.messages) + .compactMap { message -> ( + messageID: String, + path: String + )? in + guard self.isIncomingPrivateMessage(message), + let path = self.incomingMediaRelativePath( + for: message + ) else { + return nil + } + return (message.id, path) + } + ) + let ownerIDsByPath = Dictionary( + grouping: incomingPathReferences, + by: { $0.path } + ).mapValues { Set($0.map(\.messageID)) } + let protectedPayloadRelativePaths = Set( + ownerIDsByPath.compactMap { path, ownerIDs in + ownerIDs.isSubset(of: requestedIDs) ? nil : path + } + ) + var payloadRelativePaths: [String: String] = [:] + for reference in incomingPathReferences + where requestedIDs.contains(reference.messageID) + && ownerIDsByPath[reference.path, default: []] + .isSubset(of: requestedIDs) { + payloadRelativePaths[reference.messageID] = reference.path + } + persistence.persistDeletedPrivateMedia( + messageIDs: messageIDs, + payloadRelativePaths: payloadRelativePaths, + protectedPayloadRelativePaths: + protectedPayloadRelativePaths, + completion: completion + ) + } + + func requiresPrivateMediaTombstone(messageID: String) -> Bool { + guard PrivateMediaMessageIdentity.isStableID(messageID) else { + return false + } + return privateChats.values.lazy.flatMap { $0 }.contains { message in + message.id == messageID && isIncomingPrivateMessage(message) + } + } + + 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( + _ message: BitchatMessage + ) -> Bool { + if let senderPeerID = message.senderPeerID { + return senderPeerID.toShort() != meshService.myPeerID.toShort() + } + return message.sender != nickname + && !message.sender.hasPrefix(nickname + "#") + } + + private func incomingMediaRelativePath( + for message: BitchatMessage + ) -> String? { + let categories: [MimeType.Category] = [.audio, .image, .file] + guard let category = categories.first(where: { + message.content.hasPrefix($0.messagePrefix) + }), + let rawFilename = String( + message.content.dropFirst(category.messagePrefix.count) + ).trimmedOrNilIfEmpty, + let safeFilename = + (rawFilename as NSString).lastPathComponent.nilIfEmpty, + safeFilename != ".", + safeFilename != ".." else { + return nil + } + return "\(category.mediaDir)/incoming/\(safeFilename)" + } +} + +/// Synchronous boundary between detached image writers and panic deletion. +/// +/// Invalidation closes admission before waiting for writers that already +/// entered. Those writers never need the main actor while inside the boundary, +/// so a synchronous panic transaction can safely join them and then delete +/// every output before reporting completion. +private final class ImagePreparationBarrier: @unchecked Sendable { + private let condition = NSCondition() + private var generation: UInt64 = 0 + private var activeOperations = 0 + + var currentGeneration: UInt64 { + condition.lock() + defer { condition.unlock() } + return generation + } + + func isCurrent(_ candidate: UInt64) -> Bool { + condition.lock() + defer { condition.unlock() } + return generation == candidate + } + + func performIfCurrent( + generation candidate: UInt64, + operation: () throws -> T + ) rethrows -> T? { + condition.lock() + guard generation == candidate else { + condition.unlock() + return nil + } + activeOperations += 1 + condition.unlock() + + defer { + condition.lock() + activeOperations -= 1 + if activeOperations == 0 { + condition.broadcast() + } + condition.unlock() + } + return try operation() + } + + func invalidateAndWait() { + condition.lock() + generation &+= 1 + while activeOperations > 0 { + condition.wait() + } + condition.unlock() + } +} + +/// Runs synchronous media encoding and file I/O on a dispatch worker. +/// +/// These operations can legitimately block. Keeping them off Swift's +/// cooperative executor preserves forward progress when several transfers or +/// test doubles wait at the same time. +private func runBlockingMediaPreparation( + _ operation: @escaping @Sendable () throws -> T +) async throws -> T { + try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + do { + continuation.resume(returning: try operation()) + } catch { + continuation.resume(throwing: error) + } + } + } } @MainActor final class ChatMediaTransferCoordinator { private unowned let context: any ChatMediaTransferContext + private let prepareImagePacket: @Sendable (URL) throws -> ChatPreparedImage + private let imagePreparationBarrier = ImagePreparationBarrier() + private let prepareVoiceNotePacket: @Sendable (URL) throws -> BitchatFilePacket + private let reconnectRetryLimits: PrivateMediaReconnectRetryLimits + private let now: () -> Date + private let transferIDFactory: (String) -> String private(set) var transferIdToMessageIDs: [String: [String]] = [:] private(set) var messageIDToTransferId: [String: String] = [:] + private var deletionGeneration: UInt64 = 0 + private var reconnectRetryRecords: [ + String: PrivateMediaReconnectRetryRecord + ] = [:] + /// A newly authenticated session supersedes any raw-connect policy + /// resolution still in flight for that peer. + private var peersResolvingReconnectRetry: [ + PeerID: ( + id: UUID, + replacingActiveTransfer: Bool, + candidates: [PrivateMediaReconnectRetryCandidate] + ) + ] = [:] + private var reconnectRetryExpiryTasks: [String: Task] = [:] - init(context: any ChatMediaTransferContext) { + var retainedReconnectRetryCount: Int { + reconnectRetryRecords.count + } + + var retainedReconnectRetryBytes: Int { + reconnectRetryRecords.values.reduce(0) { $0 + $1.retainedBytes } + } + + init( + context: any ChatMediaTransferContext, + prepareImagePacket: @escaping @Sendable (URL) throws -> ChatPreparedImage = { + try ChatMediaPreparation.prepareImagePacket(from: $0) + }, + prepareVoiceNotePacket: @escaping @Sendable (URL) throws -> BitchatFilePacket = { + try ChatMediaPreparation.prepareVoiceNotePacket(at: $0) + }, + reconnectRetryLimits: PrivateMediaReconnectRetryLimits = + PrivateMediaReconnectRetryLimits(), + now: @escaping () -> Date = Date.init, + transferIDFactory: @escaping (String) -> String = { + "\($0)-\(UUID().uuidString)" + } + ) { self.context = context + self.prepareImagePacket = prepareImagePacket + self.prepareVoiceNotePacket = prepareVoiceNotePacket + self.reconnectRetryLimits = reconnectRetryLimits + self.now = now + self.transferIDFactory = transferIDFactory } func sendVoiceNote(at url: URL) { @@ -92,22 +547,47 @@ final class ChatMediaTransferCoordinator { } let targetPeer = context.selectedPrivateChatPeer + let privateMessageID = targetPeer.flatMap { peerID in + PrivateMediaMessageIdentity.stableID( + senderPeerID: context.myPeerID, + recipientPeerID: peerID, + fileName: url.lastPathComponent + ) + } let message = enqueueMediaMessage( content: "\(MimeType.Category.audio.messagePrefix)\(url.lastPathComponent)", - targetPeer: targetPeer + targetPeer: targetPeer, + messageID: privateMessageID ) let messageID = message.id let transferId = makeTransferID(messageID: messageID) + // Own the transfer before detached preparation begins. Cancel/delete + // must be able to invalidate this exact invocation even while file I/O + // is still running off the main actor. + registerTransfer(transferId: transferId, messageID: messageID) + let prepareVoiceNotePacket = self.prepareVoiceNotePacket + let barrier = imagePreparationBarrier + let generation = barrier.currentGeneration - Task.detached(priority: .userInitiated) { [weak self] in + Task.detached(priority: .userInitiated) { [weak self, barrier] in do { - let packet = try ChatMediaPreparation.prepareVoiceNotePacket(at: url) + let packet = try await runBlockingMediaPreparation { + try prepareVoiceNotePacket(url) + } - await MainActor.run { [weak self] in - guard let self else { return } - self.registerTransfer(transferId: transferId, messageID: messageID) + await MainActor.run { [weak self, barrier] in + guard let self, + barrier.isCurrent(generation), + self.isRegisteredTransfer(transferId, messageID: messageID) else { + return + } if let peerID = targetPeer { - self.context.sendFilePrivate(packet, to: peerID, transferId: transferId) + self.beginPrivateMediaSend( + packet, + to: peerID, + transferId: transferId, + messageID: messageID + ) } else { self.context.sendFileBroadcast(packet, transferId: transferId) } @@ -115,14 +595,22 @@ final class ChatMediaTransferCoordinator { } catch ChatMediaPreparationError.voiceNoteTooLarge(let size) { SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session) try? FileManager.default.removeItem(at: url) - await MainActor.run { [weak self] in - guard let self else { return } + await MainActor.run { [weak self, barrier] in + guard let self, + barrier.isCurrent(generation), + self.isRegisteredTransfer(transferId, messageID: messageID) else { + return + } self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", comment: "Failure reason shown when a voice note exceeds the size limit")) } } catch { SecureLogger.error("Voice note send failed: \(error)", category: .session) - await MainActor.run { [weak self] in - guard let self else { return } + await MainActor.run { [weak self, barrier] in + guard let self, + barrier.isCurrent(generation), + self.isRegisteredTransfer(transferId, messageID: messageID) else { + return + } self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", comment: "Failure reason shown when a voice note could not be sent")) } } @@ -132,11 +620,27 @@ final class ChatMediaTransferCoordinator { #if os(iOS) func processThenSendImage(_ image: UIImage?) { guard let image else { return } - Task.detached { [weak self] in + let generation = imagePreparationBarrier.currentGeneration + let barrier = imagePreparationBarrier + Task.detached(priority: .userInitiated) { [weak self, barrier] in do { - let processedURL = try ImageUtils.processImage(image) - await MainActor.run { [weak self] in - guard let self else { return } + let processedURL = try await runBlockingMediaPreparation { + try barrier.performIfCurrent( + generation: generation, + operation: { + try ImageUtils.processImage(image) + } + ) + } + guard let processedURL else { + return + } + await MainActor.run { [weak self, barrier] in + guard let self, + barrier.isCurrent(generation) else { + try? FileManager.default.removeItem(at: processedURL) + return + } self.sendImage(from: processedURL) } } catch { @@ -147,11 +651,27 @@ final class ChatMediaTransferCoordinator { #elseif os(macOS) func processThenSendImage(from url: URL?) { guard let url else { return } - Task.detached { [weak self] in + let generation = imagePreparationBarrier.currentGeneration + let barrier = imagePreparationBarrier + Task.detached(priority: .userInitiated) { [weak self, barrier] in do { - let processedURL = try ImageUtils.processImage(at: url) - await MainActor.run { [weak self] in - guard let self else { return } + let processedURL = try await runBlockingMediaPreparation { + try barrier.performIfCurrent( + generation: generation, + operation: { + try ImageUtils.processImage(at: url) + } + ) + } + guard let processedURL else { + return + } + await MainActor.run { [weak self, barrier] in + guard let self, + barrier.isCurrent(generation) else { + try? FileManager.default.removeItem(at: processedURL) + return + } self.sendImage(from: processedURL) } } catch { @@ -170,6 +690,7 @@ final class ChatMediaTransferCoordinator { } let targetPeer = context.selectedPrivateChatPeer + let generation = imagePreparationBarrier.currentGeneration do { try ImageUtils.validateImageSource(at: sourceURL) @@ -179,47 +700,87 @@ final class ChatMediaTransferCoordinator { return } - Task.detached(priority: .userInitiated) { [weak self] in + let prepareImagePacket = self.prepareImagePacket + let barrier = imagePreparationBarrier + Task.detached(priority: .userInitiated) { [weak self, barrier] in do { - let prepared = try ChatMediaPreparation.prepareImagePacket(from: sourceURL) + let prepared = try await runBlockingMediaPreparation { + try barrier.performIfCurrent( + generation: generation, + operation: { + try prepareImagePacket(sourceURL) + } + ) + } + guard let prepared else { + return + } - await MainActor.run { [weak self] in - guard let self else { return } + await MainActor.run { [weak self, barrier] in + guard let self, + barrier.isCurrent(generation) else { + try? FileManager.default.removeItem(at: prepared.outputURL) + return + } + let privateMessageID = targetPeer.flatMap { peerID in + PrivateMediaMessageIdentity.stableID( + for: prepared.packet, + senderPeerID: self.context.myPeerID, + recipientPeerID: peerID + ) + } let message = self.enqueueMediaMessage( content: "\(MimeType.Category.image.messagePrefix)\(prepared.outputURL.lastPathComponent)", - targetPeer: targetPeer + targetPeer: targetPeer, + messageID: privateMessageID ) let messageID = message.id let transferId = self.makeTransferID(messageID: messageID) self.registerTransfer(transferId: transferId, messageID: messageID) if let peerID = targetPeer { - self.context.sendFilePrivate(prepared.packet, to: peerID, transferId: transferId) + self.beginPrivateMediaSend( + prepared.packet, + to: peerID, + transferId: transferId, + messageID: messageID + ) } else { self.context.sendFileBroadcast(prepared.packet, transferId: transferId) } } } catch ChatMediaPreparationError.imageTooLarge(let size) { SecureLogger.warning("Processed image exceeds size limit (\(size) bytes)", category: .session) - await MainActor.run { [weak self] in - guard let self else { return } + await MainActor.run { [weak self, barrier] in + guard let self, + barrier.isCurrent(generation) else { + return + } self.context.addSystemMessage("Image is too large to send.") } } catch { SecureLogger.error("Image send preparation failed: \(error)", category: .session) - await MainActor.run { [weak self] in - guard let self else { return } + await MainActor.run { [weak self, barrier] in + guard let self, + barrier.isCurrent(generation) else { + return + } self.context.addSystemMessage("Failed to prepare image for sending.") } } } } - func enqueueMediaMessage(content: String, targetPeer: PeerID?) -> BitchatMessage { + func enqueueMediaMessage( + content: String, + targetPeer: PeerID?, + messageID: String? = nil + ) -> BitchatMessage { let timestamp = Date() let message: BitchatMessage if let peerID = targetPeer { message = BitchatMessage( + id: messageID, sender: context.nickname, content: content, timestamp: timestamp, @@ -253,18 +814,150 @@ final class ChatMediaTransferCoordinator { return message } + private func beginPrivateMediaSend( + _ packet: BitchatFilePacket, + to peerID: PeerID, + transferId: String, + messageID: String + ) { + continuePrivateMediaSend( + packet, + to: peerID, + transferId: transferId, + messageID: messageID, + policy: context.privateMediaSendPolicy(to: peerID) + ) + } + + private func continuePrivateMediaSend( + _ packet: BitchatFilePacket, + to peerID: PeerID, + transferId: String, + messageID: String, + policy: PrivateMediaSendPolicy + ) { + switch policy { + case .encrypted: + retainForReconnectRetryIfEligible( + packet, + peerID: peerID, + messageID: messageID, + activeTransferID: transferId + ) + context.sendFilePrivate( + packet, + to: peerID, + transferId: transferId, + allowLegacyFallback: false + ) + + case .awaitingCapabilityProof: + context.resolvePrivateMediaSendPolicy(to: peerID) { [weak self] resolvedPolicy in + guard let self, + self.isRegisteredTransfer(transferId, messageID: messageID) else { + return + } + guard resolvedPolicy != .awaitingCapabilityProof else { + self.handleMediaSendFailure( + messageID: messageID, + reason: String( + localized: "content.delivery.reason.private_media_capability_unresolved", + defaultValue: "Could not confirm encrypted media support", + comment: "Failure reason when private-media capability negotiation did not resolve" + ) + ) + return + } + self.continuePrivateMediaSend( + packet, + to: peerID, + transferId: transferId, + messageID: messageID, + policy: resolvedPolicy + ) + } + + case .legacyRequiresConsent: + context.requestLegacyPrivateMediaConsent( + for: peerID, + transferId: transferId, + messageID: messageID + ) { [weak self] approved in + guard let self else { return } + // Consent belongs to this exact placeholder/transfer. A late + // dialog callback after cancel/delete must never resurrect it. + guard self.messageIDToTransferId[messageID] == transferId, + self.transferIdToMessageIDs[transferId]?.contains(messageID) == true else { + return + } + guard approved else { + self.handleMediaSendFailure( + messageID: messageID, + reason: String( + localized: "content.delivery.reason.legacy_media_declined", + defaultValue: "Not sent without end-to-end encryption", + comment: "Failure reason after declining the warning for a legacy clear private-media send" + ) + ) + return + } + self.context.sendFilePrivate( + packet, + to: peerID, + transferId: transferId, + allowLegacyFallback: true + ) + } + + case .blockedDowngrade: + handleMediaSendFailure( + messageID: messageID, + reason: String( + localized: "content.delivery.reason.private_media_downgrade_blocked", + defaultValue: "Encrypted media required; ask this contact to upgrade", + comment: "Failure reason when a peer that previously supported encrypted media appears to downgrade" + ) + ) + } + } + func registerTransfer(transferId: String, messageID: String) { transferIdToMessageIDs[transferId, default: []].append(messageID) messageIDToTransferId[messageID] = transferId } + private func isRegisteredTransfer(_ transferId: String, messageID: String) -> Bool { + messageIDToTransferId[messageID] == transferId + && transferIdToMessageIDs[transferId]?.contains(messageID) == true + } + func makeTransferID(messageID: String) -> String { - "\(messageID)-\(UUID().uuidString)" + transferIDFactory(messageID) } func clearTransferMapping(for messageID: String) { - guard let transferId = messageIDToTransferId.removeValue(forKey: messageID) else { return } - guard var queue = transferIdToMessageIDs[transferId] else { return } + guard let transferId = messageIDToTransferId[messageID] else { return } + clearTransferMapping( + transferID: transferId, + messageID: messageID, + clearCurrentOwner: true + ) + } + + private func clearTransferMapping( + transferID: String, + messageID: String, + clearCurrentOwner: Bool + ) { + if clearCurrentOwner, + messageIDToTransferId[messageID] == transferID { + messageIDToTransferId.removeValue(forKey: messageID) + } + context.cancelLegacyPrivateMediaConsent( + transferId: transferID, + messageID: messageID + ) + guard var queue = transferIdToMessageIDs[transferID] else { return } if !queue.isEmpty { if queue.first == messageID { @@ -274,10 +967,32 @@ final class ChatMediaTransferCoordinator { } } - transferIdToMessageIDs[transferId] = queue.isEmpty ? nil : queue + transferIdToMessageIDs[transferID] = queue.isEmpty ? nil : queue + } + + /// Returns the message still owned by this exact transfer. Replacement + /// retries can receive late callbacks from the cancelled predecessor; + /// those callbacks may clear only their stale queue entry. + private func currentMessageID(forTransferID transferID: String) -> String? { + guard let messageID = transferIdToMessageIDs[transferID]?.first else { + return nil + } + guard messageIDToTransferId[messageID] == transferID else { + clearTransferMapping( + transferID: transferID, + messageID: messageID, + clearCurrentOwner: false + ) + return nil + } + return messageID } func handleMediaSendFailure(messageID: String, reason: String) { + discardReconnectRetry( + messageID: messageID, + cancelActiveTransfer: false + ) context.updateMessageDeliveryStatus(messageID, status: .failed(reason: reason)) clearTransferMapping(for: messageID) } @@ -285,23 +1000,126 @@ final class ChatMediaTransferCoordinator { func handleTransferEvent(_ event: TransferProgressManager.Event) { switch event { case .started(let id, let total): - guard let messageID = transferIdToMessageIDs[id]?.first else { return } + guard let messageID = currentMessageID(forTransferID: id) else { + return + } + if isReconnectRetryTransfer(id, messageID: messageID) { + return + } context.updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: 0, total: total)) + case .updated(let id, let sent, let total): - guard let messageID = transferIdToMessageIDs[id]?.first else { return } + guard let messageID = currentMessageID(forTransferID: id) else { + return + } + if isReconnectRetryTransfer(id, messageID: messageID) { + return + } context.updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: sent, total: total)) + case .completed(let id, _): - guard let messageID = transferIdToMessageIDs[id]?.first else { return } + guard let messageID = currentMessageID(forTransferID: id) else { + return + } + let ownsRetainedRecord = + reconnectRetryRecords[messageID]?.activeTransferID == id + let retryAfterCompletion = ownsRetainedRecord + && reconnectRetryRecords[messageID]?.retryAfterCompletion == true + let deferredTerminalReason = ownsRetainedRecord + ? reconnectRetryRecords[messageID]? + .deferredTerminalFailureReason + : nil + if ownsRetainedRecord { + reconnectRetryRecords[messageID]?.activeTransferID = nil + reconnectRetryRecords[messageID]?.retryAfterCompletion = false + reconnectRetryRecords[messageID]?.idleOutcome = + .locallyCompleted + reconnectRetryRecords[messageID]?.createdAt = now() + } context.updateMessageDeliveryStatus(messageID, status: .sent) clearTransferMapping(for: messageID) + if let deferredTerminalReason { + terminalizeReconnectRetry( + messageID: messageID, + reason: deferredTerminalReason + ) + } else if retryAfterCompletion { + startReconnectRetry(messageID: messageID) + } else if ownsRetainedRecord { + scheduleReconnectRetryExpiry(messageID: messageID) + } + case .cancelled(let id, _, _): - guard let messageID = transferIdToMessageIDs[id]?.first else { return } + guard let messageID = currentMessageID(forTransferID: id) else { + return + } + if isRetainedPrivateMediaTransfer(id, messageID: messageID) { + finishRetainedTransfer( + id, + messageID: messageID, + outcome: .cancelled, + rejectionReason: nil + ) + return + } + discardReconnectRetry( + messageID: messageID, + cancelActiveTransfer: false + ) clearTransferMapping(for: messageID) - context.removeMessage(withID: messageID, cleanupFile: true) + context.removeOutgoingMediaMessage(withID: messageID) + case .rejected(let id, let reason): + guard let messageID = currentMessageID(forTransferID: id) else { + return + } + if isRetainedPrivateMediaTransfer(id, messageID: messageID) { + finishRetainedTransfer( + id, + messageID: messageID, + outcome: .rejected(reason: reason), + rejectionReason: reason + ) + return + } + handleMediaSendFailure(messageID: messageID, reason: reason) } } func cleanupLocalFile(forMessage message: BitchatMessage) { + cleanupLocalFile( + forMessage: message, + directions: ["outgoing", "incoming"], + searchAllCategories: true + ) + } + + /// `/clear` may cancel an outgoing message before receiver tombstones are + /// committed. Restrict cleanup to that message's outgoing directory so a + /// same-name incoming payload cannot be removed prematurely. + func cleanupOutgoingLocalFile(forMessage message: BitchatMessage) { + cleanupLocalFile( + forMessage: message, + directions: ["outgoing"], + searchAllCategories: false + ) + } + + /// Receiver cleanup runs only after any required tombstone commit. Keep it + /// scoped to the parsed media category and incoming directory so unrelated + /// outgoing or cross-category payloads with the same basename survive. + func cleanupIncomingLocalFile(forMessage message: BitchatMessage) { + cleanupLocalFile( + forMessage: message, + directions: ["incoming"], + searchAllCategories: false + ) + } + + private func cleanupLocalFile( + forMessage message: BitchatMessage, + directions: [String], + searchAllCategories: Bool + ) { let categories: [MimeType.Category] = [.audio, .image, .file] guard let category = categories.first(where: { message.content.hasPrefix($0.messagePrefix) }), let rawFilename = String(message.content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty, @@ -312,11 +1130,27 @@ final class ChatMediaTransferCoordinator { return } - let subdirs = categories.flatMap { ["\($0.mediaDir)/outgoing", "\($0.mediaDir)/incoming"] } + let targetCategories = searchAllCategories ? categories : [category] + let subdirs = targetCategories.flatMap { category in + directions.map { "\(category.mediaDir)/\($0)" } + } for subdir in subdirs { let target = base.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(safeFilename) guard target.path.hasPrefix(base.path) else { continue } + guard FileManager.default.fileExists(atPath: target.path) else { + continue + } + guard let values = try? target.resourceValues( + forKeys: [.isRegularFileKey] + ), + values.isRegularFile == true else { + SecureLogger.warning( + "Refusing to cleanup non-file media target \(safeFilename)", + category: .session + ) + continue + } do { try FileManager.default.removeItem(at: target) } catch CocoaError.fileNoSuchFile { @@ -328,22 +1162,723 @@ final class ChatMediaTransferCoordinator { } func cancelMediaSend(messageID: String) { - if let transferId = messageIDToTransferId[messageID], - let active = transferIdToMessageIDs[transferId]?.first, - active == messageID { - context.cancelTransfer(transferId) - } - clearTransferMapping(for: messageID) - context.removeMessage(withID: messageID, cleanupFile: true) + cancelAllMediaSendOwners(messageID: messageID) + context.removeOutgoingMediaMessage(withID: messageID) + } + + /// Lets `/clear` cancel send ownership without implicitly deciding which + /// bubbles/files its deletion transaction may remove. + func cancelMediaTransferForConversationClear(messageID: String) { + cancelAllMediaSendOwners(messageID: messageID) } func deleteMediaMessage(messageID: String) { + // Stop every exact sender owner before the durable receiver commit. + // Otherwise a retained retry or admitted legacy send could transmit + // after the bubble and payload have been deleted. + cancelAllMediaSendOwners(messageID: messageID) + + guard context.requiresPrivateMediaTombstone( + messageID: messageID + ) else { + finishMediaDeletion( + messageID: messageID, + receiverJournalOwnsPayload: false + ) + return + } + + let generation = deletionGeneration + context.persistDeletedPrivateMedia( + messageIDs: [messageID] + ) { [weak self] persisted in + guard let self, + self.deletionGeneration == generation else { + return + } + guard persisted else { + SecureLogger.error( + "Refusing to delete private media without a durable tombstone id=\(messageID.prefix(12))…", + category: .session + ) + self.context.notifyMediaDeletionRefused( + messageID: messageID + ) + return + } + self.finishMediaDeletion( + messageID: messageID, + receiverJournalOwnsPayload: true + ) + } + } + + private func finishMediaDeletion( + messageID: String, + receiverJournalOwnsPayload: Bool + ) { + if receiverJournalOwnsPayload { + // The journal already owns the exact path. A basename cleanup here + // could delete a different arrival that reused it after unlink. + context.removeMessage(withID: messageID, cleanupFile: false) + } else { + context.removeUntombstonedMediaMessage(withID: messageID) + } + } + + private func cancelAllMediaSendOwners(messageID: String) { + // This releases the retained packet and expiry/retry owner. When its + // exact active transfer still owns the mapping, it cancels that owner + // before any deletion persistence or UI mutation can proceed. + discardReconnectRetry( + messageID: messageID, + cancelActiveTransfer: true + ) + // In particular, an approved legacy send may still be waiting on + // BLEService.messageQueue. Its admission must be canceled before the + // mapping/consent owner is released. + if let transferId = messageIDToTransferId[messageID], + transferIdToMessageIDs[transferId]?.first == messageID { + context.cancelTransfer(transferId) + } clearTransferMapping(for: messageID) - context.removeMessage(withID: messageID, cleanupFile: true) + } + + /// A raw link callback can arrive before the replacement Noise session + /// proves its capabilities. Resolve against the exact session before + /// releasing any retained bytes into a whole-file retry. + func peerDidReconnect(_ peerID: PeerID) { + resolveReconnectRetries( + for: peerID, + replacingActiveTransfer: false + ) + } + + /// Authentication supersedes a raw-connect resolution that may still + /// refer to the cached generation and replaces only stale active sends. + func peerDidAuthenticate(_ peerID: PeerID) { + resolveReconnectRetries( + for: peerID, + replacingActiveTransfer: true + ) + } + + /// 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 + /// the file. Only a remote delivery/read receipt releases retry ownership. + func confirmPrivateMediaDelivery(messageID: String) { + guard PrivateMediaMessageIdentity.isStableID(messageID) else { + return + } + discardReconnectRetry( + messageID: messageID, + cancelActiveTransfer: true + ) + } + + /// Deterministic clock seam for focused tests. Production records also own + /// wall-clock expiry tasks. + func _test_expireReconnectRetries() { + pruneExpiredReconnectRetries() + } + + /// Invalidates detached preparation work and cancels every transfer that + /// reached the transport. Closing image-preparation admission and joining + /// active synchronous writers ensures the following panic media deletion + /// is the last filesystem mutation before the transaction can complete. + func resetForPanic() { + imagePreparationBarrier.invalidateAndWait() + deletionGeneration &+= 1 + peersResolvingReconnectRetry.removeAll(keepingCapacity: false) + for task in reconnectRetryExpiryTasks.values { + task.cancel() + } + reconnectRetryExpiryTasks.removeAll(keepingCapacity: false) + reconnectRetryRecords.removeAll(keepingCapacity: false) + let transferIDs = Set(transferIdToMessageIDs.keys) + transferIdToMessageIDs.removeAll(keepingCapacity: false) + messageIDToTransferId.removeAll(keepingCapacity: false) + for transferID in transferIDs { + context.cancelTransfer(transferID) + } } } private extension ChatMediaTransferCoordinator { + func reconnectRetryCandidates( + for peerID: PeerID, + limit: Int? + ) -> [PrivateMediaReconnectRetryCandidate] { + let records = reconnectRetryRecords.values + .filter { + $0.peerID == peerID + && $0.retryCount + < reconnectRetryLimits.maxRetriesPerMessage + } + .sorted { + if $0.createdAt == $1.createdAt { + return $0.messageID < $1.messageID + } + return $0.createdAt < $1.createdAt + } + let selected: ArraySlice + if let limit { + selected = records.prefix(max(0, limit)) + } else { + selected = records[...] + } + return selected.map { + PrivateMediaReconnectRetryCandidate( + messageID: $0.messageID, + receiptSessionGeneration: $0.receiptSessionGeneration + ) + } + } + + func resolveReconnectRetries( + for peerID: PeerID, + replacingActiveTransfer: Bool + ) { + pruneExpiredReconnectRetries() + let normalizedPeerID = peerID.toShort() + let candidates: [PrivateMediaReconnectRetryCandidate] + if let pending = peersResolvingReconnectRetry[normalizedPeerID] { + // Authentication is the only event that may supersede a raw-link + // resolution; duplicate callbacks add no new proof. + guard replacingActiveTransfer, + !pending.replacingActiveTransfer else { + return + } + candidates = reconnectRetryCandidates( + for: normalizedPeerID, + limit: nil + ) + } else { + candidates = reconnectRetryCandidates( + for: normalizedPeerID, + limit: replacingActiveTransfer + ? nil + : max( + 0, + reconnectRetryLimits.maxRetriesPerReconnect + ) + ) + } + guard !candidates.isEmpty else { return } + + let resolutionID = UUID() + peersResolvingReconnectRetry[normalizedPeerID] = ( + id: resolutionID, + replacingActiveTransfer: replacingActiveTransfer, + candidates: candidates + ) + context.resolvePrivateMediaSendPolicy( + to: normalizedPeerID + ) { [weak self] policy in + guard let self, + let pending = + self.peersResolvingReconnectRetry[normalizedPeerID], + pending.id == resolutionID else { + return + } + self.peersResolvingReconnectRetry.removeValue( + forKey: normalizedPeerID + ) + guard policy == .encrypted, + let provenGeneration = self.context + .authenticatedPrivateMediaReceiptSessionGeneration( + to: normalizedPeerID + ) else { + self.terminalizeUnavailableCapabilityProof( + pending.candidates, + for: normalizedPeerID + ) + return + } + self.scheduleReconnectRetries( + pending.candidates, + for: normalizedPeerID, + replacingActiveTransfer: + pending.replacingActiveTransfer, + provenGeneration: provenGeneration + ) + } + } + + func retainForReconnectRetryIfEligible( + _ packet: BitchatFilePacket, + peerID: PeerID, + messageID: String, + activeTransferID: String + ) { + let normalizedPeerID = peerID.toShort() + guard let receiptSessionGeneration = context + .authenticatedPrivateMediaReceiptSessionGeneration( + to: normalizedPeerID + ), + reconnectRetryLimits.maxRetainedPackets > 0, + reconnectRetryLimits.maxRetainedBytes > 0, + reconnectRetryLimits.maxRetriesPerMessage > 0, + packet.content.count + <= reconnectRetryLimits.maxRetainedBytes, + PrivateMediaMessageIdentity.isStableID(messageID), + PrivateMediaMessageIdentity.stableID( + for: packet, + senderPeerID: context.myPeerID, + recipientPeerID: normalizedPeerID + ) == messageID else { + return + } + + pruneExpiredReconnectRetries() + if var existing = reconnectRetryRecords[messageID] { + cancelReconnectRetryExpiry(messageID: messageID) + existing.receiptSessionGeneration = receiptSessionGeneration + existing.createdAt = now() + existing.activeTransferID = activeTransferID + existing.retryAfterCompletion = false + existing.idleOutcome = .none + existing.deferredTerminalFailureReason = nil + existing.expiryToken = nil + reconnectRetryRecords[messageID] = existing + return + } + + makeReconnectRetryCapacity(for: packet.content.count) + guard reconnectRetryRecords.count + < reconnectRetryLimits.maxRetainedPackets, + retainedReconnectRetryBytes + packet.content.count + <= reconnectRetryLimits.maxRetainedBytes else { + SecureLogger.debug( + "Private media retry retention full; sending once id=\(messageID.prefix(12))…", + category: .session + ) + return + } + + reconnectRetryRecords[messageID] = + PrivateMediaReconnectRetryRecord( + messageID: messageID, + peerID: normalizedPeerID, + packet: packet, + receiptSessionGeneration: receiptSessionGeneration, + createdAt: now(), + retryCount: 0, + activeTransferID: activeTransferID, + retryAfterCompletion: false, + idleOutcome: .none, + deferredTerminalFailureReason: nil, + expiryToken: nil + ) + } + + func terminalizeUnavailableCapabilityProof( + _ candidates: [PrivateMediaReconnectRetryCandidate], + for peerID: PeerID + ) { + let reason = privateMediaCapabilityUnresolvedReason + for candidate in candidates { + guard var record = + reconnectRetryRecords[candidate.messageID], + record.peerID == peerID, + record.receiptSessionGeneration + == candidate.receiptSessionGeneration else { + continue + } + if record.activeTransferID != nil { + // The original transport owner can still produce a valid + // remote receipt. Defer failure until it releases ownership. + record.deferredTerminalFailureReason = reason + reconnectRetryRecords[candidate.messageID] = record + } else { + terminalizeReconnectRetry( + messageID: candidate.messageID, + reason: reason + ) + } + } + } + + func scheduleReconnectRetries( + _ candidates: [PrivateMediaReconnectRetryCandidate], + for peerID: PeerID, + replacingActiveTransfer: Bool, + provenGeneration: UUID + ) { + pruneExpiredReconnectRetries() + + var scheduledCount = 0 + let limit = max( + 0, + reconnectRetryLimits.maxRetriesPerReconnect + ) + for candidate in candidates { + guard scheduledCount < limit else { break } + let messageID = candidate.messageID + guard var record = reconnectRetryRecords[messageID], + record.peerID == peerID, + record.receiptSessionGeneration + == candidate.receiptSessionGeneration, + record.retryCount + < reconnectRetryLimits.maxRetriesPerMessage else { + continue + } + if record.deferredTerminalFailureReason != nil { + record.deferredTerminalFailureReason = nil + reconnectRetryRecords[messageID] = record + } + if replacingActiveTransfer, + candidate.receiptSessionGeneration == provenGeneration { + continue + } + if record.activeTransferID != nil { + if replacingActiveTransfer { + if replaceActiveTransferAfterAuthentication( + messageID: messageID + ) { + scheduledCount += 1 + } + continue + } + // Arm one retry after the current transfer drains. Duplicate + // reconnect callbacks cannot chain more work. + if record.retryCount == 0 { + record.retryAfterCompletion = true + reconnectRetryRecords[messageID] = record + scheduledCount += 1 + } + } else if startReconnectRetry(messageID: messageID) { + scheduledCount += 1 + } + } + } + + @discardableResult + func replaceActiveTransferAfterAuthentication( + messageID: String + ) -> Bool { + guard var record = reconnectRetryRecords[messageID], + let staleTransferID = record.activeTransferID, + record.retryCount + < reconnectRetryLimits.maxRetriesPerMessage else { + return false + } + record.activeTransferID = nil + record.retryAfterCompletion = false + record.createdAt = now() + reconnectRetryRecords[messageID] = record + + if messageIDToTransferId[messageID] == staleTransferID { + clearTransferMapping(for: messageID) + } + context.cancelTransfer(staleTransferID) + return startReconnectRetry(messageID: messageID) + } + + @discardableResult + func startReconnectRetry(messageID: String) -> Bool { + pruneExpiredReconnectRetries() + guard var record = reconnectRetryRecords[messageID], + record.activeTransferID == nil, + record.retryCount + < reconnectRetryLimits.maxRetriesPerMessage else { + return false + } + guard context.privateMediaSendPolicy(to: record.peerID) + == .encrypted, + let receiptSessionGeneration = context + .authenticatedPrivateMediaReceiptSessionGeneration( + to: record.peerID + ) else { + terminalizeReconnectRetry( + messageID: messageID, + reason: privateMediaCapabilityUnresolvedReason + ) + return false + } + + cancelReconnectRetryExpiry(messageID: messageID) + let transferID = makeTransferID(messageID: messageID) + record.retryCount += 1 + record.receiptSessionGeneration = receiptSessionGeneration + record.activeTransferID = transferID + record.retryAfterCompletion = false + record.idleOutcome = .none + record.deferredTerminalFailureReason = nil + record.expiryToken = nil + reconnectRetryRecords[messageID] = record + registerTransfer( + transferId: transferID, + messageID: messageID + ) + + SecureLogger.debug( + "🔄 Retrying private media after reconnect id=\(messageID.prefix(12))… attempt=\(record.retryCount)", + category: .session + ) + context.sendFilePrivateReceiptRetry( + record.packet, + to: record.peerID, + transferId: transferID + ) + return true + } + + func finishRetainedTransfer( + _ transferID: String, + messageID: String, + outcome: PrivateMediaReconnectRetryIdleOutcome, + rejectionReason: String? + ) { + guard var record = reconnectRetryRecords[messageID], + record.activeTransferID == transferID else { + return + } + let retryAfterCompletion = record.retryAfterCompletion + let deferredTerminalReason = + record.deferredTerminalFailureReason + record.activeTransferID = nil + record.retryAfterCompletion = false + record.idleOutcome = outcome + record.createdAt = now() + reconnectRetryRecords[messageID] = record + clearTransferMapping(for: messageID) + + if let deferredTerminalReason { + terminalizeReconnectRetry( + messageID: messageID, + reason: deferredTerminalReason + ) + } else if retryAfterCompletion { + startReconnectRetry(messageID: messageID) + } else if record.retryCount + >= reconnectRetryLimits.maxRetriesPerMessage { + terminalizeReconnectRetry( + messageID: messageID, + reason: rejectionReason + ?? privateMediaNotDeliveredReason + ) + } else { + scheduleReconnectRetryExpiry(messageID: messageID) + } + + if let rejectionReason { + SecureLogger.debug( + "Private media retry rejected id=\(messageID.prefix(12))…: \(rejectionReason)", + category: .session + ) + } + } + + func isReconnectRetryTransfer( + _ transferID: String, + messageID: String + ) -> Bool { + guard let record = reconnectRetryRecords[messageID] else { + return false + } + return record.retryCount > 0 + && record.activeTransferID == transferID + } + + func isRetainedPrivateMediaTransfer( + _ transferID: String, + messageID: String + ) -> Bool { + reconnectRetryRecords[messageID]?.activeTransferID + == transferID + } + + func discardReconnectRetry( + messageID: String, + cancelActiveTransfer: Bool + ) { + cancelReconnectRetryExpiry(messageID: messageID) + guard let record = + reconnectRetryRecords.removeValue(forKey: messageID) else { + return + } + guard cancelActiveTransfer, + let transferID = record.activeTransferID, + messageIDToTransferId[messageID] == transferID else { + return + } + // Release ownership before cancellation so its late callback cannot + // remove a remotely confirmed row. + clearTransferMapping(for: messageID) + context.cancelTransfer(transferID) + } + + func pruneExpiredReconnectRetries() { + let current = now() + let lifetime = max( + 0, + reconnectRetryLimits.retentionSeconds + ) + let expiredMessageIDs: [String] = + reconnectRetryRecords.values.compactMap { record in + guard record.activeTransferID == nil, + current.timeIntervalSince(record.createdAt) + >= lifetime else { + return nil + } + return record.messageID + } + for messageID in expiredMessageIDs { + guard let record = reconnectRetryRecords[messageID], + record.activeTransferID == nil else { + continue + } + terminalizeReconnectRetry( + messageID: messageID, + reason: expiryFailureReason(for: record) + ) + } + } + + func makeReconnectRetryCapacity(for incomingBytes: Int) { + while reconnectRetryRecords.count + >= reconnectRetryLimits.maxRetainedPackets + || retainedReconnectRetryBytes + incomingBytes + > reconnectRetryLimits.maxRetainedBytes { + guard let victim = reconnectRetryRecords.values + .filter({ $0.activeTransferID == nil }) + .min(by: { + if $0.createdAt == $1.createdAt { + return $0.messageID < $1.messageID + } + return $0.createdAt < $1.createdAt + }) else { + return + } + terminalizeReconnectRetry( + messageID: victim.messageID, + reason: expiryFailureReason(for: victim) + ) + } + } + + var privateMediaNotDeliveredReason: String { + String( + localized: "content.delivery.reason.not_delivered", + defaultValue: "Not delivered", + comment: "Failure reason shown when a private media transfer could not finish" + ) + } + + var privateMediaCapabilityUnresolvedReason: String { + String( + localized: + "content.delivery.reason.private_media_capability_unresolved", + defaultValue: "Could not confirm encrypted media support", + comment: "Failure reason when private-media capability negotiation did not resolve" + ) + } + + var privateMediaDeliveryUnconfirmedReason: String { + String( + localized: + "content.delivery.reason.private_media_delivery_unconfirmed", + defaultValue: "Delivery could not be confirmed", + comment: "Failure reason when private media left this device but no delivery receipt arrived" + ) + } + + func expiryFailureReason( + for record: PrivateMediaReconnectRetryRecord + ) -> String { + switch record.idleOutcome { + case .locallyCompleted: + return privateMediaDeliveryUnconfirmedReason + case .rejected(let reason): + return reason + case .none, .cancelled: + return privateMediaNotDeliveredReason + } + } + + func terminalizeReconnectRetry( + messageID: String, + reason: String + ) { + guard let record = reconnectRetryRecords[messageID], + record.activeTransferID == nil else { + return + } + cancelReconnectRetryExpiry(messageID: messageID) + guard reconnectRetryRecords.removeValue(forKey: messageID) != nil else { + return + } + context.updateMessageDeliveryStatus( + messageID, + status: .failed(reason: reason) + ) + } + + func scheduleReconnectRetryExpiry(messageID: String) { + guard var record = reconnectRetryRecords[messageID], + record.activeTransferID == nil else { + return + } + cancelReconnectRetryExpiry(messageID: messageID) + + let lifetime = max( + 0, + reconnectRetryLimits.retentionSeconds + ) + let elapsed = max( + 0, + now().timeIntervalSince(record.createdAt) + ) + let delay = max(0, lifetime - elapsed) + let token = UUID() + record.expiryToken = token + reconnectRetryRecords[messageID] = record + + let nanoseconds = UInt64( + min( + delay, + TimeInterval(UInt64.max) / 1_000_000_000 + ) * 1_000_000_000 + ) + reconnectRetryExpiryTasks[messageID] = Task { + @MainActor [weak self] in + if nanoseconds > 0 { + try? await Task.sleep(nanoseconds: nanoseconds) + } + guard !Task.isCancelled, + let self, + let current = + self.reconnectRetryRecords[messageID], + current.activeTransferID == nil, + current.expiryToken == token else { + return + } + self.terminalizeReconnectRetry( + messageID: messageID, + reason: self.expiryFailureReason(for: current) + ) + } + } + + func cancelReconnectRetryExpiry(messageID: String) { + reconnectRetryExpiryTasks.removeValue( + forKey: messageID + )?.cancel() + if reconnectRetryRecords[messageID]?.expiryToken != nil { + reconnectRetryRecords[messageID]?.expiryToken = nil + } + } + func applicationFilesDirectory() throws -> URL { let base = try FileManager.default.url( for: .applicationSupportDirectory, diff --git a/bitchat/ViewModels/ChatPeerListCoordinator.swift b/bitchat/ViewModels/ChatPeerListCoordinator.swift index bd045939..7646523f 100644 --- a/bitchat/ViewModels/ChatPeerListCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerListCoordinator.swift @@ -100,9 +100,14 @@ final class ChatPeerListCoordinator: @unchecked Sendable { func didUpdatePeerList(_ peers: [PeerID]) { Task { @MainActor [weak self] in - self?.handlePeerListUpdate(peers) + self?.didUpdatePeerListSynchronously(peers) } } + + @MainActor + func didUpdatePeerListSynchronously(_ peers: [PeerID]) { + handlePeerListUpdate(peers) + } } private extension ChatPeerListCoordinator { diff --git a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift index 0bfca931..e011c763 100644 --- a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift @@ -85,6 +85,11 @@ protocol ChatPrivateConversationContext: AnyObject { func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) @discardableResult func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool + /// Confirms an authenticated delivery/read acknowledgement so the router + /// stops retaining the original private message for resend. The peer + /// aliases scope the removal to the authenticated sender. + @discardableResult + func markMessageDelivered(_ messageID: String, for peerIDs: [PeerID]) -> Bool func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) @@ -166,6 +171,11 @@ extension ChatViewModel: ChatPrivateConversationContext { messageRouter.sendReadReceipt(receipt, to: peerID) } + @discardableResult + func markMessageDelivered(_ messageID: String, for peerIDs: [PeerID]) -> Bool { + messageRouter.markDelivered(messageID, for: peerIDs) + } + func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) } @@ -250,6 +260,60 @@ final class ChatPrivateConversationCoordinator { return true } + /// Account DMs can arrive under the authenticated peer's full Noise key + /// while an existing mesh conversation is keyed by its derived short ID. + /// These are the only aliases we may safely join: the short ID is derived + /// directly from the authenticated key, rather than guessed from a + /// nickname or found by scanning unrelated chats. + private func accountConversationAliases(for peerID: PeerID) -> [PeerID] { + guard peerID.noiseKey != nil else { return [peerID] } + let shortPeerID = peerID.toShort() + return shortPeerID == peerID ? [peerID] : [peerID, shortPeerID] + } + + /// Keeps a connected account DM on its short routing ID and an offline DM + /// on its stable Noise-key ID, folding the other authenticated alias into + /// it and handing an open sheet across without closing it. + private func consolidateAccountConversationAliases(for peerID: PeerID) -> PeerID { + let aliases = accountConversationAliases(for: peerID) + guard aliases.count > 1 else { return peerID } + + let shortPeerID = peerID.toShort() + let targetPeerID = context.isPeerConnected(shortPeerID) ? shortPeerID : peerID + let sourcePeerIDs = aliases.filter { $0 != targetPeerID } + + for sourcePeerID in sourcePeerIDs where !context.privateMessages(for: sourcePeerID).isEmpty { + context.migratePrivateChat(from: sourcePeerID, to: targetPeerID) + // ConversationStore deliberately preserves message values during a + // generic migration, including its destination-wins rule for + // duplicate IDs. Rewrite the resulting canonical copies so later + // read-receipt scans compare against the new routing key without + // replacing a newer destination value with the source snapshot. + let canonicalMessages = context.privateMessages(for: targetPeerID) + for message in canonicalMessages where message.senderPeerID == sourcePeerID { + context.upsertPrivateMessage( + BitchatMessage( + id: message.id, + sender: message.sender, + content: message.content, + timestamp: message.timestamp, + isRelay: message.isRelay, + originalSender: message.originalSender, + isPrivate: message.isPrivate, + recipientNickname: message.recipientNickname, + senderPeerID: targetPeerID, + mentions: message.mentions, + deliveryStatus: message.deliveryStatus, + isBridged: message.isBridged + ), + in: targetPeerID + ) + } + } + context.handOffSelectedPrivateChat(from: sourcePeerIDs, to: targetPeerID) + return targetPeerID + } + func sendPrivateMessage(_ content: String, to peerID: PeerID) { guard !content.isEmpty else { return } @@ -464,6 +528,8 @@ final class ChatPrivateConversationCoordinator { return } + let conversationPeerID = consolidateAccountConversationAliases(for: convKey) + if context.privateChatsContainMessage(withID: messageId) { return } let message = BitchatMessage( @@ -474,18 +540,18 @@ final class ChatPrivateConversationCoordinator { isRelay: false, isPrivate: true, recipientNickname: context.nickname, - senderPeerID: convKey, + senderPeerID: conversationPeerID, deliveryStatus: .delivered(to: context.nickname, at: Date()) ) - context.appendPrivateMessage(message, to: convKey) + context.appendPrivateMessage(message, to: conversationPeerID) - let isViewing = context.selectedPrivateChatPeer == convKey + let isViewing = context.selectedPrivateChatPeer == conversationPeerID let wasReadBefore = context.sentReadReceipts.contains(messageId) let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30 let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage if shouldMarkUnread { - context.markPrivateChatUnread(convKey) + context.markPrivateChatUnread(conversationPeerID) } if isViewing { @@ -493,7 +559,7 @@ final class ChatPrivateConversationCoordinator { } if !isViewing && shouldMarkUnread { - context.notifyPrivateMessage(from: senderName, message: pm.content, peerID: convKey) + context.notifyPrivateMessage(from: senderName, message: pm.content, peerID: conversationPeerID) } context.notifyUIChanged() @@ -502,17 +568,32 @@ final class ChatPrivateConversationCoordinator { func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) { guard let messageID = String(data: payload.data, encoding: .utf8) else { return } - if context.privateChat(convKey, containsMessageWithID: messageID) { - context.setPrivateDeliveryStatus( + let aliases = accountConversationAliases(for: convKey) + let clearedRetainedMessage = convKey.noiseKey != nil + ? context.markMessageDelivered(messageID, for: aliases) + : false + let hasConversationMessage = aliases.contains { + context.privateChat($0, containsMessageWithID: messageID) + } + if hasConversationMessage { + let conversationPeerID = consolidateAccountConversationAliases(for: convKey) + let didChange = context.setPrivateDeliveryStatus( .delivered(to: context.displayNameForNostrPubkey(senderPubkey), at: Date()), forMessageID: messageID, - peerID: convKey + peerID: conversationPeerID ) - context.notifyUIChanged() + if didChange { + context.notifyUIChanged() + } SecureLogger.info( "GeoDM: recv DELIVERED for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", category: .session ) + } else if clearedRetainedMessage { + SecureLogger.debug( + "GeoDM: recv DELIVERED for cleared mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", + category: .session + ) } else { // A stale ack for a message this device no longer tracks (dropped // outbox entry, cleared chat, or a peer re-acking after losing our @@ -524,14 +605,29 @@ final class ChatPrivateConversationCoordinator { func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) { guard let messageID = String(data: payload.data, encoding: .utf8) else { return } - if context.privateChat(convKey, containsMessageWithID: messageID) { - context.setPrivateDeliveryStatus( + let aliases = accountConversationAliases(for: convKey) + let clearedRetainedMessage = convKey.noiseKey != nil + ? context.markMessageDelivered(messageID, for: aliases) + : false + let hasConversationMessage = aliases.contains { + context.privateChat($0, containsMessageWithID: messageID) + } + if hasConversationMessage { + let conversationPeerID = consolidateAccountConversationAliases(for: convKey) + let didChange = context.setPrivateDeliveryStatus( .read(by: context.displayNameForNostrPubkey(senderPubkey), at: Date()), forMessageID: messageID, - peerID: convKey + peerID: conversationPeerID ) - context.notifyUIChanged() + if didChange { + context.notifyUIChanged() + } SecureLogger.info("GeoDM: recv READ for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", category: .session) + } else if clearedRetainedMessage { + SecureLogger.debug( + "GeoDM: recv READ for cleared mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", + category: .session + ) } else { SecureLogger.warning("GeoDM: read ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session) } diff --git a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift index 2cd1b4a9..301c4bee 100644 --- a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift @@ -44,6 +44,9 @@ protocol ChatPublicConversationContext: AnyObject { func removePublicMessages(fromGeohash geohash: String, where predicate: (BitchatMessage) -> Bool) /// Empties a public conversation's timeline (`/clear`). func clearPublicConversation(_ conversationID: ConversationID) + /// Erases the on-disk archive of carried public mesh messages, so clearing + /// the mesh timeline deletes that history instead of only hiding it. + func purgeArchivedPublicMessages() /// Queues a system message for the next geohash channel visit. func queueGeohashSystemMessage(_ content: String) @@ -289,12 +292,14 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { context.clearPublicConversation(ConversationID(channelID: context.activeChannel)) // Clearing the mesh timeline also dismisses its archived echoes for - // good: the watermark stops the next launch from re-seeding them - // (the archive itself keeps carrying the messages for peers), and - // the dedup keys go so a cleared message arriving live shows again. + // good: the watermark stops the next launch from re-seeding them, the + // archive on disk is erased so the cleared history is actually gone + // rather than merely hidden, and the dedup keys go so a cleared + // message arriving live shows again. if case .mesh = context.activeChannel { MeshEchoSettings.clearedThrough = Date() archivedEchoKeys.removeAll() + context.purgeArchivedPublicMessages() } // The SPM test process shares the real Application Support tree, so this diff --git a/bitchat/ViewModels/ChatTransportEventCoordinator.swift b/bitchat/ViewModels/ChatTransportEventCoordinator.swift index 1ec74850..ee2d8b6c 100644 --- a/bitchat/ViewModels/ChatTransportEventCoordinator.swift +++ b/bitchat/ViewModels/ChatTransportEventCoordinator.swift @@ -62,10 +62,15 @@ protocol ChatTransportEventContext: AnyObject { func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) // MARK: Delivery status - /// Applies the status to every known location of the message. - /// Returns `false` when no message with that ID was updated. + /// Applies an authenticated receipt to the message only when it belongs + /// to the supplied peer conversation aliases. Returns `false` for an + /// unknown ID, wrong peer, or rejected status transition. @discardableResult - func applyMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool + func applyAcknowledgedMessageDeliveryStatus( + _ messageID: String, + status: DeliveryStatus, + from peerIDAliases: Set + ) -> Bool func deliveryStatus(for messageID: String) -> DeliveryStatus? // MARK: Verification payloads @@ -122,8 +127,16 @@ extension ChatViewModel: ChatTransportEventContext { } @discardableResult - func applyMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool { - deliveryCoordinator.updateMessageDeliveryStatus(messageID, status: status) + func applyAcknowledgedMessageDeliveryStatus( + _ messageID: String, + status: DeliveryStatus, + from peerIDAliases: Set + ) -> Bool { + deliveryCoordinator.updateAcknowledgedMessageDeliveryStatus( + messageID, + status: status, + from: peerIDAliases + ) } func deliveryStatus(for messageID: String) -> DeliveryStatus? { @@ -163,21 +176,20 @@ final class ChatTransportEventCoordinator { } func didReceiveMessage(_ message: BitchatMessage) { - runOnMain { context in - guard !context.isMessageBlocked(message) else { return } - guard !message.content.trimmed.isEmpty || message.isPrivate else { return } - - if message.isPrivate { - context.handlePrivateMessage(message) - } else { - context.handlePublicMessage(message) - } - - context.checkForMentions(message) - context.sendHapticFeedback(for: message) + runOnMain { [self] context in + handleReceivedMessage(message, in: context) } } + /// Typed transport events already arrive on the main actor. Handle them + /// synchronously so observers see the ConversationStore mutation before + /// the transport completes delivery. + @MainActor + @discardableResult + func didReceiveMessageSynchronously(_ message: BitchatMessage) -> Bool { + handleReceivedMessage(message, in: context) + } + func didReceivePublicMessage( from peerID: PeerID, nickname: String, @@ -185,28 +197,36 @@ final class ChatTransportEventCoordinator { timestamp: Date, messageID: String? ) { - runOnMain { context in - let normalized = content.trimmed - let mentions = context.parseMentions(from: normalized) - let message = BitchatMessage( - id: messageID, - sender: nickname, - content: normalized, + runOnMain { [self] context in + handlePublicMessage( + from: peerID, + nickname: nickname, + content: content, timestamp: timestamp, - isRelay: false, - originalSender: nil, - isPrivate: false, - recipientNickname: nil, - senderPeerID: peerID, - mentions: mentions.isEmpty ? nil : mentions + messageID: messageID, + in: context ) - - context.handlePublicMessage(message) - context.checkForMentions(message) - context.sendHapticFeedback(for: message) } } + @MainActor + func didReceivePublicMessageSynchronously( + from peerID: PeerID, + nickname: String, + content: String, + timestamp: Date, + messageID: String? + ) { + handlePublicMessage( + from: peerID, + nickname: nickname, + content: content, + timestamp: timestamp, + messageID: messageID, + in: context + ) + } + func didReceiveNoisePayload( from peerID: PeerID, type: NoisePayloadType, @@ -224,59 +244,134 @@ final class ChatTransportEventCoordinator { } } + @MainActor + func didReceiveNoisePayloadSynchronously( + from peerID: PeerID, + type: NoisePayloadType, + payload: Data, + timestamp: Date + ) { + handleNoisePayload( + from: peerID, + type: type, + payload: payload, + timestamp: timestamp, + in: context + ) + } + func didConnectToPeer(_ peerID: PeerID) { - SecureLogger.debug("🤝 Peer connected: \(peerID)", category: .session) - - runOnMain { context in - context.isConnected = true - context.registerEphemeralSession(peerID: peerID) - context.notifyUIChanged() - - if let peer = context.unifiedPeer(for: peerID) { - let stablePeerID = PeerID(hexData: peer.noisePublicKey) - context.cacheStablePeerID(stablePeerID, for: peerID) - } - - context.flushRouterOutbox(for: peerID) - context.retryCourierDeposits(via: peerID) + runOnMain { [weak self] _ in + self?.didConnectToPeerSynchronously(peerID) } } + @MainActor + func didConnectToPeerSynchronously(_ peerID: PeerID) { + SecureLogger.debug("🤝 Peer connected: \(peerID)", category: .session) + + context.isConnected = true + context.registerEphemeralSession(peerID: peerID) + context.notifyUIChanged() + + if let peer = context.unifiedPeer(for: peerID) { + let stablePeerID = PeerID(hexData: peer.noisePublicKey) + context.cacheStablePeerID(stablePeerID, for: peerID) + } + + context.flushRouterOutbox(for: peerID) + context.retryCourierDeposits(via: peerID) + } + func didDisconnectFromPeer(_ peerID: PeerID) { + runOnMain { [weak self] _ in + self?.didDisconnectFromPeerSynchronously(peerID) + } + } + + @MainActor + func didDisconnectFromPeerSynchronously(_ peerID: PeerID) { SecureLogger.debug("👋 Peer disconnected: \(peerID)", category: .session) - runOnMain { context in - context.removeEphemeralSession(peerID: peerID) + context.removeEphemeralSession(peerID: peerID) - var stablePeerID = context.cachedStablePeerID(for: peerID) - if stablePeerID == nil, - let key = context.noiseSessionPublicKeyData(for: peerID) { - let derivedPeerID = PeerID(hexData: key) - context.cacheStablePeerID(derivedPeerID, for: peerID) - stablePeerID = derivedPeerID - } - - if let currentPeerID = context.selectedPrivateChatPeer, - currentPeerID == peerID, - let stablePeerID { - self.migrateSelectedConversationIfNeeded( - from: peerID, - to: stablePeerID, - in: context - ) - } - - let receiptIDs = context.privateMessages(for: peerID) - .filter { $0.senderPeerID == peerID } - .map(\.id) - context.unmarkReadReceiptsSent(receiptIDs) - - context.notifyUIChanged() + var stablePeerID = context.cachedStablePeerID(for: peerID) + if stablePeerID == nil, + let key = context.noiseSessionPublicKeyData(for: peerID) { + let derivedPeerID = PeerID(hexData: key) + context.cacheStablePeerID(derivedPeerID, for: peerID) + stablePeerID = derivedPeerID } + + if let currentPeerID = context.selectedPrivateChatPeer, + currentPeerID == peerID, + let stablePeerID { + migrateSelectedConversationIfNeeded( + from: peerID, + to: stablePeerID, + in: context + ) + } + + let receiptIDs = context.privateMessages(for: peerID) + .filter { $0.senderPeerID == peerID } + .map(\.id) + context.unmarkReadReceiptsSent(receiptIDs) + + context.notifyUIChanged() } } private extension ChatTransportEventCoordinator { + @MainActor + func handlePublicMessage( + from peerID: PeerID, + nickname: String, + content: String, + timestamp: Date, + messageID: String?, + in context: any ChatTransportEventContext + ) { + let normalized = content.trimmed + let mentions = context.parseMentions(from: normalized) + let message = BitchatMessage( + id: messageID, + sender: nickname, + content: normalized, + timestamp: timestamp, + isRelay: false, + originalSender: nil, + isPrivate: false, + recipientNickname: nil, + senderPeerID: peerID, + mentions: mentions.isEmpty ? nil : mentions + ) + + context.handlePublicMessage(message) + context.checkForMentions(message) + context.sendHapticFeedback(for: message) + } + + @MainActor + @discardableResult + func handleReceivedMessage( + _ message: BitchatMessage, + in context: any ChatTransportEventContext + ) -> Bool { + guard !context.isMessageBlocked(message) else { return false } + guard !message.content.trimmed.isEmpty || message.isPrivate else { return false } + + if message.isPrivate { + context.handlePrivateMessage(message) + } else { + context.handlePublicMessage(message) + } + + context.checkForMentions(message) + context.sendHapticFeedback(for: message) + return true + } + func runOnMain(_ action: @escaping @MainActor (any ChatTransportEventContext) -> Void) { Task { @MainActor [weak context = self.context] in guard let context else { return } @@ -364,9 +459,10 @@ private extension ChatTransportEventCoordinator { guard let messageID = String(data: payload, encoding: .utf8) else { return } let name = deliveryStatusName(for: peerID, in: context) - let didUpdate = context.applyMessageDeliveryStatus( + let didUpdate = context.applyAcknowledgedMessageDeliveryStatus( messageID, - status: .delivered(to: name, at: Date()) + status: .delivered(to: name, at: Date()), + from: receiptPeerAliases(for: peerID, in: context) ) if !didUpdate { @@ -381,9 +477,10 @@ private extension ChatTransportEventCoordinator { guard let messageID = String(data: payload, encoding: .utf8) else { return } let name = deliveryStatusName(for: peerID, in: context) - let didUpdate = context.applyMessageDeliveryStatus( + let didUpdate = context.applyAcknowledgedMessageDeliveryStatus( messageID, - status: .read(by: name, at: Date()) + status: .read(by: name, at: Date()), + from: receiptPeerAliases(for: peerID, in: context) ) if !didUpdate { @@ -407,6 +504,13 @@ private extension ChatTransportEventCoordinator { case .voiceFrame: context.handleVoiceFramePayload(from: peerID, payload: payload, timestamp: timestamp) + + case .privateFile, .authenticatedPeerState: + // BLEService validates and persists decrypted private files before + // emitting a normal `.messageReceived` event, and consumes peer + // state inside the transport. Neither payload crosses this + // UI-facing typed-payload fallback. + break } } @@ -414,4 +518,21 @@ private extension ChatTransportEventCoordinator { func deliveryStatusName(for peerID: PeerID, in context: any ChatTransportEventContext) -> String { context.unifiedPeer(for: peerID)?.nickname ?? context.resolveNickname(for: peerID) } + + @MainActor + func receiptPeerAliases( + for peerID: PeerID, + in context: any ChatTransportEventContext + ) -> Set { + var aliases: Set = [peerID] + // The active authenticated Noise key is authoritative. A cached + // ephemeral→stable 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 + } } diff --git a/bitchat/ViewModels/ChatVerificationCoordinator.swift b/bitchat/ViewModels/ChatVerificationCoordinator.swift index 3b78797d..2de291ff 100644 --- a/bitchat/ViewModels/ChatVerificationCoordinator.swift +++ b/bitchat/ViewModels/ChatVerificationCoordinator.swift @@ -58,6 +58,11 @@ protocol ChatVerificationContext: AnyObject { func noiseStaticPublicKeyData() -> Data func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool func triggerHandshake(with 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 sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) @@ -116,6 +121,14 @@ extension ChatViewModel: ChatVerificationContext { meshService.noiseStaticPublicKeyData() } + func privateMediaPeerDidAuthenticate(_ peerID: PeerID) { + mediaTransferCoordinator.peerDidAuthenticate(peerID.toShort()) + } + + func retrySecurePrivateMessagesAfterAuthentication(for peerIDAliases: [PeerID]) { + messageRouter.retrySecurePrivateMessagesAfterAuthentication(for: peerIDAliases) + } + func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA) } @@ -129,6 +142,10 @@ extension ChatViewModel: ChatVerificationContext { } } +extension ChatVerificationContext { + func privateMediaPeerDidAuthenticate(_ peerID: PeerID) {} +} + @MainActor final class ChatVerificationCoordinator { struct PendingVerification { @@ -197,6 +214,7 @@ final class ChatVerificationCoordinator { guard let self else { return } SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security) + self.context.privateMediaPeerDidAuthenticate(peerID) if self.context.isVerifiedFingerprint(fingerprint) { self.context.setEncryptionStatus(.noiseVerified, for: peerID) @@ -206,16 +224,37 @@ final class ChatVerificationCoordinator { self.context.invalidateEncryptionCache(for: peerID) - if self.context.cachedStablePeerID(for: peerID) == nil, - let keyData = self.context.noiseSessionPublicKeyData(for: peerID) { + var authenticatedStablePeerID: PeerID? + if let keyData = self.context.noiseSessionPublicKeyData(for: peerID) { 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( "🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stablePeerID.id.prefix(8))…", 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 { self.context.sendVerifyChallenge( to: peerID, diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 4ab0eea8..197616e7 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -89,10 +89,40 @@ import UIKit #endif import UniformTypeIdentifiers +struct PanicNetworkLifecycle { + let stop: @MainActor () -> Void + let restart: @MainActor () -> Void + + static let noop = PanicNetworkLifecycle(stop: {}, restart: {}) + + static var live: PanicNetworkLifecycle { + PanicNetworkLifecycle( + stop: { + GeohashPresenceService.shared.stopForPanic() + NetworkActivationService.shared.stopForPanic() + }, + restart: { + NetworkActivationService.shared.start() + GeohashPresenceService.shared.start() + } + ) + } +} + +private struct PendingPrivateChatClear { + let peerID: PeerID + let sourceConversationID: ConversationID + let messages: [BitchatMessage] + let otherMessageIDs: Set + let localPeerID: PeerID + let nickname: String + let outgoingMedia: [BitchatMessage] +} + /// Manages the application state and business logic for BitChat. /// Acts as the primary coordinator between UI components and backend services, /// implementing the BitchatDelegate protocol to handle network events. -final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDelegate, CommandContextProvider, GeohashParticipantContext, MessageFormattingContext { +final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessageTransportEventDelegate, CommandContextProvider, GeohashParticipantContext, MessageFormattingContext { // Use MessageFormattingEngine.Patterns for regex matching (shared, precompiled) typealias Patterns = MessageFormattingEngine.Patterns @@ -142,6 +172,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele @Published var currentColorScheme: ColorScheme = .light @Published var currentTheme: AppTheme = .matrix @Published var isConnected = false + @Published private(set) var panicRecoveryBlocked = false + var networkActivationAllowed: Bool { !panicRecoveryBlocked } @Published var nickname: String = "" { didSet { // Trim whitespace whenever nickname is set; whitespace-only becomes "" @@ -151,7 +183,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele return } // Update mesh service nickname if it's initialized - if !meshService.myPeerID.isEmpty { + if !isPanicResetting, !meshService.myPeerID.isEmpty { meshService.setNickname(nickname) } } @@ -177,7 +209,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(context: self) lazy var nostrCoordinator = ChatNostrCoordinator(context: self) lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self) - lazy var liveVoiceCoordinator = ChatLiveVoiceCoordinator(context: self) + lazy var liveVoiceCoordinator = ChatLiveVoiceCoordinator( + context: self, + sweepsOnInit: !TestEnvironment.isRunningTests + ) lazy var verificationCoordinator = ChatVerificationCoordinator(context: self) lazy var groupCoordinator = ChatGroupCoordinator(context: self) lazy var vouchCoordinator = ChatVouchCoordinator(context: self) @@ -292,6 +327,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele var nostrRelayManager: NostrRelayManager? private let userDefaults = UserDefaults.standard let keychain: KeychainManagerProtocol + private let panicRecoveryOperations: PanicRecoveryOperations + private let panicNetworkLifecycle: PanicNetworkLifecycle + private var isPanicResetting = false /// Private group membership: keys in the keychain, metadata on disk. let groupStore: GroupStore private let nicknameKey = "bitchat.nickname" @@ -325,6 +363,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // Track whether a Tor restart is pending so we only announce // "tor restarted" after an actual restart, not the first launch. var torRestartPending: Bool = false + // Announce a stalled bootstrap once per attempt, not once per poll. + var torStallAnnounced: Bool = false // Ensure we set up DM subscription only once per app session var nostrHandlersSetup: Bool = false var geoChannelCoordinator: GeoChannelCoordinator? @@ -347,6 +387,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele @Published var showBluetoothAlert = false @Published var bluetoothAlertMessage = "" @Published var bluetoothState: CBManagerState = .unknown + @Published private(set) var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest? + @MainActor private var queuedPrivateChatClears: [ + PendingPrivateChatClear + ] = [] + @MainActor private var privateChatClearInFlight = false + @MainActor private var privateChatClearGeneration: UInt64 = 0 + private var pendingLegacyPrivateMediaConsents: [PendingLegacyPrivateMediaConsent] = [] private func performDeliveryUpdate(_ update: @escaping @MainActor (ChatDeliveryCoordinator) -> Void) { if Thread.isMainThread { @@ -465,6 +512,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele } } + /// Whether a read receipt has already been recorded for `messageID`. + @MainActor + func hasSentReadReceipt(_ messageID: String) -> Bool { + sentReadReceipts.contains(messageID) + } + /// Records that a read receipt is being sent for `messageID`. /// Returns `false` when one was already recorded — the caller must skip sending. @MainActor @@ -607,7 +660,285 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele /// Empties the peer's chat but keeps the conversation alive (`/clear`). @MainActor func clearPrivateChat(_ peerID: PeerID) { - conversations.clear(.directPeer(peerID)) + let sourceConversationID = ConversationID.directPeer(peerID) + // An active live-voice row owns an open FileHandle and may be + // republished as frames/final media arrive. Treat it like an in-flight + // arrival rather than unlinking its capture or removing its bubble. + let messages = privateMessages(for: peerID).filter { + !liveVoiceCoordinator.isLiveVoiceMessage($0) + } + let localPeerID = meshService.myPeerID.toShort() + let currentNickname = nickname + let mediaPrefixes = [ + MimeType.Category.audio.messagePrefix, + MimeType.Category.image.messagePrefix, + MimeType.Category.file.messagePrefix + ] + let outgoingMedia = messages.filter { message in + guard mediaPrefixes.contains(where: { + message.content.hasPrefix($0) + }) else { + return false + } + if let senderPeerID = message.senderPeerID { + return senderPeerID.toShort() == localPeerID + } + return message.sender == currentNickname + || message.sender.hasPrefix(currentNickname + "#") + } + + // Send ownership is canceled at command time even when another clear + // transaction is ahead in the queue. UI and files remain untouched + // until this request's receiver journal commit succeeds. + for message in outgoingMedia { + mediaTransferCoordinator + .cancelMediaTransferForConversationClear( + messageID: message.id + ) + } + + queuedPrivateChatClears.append(PendingPrivateChatClear( + peerID: peerID, + sourceConversationID: sourceConversationID, + messages: messages, + otherMessageIDs: Set( + privateChats + .filter { $0.key != peerID } + .flatMap { $0.value.map(\.id) } + ), + localPeerID: localPeerID, + nickname: currentNickname, + outgoingMedia: outgoingMedia + )) + startNextPrivateChatClearIfNeeded() + } + + @MainActor + private func startNextPrivateChatClearIfNeeded() { + guard !privateChatClearInFlight, + !queuedPrivateChatClears.isEmpty else { + return + } + privateChatClearInFlight = true + let request = queuedPrivateChatClears.removeFirst() + let generation = privateChatClearGeneration + performPrivateChatClear( + request, + generation: generation + ) { [weak self] in + guard let self, + self.privateChatClearGeneration == generation else { + return + } + self.privateChatClearInFlight = false + self.startNextPrivateChatClearIfNeeded() + } + } + + @MainActor + private func performPrivateChatClear( + _ request: PendingPrivateChatClear, + generation: UInt64, + completion: @escaping @MainActor () -> Void + ) { + guard privateChatClearGeneration == generation else { + completion() + return + } + let peerID = request.peerID + let selectedConversationID = request.sourceConversationID + let messagesToClear = request.messages + guard !messagesToClear.isEmpty else { + completion() + return + } + + // Capture the transaction's exact UI set before any off-main receipt + // I/O. Messages arriving while the journal is written are not part of + // this command and must remain visible. + let capturedMessageIDs = Set(messagesToClear.map(\.id)) + let survivingMessageIDs = request.otherMessageIDs + let mediaPrefixes = [ + MimeType.Category.audio.messagePrefix, + MimeType.Category.image.messagePrefix, + MimeType.Category.file.messagePrefix + ] + let localPeerID = request.localPeerID + let isMedia: (BitchatMessage) -> Bool = { message in + mediaPrefixes.contains(where: message.content.hasPrefix) + } + let isFromMe: (BitchatMessage) -> Bool = { [nickname = request.nickname] message in + if let senderPeerID = message.senderPeerID { + return senderPeerID.toShort() == localPeerID + } + return message.sender == nickname + || message.sender.hasPrefix(nickname + "#") + } + + let outgoingMedia = request.outgoingMedia + + let capturedExclusiveIDs = + capturedMessageIDs.subtracting(survivingMessageIDs) + let capturedIncomingMedia = messagesToClear.filter { + isMedia($0) && !isFromMe($0) + } + let capturedStableMediaIDs = Set( + capturedIncomingMedia.compactMap { message in + PrivateMediaMessageIdentity.isStableID(message.id) + ? message.id + : nil + } + ) + + func currentRemovalPlan() -> [ConversationID: Set] { + // Identity handoff removes the source conversation and inserts its + // rows elsewhere. The old source may then be recreated by a new + // arrival before journal I/O finishes, so always scan all direct + // conversations. Only IDs exclusive at command time may follow a + // migration; shared aliases remain outside the source. + var plan: [ConversationID: Set] = [:] + for (conversationID, conversation) in + conversations.conversationsByID { + guard case .direct = conversationID else { continue } + let eligibleIDs = conversationID == selectedConversationID + ? capturedMessageIDs + : capturedExclusiveIDs + let matchingIDs = Set(conversation.messages.map(\.id)) + .intersection(eligibleIDs) + if !matchingIDs.isEmpty { + plan[conversationID] = matchingIDs + } + } + return plan + } + + func hasRemainingCopy( + of messageID: String, + after plan: [ConversationID: Set] + ) -> Bool { + conversations.conversationsByID.contains { conversationID, conversation in + guard case .direct = conversationID else { return false } + return conversation.messages.contains { message in + message.id == messageID + && plan[conversationID]?.contains(messageID) != true + } + } + } + + @MainActor + func continueClear( + persisted: Bool, + durableStableIDs: Set + ) { + guard privateChatClearGeneration == generation else { + completion() + return + } + guard persisted else { + SecureLogger.error( + "Refusing to clear private chat without durable media tombstones peer=\(peerID.id.prefix(8))…", + category: .session + ) + notifyPrivateMediaDeletionRefused(peerID: peerID) + completion() + return + } + + let plan = currentRemovalPlan() + let newlyLastStableIDs = Set( + capturedStableMediaIDs.filter { + !durableStableIDs.contains($0) + && !hasRemainingCopy(of: $0, after: plan) + } + ) + if !newlyLastStableIDs.isEmpty { + persistDeletedPrivateMedia( + messageIDs: Array(newlyLastStableIDs).sorted() + ) { persisted in + continueClear( + persisted: persisted, + durableStableIDs: + durableStableIDs.union(newlyLastStableIDs) + ) + } + return + } + + // A stable receiver tombstone is global for that message ID. + // Remove any alias that arrived while journal I/O was in flight. + if !durableStableIDs.isEmpty { + let directConversationIDs = conversations + .conversationsByID.keys.filter { + if case .direct = $0 { return true } + return false + } + for conversationID in directConversationIDs { + conversations.removeMessages(from: conversationID) { + durableStableIDs.contains($0.id) + } + } + } + + // Outgoing media mirrors the incoming alias protection: an ID + // 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( + forMessage: message + ) + } + let removableOutgoingIDs = Set( + removableOutgoingMedia.map(\.id) + ) + if !removableOutgoingIDs.isEmpty { + let directConversationIDs = conversations + .conversationsByID.keys.filter { + if case .direct = $0 { return true } + return false + } + for conversationID in directConversationIDs { + conversations.removeMessages(from: conversationID) { + removableOutgoingIDs.contains($0.id) + } + } + } + + // Stable payload cleanup belongs entirely to the durable receiver + // journal. Legacy/raw incoming payloads have no durable identity, + // so once their bubbles are gone the transport's gated cleanup + // decides per basename: unlink when unreferenced, or leave any + // pending/reserved path for bounded quota cleanup. + let finalPlan = currentRemovalPlan() + + for (conversationID, messageIDs) in finalPlan { + conversations.removeMessages(from: conversationID) { + messageIDs.contains($0.id) + } + } + cleanupLegacyIncomingMediaPayloads(for: capturedIncomingMedia) + completion() + } + + let initialPlan = currentRemovalPlan() + let initialStableIDs = Set( + capturedStableMediaIDs.filter { + !hasRemainingCopy(of: $0, after: initialPlan) + } + ) + persistDeletedPrivateMedia( + messageIDs: Array(initialStableIDs).sorted() + ) { persisted in + continueClear( + persisted: persisted, + durableStableIDs: initialStableIDs + ) + } } /// Removes the peer's chat entirely, including unread state. @@ -737,6 +1068,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele conversations.clear(conversationID) } + func purgeArchivedPublicMessages() { + meshService.purgeAllArchivedPublicMessages() + } + /// Queues a system message for the next geohash channel visit. (Tiny /// UI-flow queue formerly on `PublicTimelineStore`; it is notice text, /// not conversation state, so it stays on the owner.) @@ -769,7 +1104,34 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele locationPresenceStore: LocationPresenceStore? = nil, locationManager: LocationChannelManager = .shared ) { - let meshService = BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager) + let livePanicRecoveryOperations = PanicRecoveryOperations.live() + let startSuspendedForRecovery: Bool + do { + startSuspendedForRecovery = + try livePanicRecoveryOperations.isPending() + } catch { + startSuspendedForRecovery = true + } + // Preserve the preflight decision used to defer CoreBluetooth. A + // transiently successful second read must not skip recovery and leave + // the service permanently suspended without running the wipe. + let panicRecoveryOperations = PanicRecoveryOperations( + isPending: { + if startSuspendedForRecovery { + return true + } + return try livePanicRecoveryOperations.isPending() + }, + begin: livePanicRecoveryOperations.begin, + wipeMedia: livePanicRecoveryOperations.wipeMedia, + complete: livePanicRecoveryOperations.complete + ) + let meshService = BLEService( + keychain: keychain, + idBridge: idBridge, + identityManager: identityManager, + startSuspendedForPanicRecovery: startSuspendedForRecovery + ) meshService.sfMetrics = .shared self.init( keychain: keychain, @@ -781,7 +1143,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(), locationManager: locationManager, outboxStore: MessageOutboxStore(keychain: keychain), - sfMetrics: .shared + sfMetrics: .shared, + panicRecoveryOperations: panicRecoveryOperations, + panicNetworkLifecycle: .live ) } @@ -799,7 +1163,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele locationManager: LocationChannelManager = .shared, readReceiptsDefaults: UserDefaults? = nil, outboxStore: MessageOutboxStore? = nil, - sfMetrics: StoreAndForwardMetrics? = nil + sfMetrics: StoreAndForwardMetrics? = nil, + panicMediaWipe: (() throws -> Void)? = nil, + panicRecoveryOperations: PanicRecoveryOperations? = nil, + panicNetworkLifecycle: PanicNetworkLifecycle = .noop ) { let conversations = conversations ?? ConversationStore() let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore() @@ -814,6 +1181,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele ) self.keychain = keychain + self.panicRecoveryOperations = panicRecoveryOperations + ?? .ephemeral(wipeMedia: panicMediaWipe ?? {}) + self.panicNetworkLifecycle = panicNetworkLifecycle self.groupStore = GroupStore(keychain: keychain) self.idBridge = idBridge self.identityManager = identityManager @@ -849,7 +1219,31 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele } .store(in: &cancellables) - ChatViewModelBootstrapper(viewModel: self).configure() + let recoveryRequired: Bool + do { + recoveryRequired = try self.panicRecoveryOperations.isPending() + } catch { + // Failure to read the latch cannot fail open. Re-run the complete + // transaction; a persistent storage failure leaves services + // blocked below. + recoveryRequired = true + SecureLogger.error( + "Could not read panic-recovery state; retrying the full wipe before startup: \(error)", + category: .security + ) + } + + if recoveryRequired { + SecureLogger.warning( + "Pending panic recovery detected; wiping before runtime services start", + category: .security + ) + _ = panicClearAllData(restartServices: false) + } + + if networkActivationAllowed { + ChatViewModelBootstrapper(viewModel: self).configure() + } } // MARK: - Deinitialization @@ -1153,8 +1547,40 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // PANIC: Emergency data clearing for activist safety @MainActor - func panicClearAllData() { - // Messages are processed immediately - nothing to flush + @discardableResult + func panicClearAllData(restartServices: Bool = true) -> Bool { + panicRecoveryBlocked = true + isPanicResetting = true + defer { isPanicResetting = false } + + // Stop internet and location-presence work before clearing identity or + // state. These services cancel their subscriptions and delayed tasks, + // so old callbacks cannot reconnect during the transaction. + panicNetworkLifecycle.stop() + + // Establish both independent durable intents before erasing anything. + // `wipeMedia` will still attempt deletion if neither write succeeds. + let recoveryIntent = panicRecoveryOperations.begin() + + // Quiesce the mesh before clearing stores. Identity replacement below + // deliberately stays stopped until media deletion and marker commit. + if let bleService = meshService as? BLEService { + bleService.suspendForPanicReset() + } else { + meshService.emergencyDisconnectAll() + } + + // Invalidate detached media preparation and close live capture file + // handles before clearing state or removing the media directory. + mediaTransferCoordinator.resetForPanic() + liveVoiceCoordinator.resetForPanic() + privateChatClearGeneration &+= 1 + queuedPrivateChatClears.removeAll(keepingCapacity: false) + privateChatClearInFlight = false + + // Deny and release any clear-media confirmations before identities, + // message state, and local files are wiped. + cancelAllLegacyPrivateMediaConsents() // Clear all messages (public timelines and private chats live in the // single-writer ConversationStore; the derived `messages` view and @@ -1163,7 +1589,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele pendingGeohashSystemMessages.removeAll() // Delete all keychain data (including Noise and Nostr keys) - _ = keychain.deleteAllKeychainData() + let keychainWipeCompleted = keychain.deleteAllKeychainData() + if !keychainWipeCompleted { + SecureLogger.error( + "Panic keychain cleanup incomplete; recovery remains pending", + category: .security + ) + } // Clear UserDefaults identity data userDefaults.removeObject(forKey: "bitchat.noiseIdentityKey") @@ -1176,7 +1608,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // Reset nickname to anonymous nickname = "anon\(Int.random(in: 1000...9999))" - saveNickname() + userDefaults.set(nickname, forKey: nicknameKey) // Clear favorites and peer mappings // Clear through SecureIdentityStateManager instead of directly @@ -1202,6 +1634,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele GeohashChatActivityTracker.shared.clear() MeshSightingsTracker.shared.clear() MeshEchoSettings.reset() + NotificationPrivacySettings.reset() + // A hand-added relay names an operator someone chose to route through, + // which is the kind of trace a wipe should not leave behind. + NostrRelaySettings.reset() // Drop private group keys and rosters (keychain + disk) groupStore.wipe() @@ -1213,6 +1649,15 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // posts are signed with our identity key and persist for days. BoardStore.shared.wipe() + // Drop any share-extension handoff staged in the app group. The normal + // panic path clears this through AppChromeModel.onPanicWipe, but the + // crash-recovery replay calls this method directly and would otherwise + // let a staged envelope survive the wipe. Clearing here is idempotent + // (it only removes the app-group key), so the double-clear is harmless. + if let sharedDefaults = UserDefaults(suiteName: BitchatApp.groupID) { + SharedContentStore(defaults: sharedDefaults).discardAll() + } + // Identity manager has cleared persisted identity data above // Clear autocomplete state @@ -1243,83 +1688,87 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // Geohash DM handlers can capture pre-wipe Nostr identities, so a plain // disconnect is not enough here. NostrRelayManager.shared.resetForPanicWipe() + // Clearing relay handlers stops NEW events, but a detached gift-wrap + // decrypt spawned just before the wipe still holds a pre-wipe key and + // ciphertext; bump the pipeline's wipe generation so its result is + // dropped at the main-actor delivery hop instead of landing here. + nostrCoordinator.inbound.invalidateInFlightDecrypts() nostrRelayManager = nil // Clear Nostr identity associations idBridge.clearAllAssociations() - // Disconnect from all peers and clear persistent identity - // This will force creation of a new identity (new fingerprint) on next launch - meshService.emergencyDisconnectAll() + // Replace the BLE identity while keeping the radio stopped. It may + // reopen only after the durable panic transaction commits. if let bleService = meshService as? BLEService { - bleService.resetIdentityForPanic(currentNickname: nickname) + bleService.resetIdentityForPanic( + currentNickname: nickname, + restartServices: false + ) + } else { + meshService.setNickname(nickname) } - // No need to force UserDefaults synchronization + // The wipe must finish before this security action returns. A detached + // task could otherwise lose a race with a new capture or app exit and + // leave pre-panic media behind. + let panicCompleted: Bool + do { + try panicRecoveryOperations.wipeMedia(recoveryIntent) + if keychainWipeCompleted { + try panicRecoveryOperations.complete() + panicCompleted = true + SecureLogger.info( + "🗑️ Deleted all media files during panic clear", + category: .session + ) + } else { + // Do not clear either durable recovery marker. Startup must + // retry the entire transaction before any transport restarts. + panicCompleted = false + } + } catch { + panicCompleted = false + SecureLogger.error( + "Panic transaction did not commit; services remain stopped: \(error)", + category: .security + ) + } + panicRecoveryBlocked = !panicCompleted - // Reinitialize Nostr with new identity - // This will generate new Nostr keys derived from new Noise keys. - // Skipped under tests: connecting the shared relay singleton starts - // real network/reconnect work that never completes and would keep the - // test process alive (the singleton, unlike a discardable instance, is - // never deallocated to cancel it). + // BCH-01-013: Clear iOS app switcher snapshots. Keep tests away from + // the host user's real cache tree just as the default media wipe does. + #if os(iOS) if !TestEnvironment.isRunningTests { - Task { @MainActor in - // Small delay to ensure cleanup completes - try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds + Self.clearAppSwitcherSnapshots() + } + #endif - // Reinitialize Nostr relay manager with new identity. Reuse the - // shared singleton — every other component (NostrTransport, geohash - // subscriptions, AppRuntime observers) is bound to `.shared`, so - // creating a fresh instance here would split relay state and leave - // sends running against a disconnected manager. + guard panicCompleted else { return false } + + if let bleService = meshService as? BLEService { + // Startup recovery reopens admission but leaves actual service + // start to the bootstrapper immediately after this method. + bleService.completePanicReset( + restartServices: restartServices + ) + } + + if restartServices { + // All persistent state and media are gone. Bring each service back + // only now, under the new identity. + if !(meshService is BLEService) { + meshService.startServices() + } + + if !TestEnvironment.isRunningTests { nostrRelayManager = NostrRelayManager.shared setupNostrMessageHandling() - nostrRelayManager?.connect() } + panicNetworkLifecycle.restart() } - // Delete ALL media files (incoming and outgoing) in background - Task.detached(priority: .utility) { - // Skipped under tests: the test process shares the user's real - // ~/Library/Application Support/files tree, and this detached - // utility-priority wipe fires at a nondeterministic time — - // deleting media that concurrently running tests (e.g. the - // sendImage flow) just wrote there, and the developer's real - // app data with it. - guard !TestEnvironment.isRunningTests else { return } - do { - let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) - let filesDir = base.appendingPathComponent("files", isDirectory: true) - - // Delete the entire files directory and recreate it - if FileManager.default.fileExists(atPath: filesDir.path) { - try FileManager.default.removeItem(at: filesDir) - SecureLogger.info("🗑️ Deleted all media files during panic clear", category: .session) - } - - // Recreate empty directory structure - try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil) - try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("voicenotes/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil) - try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("voicenotes/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil) - try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("images/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil) - try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("images/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil) - try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("files/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil) - try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("files/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil) - } catch { - SecureLogger.error("Failed to clear media files during panic: \(error)", category: .session) - } - - // BCH-01-013: Clear iOS app switcher snapshots - // These are stored in Library/Caches/Snapshots// - #if os(iOS) - Self.clearAppSwitcherSnapshots() - #endif - } - - // Force immediate UI update for panic mode - // UI updates immediately - no flushing needed - + return true } /// BCH-01-013: Clear iOS app switcher snapshots during panic mode @@ -1578,7 +2027,83 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele @MainActor func didReceiveTransportEvent(_ event: TransportEvent) { - receiveTransportEvent(event) + switch event { + case .messageReceived(let message): + _ = didReceiveTransportMessageSynchronously(message) + + case let .publicMessageReceived( + peerID, + nickname, + content, + timestamp, + messageID + ): + transportEventCoordinator.didReceivePublicMessageSynchronously( + from: peerID, + nickname: nickname, + content: content, + timestamp: timestamp, + messageID: messageID + ) + + case let .noisePayloadReceived(peerID, type, payload, timestamp): + transportEventCoordinator.didReceiveNoisePayloadSynchronously( + from: peerID, + type: type, + payload: payload, + timestamp: timestamp + ) + + case let .groupMessageReceived(payload, timestamp): + groupCoordinator.handleGroupMessagePayload( + payload, + timestamp: timestamp + ) + + case let .publicVoiceFrameReceived( + peerID, + nickname, + payload, + timestamp + ): + liveVoiceCoordinator.handlePublicVoiceFramePayload( + from: peerID, + nickname: nickname, + payload: payload, + timestamp: timestamp + ) + + case .peerConnected(let peerID): + transportEventCoordinator.didConnectToPeerSynchronously(peerID) + mediaTransferCoordinator.peerDidReconnect(peerID) + + case .peerDisconnected(let peerID): + transportEventCoordinator.didDisconnectFromPeerSynchronously(peerID) + mediaTransferCoordinator.peerDidDisconnect(peerID) + + case .peerListUpdated(let peers): + peerListCoordinator.didUpdatePeerListSynchronously(peers) + // A peer-list update follows every verified announce, which is + // where a peer's `.vouch` capability actually arrives. + vouchCoordinator.peersUpdated(peers) + + case .peerSnapshotsUpdated: + break + + case let .messageDeliveryStatusUpdated(messageID, status): + deliveryCoordinator.didUpdateMessageDeliveryStatus( + messageID, + status: status + ) + + case .bluetoothStateUpdated(let state): + updateBluetoothState(state) + } + } + + @MainActor + func didReceiveTransportMessageSynchronously(_ message: BitchatMessage) -> Bool { + transportEventCoordinator.didReceiveMessageSynchronously(message) } func didReceiveMessage(_ message: BitchatMessage) { @@ -1641,10 +2166,16 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele func didConnectToPeer(_ peerID: PeerID) { transportEventCoordinator.didConnectToPeer(peerID) + Task { @MainActor [weak self] in + self?.mediaTransferCoordinator.peerDidReconnect(peerID) + } } func didDisconnectFromPeer(_ peerID: PeerID) { transportEventCoordinator.didDisconnectFromPeer(peerID) + Task { @MainActor [weak self] in + self?.mediaTransferCoordinator.peerDidDisconnect(peerID) + } } func didUpdatePeerList(_ peers: [PeerID]) { @@ -1805,4 +2336,91 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele publicConversationCoordinator.sendHapticFeedback(for: message) } } + +@MainActor +extension ChatViewModel { + func enqueueLegacyPrivateMediaConsent( + for peerID: PeerID, + transferId: String, + messageID: String, + completion: @escaping @MainActor (Bool) -> Void + ) { + let request = LegacyPrivateMediaConsentRequest( + id: UUID(), + peerID: peerID, + peerName: nicknameForPeer(peerID), + transferId: transferId, + messageID: messageID + ) + pendingLegacyPrivateMediaConsents.append(PendingLegacyPrivateMediaConsent( + request: request, + completion: completion + )) + if legacyPrivateMediaConsentRequest == nil { + legacyPrivateMediaConsentRequest = request + } + } + + func resolveLegacyPrivateMediaConsent(requestID: UUID, approved: Bool) { + // SwiftUI may report both the selected button and the presentation + // binding's dismissal. Resolve only the exact request that was shown; + // a duplicate callback for it must not consume the next queued send. + guard legacyPrivateMediaConsentRequest?.id == requestID, + pendingLegacyPrivateMediaConsents.first?.request.id == requestID else { + return + } + let resolved = pendingLegacyPrivateMediaConsents.removeFirst() + // Drive the boolean presentation state through false before showing + // the next queued per-send warning. Otherwise SwiftUI sees true→true, + // closes the first dialog, and never presents the second. + legacyPrivateMediaConsentRequest = nil + resolved.completion(approved) + presentNextLegacyPrivateMediaConsentDeferred() + } + + func invalidateLegacyPrivateMediaConsent(transferId: String, messageID: String) { + let invalidatedIDs = Set( + pendingLegacyPrivateMediaConsents.compactMap { pending -> UUID? in + let request = pending.request + return request.transferId == transferId && request.messageID == messageID + ? request.id + : nil + } + ) + guard !invalidatedIDs.isEmpty else { return } + + pendingLegacyPrivateMediaConsents.removeAll { + invalidatedIDs.contains($0.request.id) + } + if let currentID = legacyPrivateMediaConsentRequest?.id, + invalidatedIDs.contains(currentID) { + legacyPrivateMediaConsentRequest = nil + presentNextLegacyPrivateMediaConsentDeferred() + } + } + + func cancelAllLegacyPrivateMediaConsents() { + let pending = pendingLegacyPrivateMediaConsents + pendingLegacyPrivateMediaConsents.removeAll() + legacyPrivateMediaConsentRequest = nil + for item in pending { + item.completion(false) + } + } + + private func presentNextLegacyPrivateMediaConsentDeferred() { + guard legacyPrivateMediaConsentRequest == nil, + let nextRequestID = pendingLegacyPrivateMediaConsents.first?.request.id else { + return + } + DispatchQueue.main.async { [weak self] in + guard let self, + self.legacyPrivateMediaConsentRequest == nil, + self.pendingLegacyPrivateMediaConsents.first?.request.id == nextRequestID else { + return + } + self.legacyPrivateMediaConsentRequest = self.pendingLegacyPrivateMediaConsents[0].request + } + } +} // End of ChatViewModel class diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+Tor.swift b/bitchat/ViewModels/Extensions/ChatViewModel+Tor.swift index a0142cde..3bf7a5a1 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+Tor.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+Tor.swift @@ -15,6 +15,8 @@ extension ChatViewModel { @objc func handleTorWillStart() { Task { @MainActor in + // A fresh attempt can stall again, so let it be reported again. + self.torStallAnnounced = false if !self.torStatusAnnounced && TorManager.shared.torEnforced { self.torStatusAnnounced = true // Post only in geohash channels (queue if not active) @@ -37,6 +39,7 @@ extension ChatViewModel { @objc func handleTorDidBecomeReady() { Task { @MainActor in + self.torStallAnnounced = false // Only announce "restarted" if we actually restarted this session if self.torRestartPending { // Post only in geohash channels (queue if not active) @@ -54,11 +57,35 @@ extension ChatViewModel { } } + /// Bootstrap spent its whole deadline without connecting. Say so rather + /// than leaving "starting tor…" on screen indefinitely: on a network that + /// blocks Tor this is the terminal state, and someone needs to know that + /// internet features are stalled while the mesh still works. + @objc func handleTorBootstrapDidStall() { + Task { @MainActor in + guard TorManager.shared.torEnforced else { return } + // torEnforced is a compile-time constant in release builds; the + // runtime preference is what says whether anyone is waiting on + // Tor. Turning Tor off mid-bootstrap must not read as blocking. + guard NetworkActivationService.persistedTorPreference() else { return } + guard !self.torStallAnnounced else { return } + self.torStallAnnounced = true + self.addGeohashOnlySystemMessage( + String( + localized: "system.tor.blocked", + defaultValue: "tor could not connect — this network may be blocking it. mesh messaging still works; location channels and internet delivery are paused until tor gets through.", + comment: "System message shown when Tor bootstrap runs out its deadline without connecting, which is what a network that blocks Tor looks like" + ) + ) + } + } + @objc func handleTorPreferenceChanged(_: Notification) { Task { @MainActor in self.torStatusAnnounced = false self.torInitialReadyAnnounced = false self.torRestartPending = false + self.torStallAnnounced = false } } } diff --git a/bitchat/ViewModels/GeoPresenceTracker.swift b/bitchat/ViewModels/GeoPresenceTracker.swift index 4fa22ade..c2f89b79 100644 --- a/bitchat/ViewModels/GeoPresenceTracker.swift +++ b/bitchat/ViewModels/GeoPresenceTracker.swift @@ -103,7 +103,8 @@ final class GeoPresenceTracker { else { return } - guard event.isValidSignature() else { return } + // The signature was already verified (exactly once, off the main + // actor) by NostrRelayManager before delivery. guard shouldProcessGeoSamplingEvent(event.id) else { return } let existingCount = context.geoParticipantCount(for: gh) diff --git a/bitchat/ViewModels/NostrInboundPipeline.swift b/bitchat/ViewModels/NostrInboundPipeline.swift index 4b1a2110..85024436 100644 --- a/bitchat/ViewModels/NostrInboundPipeline.swift +++ b/bitchat/ViewModels/NostrInboundPipeline.swift @@ -78,20 +78,38 @@ extension ChatViewModel: NostrInboundPipelineContext { } } -/// The inbound Nostr hot path: raw relay events in, chat messages / Noise -/// payloads out. Pure transformation plus dedup — no relay lifecycle. +/// The inbound Nostr hot path: verified relay events in, chat messages / +/// Noise payloads out. Pure transformation plus dedup — no relay lifecycle. /// -/// Ordering is deliberate and performance-critical: cheap rejects (kind, -/// dedup lookup) run BEFORE Schnorr signature verification because duplicates -/// dominate real relay traffic; events are recorded only AFTER verification so -/// a forged-signature copy can never poison the dedup set; gift-wrap -/// verification for the account mailbox runs off-main with an atomic -/// main-actor check-and-record. +/// Every event arriving here already had its Schnorr signature verified +/// exactly once, off the main actor, by `NostrRelayManager`'s serial inbound +/// pipeline (which records events into its own dedup cache only AFTER +/// verification, so forged copies can't suppress genuine events). This +/// pipeline therefore never re-verifies; it keeps its own event-ID dedup +/// (cheap main-actor lookups) and moves NIP-17 gift-wrap decryption — two +/// ECDH+ChaCha layers — off the main actor with an atomic main-actor +/// check-and-record. final class NostrInboundPipeline { private weak var context: (any NostrInboundPipelineContext)? private let presence: GeoPresenceTracker private var geoEventLogCount = 0 + /// Monotonic panic-wipe generation for this pipeline. A panic wipe clears + /// relay handlers so no NEW events flow, but a detached decrypt task + /// spawned just BEFORE the wipe — which strongly captures a pre-wipe Nostr + /// private key and ciphertext — survives it. Spawn sites capture this + /// value; the task compares it at its main-actor hops and drops its result + /// (no delivery; the captured identity and plaintext die with the task) + /// if `invalidateInFlightDecrypts()` bumped it in between. + @MainActor private(set) var wipeGeneration: UInt64 = 0 + + /// Called from `ChatViewModel.panicClearAllData()` so plaintext decrypted + /// with pre-wipe keys can never land in post-wipe state. + @MainActor + func invalidateInFlightDecrypts() { + wipeGeneration &+= 1 + } + init(context: any NostrInboundPipelineContext, presence: GeoPresenceTracker) { self.context = context self.presence = presence @@ -100,17 +118,15 @@ final class NostrInboundPipeline { @MainActor func subscribeNostrEvent(_ event: NostrEvent) { guard let context else { return } - // Cheap rejects (kind, dedup lookup) before Schnorr verification — - // duplicates dominate real traffic and must not pay for crypto. - // Only verified events are recorded, so a forged-signature copy can - // never poison the dedup set and suppress the genuine event. + // Cheap rejects (kind, dedup lookup) — duplicates dominate real + // traffic. The signature was already verified (exactly once, off the + // main actor) by NostrRelayManager before delivery. guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue), !context.hasProcessedNostrEvent(event.id) else { return } - guard event.isValidSignature() else { return } context.recordProcessedNostrEvent(event.id) @@ -180,15 +196,14 @@ final class NostrInboundPipeline { @MainActor func handleNostrEvent(_ event: NostrEvent) { guard let context else { return } - // Cheap rejects (kind, dedup lookup) before Schnorr verification — - // duplicates dominate real traffic and must not pay for crypto. + // Cheap rejects (kind, dedup lookup) — the signature was already + // verified (exactly once, off the main actor) by NostrRelayManager. guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return } if context.hasProcessedNostrEvent(event.id) { return } - guard event.isValidSignature() else { return } context.recordProcessedNostrEvent(event.id) let powBits = NostrPoW.validatedDifficulty(idHex: event.id, tags: event.tags) @@ -273,112 +288,130 @@ final class NostrInboundPipeline { @MainActor func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { guard let context else { return } - // Dedup lookup before Schnorr verification; record only after it passes. + // Cheap dedup pre-check only; processGeohashGiftWrap does the + // authoritative main-actor check-and-record before the off-main + // NIP-17 unwrap. The outer signature was already verified (exactly + // once, off the main actor) by NostrRelayManager. guard !context.hasProcessedNostrEvent(giftWrap.id) else { return } - guard giftWrap.isValidSignature() else { return } - context.recordProcessedNostrEvent(giftWrap.id) - guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage( - giftWrap: giftWrap, - recipientIdentity: id - ), - let packet = Self.decodeEmbeddedBitChatPacket(from: content), - packet.type == MessageType.noiseEncrypted.rawValue, - let noisePayload = NoisePayload.decode(packet.payload) - else { - return - } - - let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs)) - let convKey = PeerID(nostr_: senderPubkey) - context.registerNostrKeyMapping(senderPubkey, for: convKey) - - switch noisePayload.type { - case .privateMessage: - context.handlePrivateMessage( - noisePayload, - senderPubkey: senderPubkey, - convKey: convKey, - id: id, - messageTimestamp: messageTimestamp - ) - case .delivered: - context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey) - case .readReceipt: - context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey) - // Group state travels only over mesh Noise sessions in v1; anything - // claiming to be group traffic over Nostr is ignored. - // Live voice is mesh-only: latency and relay cost make it - // meaningless over Nostr. - case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame: - break + // Capture the wipe generation at spawn, alongside the per-geohash + // identity (private key) the detached task strongly captures. A panic + // wipe between spawn and delivery bumps the generation, and the task + // drops its result instead of delivering plaintext post-wipe. + let wipeGeneration = self.wipeGeneration + Task.detached(priority: .userInitiated) { [weak self] in + await self?.processGeohashGiftWrap(giftWrap, id: id, verbose: false, wipeGeneration: wipeGeneration) } } @MainActor func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { guard let context else { return } - // Dedup lookup before Schnorr verification; record only after it passes. + // Cheap dedup pre-check only; see subscribeGiftWrap. if context.hasProcessedNostrEvent(giftWrap.id) { return } - guard giftWrap.isValidSignature() else { return } - context.recordProcessedNostrEvent(giftWrap.id) + + // Spawn-time wipe-generation capture; see subscribeGiftWrap. + let wipeGeneration = self.wipeGeneration + Task.detached(priority: .userInitiated) { [weak self] in + await self?.processGeohashGiftWrap(giftWrap, id: id, verbose: true, wipeGeneration: wipeGeneration) + } + } + + /// Geohash-DM gift wrap ingest. The NIP-17 unwrap (two ECDH+ChaCha + /// layers) runs off the main actor; results hop back for state updates. + /// `verbose` keeps `handleGiftWrap`'s decrypt logging without adding it + /// to the sampling path. + /// + /// `wipeGeneration` is this pipeline's generation captured at spawn (the + /// moment the pre-wipe `id` was captured); a mismatch at either main-actor + /// hop means a panic wipe happened in between, so the task bails without + /// decrypting (first hop) or without delivering the plaintext (second + /// hop) — the captured identity and any decrypted material are simply + /// dropped with the task. + private func processGeohashGiftWrap( + _ giftWrap: NostrEvent, + id: NostrIdentity, + verbose: Bool, + wipeGeneration: UInt64 + ) async { + guard let context else { return } + // Authoritative check-and-record, atomic on the main actor so two + // concurrent detached tasks can't both process the same event. + let alreadyProcessed: Bool = await MainActor.run { + guard self.wipeGeneration == wipeGeneration else { return true } + if context.hasProcessedNostrEvent(giftWrap.id) { return true } + context.recordProcessedNostrEvent(giftWrap.id) + return false + } + if alreadyProcessed { return } guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage( giftWrap: giftWrap, recipientIdentity: id ) else { - SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))…", category: .session) + if verbose { + SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))…", category: .session) + } return } - SecureLogger.debug( - "GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", - category: .session - ) - - guard let packet = Self.decodeEmbeddedBitChatPacket(from: content), - packet.type == MessageType.noiseEncrypted.rawValue, - let payload = NoisePayload.decode(packet.payload) - else { - return - } - - let convKey = PeerID(nostr_: senderPubkey) - context.registerNostrKeyMapping(senderPubkey, for: convKey) - - switch payload.type { - case .privateMessage: - let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs)) - context.handlePrivateMessage( - payload, - senderPubkey: senderPubkey, - convKey: convKey, - id: id, - messageTimestamp: messageTimestamp + if verbose { + SecureLogger.debug( + "GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", + category: .session ) - case .delivered: - context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey) - case .readReceipt: - context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey) - // Group state travels only over mesh Noise sessions in v1; anything - // claiming to be group traffic over Nostr is ignored. - // Live voice is mesh-only: latency and relay cost make it - // meaningless over Nostr. - case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame: - break + } + + await MainActor.run { + // A panic wipe during the off-main decrypt must not let the + // pre-wipe plaintext reach post-wipe state; drop it here, atomic + // with the wipe on the main actor. + guard self.wipeGeneration == wipeGeneration else { return } + guard let packet = Self.decodeEmbeddedBitChatPacket(from: content), + packet.type == MessageType.noiseEncrypted.rawValue, + let payload = NoisePayload.decode(packet.payload) + else { + return + } + + let convKey = PeerID(nostr_: senderPubkey) + context.registerNostrKeyMapping(senderPubkey, for: convKey) + + switch payload.type { + case .privateMessage: + let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs)) + context.handlePrivateMessage( + payload, + senderPubkey: senderPubkey, + convKey: convKey, + id: id, + messageTimestamp: messageTimestamp + ) + case .delivered: + context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey) + case .readReceipt: + context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey) + // Group state travels only over mesh Noise sessions in v1; anything + // claiming to be group traffic over Nostr is ignored. + // Live voice is mesh-only: latency and relay cost make it + // meaningless over Nostr. + case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState: + break + } } } @MainActor func handleNostrMessage(_ giftWrap: NostrEvent) { guard let context else { return } - // Cheap dedup pre-check only; Schnorr verification runs off-main in - // processNostrMessage, which then does the authoritative - // check-and-record. Recording stays after verification so a - // forged-signature copy can never poison the dedup set and suppress - // the genuine event. + // Cheap dedup pre-check only; processNostrMessage does the + // authoritative check-and-record before the off-main NIP-17 unwrap. + // The outer signature was already verified (exactly once, off the + // main actor) by NostrRelayManager, and only verified events are + // recorded, so a forged-signature copy can never poison the dedup + // set and suppress the genuine event. if context.hasProcessedNostrEvent(giftWrap.id) { return } Task.detached(priority: .userInitiated) { [weak self] in @@ -387,7 +420,6 @@ final class NostrInboundPipeline { } func processNostrMessage(_ giftWrap: NostrEvent) async { - guard giftWrap.isValidSignature() else { return } guard let context else { return } // Authoritative check-and-record, atomic on the main actor so two // concurrent detached tasks can't both process the same event. @@ -397,8 +429,13 @@ final class NostrInboundPipeline { return false } if alreadyProcessed { return } - let currentIdentity: NostrIdentity? = await MainActor.run { - context.currentNostrIdentity() + // Fetch the identity and the wipe generation in ONE main-actor hop: + // the generation then vouches for exactly this identity. A wipe after + // this point bumps the generation and the delivery hop below drops + // the decrypted result (same guard as processGeohashGiftWrap; this + // account-mailbox path had the identical hazard). + let (currentIdentity, wipeGeneration): (NostrIdentity?, UInt64) = await MainActor.run { + (context.currentNostrIdentity(), self.wipeGeneration) } guard let currentIdentity else { return } @@ -430,6 +467,9 @@ final class NostrInboundPipeline { let payload = NoisePayload.decode(packet.payload) { let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp)) await MainActor.run { + // Drop pre-wipe plaintext if a panic wipe landed + // during the off-main decrypt (see above). + guard self.wipeGeneration == wipeGeneration else { return } context.registerNostrKeyMapping(senderPubkey, for: targetPeerID) switch payload.type { @@ -449,7 +489,7 @@ final class NostrInboundPipeline { // in v1; 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: + case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState: break } } diff --git a/bitchat/ViewModels/VoiceRecordingViewModel.swift b/bitchat/ViewModels/VoiceRecordingViewModel.swift index 4e3d4c6e..727fa39a 100644 --- a/bitchat/ViewModels/VoiceRecordingViewModel.swift +++ b/bitchat/ViewModels/VoiceRecordingViewModel.swift @@ -188,8 +188,25 @@ final class VoiceRecordingViewModel: ObservableObject { Task { let finalDuration = Date().timeIntervalSince(startDate) - if let url = await session.finish(), - isValidRecording(at: url, duration: finalDuration) { + if let url = await session.finish() { + // Panic and a newer hold both invalidate this completion. + // Never route an old recording using a post-panic target. + guard generation == holdGeneration else { + try? FileManager.default.removeItem(at: url) + return + } + guard isValidRecording( + at: url, + duration: finalDuration + ) else { + guard state == .idle else { return } + state = .error( + message: finalDuration < VoiceRecorder.minRecordingDuration + ? "Recording is too short." + : "Recording failed to save." + ) + return + } completion(url) } else { guard generation == holdGeneration, state == .idle else { return } @@ -206,6 +223,17 @@ final class VoiceRecordingViewModel: ObservableObject { finish(completion: nil) } + /// Invalidates in-flight permission/start/finalize callbacks and tears + /// down an active microphone before the panic transaction continues. + func panicWipe() { + holdGeneration &+= 1 + let session = activeSession + activeSession = nil + state = .idle + isLiveStreaming = false + session?.panicCancelSynchronously() + } + private func isValidRecording(at url: URL, duration: TimeInterval) -> Bool { if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path), let fileSize = attributes[.size] as? NSNumber, diff --git a/bitchat/Views/AppInfoView.swift b/bitchat/Views/AppInfoView.swift index a75efeb8..43da991e 100644 --- a/bitchat/Views/AppInfoView.swift +++ b/bitchat/Views/AppInfoView.swift @@ -21,6 +21,10 @@ struct AppInfoView: View { @State private var showTopology = false @State private var liveVoiceEnabled = PTTSettings.liveVoiceEnabled @State private var locationNotesEnabled = LocationNotesSettings.enabled + @State private var hideMessagePreviews = NotificationPrivacySettings.hideMessagePreviews + @State private var customRelays = NostrRelaySettings.customRelays() + @State private var relayInput = "" + @State private var relayError: String? @ObservedObject private var locationManager = LocationChannelManager.shared /// Sticky across opens: first-ever open lands on Info (the gentler /// introduction), and afterwards the sheet reopens wherever it was left. @@ -79,10 +83,41 @@ struct AppInfoView: View { // internet-gateway toggle is gone: the bridge switch drives all // internet sharing, including geohash-channel gatewaying.) static let torTitle: LocalizedStringKey = "location_channels.tor.title" - static let torSubtitle: LocalizedStringKey = "location_channels.tor.subtitle" + // Replaces `location_channels.tor.subtitle`, which described the + // setting as location-channels-only. It covers private messages and + // relay-directory refreshes too, and said nothing about the cost of + // switching it off. + static let torSubtitle = String(localized: "app_info.settings.tor.subtitle", defaultValue: "sends internet traffic through tor, so relay operators see tor's address instead of yours. covers location channels and private messages delivered over the internet. recommended: on.", comment: "Subtitle for the tor routing toggle in settings, explaining what it covers") + static let torOffWarning = String(localized: "app_info.settings.tor.off_warning", defaultValue: "tor is off: every relay you connect to can see your IP address, including relays carrying your private messages.", comment: "Warning shown under the tor toggle while tor is switched off, stating that relay operators can see the device IP address") + + static let relaysTitle = String(localized: "app_info.settings.relays.title", defaultValue: "private message relays", comment: "Title of the relay list editor in settings") + static let relaysSubtitle = String(localized: "app_info.settings.relays.subtitle", defaultValue: "when the mesh can't reach someone, private messages travel through these relays. the built-in ones are well-known addresses that a network filter can block, so you can add your own — including .onion addresses.", comment: "Subtitle explaining what the relay list is for and why someone would add a relay") + static let relayBuiltIn = String(localized: "app_info.settings.relays.built_in", defaultValue: "built in", comment: "Label marking a relay as one of the built-in relays, which cannot be removed") + static let relayPlaceholder = String(localized: "app_info.settings.relays.placeholder", defaultValue: "wss://relay.example.com", comment: "Placeholder text in the field for adding a relay address") + static let relayAdd = String(localized: "app_info.settings.relays.add", defaultValue: "add", comment: "Button that adds the typed relay address to the list") + static let relayRemove = String(localized: "app_info.settings.relays.remove", defaultValue: "remove relay", comment: "Accessibility label for the button that removes an added relay") + + static func relayError(_ failure: NostrRelaySettings.AddFailure) -> String { + switch failure { + case .malformed: + return String(localized: "app_info.settings.relays.error.malformed", defaultValue: "that doesn't look like a relay address. try wss://host.", comment: "Error shown when a typed relay address cannot be parsed") + case .alreadyPresent: + return String(localized: "app_info.settings.relays.error.duplicate", defaultValue: "that relay is already in the list.", comment: "Error shown when the typed relay address is already in the list") + case .limitReached: + return String( + format: String(localized: "app_info.settings.relays.error.limit", defaultValue: "you can add up to %d relays.", comment: "Error shown when the relay list is already at its maximum size; %d is that maximum"), + locale: .current, + NostrRelaySettings.maxCustomRelays + ) + } + } static let toggleOn: LocalizedStringKey = "common.toggle.on" static let toggleOff: LocalizedStringKey = "common.toggle.off" + static let privacyTitle = String(localized: "app_info.settings.privacy.title", defaultValue: "PRIVACY", comment: "Section header (uppercase) for privacy settings such as hiding notification previews") + static let hidePreviewsTitle = String(localized: "app_info.settings.hide_previews.title", defaultValue: "hide message previews", comment: "Title of the setting that keeps message text, sender names, and geohashes out of lock-screen notifications") + static let hidePreviewsSubtitle = String(localized: "app_info.settings.hide_previews.subtitle", defaultValue: "notifications say that something arrived without showing the message, who sent it, or which location channel it came from. anyone holding your locked phone learns nothing from the lock screen. on by default.", comment: "Subtitle explaining what hiding notification message previews does") + static let dangerTitle = String(localized: "app_info.settings.danger.title", defaultValue: "DANGER ZONE", comment: "Section header (uppercase) for destructive actions in settings") static let panicButton = String(localized: "app_info.settings.danger.panic_button", defaultValue: "panic wipe", comment: "Button in the settings danger zone that erases all local data after confirmation") static let panicNote = String(localized: "app_info.settings.danger.panic_note", defaultValue: "erases all messages, keys, and identity. triple-tapping the bitchat/ logo does the same, instantly.", comment: "Caption under the panic wipe button explaining what it does and the triple-tap shortcut") @@ -413,11 +448,22 @@ struct AppInfoView: View { settingsCard { settingToggle( title: Text(Strings.Settings.torTitle), - subtitle: Text(Strings.Settings.torSubtitle), + subtitle: Text(verbatim: Strings.Settings.torSubtitle), isOn: torToggleBinding ) + // Turning tor off is not a location-channels-only choice, so + // say what it costs while it is off rather than in the + // subtitle everyone skims. + if !locationChannelsModel.userTorEnabled { + Text(verbatim: Strings.Settings.torOffWarning) + .bitchatFont(size: 11) + .foregroundColor(palette.alertRed) + .fixedSize(horizontal: false, vertical: true) + } } + relaySettingsCard + // Location notes / dead drops (merged from main's flat // layout into the shared card + pill style). Turning it on // may need the location prompt; the permission control below @@ -480,6 +526,26 @@ struct AppInfoView: View { } } + // Privacy: what a locked, seized, or borrowed phone gives away + // without being unlocked. + VStack(alignment: .leading, spacing: 12) { + SectionHeader(verbatim: Strings.Settings.privacyTitle) + + settingsCard { + settingToggle( + title: Text(verbatim: Strings.Settings.hidePreviewsTitle), + subtitle: Text(verbatim: Strings.Settings.hidePreviewsSubtitle), + isOn: Binding( + get: { hideMessagePreviews }, + set: { newValue in + hideMessagePreviews = newValue + NotificationPrivacySettings.hideMessagePreviews = newValue + } + ) + ) + } + } + // Danger zone if onPanicWipe != nil { VStack(alignment: .leading, spacing: 12) { @@ -541,6 +607,108 @@ struct AppInfoView: View { ) } + /// Relay list editor. The built-in relays are four well-known hostnames, so + /// a filter blocking four names ends internet-delivered private messages; + /// adding one here is the only fix that does not need a new build. + @ViewBuilder + private var relaySettingsCard: some View { + settingsCard { + VStack(alignment: .leading, spacing: 2) { + Text(verbatim: Strings.Settings.relaysTitle) + .bitchatFont(size: 12, weight: .semibold) + .foregroundColor(textColor) + Text(verbatim: Strings.Settings.relaysSubtitle) + .bitchatFont(size: 11) + .foregroundColor(secondaryTextColor) + .fixedSize(horizontal: false, vertical: true) + } + + ForEach(NostrRelayManager.builtInRelayURLs.sorted(), id: \.self) { relay in + HStack(spacing: 6) { + Text(verbatim: relay) + .bitchatFont(size: 11) + .foregroundColor(secondaryTextColor) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 4) + Text(verbatim: Strings.Settings.relayBuiltIn) + .bitchatFont(size: 10) + .foregroundColor(secondaryTextColor) + } + } + + ForEach(customRelays, id: \.self) { relay in + HStack(spacing: 6) { + Text(verbatim: relay) + .bitchatFont(size: 11) + .foregroundColor(textColor) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 4) + Button { + NostrRelaySettings.remove(relay) + reloadCustomRelays() + } label: { + Image(systemName: "minus.circle") + .foregroundColor(palette.alertRed) + } + .buttonStyle(.plain) + .accessibilityLabel(Strings.Settings.relayRemove) + } + } + + if customRelays.count < NostrRelaySettings.maxCustomRelays { + HStack(spacing: 6) { + TextField(Strings.Settings.relayPlaceholder, text: $relayInput) + .textFieldStyle(.plain) + .bitchatFont(size: 11) + .foregroundColor(textColor) + .autocorrectionDisabled(true) + #if os(iOS) + .textInputAutocapitalization(.never) + .keyboardType(.URL) + #endif + .onSubmit(addRelay) + Button(action: addRelay) { + Text(verbatim: Strings.Settings.relayAdd) + .bitchatFont(size: 11, weight: .semibold) + .foregroundColor(palette.accent) + } + .buttonStyle(.plain) + .disabled(relayInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + + if let relayError { + Text(verbatim: relayError) + .bitchatFont(size: 11) + .foregroundColor(palette.alertRed) + .fixedSize(horizontal: false, vertical: true) + } + } + // The store can change from outside this view — a panic wipe clears it — + // so follow it rather than trusting the value read at creation. + .onReceive(NotificationCenter.default.publisher(for: NostrRelaySettings.didChangeNotification)) { _ in + reloadCustomRelays() + } + } + + private func addRelay() { + let candidate = relayInput + switch NostrRelaySettings.add(candidate, builtIn: NostrRelayManager.builtInRelayURLs) { + case .success: + relayInput = "" + relayError = nil + reloadCustomRelays() + case .failure(let failure): + relayError = Strings.Settings.relayError(failure) + } + } + + private func reloadCustomRelays() { + customRelays = NostrRelaySettings.customRelays() + } + private var torToggleBinding: Binding { Binding( get: { locationChannelsModel.userTorEnabled }, diff --git a/bitchat/Views/ContentComposerView.swift b/bitchat/Views/ContentComposerView.swift index 9913ce03..b5020980 100644 --- a/bitchat/Views/ContentComposerView.swift +++ b/bitchat/Views/ContentComposerView.swift @@ -73,7 +73,14 @@ struct ContentComposerView: View { .textInputAutocapitalization(.sentences) #endif .submitLabel(.send) - .onSubmit(onSendMessage) + .onSubmit { + onSendMessage() + // Only the return-key path: it steals focus on iOS, so + // every message would cost a tap to reopen the keyboard. + // The send button must not reopen a deliberately + // dismissed keyboard, so it stays out of this. + isTextFieldFocused.wrappedValue = true + } .padding(.vertical, theme.usesGlassChrome ? 8 : 4) .padding(.horizontal, 6) .themedInputBackground() diff --git a/bitchat/Views/ContentSheetViews.swift b/bitchat/Views/ContentSheetViews.swift index 0cbd27d1..a4092b43 100644 --- a/bitchat/Views/ContentSheetViews.swift +++ b/bitchat/Views/ContentSheetViews.swift @@ -6,11 +6,28 @@ import UIKit import AppKit #endif +struct ContentPeopleSheetModalPresentationState { + var isImagePreviewPresented = false + var isVerificationSheetPresented = false + var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest? = nil + var isVoiceAlertPresented = false + var isMediaPickerPresented = false + + var hasPresentation: Bool { + isImagePreviewPresented + || isVerificationSheetPresented + || legacyPrivateMediaConsentRequest != nil + || isVoiceAlertPresented + || isMediaPickerPresented + } +} + struct ContentPeopleSheetView: View { @EnvironmentObject private var appChromeModel: AppChromeModel @EnvironmentObject private var privateConversationModel: PrivateConversationModel @EnvironmentObject private var verificationModel: VerificationModel @EnvironmentObject private var conversationUIModel: ConversationUIModel + @Environment(\.scenePhase) private var scenePhase @Binding var showSidebar: Bool @Binding var messageText: String @@ -23,6 +40,7 @@ struct ContentPeopleSheetView: View { var isTextFieldFocused: FocusState.Binding @ObservedObject var voiceRecordingVM: VoiceRecordingViewModel @Binding var autocompleteDebounceTimer: Timer? + @State private var showVerifySheet = false @ThemedPalette private var palette let headerHeight: CGFloat @@ -35,7 +53,77 @@ struct ContentPeopleSheetView: View { @Binding var showMacImagePicker: Bool #endif + private func modalPresentationState( + includingVoiceAlert: Bool + ) -> ContentPeopleSheetModalPresentationState { + #if os(iOS) + let isMediaPickerPresented = showImagePicker + #else + let isMediaPickerPresented = showMacImagePicker + #endif + + return ContentPeopleSheetModalPresentationState( + isImagePreviewPresented: imagePreviewURL != nil, + isVerificationSheetPresented: showVerifySheet, + legacyPrivateMediaConsentRequest: + conversationUIModel.legacyPrivateMediaConsentRequest, + isVoiceAlertPresented: includingVoiceAlert && voiceRecordingVM.showAlert, + isMediaPickerPresented: isMediaPickerPresented + ) + } + + 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 { + Binding( + get: { + scenePhase == .active + && appChromeModel.showBluetoothAlert + && !hasModalPresentation + }, + set: { isPresented in + guard !isPresented, + scenePhase == .active, + !hasModalPresentation else { + return + } + appChromeModel.showBluetoothAlert = false + } + ) + } + + /// 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 { + Binding( + get: { + scenePhase == .active + && voiceRecordingVM.showAlert + && !hasModalPresentationBesidesVoiceAlert + }, + set: { isPresented in + guard !isPresented, + scenePhase == .active, + !hasModalPresentationBesidesVoiceAlert else { + return + } + voiceRecordingVM.showAlert = false + } + ) + } + var body: some View { + let legacyConsentRequest = conversationUIModel.legacyPrivateMediaConsentRequest NavigationStack { Group { if privateConversationModel.selectedPeerID != nil { @@ -77,7 +165,8 @@ struct ContentPeopleSheetView: View { #endif } else { ContentPeopleListView( - showSidebar: $showSidebar + showSidebar: $showSidebar, + showVerifySheet: $showVerifySheet ) } } @@ -97,6 +186,63 @@ struct ContentPeopleSheetView: View { } .themedSheetBackground() .foregroundColor(palette.primary) + .confirmationDialog( + String( + localized: "content.private_media.legacy_warning.title", + defaultValue: "Send without end-to-end encryption?", + comment: "Title warning before sending private media to an older client in a clear signed envelope" + ), + isPresented: Binding( + get: { legacyConsentRequest != nil }, + set: { isPresented in + if !isPresented, let requestID = legacyConsentRequest?.id { + conversationUIModel.resolveLegacyPrivateMediaConsent( + requestID: requestID, + approved: false + ) + } + } + ), + titleVisibility: .visible + ) { + Button( + String( + localized: "content.private_media.legacy_warning.send", + defaultValue: "send visible file", + comment: "Destructive confirmation action for one legacy clear private-media send" + ), + role: .destructive + ) { + if let requestID = legacyConsentRequest?.id { + conversationUIModel.resolveLegacyPrivateMediaConsent( + requestID: requestID, + approved: true + ) + } + } + Button("common.cancel", role: .cancel) { + if let requestID = legacyConsentRequest?.id { + conversationUIModel.resolveLegacyPrivateMediaConsent( + requestID: requestID, + approved: false + ) + } + } + } message: { + if let request = legacyConsentRequest { + Text( + String( + format: String( + localized: "content.private_media.legacy_warning.message", + defaultValue: "%@'s client does not advertise encrypted private media. This file will be signed but not end-to-end encrypted, so mesh relays can see it. Send this file anyway?", + comment: "Warning explaining the confidentiality loss for one legacy private-media send; parameter is the peer name" + ), + locale: .current, + request.peerName + ) + ) + } + } #if os(macOS) .frame(minWidth: 420, minHeight: 520) #endif @@ -124,6 +270,27 @@ struct ContentPeopleSheetView: View { } } #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( + "content.alert.bluetooth_required.title", + isPresented: bluetoothAlertBinding + ) { + Button("content.alert.bluetooth_required.settings") { + SystemSettings.bluetooth.open() + } + Button("common.ok", role: .cancel) {} + } message: { + Text(appChromeModel.bluetoothAlertMessage) + } } } @@ -138,8 +305,7 @@ private struct ContentPeopleListView: View { @ThemedPalette private var palette @Binding var showSidebar: Bool - - @State private var showVerifySheet = false + @Binding var showVerifySheet: Bool var body: some View { VStack(spacing: 0) { @@ -446,7 +612,8 @@ private struct ContentPrivateChatSheetView: View { if privateConversationModel.selectedPeerID?.isGroup == true { return String(localized: "content.private.caption_group", comment: "Caption above the group chat composer noting messages are encrypted to group members") } - // Geohash DMs are NIP-17 gift-wrapped — always end-to-end encrypted, + // Geohash DMs use BitChat's private-envelope encryption over Nostr — + // always end-to-end encrypted, // even though they carry no Noise session status. Mesh DMs earn the // "encrypted" claim only once the Noise handshake has secured. let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index fc1a6766..be2bdeec 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -14,6 +14,61 @@ import AppKit #endif import BitFoundation +struct ContentRootModalPresentationState { + var isPeopleSheetPresented = false + var isAppInfoPresented = false + var isFingerprintPresented = false + var isLocationChannelsSheetPresented = false + var isNoticesSheetPresented = false + var isImagePreviewPresented = false + var isVerificationSheetPresented = false + var isVoiceAlertPresented = false + var isScreenshotPrivacyAlertPresented = false + var isMediaPickerPresented = false + + var hasPresentation: Bool { + isPeopleSheetPresented + || isAppInfoPresented + || isFingerprintPresented + || isLocationChannelsSheetPresented + || isNoticesSheetPresented + || isImagePreviewPresented + || isVerificationSheetPresented + || isVoiceAlertPresented + || isScreenshotPrivacyAlertPresented + || isMediaPickerPresented + } +} + +extension ContentRootModalPresentationState { + @MainActor + init( + appChromeModel: AppChromeModel, + isPeopleSheetPresented: Bool = false, + isImagePreviewPresented: Bool = false, + isVerificationSheetPresented: Bool = false, + isVoiceAlertPresented: Bool = false, + isMediaPickerPresented: Bool = false + ) { + self.init( + isPeopleSheetPresented: isPeopleSheetPresented, + isAppInfoPresented: appChromeModel.isAppInfoPresented, + isFingerprintPresented: + appChromeModel.showingFingerprintFor != nil, + isLocationChannelsSheetPresented: + appChromeModel.isLocationChannelsSheetPresented, + isNoticesSheetPresented: + appChromeModel.isNoticesSheetPresented, + isImagePreviewPresented: isImagePreviewPresented, + isVerificationSheetPresented: isVerificationSheetPresented, + isVoiceAlertPresented: isVoiceAlertPresented, + isScreenshotPrivacyAlertPresented: + appChromeModel.showScreenshotPrivacyWarning, + isMediaPickerPresented: isMediaPickerPresented + ) + } +} + /// On macOS 14+, disables the default system focus ring on TextFields. /// On earlier macOS versions and on iOS this is a no-op. struct FocusEffectDisabledModifier: ViewModifier { @@ -36,12 +91,14 @@ struct ContentView: View { @EnvironmentObject private var verificationModel: VerificationModel @EnvironmentObject private var conversationUIModel: ConversationUIModel @EnvironmentObject private var locationChannelsModel: LocationChannelsModel + @EnvironmentObject private var sharedContentImportModel: SharedContentImportModel @StateObject private var voiceRecordingVM = VoiceRecordingViewModel() @State private var messageText = "" @FocusState private var isTextFieldFocused: Bool @Environment(\.colorScheme) var colorScheme @Environment(\.appTheme) private var appTheme + @Environment(\.scenePhase) private var scenePhase @State private var showSidebar = false @State private var selectedMessageSender: String? @State private var selectedMessageSenderID: PeerID? @@ -69,8 +126,90 @@ struct ContentView: View { privateConversationModel.selectedPeerID } + private var sharedContentDestination: SharedContentDestination { + SharedContentDestination.resolve( + selectedPrivatePeerID: selectedPrivatePeerID, + privateDisplayName: privateConversationModel.selectedHeaderState?.displayName, + activeChannel: locationChannelsModel.selectedChannel + ) + } + private var usesGlassLayout: Bool { appTheme.usesGlassChrome } + private var isPeopleSheetPresented: Bool { + showSidebar || selectedPrivatePeerID != nil + } + + private func rootModalPresentationState( + includingVoiceAlert: Bool + ) -> ContentRootModalPresentationState { + #if os(iOS) + let isMediaPickerPresented = showImagePicker + #else + let isMediaPickerPresented = showMacImagePicker + #endif + + return ContentRootModalPresentationState( + appChromeModel: appChromeModel, + isPeopleSheetPresented: isPeopleSheetPresented, + isImagePreviewPresented: imagePreviewURL != nil, + isVerificationSheetPresented: showVerifySheet, + isVoiceAlertPresented: includingVoiceAlert && voiceRecordingVM.showAlert, + isMediaPickerPresented: isMediaPickerPresented + ) + } + + 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 { + Binding( + get: { + scenePhase == .active + && appChromeModel.showBluetoothAlert + && !hasRootModalPresentation + }, + set: { isPresented in + guard !isPresented, + scenePhase == .active, + !hasRootModalPresentation else { + return + } + appChromeModel.showBluetoothAlert = false + } + ) + } + + /// 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 { + Binding( + get: { + scenePhase == .active + && voiceRecordingVM.showAlert + && !hasRootModalPresentationBesidesVoiceAlert + }, + set: { isPresented in + guard !isPresented, + scenePhase == .active, + !hasRootModalPresentationBesidesVoiceAlert else { + return + } + voiceRecordingVM.showAlert = false + } + ) + } + var body: some View { mainContent .onAppear { @@ -79,12 +218,16 @@ struct ContentView: View { voiceRecordingVM.sessionProvider = { [weak conversationUIModel] in conversationUIModel?.makeVoiceCaptureSession() ?? VoiceNoteCaptureSession() } + appChromeModel.setPanicPreparation { [weak voiceRecordingVM] in + voiceRecordingVM?.panicWipe() + } #if os(macOS) DispatchQueue.main.async { isNicknameFieldFocused = false isTextFieldFocused = true } #endif + sharedContentImportModel.updateDestination(sharedContentDestination) } .onChange(of: colorScheme) { newValue in conversationUIModel.setCurrentColorScheme(newValue) @@ -101,14 +244,27 @@ struct ContentView: View { if newValue != nil { showSidebar = true } + sharedContentImportModel.updateDestination(sharedContentDestination) + } + .onChange(of: locationChannelsModel.selectedChannel) { _ in + sharedContentImportModel.updateDestination(sharedContentDestination) } .sheet( isPresented: Binding( - get: { showSidebar || selectedPrivatePeerID != nil }, + get: { isPeopleSheetPresented }, set: { isPresented in if !isPresented { showSidebar = false - privateConversationModel.endConversation() + // Scene/background and alert-presentation + // reconciliation (Bluetooth-off, recording errors) + // are not user requests to leave the conversation. + // Keep the selected DM so the sheet remains live + // when the app returns from Settings. + if scenePhase == .active, + !appChromeModel.showBluetoothAlert, + !voiceRecordingVM.showAlert { + privateConversationModel.endConversation() + } } } ) @@ -209,7 +365,7 @@ struct ContentView: View { ImagePreviewView(url: url) } } - .alert("Recording Error", isPresented: $voiceRecordingVM.showAlert, actions: { + .alert("Recording Error", isPresented: rootVoiceAlertBinding, actions: { Button("common.ok", role: .cancel) {} if voiceRecordingVM.state == .permissionDenied { Button("location_channels.action.open_settings") { @@ -219,7 +375,7 @@ struct ContentView: View { }, message: { Text(voiceRecordingVM.state.alertMessage) }) - .alert("content.alert.bluetooth_required.title", isPresented: $appChromeModel.showBluetoothAlert) { + .alert("content.alert.bluetooth_required.title", isPresented: rootBluetoothAlertBinding) { Button("content.alert.bluetooth_required.settings") { SystemSettings.bluetooth.open() } @@ -227,8 +383,37 @@ struct ContentView: View { } message: { Text(appChromeModel.bluetoothAlertMessage) } + .alert( + String(localized: "share_import.review.title", comment: "Title for reviewing content received from the share extension"), + isPresented: Binding( + get: { sharedContentImportModel.offer != nil }, + set: { _ in } + ), + presenting: sharedContentImportModel.offer + ) { _ in + Button("common.cancel", role: .cancel) { + sharedContentImportModel.cancel(destination: sharedContentDestination) + } + Button("share_import.review.use_in_composer") { + guard let importedText = sharedContentImportModel.confirm( + destination: sharedContentDestination + ) else { return } + // Replacing is deliberate and called out in the prompt. It + // avoids combining a stale draft from another conversation + // with newly shared content. + messageText = importedText + isTextFieldFocused = true + } + } message: { offer in + let format = String( + localized: "share_import.review.message", + comment: "Explains that shared content will replace the named destination's composer and will not be sent automatically" + ) + Text(String(format: format, offer.destination.displayName) + "\n\n" + offer.payload.preview) + } .onDisappear { autocompleteDebounceTimer?.invalidate() + appChromeModel.setPanicPreparation(nil) } } diff --git a/bitchat/_PreviewHelpers/PreviewKeychainManager.swift b/bitchat/_PreviewHelpers/PreviewKeychainManager.swift index 32827f48..17bc9ead 100644 --- a/bitchat/_PreviewHelpers/PreviewKeychainManager.swift +++ b/bitchat/_PreviewHelpers/PreviewKeychainManager.swift @@ -14,11 +14,25 @@ final class PreviewKeychainManager: KeychainManagerProtocol { // every default-constructed component under test, which access it from // arbitrary threads. private let lock = NSLock() + private let installAccessGate: KeychainInstallAccessGate + private let reconcileInstallAccess: () -> Bool private var storage: [String: Data] = [:] private var serviceStorage: [String: [String: Data]] = [:] - init() {} + + init( + installAccessGate: KeychainInstallAccessGate = KeychainInstallAccessGate(), + reconcileInstallAccess: @escaping () -> Bool = { true } + ) { + self.installAccessGate = installAccessGate + self.reconcileInstallAccess = reconcileInstallAccess + } + + private func installAccessAllowed() -> Bool { + installAccessGate.allowsAccess(reconcile: reconcileInstallAccess) + } func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { + guard installAccessAllowed() else { return false } lock.lock() defer { lock.unlock() } storage[key] = keyData @@ -26,12 +40,14 @@ final class PreviewKeychainManager: KeychainManagerProtocol { } func getIdentityKey(forKey key: String) -> Data? { + guard installAccessAllowed() else { return nil } lock.lock() defer { lock.unlock() } return storage[key] } func deleteIdentityKey(forKey key: String) -> Bool { + guard installAccessAllowed() else { return false } lock.lock() defer { lock.unlock() } storage.removeValue(forKey: key) @@ -51,6 +67,7 @@ final class PreviewKeychainManager: KeychainManagerProtocol { func secureClear(_ string: inout String) {} func verifyIdentityKeyExists() -> Bool { + guard installAccessAllowed() else { return false } lock.lock() defer { lock.unlock() } return storage["identity_noiseStaticKey"] != nil @@ -58,6 +75,7 @@ final class PreviewKeychainManager: KeychainManagerProtocol { // BCH-01-009: New methods with proper error classification func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult { + guard installAccessAllowed() else { return .accessDenied } lock.lock() defer { lock.unlock() } if let data = storage[key] { @@ -67,6 +85,7 @@ final class PreviewKeychainManager: KeychainManagerProtocol { } func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult { + guard installAccessAllowed() else { return .accessDenied } lock.lock() defer { lock.unlock() } storage[key] = keyData @@ -76,24 +95,38 @@ final class PreviewKeychainManager: KeychainManagerProtocol { // MARK: - Generic Data Storage (consolidated from KeychainHelper) func save(key: String, data: Data, service: String, accessible: CFString?) { + guard installAccessAllowed() else { return } lock.lock() defer { lock.unlock() } serviceStorage[service, default: [:]][key] = data } func load(key: String, service: String) -> Data? { + guard case .success(let data) = loadWithResult(key: key, service: service) else { + return nil + } + return data + } + + func loadWithResult(key: String, service: String) -> KeychainReadResult { + guard installAccessAllowed() else { return .accessDenied } lock.lock() defer { lock.unlock() } - return serviceStorage[service]?[key] + guard let data = serviceStorage[service]?[key] else { + return .itemNotFound + } + return .success(data) } func delete(key: String, service: String) { + guard installAccessAllowed() else { return } lock.lock() defer { lock.unlock() } serviceStorage[service]?.removeValue(forKey: key) } func deleteAll(service: String) { + guard installAccessAllowed() else { return } lock.lock() defer { lock.unlock() } serviceStorage.removeValue(forKey: service) diff --git a/bitchatShareExtension/Localization/Localizable.xcstrings b/bitchatShareExtension/Localization/Localizable.xcstrings index e3271ccb..23c8fab2 100644 --- a/bitchatShareExtension/Localization/Localizable.xcstrings +++ b/bitchatShareExtension/Localization/Localizable.xcstrings @@ -202,207 +202,6 @@ } } }, - "share.status.failed_to_encode" : { - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تعذر ترميز الرابط", - "comment" : "Shown when the share payload cannot be encoded" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "লিঙ্ক এনকোড করা যায়নি" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "link konnte nicht codiert werden", - "comment" : "Shown when the share payload cannot be encoded" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "failed to encode link", - "comment" : "Shown when the share payload cannot be encoded" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "no se pudo codificar el enlace", - "comment" : "Shown when the share payload cannot be encoded" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "کدگذاری پیوند ناموفق بود" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "hindi ma-encode ang link" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "échec de l'encodage du lien", - "comment" : "Shown when the share payload cannot be encoded" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "לא ניתן לקודד את הקישור", - "comment" : "Shown when the share payload cannot be encoded" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "लिंक एन्कोड नहीं हो सका" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "gagal mengodekan tautan", - "comment" : "Shown when the share payload cannot be encoded" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "impossibile codificare il link", - "comment" : "Shown when the share payload cannot be encoded" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "リンクのエンコードに失敗しました", - "comment" : "Shown when the share payload cannot be encoded" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "링크를 인코딩하는 데 실패했습니다", - "comment" : "Shown when the share payload cannot be encoded" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gagal mengekod pautan" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "लिङ्क सङ्केत गर्न सकेन", - "comment" : "Shown when the share payload cannot be encoded" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "link coderen mislukt" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "nie udało się zakodować linku" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "falha ao codificar a ligação" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "falha ao codificar link", - "comment" : "Shown when the share payload cannot be encoded" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "не удалось закодировать ссылку", - "comment" : "Shown when the share payload cannot be encoded" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "kunde inte koda länken" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "இணைப்பை என்கோட் செய்ய முடியவில்லை" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "เข้ารหัสลิงก์ไม่สำเร็จ" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "bağlantı kodlanamadı", - "comment" : "Shown when the share payload cannot be encoded" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "не вдалося закодувати посилання", - "comment" : "Shown when the share payload cannot be encoded" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "لنک انکوڈ نہیں ہو سکا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "không thể mã hóa liên kết" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "无法编码链接", - "comment" : "Shown when the share payload cannot be encoded" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "無法編碼連結" - } - } - } - }, "share.status.no_shareable_content" : { "extractionState" : "manual", "localizations" : { @@ -805,406 +604,76 @@ } } }, - "share.status.shared_link" : { + "share.status.failed_to_save" : { + "comment" : "Shown when content cannot be staged for the main app", "extractionState" : "manual", "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ تم إرسال الرابط إلى bitchat", - "comment" : "Confirmation after successfully sharing a link" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ bitchat-এ লিঙ্ক শেয়ার করা হয়েছে" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ link zu bitchat geteilt", - "comment" : "Confirmation after successfully sharing a link" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ shared link to bitchat", - "comment" : "Confirmation after successfully sharing a link" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ enlace compartido con bitchat", - "comment" : "Confirmation after successfully sharing a link" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ پیوند در bitchat به اشتراک گذاشته شد" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ naibahagi ang link sa bitchat" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ lien partagé vers bitchat", - "comment" : "Confirmation after successfully sharing a link" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ הקישור נשלח אל bitchat", - "comment" : "Confirmation after successfully sharing a link" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ bitchat पर लिंक शेयर किया गया" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ tautan dikirim ke bitchat", - "comment" : "Confirmation after successfully sharing a link" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ link inviato a bitchat", - "comment" : "Confirmation after successfully sharing a link" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ bitchatにリンクを共有", - "comment" : "Confirmation after successfully sharing a link" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ bitchat으로 링크를 공유했습니다", - "comment" : "Confirmation after successfully sharing a link" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ pautan dikongsi ke bitchat" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ bitchat मा लिङ्क पठाइयो", - "comment" : "Confirmation after successfully sharing a link" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ link gedeeld met bitchat" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ udostępniono link w bitchat" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ ligação enviada para o bitchat" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ link enviado para bitchat", - "comment" : "Confirmation after successfully sharing a link" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ ссылка отправлена в bitchat", - "comment" : "Confirmation after successfully sharing a link" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ länk delad till bitchat" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ bitchat-க்கு இணைப்பு பகிரப்பட்டது" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ แชร์ลิงก์ไปยัง bitchat แล้ว" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ bitchat'e bağlantı paylaşıldı", - "comment" : "Confirmation after successfully sharing a link" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ посилання надіслано в bitchat", - "comment" : "Confirmation after successfully sharing a link" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ bitchat پر لنک شیئر کر دیا گیا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ đã chia sẻ liên kết tới bitchat" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ 已将链接分享至 bitchat", - "comment" : "Confirmation after successfully sharing a link" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ 已將連結分享至 bitchat" - } - } + "ar" : { "stringUnit" : { "state" : "needs_review", "value" : "تعذر الحفظ في bitchat" } }, + "bn" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat-এ সংরক্ষণ করা যায়নি" } }, + "de" : { "stringUnit" : { "state" : "needs_review", "value" : "Konnte nicht in bitchat gespeichert werden" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "Could not save to bitchat" } }, + "es" : { "stringUnit" : { "state" : "needs_review", "value" : "No se pudo guardar en bitchat" } }, + "fa" : { "stringUnit" : { "state" : "needs_review", "value" : "ذخیره در bitchat ممکن نشد" } }, + "fil" : { "stringUnit" : { "state" : "needs_review", "value" : "Hindi ma-save sa bitchat" } }, + "fr" : { "stringUnit" : { "state" : "needs_review", "value" : "Impossible d’enregistrer dans bitchat" } }, + "he" : { "stringUnit" : { "state" : "needs_review", "value" : "לא ניתן לשמור ב-bitchat" } }, + "hi" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat में सेव नहीं किया जा सका" } }, + "id" : { "stringUnit" : { "state" : "needs_review", "value" : "Tidak dapat menyimpan ke bitchat" } }, + "it" : { "stringUnit" : { "state" : "needs_review", "value" : "Impossibile salvare in bitchat" } }, + "ja" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat に保存できませんでした" } }, + "ko" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat에 저장할 수 없습니다" } }, + "ms" : { "stringUnit" : { "state" : "needs_review", "value" : "Tidak dapat menyimpan ke bitchat" } }, + "ne" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat मा सुरक्षित गर्न सकिएन" } }, + "nl" : { "stringUnit" : { "state" : "needs_review", "value" : "Kon niet opslaan in bitchat" } }, + "pl" : { "stringUnit" : { "state" : "needs_review", "value" : "Nie udało się zapisać w bitchat" } }, + "pt" : { "stringUnit" : { "state" : "needs_review", "value" : "Não foi possível guardar no bitchat" } }, + "pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "Não foi possível salvar no bitchat" } }, + "ru" : { "stringUnit" : { "state" : "needs_review", "value" : "Не удалось сохранить в bitchat" } }, + "sv" : { "stringUnit" : { "state" : "needs_review", "value" : "Kunde inte spara i bitchat" } }, + "ta" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat-இல் சேமிக்க முடியவில்லை" } }, + "th" : { "stringUnit" : { "state" : "needs_review", "value" : "บันทึกไปยัง bitchat ไม่ได้" } }, + "tr" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat’e kaydedilemedi" } }, + "uk" : { "stringUnit" : { "state" : "needs_review", "value" : "Не вдалося зберегти в bitchat" } }, + "ur" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat میں محفوظ نہیں ہو سکا" } }, + "vi" : { "stringUnit" : { "state" : "needs_review", "value" : "Không thể lưu vào bitchat" } }, + "zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "无法保存到 bitchat" } }, + "zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "無法儲存到 bitchat" } } } }, - "share.status.shared_text" : { + "share.status.saved_for_review" : { + "comment" : "Shown after content is staged for review in the main app", "extractionState" : "manual", "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ تم إرسال النص إلى bitchat", - "comment" : "Confirmation after successfully sharing text" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ bitchat-এ টেক্সট শেয়ার করা হয়েছে" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ text zu bitchat geteilt", - "comment" : "Confirmation after successfully sharing text" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ shared text to bitchat", - "comment" : "Confirmation after successfully sharing text" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ texto compartido con bitchat", - "comment" : "Confirmation after successfully sharing text" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ متن در bitchat به اشتراک گذاشته شد" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ naibahagi ang teksto sa bitchat" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ texte partagé vers bitchat", - "comment" : "Confirmation after successfully sharing text" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ הטקסט נשלח אל bitchat", - "comment" : "Confirmation after successfully sharing text" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ bitchat पर टेक्स्ट शेयर किया गया" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ teks dikirim ke bitchat", - "comment" : "Confirmation after successfully sharing text" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ testo inviato a bitchat", - "comment" : "Confirmation after successfully sharing text" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ bitchatにテキストを共有", - "comment" : "Confirmation after successfully sharing text" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ bitchat으로 텍스트를 공유했습니다", - "comment" : "Confirmation after successfully sharing text" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ teks dikongsi ke bitchat" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ bitchat मा पाठ पठाइयो", - "comment" : "Confirmation after successfully sharing text" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ tekst gedeeld met bitchat" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ udostępniono tekst w bitchat" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ texto enviado para o bitchat" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ texto enviado para bitchat", - "comment" : "Confirmation after successfully sharing text" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ текст отправлен в bitchat", - "comment" : "Confirmation after successfully sharing text" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ text delad till bitchat" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ bitchat-க்கு உரை பகிரப்பட்டது" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ แชร์ข้อความไปยัง bitchat แล้ว" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ bitchat'e metin paylaşıldı", - "comment" : "Confirmation after successfully sharing text" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ текст надіслано в bitchat", - "comment" : "Confirmation after successfully sharing text" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ bitchat پر متن شیئر کر دیا گیا" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ đã chia sẻ văn bản tới bitchat" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "✓ 已将文本分享至 bitchat", - "comment" : "Confirmation after successfully sharing text" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "✓ 已將文字分享至 bitchat" - } - } + "ar" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ حُفظ في bitchat — افتح التطبيق للمراجعة" } }, + "bn" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat-এ সংরক্ষিত — পর্যালোচনার জন্য অ্যাপটি খুলুন" } }, + "de" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ In bitchat gespeichert — App zum Prüfen öffnen" } }, + "en" : { "stringUnit" : { "state" : "translated", "value" : "✓ Saved in bitchat — open the app to review" } }, + "es" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Guardado en bitchat — abre la app para revisarlo" } }, + "fa" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ در bitchat ذخیره شد — برای بازبینی، برنامه را باز کنید" } }, + "fil" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Na-save sa bitchat — buksan ang app para suriin" } }, + "fr" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Enregistré dans bitchat — ouvrez l’app pour vérifier" } }, + "he" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ נשמר ב-bitchat — יש לפתוח את האפליקציה לבדיקה" } }, + "hi" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat में सेव किया गया — समीक्षा के लिए ऐप खोलें" } }, + "id" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Disimpan di bitchat — buka aplikasi untuk meninjau" } }, + "it" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Salvato in bitchat — apri l’app per controllare" } }, + "ja" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat に保存しました — アプリを開いて確認してください" } }, + "ko" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat에 저장됨 — 앱을 열어 검토하세요" } }, + "ms" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Disimpan dalam bitchat — buka aplikasi untuk menyemak" } }, + "ne" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat मा सुरक्षित गरियो — समीक्षा गर्न एप खोल्नुहोस्" } }, + "nl" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Opgeslagen in bitchat — open de app om te bekijken" } }, + "pl" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Zapisano w bitchat — otwórz aplikację, aby sprawdzić" } }, + "pt" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Guardado no bitchat — abra a app para rever" } }, + "pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Salvo no bitchat — abra o app para revisar" } }, + "ru" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Сохранено в bitchat — откройте приложение для проверки" } }, + "sv" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Sparat i bitchat — öppna appen för att granska" } }, + "ta" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat-இல் சேமிக்கப்பட்டது — மதிப்பாய்வு செய்ய செயலியைத் திறக்கவும்" } }, + "th" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ บันทึกใน bitchat แล้ว — เปิดแอปเพื่อตรวจสอบ" } }, + "tr" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat’e kaydedildi — incelemek için uygulamayı açın" } }, + "uk" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Збережено в bitchat — відкрийте застосунок для перегляду" } }, + "ur" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat میں محفوظ ہو گیا — جائزے کے لیے ایپ کھولیں" } }, + "vi" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Đã lưu trong bitchat — mở ứng dụng để xem lại" } }, + "zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ 已保存在 bitchat 中 — 打开应用查看" } }, + "zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ 已儲存在 bitchat 中 — 開啟 App 查看" } } } } }, diff --git a/bitchatShareExtension/ShareViewController.swift b/bitchatShareExtension/ShareViewController.swift index 6867d1d4..d46f1c1f 100644 --- a/bitchatShareExtension/ShareViewController.swift +++ b/bitchatShareExtension/ShareViewController.swift @@ -19,9 +19,8 @@ final class ShareViewController: UIViewController { static let nothingToShare = String(localized: "share.status.nothing_to_share", comment: "Shown when the share extension receives no content") static let noShareableContent = String(localized: "share.status.no_shareable_content", comment: "Shown when provided content cannot be shared") static let sharedLinkTitleFallback = String(localized: "share.fallback.shared_link_title", comment: "Fallback title when saving a shared link") - static let sharedLinkConfirmation = String(localized: "share.status.shared_link", comment: "Confirmation after successfully sharing a link") - static let sharedTextConfirmation = String(localized: "share.status.shared_text", comment: "Confirmation after successfully sharing text") - static let failedToEncode = String(localized: "share.status.failed_to_encode", comment: "Shown when the share payload cannot be encoded") + static let savedForReview = String(localized: "share.status.saved_for_review", comment: "Shown after content is staged for review in the main app") + static let failedToSave = String(localized: "share.status.failed_to_save", comment: "Shown when content cannot be staged for the main app") } private let statusLabel: UILabel = { @@ -44,9 +43,7 @@ final class ShareViewController: UIViewController { statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor), statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor) ]) - DispatchQueue.global().async { - self.processShare() - } + processShare() } // MARK: - Processing @@ -151,30 +148,33 @@ final class ShareViewController: UIViewController { // MARK: - Save + Finish private func saveAndFinish(url: URL, title: String?) { - let payload: [String: String] = [ - "url": url.absoluteString, - "title": title ?? url.host ?? Strings.sharedLinkTitleFallback - ] - if let json = try? JSONSerialization.data(withJSONObject: payload), - let s = String(data: json, encoding: .utf8) { - saveToSharedDefaults(content: s, type: "url") - finishWithMessage(Strings.sharedLinkConfirmation) - } else { - finishWithMessage(Strings.failedToEncode) - } + let payload = SharedContentPayload( + kind: .url, + content: url.absoluteString, + title: title ?? url.host ?? Strings.sharedLinkTitleFallback + ) + stageAndFinish(payload) } private func saveAndFinish(text: String) { - saveToSharedDefaults(content: text, type: "text") - finishWithMessage(Strings.sharedTextConfirmation) + stageAndFinish(.text(text)) } - private func saveToSharedDefaults(content: String, type: String) { - guard let userDefaults = UserDefaults(suiteName: Self.groupID) else { return } - userDefaults.set(content, forKey: "sharedContent") - userDefaults.set(type, forKey: "sharedContentType") - userDefaults.set(Date(), forKey: "sharedContentDate") - // No need to force synchronize; the system persists changes + private func stageAndFinish(_ payload: SharedContentPayload) { + guard let defaults = UserDefaults(suiteName: Self.groupID) else { + finishWithMessage(Strings.failedToSave) + return + } + let store = SharedContentStore(defaults: defaults) + + do { + try store.stage(payload) + // Staging is not sending. The main app will require a second, + // destination-labelled confirmation before filling its composer. + finishWithMessage(Strings.savedForReview) + } catch { + finishWithMessage(Strings.failedToSave) + } } private func finishWithMessage(_ msg: String) { diff --git a/bitchatTests/BLEServiceCoreTests.swift b/bitchatTests/BLEServiceCoreTests.swift index a1d62cae..3c856e19 100644 --- a/bitchatTests/BLEServiceCoreTests.swift +++ b/bitchatTests/BLEServiceCoreTests.swift @@ -39,7 +39,7 @@ struct BLEServiceCoreTests { ble._test_handlePacket(packet, fromPeerID: sender, signingPublicKey: signingKey) let receivedDuplicate = await TestHelpers.waitUntil( { delegate.publicMessagesSnapshot().count > 1 }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!receivedDuplicate) @@ -99,6 +99,95 @@ struct BLEServiceCoreTests { #expect(ble.currentPeerSnapshots().isEmpty) } + @Test + func unsignedAndBadSignatureLeaveDoNotEvictOrRelayClaimedPeer() async throws { + let ble = makeService() + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let mallory = NoiseEncryptionService(keychain: MockKeychain()) + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + let outbound = OutboundPacketTap() + ble._test_onOutboundPacket = outbound.record + + let unsigned = makeLeavePacket(sender: alicePeerID, marker: "unsigned") + ble._test_handlePacket( + unsigned, + fromPeerID: alicePeerID, + signingPublicKey: alice.getSigningPublicKeyData() + ) + + let unsignedRelayed = await TestHelpers.waitUntil( + { outbound.count(ofType: .leave) > 0 }, + timeout: TestConstants.negativeWaitWindow + ) + #expect(!unsignedRelayed) + #expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID }) + + let badSignature = try #require( + mallory.signPacket(makeLeavePacket(sender: alicePeerID, marker: "bad-signature")) + ) + ble._test_handlePacket( + badSignature, + fromPeerID: alicePeerID, + signingPublicKey: alice.getSigningPublicKeyData() + ) + + let badSignatureRelayed = await TestHelpers.waitUntil( + { outbound.count(ofType: .leave) > 0 }, + timeout: TestConstants.negativeWaitWindow + ) + #expect(!badSignatureRelayed) + #expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID }) + } + + @Test + func validSignedLeaveEvictsSessionAndRelays() async throws { + let ble = makeService() + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + + // Establish a real session so the leave regression also verifies that + // stale secure-delivery state is retired, not just the peer-list row. + let message1 = try ble._test_noiseInitiateHandshake(with: alicePeerID) + let message2 = try #require( + try alice.processHandshakeMessage(from: ble.myPeerID, message: message1) + ) + let message3 = try #require( + try ble._test_noiseProcessHandshakeMessage(from: alicePeerID, message: message2) + ) + _ = try alice.processHandshakeMessage(from: ble.myPeerID, message: message3) + #expect(ble.canDeliverSecurely(to: alicePeerID)) + let centralUUID = "central-valid-leave" + ble._test_bindCentral(centralUUID, to: alicePeerID) + ble._test_markNoiseAuthenticatedCentral(centralUUID, to: alicePeerID) + #expect(ble._test_isNoiseAuthenticatedCentral(centralUUID, for: alicePeerID)) + + let outbound = OutboundPacketTap() + ble._test_onOutboundPacket = outbound.record + let signedLeave = try #require( + alice.signPacket(makeLeavePacket(sender: alicePeerID, marker: "valid")) + ) + ble._test_handlePacket( + signedLeave, + fromPeerID: alicePeerID, + signingPublicKey: alice.getSigningPublicKeyData() + ) + + let evicted = await TestHelpers.waitUntil( + { + !ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID } + && !ble.canDeliverSecurely(to: alicePeerID) + && !ble._test_isNoiseAuthenticatedCentral(centralUUID, for: alicePeerID) + }, + timeout: TestConstants.longTimeout + ) + #expect(evicted) + let relayed = await TestHelpers.waitUntil( + { outbound.count(ofType: .leave) == 1 }, + timeout: TestConstants.longTimeout + ) + #expect(relayed) + } + @Test func ingressAllowsRelayedSenderOnBoundLink() async throws { let ble = makeService() @@ -413,14 +502,26 @@ struct BLEServiceCoreTests { ) let replay = try #require(victim.signPacket(unsigned), "Failed to sign replayed announce") #expect(ble._test_recordIngressIfNew(packet: replay, linkID: attackerLink)) + let rebindGate = VerifiedDirectRebindGate() + ble._test_afterVerifiedDirectRebindEnqueued = rebindGate.pause + defer { + rebindGate.release() + ble._test_afterVerifiedDirectRebindEnqueued = nil + } ble._test_handlePacket(replay, fromPeerID: victimPeerID, preseedPeer: false) - let rebound = await TestHelpers.waitUntil( - { ble._test_centralBinding(attackerLink) == victimPeerID }, + let announcePaused = await TestHelpers.waitUntil( + { rebindGate.hasPaused }, timeout: TestConstants.longTimeout ) - #expect(rebound) - #expect(ble.canDeliverSecurely(to: victimPeerID)) + try #require(announcePaused) + + // Rebind and ordinary reconnect preparation are one bleQueue + // critical section. Once the binding is visible, stale sending keys + // must already be unavailable. + #expect(ble._test_centralBinding(attackerLink) == victimPeerID) + #expect(!ble.canDeliverSecurely(to: victimPeerID)) + rebindGate.release() let outbound = OutboundPacketTap() ble._test_onOutboundPacket = { outbound.record($0) } @@ -440,6 +541,461 @@ struct BLEServiceCoreTests { #expect(outbound.count(ofType: .courierEnvelope) == 0) } + @Test + func replacementXXMessageOneWithPayloadCannotAuthenticateIngressLink() async throws { + let ble = makeService() + let victim = NoiseEncryptionService(keychain: MockKeychain()) + let victimPeerID = PeerID(publicKey: victim.getStaticPublicKeyData()) + + // Preserve a working victim session while an unauthenticated + // replacement candidate arrives on a newly bound physical link. + // Establish BLE as responder so the replacement candidate below is + // not coalesced by the initiator-completion grace path. + let message1 = try victim.initiateHandshake(with: ble.myPeerID) + let message2 = try #require( + try ble._test_noiseProcessHandshakeMessage( + from: victimPeerID, + message: message1 + ) + ) + let message3 = try #require( + try victim.processHandshakeMessage( + from: ble.myPeerID, + message: message2 + ) + ) + _ = try ble._test_noiseProcessHandshakeMessage( + from: victimPeerID, + message: message3 + ) + await ble._test_drainNoiseMessagePipeline() + #expect(ble.canDeliverSecurely(to: victimPeerID)) + + let centralUUID = "central-replacement-xx-message-one" + ble._test_bindCentral(centralUUID, to: victimPeerID) + #expect( + !ble._test_isNoiseAuthenticatedCentral( + centralUUID, + for: victimPeerID + ) + ) + + // XX message one may legally carry a payload, so its length is not a + // reliable signal that the replacement handshake completed. + let unauthenticatedInitiator = NoiseHandshakeState( + role: .initiator, + pattern: .XX, + keychain: MockKeychain() + ) + let replacementMessage1 = try unauthenticatedInitiator.writeMessage( + payload: Data([0xA5]) + ) + #expect( + replacementMessage1.count + > NoiseSecurityConstants.xxInitialMessageSize + ) + + let packet = BitchatPacket( + type: MessageType.noiseHandshake.rawValue, + senderID: Data(hexString: victimPeerID.id) ?? Data(), + recipientID: Data(hexString: ble.myPeerID.id), + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: replacementMessage1, + signature: nil, + ttl: TransportConfig.messageTTLDefault + ) + #expect( + ble._test_recordIngressIfNew( + packet: packet, + linkID: centralUUID + ) + ) + + let outbound = OutboundPacketTap() + ble._test_onOutboundPacket = outbound.record + ble._test_handlePacket( + packet, + fromPeerID: victimPeerID, + preseedPeer: false + ) + + // Waiting for the responder's message two proves the candidate was + // processed before checking its exact authentication result. + let candidateProcessed = await TestHelpers.waitUntil( + { outbound.count(ofType: .noiseHandshake) == 1 }, + timeout: TestConstants.longTimeout + ) + #expect(candidateProcessed) + #expect( + !ble._test_isNoiseAuthenticatedCentral( + centralUUID, + for: victimPeerID + ) + ) + // Ordinary reconnect hardening quarantines the cached transport while + // this candidate proves the claimed identity. It must be unavailable + // for sending as well as unable to authenticate this ingress link. + #expect(!ble.canDeliverSecurely(to: victimPeerID)) + } + + @Test + func failedInboundReconnectRestoresAndDrainsWaitingWorkOnce() async throws { + let ble = makeService() + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let mallory = NoiseEncryptionService(keychain: MockKeychain()) + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + + // Establish BLE as responder so the following inbound reconnect is + // not intentionally coalesced by the initiator-completion grace path. + let message1 = try alice.initiateHandshake(with: ble.myPeerID) + let message2 = try #require( + try ble._test_noiseProcessHandshakeMessage( + from: alicePeerID, + message: message1 + ) + ) + let message3 = try #require( + try alice.processHandshakeMessage( + from: ble.myPeerID, + message: message2 + ) + ) + _ = try ble._test_noiseProcessHandshakeMessage( + from: alicePeerID, + message: message3 + ) + await ble._test_drainNoiseMessagePipeline() + #expect(ble.canDeliverSecurely(to: alicePeerID)) + // The establishment transition enqueues its forced announce as a + // separate serialized phase; drain again so it lands before the tap + // and cannot masquerade as restore-driven announce traffic below. + await ble._test_drainNoiseMessagePipeline() + + let outbound = OutboundPacketTap() + ble._test_onOutboundPacket = outbound.record + let forgedMessage1 = try mallory.initiateHandshake(with: ble.myPeerID) + let firstPacket = BitchatPacket( + type: MessageType.noiseHandshake.rawValue, + senderID: Data(hexString: alicePeerID.id) ?? Data(), + recipientID: Data(hexString: ble.myPeerID.id), + timestamp: UInt64(Date().timeIntervalSince1970 * 1_000), + payload: forgedMessage1, + signature: nil, + ttl: 7 + ) + ble._test_handlePacket(firstPacket, fromPeerID: alicePeerID) + + let responseReady = await TestHelpers.waitUntil( + { + outbound.snapshot().contains { + $0.type == MessageType.noiseHandshake.rawValue + && PeerID(hexData: $0.senderID) == ble.myPeerID + && $0.payload.count + != NoiseSecurityConstants.xxInitialMessageSize + } + }, + timeout: TestConstants.longTimeout + ) + try #require(responseReady) + let forgedMessage2 = try #require( + outbound.snapshot().first { + $0.type == MessageType.noiseHandshake.rawValue + && PeerID(hexData: $0.senderID) == ble.myPeerID + && $0.payload.count + != NoiseSecurityConstants.xxInitialMessageSize + }?.payload + ) + #expect(!ble.canDeliverSecurely(to: alicePeerID)) + + // Typed control traffic must queue behind the ordinary responder, + // rather than attempting encryption and disappearing. + let privateMessageID = "quarantine-pm-\(UUID().uuidString)" + ble.sendPrivateMessage( + "queued private message", + to: alicePeerID, + recipientNickname: "Alice", + messageID: privateMessageID + ) + ble.sendGroupInvite(Data("queued-during-quarantine".utf8), to: alicePeerID) + await ble._test_drainNoiseMessagePipeline() + #expect(outbound.count(ofType: .noiseEncrypted) == 0) + + let forgedMessage3 = try #require( + try mallory.processHandshakeMessage( + from: ble.myPeerID, + message: forgedMessage2 + ) + ) + let forgedEarlyPayload = try #require( + BLENoisePayloadFactory.privateMessage( + content: "forged early message", + messageID: "forged-early" + ) + ) + try #require( + mallory.hasEstablishedSession(with: ble.myPeerID), + "forged initiator did not establish after producing message three" + ) + let forgedEarlyCiphertext = try mallory.encrypt( + forgedEarlyPayload, + for: ble.myPeerID + ) + let earlyPacket = BitchatPacket( + type: MessageType.noiseEncrypted.rawValue, + senderID: Data(hexString: alicePeerID.id) ?? Data(), + recipientID: Data(hexString: ble.myPeerID.id), + timestamp: UInt64(Date().timeIntervalSince1970 * 1_000) + 1, + payload: forgedEarlyCiphertext, + signature: nil, + ttl: 7 + ) + ble._test_handlePacket(earlyPacket, fromPeerID: alicePeerID) + await ble._test_drainNoiseMessagePipeline() + + let thirdPacket = BitchatPacket( + type: MessageType.noiseHandshake.rawValue, + senderID: Data(hexString: alicePeerID.id) ?? Data(), + recipientID: Data(hexString: ble.myPeerID.id), + timestamp: UInt64(Date().timeIntervalSince1970 * 1_000) + 2, + payload: forgedMessage3, + signature: nil, + ttl: 7 + ) + ble._test_handlePacket(thirdPacket, fromPeerID: alicePeerID) + + // Rollback restores the same generation. It retries the bounded early + // ciphertext and drains both outbound queues, but must not repeat a + // new-generation capability proof or forced announce. + let drained = await TestHelpers.waitUntil( + { outbound.count(ofType: .noiseEncrypted) >= 2 }, + timeout: TestConstants.longTimeout + ) + try #require(drained) + await ble._test_drainNoiseMessagePipeline() + let plaintexts = try outbound.snapshot() + .filter { $0.type == MessageType.noiseEncrypted.rawValue } + .map { try alice.decrypt($0.payload, from: ble.myPeerID) } + #expect(plaintexts.count == 2) + #expect( + plaintexts.filter { + $0.first == NoisePayloadType.authenticatedPeerState.rawValue + }.isEmpty + ) + #expect( + plaintexts.filter { + $0.first == NoisePayloadType.privateMessage.rawValue + }.count == 1 + ) + #expect( + plaintexts.filter { + $0.first == NoisePayloadType.groupInvite.rawValue + }.count == 1 + ) + #expect(outbound.count(ofType: .announce) == 0) + + // A duplicate ready callback cannot replay either buffer. + ble._test_reconcileCurrentNoiseSession(for: alicePeerID) + await ble._test_drainNoiseMessagePipeline() + #expect(outbound.count(ofType: .noiseEncrypted) == 2) + #expect(outbound.count(ofType: .announce) == 0) + } + + /// The message-loss interleaving behind a legitimate peer restart: the + /// remote completes a replacement handshake (discarding the old keys) + /// but its completion never arrives, so the local responder timeout + /// restores the quarantined OLD generation and requests one convergence + /// retry — both dispatched unordered. When the restore handler wins the + /// race, it must NOT drain the pending private-message/typed-payload + /// queues under the restored keys the remote no longer holds; the drain + /// has to wait for the convergence handshake and use its new session. + @Test + func timeoutRestoredSessionDefersQueueDrainUntilConvergence() async throws { + let ble = makeService(noiseResponderHandshakeTimeout: 0.3) + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let mallory = NoiseEncryptionService(keychain: MockKeychain()) + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + + // Establish BLE as responder so the inbound reconnect below is not + // coalesced by the initiator-completion grace path. + let message1 = try alice.initiateHandshake(with: ble.myPeerID) + let message2 = try #require( + try ble._test_noiseProcessHandshakeMessage( + from: alicePeerID, + message: message1 + ) + ) + let message3 = try #require( + try alice.processHandshakeMessage( + from: ble.myPeerID, + message: message2 + ) + ) + _ = try ble._test_noiseProcessHandshakeMessage( + from: alicePeerID, + message: message3 + ) + await ble._test_drainNoiseMessagePipeline() + #expect(ble.canDeliverSecurely(to: alicePeerID)) + + // The convergence retry only prepares for reachable peers. + ble._test_seedConnectedPeer(alicePeerID, nickname: "Alice") + + let reconciled = SessionReconcileCounter() + ble._test_onPrivateMediaSessionReconciled = reconciled.record + // Park the convergence-recovery callback on its global-queue thread + // before it can enqueue onto messageQueue: the restore handler + // deterministically wins the dispatch race this test exercises. + let recoveryGate = HandshakeRecoveryEnqueueGate() + defer { recoveryGate.release() } + ble._test_beforeHandshakeRecoveryEnqueued = { _ in recoveryGate.pause() } + let outbound = OutboundPacketTap() + ble._test_onOutboundPacket = outbound.record + + // Park the traffic the race would lose directly in the pending + // queues — the same place live sends land during quarantine — so no + // assertion below depends on outrunning the responder deadline under + // parallel test load. Nothing drains these queues while the current + // session stays untouched. + ble._test_enqueuePendingPrivateMessage( + content: "deferred private message", + messageID: "deferred-pm-\(UUID().uuidString)", + for: alicePeerID + ) + ble._test_enqueuePendingNoisePayload( + NoisePayload( + type: .groupInvite, + data: Data("queued-during-quarantine".utf8) + ).encode(), + transferId: "deferred-invite-\(UUID().uuidString)", + for: alicePeerID + ) + + // An unauthenticated message 1 quarantines the established transport. + // Its message 3 never arrives, modeling the restarted peer whose + // completion was lost after it already discarded the old keys. + let reconnectMessage1 = try mallory.initiateHandshake(with: ble.myPeerID) + let reconnectPacket = BitchatPacket( + type: MessageType.noiseHandshake.rawValue, + senderID: Data(hexString: alicePeerID.id) ?? Data(), + recipientID: Data(hexString: ble.myPeerID.id), + timestamp: UInt64(Date().timeIntervalSince1970 * 1_000), + payload: reconnectMessage1, + signature: nil, + ttl: 7 + ) + ble._test_handlePacket(reconnectPacket, fromPeerID: alicePeerID) + // The responder's message 2 is a monotonic quarantine signal; the + // secure-delivery dip itself only lasts until the responder deadline, + // which parallel test load can outrun. (The recovery gate keeps the + // convergence retry's message 1 out of the tap until released.) + let responderReady = await TestHelpers.waitUntil( + { outbound.count(ofType: .noiseHandshake) >= 1 }, + timeout: TestConstants.longTimeout + ) + try #require(responderReady) + #expect(outbound.count(ofType: .noiseEncrypted) == 0) + + // The responder timeout restores the quarantined generation; the + // gate guarantees its handler runs before the convergence retry. + let restoreRan = await TestHelpers.waitUntil( + { reconciled.count(for: alicePeerID) == 1 }, + timeout: TestConstants.longTimeout + ) + try #require(restoreRan) + #expect(ble.canDeliverSecurely(to: alicePeerID)) + await ble._test_drainNoiseMessagePipeline() + // The parked queues must not have been encrypted under the restored + // old generation the remote may no longer be able to read. + #expect(outbound.count(ofType: .noiseEncrypted) == 0) + + // Release the mandatory convergence retry: it retires the restored + // session and starts a fresh XX exchange with the live peer. + recoveryGate.release() + let retryStarted = await TestHelpers.waitUntil( + { + outbound.snapshot().contains { + $0.type == MessageType.noiseHandshake.rawValue + && PeerID(hexData: $0.senderID) == ble.myPeerID + && $0.payload.count + == NoiseSecurityConstants.xxInitialMessageSize + } + }, + timeout: TestConstants.longTimeout + ) + try #require(retryStarted) + #expect(outbound.count(ofType: .noiseEncrypted) == 0) + let retryMessage1 = try #require( + outbound.snapshot().last { + $0.type == MessageType.noiseHandshake.rawValue + && PeerID(hexData: $0.senderID) == ble.myPeerID + && $0.payload.count + == NoiseSecurityConstants.xxInitialMessageSize + }?.payload + ) + + // Alice answers the retry as the restarted peer she models: her old + // session is gone, so the retry is a fresh responder exchange (and + // never the initiator-completion grace deferral, whose lower-peerID + // arm would otherwise coalesce the retry for random key orderings). + alice.clearSession(for: ble.myPeerID) + let retryMessage2 = try #require( + try alice.processHandshakeMessage( + from: ble.myPeerID, + message: retryMessage1 + ) + ) + let retryPacket = BitchatPacket( + type: MessageType.noiseHandshake.rawValue, + senderID: Data(hexString: alicePeerID.id) ?? Data(), + recipientID: Data(hexString: ble.myPeerID.id), + timestamp: UInt64(Date().timeIntervalSince1970 * 1_000) + 1, + payload: retryMessage2, + signature: nil, + ttl: 7 + ) + ble._test_handlePacket(retryPacket, fromPeerID: alicePeerID) + let drained = await TestHelpers.waitUntil( + { outbound.count(ofType: .noiseEncrypted) >= 3 }, + timeout: TestConstants.longTimeout + ) + try #require(drained) + await ble._test_drainNoiseMessagePipeline() + + // Alice completes with message 3, then must be able to decrypt every + // drained payload — proving nothing left under the old generation. + let retryMessage3 = try #require( + outbound.snapshot().last { + $0.type == MessageType.noiseHandshake.rawValue + && PeerID(hexData: $0.senderID) == ble.myPeerID + && $0.payload.count + != NoiseSecurityConstants.xxInitialMessageSize + }?.payload + ) + _ = try alice.processHandshakeMessage( + from: ble.myPeerID, + message: retryMessage3 + ) + let plaintexts = try outbound.snapshot() + .filter { $0.type == MessageType.noiseEncrypted.rawValue } + .map { try alice.decrypt($0.payload, from: ble.myPeerID) } + #expect(plaintexts.count == 3) + #expect( + plaintexts.filter { + $0.first == NoisePayloadType.authenticatedPeerState.rawValue + }.count == 1 + ) + #expect( + plaintexts.filter { + $0.first == NoisePayloadType.privateMessage.rawValue + }.count == 1 + ) + #expect( + plaintexts.filter { + $0.first == NoisePayloadType.groupInvite.rawValue + }.count == 1 + ) + } + /// A legitimate rotation announce necessarily arrives on a link still /// bound to the OLD ID, so its registry upsert stores the new peer /// disconnected. The successful rebind must promote it: a healed @@ -572,6 +1128,146 @@ struct BLEServiceCoreTests { #expect(ble.myPeerID == PeerID(str: newFingerprint.prefix(16))) } + @Test + func panicSuspension_dropsLateOutboundWorkUntilCommit() async { + let ble = makeService() + let outbound = OutboundPacketTap() + ble._test_onOutboundPacket = outbound.record + let packet = makePublicPacket( + content: "late callback", + sender: ble.myPeerID, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000) + ) + + ble.suspendForPanicReset() + ble.sendPacket(packet) + #expect(outbound.count(ofType: .message) == 0) + + ble.completePanicReset(restartServices: false) + ble.sendPacket(packet) + #expect(outbound.count(ofType: .message) == 1) + } + + @Test @MainActor + func panicSuspension_invalidatesQueuedMainActorIngress() async { + let ble = makeService() + let delegate = TransportEventCaptureDelegate() + ble.eventDelegate = delegate + let message = BitchatMessage( + id: "pre-panic-ingress", + sender: "Peer", + content: "must not survive panic", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Me", + senderPeerID: PeerID(str: "1122334455667788") + ) + + // The test already owns MainActor, so this task cannot run until the + // synchronous panic boundary below has invalidated its generation. + ble._test_emitTransportEvent(.messageReceived(message)) + ble.suspendForPanicReset() + await Task.yield() + #expect(delegate.messageIDs.isEmpty) + + ble.completePanicReset(restartServices: false) + ble._test_emitTransportEvent(.messageReceived(message)) + await Task.yield() + #expect(delegate.messageIDs == [message.id]) + } + + @Test @MainActor + func panicSuspension_rejectsPausedBLEReceiveBeforeMessageQueueHandoff() async { + let ble = makeService() + let gate = ReceivePacketHandoffGate() + ble._test_beforeReceivePacketHandoff = gate.pause + ble._test_onReceivePacketHandoff = gate.recordHandoff + defer { + gate.release() + ble._test_beforeReceivePacketHandoff = nil + ble._test_onReceivePacketHandoff = nil + } + + let sender = PeerID(str: "1122334455667788") + let packet = makePublicPacket( + content: "must not cross panic", + sender: sender, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000) + ) + ble._test_handlePacketFromBLEQueue(packet, fromPeerID: sender) + #expect(await TestHelpers.waitUntil( + { gate.hasPaused }, + timeout: TestConstants.longTimeout + )) + + // Panic closes the lifecycle before waiting for the paused bleQueue + // callback. Releasing it afterward lets the callback enqueue its + // messageQueue handoff, where the captured generation must be rejected + // before packet processing starts. + let panicIngressObserver = PanicIngressObserver(service: ble) + let didObservePanicClosure = await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + let didObserveClosure = panicIngressObserver.waitUntilClosed( + timeout: TestConstants.settleTimeout + ) + gate.release() + continuation.resume(returning: didObserveClosure) + } + ble.suspendForPanicReset() + } + + #expect(didObservePanicClosure) + #expect(gate.handoffCount == 0) + + // A packet captured under the reopened lifecycle still crosses the + // same handoff, proving the test did not merely disable the hook. + ble.completePanicReset(restartServices: false) + ble._test_handlePacketFromBLEQueue(packet, fromPeerID: sender) + #expect(await TestHelpers.waitUntil( + { gate.handoffCount == 1 }, + timeout: TestConstants.longTimeout + )) + } + + @Test @MainActor + func panicSuspension_finalizesStaleTransportEventsAsRejected() async { + let ble = makeService() + let delegate = TransportEventCaptureDelegate() + ble.eventDelegate = delegate + let message = BitchatMessage( + id: "pre-panic-finalization", + sender: "Peer", + content: "must be rejected", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Me", + senderPeerID: PeerID(str: "1122334455667788") + ) + var completions = 0 + var outcomes: [TransportEventDeliveryOutcome] = [] + + ble._test_emitTransportEvent( + .messageReceived(message), + completion: { completions += 1 }, + finalization: { outcomes.append($0) } + ) + ble.suspendForPanicReset() + ble._test_emitTransportEvent( + .messageReceived(message), + completion: { completions += 1 }, + finalization: { outcomes.append($0) } + ) + for _ in 0..<4 { + await Task.yield() + } + + #expect(delegate.messageIDs.isEmpty) + #expect(completions == 0) + #expect(outcomes == [.rejected, .rejected]) + } + @Test func modifiedServices_rediscoverWhenBitChatServiceIsInvalidated() async throws { let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB") @@ -644,7 +1340,7 @@ struct BLEServiceCoreTests { // rotated sender IDs never bought a sixth response. let exceededBudget = await TestHelpers.waitUntil( { outbound.count(ofType: .pong) > budget }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!exceededBudget) #expect(outbound.count(ofType: .pong) == budget) @@ -664,9 +1360,147 @@ private final class OutboundPacketTap { lock.lock(); defer { lock.unlock() } return packets.filter { $0.type == type.rawValue }.count } + + func snapshot() -> [BitchatPacket] { + lock.lock(); defer { lock.unlock() } + return packets + } } -private func makeService() -> BLEService { +/// Blocks the convergence-recovery callback on its global-queue thread so a +/// test can prove the quarantine-restore handler wins the messageQueue race. +private final class HandshakeRecoveryEnqueueGate: @unchecked Sendable { + private let condition = NSCondition() + private var released = false + + func pause() { + condition.lock() + while !released { + condition.wait() + } + condition.unlock() + } + + func release() { + condition.lock() + released = true + condition.broadcast() + condition.unlock() + } +} + +/// Thread-safe counter for `_test_onPrivateMediaSessionReconciled` firings. +private final class SessionReconcileCounter: @unchecked Sendable { + private let lock = NSLock() + private var reconciles: [PeerID] = [] + + func record(_ peerID: PeerID) { + lock.lock() + reconciles.append(peerID) + lock.unlock() + } + + func count(for peerID: PeerID) -> Int { + lock.lock(); defer { lock.unlock() } + return reconciles.filter { $0 == peerID }.count + } +} + +private final class VerifiedDirectRebindGate: @unchecked Sendable { + private let condition = NSCondition() + private var paused = false + private var released = false + + var hasPaused: Bool { + condition.lock() + defer { condition.unlock() } + return paused + } + + func pause() { + condition.lock() + paused = true + condition.broadcast() + while !released { + condition.wait() + } + condition.unlock() + } + + func release() { + condition.lock() + released = true + condition.broadcast() + condition.unlock() + } +} + +private final class ReceivePacketHandoffGate: @unchecked Sendable { + private let condition = NSCondition() + private var paused = false + private var released = false + private var recordedHandoffCount = 0 + + var hasPaused: Bool { + condition.lock() + defer { condition.unlock() } + return paused + } + + var handoffCount: Int { + condition.lock() + defer { condition.unlock() } + return recordedHandoffCount + } + + func pause() { + condition.lock() + paused = true + condition.broadcast() + while !released { + condition.wait() + } + condition.unlock() + } + + func release() { + condition.lock() + released = true + condition.broadcast() + condition.unlock() + } + + func recordHandoff() { + condition.lock() + recordedHandoffCount += 1 + condition.unlock() + } +} + +/// Lets a dedicated dispatch worker observe the lock-protected panic gate +/// without treating the full BLE service as generally Sendable. +private final class PanicIngressObserver: @unchecked Sendable { + private let service: BLEService + + init(service: BLEService) { + self.service = service + } + + func waitUntilClosed(timeout: TimeInterval) -> Bool { + let deadline = DispatchTime.now().uptimeNanoseconds + + UInt64(timeout * 1_000_000_000) + while service._test_isPanicIngressOpen, + DispatchTime.now().uptimeNanoseconds < deadline { + Thread.sleep(forTimeInterval: 0.001) + } + return !service._test_isPanicIngressOpen + } +} + +private func makeService( + noiseResponderHandshakeTimeout: TimeInterval = + NoiseSecurityConstants.ordinaryResponderHandshakeTimeout +) -> BLEService { let keychain = MockKeychain() let identityManager = MockIdentityManager(keychain) let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) @@ -674,7 +1508,8 @@ private func makeService() -> BLEService { keychain: keychain, idBridge: idBridge, identityManager: identityManager, - initializeBluetoothManagers: false + initializeBluetoothManagers: false, + noiseResponderHandshakeTimeout: noiseResponderHandshakeTimeout ) } @@ -690,6 +1525,18 @@ private func makePublicPacket(content: String, sender: PeerID, timestamp: UInt64 ) } +private func makeLeavePacket(sender: PeerID, marker: String) -> BitchatPacket { + BitchatPacket( + type: MessageType.leave.rawValue, + senderID: Data(hexString: sender.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: Data(marker.utf8), + signature: nil, + ttl: TransportConfig.messageTTLDefault + ) +} + private final class PublicCaptureDelegate: BitchatDelegate { private let lock = NSLock() private(set) var publicMessages: [BitchatMessage] = [] @@ -723,4 +1570,15 @@ private final class PublicCaptureDelegate: BitchatDelegate { defer { lock.unlock() } return publicMessages } + +} + +@MainActor +private final class TransportEventCaptureDelegate: TransportEventDelegate { + private(set) var messageIDs: [String] = [] + + func didReceiveTransportEvent(_ event: TransportEvent) { + guard case .messageReceived(let message) = event else { return } + messageIDs.append(message.id) + } } diff --git a/bitchatTests/ChatLiveVoiceCoordinatorTests.swift b/bitchatTests/ChatLiveVoiceCoordinatorTests.swift index 18e25ef4..2e5ca317 100644 --- a/bitchatTests/ChatLiveVoiceCoordinatorTests.swift +++ b/bitchatTests/ChatLiveVoiceCoordinatorTests.swift @@ -14,6 +14,7 @@ import BitFoundation @MainActor private final class MockChatLiveVoiceContext: ChatLiveVoiceContext { var nickname = "me" + var myPeerID = PeerID(str: "0102030405060708") var selectedPrivateChatPeer: PeerID? var isViewingPublicMeshTimeline = false var blockedPeers: Set = [] @@ -23,7 +24,10 @@ private final class MockChatLiveVoiceContext: ChatLiveVoiceContext { private(set) var upsertedMessages: [(message: BitchatMessage, peerID: PeerID)] = [] private(set) var upsertedPublicMessages: [BitchatMessage] = [] private(set) var removedMessageIDs: [String] = [] + private(set) var sentReadReceipts: [(receipt: ReadReceipt, peerID: PeerID)] = [] private(set) var talkerUpdates: [String?] = [] + private(set) var privateMutationLog: [String] = [] + private var readReceiptMessageIDs: Set = [] func isPeerBlocked(_ peerID: PeerID) -> Bool { blockedPeers.contains(peerID) } func resolveNickname(for peerID: PeerID) -> String { "alice" } @@ -31,6 +35,7 @@ private final class MockChatLiveVoiceContext: ChatLiveVoiceContext { func appendPublicMeshMessage(_ message: BitchatMessage) { appendedPublicMessages.append(message) } func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) { upsertedMessages.append((message, peerID)) + privateMutationLog.append("upsert:\(message.id)") } func upsertPublicMeshMessage(_ message: BitchatMessage) { upsertedPublicMessages.append(message) @@ -38,8 +43,18 @@ private final class MockChatLiveVoiceContext: ChatLiveVoiceContext { @discardableResult func removePrivateMessage(withID messageID: String) -> BitchatMessage? { removedMessageIDs.append(messageID) + privateMutationLog.append("remove:\(messageID)") return nil } + func hasSentReadReceipt(_ messageID: String) -> Bool { + readReceiptMessageIDs.contains(messageID) + } + func markReadReceiptSent(_ messageID: String) -> Bool { + readReceiptMessageIDs.insert(messageID).inserted + } + func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { + sentReadReceipts.append((receipt, peerID)) + } func removeMessage(withID messageID: String, cleanupFile: Bool) { removedMessageIDs.append(messageID) } @@ -151,17 +166,29 @@ struct ChatLiveVoiceCoordinatorTests { @Test func absorbsFinalizedNoteIntoLiveBubble() throws { let context = MockChatLiveVoiceContext() + context.selectedPrivateChatPeer = peer let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) let burstID = makeBurstID(0xB2) let hex = burstID.hexEncodedString() + let fileName = "voice_\(hex).m4a" + let stableMessageID = try #require(PrivateMediaMessageIdentity.stableID( + senderPeerID: peer, + recipientPeerID: context.myPeerID, + fileName: fileName + )) send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 7, count: 50)]))), to: coordinator, from: peer) send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer) let bubble = try #require(context.handledPrivateMessages.first) + // The user read the live bubble, then left before the finalized file + // arrived. Stable-ID adoption must preserve that read state. + #expect(context.markReadReceiptSent(bubble.id)) + context.selectedPrivateChatPeer = nil let note = BitchatMessage( + id: stableMessageID, sender: "alice", - content: "[voice] voice_\(hex).m4a", + content: "[voice] \(fileName)", timestamp: Date(), isRelay: false, isPrivate: true, @@ -170,12 +197,21 @@ struct ChatLiveVoiceCoordinatorTests { ) #expect(coordinator.absorbFinalizedVoiceNote(note)) - // The note replaced the live bubble in place: same message ID, new - // content, partial capture deleted. + // The finalized note adopts the sender-correlatable ID, removes the + // receiver-local live ID, and emits a fresh READ now that the sender + // has created its finalized media row. let replacement = try #require(context.upsertedMessages.last) - #expect(replacement.message.id == bubble.id) + #expect(replacement.message.id == stableMessageID) #expect(replacement.message.content == note.content) #expect(replacement.peerID == peer) + #expect(context.removedMessageIDs.contains(bubble.id)) + #expect(Array(context.privateMutationLog.suffix(2)) == [ + "upsert:\(stableMessageID)", + "remove:\(bubble.id)" + ]) + #expect(context.sentReadReceipts.count == 1) + #expect(context.sentReadReceipts.first?.receipt.originalMessageID == stableMessageID) + #expect(context.sentReadReceipts.first?.peerID == peer) // The promoted partial capture is deleted in favor of the note. let url = try #require(fallbackFileURL(burstID: burstID, peerID: peer)) #expect(!FileManager.default.fileExists(atPath: url.path)) @@ -449,7 +485,8 @@ struct ChatLiveVoiceCoordinatorTests { isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: peer ) #expect(coordinator.absorbFinalizedVoiceNote(dmNote)) - #expect(try #require(context.upsertedMessages.last).message.id == dmBubble.id) + #expect(try #require(context.upsertedMessages.last).message.id == dmNote.id) + #expect(context.removedMessageIDs.contains(dmBubble.id)) } @Test func finalizedNoteBindsToItsAuthenticatedSender() throws { @@ -479,8 +516,9 @@ struct ChatLiveVoiceCoordinatorTests { ) #expect(coordinator.absorbFinalizedVoiceNote(note)) let replacement = try #require(context.upsertedMessages.last) - #expect(replacement.message.id == victimBubble.id) + #expect(replacement.message.id == note.id) #expect(replacement.peerID == peer) + #expect(context.removedMessageIDs.contains(victimBubble.id)) // The attacker's note can only ever claim the attacker's own bubble. let attackerNote = BitchatMessage( @@ -489,8 +527,9 @@ struct ChatLiveVoiceCoordinatorTests { ) #expect(coordinator.absorbFinalizedVoiceNote(attackerNote)) let attackerReplacement = try #require(context.upsertedMessages.last) - #expect(attackerReplacement.message.id == attackerBubble.id) + #expect(attackerReplacement.message.id == attackerNote.id) #expect(attackerReplacement.peerID == attacker) + #expect(context.removedMessageIDs.contains(attackerBubble.id)) // Both registry entries are consumed — nothing left to hijack. #expect(!coordinator.absorbFinalizedVoiceNote(note)) diff --git a/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift b/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift index 60932a5a..ad200113 100644 --- a/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift +++ b/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift @@ -7,15 +7,19 @@ // `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` / // `ChatPrivateConversationCoordinatorContextTests` exemplars. // -// Scope note: the async media-preparation pipelines (`ImageUtils`, -// `ChatMediaPreparation`) run real file/codec work and remain covered by -// `ChatMediaPreparationTests`; here we cover message enqueueing, transfer -// bookkeeping, and the blocked-context guards. +// Real file/codec work remains covered by `ChatMediaPreparationTests`. These +// tests inject paused media preparers to exercise cancellation ownership +// across the detached-preparation/MainActor boundary deterministically. // import Testing import Foundation import BitFoundation +#if os(iOS) +import UIKit +#else +import AppKit +#endif @testable import bitchat // MARK: - Mock Context @@ -54,6 +58,8 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext { private(set) var appendedPublicMessages: [(message: BitchatMessage, conversationID: ConversationID)] = [] private(set) var removedMessages: [(messageID: String, cleanupFile: Bool)] = [] + private(set) var untombstonedMediaRemovals: [String] = [] + private(set) var outgoingMediaRemovals: [String] = [] private(set) var systemMessages: [String] = [] private(set) var notifyUIChangedCount = 0 @@ -67,7 +73,21 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext { removedMessages.append((messageID, cleanupFile)) } + func removeUntombstonedMediaMessage(withID messageID: String) { + untombstonedMediaRemovals.append(messageID) + removedMessages.append((messageID, false)) + } + + func removeOutgoingMediaMessage(withID messageID: String) { + outgoingMediaRemovals.append(messageID) + removedMessages.append((messageID, false)) + } + func addSystemMessage(_ content: String) { systemMessages.append(content) } + private(set) var mediaDeletionRefusals: [String] = [] + func notifyMediaDeletionRefused(messageID: String) { + mediaDeletionRefusals.append(messageID) + } func notifyUIChanged() { notifyUIChangedCount += 1 } // Delivery status & dedup @@ -85,12 +105,133 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext { } // Mesh file transfer - private(set) var privateFileSends: [(peerID: PeerID, transferId: String)] = [] + private(set) var privateFileSends: [( + packet: BitchatFilePacket, + peerID: PeerID, + transferId: String + )] = [] + private(set) var privateFileLegacyAllowances: [Bool] = [] + private(set) var privateFileReceiptRetryTransferIDs: [String] = [] private(set) var broadcastFileSends: [String] = [] private(set) var cancelledTransfers: [String] = [] + private(set) var privateMediaPolicyResolutionRequests: [PeerID] = [] + private(set) var persistedDeletionBatches: [[String]] = [] + var requiredTombstoneIDs: Set = [] + var deletedMediaPersistenceResult = true + var deferDeletedMediaPersistence = false + private var pendingDeletionCompletions: [ + @MainActor (Bool) -> Void + ] = [] + var privateMediaPolicy: PrivateMediaSendPolicy = .encrypted + var resolvedPrivateMediaPolicy: PrivateMediaSendPolicy? + var resolvesPrivateMediaPolicyImmediately = true + var supportsAuthenticatedPrivateMediaReceipts = false + var authenticatedPrivateMediaReceiptGeneration = UUID( + uuidString: "00000000-0000-0000-0000-000000000001" + )! + private var pendingPrivateMediaPolicyResolutions: [ + @MainActor (PrivateMediaSendPolicy) -> Void + ] = [] + private(set) var legacyConsentRequests: [( + id: UUID, + peerID: PeerID, + transferId: String, + messageID: String + )] = [] + private(set) var invalidatedLegacyConsents: [(transferId: String, messageID: String)] = [] + private var pendingLegacyConsentIDs: [UUID] = [] + private var legacyConsentCompletions: [UUID: @MainActor (Bool) -> Void] = [:] - func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) { - privateFileSends.append((peerID, transferId)) + func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { + privateMediaPolicy + } + + func authenticatedPrivateMediaReceiptSessionGeneration( + to peerID: PeerID + ) -> UUID? { + supportsAuthenticatedPrivateMediaReceipts + ? authenticatedPrivateMediaReceiptGeneration + : nil + } + + func resolvePrivateMediaSendPolicy( + to peerID: PeerID, + completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void + ) { + privateMediaPolicyResolutionRequests.append(peerID) + if resolvesPrivateMediaPolicyImmediately { + completion(resolvedPrivateMediaPolicy ?? privateMediaPolicy) + } else { + pendingPrivateMediaPolicyResolutions.append(completion) + } + } + + var pendingPrivateMediaPolicyResolutionCount: Int { + pendingPrivateMediaPolicyResolutions.count + } + + func resolveNextPrivateMediaPolicy( + _ policy: PrivateMediaSendPolicy? = nil + ) { + guard !pendingPrivateMediaPolicyResolutions.isEmpty else { return } + let completion = pendingPrivateMediaPolicyResolutions.removeFirst() + completion( + policy + ?? resolvedPrivateMediaPolicy + ?? privateMediaPolicy + ) + } + + func requestLegacyPrivateMediaConsent( + for peerID: PeerID, + transferId: String, + messageID: String, + completion: @escaping @MainActor (Bool) -> Void + ) { + let id = UUID() + legacyConsentRequests.append((id, peerID, transferId, messageID)) + pendingLegacyConsentIDs.append(id) + legacyConsentCompletions[id] = completion + } + + func cancelLegacyPrivateMediaConsent(transferId: String, messageID: String) { + invalidatedLegacyConsents.append((transferId, messageID)) + let matchingIDs = Set(legacyConsentRequests.compactMap { request in + request.transferId == transferId && request.messageID == messageID + ? request.id + : nil + }) + pendingLegacyConsentIDs.removeAll { matchingIDs.contains($0) } + } + + func resolveNextLegacyConsent(_ approved: Bool) { + guard !pendingLegacyConsentIDs.isEmpty else { return } + let id = pendingLegacyConsentIDs.removeFirst() + legacyConsentCompletions[id]?(approved) + } + + func invokeLegacyConsentEvenIfInvalidated(id: UUID, approved: Bool) { + legacyConsentCompletions[id]?(approved) + } + + func sendFilePrivate( + _ packet: BitchatFilePacket, + to peerID: PeerID, + transferId: String, + allowLegacyFallback: Bool + ) { + privateFileSends.append((packet, peerID, transferId)) + privateFileLegacyAllowances.append(allowLegacyFallback) + } + + func sendFilePrivateReceiptRetry( + _ packet: BitchatFilePacket, + to peerID: PeerID, + transferId: String + ) { + privateFileSends.append((packet, peerID, transferId)) + privateFileLegacyAllowances.append(false) + privateFileReceiptRetryTransferIDs.append(transferId) } func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) { @@ -100,6 +241,126 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext { func cancelTransfer(_ transferId: String) { cancelledTransfers.append(transferId) } + + func persistDeletedPrivateMedia( + messageIDs: [String], + completion: @escaping @MainActor (Bool) -> Void + ) { + persistedDeletionBatches.append(messageIDs) + if deferDeletedMediaPersistence { + pendingDeletionCompletions.append(completion) + } else { + completion(deletedMediaPersistenceResult) + } + } + + func requiresPrivateMediaTombstone(messageID: String) -> Bool { + requiredTombstoneIDs.contains(messageID) + } + + func resolveNextDeletionPersistence(_ result: Bool? = nil) { + guard !pendingDeletionCompletions.isEmpty else { return } + let completion = pendingDeletionCompletions.removeFirst() + completion(result ?? deletedMediaPersistenceResult) + } +} + +private final class PausedVoiceNotePreparer: @unchecked Sendable { + private let condition = NSCondition() + private var started = false + private var released = false + private var finished = false + + func prepare(_ url: URL) throws -> BitchatFilePacket { + condition.lock() + started = true + condition.broadcast() + while !released { + condition.wait() + } + finished = true + condition.broadcast() + condition.unlock() + let content = Data("voice".utf8) + return BitchatFilePacket( + fileName: url.lastPathComponent, + fileSize: UInt64(content.count), + mimeType: "audio/mp4", + content: content + ) + } + + var hasStarted: Bool { + condition.lock() + defer { condition.unlock() } + return started + } + + var hasFinished: Bool { + condition.lock() + defer { condition.unlock() } + return finished + } + + func release() { + condition.lock() + released = true + condition.broadcast() + condition.unlock() + } +} + +private final class StaticVoiceNotePreparer: @unchecked Sendable { + private let packet: BitchatFilePacket + + init(fileName: String, content: Data = Data("voice".utf8)) { + packet = BitchatFilePacket( + fileName: fileName, + fileSize: UInt64(content.count), + mimeType: "audio/mp4", + content: content + ) + } + + func prepare(_ _: URL) throws -> BitchatFilePacket { + packet + } +} + +private final class DeterministicMediaTransferIDFactory: + @unchecked Sendable { + private let lock = NSLock() + private var nextOrdinal = 0 + + func make(messageID: String) -> String { + lock.lock() + defer { + nextOrdinal += 1 + lock.unlock() + } + return "\(messageID)-attempt-\(nextOrdinal)" + } +} + +private final class MutableMediaRetryClock: @unchecked Sendable { + private let lock = NSLock() + private var value: Date + + init(_ value: Date) { + self.value = value + } + + func now() -> Date { + lock.lock() + defer { lock.unlock() } + return value + } + + func advance(by interval: TimeInterval) { + lock.lock() + value = value.addingTimeInterval(interval) + lock.unlock() + } } // MARK: - Coordinator Tests Against Mock Context @@ -165,7 +426,16 @@ struct ChatMediaTransferCoordinatorContextTests { coordinator.handleTransferEvent(.cancelled(id: "t2", sentFragments: 1, totalFragments: 5)) #expect(context.removedMessages.count == 1) #expect(context.removedMessages.first?.messageID == "m2") - #expect(context.removedMessages.first?.cleanupFile == true) + #expect(context.removedMessages.first?.cleanupFile == false) + #expect(context.outgoingMediaRemovals == ["m2"]) + + // A pre-start rejection keeps the placeholder visible and failed, + // including queued post-handshake encryption failures. + coordinator.registerTransfer(transferId: "t3", messageID: "m3") + coordinator.handleTransferEvent(.rejected(id: "t3", reason: "encryption failed")) + #expect(context.deliveryStatusUpdates.last?.messageID == "m3") + #expect(context.deliveryStatusUpdates.last?.status == .failed(reason: "encryption failed")) + #expect(coordinator.messageIDToTransferId["m3"] == nil) } @Test @MainActor @@ -188,6 +458,295 @@ struct ChatMediaTransferCoordinatorContextTests { #expect(coordinator.messageIDToTransferId.isEmpty) } + @Test @MainActor + func resetForPanic_cancelsEveryTransportTransferAndClearsMappings() { + let context = MockChatMediaTransferContext() + let coordinator = ChatMediaTransferCoordinator(context: context) + coordinator.registerTransfer(transferId: "t1", messageID: "m1") + coordinator.registerTransfer(transferId: "t1", messageID: "m2") + coordinator.registerTransfer(transferId: "t2", messageID: "m3") + + coordinator.resetForPanic() + + #expect(Set(context.cancelledTransfers) == Set(["t1", "t2"])) + #expect(coordinator.transferIdToMessageIDs.isEmpty) + #expect(coordinator.messageIDToTransferId.isEmpty) + } + + @Test @MainActor + func resetForPanic_waitsForActiveImageWriterBeforeReturning() async throws { + let context = MockChatMediaTransferContext() + let sourceURL = try makeCoordinatorTestImageURL() + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent("panic-prepared-\(UUID().uuidString).jpg") + let preparer = PausedImagePreparer(outputURL: outputURL) + let coordinator = ChatMediaTransferCoordinator( + context: context, + prepareImagePacket: { sourceURL in + try preparer.prepare(sourceURL) + } + ) + defer { + preparer.release() + try? FileManager.default.removeItem(at: sourceURL) + try? FileManager.default.removeItem(at: outputURL) + } + + coordinator.sendImage(from: sourceURL) + #expect(await TestHelpers.waitUntil( + { preparer.hasStarted }, + timeout: TestConstants.longTimeout + )) + + DispatchQueue.global(qos: .userInitiated).asyncAfter( + deadline: .now() + .milliseconds(100) + ) { + preparer.release() + } + coordinator.resetForPanic() + + // The synchronous reset boundary cannot return while a pre-panic + // writer can still create output. The real panic path deletes media + // immediately after this method returns. + #expect(preparer.hasFinished) + + try? FileManager.default.removeItem(at: outputURL) + #expect(await TestHelpers.waitUntil( + { !FileManager.default.fileExists(atPath: outputURL.path) }, + timeout: TestConstants.longTimeout + )) + await Task.yield() + #expect(context.privateFileSends.isEmpty) + #expect(context.broadcastFileSends.isEmpty) + #expect(context.systemMessages.isEmpty) + } + + @Test @MainActor + func imagePreparation_doesNotRetainCoordinatorOrDeallocatedContext() async throws { + let sourceURL = try makeCoordinatorTestImageURL() + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent("released-context-\(UUID().uuidString).jpg") + let preparer = PausedImagePreparer(outputURL: outputURL) + var context: MockChatMediaTransferContext? = MockChatMediaTransferContext() + var coordinator: ChatMediaTransferCoordinator? = ChatMediaTransferCoordinator( + context: context!, + prepareImagePacket: { sourceURL in + try preparer.prepare(sourceURL) + } + ) + weak var weakContext: MockChatMediaTransferContext? + weak var weakCoordinator: ChatMediaTransferCoordinator? + weakContext = context + weakCoordinator = coordinator + defer { + preparer.release() + try? FileManager.default.removeItem(at: sourceURL) + try? FileManager.default.removeItem(at: outputURL) + } + + coordinator?.sendImage(from: sourceURL) + #expect(await TestHelpers.waitUntil( + { preparer.hasStarted }, + timeout: TestConstants.longTimeout + )) + + coordinator = nil + context = nil + #expect(weakCoordinator == nil) + #expect(weakContext == nil) + + preparer.release() + #expect(await TestHelpers.waitUntil( + { preparer.hasFinished }, + timeout: TestConstants.longTimeout + )) + #expect(await TestHelpers.waitUntil( + { !FileManager.default.fileExists(atPath: outputURL.path) }, + timeout: TestConstants.longTimeout + )) + } + + @Test @MainActor + func deleteMediaMessage_cancelsApprovedTransferBeforeRemovingMapping() { + let context = MockChatMediaTransferContext() + let coordinator = ChatMediaTransferCoordinator(context: context) + coordinator.registerTransfer(transferId: "approved-delete", messageID: "message-delete") + + coordinator.deleteMediaMessage(messageID: "message-delete") + + #expect(context.cancelledTransfers == ["approved-delete"]) + #expect(coordinator.messageIDToTransferId["message-delete"] == nil) + #expect(context.removedMessages.map(\.messageID) == ["message-delete"]) + #expect(context.removedMessages.first?.cleanupFile == false) + #expect( + context.untombstonedMediaRemovals == ["message-delete"] + ) + } + + @Test @MainActor + func deleteIncomingStableMediaWaitsForDurableTombstone() { + let context = MockChatMediaTransferContext() + let coordinator = ChatMediaTransferCoordinator(context: context) + let messageID = "media-00112233445566778899aabbccddeeff" + context.requiredTombstoneIDs = [messageID] + context.deferDeletedMediaPersistence = true + coordinator.registerTransfer( + transferId: "incoming-delete", + messageID: messageID + ) + + coordinator.deleteMediaMessage(messageID: messageID) + + #expect(context.persistedDeletionBatches == [[messageID]]) + #expect(context.removedMessages.isEmpty) + #expect(context.cancelledTransfers == ["incoming-delete"]) + #expect(coordinator.messageIDToTransferId[messageID] == nil) + + context.resolveNextDeletionPersistence(true) + + #expect(context.cancelledTransfers == ["incoming-delete"]) + #expect(context.removedMessages.map(\.messageID) == [messageID]) + #expect(context.removedMessages.first?.cleanupFile == false) + #expect(context.untombstonedMediaRemovals.isEmpty) + #expect(coordinator.messageIDToTransferId[messageID] == nil) + } + + @Test @MainActor + func deleteIncomingStableMediaCompletionAfterPanicIsIgnored() { + let context = MockChatMediaTransferContext() + let coordinator = ChatMediaTransferCoordinator(context: context) + let messageID = "media-11223344556677889900aabbccddeeff" + context.requiredTombstoneIDs = [messageID] + context.deferDeletedMediaPersistence = true + + coordinator.deleteMediaMessage(messageID: messageID) + #expect(context.persistedDeletionBatches == [[messageID]]) + + coordinator.resetForPanic() + context.resolveNextDeletionPersistence(true) + + #expect(context.removedMessages.isEmpty) + #expect(context.untombstonedMediaRemovals.isEmpty) + } + + @Test @MainActor + func deleteIncomingStableMediaPreservesStateWhenTombstoneFails() { + let context = MockChatMediaTransferContext() + let coordinator = ChatMediaTransferCoordinator(context: context) + let messageID = "media-ffeeddccbbaa99887766554433221100" + context.requiredTombstoneIDs = [messageID] + context.deletedMediaPersistenceResult = false + coordinator.registerTransfer( + transferId: "failed-delete", + messageID: messageID + ) + + coordinator.deleteMediaMessage(messageID: messageID) + + #expect(context.persistedDeletionBatches == [[messageID]]) + #expect(context.removedMessages.isEmpty) + #expect(context.cancelledTransfers == ["failed-delete"]) + #expect(coordinator.messageIDToTransferId[messageID] == nil) + // The refusal must be visible in the affected chat, not just logged. + #expect(context.mediaDeletionRefusals == [messageID]) + } + + @Test @MainActor + func deleteStableMediaReleasesRetainedRetryBeforeTombstoneCommit() + async throws + { + let context = MockChatMediaTransferContext() + let peerID = PeerID(str: "1122334455667788") + context.selectedPrivateChatPeer = peerID + context.supportsAuthenticatedPrivateMediaReceipts = true + context.deferDeletedMediaPersistence = true + let fileName = "voice_deadbeefdeadbeef.m4a" + let preparer = StaticVoiceNotePreparer(fileName: fileName) + let coordinator = ChatMediaTransferCoordinator( + context: context, + prepareVoiceNotePacket: { url in + try preparer.prepare(url) + } + ) + let url = try makeCoordinatorVoiceURL(fileName: fileName) + defer { + try? FileManager.default.removeItem( + at: url.deletingLastPathComponent() + ) + } + + coordinator.sendVoiceNote(at: url) + #expect(await TestHelpers.waitUntil( + { context.privateFileSends.count == 1 }, + timeout: TestConstants.longTimeout + )) + let messageID = try #require( + context.privateChats[peerID]?.first?.id + ) + let transferID = try #require( + context.privateFileSends.first?.transferId + ) + context.requiredTombstoneIDs = [messageID] + #expect(coordinator.retainedReconnectRetryCount == 1) + + coordinator.deleteMediaMessage(messageID: messageID) + + #expect(context.persistedDeletionBatches == [[messageID]]) + #expect(coordinator.retainedReconnectRetryCount == 0) + #expect(coordinator.retainedReconnectRetryBytes == 0) + #expect(context.cancelledTransfers == [transferID]) + #expect(context.removedMessages.isEmpty) + + coordinator.peerDidReconnect(peerID) + #expect(context.privateFileSends.count == 1) + + context.resolveNextDeletionPersistence(true) + #expect(context.removedMessages.map(\.messageID) == [messageID]) + } + + @Test @MainActor + func legacyCleanupNeverRecursivelyDeletesDirectoryTarget() throws { + let context = MockChatMediaTransferContext() + let coordinator = ChatMediaTransferCoordinator(context: context) + let incoming = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + .appendingPathComponent( + "files/images/incoming", + isDirectory: true + ) + let directoryName = "cleanup-dir-\(UUID().uuidString)" + let directory = incoming.appendingPathComponent( + directoryName, + isDirectory: true + ) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + let child = directory.appendingPathComponent("child.jpg") + try Data([0x01]).write(to: child) + defer { try? FileManager.default.removeItem(at: directory) } + let message = BitchatMessage( + id: UUID().uuidString, + sender: "Peer", + content: + "\(MimeType.Category.image.messagePrefix)\(directoryName)", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Me" + ) + + coordinator.cleanupIncomingLocalFile(forMessage: message) + + #expect(FileManager.default.fileExists(atPath: directory.path)) + #expect(FileManager.default.fileExists(atPath: child.path)) + } + @Test @MainActor func sendVoiceNote_blockedContextRemovesFileAndExplains() async throws { let context = MockChatMediaTransferContext() @@ -206,4 +765,1039 @@ struct ChatMediaTransferCoordinatorContextTests { #expect(context.appendedPublicMessages.isEmpty) #expect(coordinator.transferIdToMessageIDs.isEmpty) } + + @Test @MainActor + func privateVoiceNoteUsesWireDerivableMessageID() async throws { + let context = MockChatMediaTransferContext() + let coordinator = ChatMediaTransferCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") + context.selectedPrivateChatPeer = peerID + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("voice_receipt_\(UUID().uuidString).m4a") + try Data("voice".utf8).write(to: url) + defer { try? FileManager.default.removeItem(at: url) } + + coordinator.sendVoiceNote(at: url) + + #expect(await TestHelpers.waitUntil( + { context.privateFileSends.count == 1 }, + timeout: TestConstants.longTimeout + )) + let message = try #require(context.privateChats[peerID]?.first) + let sentPacket = try #require(context.privateFileSends.first?.packet) + #expect(message.id == PrivateMediaMessageIdentity.stableID( + for: sentPacket, + senderPeerID: context.myPeerID, + recipientPeerID: peerID + )) + } + + @Test @MainActor + func privateImageUsesWireDerivableMessageID() async throws { + let context = MockChatMediaTransferContext() + let coordinator = ChatMediaTransferCoordinator(context: context) + let peerID = PeerID(str: "99aabbccddeeff00") + context.selectedPrivateChatPeer = peerID + let sourceURL = try makeCoordinatorTestImageURL() + defer { try? FileManager.default.removeItem(at: sourceURL) } + + coordinator.sendImage(from: sourceURL) + + #expect(await TestHelpers.waitUntil( + { context.privateFileSends.count == 1 }, + timeout: TestConstants.longTimeout + )) + let message = try #require(context.privateChats[peerID]?.first) + let sentPacket = try #require(context.privateFileSends.first?.packet) + #expect(message.id == PrivateMediaMessageIdentity.stableID( + for: sentPacket, + senderPeerID: context.myPeerID, + recipientPeerID: peerID + )) + coordinator.cleanupLocalFile(forMessage: message) + } + + @Test @MainActor + func panicDuringImagePreparationDeletesStaleOutputWithoutSideEffects() async throws { + let context = MockChatMediaTransferContext() + let peerID = PeerID(str: "99aabbccddeeff00") + context.selectedPrivateChatPeer = peerID + let sourceURL = try makeCoordinatorTestImageURL() + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent( + "panic-stale-image-\(UUID().uuidString).jpg" + ) + let preparer = PausedImagePreparer(outputURL: outputURL) + let coordinator = ChatMediaTransferCoordinator( + context: context, + prepareImagePacket: { url in try preparer.prepare(url) } + ) + defer { + preparer.release() + try? FileManager.default.removeItem(at: sourceURL) + try? FileManager.default.removeItem(at: outputURL) + } + + coordinator.sendImage(from: sourceURL) + #expect(await TestHelpers.waitUntil( + { preparer.hasStarted }, + timeout: TestConstants.longTimeout + )) + + DispatchQueue.global(qos: .userInitiated).asyncAfter( + deadline: .now() + .milliseconds(100) + ) { + preparer.release() + } + coordinator.resetForPanic() + + #expect(await TestHelpers.waitUntil( + { preparer.hasFinished }, + timeout: TestConstants.longTimeout + )) + #expect(await TestHelpers.waitUntil( + { !FileManager.default.fileExists(atPath: outputURL.path) }, + timeout: TestConstants.longTimeout + )) + #expect(context.privateChats[peerID]?.isEmpty != false) + #expect(context.appendedPublicMessages.isEmpty) + #expect(context.privateFileSends.isEmpty) + #expect(context.broadcastFileSends.isEmpty) + #expect(context.systemMessages.isEmpty) + #expect(context.deliveryStatusUpdates.isEmpty) + #expect(coordinator.transferIdToMessageIDs.isEmpty) + #expect(coordinator.messageIDToTransferId.isEmpty) + } + + @Test @MainActor + func cancelVoiceNoteDuringDetachedPreparationCannotSendOrRestoreMapping() async throws { + let context = MockChatMediaTransferContext() + let peerID = PeerID(str: "5566778899aabbcc") + context.selectedPrivateChatPeer = peerID + let preparer = PausedVoiceNotePreparer() + let coordinator = ChatMediaTransferCoordinator( + context: context, + prepareVoiceNotePacket: { url in try preparer.prepare(url) } + ) + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("paused-private-\(UUID().uuidString).m4a") + try Data("voice".utf8).write(to: url) + defer { + preparer.release() + try? FileManager.default.removeItem(at: url) + } + + coordinator.sendVoiceNote(at: url) + #expect(await TestHelpers.waitUntil({ preparer.hasStarted }, timeout: TestConstants.longTimeout)) + let messageID = try #require(context.privateChats[peerID]?.first?.id) + let transferId = try #require(coordinator.messageIDToTransferId[messageID]) + + coordinator.cancelMediaSend(messageID: messageID) + preparer.release() + #expect(await TestHelpers.waitUntil({ preparer.hasFinished }, timeout: TestConstants.longTimeout)) + for _ in 0..<10 { await Task.yield() } + + #expect(context.cancelledTransfers == [transferId]) + #expect(context.privateFileSends.isEmpty) + #expect(context.broadcastFileSends.isEmpty) + #expect(coordinator.messageIDToTransferId[messageID] == nil) + #expect(coordinator.transferIdToMessageIDs[transferId] == nil) + #expect(context.removedMessages.map(\.messageID) == [messageID]) + } + + @Test @MainActor + func deletePublicVoiceNoteDuringDetachedPreparationCannotBroadcastOrRestoreMapping() async throws { + let context = MockChatMediaTransferContext() + let preparer = PausedVoiceNotePreparer() + let coordinator = ChatMediaTransferCoordinator( + context: context, + prepareVoiceNotePacket: { url in try preparer.prepare(url) } + ) + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("paused-public-\(UUID().uuidString).m4a") + try Data("voice".utf8).write(to: url) + defer { + preparer.release() + try? FileManager.default.removeItem(at: url) + } + + coordinator.sendVoiceNote(at: url) + #expect(await TestHelpers.waitUntil({ preparer.hasStarted }, timeout: TestConstants.longTimeout)) + let messageID = try #require(context.appendedPublicMessages.first?.message.id) + let transferId = try #require(coordinator.messageIDToTransferId[messageID]) + + coordinator.deleteMediaMessage(messageID: messageID) + preparer.release() + #expect(await TestHelpers.waitUntil({ preparer.hasFinished }, timeout: TestConstants.longTimeout)) + for _ in 0..<10 { await Task.yield() } + + #expect(context.cancelledTransfers == [transferId]) + #expect(context.broadcastFileSends.isEmpty) + #expect(context.privateFileSends.isEmpty) + #expect(coordinator.messageIDToTransferId[messageID] == nil) + #expect(coordinator.transferIdToMessageIDs[transferId] == nil) + #expect(context.removedMessages.map(\.messageID) == [messageID]) + } + + @Test @MainActor + func voicePreparationFailureMarksPlaceholderFailedAndClearsEarlyMapping() async throws { + let context = MockChatMediaTransferContext() + let peerID = PeerID(str: "66778899aabbccdd") + context.selectedPrivateChatPeer = peerID + let coordinator = ChatMediaTransferCoordinator( + context: context, + prepareVoiceNotePacket: { _ in + throw ChatMediaPreparationError.voiceNoteTooLarge(bytes: 999_999) + } + ) + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("failing-private-\(UUID().uuidString).m4a") + try Data("voice".utf8).write(to: url) + defer { try? FileManager.default.removeItem(at: url) } + + coordinator.sendVoiceNote(at: url) + #expect(await TestHelpers.waitUntil( + { + context.deliveryStatusUpdates.contains { update in + if case .failed = update.status { return true } + return false + } + }, + timeout: TestConstants.longTimeout + )) + let messageID = try #require(context.privateChats[peerID]?.first?.id) + + #expect(coordinator.messageIDToTransferId[messageID] == nil) + #expect(coordinator.transferIdToMessageIDs.isEmpty) + #expect(context.privateFileSends.isEmpty) + #expect(context.broadcastFileSends.isEmpty) + } + + @Test @MainActor + func legacyPrivateVoiceNoteWaitsForPerSendConsent() async throws { + let context = MockChatMediaTransferContext() + let coordinator = ChatMediaTransferCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") + context.selectedPrivateChatPeer = peerID + context.privateMediaPolicy = .legacyRequiresConsent + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("legacy-consent-\(UUID().uuidString).m4a") + try (Data([0x00, 0x00, 0x00, 0x18]) + Data("ftypM4A voice".utf8)).write(to: url) + defer { try? FileManager.default.removeItem(at: url) } + + coordinator.sendVoiceNote(at: url) + + let prompted = await TestHelpers.waitUntil( + { context.legacyConsentRequests.count == 1 }, + timeout: TestConstants.longTimeout + ) + #expect(prompted) + #expect(context.legacyConsentRequests.map { $0.peerID } == [peerID]) + #expect(context.privateFileSends.isEmpty) + + context.resolveNextLegacyConsent(true) + + #expect(context.privateFileSends.count == 1) + #expect(context.privateFileLegacyAllowances == [true]) + } + + @Test @MainActor + func capabilityProofTimeoutTransitionsToConsentWithoutAutomaticRawSend() async throws { + let context = MockChatMediaTransferContext() + let coordinator = ChatMediaTransferCoordinator(context: context) + let peerID = PeerID(str: "1020304050607080") + context.selectedPrivateChatPeer = peerID + context.privateMediaPolicy = .awaitingCapabilityProof + context.resolvedPrivateMediaPolicy = .legacyRequiresConsent + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("proof-timeout-consent-\(UUID().uuidString).m4a") + try Data("voice".utf8).write(to: url) + defer { try? FileManager.default.removeItem(at: url) } + + coordinator.sendVoiceNote(at: url) + + let prompted = await TestHelpers.waitUntil( + { context.legacyConsentRequests.count == 1 }, + timeout: TestConstants.longTimeout + ) + #expect(prompted) + #expect(context.privateFileSends.isEmpty) + context.resolveNextLegacyConsent(false) + #expect(context.privateFileSends.isEmpty) + } + + @Test @MainActor + func legacyConsentApprovalAfterCancelCannotSend() async throws { + let context = MockChatMediaTransferContext() + let coordinator = ChatMediaTransferCoordinator(context: context) + let peerID = PeerID(str: "2233445566778899") + context.selectedPrivateChatPeer = peerID + context.privateMediaPolicy = .legacyRequiresConsent + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("legacy-cancel-\(UUID().uuidString).m4a") + try Data("voice".utf8).write(to: url) + defer { try? FileManager.default.removeItem(at: url) } + + coordinator.sendVoiceNote(at: url) + let prompted = await TestHelpers.waitUntil( + { context.legacyConsentRequests.count == 1 }, + timeout: TestConstants.longTimeout + ) + #expect(prompted) + let request = try #require(context.legacyConsentRequests.first) + + coordinator.cancelMediaSend(messageID: request.messageID) + #expect(context.invalidatedLegacyConsents.contains { + $0.transferId == request.transferId && $0.messageID == request.messageID + }) + + // Model a stale framework callback that escaped active invalidation. + // The coordinator's transfer/message binding check is the final gate. + context.invokeLegacyConsentEvenIfInvalidated(id: request.id, approved: true) + #expect(context.privateFileSends.isEmpty) + #expect(coordinator.messageIDToTransferId[request.messageID] == nil) + } + + @Test @MainActor + func legacyConsentApprovalAfterDeleteCannotSend() async throws { + let context = MockChatMediaTransferContext() + let coordinator = ChatMediaTransferCoordinator(context: context) + let peerID = PeerID(str: "33445566778899aa") + context.selectedPrivateChatPeer = peerID + context.privateMediaPolicy = .legacyRequiresConsent + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("legacy-delete-\(UUID().uuidString).m4a") + try Data("voice".utf8).write(to: url) + defer { try? FileManager.default.removeItem(at: url) } + + coordinator.sendVoiceNote(at: url) + let prompted = await TestHelpers.waitUntil( + { context.legacyConsentRequests.count == 1 }, + timeout: TestConstants.longTimeout + ) + #expect(prompted) + let request = try #require(context.legacyConsentRequests.first) + + coordinator.deleteMediaMessage(messageID: request.messageID) + context.invokeLegacyConsentEvenIfInvalidated(id: request.id, approved: true) + + #expect(context.invalidatedLegacyConsents.contains { + $0.transferId == request.transferId && $0.messageID == request.messageID + }) + #expect(context.privateFileSends.isEmpty) + #expect(coordinator.messageIDToTransferId[request.messageID] == nil) + } + + @Test @MainActor + func pinnedPrivateMediaDowngradeNeverPromptsOrSends() async throws { + let context = MockChatMediaTransferContext() + let coordinator = ChatMediaTransferCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") + context.selectedPrivateChatPeer = peerID + context.privateMediaPolicy = .blockedDowngrade + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("blocked-downgrade-\(UUID().uuidString).m4a") + try Data("voice".utf8).write(to: url) + defer { try? FileManager.default.removeItem(at: url) } + + coordinator.sendVoiceNote(at: url) + + let failed = await TestHelpers.waitUntil( + { context.deliveryStatusUpdates.contains { update in + if case .failed = update.status { return true } + return false + } }, + timeout: TestConstants.longTimeout + ) + #expect(failed) + #expect(context.legacyConsentRequests.isEmpty) + #expect(context.privateFileSends.isEmpty) + } + + @Test @MainActor + func receiptCapableEncryptedMediaRetriesExactPacketAfterReconnect() async throws { + let context = MockChatMediaTransferContext() + let peerID = PeerID(str: "1122334455667788") + context.selectedPrivateChatPeer = peerID + context.supportsAuthenticatedPrivateMediaReceipts = true + let fileName = "voice_0011223344556677.m4a" + let preparer = StaticVoiceNotePreparer(fileName: fileName) + let transferIDs = DeterministicMediaTransferIDFactory() + let coordinator = ChatMediaTransferCoordinator( + context: context, + prepareVoiceNotePacket: { url in + try preparer.prepare(url) + }, + transferIDFactory: transferIDs.make + ) + let url = try makeCoordinatorVoiceURL(fileName: fileName) + defer { + try? FileManager.default.removeItem( + at: url.deletingLastPathComponent() + ) + } + + coordinator.sendVoiceNote(at: url) + #expect(await TestHelpers.waitUntil( + { context.privateFileSends.count == 1 }, + timeout: TestConstants.longTimeout + )) + let messageID = try #require( + context.privateChats[peerID]?.first?.id + ) + let initial = try #require( + context.privateFileSends.first + ) + #expect(PrivateMediaMessageIdentity.isStableID(messageID)) + #expect(coordinator.retainedReconnectRetryCount == 1) + + coordinator.handleTransferEvent(.completed( + id: initial.transferId, + totalFragments: 1 + )) + #expect(coordinator.retainedReconnectRetryCount == 1) + #expect(context.deliveryStatusUpdates.last?.status == .sent) + + coordinator.peerDidReconnect(peerID) + #expect(context.privateFileSends.count == 2) + let retry = try #require(context.privateFileSends.last) + #expect(retry.packet.encode() == initial.packet.encode()) + #expect(retry.peerID == peerID) + #expect(retry.transferId != initial.transferId) + #expect(context.privateFileReceiptRetryTransferIDs == [ + retry.transferId + ]) + #expect(context.privateFileLegacyAllowances == [false, false]) + + coordinator.confirmPrivateMediaDelivery(messageID: messageID) + #expect(coordinator.retainedReconnectRetryCount == 0) + #expect(context.cancelledTransfers == [retry.transferId]) + #expect(context.removedMessages.isEmpty) + #expect(!context.deliveryStatusUpdates.contains { + if case .failed = $0.status { return true } + return false + }) + + // Receipt confirmation removes retry ownership before transport + // cancellation, so its late callback cannot delete the delivered row + // or re-arm another reconnect retry. + coordinator.handleTransferEvent(.cancelled( + id: retry.transferId, + sentFragments: 1, + totalFragments: 2 + )) + coordinator.peerDidReconnect(peerID) + #expect(context.privateFileSends.count == 2) + #expect(context.removedMessages.isEmpty) + #expect(coordinator.messageIDToTransferId[messageID] == nil) + } + + @Test @MainActor + func 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 + func bit8OnlyEncryptedMediaNeverRetainsOrAutomaticallyRetries() async throws { + let context = MockChatMediaTransferContext() + let peerID = PeerID(str: "1122334455667788") + context.selectedPrivateChatPeer = peerID + context.privateMediaPolicy = .encrypted + context.supportsAuthenticatedPrivateMediaReceipts = false + let fileName = "voice_1111222233334444.m4a" + let preparer = StaticVoiceNotePreparer(fileName: fileName) + let coordinator = ChatMediaTransferCoordinator( + context: context, + prepareVoiceNotePacket: { url in + try preparer.prepare(url) + } + ) + let url = try makeCoordinatorVoiceURL(fileName: fileName) + defer { + try? FileManager.default.removeItem( + at: url.deletingLastPathComponent() + ) + } + + coordinator.sendVoiceNote(at: url) + #expect(await TestHelpers.waitUntil( + { context.privateFileSends.count == 1 }, + timeout: TestConstants.longTimeout + )) + let initial = try #require(context.privateFileSends.first) + coordinator.handleTransferEvent(.completed( + id: initial.transferId, + totalFragments: 1 + )) + coordinator.peerDidReconnect(peerID) + coordinator.peerDidAuthenticate(peerID) + + #expect(coordinator.retainedReconnectRetryCount == 0) + #expect(context.privateFileSends.count == 1) + #expect(context.privateFileReceiptRetryTransferIDs.isEmpty) + #expect(context.privateMediaPolicyResolutionRequests.isEmpty) + } + + @Test @MainActor + func consentedRawLegacyMediaNeverEntersAutomaticRetry() async throws { + let context = MockChatMediaTransferContext() + let peerID = PeerID(str: "1122334455667788") + context.selectedPrivateChatPeer = peerID + context.privateMediaPolicy = .legacyRequiresConsent + // Even a contradictory stale bit-9 observation must not retain an + // invocation that actually selected the explicit raw path. + context.supportsAuthenticatedPrivateMediaReceipts = true + let fileName = "voice_2222333344445555.m4a" + let preparer = StaticVoiceNotePreparer(fileName: fileName) + let coordinator = ChatMediaTransferCoordinator( + context: context, + prepareVoiceNotePacket: { url in + try preparer.prepare(url) + } + ) + let url = try makeCoordinatorVoiceURL(fileName: fileName) + defer { + try? FileManager.default.removeItem( + at: url.deletingLastPathComponent() + ) + } + + coordinator.sendVoiceNote(at: url) + #expect(await TestHelpers.waitUntil( + { context.legacyConsentRequests.count == 1 }, + timeout: TestConstants.longTimeout + )) + context.resolveNextLegacyConsent(true) + let initial = try #require(context.privateFileSends.first) + #expect(context.privateFileLegacyAllowances == [true]) + #expect(coordinator.retainedReconnectRetryCount == 0) + + coordinator.handleTransferEvent(.completed( + id: initial.transferId, + totalFragments: 1 + )) + context.privateMediaPolicy = .encrypted + coordinator.peerDidReconnect(peerID) + coordinator.peerDidAuthenticate(peerID) + + #expect(context.privateFileSends.count == 1) + #expect(context.privateFileReceiptRetryTransferIDs.isEmpty) + #expect(coordinator.retainedReconnectRetryCount == 0) + } + + @Test @MainActor + func authenticatedGenerationSupersedesStaleReconnectResolution() async throws { + let context = MockChatMediaTransferContext() + let peerID = PeerID(str: "1122334455667788") + context.selectedPrivateChatPeer = peerID + context.supportsAuthenticatedPrivateMediaReceipts = true + context.resolvesPrivateMediaPolicyImmediately = false + let oldGeneration = context + .authenticatedPrivateMediaReceiptGeneration + let newGeneration = UUID( + uuidString: "00000000-0000-0000-0000-000000000002" + )! + let fileName = "voice_3333444455556666.m4a" + let preparer = StaticVoiceNotePreparer(fileName: fileName) + let transferIDs = DeterministicMediaTransferIDFactory() + let coordinator = ChatMediaTransferCoordinator( + context: context, + prepareVoiceNotePacket: { url in + try preparer.prepare(url) + }, + transferIDFactory: transferIDs.make + ) + let url = try makeCoordinatorVoiceURL(fileName: fileName) + defer { + try? FileManager.default.removeItem( + at: url.deletingLastPathComponent() + ) + } + + coordinator.sendVoiceNote(at: url) + #expect(await TestHelpers.waitUntil( + { context.privateFileSends.count == 1 }, + timeout: TestConstants.longTimeout + )) + let initial = try #require(context.privateFileSends.first) + coordinator.peerDidReconnect(peerID) + #expect(context.pendingPrivateMediaPolicyResolutionCount == 1) + + context.authenticatedPrivateMediaReceiptGeneration = newGeneration + coordinator.peerDidAuthenticate(peerID) + #expect(context.pendingPrivateMediaPolicyResolutionCount == 2) + + // The old-generation completion lost ownership and is inert. + context.resolveNextPrivateMediaPolicy(.encrypted) + #expect(context.privateFileReceiptRetryTransferIDs.isEmpty) + #expect(context.cancelledTransfers.isEmpty) + + context.resolveNextPrivateMediaPolicy(.encrypted) + #expect(context.cancelledTransfers == [initial.transferId]) + #expect(context.privateFileReceiptRetryTransferIDs.count == 1) + #expect( + context.authenticatedPrivateMediaReceiptGeneration + == newGeneration + ) + #expect(oldGeneration != newGeneration) + } + + @Test @MainActor + func panicClearsRetainedRetryPendingResolutionAndExpiry() async throws { + let context = MockChatMediaTransferContext() + let peerID = PeerID(str: "1122334455667788") + context.selectedPrivateChatPeer = peerID + context.supportsAuthenticatedPrivateMediaReceipts = true + context.resolvesPrivateMediaPolicyImmediately = false + let fileName = "voice_3333444455556666.m4a" + let preparer = StaticVoiceNotePreparer(fileName: fileName) + let coordinator = ChatMediaTransferCoordinator( + context: context, + prepareVoiceNotePacket: { url in + try preparer.prepare(url) + }, + reconnectRetryLimits: PrivateMediaReconnectRetryLimits( + maxRetainedPackets: 1, + maxRetainedBytes: 1_024, + maxRetriesPerMessage: 1, + retentionSeconds: 0.1, + maxRetriesPerReconnect: 1 + ) + ) + let url = try makeCoordinatorVoiceURL(fileName: fileName) + defer { + try? FileManager.default.removeItem( + at: url.deletingLastPathComponent() + ) + } + + coordinator.sendVoiceNote(at: url) + #expect(await TestHelpers.waitUntil( + { context.privateFileSends.count == 1 }, + timeout: TestConstants.longTimeout + )) + let initial = try #require(context.privateFileSends.first) + coordinator.handleTransferEvent(.completed( + id: initial.transferId, + totalFragments: 1 + )) + #expect(coordinator.retainedReconnectRetryCount == 1) + + coordinator.peerDidReconnect(peerID) + #expect(context.pendingPrivateMediaPolicyResolutionCount == 1) + let failedBeforePanic = context.deliveryStatusUpdates.filter { + if case .failed = $0.status { return true } + return false + }.count + + coordinator.resetForPanic() + context.resolveNextPrivateMediaPolicy(.encrypted) + try await Task.sleep(nanoseconds: 250_000_000) + coordinator._test_expireReconnectRetries() + + #expect(coordinator.retainedReconnectRetryCount == 0) + #expect(coordinator.retainedReconnectRetryBytes == 0) + #expect(coordinator.transferIdToMessageIDs.isEmpty) + #expect(coordinator.messageIDToTransferId.isEmpty) + #expect(context.privateFileSends.count == 1) + #expect(context.privateFileReceiptRetryTransferIDs.isEmpty) + #expect(context.deliveryStatusUpdates.filter { + if case .failed = $0.status { return true } + return false + }.count == failedBeforePanic) + } + + @Test @MainActor + func retryCountAndRetentionTimeEndInVisibleFailure() async throws { + let context = MockChatMediaTransferContext() + let peerID = PeerID(str: "1122334455667788") + context.selectedPrivateChatPeer = peerID + context.supportsAuthenticatedPrivateMediaReceipts = true + let clock = MutableMediaRetryClock( + Date(timeIntervalSince1970: 4_000) + ) + let limits = PrivateMediaReconnectRetryLimits( + maxRetainedPackets: 2, + maxRetainedBytes: 1_024, + maxRetriesPerMessage: 1, + retentionSeconds: 10, + maxRetriesPerReconnect: 1 + ) + let fileName = "voice_4444555566667777.m4a" + let preparer = StaticVoiceNotePreparer(fileName: fileName) + let transferIDs = DeterministicMediaTransferIDFactory() + let coordinator = ChatMediaTransferCoordinator( + context: context, + prepareVoiceNotePacket: { url in + try preparer.prepare(url) + }, + reconnectRetryLimits: limits, + now: clock.now, + transferIDFactory: transferIDs.make + ) + let url = try makeCoordinatorVoiceURL(fileName: fileName) + defer { + try? FileManager.default.removeItem( + at: url.deletingLastPathComponent() + ) + } + + coordinator.sendVoiceNote(at: url) + #expect(await TestHelpers.waitUntil( + { context.privateFileSends.count == 1 }, + timeout: TestConstants.longTimeout + )) + let initial = try #require(context.privateFileSends.first) + coordinator.handleTransferEvent(.completed( + id: initial.transferId, + totalFragments: 1 + )) + coordinator.peerDidReconnect(peerID) + let retryID = try #require( + context.privateFileReceiptRetryTransferIDs.first + ) + coordinator.handleTransferEvent(.cancelled( + id: retryID, + sentFragments: 0, + totalFragments: 1 + )) + + #expect(coordinator.retainedReconnectRetryCount == 0) + #expect(context.removedMessages.isEmpty) + #expect(context.deliveryStatusUpdates.contains { + $0.messageID.hasPrefix("media-") + && $0.status == .failed(reason: String( + localized: "content.delivery.reason.not_delivered", + defaultValue: "Not delivered", + comment: "Failure reason shown when a private media transfer could not finish" + )) + }) + + // A separate retained row that locally completed but never received a + // remote receipt expires to a distinct visible failure. + let ttlFileName = "voice_5555666677778888.m4a" + let ttlPreparer = StaticVoiceNotePreparer( + fileName: ttlFileName + ) + let ttlCoordinator = ChatMediaTransferCoordinator( + context: context, + prepareVoiceNotePacket: { url in + try ttlPreparer.prepare(url) + }, + reconnectRetryLimits: limits, + now: clock.now, + transferIDFactory: transferIDs.make + ) + let ttlURL = try makeCoordinatorVoiceURL( + fileName: ttlFileName + ) + defer { + try? FileManager.default.removeItem( + at: ttlURL.deletingLastPathComponent() + ) + } + let sendsBeforeTTL = context.privateFileSends.count + ttlCoordinator.sendVoiceNote(at: ttlURL) + #expect(await TestHelpers.waitUntil( + { + context.privateFileSends.count + == sendsBeforeTTL + 1 + }, + timeout: TestConstants.longTimeout + )) + let ttlInitial = try #require(context.privateFileSends.last) + ttlCoordinator.handleTransferEvent(.completed( + id: ttlInitial.transferId, + totalFragments: 1 + )) + clock.advance(by: 10) + ttlCoordinator._test_expireReconnectRetries() + + #expect(ttlCoordinator.retainedReconnectRetryCount == 0) + #expect(context.deliveryStatusUpdates.contains { + $0.status == .failed( + reason: String( + localized: + "content.delivery.reason.private_media_delivery_unconfirmed", + defaultValue: "Delivery could not be confirmed" + ) + ) + }) + } + + @Test @MainActor + func retentionAndPerReconnectWorkAreBounded() async throws { + let context = MockChatMediaTransferContext() + let peerID = PeerID(str: "1122334455667788") + context.selectedPrivateChatPeer = peerID + context.supportsAuthenticatedPrivateMediaReceipts = true + let limits = PrivateMediaReconnectRetryLimits( + maxRetainedPackets: 2, + maxRetainedBytes: 10, + maxRetriesPerMessage: 2, + retentionSeconds: 120, + maxRetriesPerReconnect: 1 + ) + let transferIDs = DeterministicMediaTransferIDFactory() + let coordinator = ChatMediaTransferCoordinator( + context: context, + prepareVoiceNotePacket: { url in + let content = Data("voice".utf8) + return BitchatFilePacket( + fileName: url.lastPathComponent, + fileSize: UInt64(content.count), + mimeType: "audio/mp4", + content: content + ) + }, + reconnectRetryLimits: limits, + transferIDFactory: transferIDs.make + ) + let fileNames = [ + "voice_6666777788889999.m4a", + "voice_777788889999aaaa.m4a", + "voice_88889999aaaabbbb.m4a" + ] + var roots: [URL] = [] + defer { + for root in roots { + try? FileManager.default.removeItem(at: root) + } + } + + for fileName in fileNames { + let url = try makeCoordinatorVoiceURL( + fileName: fileName, + bytes: Data("voice".utf8) + ) + roots.append(url.deletingLastPathComponent()) + // The production preparer preserves this stable filename. + coordinator.sendVoiceNote(at: url) + } + #expect(await TestHelpers.waitUntil( + { context.privateFileSends.count == 3 }, + timeout: TestConstants.longTimeout + )) + #expect(coordinator.retainedReconnectRetryCount == 2) + #expect(coordinator.retainedReconnectRetryBytes <= 10) + + for send in context.privateFileSends { + coordinator.handleTransferEvent(.completed( + id: send.transferId, + totalFragments: 1 + )) + } + coordinator.peerDidReconnect(peerID) + #expect(context.privateFileReceiptRetryTransferIDs.count == 1) + } + + @Test @MainActor + func userCancellationReleasesRetainedBytesAndIgnoresLateEvent() async throws { + let context = MockChatMediaTransferContext() + let peerID = PeerID(str: "1122334455667788") + context.selectedPrivateChatPeer = peerID + context.supportsAuthenticatedPrivateMediaReceipts = true + let fileName = "voice_9999aaaabbbbcccc.m4a" + let preparer = StaticVoiceNotePreparer(fileName: fileName) + let coordinator = ChatMediaTransferCoordinator( + context: context, + prepareVoiceNotePacket: { url in + try preparer.prepare(url) + } + ) + let url = try makeCoordinatorVoiceURL(fileName: fileName) + defer { + try? FileManager.default.removeItem( + at: url.deletingLastPathComponent() + ) + } + + coordinator.sendVoiceNote(at: url) + #expect(await TestHelpers.waitUntil( + { context.privateFileSends.count == 1 }, + timeout: TestConstants.longTimeout + )) + let messageID = try #require( + context.privateChats[peerID]?.first?.id + ) + let transferID = try #require( + context.privateFileSends.first?.transferId + ) + + coordinator.cancelMediaSend(messageID: messageID) + coordinator.handleTransferEvent(.cancelled( + id: transferID, + sentFragments: 0, + totalFragments: 1 + )) + + #expect(coordinator.retainedReconnectRetryCount == 0) + #expect(coordinator.retainedReconnectRetryBytes == 0) + #expect(context.cancelledTransfers == [transferID]) + #expect(context.removedMessages.map(\.messageID) == [ + messageID + ]) + #expect(!context.deliveryStatusUpdates.contains { + if case .failed = $0.status { return true } + return false + }) + } +} + +private func makeCoordinatorVoiceURL( + fileName: String, + bytes: Data = Data("voice".utf8) +) throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent( + "media-retry-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + let url = directory.appendingPathComponent(fileName) + try bytes.write(to: url) + return url +} + +private final class PausedImagePreparer: @unchecked Sendable { + private let condition = NSCondition() + private let outputURL: URL + private var started = false + private var released = false + private var finished = false + + init(outputURL: URL) { + self.outputURL = outputURL + } + + var hasStarted: Bool { + condition.lock() + defer { condition.unlock() } + return started + } + + var hasFinished: Bool { + condition.lock() + defer { condition.unlock() } + return finished + } + + func prepare(_ _: URL) throws -> ChatPreparedImage { + condition.lock() + started = true + condition.broadcast() + while !released { + condition.wait() + } + condition.unlock() + + let data = Data("prepared image".utf8) + try data.write(to: outputURL, options: .atomic) + let packet = BitchatFilePacket( + fileName: outputURL.lastPathComponent, + fileSize: UInt64(data.count), + mimeType: "image/jpeg", + content: data + ) + + condition.lock() + finished = true + condition.broadcast() + condition.unlock() + return ChatPreparedImage(outputURL: outputURL, packet: packet) + } + + func release() { + condition.lock() + released = true + condition.broadcast() + condition.unlock() + } +} + +private func makeCoordinatorTestImageURL() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("coordinator-image-\(UUID().uuidString).png") + #if os(iOS) + let image = UIGraphicsImageRenderer(size: CGSize(width: 16, height: 16)) + .image { context in + UIColor.systemBlue.setFill() + context.fill(CGRect(x: 0, y: 0, width: 16, height: 16)) + } + guard let data = image.pngData() else { + throw CoordinatorImageTestError.encodingFailed + } + #else + let image = NSImage(size: NSSize(width: 16, height: 16)) + image.lockFocus() + NSColor.systemBlue.setFill() + NSRect(x: 0, y: 0, width: 16, height: 16).fill() + image.unlockFocus() + guard let tiff = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiff), + let data = bitmap.representation(using: .png, properties: [:]) else { + throw CoordinatorImageTestError.encodingFailed + } + #endif + try data.write(to: url, options: .atomic) + return url +} + +private enum CoordinatorImageTestError: Error { + case encodingFailed } diff --git a/bitchatTests/ChatNostrCoordinatorContextTests.swift b/bitchatTests/ChatNostrCoordinatorContextTests.swift index 647e0957..80d249c4 100644 --- a/bitchatTests/ChatNostrCoordinatorContextTests.swift +++ b/bitchatTests/ChatNostrCoordinatorContextTests.swift @@ -354,10 +354,12 @@ struct ChatNostrCoordinatorContextTests { coordinator.inbound.handleGiftWrap(giftWrap, id: recipient) + // The NIP-17 unwrap runs off the main actor; wait for the hop back. let convKey = PeerID(nostr_: sender.publicKeyHex) + let routed = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 }) + #expect(routed) #expect(context.recordedNostrEventIDs == [giftWrap.id]) #expect(context.nostrKeyMapping[convKey] == sender.publicKeyHex) - #expect(context.handledPrivateMessages.count == 1) #expect(context.handledPrivateMessages.first?.senderPubkey == sender.publicKeyHex) #expect(context.handledPrivateMessages.first?.convKey == convKey) @@ -370,30 +372,76 @@ struct ChatNostrCoordinatorContextTests { // The same gift wrap is dropped on replay. coordinator.inbound.handleGiftWrap(giftWrap, id: recipient) + await drainMainQueue() #expect(context.recordedNostrEventIDs == [giftWrap.id]) #expect(context.handledPrivateMessages.count == 1) } @Test @MainActor - func processNostrMessage_invalidSignatureDoesNotPoisonDedup() async throws { + func handleGiftWrap_panicWipeAfterSpawnDropsDecryptedResult() async throws { let context = MockChatNostrContext() let coordinator = ChatNostrCoordinator(context: context) let recipient = try NostrIdentity.generate() let sender = try NostrIdentity.generate() + let embedded = try #require(NostrEmbeddedBitChat.encodePMForNostrNoRecipient( + content: "pre-wipe secret", + messageID: "gm-wipe-1", + senderPeerID: PeerID(str: "aabbccddeeff0011") + )) + let giftWrap = try NostrProtocol.createPrivateMessage( + content: embedded, + recipientPubkey: recipient.publicKeyHex, + senderIdentity: sender + ) + + // Spawn the detached decrypt (it strongly captures the pre-wipe + // identity), then panic-wipe in the SAME main-actor turn — guaranteed + // to land before the task's first main-actor hop. + coordinator.inbound.handleGiftWrap(giftWrap, id: recipient) + coordinator.inbound.invalidateInFlightDecrypts() + + // Give the detached task ample time to have delivered if the wipe + // guard were broken. + try? await Task.sleep(nanoseconds: 200_000_000) + await drainMainQueue() + + #expect(context.handledPrivateMessages.isEmpty) + #expect(context.recordedNostrEventIDs.isEmpty) + + // The pipeline itself stays usable: a gift wrap spawned AFTER the + // wipe (new generation) still decrypts and delivers. + coordinator.inbound.handleGiftWrap(giftWrap, id: recipient) + let delivered = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 }) + #expect(delivered) + } + + // NOTE: Inbound Schnorr signature verification (and the forged-copy + // dedup-poisoning invariant) is enforced once, off the main actor, at the + // relay boundary — see NostrRelayManagerTests + // `test_receiveEvent_invalidSignatureDoesNotPoisonDuplicateCache` and + // `test_receiveGiftWrap_tamperedSignatureIsDroppedAndDoesNotPoisonDedup`. + // The inbound pipeline only ever sees verified events. + + @Test @MainActor + func processNostrMessage_duplicateDeliveryProcessesOnce() async throws { + let context = MockChatNostrContext() + let coordinator = ChatNostrCoordinator(context: context) + + let recipient = try NostrIdentity.generate() + let sender = try NostrIdentity.generate() + context.nostrIdentity = recipient let giftWrap = try NostrProtocol.createPrivateMessage( content: "verify:noop", recipientPubkey: recipient.publicKeyHex, senderIdentity: sender ) - var invalidGiftWrap = giftWrap - invalidGiftWrap.sig = String(repeating: "0", count: 128) - // A forged-signature copy is rejected WITHOUT entering the dedup set... - await coordinator.inbound.processNostrMessage(invalidGiftWrap) - #expect(context.recordedNostrEventIDs.isEmpty) + // Fan-in of the same (already verified) gift wrap from several relays + // records and processes exactly once. + await coordinator.inbound.processNostrMessage(giftWrap) + #expect(context.recordedNostrEventIDs == [giftWrap.id]) - // ...so the genuine event with the same ID still processes and records. await coordinator.inbound.processNostrMessage(giftWrap) #expect(context.recordedNostrEventIDs == [giftWrap.id]) } diff --git a/bitchatTests/ChatPeerListCoordinatorContextTests.swift b/bitchatTests/ChatPeerListCoordinatorContextTests.swift index 079da987..d7882f0e 100644 --- a/bitchatTests/ChatPeerListCoordinatorContextTests.swift +++ b/bitchatTests/ChatPeerListCoordinatorContextTests.swift @@ -104,6 +104,20 @@ private func makeMessage(id: String, senderPeerID: PeerID? = nil) -> BitchatMess /// no `ChatViewModel`. struct ChatPeerListCoordinatorContextTests { + @Test @MainActor + func synchronousPeerListUpdate_appliesBeforeReturning() { + let context = MockChatPeerListContext() + let coordinator = ChatPeerListCoordinator(context: context) + let peerID = PeerID(str: "0011223344556677") + + coordinator.didUpdatePeerListSynchronously([peerID]) + + #expect(context.isConnected) + #expect(context.registeredEphemeralSessions == [peerID]) + #expect(context.updateEncryptionStatusForPeersCount == 1) + #expect(context.cleanupOldReadReceiptsCount == 1) + } + @Test @MainActor func didUpdatePeerList_updatesConnectionSessionsAndEncryptionStatus() async { let context = MockChatPeerListContext() diff --git a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift index 5fad9fd0..54cb341e 100644 --- a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift +++ b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift @@ -176,6 +176,9 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC private(set) var geoPrivateMessages: [(content: String, recipientHex: String, messageID: String)] = [] private(set) var geoDeliveryAcks: [(messageID: String, recipientHex: String)] = [] private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = [] + var queuedMessageIDsByPeerID: [PeerID: Set] = [:] + private(set) var deliveryAckAttempts: [(messageID: String, peerIDs: [PeerID])] = [] + private(set) var deliveredMessageIDs: [String] = [] func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { routedPrivateMessages.append((content, peerID, messageID)) @@ -187,6 +190,22 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC return routeReadReceiptResult } + @discardableResult + func markMessageDelivered(_ messageID: String, for peerIDs: [PeerID]) -> Bool { + deliveryAckAttempts.append((messageID, peerIDs)) + var cleared = false + for peerID in Set(peerIDs) { + guard var queued = queuedMessageIDsByPeerID[peerID], + queued.remove(messageID) != nil else { continue } + queuedMessageIDsByPeerID[peerID] = queued.isEmpty ? nil : queued + cleared = true + } + if cleared { + deliveredMessageIDs.append(messageID) + } + return cleared + } + func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { meshReadReceipts.append((receipt.originalMessageID, peerID)) } @@ -370,6 +389,66 @@ struct ChatPrivateConversationCoordinatorContextTests { convKey: convKey ) #expect(context.notifyUIChangedCount == 2) + #expect(context.deliveryAckAttempts.isEmpty) + #expect(context.deliveredMessageIDs.isEmpty) + } + + @Test @MainActor + func accountDMAcks_findShortIDMessageAndHandConversationToStable() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let noiseKey = Data(repeating: 0xD5, count: 32) + let stablePeerID = PeerID(hexData: noiseKey) + let shortPeerID = stablePeerID.toShort() + let senderPubkey = "feedface00112233" + context.displayNamesByPubkey[senderPubkey] = "alice" + context.selectedPrivateChatPeer = shortPeerID + context.privateChats[shortPeerID] = [ + makeIncomingMessage(id: "mine-short-1", sender: "me"), + makeIncomingMessage(id: "mine-short-2", sender: "me") + ] + context.queuedMessageIDsByPeerID[shortPeerID] = ["mine-short-1", "mine-short-2"] + + coordinator.handleDelivered( + NoisePayload(type: .delivered, data: Data("mine-short-1".utf8)), + senderPubkey: senderPubkey, + convKey: stablePeerID + ) + coordinator.handleReadReceipt( + NoisePayload(type: .readReceipt, data: Data("mine-short-2".utf8)), + senderPubkey: senderPubkey, + convKey: stablePeerID + ) + + #expect(context.privateChats[shortPeerID] == nil) + #expect(isDelivered(context.privateChats[stablePeerID]?.first?.deliveryStatus, to: "alice")) + #expect(isRead(context.privateChats[stablePeerID]?.last?.deliveryStatus, by: "alice")) + #expect(context.selectedPrivateChatPeer == stablePeerID) + #expect(context.deliveredMessageIDs == ["mine-short-1", "mine-short-2"]) + #expect(context.notifyUIChangedCount == 2) + } + + @Test @MainActor + func accountDMAck_doesNotTouchAnUnrelatedConversationWithTheSameMessageID() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let stablePeerID = PeerID(hexData: Data(repeating: 0xC1, count: 32)) + let unrelatedPeerID = PeerID(str: "1111222233334444") + context.privateChats[unrelatedPeerID] = [ + makeIncomingMessage(id: "collision", sender: "me") + ] + context.queuedMessageIDsByPeerID[unrelatedPeerID] = ["collision"] + + coordinator.handleDelivered( + NoisePayload(type: .delivered, data: Data("collision".utf8)), + senderPubkey: "feedface00112233", + convKey: stablePeerID + ) + + #expect(isDelivered(context.privateChats[unrelatedPeerID]?.first?.deliveryStatus, to: "me")) + #expect(context.deliveredMessageIDs.isEmpty) + #expect(context.queuedMessageIDsByPeerID[unrelatedPeerID] == ["collision"]) + #expect(context.notifyUIChangedCount == 0) } @Test @MainActor @@ -412,6 +491,180 @@ struct ChatPrivateConversationCoordinatorContextTests { #expect(context.privateChats[convKey]?.count == 1) } + @Test @MainActor + func accountDM_handsOpenShortIDConversationToStableWhenOffline() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let noiseKey = Data(repeating: 0xA7, count: 32) + let stablePeerID = PeerID(hexData: noiseKey) + let shortPeerID = stablePeerID.toShort() + let senderPubkey = "feedface00112233" + let now = Date() + context.displayNamesByPubkey[senderPubkey] = "bob" + context.selectedPrivateChatPeer = shortPeerID + context.privateChats[shortPeerID] = [ + makeIncomingMessage( + id: "short-history", + sender: "me", + timestamp: now.addingTimeInterval(-30), + senderPeerID: context.myPeerID + ), + makeIncomingMessage( + id: "short-inbound", + sender: "bob", + timestamp: now.addingTimeInterval(-25), + senderPeerID: shortPeerID + ) + ] + context.privateChats[stablePeerID] = [ + makeIncomingMessage( + id: "stable-history", + sender: "bob", + timestamp: now.addingTimeInterval(-20), + senderPeerID: stablePeerID + ) + ] + let payloadData = PrivateMessagePacket(messageID: "account-live-1", content: "live reply").encode()! + + coordinator.handlePrivateMessage( + NoisePayload(type: .privateMessage, data: payloadData), + senderPubkey: senderPubkey, + convKey: stablePeerID, + id: MockChatPrivateConversationContext.dummyIdentity, + messageTimestamp: now + ) + + #expect(context.privateChats[shortPeerID] == nil) + #expect(context.privateChats[stablePeerID]?.map(\.id) == [ + "short-history", + "short-inbound", + "stable-history", + "account-live-1" + ]) + #expect(context.privateChats[stablePeerID]?[1].senderPeerID == stablePeerID) + #expect(context.privateChats[stablePeerID]?.last?.senderPeerID == stablePeerID) + #expect(context.migratedChats.contains(where: { $0.from == shortPeerID && $0.to == stablePeerID })) + #expect(context.selectedPrivateChatPeer == stablePeerID) + #expect(context.geoReadReceipts.map(\.messageID) == ["account-live-1"]) + #expect(context.unreadPrivateMessages.isEmpty) + #expect(context.notifyUIChangedCount == 1) + } + + @Test @MainActor + func accountDM_aliasMergePreservesTheCanonicalDestinationCopy() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let noiseKey = Data(repeating: 0xA8, count: 32) + let stablePeerID = PeerID(hexData: noiseKey) + let shortPeerID = stablePeerID.toShort() + let senderPubkey = "feedface00112233" + let olderSource = makeIncomingMessage( + id: "duplicate-history", + sender: "bob", + content: "older source copy", + senderPeerID: shortPeerID + ) + olderSource.deliveryStatus = .delivered(to: "me", at: Date(timeIntervalSince1970: 10)) + let newerDestination = makeIncomingMessage( + id: "duplicate-history", + sender: "bob", + content: "newer destination copy", + senderPeerID: shortPeerID + ) + newerDestination.deliveryStatus = .read(by: "me", at: Date(timeIntervalSince1970: 20)) + context.privateChats[shortPeerID] = [olderSource] + context.privateChats[stablePeerID] = [newerDestination] + context.displayNamesByPubkey[senderPubkey] = "bob" + let payloadData = PrivateMessagePacket(messageID: "after-merge", content: "new reply").encode()! + + coordinator.handlePrivateMessage( + NoisePayload(type: .privateMessage, data: payloadData), + senderPubkey: senderPubkey, + convKey: stablePeerID, + id: MockChatPrivateConversationContext.dummyIdentity, + messageTimestamp: Date() + ) + + let merged = context.privateChats[stablePeerID]?.first + #expect(context.privateChats[shortPeerID] == nil) + #expect(merged?.content == "newer destination copy") + #expect(isRead(merged?.deliveryStatus, by: "me")) + #expect(merged?.senderPeerID == stablePeerID) + } + + @Test @MainActor + func accountDM_keepsTheConnectedShortIDConversationCanonical() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let noiseKey = Data(repeating: 0xB8, count: 32) + let stablePeerID = PeerID(hexData: noiseKey) + let shortPeerID = stablePeerID.toShort() + let senderPubkey = "feedface00112233" + context.connectedPeers = [shortPeerID] + context.selectedPrivateChatPeer = stablePeerID + context.displayNamesByPubkey[senderPubkey] = "bob" + context.privateChats[stablePeerID] = [ + makeIncomingMessage(id: "stable-history", senderPeerID: stablePeerID) + ] + let payloadData = PrivateMessagePacket(messageID: "connected-live-1", content: "still nearby").encode()! + + coordinator.handlePrivateMessage( + NoisePayload(type: .privateMessage, data: payloadData), + senderPubkey: senderPubkey, + convKey: stablePeerID, + id: MockChatPrivateConversationContext.dummyIdentity, + messageTimestamp: Date() + ) + + #expect(context.privateChats[stablePeerID] == nil) + #expect(context.privateChats[shortPeerID]?.map(\.id) == ["stable-history", "connected-live-1"]) + #expect(context.privateChats[shortPeerID]?.first?.senderPeerID == shortPeerID) + #expect(context.privateChats[shortPeerID]?.last?.senderPeerID == shortPeerID) + #expect(context.selectedPrivateChatPeer == shortPeerID) + #expect(context.geoReadReceipts.map(\.messageID) == ["connected-live-1"]) + } + + @Test @MainActor + func accountDM_duplicateDowngradeAckStillClearsTheRetainedOutbox() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let stablePeerID = PeerID(hexData: Data(repeating: 0xE2, count: 32)) + let message = makeIncomingMessage(id: "already-read", sender: "me") + message.deliveryStatus = .read(by: "bob", at: Date()) + context.privateChats[stablePeerID] = [message] + context.queuedMessageIDsByPeerID[stablePeerID] = ["already-read"] + + coordinator.handleDelivered( + NoisePayload(type: .delivered, data: Data("already-read".utf8)), + senderPubkey: "feedface00112233", + convKey: stablePeerID + ) + + #expect(isRead(context.privateChats[stablePeerID]?.first?.deliveryStatus, by: "bob")) + #expect(context.deliveredMessageIDs == ["already-read"]) + #expect(context.notifyUIChangedCount == 0) + } + + @Test @MainActor + func accountDMAck_clearsRetainedMessageAfterConversationWasRemoved() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let stablePeerID = PeerID(hexData: Data(repeating: 0xE3, count: 32)) + let shortPeerID = stablePeerID.toShort() + context.queuedMessageIDsByPeerID[shortPeerID] = ["cleared-bubble"] + + coordinator.handleDelivered( + NoisePayload(type: .delivered, data: Data("cleared-bubble".utf8)), + senderPubkey: "feedface00112233", + convKey: stablePeerID + ) + + #expect(context.privateChats.isEmpty) + #expect(context.queuedMessageIDsByPeerID[shortPeerID] == nil) + #expect(context.deliveredMessageIDs == ["cleared-bubble"]) + #expect(context.notifyUIChangedCount == 0) + } + @Test @MainActor func handleViewingThisChat_clearsUnreadAndSendsRoutedReadReceiptOnce() async { let context = MockChatPrivateConversationContext() diff --git a/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift b/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift index 54858217..8a9aec64 100644 --- a/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift +++ b/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift @@ -79,12 +79,17 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon } private(set) var clearedConversations: [ConversationID] = [] + private(set) var archivePurgeCount = 0 func clearPublicConversation(_ conversationID: ConversationID) { clearedConversations.append(conversationID) conversations[conversationID] = [] } + func purgeArchivedPublicMessages() { + archivePurgeCount += 1 + } + func queueGeohashSystemMessage(_ content: String) { queuedGeohashSystemMessages.append(content) } @@ -345,6 +350,35 @@ struct ChatPublicConversationCoordinatorContextTests { #expect(context.enqueuedMessages.first?.conversationID == .geohash(geohash)) } + /// `/clear` on the mesh timeline used to only record a watermark, leaving + /// the archive on disk for up to its freshness window — so someone who + /// cleared before a police stop had deleted nothing. It must now erase the + /// archive behind the timeline. + @Test @MainActor + func clearCurrentPublicTimeline_onMesh_erasesTheArchive() async { + let context = MockChatPublicConversationContext() + let coordinator = ChatPublicConversationCoordinator(context: context) + context.activeChannel = .mesh + + coordinator.clearCurrentPublicTimeline() + + #expect(context.clearedConversations == [.mesh]) + #expect(context.archivePurgeCount == 1) + } + + /// Geohash timelines are Nostr-backed and carry no mesh gossip archive, so + /// clearing one must not reach for it. + @Test @MainActor + func clearCurrentPublicTimeline_onGeohash_leavesTheArchiveAlone() async { + let context = MockChatPublicConversationContext() + let coordinator = ChatPublicConversationCoordinator(context: context) + context.activeChannel = .location(GeohashChannel(level: .city, geohash: "u4pruy")) + + coordinator.clearCurrentPublicTimeline() + + #expect(context.archivePurgeCount == 0) + } + @Test @MainActor func blockGeohashUser_purgesMessagesMappingsAndPrivateChats() async { let context = MockChatPublicConversationContext() diff --git a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift index 88d118f3..aa02b255 100644 --- a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift +++ b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift @@ -133,11 +133,17 @@ private final class MockChatTransportEventContext: ChatTransportEventContext { // Delivery status var applyMessageDeliveryStatusResult = true var deliveryStatusesByMessageID: [String: DeliveryStatus] = [:] - private(set) var appliedDeliveryStatuses: [(messageID: String, status: DeliveryStatus)] = [] + private(set) var appliedDeliveryStatuses: [ + (messageID: String, status: DeliveryStatus, peerIDAliases: Set) + ] = [] @discardableResult - func applyMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool { - appliedDeliveryStatuses.append((messageID, status)) + func applyAcknowledgedMessageDeliveryStatus( + _ messageID: String, + status: DeliveryStatus, + from peerIDAliases: Set + ) -> Bool { + appliedDeliveryStatuses.append((messageID, status, peerIDAliases)) return applyMessageDeliveryStatusResult } @@ -220,26 +226,85 @@ struct ChatTransportEventCoordinatorContextTests { func didReceiveMessage_routesPrivateAndPublic_skipsBlockedAndEmpty() async { let context = MockChatTransportEventContext() let coordinator = ChatTransportEventCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") // Blocked messages are dropped before any handling. - context.blockedMessageIDs = ["blocked"] + context.blockedMessageIDs = ["blocked", "blocked-private"] coordinator.didReceiveMessage(makeMessage(id: "blocked")) + coordinator.didReceiveMessage(makeMessage( + id: "blocked-private", + isPrivate: true, + senderPeerID: peerID + )) // Empty public content is dropped too. coordinator.didReceiveMessage(makeMessage(id: "empty", content: " ")) await drainMainActorTasks() #expect(context.handledPublicMessages.isEmpty) #expect(context.handledPrivateMessages.isEmpty) #expect(context.mentionCheckedMessageIDs.isEmpty) + #expect(context.meshDeliveryAcks.isEmpty) // Private goes to the private handler, public to the public handler; - // both get mention checks and haptics. - coordinator.didReceiveMessage(makeMessage(id: "pm", isPrivate: true)) + // both get mention checks and haptics. Stable-media ACK authorization + // belongs to BLEFileTransferHandler after its durable commit and this + // synchronous acceptance result, not to the generic UI coordinator. + let stableMediaID = "media-\(String(repeating: "a", count: 32))" + coordinator.didReceiveMessage(makeMessage( + id: stableMediaID, + isPrivate: true, + senderPeerID: peerID + )) + coordinator.didReceiveMessage(makeMessage( + id: "legacy-media", + isPrivate: true, + senderPeerID: peerID + )) + coordinator.didReceiveMessage(makeMessage(id: "pm-missing-sender", isPrivate: true)) coordinator.didReceiveMessage(makeMessage(id: "pub")) await drainMainActorTasks() - #expect(context.handledPrivateMessages.map(\.id) == ["pm"]) + #expect(context.handledPrivateMessages.map(\.id) == [ + stableMediaID, + "legacy-media", + "pm-missing-sender" + ]) #expect(context.handledPublicMessages.map(\.id) == ["pub"]) - #expect(context.mentionCheckedMessageIDs == ["pm", "pub"]) - #expect(context.hapticMessageIDs == ["pm", "pub"]) + #expect(context.mentionCheckedMessageIDs == [ + stableMediaID, + "legacy-media", + "pm-missing-sender", + "pub" + ]) + #expect(context.hapticMessageIDs == [ + stableMediaID, + "legacy-media", + "pm-missing-sender", + "pub" + ]) + #expect(context.meshDeliveryAcks.isEmpty) + } + + @Test @MainActor + func synchronousMessageDeliveryReportsAcceptanceForAckGating() { + let context = MockChatTransportEventContext() + let coordinator = ChatTransportEventCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") + let blocked = makeMessage( + id: "blocked-private-media", + isPrivate: true, + senderPeerID: peerID + ) + context.blockedMessageIDs = [blocked.id] + + #expect(coordinator.didReceiveMessageSynchronously(blocked) == false) + #expect(context.handledPrivateMessages.isEmpty) + + let accepted = makeMessage( + id: "accepted-private-media", + isPrivate: true, + senderPeerID: peerID + ) + #expect(coordinator.didReceiveMessageSynchronously(accepted) == true) + #expect(context.handledPrivateMessages.map(\.id) == [accepted.id]) } @Test @MainActor @@ -295,6 +360,32 @@ struct ChatTransportEventCoordinatorContextTests { #expect(context.notifyUIChangedCount == 2) } + @Test @MainActor + func synchronousConnectAndDisconnect_applyBeforeReturning() { + let context = MockChatTransportEventContext() + let coordinator = ChatTransportEventCoordinator(context: context) + let peerID = PeerID(str: "2233445566778899") + let incoming = makeMessage( + id: "incoming-receipt", + isPrivate: true, + senderPeerID: peerID + ) + context.privateChats[peerID] = [incoming] + + coordinator.didConnectToPeerSynchronously(peerID) + + #expect(context.isConnected) + #expect(context.registeredEphemeralSessions == [peerID]) + #expect(context.flushedOutboxPeerIDs == [peerID]) + #expect(context.courierRetryPeerIDs == [peerID]) + + coordinator.didDisconnectFromPeerSynchronously(peerID) + + #expect(context.removedEphemeralSessions == [peerID]) + #expect(context.unmarkedReadReceiptBatches == [[incoming.id]]) + #expect(context.notifyUIChangedCount == 2) + } + @Test @MainActor func didDisconnect_whileViewingChat_migratesConversationToStablePeerID() async { let context = MockChatTransportEventContext() @@ -332,6 +423,10 @@ struct ChatTransportEventCoordinatorContextTests { let peerID = PeerID(str: "99aabbccddeeff00") let noiseKey = Data(repeating: 0x44, count: 32) 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. let packet = PrivateMessagePacket(messageID: "pm-1", content: "hi there") @@ -353,6 +448,8 @@ struct ChatTransportEventCoordinatorContextTests { await drainMainActorTasks() #expect(context.appliedDeliveryStatuses.count == 2) #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 { #expect(to == "alice") } else { diff --git a/bitchatTests/ChatVerificationCoordinatorContextTests.swift b/bitchatTests/ChatVerificationCoordinatorContextTests.swift index 326ab453..83cc7296 100644 --- a/bitchatTests/ChatVerificationCoordinatorContextTests.swift +++ b/bitchatTests/ChatVerificationCoordinatorContextTests.swift @@ -97,6 +97,8 @@ private final class MockChatVerificationContext: ChatVerificationContext { var noiseSessionKeysByPeerID: [PeerID: Data] = [:] private(set) var installedCallbacks: (onPeerAuthenticated: (PeerID, String) -> Void, onHandshakeRequired: (PeerID) -> Void)? private(set) var triggeredHandshakes: [PeerID] = [] + private(set) var privateMediaAuthenticatedPeers: [PeerID] = [] + private(set) var securePrivateMessageRetryAliases: [[PeerID]] = [] private(set) var sentChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = [] private(set) var sentResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = [] @@ -113,6 +115,13 @@ private final class MockChatVerificationContext: ChatVerificationContext { establishedNoiseSessions.contains(peerID) } func triggerHandshake(with peerID: PeerID) { triggeredHandshakes.append(peerID) } + func privateMediaPeerDidAuthenticate(_ peerID: PeerID) { + privateMediaAuthenticatedPeers.append(peerID) + } + + func retrySecurePrivateMessagesAfterAuthentication(for peerIDAliases: [PeerID]) { + securePrivateMessageRetryAliases.append(peerIDAliases) + } func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { sentChallenges.append((peerID, noiseKeyHex, nonceA)) @@ -265,6 +274,10 @@ struct ChatVerificationCoordinatorContextTests { let peerID = PeerID(str: "1122334455667788") let noiseKey = Data(repeating: 0x33, count: 32) context.noiseSessionKeysByPeerID[peerID] = noiseKey + context.cacheStablePeerID( + PeerID(hexData: Data(repeating: 0x44, count: 32)), + for: peerID + ) context.verifiedFingerprints = ["fp-verified"] coordinator.setupNoiseCallbacks() @@ -275,8 +288,11 @@ struct ChatVerificationCoordinatorContextTests { callbacks?.onPeerAuthenticated(peerID, "fp-verified") await waitForMainQueue() #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.privateMediaAuthenticatedPeers == [peerID]) + #expect(context.securePrivateMessageRetryAliases == [[peerID, stablePeerID]]) // Handshake required -> handshaking status. callbacks?.onHandshakeRequired(peerID) diff --git a/bitchatTests/ChatViewModelDeliveryStatusTests.swift b/bitchatTests/ChatViewModelDeliveryStatusTests.swift index fca755f2..15a1edea 100644 --- a/bitchatTests/ChatViewModelDeliveryStatusTests.swift +++ b/bitchatTests/ChatViewModelDeliveryStatusTests.swift @@ -13,8 +13,11 @@ import BitFoundation // MARK: - Test Helpers @MainActor -private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) { - let keychain = MockKeychain() +private func makeTestableViewModel( + keychain injectedKeychain: MockKeychain? = nil, + outboxStore: MessageOutboxStore? = nil +) -> (viewModel: ChatViewModel, transport: MockTransport) { + let keychain = injectedKeychain ?? MockKeychain() let keychainHelper = MockKeychainHelper() let idBridge = NostrIdentityBridge(keychain: keychainHelper) let identityManager = MockIdentityManager(keychain) @@ -24,7 +27,8 @@ private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: Mo keychain: keychain, idBridge: idBridge, identityManager: identityManager, - transport: transport + transport: transport, + outboxStore: outboxStore ) return (viewModel, transport) @@ -298,6 +302,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 func cleanupOldReadReceipts_removesReceiptIDsWithoutMessages() async { let (viewModel, transport) = makeTestableViewModel() @@ -323,6 +458,65 @@ struct ChatViewModelDeliveryStatusTests { #expect(viewModel.sentReadReceipts == ["keep-receipt"]) } + // MARK: - Relaunch-Restored Outbox Tests + + @Test @MainActor + func deliveryAckAfterRelaunchClearsRestoredOutboxWithoutConversation() async { + // Force-quit → relaunch: the durable outbox restores the retained DM, + // but the in-memory conversation store starts empty. The delivery ack + // must still clear the router's retained copy — gating it on the + // conversation lookup would leave the entry re-sending on every + // flush/auth event until the attempt cap marks it failed despite + // delivery. + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("relaunch-ack-\(UUID().uuidString).sealed") + defer { try? FileManager.default.removeItem(at: fileURL) } + let keychain = MockKeychain() + let peerID = PeerID(str: "0102030405060708") + let messageID = "relaunch-retained-dm" + + // Pre-quit state: one retained private message on disk. + MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([ + peerID: [MessageOutboxStore.QueuedMessage( + content: "Sent before force-quit", + nickname: "Peer", + messageID: messageID, + timestamp: Date() + )] + ]) + + // Relaunch: fresh view model over the same durable store. + let (viewModel, transport) = makeTestableViewModel( + keychain: keychain, + outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL) + ) + #expect(viewModel.privateChats[peerID] == nil) + + // The peer's delivery ack arrives with no conversation in the store. + // No UI transition is possible (and none must crash), but the durable + // retry state has to clear. + let didUpdate = viewModel.deliveryCoordinator + .updateAcknowledgedMessageDeliveryStatus( + messageID, + status: .delivered(to: "Peer", at: Date()), + from: [peerID] + ) + #expect(!didUpdate) + #expect(viewModel.privateChats[peerID] == nil) + + // Neither a flush nor a replacement-handshake auth event may re-send + // the already-delivered message. + transport.connectedPeers.insert(peerID) + transport.securePeers = [peerID] + viewModel.messageRouter.flushOutbox(for: peerID) + viewModel.messageRouter.retrySecurePrivateMessagesAfterAuthentication(for: [peerID]) + #expect(transport.sentPrivateMessages.isEmpty) + + // The clear reached the durable snapshot: the next relaunch restores + // nothing. + #expect(MessageOutboxStore(keychain: keychain, fileURL: fileURL).load().isEmpty) + } + // MARK: - Public Timeline Status Tests @Test @MainActor @@ -577,12 +771,27 @@ private final class MockChatDeliveryContext: ChatDeliveryContext { var isStartupPhase = false private(set) var notifyUIChangedCount = 0 private(set) var markedDeliveredMessageIDs: [String] = [] + private(set) var peerBoundDeliveredMessages: [(messageID: String, peerIDs: Set)] = [] + private(set) var confirmedMediaMessageIDs: [String] = [] @discardableResult func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool { store.setDeliveryStatus(status, forMessageID: messageID) } + @discardableResult + func setDeliveryStatus( + _ status: DeliveryStatus, + forMessageID messageID: String, + inDirectPeerAliases peerIDs: Set + ) -> Bool { + store.setDeliveryStatus( + status, + forMessageID: messageID, + inDirectPeerAliases: peerIDs + ) + } + func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? { store.deliveryStatus(forMessageID: messageID) } @@ -604,6 +813,28 @@ private final class MockChatDeliveryContext: ChatDeliveryContext { func markMessageDelivered(_ messageID: String) { markedDeliveredMessageIDs.append(messageID) } + + func markMessageDelivered(_ messageID: String, from peerIDs: Set) { + peerBoundDeliveredMessages.append((messageID, peerIDs)) + } + + func confirmPrivateMediaDelivery(_ messageID: String) { + confirmedMediaMessageIDs.append(messageID) + } + + func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set) -> 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 @@ -685,7 +916,163 @@ struct ChatDeliveryCoordinatorContextTests { #expect(isRead(coordinator.deliveryStatus(for: messageID))) #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 = [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))) + // The router-side clear runs, but bound only to the wrong peer's own + // aliases — a scoped no-op that cannot touch the intended peer's + // retained copy. Status, media retry, and UI stay untouched. + #expect(context.peerBoundDeliveredMessages.count == 1) + #expect(context.peerBoundDeliveredMessages[0].messageID == messageID) + #expect(context.peerBoundDeliveredMessages[0].peerIDs == [otherPeer]) + #expect(context.markedDeliveredMessageIDs.isEmpty) + #expect(context.confirmedMediaMessageIDs.isEmpty) + #expect(context.notifyUIChangedCount == 0) + } + + @Test @MainActor + func rejectedStaleReceiptDoesNotDowngradeStatusOrNotify() 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))) + // The peer-scoped router clear re-runs (idempotent: the earlier read + // receipt already emptied this peer's retained copy), but the stale + // delivered ack must not downgrade the read status, release media, or + // notify the UI. + #expect(context.peerBoundDeliveredMessages.count == 1) + #expect(context.peerBoundDeliveredMessages[0].peerIDs == [peerID]) + #expect(context.markedDeliveredMessageIDs.isEmpty) + #expect(context.confirmedMediaMessageIDs.isEmpty) + #expect(context.notifyUIChangedCount == 0) } @Test @MainActor diff --git a/bitchatTests/ChatViewModelExtensionsTests.swift b/bitchatTests/ChatViewModelExtensionsTests.swift index 0097a368..a1d12550 100644 --- a/bitchatTests/ChatViewModelExtensionsTests.swift +++ b/bitchatTests/ChatViewModelExtensionsTests.swift @@ -366,31 +366,11 @@ struct ChatViewModelNostrExtensionTests { #expect(!viewModel.messages.contains { $0.content == "Blocked" }) } - @Test @MainActor - func handleNostrEvent_rejectsInvalidSignature() async throws { - let (viewModel, _) = makeTestableViewModel() - let geohash = "u4pruydq" - let identity = try NostrIdentity.generate() - - viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) - - let event = NostrEvent( - pubkey: identity.publicKeyHex, - createdAt: Date(), - kind: .ephemeralEvent, - tags: [["g", geohash]], - content: "Valid" - ) - var signed = try event.sign(with: identity.schnorrSigningKey()) - signed.id = "deadbeef" - - viewModel.handleNostrEvent(signed) - - try? await Task.sleep(nanoseconds: 100_000_000) - viewModel.publicMessagePipeline.flushIfNeeded() - - #expect(!viewModel.messages.contains { $0.content == "Tampered" }) - } + // NOTE: Tampered-signature rejection is enforced once, off the main + // actor, at the relay boundary (events only reach the inbound pipeline + // after verification) — see NostrRelayManagerTests + // `test_receiveEvent_invalidSignatureDoesNotPoisonDuplicateCache` and + // `test_receiveGiftWrap_tamperedSignatureIsDroppedAndDoesNotPoisonDedup`. @Test @MainActor func subscribeGiftWrap_rejectsOversizedEmbeddedPacket() async throws { @@ -579,9 +559,14 @@ struct ChatViewModelNostrExtensionTests { viewModel.handleGiftWrap(giftWrap, id: recipient) - try? await Task.sleep(nanoseconds: 50_000_000) + // Gift-wrap decryption runs off the main actor; wait for the ack + // (sent even for blocked senders) to know processing finished. + let didAck = await TestHelpers.waitUntil( + { viewModel.sentGeoDeliveryAcks.contains(messageID) }, + timeout: 5.0 + ) + #expect(didAck) #expect(viewModel.privateChats[convKey] == nil) - #expect(viewModel.sentGeoDeliveryAcks.contains(messageID)) } @Test @MainActor @@ -1048,6 +1033,89 @@ struct ChatViewModelMediaTransferTests { #expect(viewModel.transferIdToMessageIDs.count == 1) } + @Test @MainActor + func legacyPrivateMediaConsentRequestsArePerSendAndQueued() async throws { + let (viewModel, _) = makeTestableViewModel() + let firstPeer = PeerID(str: "1111111111111111") + let secondPeer = PeerID(str: "2222222222222222") + var decisions: [Bool] = [] + + viewModel.enqueueLegacyPrivateMediaConsent( + for: firstPeer, + transferId: "transfer-1", + messageID: "message-1" + ) { decisions.append($0) } + viewModel.enqueueLegacyPrivateMediaConsent( + for: secondPeer, + transferId: "transfer-2", + messageID: "message-2" + ) { decisions.append($0) } + + #expect(viewModel.legacyPrivateMediaConsentRequest?.peerID == firstPeer) + let firstRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id) + viewModel.resolveLegacyPrivateMediaConsent(requestID: firstRequestID, approved: true) + let showedSecond = await TestHelpers.waitUntil( + { viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer }, + timeout: TestConstants.longTimeout + ) + #expect(showedSecond) + let secondRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id) + + // A button action and the dialog binding may both resolve the first + // ID. The stale second callback must not consume the queued request. + viewModel.resolveLegacyPrivateMediaConsent(requestID: firstRequestID, approved: false) + #expect(decisions == [true]) + #expect(viewModel.legacyPrivateMediaConsentRequest?.id == secondRequestID) + + viewModel.resolveLegacyPrivateMediaConsent(requestID: secondRequestID, approved: false) + + #expect(decisions == [true, false]) + #expect(viewModel.legacyPrivateMediaConsentRequest == nil) + } + + @Test @MainActor + func invalidatingPresentedLegacyConsentAdvancesQueueAndStaleResolutionNoops() async throws { + let (viewModel, _) = makeTestableViewModel() + let firstPeer = PeerID(str: "3333333333333333") + let secondPeer = PeerID(str: "4444444444444444") + var decisions: [String] = [] + + viewModel.enqueueLegacyPrivateMediaConsent( + for: firstPeer, + transferId: "transfer-cancelled", + messageID: "message-cancelled" + ) { decisions.append("first:\($0)") } + viewModel.enqueueLegacyPrivateMediaConsent( + for: secondPeer, + transferId: "transfer-kept", + messageID: "message-kept" + ) { decisions.append("second:\($0)") } + + let cancelledRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id) + viewModel.invalidateLegacyPrivateMediaConsent( + transferId: "transfer-cancelled", + messageID: "message-cancelled" + ) + let advanced = await TestHelpers.waitUntil( + { viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer }, + timeout: TestConstants.longTimeout + ) + #expect(advanced) + #expect(decisions.isEmpty, "Invalidation drops the request rather than resolving its send") + + viewModel.resolveLegacyPrivateMediaConsent( + requestID: cancelledRequestID, + approved: true + ) + #expect(viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer) + #expect(decisions.isEmpty) + + let keptRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id) + viewModel.resolveLegacyPrivateMediaConsent(requestID: keptRequestID, approved: true) + #expect(decisions == ["second:true"]) + #expect(viewModel.legacyPrivateMediaConsentRequest == nil) + } + @Test @MainActor func sendVoiceNote_oversizedFileFailsAndDeletesTempFile() async throws { let (viewModel, transport) = makeTestableViewModel() @@ -1306,3 +1374,37 @@ private func makeImageData() throws -> Data { return data #endif } + +// MARK: - Tor Extension Tests + +struct ChatViewModelTorExtensionTests { + + /// Turning Tor off mid-bootstrap must not read as "the network is + /// blocking tor": `torEnforced` is a compile-time constant, so the stall + /// handler has to consult the runtime preference before announcing. + @Test @MainActor + func bootstrapStall_withTorPreferenceOff_announcesNothing() async { + let key = NetworkActivationService.torPreferenceKey + let previous = UserDefaults.standard.object(forKey: key) + defer { + if let previous { + UserDefaults.standard.set(previous, forKey: key) + } else { + UserDefaults.standard.removeObject(forKey: key) + } + } + let (viewModel, _) = makeTestableViewModel() + + UserDefaults.standard.set(false, forKey: key) + viewModel.handleTorBootstrapDidStall() + try? await Task.sleep(nanoseconds: 50_000_000) + #expect(viewModel.torStallAnnounced == false) + + // The same stall with the preference on (the persisted default) is + // exactly what must still be announced. + UserDefaults.standard.set(true, forKey: key) + viewModel.handleTorBootstrapDidStall() + try? await Task.sleep(nanoseconds: 50_000_000) + #expect(viewModel.torStallAnnounced == true) + } +} diff --git a/bitchatTests/ChatViewModelRefactoringTests.swift b/bitchatTests/ChatViewModelRefactoringTests.swift index d576106d..afc8e443 100644 --- a/bitchatTests/ChatViewModelRefactoringTests.swift +++ b/bitchatTests/ChatViewModelRefactoringTests.swift @@ -43,14 +43,14 @@ struct ChatViewModelRefactoringTests { transport.simulateConnect(peerID, nickname: "alice") let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("alice") != nil }, - timeout: TestConstants.shortTimeout) + timeout: TestConstants.settleTimeout) #expect(didResolve) // Action: User types /msg command viewModel.sendMessage("/msg @alice Hello Private World") let didSend = await TestHelpers.waitUntil({ transport.sentPrivateMessages.count == 1 }, - timeout: TestConstants.shortTimeout) + timeout: TestConstants.settleTimeout) #expect(didSend) // Assert: @@ -74,7 +74,7 @@ struct ChatViewModelRefactoringTests { transport.simulateConnect(peerID, nickname: "troll") let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("troll") != nil }, - timeout: TestConstants.shortTimeout) + timeout: TestConstants.settleTimeout) #expect(didResolve) // Action @@ -83,7 +83,7 @@ struct ChatViewModelRefactoringTests { // Assert // Verify identity manager was called to block "fingerprint_123" let didBlock = await TestHelpers.waitUntil({ identity.isBlocked(fingerprint: "fingerprint_123") }, - timeout: TestConstants.shortTimeout) + timeout: TestConstants.settleTimeout) #expect(didBlock) } @@ -114,7 +114,7 @@ struct ChatViewModelRefactoringTests { // Wait for async processing with proper timeout let found = await TestHelpers.waitUntil( { viewModel.privateChats[senderID]?.first?.content == "Secret" }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) // Assert @@ -140,7 +140,7 @@ struct ChatViewModelRefactoringTests { { viewModel.publicMessages(for: .mesh).contains(where: { $0.content == "Public Hi" }) }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) // Assert diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index aa4fc5a8..7e4e46a1 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -15,8 +15,13 @@ import BitFoundation /// Creates a ChatViewModel with mock dependencies for testing @MainActor -private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) { - let keychain = MockKeychain() +private func makeTestableViewModel( + keychain injectedKeychain: MockKeychain? = nil, + panicMediaWipe: (() throws -> Void)? = nil, + panicRecoveryOperations: PanicRecoveryOperations? = nil, + panicNetworkLifecycle: PanicNetworkLifecycle = .noop +) -> (viewModel: ChatViewModel, transport: MockTransport) { + let keychain = injectedKeychain ?? MockKeychain() let keychainHelper = MockKeychainHelper() let idBridge = NostrIdentityBridge(keychain: keychainHelper) let identityManager = MockIdentityManager(keychain) @@ -26,7 +31,10 @@ private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: Mo keychain: keychain, idBridge: idBridge, identityManager: identityManager, - transport: transport + transport: transport, + panicMediaWipe: panicMediaWipe, + panicRecoveryOperations: panicRecoveryOperations, + panicNetworkLifecycle: panicNetworkLifecycle ) return (viewModel, transport) @@ -313,7 +321,7 @@ struct ChatViewModelCommandTests { transport.simulateConnect(peerID, nickname: "Alice") let resolved = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("Alice") == peerID - }, timeout: TestConstants.defaultTimeout) + }, timeout: TestConstants.negativeWaitWindow) #expect(resolved) viewModel.handleCommand("/msg Alice") @@ -414,7 +422,7 @@ struct ChatViewModelServiceLifecycleTests { transport.sentReadReceipts.contains { $0.peerID == peerID && $0.receipt.originalMessageID == "read-1" } - }, timeout: TestConstants.defaultTimeout) + }, timeout: TestConstants.negativeWaitWindow) #expect(sentReadReceipt) #expect(!viewModel.unreadPrivateMessages.contains(peerID)) @@ -498,7 +506,7 @@ struct ChatViewModelReceivingTests { let found = await TestHelpers.waitUntil({ viewModel.publicMessages(for: .mesh).contains { $0.content == "Public hello from Bob" } - }, timeout: TestConstants.defaultTimeout) + }, timeout: TestConstants.settleTimeout) #expect(found) } @@ -527,11 +535,11 @@ struct ChatViewModelNoisePayloadTests { let stored = await TestHelpers.waitUntil({ viewModel.privateChats[peerID]?.contains(where: { $0.id == "pm-noise-1" && $0.content == "Secret hello" }) == true - }, timeout: TestConstants.defaultTimeout) + }, timeout: TestConstants.settleTimeout) let acked = await TestHelpers.waitUntil({ transport.sentDeliveryAcks.contains { $0.messageID == "pm-noise-1" && $0.peerID == peerID } - }, timeout: TestConstants.defaultTimeout) + }, timeout: TestConstants.settleTimeout) #expect(stored) #expect(acked) @@ -571,7 +579,7 @@ struct ChatViewModelNoisePayloadTests { return name == "Bob" } return false - }, timeout: TestConstants.defaultTimeout) + }, timeout: TestConstants.settleTimeout) #expect(delivered) } @@ -609,7 +617,7 @@ struct ChatViewModelNoisePayloadTests { return true } return false - }, timeout: TestConstants.defaultTimeout) + }, timeout: TestConstants.settleTimeout) let conversationStoreUpdated = await TestHelpers.waitUntil({ let messages = viewModel.conversations.conversationsByID[.directPeer(peerID)]?.messages ?? [] @@ -618,7 +626,7 @@ struct ChatViewModelNoisePayloadTests { return true } return false - }, timeout: TestConstants.defaultTimeout) + }, timeout: TestConstants.settleTimeout) #expect(privateChatUpdated) #expect(conversationStoreUpdated) @@ -722,7 +730,7 @@ struct ChatViewModelVerificationTests { let bound = await TestHelpers.waitUntil({ viewModel.unifiedPeerService.peers.contains { $0.peerID == peerID } - }, timeout: TestConstants.defaultTimeout) + }, timeout: TestConstants.settleTimeout) #expect(bound) let qr = VerificationService.VerificationQR( @@ -856,6 +864,80 @@ struct ChatViewModelPublicConversationTests { struct ChatViewModelPeerTests { + @Test @MainActor + func typedPeerLifecycleEvents_applyBeforeReturning() { + let (viewModel, _) = makeTestableViewModel() + let peerID = PeerID(str: "1122334455667788") + let incoming = BitchatMessage( + id: "typed-peer-incoming", + sender: "Alice", + content: "Hello", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: viewModel.nickname, + senderPeerID: peerID + ) + viewModel.seedPrivateChat([incoming], for: peerID) + viewModel.sentReadReceipts.insert(incoming.id) + + viewModel.didReceiveTransportEvent(.peerConnected(peerID)) + + #expect(viewModel.isConnected) + + viewModel.didReceiveTransportEvent(.peerDisconnected(peerID)) + + #expect(!viewModel.sentReadReceipts.contains(incoming.id)) + } + + @Test @MainActor + func typedPeerListDeliveryAndBluetoothEvents_applyBeforeReturning() { + let (viewModel, transport) = makeTestableViewModel() + let stalePeer = PeerID(str: "00000000000000a2") + let deliveryPeer = PeerID(str: "0102030405060708") + let messageID = "typed-delivery-status" + let delivered = DeliveryStatus.delivered( + to: "Alice", + at: Date(timeIntervalSince1970: 1_234) + ) + let outgoing = BitchatMessage( + id: messageID, + sender: viewModel.nickname, + content: "On the way", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Alice", + senderPeerID: transport.myPeerID, + deliveryStatus: .sent + ) + viewModel.markPrivateChatUnread(stalePeer) + viewModel.seedPrivateChat([outgoing], for: deliveryPeer) + + viewModel.didReceiveTransportEvent(.peerListUpdated([])) + #expect(!viewModel.unreadPrivateMessages.contains(stalePeer)) + + viewModel.didReceiveTransportEvent( + .messageDeliveryStatusUpdated( + messageID: messageID, + status: delivered + ) + ) + #expect( + viewModel.privateMessages(for: deliveryPeer).first?.deliveryStatus + == delivered + ) + + viewModel.didReceiveTransportEvent(.bluetoothStateUpdated(.poweredOff)) + #expect(viewModel.bluetoothState == .poweredOff) + #expect(viewModel.showBluetoothAlert) + + // Snapshot events belong to TransportPeerEventsDelegate and are + // intentionally ignored at this typed sink. + viewModel.didReceiveTransportEvent(.peerSnapshotsUpdated([])) + #expect(viewModel.bluetoothState == .poweredOff) + } + @Test @MainActor func didConnectToPeer_notifiesDelegate() async { let (_, transport) = makeTestableViewModel() @@ -900,7 +982,7 @@ struct ChatViewModelPeerTests { let cleaned = await TestHelpers.waitUntil({ !viewModel.unreadPrivateMessages.contains(stalePeer) - }, timeout: TestConstants.defaultTimeout) + }, timeout: TestConstants.settleTimeout) #expect(cleaned) } @@ -1112,10 +1194,1040 @@ struct ChatViewModelBluetoothTests { } } +// MARK: - Private Media Deletion Tests + +struct ChatViewModelPrivateMediaDeletionTests { + + @Test @MainActor + func deleteMediaMessageTombstonesIncomingButNotOutgoingStableMedia() { + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: String(repeating: "8", count: 64)) + let incomingID = "media-\(String(repeating: "e", count: 32))" + let outgoingID = "media-\(String(repeating: "f", count: 32))" + viewModel.seedPrivateChat([ + privateMediaMessage( + id: incomingID, + sender: "Peer", + senderPeerID: peerID, + recipient: viewModel.nickname, + filename: "incoming.jpg" + ), + privateMediaMessage( + id: outgoingID, + sender: viewModel.nickname, + senderPeerID: transport.myPeerID, + recipient: "Peer", + filename: "outgoing.jpg" + ) + ], for: peerID) + + viewModel.deleteMediaMessage(messageID: outgoingID) + #expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty) + #expect(viewModel.privateChats[peerID]?.map(\.id) == [incomingID]) + + viewModel.deleteMediaMessage(messageID: incomingID) + #expect( + transport.deletedPrivateMediaMessageIDBatches == [[incomingID]] + ) + #expect( + transport.deletedPrivateMediaRelativePaths + == [[incomingID: "images/incoming/incoming.jpg"]] + ) + #expect((viewModel.privateChats[peerID] ?? []).isEmpty) + } + + @Test @MainActor + func stableDeleteProtectsPathSharedWithLegacyBubble() { + let (viewModel, transport) = makeTestableViewModel() + transport.persistDeletedPrivateMediaResult = false + let peerID = PeerID(str: String(repeating: "6", count: 64)) + let stableID = "media-\(String(repeating: "5", count: 32))" + let legacyID = UUID().uuidString + let filename = "shared-migration.jpg" + viewModel.seedPrivateChat([ + privateMediaMessage( + id: stableID, + sender: "Peer", + senderPeerID: peerID, + recipient: viewModel.nickname, + filename: filename + ), + privateMediaMessage( + id: legacyID, + sender: "Old client", + senderPeerID: peerID, + recipient: viewModel.nickname, + filename: filename + ) + ], for: peerID) + + viewModel.deleteMediaMessage(messageID: stableID) + + #expect( + transport.deletedPrivateMediaMessageIDBatches == [[stableID]] + ) + #expect(transport.deletedPrivateMediaRelativePaths == [[:]]) + #expect( + transport.protectedPrivateMediaRelativePaths == [[ + "images/incoming/\(filename)" + ]] + ) + 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( + messages.last?.content + == String(localized: "content.system.media_delete_refused") + ) + } + + @Test @MainActor + func clearPrivateChatTombstonesIncomingAndCancelsOutgoingBeforeClear() { + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: String(repeating: "1", count: 64)) + let incomingID = "media-\(String(repeating: "a", count: 32))" + let outgoingID = "media-\(String(repeating: "b", count: 32))" + viewModel.seedPrivateChat([ + privateMediaMessage( + id: incomingID, + sender: "Peer", + senderPeerID: peerID, + recipient: viewModel.nickname, + filename: "incoming.jpg" + ), + privateMediaMessage( + id: outgoingID, + sender: viewModel.nickname, + senderPeerID: transport.myPeerID, + recipient: "Peer", + filename: "outgoing.jpg" + ), + BitchatMessage( + id: "ordinary-message", + sender: "Peer", + content: "hello", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: viewModel.nickname, + senderPeerID: peerID + ) + ], for: peerID) + viewModel.registerTransfer( + transferId: "outgoing-clear-transfer", + messageID: outgoingID + ) + + viewModel.clearPrivateChat(peerID) + + #expect( + transport.deletedPrivateMediaMessageIDBatches == [[incomingID]] + ) + #expect( + transport.deletedPrivateMediaRelativePaths + == [[incomingID: "images/incoming/incoming.jpg"]] + ) + #expect( + transport.cancelledTransfers == ["outgoing-clear-transfer"] + ) + #expect(viewModel.messageIDToTransferId[outgoingID] == nil) + #expect(viewModel.privateChats[peerID]?.isEmpty == true) + } + + @Test @MainActor + func clearPrivateChatPreservesCapturedMessagesWhenTombstoneFails() { + let (viewModel, transport) = makeTestableViewModel() + transport.persistDeletedPrivateMediaResult = false + let peerID = PeerID(str: String(repeating: "2", count: 64)) + let incomingID = "media-\(String(repeating: "c", count: 32))" + let outgoingID = "media-\(String(repeating: "7", count: 32))" + viewModel.seedPrivateChat([ + privateMediaMessage( + id: incomingID, + sender: "Peer", + senderPeerID: peerID, + recipient: viewModel.nickname, + filename: "incoming.jpg" + ), + privateMediaMessage( + id: outgoingID, + sender: viewModel.nickname, + senderPeerID: transport.myPeerID, + recipient: "Peer", + filename: "outgoing.jpg" + ), + BitchatMessage( + id: "ordinary-message", + sender: "Peer", + content: "keep me on failure", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: viewModel.nickname, + senderPeerID: peerID + ) + ], for: peerID) + viewModel.registerTransfer( + transferId: "failed-clear-outgoing", + messageID: outgoingID + ) + + viewModel.clearPrivateChat(peerID) + + #expect( + transport.deletedPrivateMediaMessageIDBatches == [[incomingID]] + ) + let messages = viewModel.privateChats[peerID] ?? [] + #expect( + messages.prefix(3).map(\.id) + == [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(viewModel.messageIDToTransferId[outgoingID] == nil) + } + + @Test @MainActor + func clearFailurePreservesSameNameIncomingPayload() throws { + let (viewModel, transport) = makeTestableViewModel() + transport.persistDeletedPrivateMediaResult = false + let peerID = PeerID(str: String(repeating: "7", count: 64)) + let incomingID = "media-\(String(repeating: "8", count: 32))" + let outgoingID = "media-\(String(repeating: "9", count: 32))" + let filename = "clear-collision-\(UUID().uuidString).jpg" + let filesDirectory = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + .appendingPathComponent("files/images", isDirectory: true) + let incomingDirectory = filesDirectory.appendingPathComponent( + "incoming", + isDirectory: true + ) + let outgoingDirectory = filesDirectory.appendingPathComponent( + "outgoing", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: incomingDirectory, + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + at: outgoingDirectory, + withIntermediateDirectories: true + ) + let incomingURL = incomingDirectory.appendingPathComponent(filename) + let outgoingURL = outgoingDirectory.appendingPathComponent(filename) + try Data("incoming".utf8).write(to: incomingURL, options: .atomic) + try Data("outgoing".utf8).write(to: outgoingURL, options: .atomic) + defer { + try? FileManager.default.removeItem(at: incomingURL) + try? FileManager.default.removeItem(at: outgoingURL) + } + viewModel.seedPrivateChat([ + privateMediaMessage( + id: incomingID, + sender: "Peer", + senderPeerID: peerID, + recipient: viewModel.nickname, + filename: filename + ), + privateMediaMessage( + id: outgoingID, + sender: viewModel.nickname, + senderPeerID: transport.myPeerID, + recipient: "Peer", + filename: filename + ) + ], for: peerID) + + viewModel.clearPrivateChat(peerID) + + #expect(FileManager.default.fileExists(atPath: incomingURL.path)) + #expect(FileManager.default.fileExists(atPath: outgoingURL.path)) + #expect( + transport.deletedPrivateMediaRelativePaths + == [[incomingID: "images/incoming/\(filename)"]] + ) + let messages = viewModel.privateChats[peerID] ?? [] + #expect(messages.prefix(2).map(\.id) == [incomingID, outgoingID]) + #expect(messages.last?.sender == "system") + #expect( + messages.last?.content + == String(localized: "content.system.media_delete_refused") + ) + } + + @Test @MainActor + func clearPrivateChatPreservesArrivalDuringTombstoneIO() { + let (viewModel, transport) = makeTestableViewModel() + transport.deferDeletedPrivateMediaPersistence = true + let peerID = PeerID(str: String(repeating: "3", count: 64)) + let incomingID = "media-\(String(repeating: "d", count: 32))" + viewModel.seedPrivateChat([ + privateMediaMessage( + id: incomingID, + sender: "Peer", + senderPeerID: peerID, + recipient: viewModel.nickname, + filename: "incoming.jpg" + ), + BitchatMessage( + id: "captured-text", + sender: "Peer", + content: "old", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: viewModel.nickname, + senderPeerID: peerID + ) + ], for: peerID) + + viewModel.clearPrivateChat(peerID) + let arrival = BitchatMessage( + id: "concurrent-arrival", + sender: "Peer", + content: "new", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: viewModel.nickname, + senderPeerID: peerID + ) + #expect(viewModel.appendPrivateMessage(arrival, to: peerID)) + + transport.resolveNextDeletedPrivateMediaPersistence(true) + + #expect( + viewModel.privateChats[peerID]?.map(\.id) + == ["concurrent-arrival"] + ) + } + + @Test @MainActor + func clearPrivateChatPreservesActiveLiveVoiceAssembly() throws { + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: String(repeating: "7", count: 64)) + viewModel.selectedPrivateChatPeer = peerID + let burstID = Data( + repeating: 0xE1, + count: VoiceBurstPacket.burstIDSize + ) + let start = try #require(VoiceBurstPacket( + burstID: burstID, + seq: 0, + kind: .start(codec: .aacLC16kMono) + )) + let cancel = try #require(VoiceBurstPacket( + burstID: burstID, + seq: 1, + kind: .canceled + )) + let coordinator = viewModel.liveVoiceCoordinator + defer { + coordinator.handleVoiceFramePayload( + from: peerID, + payload: cancel.encode(), + timestamp: Date() + ) + } + coordinator.handleVoiceFramePayload( + from: peerID, + payload: start.encode(), + timestamp: Date() + ) + let liveMessage = try #require( + viewModel.privateChats[peerID]?.first + ) + #expect(coordinator.isLiveVoiceMessage(liveMessage)) + let ordinary = BitchatMessage( + id: "clear-around-live-voice", + sender: "Peer", + content: "old text", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: viewModel.nickname, + senderPeerID: peerID + ) + #expect(viewModel.appendPrivateMessage(ordinary, to: peerID)) + + viewModel.clearPrivateChat(peerID) + + #expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty) + #expect( + viewModel.privateChats[peerID]?.map(\.id) + == [liveMessage.id] + ) + #expect(coordinator.isLiveVoiceMessage(liveMessage)) + } + + @Test @MainActor + func overlappingClearsTombstoneTheLastMirroredStableAlias() { + let (viewModel, transport) = makeTestableViewModel() + transport.deferDeletedPrivateMediaPersistence = true + let firstPeerID = PeerID(str: String(repeating: "b", count: 64)) + let secondPeerID = PeerID(str: String(repeating: "c", count: 64)) + let sharedID = "media-\(String(repeating: "1", count: 32))" + let firstUniqueID = "media-\(String(repeating: "2", count: 32))" + let secondUniqueID = "media-\(String(repeating: "3", count: 32))" + viewModel.seedPrivateChat([ + privateMediaMessage( + id: sharedID, + sender: "Peer", + senderPeerID: firstPeerID, + recipient: viewModel.nickname, + filename: "shared.jpg" + ), + privateMediaMessage( + id: firstUniqueID, + sender: "Peer", + senderPeerID: firstPeerID, + recipient: viewModel.nickname, + filename: "first.jpg" + ) + ], for: firstPeerID) + viewModel.seedPrivateChat([ + privateMediaMessage( + id: sharedID, + sender: "Peer", + senderPeerID: secondPeerID, + recipient: viewModel.nickname, + filename: "shared.jpg" + ), + privateMediaMessage( + id: secondUniqueID, + sender: "Peer", + senderPeerID: secondPeerID, + recipient: viewModel.nickname, + filename: "second.jpg" + ) + ], for: secondPeerID) + + viewModel.clearPrivateChat(firstPeerID) + viewModel.clearPrivateChat(secondPeerID) + let queuedArrival = BitchatMessage( + id: "arrival-after-queued-clear", + sender: "Peer", + content: "new", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: viewModel.nickname, + senderPeerID: secondPeerID + ) + #expect(viewModel.appendPrivateMessage( + queuedArrival, + to: secondPeerID + )) + + #expect( + transport.deletedPrivateMediaMessageIDBatches + == [[firstUniqueID]] + ) + + transport.resolveNextDeletedPrivateMediaPersistence(true) + + #expect( + transport.deletedPrivateMediaMessageIDBatches == [ + [firstUniqueID], + [secondUniqueID, sharedID].sorted() + ] + ) + + transport.resolveNextDeletedPrivateMediaPersistence(true) + + #expect((viewModel.privateChats[firstPeerID] ?? []).isEmpty) + #expect( + viewModel.privateChats[secondPeerID]?.map(\.id) + == ["arrival-after-queued-clear"] + ) + } + + @Test @MainActor + func clearFollowsCapturedRowsAcrossPeerIdentityMigration() { + let (viewModel, transport) = makeTestableViewModel() + transport.deferDeletedPrivateMediaPersistence = true + let sourcePeerID = PeerID(str: String(repeating: "d", count: 64)) + let destinationPeerID = PeerID(str: String(repeating: "e", count: 64)) + let thirdPeerID = PeerID(str: String(repeating: "f", count: 64)) + let stableID = "media-\(String(repeating: "4", count: 32))" + viewModel.selectedPrivateChatPeer = sourcePeerID + viewModel.seedPrivateChat([ + privateMediaMessage( + id: stableID, + sender: "Peer", + senderPeerID: sourcePeerID, + recipient: viewModel.nickname, + filename: "migrated.jpg" + ), + BitchatMessage( + id: "captured-before-migration", + sender: "Peer", + content: "old", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: viewModel.nickname, + senderPeerID: sourcePeerID + ) + ], for: sourcePeerID) + + viewModel.clearPrivateChat(sourcePeerID) + viewModel.migratePrivateChat( + from: sourcePeerID, + to: destinationPeerID + ) + viewModel.selectedPrivateChatPeer = thirdPeerID + let arrival = BitchatMessage( + id: "arrival-after-migration", + sender: "Peer", + content: "new", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: viewModel.nickname, + senderPeerID: destinationPeerID + ) + #expect(viewModel.appendPrivateMessage( + arrival, + to: destinationPeerID + )) + + transport.resolveNextDeletedPrivateMediaPersistence(true) + + #expect( + viewModel.privateChats[destinationPeerID]?.map(\.id) + == ["arrival-after-migration"] + ) + } + + @Test @MainActor + func clearFollowsMigrationWhenOldSourceIsRecreated() { + let (viewModel, transport) = makeTestableViewModel() + transport.deferDeletedPrivateMediaPersistence = true + let sourcePeerID = PeerID(str: String(repeating: "1", count: 64)) + let destinationPeerID = PeerID(str: String(repeating: "2", count: 64)) + let stableID = "media-\(String(repeating: "6", count: 32))" + viewModel.seedPrivateChat([ + privateMediaMessage( + id: stableID, + sender: "Peer", + senderPeerID: sourcePeerID, + recipient: viewModel.nickname, + filename: "migrated-recreated.jpg" + ), + BitchatMessage( + id: "captured-before-recreation", + sender: "Peer", + content: "old", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: viewModel.nickname, + senderPeerID: sourcePeerID + ) + ], for: sourcePeerID) + + viewModel.clearPrivateChat(sourcePeerID) + viewModel.migratePrivateChat( + from: sourcePeerID, + to: destinationPeerID + ) + let recreatedArrival = BitchatMessage( + id: "arrival-recreating-source", + sender: "Peer", + content: "new", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: viewModel.nickname, + senderPeerID: sourcePeerID + ) + #expect(viewModel.appendPrivateMessage( + recreatedArrival, + to: sourcePeerID + )) + + transport.resolveNextDeletedPrivateMediaPersistence(true) + + #expect( + (viewModel.privateChats[destinationPeerID] ?? []).isEmpty + ) + #expect( + viewModel.privateChats[sourcePeerID]?.map(\.id) + == ["arrival-recreating-source"] + ) + } + + @Test @MainActor + func clearPrivateChatKeepsMediaReferencedByAnotherConversation() { + let (viewModel, transport) = makeTestableViewModel() + let firstPeerID = PeerID(str: String(repeating: "4", count: 64)) + let aliasPeerID = PeerID(str: String(repeating: "5", count: 64)) + let messageID = "media-\(String(repeating: "6", count: 32))" + let message = privateMediaMessage( + id: messageID, + sender: "Peer", + senderPeerID: firstPeerID, + recipient: viewModel.nickname, + filename: "mirrored.jpg" + ) + viewModel.seedPrivateChat([message], for: firstPeerID) + viewModel.seedPrivateChat([message], for: aliasPeerID) + + viewModel.clearPrivateChat(firstPeerID) + + #expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty) + #expect(viewModel.privateChats[firstPeerID]?.isEmpty == true) + #expect( + viewModel.privateChats[aliasPeerID]?.map(\.id) == [messageID] + ) + } + + @Test @MainActor + func clearPrivateChatKeepsOutgoingMediaReferencedByAnotherConversation() + 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 { + let (viewModel, transport) = makeTestableViewModel() + let firstPeerID = PeerID(str: String(repeating: "9", count: 64)) + let aliasPeerID = PeerID(str: String(repeating: "a", count: 64)) + let incomingDirectory = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + .appendingPathComponent("files/images/incoming", isDirectory: true) + try FileManager.default.createDirectory( + at: incomingDirectory, + withIntermediateDirectories: true + ) + let fileURL = incomingDirectory.appendingPathComponent( + "legacy-clear-\(UUID().uuidString).jpg" + ) + try Data("legacy-image".utf8).write(to: fileURL, options: .atomic) + defer { try? FileManager.default.removeItem(at: fileURL) } + let message = privateMediaMessage( + id: UUID().uuidString, + sender: "Old client", + senderPeerID: firstPeerID, + recipient: viewModel.nickname, + filename: fileURL.lastPathComponent + ) + viewModel.seedPrivateChat([message], for: firstPeerID) + viewModel.seedPrivateChat([message], for: aliasPeerID) + + viewModel.clearPrivateChat(firstPeerID) + + // A surviving mirror in another conversation keeps the payload. + #expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty) + #expect(transport.removedLegacyPrivateMediaPaths.isEmpty) + #expect(FileManager.default.fileExists(atPath: fileURL.path)) + #expect(viewModel.privateChats[aliasPeerID]?.map(\.id) == [message.id]) + + 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)) + } + + @Test @MainActor + func panicInvalidatesActiveAndQueuedPrivateChatClears() { + let (viewModel, transport) = makeTestableViewModel() + transport.deferDeletedPrivateMediaPersistence = true + let firstPeerID = PeerID(str: String(repeating: "4", count: 64)) + let secondPeerID = PeerID(str: String(repeating: "5", count: 64)) + let firstID = "media-\(String(repeating: "6", count: 32))" + let secondID = "media-\(String(repeating: "7", count: 32))" + viewModel.seedPrivateChat([ + privateMediaMessage( + id: firstID, + sender: "First", + senderPeerID: firstPeerID, + recipient: viewModel.nickname, + filename: "first-pre-panic.jpg" + ) + ], for: firstPeerID) + viewModel.seedPrivateChat([ + privateMediaMessage( + id: secondID, + sender: "Second", + senderPeerID: secondPeerID, + recipient: viewModel.nickname, + filename: "second-pre-panic.jpg" + ) + ], for: secondPeerID) + + viewModel.clearPrivateChat(firstPeerID) + viewModel.clearPrivateChat(secondPeerID) + #expect( + transport.deletedPrivateMediaMessageIDBatches == [[firstID]] + ) + + _ = viewModel.panicClearAllData(restartServices: false) + viewModel.seedPrivateChat([ + privateMediaMessage( + id: firstID, + sender: "First", + senderPeerID: firstPeerID, + recipient: viewModel.nickname, + filename: "first-post-panic.jpg" + ) + ], for: firstPeerID) + viewModel.seedPrivateChat([ + privateMediaMessage( + id: secondID, + sender: "Second", + senderPeerID: secondPeerID, + recipient: viewModel.nickname, + filename: "second-post-panic.jpg" + ) + ], for: secondPeerID) + + transport.resolveNextDeletedPrivateMediaPersistence(true) + + #expect( + transport.deletedPrivateMediaMessageIDBatches == [[firstID]] + ) + #expect(viewModel.privateChats[firstPeerID]?.map(\.id) == [firstID]) + #expect(viewModel.privateChats[secondPeerID]?.map(\.id) == [secondID]) + } + + private func privateMediaMessage( + id: String, + sender: String, + senderPeerID: PeerID, + recipient: String, + filename: String + ) -> BitchatMessage { + BitchatMessage( + id: id, + sender: sender, + content: "\(MimeType.Category.image.messagePrefix)\(filename)", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: recipient, + senderPeerID: senderPeerID + ) + } +} + // MARK: - Panic Clear Tests struct ChatViewModelPanicTests { + @Test @MainActor + func panicClearAllData_finishesMediaWipeBeforeReturning() { + var wipeFinished = false + let (viewModel, _) = makeTestableViewModel(panicMediaWipe: { + wipeFinished = true + }) + + viewModel.panicClearAllData() + + #expect(wipeFinished) + } + + @Test @MainActor + func panicClearAllData_stopsNetworkBeforeWipeAndRestartsAfterCommit() { + var events: [String] = [] + let lifecycle = PanicNetworkLifecycle( + stop: { events.append("stop") }, + restart: { events.append("restart") } + ) + let (viewModel, _) = makeTestableViewModel( + panicMediaWipe: { events.append("wipe") }, + panicNetworkLifecycle: lifecycle + ) + + let completed = viewModel.panicClearAllData() + + #expect(completed) + #expect(events == ["stop", "wipe", "restart"]) + #expect(viewModel.networkActivationAllowed) + } + + @Test @MainActor + func panicKeychainFailureKeepsRecoveryPendingAndServicesStopped() { + let keychain = MockKeychain() + keychain.simulatedDeleteAllResult = false + var events: [String] = [] + let operations = PanicRecoveryOperations( + isPending: { false }, + begin: { + events.append("begin") + return PanicRecoveryIntent( + fileMarkerEstablished: true, + externalMarkerEstablished: false + ) + }, + wipeMedia: { _ in events.append("wipe") }, + complete: { events.append("complete") } + ) + let lifecycle = PanicNetworkLifecycle( + stop: { events.append("stop") }, + restart: { events.append("restart") } + ) + let (viewModel, transport) = makeTestableViewModel( + keychain: keychain, + panicRecoveryOperations: operations, + panicNetworkLifecycle: lifecycle + ) + let startsBeforePanic = transport.startServicesCallCount + + let completed = viewModel.panicClearAllData() + + #expect(!completed) + #expect(events == ["stop", "begin", "wipe"]) + #expect(keychain.deleteAllCallCount == 1) + #expect(transport.startServicesCallCount == startsBeforePanic) + #expect(!viewModel.networkActivationAllowed) + } + + @Test @MainActor + func pendingPanicRecoveryCompletesBeforeTransportBootstrap() { + var events: [String] = [] + let operations = PanicRecoveryOperations( + isPending: { + events.append("read") + return true + }, + begin: { + events.append("begin") + return PanicRecoveryIntent( + fileMarkerEstablished: true, + externalMarkerEstablished: false + ) + }, + wipeMedia: { _ in events.append("wipe") }, + complete: { events.append("complete") } + ) + + let (viewModel, transport) = makeTestableViewModel( + panicRecoveryOperations: operations + ) + + #expect(events == ["read", "begin", "wipe", "complete"]) + #expect(transport.emergencyDisconnectCallCount == 1) + #expect(transport.startServicesCallCount == 1) + #expect(viewModel.networkActivationAllowed) + } + + @Test @MainActor + func failedStartupRecoveryLeavesTransportAndNetworkBlocked() { + enum WipeFailure: Error { case failed } + var completedMarker = false + let operations = PanicRecoveryOperations( + isPending: { true }, + begin: { + PanicRecoveryIntent( + fileMarkerEstablished: true, + externalMarkerEstablished: false + ) + }, + wipeMedia: { _ in throw WipeFailure.failed }, + complete: { completedMarker = true } + ) + + let (viewModel, transport) = makeTestableViewModel( + panicRecoveryOperations: operations + ) + + #expect(!completedMarker) + #expect(transport.emergencyDisconnectCallCount == 1) + #expect(transport.startServicesCallCount == 0) + #expect(!viewModel.networkActivationAllowed) + } + + @Test @MainActor + func failedStartupKeychainRecoveryLeavesIntentAndTransportBlocked() { + let keychain = MockKeychain() + keychain.simulatedDeleteAllResult = false + var events: [String] = [] + let operations = PanicRecoveryOperations( + isPending: { true }, + begin: { + events.append("begin") + return PanicRecoveryIntent( + fileMarkerEstablished: true, + externalMarkerEstablished: true + ) + }, + wipeMedia: { _ in events.append("wipe") }, + complete: { events.append("complete") } + ) + + let (viewModel, transport) = makeTestableViewModel( + keychain: keychain, + panicRecoveryOperations: operations + ) + + #expect(events == ["begin", "wipe"]) + #expect(keychain.deleteAllCallCount == 1) + #expect(transport.emergencyDisconnectCallCount == 1) + #expect(transport.startServicesCallCount == 0) + #expect(!viewModel.networkActivationAllowed) + } + @Test @MainActor func panicClearAllData_delegatesToTransport() async { let (viewModel, transport) = makeTestableViewModel() diff --git a/bitchatTests/ConversationStoreTests.swift b/bitchatTests/ConversationStoreTests.swift index 3c9ef6fd..cc1e6e09 100644 --- a/bitchatTests/ConversationStoreTests.swift +++ b/bitchatTests/ConversationStoreTests.swift @@ -45,6 +45,154 @@ private func makeDirectConversationID(_ suffix: String) -> ConversationID { )) } +/// Deliberately simple O(n) model used to differentially test the store's +/// optimized logical-index bookkeeping. It models observable behavior only; +/// it has no offset or ID index and therefore cannot reproduce the same bug. +private struct ReferenceConversationTimeline { + struct Message: Equatable { + let id: String + let timestamp: Date + let content: String + var deliveryStatus: DeliveryStatus? + + init(_ message: BitchatMessage) { + id = message.id + timestamp = message.timestamp + content = message.content + deliveryStatus = message.deliveryStatus + } + } + + struct AppendResult { + let inserted: Bool + let trimmedCount: Int + } + + let cap: Int + private(set) var messages: [Message] = [] + + func contains(_ id: String) -> Bool { + messages.contains { $0.id == id } + } + + mutating func append(_ message: BitchatMessage) -> AppendResult { + guard !contains(message.id) else { + return AppendResult(inserted: false, trimmedCount: 0) + } + + let snapshot = Message(message) + var low = 0 + var high = messages.count + while low < high { + let mid = (low + high) / 2 + if messages[mid].timestamp <= snapshot.timestamp { + low = mid + 1 + } else { + high = mid + } + } + messages.insert(snapshot, at: low) + + let overflow = max(0, messages.count - cap) + if overflow > 0 { + messages.removeFirst(overflow) + } + return AppendResult(inserted: true, trimmedCount: overflow) + } + + mutating func upsert(_ message: BitchatMessage) -> Int { + if let index = messages.firstIndex(where: { $0.id == message.id }) { + messages[index] = Message(message) + return 0 + } + return append(message).trimmedCount + } + + mutating func applyDeliveryStatus(_ status: DeliveryStatus, to id: String) -> Bool { + guard let index = messages.firstIndex(where: { $0.id == id }), + messages[index].deliveryStatus != status else { + return false + } + // The differential stream uses only unique `.delivered` values (or + // an exact repeat), so no-downgrade policy is intentionally outside + // this index-focused reference model. + messages[index].deliveryStatus = status + return true + } + + mutating func remove(at index: Int) -> Message { + messages.remove(at: index) + } + + mutating func removeAll(where predicate: (Message) -> Bool) { + messages.removeAll(where: predicate) + } + + mutating func clear() { + messages.removeAll() + } +} + +private struct ConversationStoreDifferentialRNG { + private var state: UInt64 + + init(seed: UInt64) { + state = seed + } + + mutating func next() -> UInt64 { + state &+= 0x9E37_79B9_7F4A_7C15 + var value = state + value = (value ^ (value >> 30)) &* 0xBF58_476D_1CE4_E5B9 + value = (value ^ (value >> 27)) &* 0x94D0_49BB_1331_11EB + return value ^ (value >> 31) + } + + mutating func index(upperBound: Int) -> Int { + precondition(upperBound > 0) + return Int(next() % UInt64(upperBound)) + } +} + +@MainActor +private func expectStore( + _ store: ConversationStore, + matches reference: ReferenceConversationTimeline, + issuedIDs: [String], + checkpoint: String +) { + let conversation = store.conversation(for: .mesh) + let actual = conversation.messages.map(ReferenceConversationTimeline.Message.init) + #expect(actual == reference.messages, "timeline mismatch at \(checkpoint)") + + let lookupSnapshot = reference.messages.compactMap { expected in + conversation.message(withID: expected.id).map(ReferenceConversationTimeline.Message.init) + } + #expect(lookupSnapshot == reference.messages, "ID lookup mismatch at \(checkpoint)") + #expect( + Set(conversation.messageIDs) == Set(reference.messages.map(\.id)), + "per-conversation ID set mismatch at \(checkpoint)" + ) + + if !reference.messages.isEmpty { + for index in Set([0, reference.messages.count / 2, reference.messages.count - 1]) { + let id = reference.messages[index].id + #expect(store.conversationIDs(forMessageID: id) == [.mesh], "store ID map mismatch at \(checkpoint)") + } + } + + let activeIDs = Set(reference.messages.map(\.id)) + var checkedStaleIDs = 0 + for id in issuedIDs.reversed() where !activeIDs.contains(id) { + #expect(conversation.message(withID: id) == nil, "stale conversation index entry at \(checkpoint)") + #expect(store.conversationIDs(forMessageID: id).isEmpty, "stale store ID map entry at \(checkpoint)") + checkedStaleIDs += 1 + if checkedStaleIDs == 16 { break } + } + + #expect(store.auditInvariants().isEmpty, "invariant audit failed at \(checkpoint)") +} + @Suite("ConversationStore") struct ConversationStoreTests { @@ -140,6 +288,282 @@ struct ConversationStoreTests { #expect(conversation.message(withID: probeID)?.deliveryStatus == .sent) } + @Test("steady-state cap trimming keeps lookups exact across mixed mutations") + @MainActor + func steadyStateCapTrimmingKeepsLogicalIndexExact() { + let store = ConversationStore() + let conversation = store.conversation(for: .mesh) + let overflow = 64 + + for i in 0..<(conversation.cap + overflow) { + store.append(makeMessage(id: "m\(i)", timestamp: TimeInterval(i)), to: .mesh) + } + + #expect(conversation.messages.first?.id == "m\(overflow)") + #expect(conversation.message(withID: "m\(overflow)")?.id == "m\(overflow)") + + // Exercise a suffix reindex after the head offset has advanced, then + // trim the old head. The late row becomes the new first element. + let late = makeMessage(id: "late", timestamp: TimeInterval(overflow) + 0.5) + #expect(store.append(late, to: .mesh)) + #expect(conversation.messages.first?.id == "late") + #expect(conversation.message(withID: "m\(overflow + 1)")?.id == "m\(overflow + 1)") + + // Head and middle removals, an in-place upsert, and a status update + // must all resolve through the same logical index representation. + #expect(store.removeMessage(withID: "late", from: .mesh)?.id == "late") + let middleID = "m\(overflow + conversation.cap / 2)" + #expect(store.removeMessage(withID: middleID, from: .mesh)?.id == middleID) + + let probeID = "m\(overflow + 10)" + store.upsertByID( + makeMessage(id: probeID, timestamp: TimeInterval(overflow + 10), content: "edited"), + in: .mesh + ) + #expect(conversation.message(withID: probeID)?.content == "edited") + #expect(store.setDeliveryStatus(.sent, forMessageID: probeID, in: .mesh)) + #expect(conversation.message(withID: probeID)?.deliveryStatus == .sent) + #expect(store.auditInvariants().isEmpty) + + // Clearing resets the logical offset as well as the maps. + store.clear(.mesh) + #expect(store.append(makeMessage(id: "after-clear", timestamp: 10_000), to: .mesh)) + #expect(conversation.message(withID: "after-clear")?.id == "after-clear") + #expect(store.auditInvariants().isEmpty) + } + + @Test("logical index offset matches a reference model under adversarial mutations") + @MainActor + func logicalIndexOffsetDifferentialStress() async { + let store = ConversationStore() + let cap = store.conversation(for: .mesh).cap + var reference = ReferenceConversationTimeline(cap: cap) + var rng = ConversationStoreDifferentialRNG(seed: 0xC0FF_EE13_37CA_FE42) + var issuedIDs: [String] = [] + var nextID = 0 + var nextTailTimestamp: TimeInterval = 1_700_000_000 + var trimmedCount = 0 + + var tailAppendCount = 0 + var outOfOrderCount = 0 + var duplicateOrReuseCount = 0 + var headRemovalCount = 0 + var middleRemovalCount = 0 + var upsertCount = 0 + var deliveryUpdateCount = 0 + var filterCount = 0 + var clearCount = 0 + + func issueMessage(timestamp: TimeInterval? = nil, tag: String) -> BitchatMessage { + let number = nextID + nextID += 1 + let id = "diff-\(number)" + issuedIDs.append(id) + let resolvedTimestamp: TimeInterval + if let timestamp { + resolvedTimestamp = timestamp + } else { + resolvedTimestamp = nextTailTimestamp + nextTailTimestamp += 1 + } + let dropMarker = number.isMultiple(of: 11) ? " [drop]" : "" + return makeMessage( + id: id, + timestamp: resolvedTimestamp, + content: "\(tag) \(number)\(dropMarker)" + ) + } + + @discardableResult + func appendAndCompare(_ message: BitchatMessage, checkpoint: String) -> ReferenceConversationTimeline.AppendResult { + let expected = reference.append(message) + let actual = store.append(message, to: .mesh) + #expect(actual == expected.inserted, "append result mismatch at \(checkpoint)") + trimmedCount += expected.trimmedCount + return expected + } + + func refill(extra: Int, checkpoint: String) async { + let appendCount = max(0, cap - reference.messages.count) + extra + for index in 0.. 1_200) + #expect(tailAppendCount > 300) + #expect(outOfOrderCount > 150) + #expect(duplicateOrReuseCount > 75) + #expect(headRemovalCount > 50) + #expect(middleRemovalCount > 50) + #expect(upsertCount > 75) + #expect(deliveryUpdateCount > 75) + #expect(filterCount == 2) + #expect(clearCount == 1) + } + // MARK: - Upsert @Test("upsertByID replaces in place and appends when absent") @@ -755,6 +1179,71 @@ struct ConversationStoreTests { #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() + 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) /// A store exercised through every intent family: public + geohash + diff --git a/bitchatTests/EndToEnd/CourierEndToEndTests.swift b/bitchatTests/EndToEnd/CourierEndToEndTests.swift index ac68c145..53de683b 100644 --- a/bitchatTests/EndToEnd/CourierEndToEndTests.swift +++ b/bitchatTests/EndToEnd/CourierEndToEndTests.swift @@ -142,7 +142,7 @@ struct CourierEndToEndTests { )) let deposited = await TestHelpers.waitUntil( { aliceOut.first(ofType: .courierEnvelope) != nil }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(deposited) let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) @@ -151,7 +151,7 @@ struct CourierEndToEndTests { carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData()) let carried = await TestHelpers.waitUntil( { !carol.courierStore.isEmpty }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(carried) @@ -161,7 +161,7 @@ struct CourierEndToEndTests { bob.sendBroadcastAnnounce() let announced = await TestHelpers.waitUntil( { bobOut.first(ofType: .announce) != nil }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(announced) let announcePacket = try #require(bobOut.first(ofType: .announce)) @@ -169,7 +169,7 @@ struct CourierEndToEndTests { let handedOver = await TestHelpers.waitUntil( { carolOut.first(ofType: .courierEnvelope) != nil }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(handedOver) // With CoreBluetooth disabled there is no physical link for the send @@ -183,7 +183,7 @@ struct CourierEndToEndTests { bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID) let received = await TestHelpers.waitUntil( { !bobDelegate.snapshot().isEmpty }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(received) @@ -229,7 +229,7 @@ struct CourierEndToEndTests { )) let deposited = await TestHelpers.waitUntil( { aliceOut.first(ofType: .courierEnvelope) != nil }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(deposited) let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) @@ -237,7 +237,7 @@ struct CourierEndToEndTests { carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData()) let carried = await TestHelpers.waitUntil( { !carol.courierStore.isEmpty }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(carried) @@ -245,7 +245,7 @@ struct CourierEndToEndTests { bob.sendBroadcastAnnounce() let announced = await TestHelpers.waitUntil( { bobOut.first(ofType: .announce) != nil }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(announced) let announcePacket = try #require(bobOut.first(ofType: .announce)) @@ -253,7 +253,7 @@ struct CourierEndToEndTests { let handedOver = await TestHelpers.waitUntil( { carolOut.first(ofType: .courierEnvelope) != nil }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(handedOver) let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope)) @@ -265,7 +265,7 @@ struct CourierEndToEndTests { bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID) let delivered = await TestHelpers.waitUntil( { !bobDelegate.snapshot().isEmpty }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!delivered) } @@ -293,7 +293,7 @@ struct CourierEndToEndTests { )) let deposited = await TestHelpers.waitUntil( { aliceOut.first(ofType: .courierEnvelope) != nil }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(deposited) let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) @@ -301,7 +301,7 @@ struct CourierEndToEndTests { carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData()) let carried = await TestHelpers.waitUntil( { !carol.courierStore.isEmpty }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(carried) @@ -310,7 +310,7 @@ struct CourierEndToEndTests { let leakedOnUnverifiedAnnounce = await TestHelpers.waitUntil( { carolOut.count(ofType: .courierEnvelope) > 0 }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!leakedOnUnverifiedAnnounce) #expect(!carol.courierStore.isEmpty) @@ -318,7 +318,7 @@ struct CourierEndToEndTests { bob.sendBroadcastAnnounce() let announced = await TestHelpers.waitUntil( { bobOut.first(ofType: .announce) != nil }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(announced) let verifiedAnnounce = try #require(bobOut.first(ofType: .announce)) @@ -326,7 +326,7 @@ struct CourierEndToEndTests { let handedOver = await TestHelpers.waitUntil( { carolOut.count(ofType: .courierEnvelope) == 1 }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(handedOver) #expect(!carol.courierStore.isEmpty) @@ -355,7 +355,7 @@ struct CourierEndToEndTests { )) let deposited = await TestHelpers.waitUntil( { aliceOut.first(ofType: .courierEnvelope) != nil }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(deposited) let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) @@ -363,14 +363,14 @@ struct CourierEndToEndTests { carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData()) let carried = await TestHelpers.waitUntil( { !carol.courierStore.isEmpty }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(carried) bob.sendBroadcastAnnounce() let announced = await TestHelpers.waitUntil( { bobOut.first(ofType: .announce) != nil }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(announced) let directAnnounce = try #require(bobOut.first(ofType: .announce)) @@ -385,7 +385,7 @@ struct CourierEndToEndTests { let remoteHandover = await TestHelpers.waitUntil( { carolOut.count(ofType: .courierEnvelope) == 1 }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(remoteHandover) #expect(!carol.courierStore.isEmpty) @@ -398,7 +398,7 @@ struct CourierEndToEndTests { bob.sendBroadcastAnnounce() let reannounced = await TestHelpers.waitUntil( { bobOut.all(ofType: .announce).contains { $0.timestamp != directAnnounce.timestamp } }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(reannounced) let freshAnnounce = try #require( @@ -410,7 +410,7 @@ struct CourierEndToEndTests { let refloodedInCooldown = await TestHelpers.waitUntil( { carolOut.count(ofType: .courierEnvelope) > 1 }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!refloodedInCooldown) #expect(!carol.courierStore.isEmpty) @@ -424,7 +424,7 @@ struct CourierEndToEndTests { bob.sendBroadcastAnnounce() let announcedAgain = await TestHelpers.waitUntil( { bobOut.all(ofType: .announce).contains { $0.timestamp != directAnnounce.timestamp && $0.timestamp != freshAnnounce.timestamp } }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(announcedAgain) let directAgain = try #require( @@ -434,7 +434,7 @@ struct CourierEndToEndTests { let handedOverWithoutLinkProof = await TestHelpers.waitUntil( { carolOut.count(ofType: .courierEnvelope) > 1 }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!handedOverWithoutLinkProof) #expect(!carol.courierStore.isEmpty) @@ -457,7 +457,7 @@ struct CourierEndToEndTests { let queuedPacket = await TestHelpers.waitUntil( { aliceOut.first(ofType: .courierEnvelope) != nil }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!queuedPacket) } @@ -494,7 +494,7 @@ struct CourierEndToEndTests { carol._test_handlePacket(packet, fromPeerID: alicePeerID, signingPublicKey: alice.getSigningPublicKeyData()) let stored = await TestHelpers.waitUntil( { !carol.courierStore.isEmpty }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!stored) } @@ -532,7 +532,7 @@ struct CourierEndToEndTests { carol._test_handlePacket(packet, fromPeerID: alicePeerID, signingPublicKey: alice.getSigningPublicKeyData()) let stored = await TestHelpers.waitUntil( { !carol.courierStore.isEmpty }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!stored) } @@ -575,7 +575,7 @@ struct CourierEndToEndTests { carol._test_handlePacket(packet, fromPeerID: mallory.myPeerID, preseedPeer: false) let stored = await TestHelpers.waitUntil( { !carol.courierStore.isEmpty }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!stored) } @@ -602,14 +602,14 @@ struct CourierEndToEndTests { let delivered = await TestHelpers.waitUntil( { !bobDelegate.snapshot().isEmpty }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(delivered) // Give a duplicate delivery a chance to surface, then confirm the // second copy never reached the delegate. let duplicated = await TestHelpers.waitUntil( { bobDelegate.snapshot().count > 1 }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!duplicated) #expect(bobDelegate.snapshot().count == 1) @@ -629,7 +629,7 @@ struct CourierEndToEndTests { let initiated = await TestHelpers.waitUntil( { outbound.count(ofType: .noiseHandshake) > 0 }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!initiated) @@ -639,7 +639,7 @@ struct CourierEndToEndTests { ble.sendDeliveryAck(for: "msg-2", to: present) let initiatedForPresent = await TestHelpers.waitUntil( { outbound.count(ofType: .noiseHandshake) > 0 }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(initiatedForPresent) } diff --git a/bitchatTests/EndToEnd/PrekeyEndToEndTests.swift b/bitchatTests/EndToEnd/PrekeyEndToEndTests.swift index 79bd28b5..692c8f6d 100644 --- a/bitchatTests/EndToEnd/PrekeyEndToEndTests.swift +++ b/bitchatTests/EndToEnd/PrekeyEndToEndTests.swift @@ -87,7 +87,7 @@ struct PrekeyEndToEndTests { peer.sendBroadcastAnnounce() let published = await TestHelpers.waitUntil( { tap.first(ofType: .announce) != nil && tap.first(ofType: .prekeyBundle) != nil }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(published) return ( @@ -124,7 +124,7 @@ struct PrekeyEndToEndTests { let cached = await TestHelpers.waitUntil( { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(cached) @@ -138,7 +138,7 @@ struct PrekeyEndToEndTests { )) let deposited = await TestHelpers.waitUntil( { aliceOut.first(ofType: .courierEnvelope) != nil }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(deposited) let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) @@ -149,7 +149,7 @@ struct PrekeyEndToEndTests { carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData()) let carried = await TestHelpers.waitUntil( { !carol.courierStore.isEmpty }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(carried) @@ -158,7 +158,7 @@ struct PrekeyEndToEndTests { bob.sendBroadcastAnnounce() let reannounced = await TestHelpers.waitUntil( { bobOut.first(ofType: .announce) != nil }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(reannounced) let handoverTrigger = try #require(bobOut.first(ofType: .announce)) @@ -166,7 +166,7 @@ struct PrekeyEndToEndTests { let handedOver = await TestHelpers.waitUntil( { carolOut.first(ofType: .courierEnvelope) != nil }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(handedOver) let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope)) @@ -178,7 +178,7 @@ struct PrekeyEndToEndTests { bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID) let received = await TestHelpers.waitUntil( { !bobDelegate.snapshot().isEmpty }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(received) @@ -207,7 +207,7 @@ struct PrekeyEndToEndTests { bob._test_handlePacket(redelivery, fromPeerID: carol.myPeerID) let redelivered = await TestHelpers.waitUntil( { bobDelegate.snapshot().count == 2 }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!redelivered) #expect(bobDelegate.snapshot().count == 1) @@ -235,7 +235,7 @@ struct PrekeyEndToEndTests { )) let deposited = await TestHelpers.waitUntil( { aliceOut.first(ofType: .courierEnvelope) != nil }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(deposited) let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) @@ -248,7 +248,7 @@ struct PrekeyEndToEndTests { bob._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, preseedPeer: false) let received = await TestHelpers.waitUntil( { !bobDelegate.snapshot().isEmpty }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(received) let delivered = try #require(bobDelegate.snapshot().first) @@ -272,7 +272,7 @@ struct PrekeyEndToEndTests { let cached = await TestHelpers.waitUntil( { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!cached) } @@ -310,7 +310,7 @@ struct PrekeyEndToEndTests { let cached = await TestHelpers.waitUntil( { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!cached) } @@ -328,7 +328,7 @@ struct PrekeyEndToEndTests { let cached = await TestHelpers.waitUntil( { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) }, - timeout: TestConstants.defaultTimeout + timeout: TestConstants.settleTimeout ) #expect(cached) // The verified bundle now participates in Alice's sync rounds. @@ -364,7 +364,7 @@ struct PrekeyEndToEndTests { let cached = await TestHelpers.waitUntil( { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!cached) #expect(!alice._test_hasGossipPrekeyBundle(for: bob.myPeerID)) @@ -396,7 +396,7 @@ struct PrekeyEndToEndTests { let cached = await TestHelpers.waitUntil( { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.negativeWaitWindow ) #expect(!cached) #expect(!alice._test_hasGossipPrekeyBundle(for: bob.myPeerID)) diff --git a/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift b/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift new file mode 100644 index 00000000..44d6f51e --- /dev/null +++ b/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift @@ -0,0 +1,1695 @@ +import BitFoundation +import Combine +import CoreBluetooth +import Foundation +import Testing +@testable import bitchat + +/// Wire-level coverage for finalized DM media. The sender encrypts one typed +/// private-file payload, relays see only the outer Noise packet/fragments, and +/// the receiver reassembles, decrypts, validates, persists, and delivers it. +@Suite("Private media end to end", .serialized) +struct PrivateMediaEndToEndTests { + @Test + func privateMediaCancellationTombstonesAreCountBounded() async { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-tombstone-bound-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let service = makeService(baseDirectory: root) + + for index in 0..<600 { + service.cancelTransfer("cancelled-before-admission-\(index)") + } + + #expect(service._test_privateMediaAdmissionEntryCount() <= 512) + await service._test_drainPrivateMediaSendPipeline() + } + + @Test + func privateMediaAdmissionCapacityRejectsNewcomerWithoutEvictingActiveTransfer() async { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-admission-capacity-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let service = makeService(baseDirectory: root) + let now = Date() + let activeIDs = (0..<512).map { "capacity-active-\($0)" } + for transferId in activeIDs { + #expect(service._test_beginPrivateMediaAdmission(transferId, now: now)) + } + defer { + for transferId in activeIDs { + service._test_finishPrivateMediaAdmission(transferId) + } + } + + let overflowID = "capacity-overflow-\(UUID().uuidString)" + let rejections = TransferCancellationRecorder() + let cancellable = TransferProgressManager.shared.publisher.sink { rejections.record($0) } + let content = Data("%PDF-1.7\ncapacity".utf8) + service.sendFilePrivate( + BitchatFilePacket( + fileName: "capacity.pdf", + fileSize: UInt64(content.count), + mimeType: "application/pdf", + content: content + ), + to: PeerID(str: "1122334455667788"), + transferId: overflowID, + allowLegacyFallback: true + ) + + #expect(await TestHelpers.waitUntil( + { rejections.contains(overflowID) }, + timeout: TestConstants.longTimeout + )) + #expect(rejections.reason(for: overflowID) != nil) + #expect(service._test_isPrivateMediaAdmissionActive(activeIDs[0], now: now)) + #expect(service._test_privateMediaAdmissionEntryCount() == 512) + _ = cancellable + } + + @Test + func expiredActivePrivateMediaAdmissionEmitsVisibleFailure() async { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-admission-expiry-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let service = makeService(baseDirectory: root) + let transferId = "expired-active-\(UUID().uuidString)" + let admittedAt = Date(timeIntervalSince1970: 1_000) + let rejections = TransferCancellationRecorder() + let cancellable = TransferProgressManager.shared.publisher.sink { rejections.record($0) } + + #expect(service._test_beginPrivateMediaAdmission(transferId, now: admittedAt)) + #expect(!service._test_isPrivateMediaAdmissionActive( + transferId, + now: admittedAt.addingTimeInterval(60 * 60 + 1) + )) + #expect(await TestHelpers.waitUntil( + { rejections.contains(transferId) }, + timeout: TestConstants.longTimeout + )) + #expect(rejections.reason(for: transferId) != nil) + #expect(service._test_privateMediaAdmissionEntryCount() == 0) + _ = cancellable + } + + @Test + func approvedLegacySendCancelledBeforeDeferredAdmissionDoesNotTransmit() async throws { + try await assertApprovedLegacySendCancelledBeforeAdmission(label: "cancel") + } + + @Test + func approvedLegacySendDeletedBeforeDeferredAdmissionDoesNotTransmit() async throws { + // ChatMediaTransferCoordinator.deleteMediaMessage now invokes this same + // synchronous transport cancellation before removing its mapping; its + // coordinator-level call is covered separately in the context tests. + try await assertApprovedLegacySendCancelledBeforeAdmission(label: "delete") + } + + @Test + func panicSuspensionFinishesAdmissionAtInitialDeferredSendBoundary() async { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-panic-deferred-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let service = makeService(baseDirectory: root) + let tap = PacketTap() + service._test_onOutboundPacket = tap.record + service.suspendForPanicReset() + defer { service.completePanicReset(restartServices: false) } + + let transferId = "panic-deferred-\(UUID().uuidString)" + let content = Data("%PDF-1.7\npanic-deferred".utf8) + service.sendFilePrivate( + BitchatFilePacket( + fileName: "panic-deferred.pdf", + fileSize: UInt64(content.count), + mimeType: "application/pdf", + content: content + ), + to: PeerID(str: "1122334455667788"), + transferId: transferId, + allowLegacyFallback: true + ) + await service._test_drainPrivateMediaSendPipeline() + + let state = service._test_privateMediaTransferState(transferId: transferId) + #expect(!state.admissionActive) + #expect(!state.pendingNoise) + #expect(state.activeScheduler == 0) + #expect(state.pendingScheduler == 0) + #expect(service._test_privateMediaAdmissionEntryCount() == 0) + #expect(tap.snapshot().isEmpty) + } + + @Test + func panicSuspensionFinishesAdmissionAtBroadcastBoundary() { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-panic-broadcast-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let service = makeService(baseDirectory: root) + let tap = PacketTap() + service._test_onOutboundPacket = tap.record + service.suspendForPanicReset() + defer { service.completePanicReset(restartServices: false) } + + let transferId = "panic-broadcast-\(UUID().uuidString)" + #expect(service._test_beginPrivateMediaAdmission(transferId, now: Date())) + let packet = BitchatPacket( + type: MessageType.noiseEncrypted.rawValue, + senderID: Data(hexString: service.myPeerID.id) ?? Data(), + recipientID: Data(hexString: "1122334455667788"), + timestamp: UInt64(Date().timeIntervalSince1970 * 1_000), + payload: Data([NoisePayloadType.privateFile.rawValue]), + signature: nil, + ttl: TransportConfig.messageTTLDefault + ) + + service._test_broadcastPrivateMediaPacket(packet, transferId: transferId) + + #expect(!service._test_isPrivateMediaAdmissionActive(transferId, now: Date())) + #expect(service._test_privateMediaAdmissionEntryCount() == 0) + #expect(tap.snapshot().isEmpty) + } + + @Test + func legacyFallbackRequiresPerSendConsentAndConsumesItOnce() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-capability-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let aliceRoot = root.appendingPathComponent("alice", isDirectory: true) + let bobRoot = root.appendingPathComponent("bob", isDirectory: true) + let alice = makeService(baseDirectory: aliceRoot) + let bob = makeService(baseDirectory: bobRoot) + alice._test_seedConnectedPeer( + bob.myPeerID, + nickname: "Old Bob", + noisePublicKey: bob.noiseStaticPublicKeyData() + ) + + let tap = PacketTap() + let delegate = MessageCaptureDelegate() + alice._test_onOutboundPacket = tap.record + bob.delegate = delegate + let content = Data("%PDF-1.7\nprivate".utf8) + let file = BitchatFilePacket( + fileName: "private.pdf", + fileSize: UInt64(content.count), + mimeType: "application/pdf", + content: content + ) + let cancellations = TransferCancellationRecorder() + let cancellable = TransferProgressManager.shared.publisher.sink { cancellations.record($0) } + + let deniedID = "legacy-without-consent-\(UUID().uuidString)" + alice.sendFilePrivate( + file, + to: bob.myPeerID, + transferId: deniedID + ) + let denied = await TestHelpers.waitUntil( + { cancellations.contains(deniedID) }, + timeout: TestConstants.longTimeout + ) + #expect(denied) + #expect(tap.snapshot().allSatisfy { $0.type != MessageType.fileTransfer.rawValue }) + + let allowedID = "legacy-with-consent-\(UUID().uuidString)" + alice.sendFilePrivate( + file, + to: bob.myPeerID, + transferId: allowedID, + allowLegacyFallback: true + ) + + let sent = await TestHelpers.waitUntil( + { tap.snapshot().contains { $0.type == MessageType.fileTransfer.rawValue } }, + timeout: TestConstants.longTimeout + ) + #expect(sent) + + let outbound = tap.snapshot() + let rawTransfers = outbound.filter { $0.type == MessageType.fileTransfer.rawValue } + let raw = try #require(rawTransfers.first) + #expect(rawTransfers.count == 1, "Migration fallback must never dual-send") + #expect(outbound.allSatisfy { $0.type != MessageType.noiseEncrypted.rawValue }) + #expect(raw.recipientID == Data(hexString: bob.myPeerID.toShort().id)) + #expect(raw.signature?.count == 64) + #expect(BitchatFilePacket.decode(raw.payload)?.content == content) + + // Exercise the normal raw receive path with Alice's actual signing + // key. The migration fallback is accepted because it is directed and + // signed; the handler still rejects unsigned/forged raw transfers. + bob._test_handlePacket( + raw, + fromPeerID: alice.myPeerID, + signingPublicKey: alice.noiseSigningPublicKeyData() + ) + let delivered = await TestHelpers.waitUntil( + { delegate.snapshot().count == 1 }, + timeout: TestConstants.longTimeout + ) + #expect(delivered) + #expect(delegate.snapshot().first?.isPrivate == true) + #expect(recursivelyStoredFiles(under: bobRoot).count == 1) + + // Consent is invocation-scoped, not a sticky peer preference. + let retryID = "legacy-retry-without-consent-\(UUID().uuidString)" + alice.sendFilePrivate(file, to: bob.myPeerID, transferId: retryID) + let retryDenied = await TestHelpers.waitUntil( + { cancellations.contains(retryID) }, + timeout: TestConstants.longTimeout + ) + #expect(retryDenied) + #expect(tap.snapshot().filter { $0.type == MessageType.fileTransfer.rawValue }.count == 1) + _ = cancellable + } + + @Test + func authenticatedPrivateMediaCapabilityPinsAgainstRawDowngrade() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-pin-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let identity = MockIdentityManager(MockKeychain()) + let alice = makeService( + baseDirectory: root.appendingPathComponent("alice", isDirectory: true), + identityManager: identity + ) + let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true)) + let bobKey = bob.noiseStaticPublicKeyData() + + alice._test_seedConnectedPeer( + bob.myPeerID, + nickname: "Bob", + capabilities: .privateMedia, + noisePublicKey: bobKey + ) + #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .awaitingCapabilityProof) + let bobFingerprint = bobKey.sha256Fingerprint() + #expect(!identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint)) + + try await establishSession(alice: alice, bob: bob) + let capabilityPinned = await TestHelpers.waitUntil( + { identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint) }, + timeout: TestConstants.longTimeout + ) + #expect(capabilityPinned) + + #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted) + + // A public no-bit announce cannot override state authenticated by the + // current session. A later authenticated no-bit state is a real + // downgrade and must block despite a caller offering legacy consent. + alice._test_seedConnectedPeer( + bob.myPeerID, + nickname: "Bob", + capabilities: [], + noisePublicKey: bobKey + ) + #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted) + let authenticatedNoBit = try authenticatedPeerStatePacket( + from: bob, + to: alice, + capabilities: [] + ) + alice._test_handlePacket(authenticatedNoBit, fromPeerID: bob.myPeerID) + let downgradeObserved = await TestHelpers.waitUntil( + { alice.privateMediaSendPolicy(to: bob.myPeerID) == .blockedDowngrade }, + timeout: TestConstants.longTimeout + ) + #expect(downgradeObserved) + #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .blockedDowngrade) + + let tap = PacketTap() + alice._test_onOutboundPacket = tap.record + let transferID = "pinned-downgrade-\(UUID().uuidString)" + let cancellations = TransferCancellationRecorder() + let cancellable = TransferProgressManager.shared.publisher.sink { cancellations.record($0) } + let content = Data("%PDF-1.7\nblocked".utf8) + alice.sendFilePrivate( + BitchatFilePacket( + fileName: "blocked.pdf", + fileSize: UInt64(content.count), + mimeType: "application/pdf", + content: content + ), + to: bob.myPeerID, + transferId: transferID, + allowLegacyFallback: true + ) + + let blocked = await TestHelpers.waitUntil( + { cancellations.contains(transferID) }, + timeout: TestConstants.longTimeout + ) + #expect(blocked) + #expect(tap.snapshot().allSatisfy { $0.type != MessageType.fileTransfer.rawValue }) + _ = cancellable + } + + @Test + func unpinnedExplicitCapabilitiesWithoutPrivateMediaRequireConsent() { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-explicit-capabilities-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let alice = makeService(baseDirectory: root.appendingPathComponent("alice", isDirectory: true)) + let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true)) + + alice._test_seedConnectedPeer( + bob.myPeerID, + nickname: "Modern Bob", + capabilities: [], + noisePublicKey: bob.noiseStaticPublicKeyData() + ) + + #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent) + } + + @Test + func privateMediaRetryRequiresExactAuthenticatedBit9Proof() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent( + "private-media-receipt-proof-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: root) } + let alice = makeService( + baseDirectory: root.appendingPathComponent( + "alice", + isDirectory: true + ) + ) + let bob = makeService( + baseDirectory: root.appendingPathComponent( + "bob", + isDirectory: true + ) + ) + let bothCapabilities: PeerCapabilities = [ + .privateMedia, + .privateMediaReceipts + ] + + // A public bit-9 announce is discovery only. + alice._test_seedConnectedPeer( + bob.myPeerID, + nickname: "Bob", + capabilities: bothCapabilities, + noisePublicKey: bob.noiseStaticPublicKeyData() + ) + #expect( + alice.authenticatedPrivateMediaReceiptSessionGeneration( + to: bob.myPeerID + ) == nil + ) + + let proofs = try await establishSessionCapturingPeerState( + alice: alice, + bob: bob + ) + #expect( + alice.authenticatedPrivateMediaReceiptSessionGeneration( + to: bob.myPeerID + ) == nil + ) + + // Bit 8 alone preserves encrypted transfer compatibility but cannot + // authorize automatic resend. + let privateMediaOnly = try authenticatedPeerStatePacket( + from: bob, + to: alice, + capabilities: .privateMedia + ) + alice._test_handlePacket( + privateMediaOnly, + fromPeerID: bob.myPeerID + ) + #expect(await TestHelpers.waitUntil( + { + alice.privateMediaSendPolicy(to: bob.myPeerID) + == .encrypted + }, + timeout: TestConstants.longTimeout + )) + #expect( + alice.authenticatedPrivateMediaReceiptSessionGeneration( + to: bob.myPeerID + ) == nil + ) + + let receiptCapable = try authenticatedPeerStatePacket( + from: bob, + to: alice, + capabilities: bothCapabilities + ) + alice._test_handlePacket( + receiptCapable, + fromPeerID: bob.myPeerID + ) + #expect(await TestHelpers.waitUntil( + { + alice.authenticatedPrivateMediaReceiptSessionGeneration( + to: bob.myPeerID + ) != nil + }, + timeout: TestConstants.longTimeout + )) + + bob._test_handlePacket( + proofs.alice, + fromPeerID: alice.myPeerID + ) + alice._test_onOutboundPacket = nil + bob._test_onOutboundPacket = nil + } + + @Test + func receiptRetryRechecksBit9AtDeferredTransportBoundary() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent( + "private-media-retry-proof-race-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: root) } + let alice = makeService( + baseDirectory: root.appendingPathComponent( + "alice", + isDirectory: true + ) + ) + let bob = makeService( + baseDirectory: root.appendingPathComponent( + "bob", + isDirectory: true + ) + ) + let receiptCapabilities: PeerCapabilities = [ + .privateMedia, + .privateMediaReceipts + ] + alice._test_seedConnectedPeer( + bob.myPeerID, + nickname: "Bob", + capabilities: receiptCapabilities, + noisePublicKey: bob.noiseStaticPublicKeyData() + ) + bob._test_seedConnectedPeer( + alice.myPeerID, + nickname: "Alice", + capabilities: receiptCapabilities, + noisePublicKey: alice.noiseStaticPublicKeyData() + ) + try await establishSession(alice: alice, bob: bob) + #expect( + alice.authenticatedPrivateMediaReceiptSessionGeneration( + to: bob.myPeerID + ) != nil + ) + + let privateMediaOnly = try authenticatedPeerStatePacket( + from: bob, + to: alice, + capabilities: .privateMedia + ) + let transferID = + "receipt-proof-race-\(UUID().uuidString)" + let tap = PacketTap() + let boundaryProofs = ReceiptCapabilityRecorder() + let rejections = TransferCancellationRecorder() + let cancellable = TransferProgressManager.shared.publisher.sink { + rejections.record($0) + } + alice._test_onOutboundPacket = tap.record + alice._test_beforePrivateMediaDeferredSend = { id in + guard id == transferID else { return } + boundaryProofs.record( + alice + .authenticatedPrivateMediaReceiptSessionGeneration( + to: bob.myPeerID + ) != nil + ) + } + defer { + alice._test_beforePrivateMediaDeferredSend = nil + alice._test_onOutboundPacket = nil + } + + // Rotate authenticated state before the deferred retry reaches its + // admission boundary. + alice._test_handlePacket( + privateMediaOnly, + fromPeerID: bob.myPeerID + ) + let content = Data("%PDF-1.7\nreceipt-proof-race".utf8) + alice.sendFilePrivateReceiptRetry( + BitchatFilePacket( + fileName: "receipt-proof-race.pdf", + fileSize: UInt64(content.count), + mimeType: "application/pdf", + content: content + ), + to: bob.myPeerID, + transferId: transferID + ) + #expect(await TestHelpers.waitUntil( + { boundaryProofs.snapshot() == [false] }, + timeout: TestConstants.longTimeout + )) + await alice._test_drainPrivateMediaSendPipeline() + + #expect(await TestHelpers.waitUntil( + { rejections.contains(transferID) }, + timeout: TestConstants.longTimeout + )) + #expect(tap.snapshot().allSatisfy { + $0.type != MessageType.fileTransfer.rawValue + && !( + $0.type == MessageType.noiseEncrypted.rawValue + && $0.version == 2 + ) + }) + let state = alice._test_privateMediaTransferState( + transferId: transferID + ) + #expect(!state.admissionActive) + #expect(!state.pendingNoise) + _ = cancellable + } + + @Test + func capabilityAnnounceCannotPoisonPinWithoutMatchingNoiseAuthentication() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-poisoning-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let identity = MockIdentityManager(MockKeychain()) + let alice = makeService( + baseDirectory: root.appendingPathComponent("alice", isDirectory: true), + identityManager: identity + ) + let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true)) + let bobFingerprint = bob.noiseStaticPublicKeyData().sha256Fingerprint() + let capableAnnounce = try signedAnnounce( + from: bob, + capabilities: .privateMedia + ) + alice._test_handlePacket( + capableAnnounce, + fromPeerID: bob.myPeerID, + preseedPeer: false + ) + let advertised = await TestHelpers.waitUntil( + { alice.privateMediaSendPolicy(to: bob.myPeerID) == .awaitingCapabilityProof }, + timeout: TestConstants.longTimeout + ) + #expect(advertised) + // The production signed-announce path ran, but with no authenticated + // session it must remain a no-op. Querying policy is side-effect free. + #expect(!identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint)) + + let noBitAnnounce = try signedAnnounce( + from: bob, + capabilities: [] + ) + alice._test_handlePacket( + noBitAnnounce, + fromPeerID: bob.myPeerID, + preseedPeer: false + ) + let remainedLegacyEligible = await TestHelpers.waitUntil( + { alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent }, + timeout: TestConstants.longTimeout + ) + #expect(remainedLegacyEligible) + } + + @Test + func copiedNoiseKeyPreannounceCannotPinWhenRealOwnerAuthenticates() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-copied-static-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let identity = MockIdentityManager(MockKeychain()) + let alice = makeService( + baseDirectory: root.appendingPathComponent("alice", isDirectory: true), + identityManager: identity + ) + let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true)) + let attacker = makeService(baseDirectory: root.appendingPathComponent("attacker", isDirectory: true)) + let bobKey = bob.noiseStaticPublicKeyData() + let bobFingerprint = bobKey.sha256Fingerprint() + + // Mallory copies Bob's public Noise key, advertises bit 8, supplies + // Mallory's Ed25519 key, and self-signs. This is internally consistent + // but does not prove possession of Bob's Noise private key. + let forged = try copiedStaticAnnounce( + claimedOwner: bob, + signedBy: attacker, + capabilities: .privateMedia + ) + alice._test_handlePacket(forged, fromPeerID: bob.myPeerID, preseedPeer: false) + let hintAccepted = await TestHelpers.waitUntil( + { alice.privateMediaSendPolicy(to: bob.myPeerID) == .awaitingCapabilityProof }, + timeout: TestConstants.longTimeout + ) + #expect(hintAccepted) + #expect(!identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint)) + + let proofs = try await establishSessionCapturingPeerState(alice: alice, bob: bob) + #expect(!identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint)) + #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .awaitingCapabilityProof) + + // Only Bob's encrypted state authorizes bit 8 and replaces the forged + // announcement signing key with Bob's Noise-authenticated Ed key. + alice._test_handlePacket(proofs.bob, fromPeerID: bob.myPeerID) + let pinned = await TestHelpers.waitUntil( + { identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint) }, + timeout: TestConstants.longTimeout + ) + #expect(pinned) + #expect(identity.authenticatedSigningPublicKey(forFingerprint: bobFingerprint) + == bob.noiseSigningPublicKeyData()) + #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted) + + bob._test_handlePacket(proofs.alice, fromPeerID: alice.myPeerID) + alice._test_onOutboundPacket = nil + bob._test_onOutboundPacket = nil + } + + @Test + func droppedInitiatorProofConvergesViaSingleAuthenticatedEcho() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-proof-echo-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let alice = makeService(baseDirectory: root.appendingPathComponent("alice", isDirectory: true)) + let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true)) + alice._test_seedConnectedPeer( + bob.myPeerID, + nickname: "Bob", + capabilities: .privateMedia, + noisePublicKey: bob.noiseStaticPublicKeyData() + ) + bob._test_seedConnectedPeer( + alice.myPeerID, + nickname: "Alice", + capabilities: .privateMedia, + noisePublicKey: alice.noiseStaticPublicKeyData() + ) + + let initial = try await establishSessionCapturingPeerState(alice: alice, bob: bob) + // Model Alice's first proof racing ahead of Bob's message-3 handling + // and being dropped. Bob's proof reaches Alice; Alice must emit one + // idempotent echo that lets Bob converge without a new handshake. + _ = initial.alice + let echoTap = PacketTap() + alice._test_onOutboundPacket = echoTap.record + alice._test_handlePacket(initial.bob, fromPeerID: bob.myPeerID) + let echoed = await TestHelpers.waitUntil( + { echoTap.snapshot().contains { $0.type == MessageType.noiseEncrypted.rawValue } }, + timeout: TestConstants.longTimeout + ) + #expect(echoed) + let echo = try #require( + echoTap.snapshot().first { $0.type == MessageType.noiseEncrypted.rawValue } + ) + bob._test_handlePacket(echo, fromPeerID: alice.myPeerID) + let converged = await TestHelpers.waitUntil( + { + alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted + && bob.privateMediaSendPolicy(to: alice.myPeerID) == .encrypted + }, + timeout: TestConstants.longTimeout + ) + #expect(converged) + alice._test_onOutboundPacket = nil + bob._test_onOutboundPacket = nil + } + + @Test + func noProofTimeoutResolvesToConsentWithoutSendingRawMedia() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-proof-timeout-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let alice = makeService(baseDirectory: root.appendingPathComponent("alice", isDirectory: true)) + let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true)) + alice._test_seedConnectedPeer( + bob.myPeerID, + nickname: "Prerelease Bob", + capabilities: .privateMedia, + noisePublicKey: bob.noiseStaticPublicKeyData() + ) + + _ = try await establishSessionCapturingPeerState(alice: alice, bob: bob) + #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .awaitingCapabilityProof) + let recorder = PrivateMediaPolicyRecorder() + alice.resolvePrivateMediaSendPolicy(to: bob.myPeerID) { recorder.record($0) } + let registered = await TestHelpers.waitUntil( + { alice._test_hasPendingPrivateMediaPolicyResolution(for: bob.myPeerID) }, + timeout: TestConstants.longTimeout + ) + #expect(registered) + alice._test_forcePrivateMediaProofTimeout(for: bob.myPeerID) + let resolved = await TestHelpers.waitUntil( + { recorder.snapshot() == .legacyRequiresConsent }, + timeout: TestConstants.longTimeout + ) + #expect(resolved) + #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent) + #expect(recorder.snapshot() != .encrypted) + alice._test_onOutboundPacket = nil + bob._test_onOutboundPacket = nil + } + + @Test + func panicDropsPendingPrivateMediaPolicyCompletion() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent( + "private-media-policy-panic-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: root) } + let alice = makeService( + baseDirectory: root.appendingPathComponent( + "alice", + isDirectory: true + ) + ) + let bob = makeService( + baseDirectory: root.appendingPathComponent( + "bob", + isDirectory: true + ) + ) + alice._test_seedConnectedPeer( + bob.myPeerID, + nickname: "Prerelease Bob", + capabilities: .privateMedia, + noisePublicKey: bob.noiseStaticPublicKeyData() + ) + + _ = try await establishSessionCapturingPeerState( + alice: alice, + bob: bob + ) + #expect( + alice.privateMediaSendPolicy(to: bob.myPeerID) + == .awaitingCapabilityProof + ) + let recorder = PrivateMediaPolicyRecorder() + alice.resolvePrivateMediaSendPolicy(to: bob.myPeerID) { + recorder.record($0) + } + let registered = await TestHelpers.waitUntil( + { + alice._test_hasPendingPrivateMediaPolicyResolution( + for: bob.myPeerID + ) + }, + timeout: TestConstants.longTimeout + ) + #expect(registered) + + alice.suspendForPanicReset() + alice.resetIdentityForPanic( + currentNickname: "anon", + restartServices: false + ) + alice._test_forcePrivateMediaProofTimeout(for: bob.myPeerID) + await Task.yield() + + #expect(recorder.snapshot() == nil) + #expect( + !alice._test_hasPendingPrivateMediaPolicyResolution( + for: bob.myPeerID + ) + ) + } + + @Test + func queuedPrivatePayloadWaitsForProofNotHandshakeCompletion() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-proof-drain-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let alice = makeService(baseDirectory: root.appendingPathComponent("alice", isDirectory: true)) + let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true)) + alice._test_seedConnectedPeer( + bob.myPeerID, + nickname: "Bob", + capabilities: .privateMedia, + noisePublicKey: bob.noiseStaticPublicKeyData() + ) + let proofs = try await establishSessionCapturingPeerState(alice: alice, bob: bob) + + let content = Data("proof-gated-private-file".utf8) + let file = BitchatFilePacket( + fileName: "proof.txt", + fileSize: UInt64(content.count), + mimeType: "text/plain", + content: content + ) + let payload = try #require(BLENoisePayloadFactory.privateFile(file)) + let transferID = "proof-gated-\(UUID().uuidString)" + alice._test_enqueuePendingNoisePayload(payload, transferId: transferID, for: bob.myPeerID) + alice._test_sendPendingNoisePayloadsAfterHandshake(for: bob.myPeerID) + + #expect(alice._test_privateMediaTransferState(transferId: transferID).pendingNoise) + alice._test_handlePacket(proofs.bob, fromPeerID: bob.myPeerID) + let drained = await TestHelpers.waitUntil( + { !alice._test_privateMediaTransferState(transferId: transferID).pendingNoise }, + timeout: TestConstants.longTimeout + ) + #expect(drained) + bob._test_handlePacket(proofs.alice, fromPeerID: alice.myPeerID) + alice._test_onOutboundPacket = nil + bob._test_onOutboundPacket = nil + } + + @Test + func authenticatedFingerprintMismatchCannotPoisonCapabilityPin() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-key-mismatch-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let identity = MockIdentityManager(MockKeychain()) + let alice = makeService( + baseDirectory: root.appendingPathComponent("alice", isDirectory: true), + identityManager: identity + ) + let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true)) + let impostor = makeService(baseDirectory: root.appendingPathComponent("impostor", isDirectory: true)) + let impostorKey = impostor.noiseStaticPublicKeyData() + let reconciliations = PeerIDRecorder() + alice._test_onPrivateMediaSessionReconciled = reconciliations.record + + alice._test_seedConnectedPeer( + bob.myPeerID, + nickname: "Bob", + capabilities: .privateMedia, + noisePublicKey: impostorKey + ) + try await establishSession(alice: alice, bob: bob) + + let sessionReconciled = await TestHelpers.waitUntil( + { reconciliations.contains(bob.myPeerID) }, + timeout: TestConstants.longTimeout + ) + #expect(sessionReconciled) + #expect(!identity.hasObservedPrivateMediaCapability( + fingerprint: impostorKey.sha256Fingerprint() + )) + #expect(identity.hasObservedPrivateMediaCapability( + fingerprint: bob.noiseStaticPublicKeyData().sha256Fingerprint() + )) + alice._test_seedConnectedPeer( + bob.myPeerID, + nickname: "Bob", + capabilities: [], + noisePublicKey: impostorKey + ) + // The exact live Noise identity remains authoritative over a later + // impostor registry rewrite. + #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted) + } + + @Test + func capabilityAnnounceAfterNoiseSessionStillRequiresEncryptedProof() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-race-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let identity = MockIdentityManager(MockKeychain()) + let alice = makeService( + baseDirectory: root.appendingPathComponent("alice", isDirectory: true), + identityManager: identity + ) + let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true)) + + let proofs = try await establishSessionCapturingPeerState(alice: alice, bob: bob) + let bobKey = bob.noiseStaticPublicKeyData() + let capableAnnounce = try signedAnnounce( + from: bob, + capabilities: .privateMedia + ) + alice._test_handlePacket( + capableAnnounce, + fromPeerID: bob.myPeerID, + preseedPeer: false + ) + + let announceDidNotPin = await TestHelpers.waitUntil( + { alice.privateMediaSendPolicy(to: bob.myPeerID) == .awaitingCapabilityProof }, + timeout: TestConstants.longTimeout + ) + #expect(announceDidNotPin) + #expect(!identity.hasObservedPrivateMediaCapability( + fingerprint: bobKey.sha256Fingerprint() + )) + + alice._test_handlePacket(proofs.bob, fromPeerID: bob.myPeerID) + + let pinned = await TestHelpers.waitUntil( + { + identity.hasObservedPrivateMediaCapability( + fingerprint: bobKey.sha256Fingerprint() + ) + }, + timeout: TestConstants.longTimeout + ) + #expect(pinned) + bob._test_handlePacket(proofs.alice, fromPeerID: alice.myPeerID) + alice._test_onOutboundPacket = nil + bob._test_onOutboundPacket = nil + } + + @Test + func consentedLegacySendRejectsAboveAndroidFragmentCapButEncryptedDoesNot() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-fragment-cap-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let alice = makeService(baseDirectory: root.appendingPathComponent("alice", isDirectory: true)) + let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true)) + let oldCarol = makeService(baseDirectory: root.appendingPathComponent("carol", isDirectory: true)) + + alice._test_seedConnectedPeer( + bob.myPeerID, + nickname: "Bob", + capabilities: .privateMedia, + noisePublicKey: bob.noiseStaticPublicKeyData() + ) + alice._test_seedConnectedPeer( + oldCarol.myPeerID, + nickname: "Old Carol", + noisePublicKey: oldCarol.noiseStaticPublicKeyData() + ) + try await establishSession(alice: alice, bob: bob) + + var state: UInt64 = 0x1234_5678_9ABC_DEF0 + let body = Data((0..<(130 * 1024)).map { _ in + state = state &* 6364136223846793005 &+ 1442695040888963407 + return UInt8(truncatingIfNeeded: state >> 32) + }) + let content = Data("%PDF-1.7\n".utf8) + body + let file = BitchatFilePacket( + fileName: "too-many-fragments.pdf", + fileSize: UInt64(content.count), + mimeType: "application/pdf", + content: content + ) + + let tap = PacketTap() + alice._test_onOutboundPacket = tap.record + let rejections = TransferCancellationRecorder() + let cancellable = TransferProgressManager.shared.publisher.sink { rejections.record($0) } + let encryptedID = "encrypted-over-256-\(UUID().uuidString)" + let legacyID = "legacy-over-256-\(UUID().uuidString)" + + alice.sendFilePrivate(file, to: bob.myPeerID, transferId: encryptedID) + alice.sendFilePrivate( + file, + to: oldCarol.myPeerID, + transferId: legacyID, + allowLegacyFallback: true + ) + + // The directed raw-file migration fallback (Android-style peer without + // the .privateMedia capability) still honors the 256-fragment ceiling. + let legacyRejected = await TestHelpers.waitUntil( + { rejections.contains(legacyID) }, + timeout: TestConstants.longTimeout + ) + #expect(legacyRejected) + #expect(rejections.reason(for: legacyID)?.contains("256") == true) + + // Encrypted private media to a .privateMedia-capable peer is NOT forced + // down to Android's 256 cap: it uses the full receiver ceiling and + // proceeds to fragment/emit (a 130 KiB file exceeds 256 fragments). + let encryptedEmitted = await TestHelpers.waitUntil( + { !tap.snapshot().isEmpty }, + timeout: TestConstants.longTimeout + ) + #expect(encryptedEmitted, "Encrypted send to a capable peer must not be blocked by the Android cap") + #expect(!rejections.contains(encryptedID)) + _ = cancellable + } + + @Test + func queuedPrivateEncryptionFailureRejectsBoundTransfer() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-queued-failure-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let alice = makeService(baseDirectory: root.appendingPathComponent("alice", isDirectory: true)) + let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true)) + try await establishSession(alice: alice, bob: bob) + + let transferID = "queued-encryption-failure-\(UUID().uuidString)" + let rejections = TransferCancellationRecorder() + let cancellable = TransferProgressManager.shared.publisher.sink { rejections.record($0) } + var oversizedTypedPayload = Data([NoisePayloadType.privateFile.rawValue]) + oversizedTypedPayload.append(Data( + repeating: 0x42, + count: NoiseSecurityConstants.maxPrivateFilePlaintextSize + )) + + alice._test_enqueuePendingNoisePayload( + oversizedTypedPayload, + transferId: transferID, + for: bob.myPeerID + ) + alice._test_sendPendingNoisePayloadsAfterHandshake(for: bob.myPeerID) + + let rejected = await TestHelpers.waitUntil( + { rejections.contains(transferID) }, + timeout: TestConstants.longTimeout + ) + #expect(rejected) + #expect(rejections.reason(for: transferID)?.isEmpty == false) + _ = cancellable + } + + @Test + func canonical0x20EncryptedFileIsAcceptedAcrossV1OuterPacket() async throws { + let content = Data("%PDF-1.7\nandroid-private".utf8) + try await assertInboundEncryptedPrivateMedia( + typeByte: 0x20, + content: content, + outerVersion: 1, + directoryLabel: "android-0x20" + ) + } + + @Test + func prerelease0x09LargeEncryptedFileIsAcceptedDuringMigration() async throws { + let content = Data("%PDF-1.7\nprerelease-private".utf8) + + Data(repeating: 0x39, count: 70 * 1024) + #expect(content.count > NoiseSecurityConstants.maxMessageSize) + try await assertInboundEncryptedPrivateMedia( + typeByte: NoisePayloadType.prereleasePrivateFileRawValue, + content: content, + outerVersion: 2, + directoryLabel: "prerelease-0x09" + ) + } + + @Test + func privateJPEGIsOpaqueBeforeFragmentationAndDelivers() async throws { + let marker = Data("JPEG_PRIVATE_MARKER_7f5e5eacb86f4b9a".utf8) + let content = Data([0xFF, 0xD8, 0xFF, 0xE0]) + + marker + + Data(repeating: 0x4A, count: 6 * 1024) + try await assertPrivateMediaRoundTrip( + fileName: "img_20260725_120000_11111111-1111-1111-1111-111111111111.jpg", + mimeType: "image/jpeg", + content: content, + marker: marker, + expectedMessagePrefix: "[image]" + ) + } + + @Test + func finalizedPrivateM4AIsOpaqueBeforeFragmentationAndDelivers() async throws { + let marker = Data("M4A_PRIVATE_MARKER_e0cd431b61fb4a6c".utf8) + let content = Data([0x00, 0x00, 0x00, 0x18]) + + Data("ftypM4A ".utf8) + + marker + + Data(repeating: 0x4D, count: 6 * 1024) + try await assertPrivateMediaRoundTrip( + fileName: "voice_0011223344556677.m4a", + mimeType: "audio/mp4", + content: content, + marker: marker, + expectedMessagePrefix: "[voice]" + ) + } + + @Test + func capablePeerUsesCanonicalAndroid0x20EncryptedSend() async throws { + let marker = Data("PDF_PRIVATE_MARKER_b333f84b8fc7478d".utf8) + let content = Data("%PDF-1.7\n".utf8) + + marker + + Data(repeating: 0x50, count: 6 * 1024) + let file = BitchatFilePacket( + fileName: "private.pdf", + fileSize: UInt64(content.count), + mimeType: "application/pdf", + content: content + ) + #expect(BLENoisePayloadFactory.privateFile(file)?.first == 0x20) + try await assertPrivateMediaRoundTrip( + fileName: "private.pdf", + mimeType: "application/pdf", + content: content, + marker: marker, + expectedMessagePrefix: "[file]" + ) + } + + @Test + func privateMediaAboveOrdinaryNoiseLimitUsesV2OuterPacketAndDelivers() async throws { + let marker = Data("LARGE_PRIVATE_MARKER_1ec63f261a7041ee".utf8) + let content = Data("%PDF-1.7\n".utf8) + + marker + + Data(repeating: 0x4C, count: 70 * 1024) + try await assertPrivateMediaRoundTrip( + fileName: "large-private.pdf", + mimeType: "application/pdf", + content: content, + marker: marker, + expectedMessagePrefix: "[file]", + expectedOuterVersion: 2 + ) + } + + /// Models an already-established remote sender independently of the local + /// send policy. Exact Android b7f0b33d plaintext bytes are frozen in + /// `BLENoisePayloadFactoryTests`; this helper exercises the encrypted + /// inbound transport around that shared wire encoding. + private func assertInboundEncryptedPrivateMedia( + typeByte: UInt8, + content: Data, + outerVersion: UInt8, + directoryLabel: String + ) async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-\(directoryLabel)-\(UUID().uuidString)", isDirectory: true) + let aliceRoot = root.appendingPathComponent("alice", isDirectory: true) + let bobRoot = root.appendingPathComponent("bob", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let alice = makeService(baseDirectory: aliceRoot) + let bob = makeService(baseDirectory: bobRoot) + let delegate = MessageCaptureDelegate() + bob.delegate = delegate + try await establishSession(alice: alice, bob: bob) + + let file = BitchatFilePacket( + fileName: "\(directoryLabel).pdf", + fileSize: UInt64(content.count), + mimeType: "application/pdf", + content: content + ) + let encodedFile = try #require(file.encode()) + var typedPayload = Data([typeByte]) + typedPayload.append(encodedFile) + + let encrypted = try alice._test_makeEncryptedNoisePacket(typedPayload, to: bob.myPeerID) + let remoteShapedPacket = BitchatPacket( + type: encrypted.type, + senderID: encrypted.senderID, + recipientID: encrypted.recipientID, + timestamp: encrypted.timestamp, + payload: encrypted.payload, + signature: nil, + ttl: encrypted.ttl, + version: outerVersion + ) + bob._test_handlePacket(remoteShapedPacket, fromPeerID: alice.myPeerID) + + let delivered = await TestHelpers.waitUntil( + { delegate.snapshot().count == 1 }, + timeout: TestConstants.longTimeout + ) + #expect(delivered) + #expect(delegate.snapshot().first?.isPrivate == true) + let stored = recursivelyStoredFiles(under: bobRoot) + #expect(stored.count == 1) + if let storedURL = stored.first { + #expect(try Data(contentsOf: storedURL) == content) + } + } + + private func assertApprovedLegacySendCancelledBeforeAdmission(label: String) async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-admission-\(label)-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let alice = makeService(baseDirectory: root.appendingPathComponent("alice", isDirectory: true)) + let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true)) + alice._test_seedConnectedPeer( + bob.myPeerID, + nickname: "Legacy Bob", + noisePublicKey: bob.noiseStaticPublicKeyData() + ) + + let transferId = "approved-\(label)-\(UUID().uuidString)" + let gate = PrivateMediaDeferredSendGate() + let tap = PacketTap() + alice._test_onOutboundPacket = tap.record + alice._test_beforePrivateMediaDeferredSend = { id in + guard id == transferId else { return } + gate.pause() + } + defer { + gate.release() + alice._test_beforePrivateMediaDeferredSend = nil + } + + let content = Data("%PDF-1.7\ncancelled-before-admission".utf8) + alice.sendFilePrivate( + BitchatFilePacket( + fileName: "cancelled.pdf", + fileSize: UInt64(content.count), + mimeType: "application/pdf", + content: content + ), + to: bob.myPeerID, + transferId: transferId, + allowLegacyFallback: true + ) + + let paused = await TestHelpers.waitUntil( + { gate.hasPaused }, + timeout: TestConstants.longTimeout + ) + #expect(paused) + + // This is the transport action used by both cancel and delete. It must + // invalidate synchronously while messageQueue is still held above. + alice.cancelTransfer(transferId) + gate.release() + await alice._test_drainPrivateMediaSendPipeline() + + let state = alice._test_privateMediaTransferState(transferId: transferId) + #expect(!state.admissionActive) + #expect(!state.pendingNoise) + #expect(state.activeScheduler == 0) + #expect(state.pendingScheduler == 0) + #expect(await TestHelpers.waitUntil( + { alice._test_privateMediaAdmissionEntryCount() == 0 }, + timeout: TestConstants.longTimeout + )) + #expect(tap.snapshot().allSatisfy { + $0.type != MessageType.fileTransfer.rawValue + && $0.type != MessageType.noiseEncrypted.rawValue + }) + } + + private func assertPrivateMediaRoundTrip( + fileName: String, + mimeType: String, + content: Data, + marker: Data, + expectedMessagePrefix: String, + expectedOuterVersion: UInt8 = 2 + ) async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-e2e-\(UUID().uuidString)", isDirectory: true) + let aliceRoot = root.appendingPathComponent("alice", isDirectory: true) + let bobRoot = root.appendingPathComponent("bob", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let alice = makeService(baseDirectory: aliceRoot) + let bob = makeService(baseDirectory: bobRoot) + let tap = PacketTap() + let delegate = MessageCaptureDelegate() + bob.delegate = delegate + + alice._test_seedConnectedPeer( + bob.myPeerID, + nickname: "Bob", + capabilities: .privateMedia, + noisePublicKey: bob.noiseStaticPublicKeyData() + ) + bob._test_seedConnectedPeer( + alice.myPeerID, + nickname: "Alice", + capabilities: .privateMedia, + noisePublicKey: alice.noiseStaticPublicKeyData() + ) + try await establishSession(alice: alice, bob: bob) + alice._test_onOutboundPacket = tap.record + + let file = BitchatFilePacket( + fileName: fileName, + fileSize: UInt64(content.count), + mimeType: mimeType, + content: content + ) + alice.sendFilePrivate(file, to: bob.myPeerID, transferId: "wire-\(UUID().uuidString)") + + let fragmented = await TestHelpers.waitUntil( + { tap.hasCompleteFragmentTrain }, + timeout: 10 + ) + #expect(fragmented) + + let outbound = tap.snapshot() + let encryptedPackets = outbound.filter { $0.type == MessageType.noiseEncrypted.rawValue } + let fragments = outbound + .filter { $0.type == MessageType.fragment.rawValue } + .sorted { fragmentIndex($0) < fragmentIndex($1) } + + #expect(encryptedPackets.count == 1) + #expect(encryptedPackets.first?.version == expectedOuterVersion) + #expect(!fragments.isEmpty) + #expect(outbound.allSatisfy { $0.type != MessageType.fileTransfer.rawValue }) + for packet in encryptedPackets + fragments { + #expect(packet.payload.range(of: marker) == nil) + #expect(packet.payload.range(of: content) == nil) + } + + // Real BLE delivers the train at the scheduler's paced interval. Feed + // bounded batches here instead of enqueuing hundreds of synthetic + // callbacks at once, which can exhaust libdispatch worker threads as + // they wait on the fragment-assembly barrier. + for batchStart in stride(from: 0, to: fragments.count, by: 16) { + let batchEnd = min(batchStart + 16, fragments.count) + for fragment in fragments[batchStart.. BLEService { + let keychain = MockKeychain() + return BLEService( + keychain: keychain, + idBridge: NostrIdentityBridge(keychain: MockKeychainHelper()), + identityManager: identityManager ?? MockIdentityManager(keychain), + initializeBluetoothManagers: false, + incomingFileStore: BLEIncomingFileStore(baseDirectory: baseDirectory) + ) + } + + private func establishSession(alice: BLEService, bob: BLEService) async throws { + let proofs = try await establishSessionCapturingPeerState(alice: alice, bob: bob) + bob._test_handlePacket(proofs.alice, fromPeerID: alice.myPeerID) + alice._test_handlePacket(proofs.bob, fromPeerID: bob.myPeerID) + // Fence the message/identity mutations without assuming either test + // seeded a registry entry (inbound-only tests intentionally do not). + await alice._test_drainNoiseMessagePipeline() + await bob._test_drainNoiseMessagePipeline() + alice._test_onOutboundPacket = nil + bob._test_onOutboundPacket = nil + } + + private func establishSessionCapturingPeerState( + alice: BLEService, + bob: BLEService + ) async throws -> (alice: BitchatPacket, bob: BitchatPacket) { + let aliceTap = PacketTap() + let bobTap = PacketTap() + alice._test_onOutboundPacket = aliceTap.record + bob._test_onOutboundPacket = bobTap.record + + let first = try alice._test_noiseInitiateHandshake(with: bob.myPeerID) + let second = try #require( + try bob._test_noiseProcessHandshakeMessage(from: alice.myPeerID, message: first) + ) + let third = try #require( + try alice._test_noiseProcessHandshakeMessage(from: bob.myPeerID, message: second) + ) + _ = try bob._test_noiseProcessHandshakeMessage(from: alice.myPeerID, message: third) + #expect(alice.canDeliverSecurely(to: bob.myPeerID)) + #expect(bob.canDeliverSecurely(to: alice.myPeerID)) + + let emitted = await TestHelpers.waitUntil( + { + aliceTap.snapshot().contains { $0.type == MessageType.noiseEncrypted.rawValue } + && bobTap.snapshot().contains { $0.type == MessageType.noiseEncrypted.rawValue } + }, + timeout: TestConstants.longTimeout + ) + #expect(emitted) + let aliceProof = try #require( + aliceTap.snapshot().first { $0.type == MessageType.noiseEncrypted.rawValue } + ) + let bobProof = try #require( + bobTap.snapshot().first { $0.type == MessageType.noiseEncrypted.rawValue } + ) + return (aliceProof, bobProof) + } + + private func authenticatedPeerStatePacket( + from sender: BLEService, + to recipient: BLEService, + capabilities: PeerCapabilities + ) throws -> BitchatPacket { + let state = AuthenticatedPeerStatePacket( + capabilities: capabilities, + signingPublicKey: sender.noiseSigningPublicKeyData() + ) + let typed = try #require(BLENoisePayloadFactory.authenticatedPeerState(state)) + return try sender._test_makeEncryptedNoisePacket(typed, to: recipient.myPeerID) + } + + private func signedAnnounce( + from service: BLEService, + capabilities: PeerCapabilities? + ) throws -> BitchatPacket { + let announcement = AnnouncementPacket( + nickname: "Bob", + noisePublicKey: service.noiseStaticPublicKeyData(), + signingPublicKey: service.noiseSigningPublicKeyData(), + directNeighbors: nil, + capabilities: capabilities + ) + let payload = try #require(announcement.encode()) + let unsigned = BitchatPacket( + type: MessageType.announce.rawValue, + senderID: Data(hexString: service.myPeerID.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1_000), + payload: payload, + signature: nil, + ttl: TransportConfig.messageTTLDefault + ) + return service.signPacketForBroadcast(unsigned) + } + + private func copiedStaticAnnounce( + claimedOwner: BLEService, + signedBy signer: BLEService, + capabilities: PeerCapabilities + ) throws -> BitchatPacket { + let announcement = AnnouncementPacket( + nickname: "Mallory-as-Bob", + noisePublicKey: claimedOwner.noiseStaticPublicKeyData(), + signingPublicKey: signer.noiseSigningPublicKeyData(), + directNeighbors: nil, + capabilities: capabilities + ) + let payload = try #require(announcement.encode()) + let unsigned = BitchatPacket( + type: MessageType.announce.rawValue, + senderID: Data(hexString: claimedOwner.myPeerID.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1_000), + payload: payload, + signature: nil, + // Relayed shape avoids the proactive direct-hint handshake in + // this deterministic test; it does not change signature validity. + ttl: TransportConfig.messageTTLDefault - 1 + ) + return signer.signPacketForBroadcast(unsigned) + } + + private func recursivelyStoredFiles(under root: URL) -> [URL] { + guard let enumerator = FileManager.default.enumerator( + at: root, + includingPropertiesForKeys: [.isRegularFileKey] + ) else { return [] } + + return enumerator.compactMap { item in + guard let url = item as? URL, + !url.pathComponents.contains(".private-media-receipts"), + url.lastPathComponent != ".private-media-receipts.json", + (try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true else { + return nil + } + return url + } + } +} + +private func fragmentIndex(_ packet: BitchatPacket) -> Int { + guard packet.payload.count >= 10 else { return .max } + return (Int(packet.payload[8]) << 8) | Int(packet.payload[9]) +} + +private final class PacketTap: @unchecked Sendable { + private let lock = NSLock() + private var packets: [BitchatPacket] = [] + + func record(_ packet: BitchatPacket) { + lock.lock() + packets.append(packet) + lock.unlock() + } + + func snapshot() -> [BitchatPacket] { + lock.lock() + defer { lock.unlock() } + return packets + } + + var hasCompleteFragmentTrain: Bool { + let fragments = snapshot().filter { $0.type == MessageType.fragment.rawValue } + guard let first = fragments.first, first.payload.count >= 12 else { return false } + let total = (Int(first.payload[10]) << 8) | Int(first.payload[11]) + return total > 0 && fragments.count >= total + } +} + +private final class PrivateMediaDeferredSendGate: @unchecked Sendable { + private let condition = NSCondition() + private var paused = false + private var released = false + + var hasPaused: Bool { + condition.lock() + defer { condition.unlock() } + return paused + } + + func pause() { + condition.lock() + paused = true + condition.broadcast() + while !released { + condition.wait() + } + condition.unlock() + } + + func release() { + condition.lock() + released = true + condition.broadcast() + condition.unlock() + } +} + +private final class PeerIDRecorder: @unchecked Sendable { + private let lock = NSLock() + private var peerIDs: [PeerID] = [] + + func record(_ peerID: PeerID) { + lock.lock() + peerIDs.append(peerID) + lock.unlock() + } + + func contains(_ peerID: PeerID) -> Bool { + lock.lock() + defer { lock.unlock() } + return peerIDs.contains(peerID) + } +} + +private final class PrivateMediaPolicyRecorder: @unchecked Sendable { + private let lock = NSLock() + private var policy: PrivateMediaSendPolicy? + + func record(_ policy: PrivateMediaSendPolicy) { + lock.lock() + self.policy = policy + lock.unlock() + } + + func snapshot() -> PrivateMediaSendPolicy? { + lock.lock() + defer { lock.unlock() } + return policy + } +} + +private final class ReceiptCapabilityRecorder: @unchecked Sendable { + private let lock = NSLock() + private var values: [Bool] = [] + + func record(_ value: Bool) { + lock.lock() + values.append(value) + lock.unlock() + } + + func snapshot() -> [Bool] { + lock.lock() + defer { lock.unlock() } + return values + } +} + +private final class MessageCaptureDelegate: BitchatDelegate, @unchecked Sendable { + private let lock = NSLock() + private var messages: [BitchatMessage] = [] + + func didReceiveMessage(_ message: BitchatMessage) { + lock.lock() + messages.append(message) + lock.unlock() + } + + func snapshot() -> [BitchatMessage] { + lock.lock() + defer { lock.unlock() } + return messages + } + + func didConnectToPeer(_ peerID: PeerID) {} + func didDisconnectFromPeer(_ peerID: PeerID) {} + func didUpdatePeerList(_ peers: [PeerID]) {} + func didUpdateBluetoothState(_ state: CBManagerState) {} +} + +private final class TransferCancellationRecorder: @unchecked Sendable { + private let lock = NSLock() + private var transferIDs: Set = [] + private var rejectionReasons: [String: String] = [:] + + func record(_ event: TransferProgressManager.Event) { + let id: String + switch event { + case .cancelled(let cancelledID, _, _): + id = cancelledID + case .rejected(let rejectedID, _): + id = rejectedID + case .started, .updated, .completed: + return + } + lock.lock() + transferIDs.insert(id) + if case .rejected(_, let reason) = event { + rejectionReasons[id] = reason + } + lock.unlock() + } + + func contains(_ transferID: String) -> Bool { + lock.lock() + defer { lock.unlock() } + return transferIDs.contains(transferID) + } + + + func reason(for transferID: String) -> String? { + lock.lock() + defer { lock.unlock() } + return rejectionReasons[transferID] + } +} diff --git a/bitchatTests/GeohashPresenceTests.swift b/bitchatTests/GeohashPresenceTests.swift index 96f19080..e0d43169 100644 --- a/bitchatTests/GeohashPresenceTests.swift +++ b/bitchatTests/GeohashPresenceTests.swift @@ -326,26 +326,11 @@ struct ChatViewModelPresenceHandlingTests { #expect(viewModel.geohashParticipantCount(for: activeGeohash) >= 1) } - @Test func subscribeNostrEvent_samplingInvalidSignatureDoesNotPoisonDedup() async throws { - let (viewModel, _) = makeTestableViewModel() - let sampleGeohash = "u4pru" - let identity = try NostrIdentity.generate() - let event = NostrEvent( - pubkey: identity.publicKeyHex, - createdAt: Date(), - kind: .geohashPresence, - tags: [["g", sampleGeohash]], - content: "" - ) - let signed = try event.sign(with: identity.schnorrSigningKey()) - var invalid = signed - invalid.sig = String(repeating: "0", count: 128) - - viewModel.subscribeNostrEvent(invalid, gh: sampleGeohash) - viewModel.subscribeNostrEvent(signed, gh: sampleGeohash) - - #expect(viewModel.geohashParticipantCount(for: sampleGeohash) == 1) - } + // NOTE: Tampered-signature rejection (and the forged-copy dedup-poisoning + // invariant) is enforced once, off the main actor, at the relay boundary — + // the sampling path only ever sees verified events. See + // NostrRelayManagerTests + // `test_receiveEvent_invalidSignatureDoesNotPoisonDuplicateCache`. // MARK: - Test Helper diff --git a/bitchatTests/GossipSyncManagerTests.swift b/bitchatTests/GossipSyncManagerTests.swift index 2d39ea88..e0f3580c 100644 --- a/bitchatTests/GossipSyncManagerTests.swift +++ b/bitchatTests/GossipSyncManagerTests.swift @@ -37,7 +37,7 @@ struct GossipSyncManagerTests { } manager.scheduleInitialSyncToPeer(PeerID(str: "FFFFFFFFFFFFFFFF"), delaySeconds: 0.0) - try await TestHelpers.waitFor({ delegate.lastPacket != nil }, timeout: TestConstants.shortTimeout) + try await TestHelpers.waitFor({ delegate.lastPacket != nil }, timeout: TestConstants.settleTimeout) } let lastPacket = try #require(delegate.lastPacket, "Expected sync packet to be sent") @@ -394,7 +394,7 @@ struct GossipSyncManagerTests { ) manager.handleRequestSync(from: peer, request: request) - try await TestHelpers.waitFor({ delegate.packets.count == 2 }, timeout: TestConstants.shortTimeout) + try await TestHelpers.waitFor({ delegate.packets.count == 2 }, timeout: TestConstants.settleTimeout) // Barrier: flush the sync queue so a late third packet would be visible. manager._performMaintenanceSynchronously(now: Date()) let sentPackets = delegate.packets @@ -477,7 +477,7 @@ struct GossipSyncManagerTests { manager.handleRequestSync(from: peer, request: request) manager.handleRequestSync(from: peer, request: request) - try await TestHelpers.waitFor({ delegate.packets.count >= 1 }, timeout: TestConstants.shortTimeout) + try await TestHelpers.waitFor({ delegate.packets.count >= 1 }, timeout: TestConstants.settleTimeout) // Barrier: both requests have been processed once this returns. manager._performMaintenanceSynchronously(now: Date()) #expect(delegate.packets.count == 1) @@ -498,7 +498,7 @@ struct GossipSyncManagerTests { manager.scheduleInitialSyncToPeer(PeerID(str: "FFFFFFFFFFFFFFFF"), delaySeconds: 0.0) - try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout) + try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout) let packet = try #require(delegate.packets.first) let request = try #require(RequestSyncPacket.decode(from: packet.payload)) let types = try #require(request.types) @@ -553,7 +553,7 @@ struct GossipSyncManagerTests { let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .fragment) manager.handleRequestSync(from: peer, request: request) - try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout) + try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout) let sentPackets = delegate.packets #expect(sentPackets.count == 1) #expect(sentPackets[0].type == MessageType.fragment.rawValue) @@ -615,7 +615,7 @@ struct GossipSyncManagerTests { ) manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request) - try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout) + try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout) // Barrier: flush the sync queue so a late second packet would be visible. manager._performMaintenanceSynchronously(now: Date()) let sentPackets = delegate.packets @@ -641,7 +641,7 @@ struct GossipSyncManagerTests { let stalledID = try #require(Data(hexString: "0102030405060708")) manager.requestMissingFragments(fragmentIDs: [stalledID]) - try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout) + try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout) let sent = try #require(delegate.packets.first) #expect(sent.type == MessageType.requestSync.rawValue) #expect(sent.ttl == 0) @@ -697,7 +697,7 @@ struct GossipSyncManagerTests { // And a .prekeyBundle sync request is answered with the stored packet. let request = RequestSyncPacket(p: 7, m: 1, data: Data(), types: .prekeyBundle) manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request) - try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout) + try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout) let served = try #require(delegate.packets.first) #expect(served.type == MessageType.prekeyBundle.rawValue) #expect(served.isRSR) @@ -774,7 +774,7 @@ struct GossipSyncManagerTests { ) let restored = await TestHelpers.waitUntil( { second._messageCount(for: PeerID(hexData: senderID)) == 1 }, - timeout: TestConstants.shortTimeout + timeout: TestConstants.settleTimeout ) #expect(restored) } @@ -810,6 +810,45 @@ struct GossipSyncManagerTests { #expect(manager._messageCount(for: PeerID(hexData: senderID)) == 0) } + /// Clearing the mesh timeline must leave nothing behind on disk: a + /// relaunch that restored the archive would undo the clear. + @Test func removeAllPublicMessagesErasesTheArchiveOnDisk() async throws { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("gossip-archive-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: fileURL) } + + let senderID = try #require(Data(hexString: "1122334455667788")) + let packet = BitchatPacket( + type: MessageType.message.rawValue, + senderID: senderID, + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: Data([0x01, 0x02]), + signature: nil, + ttl: 1 + ) + + let manager = GossipSyncManager( + myPeerID: myPeerID, + requestSyncManager: RequestSyncManager(), + archive: GossipMessageArchive(fileURL: fileURL) + ) + manager.onPublicPacketSeen(packet) + manager._performMaintenanceSynchronously(now: Date()) + #expect(FileManager.default.fileExists(atPath: fileURL.path)) + + manager.removeAllPublicMessages() + + let erased = await TestHelpers.waitUntil( + { + !FileManager.default.fileExists(atPath: fileURL.path) + && manager._messageCount(for: PeerID(hexData: senderID)) == 0 + }, + timeout: TestConstants.settleTimeout + ) + #expect(erased) + } + } private final class RecordingDelegate: GossipSyncManager.Delegate { diff --git a/bitchatTests/Integration/IntegrationTests.swift b/bitchatTests/Integration/IntegrationTests.swift index 11ba1602..e07f0fae 100644 --- a/bitchatTests/Integration/IntegrationTests.swift +++ b/bitchatTests/Integration/IntegrationTests.swift @@ -12,6 +12,7 @@ import Testing @testable import BitFoundation // to avoid unnecessary public's @testable import bitchat +@Suite("Integration Tests", .serialized) struct IntegrationTests { private var helper = TestNetworkHelper() @@ -272,8 +273,18 @@ struct IntegrationTests { // Re-establish Noise handshake explicitly via managers do { let m1 = try helper.noiseManagers["Bob"]!.initiateHandshake(with: helper.nodes["Alice"]!.peerID) - let m2 = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m1)! - let m3 = try helper.noiseManagers["Bob"]!.handleIncomingHandshake(from: helper.nodes["Alice"]!.peerID, message: m2)! + let m2 = try #require( + try helper.noiseManagers["Alice"]!.handleIncomingHandshake( + from: helper.nodes["Bob"]!.peerID, + message: m1 + ) + ) + let m3 = try #require( + try helper.noiseManagers["Bob"]!.handleIncomingHandshake( + from: helper.nodes["Alice"]!.peerID, + message: m2 + ) + ) _ = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m3) } catch { Issue.record("Failed to re-establish Noise session after restart: \(error)") diff --git a/bitchatTests/Integration/TestNetworkHelper.swift b/bitchatTests/Integration/TestNetworkHelper.swift index d9e0b3a9..0131739c 100644 --- a/bitchatTests/Integration/TestNetworkHelper.swift +++ b/bitchatTests/Integration/TestNetworkHelper.swift @@ -8,6 +8,7 @@ import Foundation import CryptoKit +import Testing @testable import BitFoundation // to avoid unnecessary public's @testable import bitchat @@ -27,9 +28,14 @@ final class TestNetworkHelper { node.mockNickname = name nodes[name] = node - // Create/replace Noise manager for this node + // This synchronous helper directly drives all three XX messages and + // has no transport callback loop for delayed collision recovery. let key = Curve25519.KeyAgreement.PrivateKey() - noiseManagers[name] = NoiseSessionManager(localStaticKey: key, keychain: mockKeychain) + noiseManagers[name] = NoiseSessionManager( + localStaticKey: key, + keychain: mockKeychain, + recentInitiatorCompletionGracePeriod: 0 + ) return node } @@ -108,8 +114,18 @@ final class TestNetworkHelper { let peer2ID = nodes[node2]?.peerID else { return } let msg1 = try manager1.initiateHandshake(with: peer2ID) - let msg2 = try manager2.handleIncomingHandshake(from: peer1ID, message: msg1)! - let msg3 = try manager1.handleIncomingHandshake(from: peer2ID, message: msg2)! + let msg2 = try #require( + try manager2.handleIncomingHandshake( + from: peer1ID, + message: msg1 + ) + ) + let msg3 = try #require( + try manager1.handleIncomingHandshake( + from: peer2ID, + message: msg2 + ) + ) _ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3) } } diff --git a/bitchatTests/Mocks/MockIdentityManager.swift b/bitchatTests/Mocks/MockIdentityManager.swift index a3603017..930d57ff 100644 --- a/bitchatTests/Mocks/MockIdentityManager.swift +++ b/bitchatTests/Mocks/MockIdentityManager.swift @@ -14,6 +14,8 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol { private var blockedFingerprints: Set = [] private var blockedNostrPubkeys: Set = [] private var socialIdentities: [String: SocialIdentity] = [:] + private var privateMediaCapableFingerprints: Set = [] + private var authenticatedSigningKeys: [String: Data] = [:] init(_: KeychainManagerProtocol) {} @@ -87,7 +89,10 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol { func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState) {} - func clearAllIdentityData() {} + func clearAllIdentityData() { + privateMediaCapableFingerprints.removeAll() + authenticatedSigningKeys.removeAll() + } func removeEphemeralSession(peerID: PeerID) {} @@ -101,6 +106,22 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol { Set() } + func markPrivateMediaCapable(fingerprint: String) { + privateMediaCapableFingerprints.insert(fingerprint) + } + + func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool { + privateMediaCapableFingerprints.contains(fingerprint) + } + + func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String) { + authenticatedSigningKeys[fingerprint] = signingPublicKey + } + + func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data? { + authenticatedSigningKeys[fingerprint] + } + // MARK: Vouching (transitive verification) private var vouchesByVouchee: [String: [VouchRecord]] = [:] diff --git a/bitchatTests/Mocks/MockKeychain.swift b/bitchatTests/Mocks/MockKeychain.swift index c12f2109..d5e99f1d 100644 --- a/bitchatTests/Mocks/MockKeychain.swift +++ b/bitchatTests/Mocks/MockKeychain.swift @@ -18,6 +18,8 @@ final class MockKeychain: KeychainManagerProtocol { var simulatedReadError: KeychainReadResult? var simulatedSaveError: KeychainSaveResult? var simulatedGenericReadError: KeychainReadResult? + var simulatedDeleteAllResult = true + private(set) var deleteAllCallCount = 0 func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { storage[key] = keyData @@ -34,6 +36,8 @@ final class MockKeychain: KeychainManagerProtocol { } func deleteAllKeychainData() -> Bool { + deleteAllCallCount += 1 + guard simulatedDeleteAllResult else { return false } storage.removeAll() serviceStorage.removeAll() return true diff --git a/bitchatTests/Mocks/MockTransport.swift b/bitchatTests/Mocks/MockTransport.swift index ad01a1c4..4620408f 100644 --- a/bitchatTests/Mocks/MockTransport.swift +++ b/bitchatTests/Mocks/MockTransport.swift @@ -14,7 +14,7 @@ import BitFoundation /// Mock Transport implementation for testing ChatViewModel in isolation. /// Records all method calls and allows test code to verify interactions. -final class MockTransport: Transport { +final class MockTransport: Transport, PrivateMediaDeletionPersisting { // MARK: - Protocol Properties @@ -36,7 +36,13 @@ final class MockTransport: Transport { private(set) var sentFavoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = [] private(set) var sentBroadcastFiles: [(packet: BitchatFilePacket, transferID: String)] = [] private(set) var sentPrivateFiles: [(packet: BitchatFilePacket, peerID: PeerID, transferID: String)] = [] + private(set) var sentPrivateFileLegacyAllowances: [Bool] = [] private(set) var cancelledTransfers: [String] = [] + private(set) var deletedPrivateMediaMessageIDBatches: [[String]] = [] + private(set) var deletedPrivateMediaRelativePaths: [ + [String: String] + ] = [] + private(set) var protectedPrivateMediaRelativePaths: [Set] = [] private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = [] private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = [] private(set) var sentCourierMessages: [(content: String, messageID: String, recipientNoiseKey: Data, couriers: [PeerID])] = [] @@ -58,6 +64,16 @@ final class MockTransport: Transport { var peerNicknames: [PeerID: String] = [:] var peerFingerprints: [PeerID: String] = [:] var peerNoiseStates: [PeerID: LazyHandshakeState] = [:] + var privateMediaPolicies: [PeerID: PrivateMediaSendPolicy] = [:] + var privateMediaReceiptSessionGenerations: [PeerID: UUID] = [:] + var persistDeletedPrivateMediaResult = true + var deferDeletedPrivateMediaPersistence = false + private var pendingDeletedPrivateMediaCompletions: [ + @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() // MARK: - Transport Protocol Implementation @@ -162,6 +178,11 @@ final class MockTransport: Transport { func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { sentPrivateMessages.append((content, peerID, recipientNickname, messageID)) + if let onSendPrivateMessage { + MainActor.assumeIsolated { + onSendPrivateMessage(messageID) + } + } } func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { @@ -186,12 +207,81 @@ final class MockTransport: Transport { func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) { sentPrivateFiles.append((packet, peerID, transferId)) + sentPrivateFileLegacyAllowances.append(false) + } + + func sendFilePrivate( + _ packet: BitchatFilePacket, + to peerID: PeerID, + transferId: String, + allowLegacyFallback: Bool + ) { + sentPrivateFiles.append((packet, peerID, transferId)) + sentPrivateFileLegacyAllowances.append(allowLegacyFallback) + } + + func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { + privateMediaPolicies[peerID] ?? .encrypted + } + + func authenticatedPrivateMediaReceiptSessionGeneration( + to peerID: PeerID + ) -> UUID? { + privateMediaReceiptSessionGenerations[peerID] + } + + func resolvePrivateMediaSendPolicy( + to peerID: PeerID, + completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void + ) { + let policy = privateMediaPolicies[peerID] ?? .encrypted + Task { @MainActor in completion(policy) } } func cancelTransfer(_ transferId: String) { cancelledTransfers.append(transferId) } + @MainActor + func persistDeletedPrivateMedia( + messageIDs: [String], + payloadRelativePaths: [String: String], + protectedPayloadRelativePaths: Set, + completion: @escaping @MainActor (Bool) -> Void + ) { + deletedPrivateMediaMessageIDBatches.append(messageIDs) + deletedPrivateMediaRelativePaths.append(payloadRelativePaths) + protectedPrivateMediaRelativePaths.append( + protectedPayloadRelativePaths + ) + if deferDeletedPrivateMediaPersistence { + pendingDeletedPrivateMediaCompletions.append(completion) + } else { + completion(persistDeletedPrivateMediaResult) + } + } + + @MainActor + func resolveNextDeletedPrivateMediaPersistence( + _ result: Bool? = nil + ) { + guard !pendingDeletedPrivateMediaCompletions.isEmpty else { return } + let completion = pendingDeletedPrivateMediaCompletions.removeFirst() + completion(result ?? persistDeletedPrivateMediaResult) + } + + /// 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) { sentVerifyChallenges.append((peerID, noiseKeyHex, nonceA)) } @@ -239,6 +329,9 @@ final class MockTransport: Transport { sentBroadcastFiles.removeAll() sentPrivateFiles.removeAll() cancelledTransfers.removeAll() + deletedPrivateMediaMessageIDBatches.removeAll() + deletedPrivateMediaRelativePaths.removeAll() + protectedPrivateMediaRelativePaths.removeAll() sentVerifyChallenges.removeAll() sentVerifyResponses.removeAll() startServicesCallCount = 0 diff --git a/bitchatTests/NearbyNotesCounterTests.swift b/bitchatTests/NearbyNotesCounterTests.swift index b1036778..5e328197 100644 --- a/bitchatTests/NearbyNotesCounterTests.swift +++ b/bitchatTests/NearbyNotesCounterTests.swift @@ -8,25 +8,9 @@ import XCTest /// the pooled subscription must come up exactly once and go down exactly once. @MainActor final class NearbyNotesCounterTests: XCTestCase { - private var previousNotesEnabled: Any? - - override func setUp() { - super.setUp() - previousNotesEnabled = UserDefaults.standard.object(forKey: "locationNotes.enabled") - UserDefaults.standard.set(true, forKey: "locationNotes.enabled") - } - - override func tearDown() { - if let previous = previousNotesEnabled as? Bool { - UserDefaults.standard.set(previous, forKey: "locationNotes.enabled") - } else { - UserDefaults.standard.removeObject(forKey: "locationNotes.enabled") - } - super.tearDown() - } - func test_counterOnlySubscribesAfterReveal_countsUnexpiredNotes_andUnsubscribesOnDeactivate() async throws { let relays = SubscriptionRecorder() + let settings = LocationNotesSettingsStub() let locationManager = try await makeAuthorizedLocationManager() let buildingGeohash = try XCTUnwrap( locationManager.availableChannels.first(where: { $0.level == .building })?.geohash @@ -35,7 +19,9 @@ final class NearbyNotesCounterTests: XCTestCase { let counter = NearbyNotesCounter( locationManager: locationManager, managerFactory: { LocationNotesManager(geohash: $0, dependencies: relays.dependencies) }, - releaseManager: { $0?.cancel() } + releaseManager: { $0?.cancel() }, + locationNotesEnabled: { settings.enabled }, + locationNotesSettings: settings.changes ) counter.activate() @@ -93,11 +79,14 @@ final class NearbyNotesCounterTests: XCTestCase { func test_permissionRevocation_releasesBuildingSubscriptionDespiteCachedChannels() async throws { let relays = SubscriptionRecorder() + let settings = LocationNotesSettingsStub() let locationManager = try await makeAuthorizedLocationManager() let counter = NearbyNotesCounter( locationManager: locationManager, managerFactory: { LocationNotesManager(geohash: $0, dependencies: relays.dependencies) }, - releaseManager: { $0?.cancel() } + releaseManager: { $0?.cancel() }, + locationNotesEnabled: { settings.enabled }, + locationNotesSettings: settings.changes ) counter.activate() @@ -122,24 +111,27 @@ final class NearbyNotesCounterTests: XCTestCase { func test_locationNotesKillSwitch_releasesAndCanReacquireBuildingSubscription() async throws { let relays = SubscriptionRecorder() + let settings = LocationNotesSettingsStub() let locationManager = try await makeAuthorizedLocationManager() let counter = NearbyNotesCounter( locationManager: locationManager, managerFactory: { LocationNotesManager(geohash: $0, dependencies: relays.dependencies) }, - releaseManager: { $0?.cancel() } + releaseManager: { $0?.cancel() }, + locationNotesEnabled: { settings.enabled }, + locationNotesSettings: settings.changes ) counter.activate() counter.reveal() XCTAssertEqual(relays.subscribeCount, 1) - LocationNotesSettings.enabled = false + settings.setEnabled(false) let released = await waitUntil { relays.unsubscribeCount == 1 } XCTAssertTrue(released) XCTAssertEqual(counter.noteCount, 0) - LocationNotesSettings.enabled = true + settings.setEnabled(true) let reacquired = await waitUntil { relays.subscribeCount == 2 } XCTAssertTrue(reacquired) @@ -149,10 +141,13 @@ final class NearbyNotesCounterTests: XCTestCase { func test_checkNotesHint_requiresAuthorizedLocationPermission() { let relays = SubscriptionRecorder() + let settings = LocationNotesSettingsStub() let counter = NearbyNotesCounter( locationManager: makeBareLocationManager(), managerFactory: { LocationNotesManager(geohash: $0, dependencies: relays.dependencies) }, - releaseManager: { $0?.cancel() } + releaseManager: { $0?.cancel() }, + locationNotesEnabled: { settings.enabled }, + locationNotesSettings: settings.changes ) // An unauthorized install must never see the hint: the tap can't @@ -164,9 +159,9 @@ final class NearbyNotesCounterTests: XCTestCase { XCTAssertTrue(counter.offersRevealHint(permissionState: .authorized)) // The app-info kill switch hides it too. - LocationNotesSettings.enabled = false + settings.setEnabled(false) XCTAssertFalse(counter.offersRevealHint(permissionState: .authorized)) - LocationNotesSettings.enabled = true + settings.setEnabled(true) // Once revealed, the hint yields to the live strip and count. counter.reveal() @@ -397,7 +392,7 @@ final class NearbyNotesCounterTests: XCTestCase { } private func waitUntil( - timeout: TimeInterval = 1.0, + timeout: TimeInterval = TestConstants.settleTimeout, condition: @escaping @MainActor () -> Bool ) async -> Bool { let deadline = Date().addingTimeInterval(timeout) @@ -411,6 +406,21 @@ final class NearbyNotesCounterTests: XCTestCase { } } +@MainActor +private final class LocationNotesSettingsStub { + private let changesSubject = PassthroughSubject() + private(set) var enabled = true + + var changes: AnyPublisher { + changesSubject.eraseToAnyPublisher() + } + + func setEnabled(_ enabled: Bool) { + self.enabled = enabled + changesSubject.send(()) + } +} + /// Stub relay layer: counts REQs, captures the last filter/handler, and never /// touches the network. @MainActor diff --git a/bitchatTests/Noise/NoiseCoverageTests.swift b/bitchatTests/Noise/NoiseCoverageTests.swift index ab3b8641..d083bd47 100644 --- a/bitchatTests/Noise/NoiseCoverageTests.swift +++ b/bitchatTests/Noise/NoiseCoverageTests.swift @@ -5,15 +5,22 @@ import BitFoundation @testable import bitchat -@Suite("Noise Coverage Tests") +@Suite("Noise Coverage Tests", .serialized) struct NoiseCoverageTests { private let keychain = MockKeychain() private let aliceStaticKey = Curve25519.KeyAgreement.PrivateKey() private let bobStaticKey = Curve25519.KeyAgreement.PrivateKey() private let charlieStaticKey = Curve25519.KeyAgreement.PrivateKey() - private let alicePeerID = PeerID(str: "0011223344556677") - private let bobPeerID = PeerID(str: "8899aabbccddeeff") + // Manager test dictionaries are keyed by the remote peer. Keep the + // historical names, but derive each wire ID from the static key that the + // corresponding manager authenticates during the handshake. + private var alicePeerID: PeerID { + PeerID(publicKey: bobStaticKey.publicKey.rawRepresentation) + } + private var bobPeerID: PeerID { + PeerID(publicKey: aliceStaticKey.publicKey.rawRepresentation) + } private let charliePeerID = PeerID(str: "fedcba9876543210") @Test("Protocol metadata and handshake patterns expose expected values") @@ -535,8 +542,12 @@ struct NoiseCoverageTests { let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain) let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain) - aliceManager.onSessionEstablished = establishedRecorder.recordEstablished(peerID:remoteKey:) - bobManager.onSessionEstablished = establishedRecorder.recordEstablished(peerID:remoteKey:) + aliceManager.onSessionEstablished = establishedRecorder.recordEstablished( + peerID:remoteKey:sessionGeneration: + ) + bobManager.onSessionEstablished = establishedRecorder.recordEstablished( + peerID:remoteKey:sessionGeneration: + ) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) @@ -622,8 +633,16 @@ struct NoiseCoverageTests { ) let replacementSession = try #require(manager.getSession(for: alicePeerID)) - #expect(replacementResponse != nil) - #expect(replacementSession !== restartedSession) + let localPeerID = PeerID( + publicKey: aliceStaticKey.publicKey.rawRepresentation + ) + if localPeerID < alicePeerID { + #expect(replacementResponse == nil) + #expect(replacementSession === restartedSession) + } else { + #expect(replacementResponse != nil) + #expect(replacementSession !== restartedSession) + } let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain) let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain) @@ -643,13 +662,132 @@ struct NoiseCoverageTests { try aliceManager.initiateHandshake(with: alicePeerID) } - try aliceManager.initiateRekey(for: alicePeerID) + let rekeyInitiation = try aliceManager.initiateRekey(for: alicePeerID) + let rekeyHandshake = try #require( + aliceManager.claimHandshakeInitiation( + rekeyInitiation, + for: alicePeerID + ) + ) + #expect(!rekeyHandshake.isEmpty) let rekeyedSession = try #require(aliceManager.getSession(for: alicePeerID)) #expect(rekeyedSession !== establishedSession) #expect(rekeyedSession.getState() == .handshaking) } + @Test("A stale decrypt generation cannot commit across session promotion") + func staleDecryptGenerationCannotCommitAcrossPromotion() throws { + let aliceManager = NoiseSessionManager( + localStaticKey: aliceStaticKey, + keychain: keychain, + recentInitiatorCompletionGracePeriod: 0, + sessionFactory: { peerID, role in + BlockingDecryptNoiseSession( + peerID: peerID, + role: role, + keychain: self.keychain, + localStaticKey: self.aliceStaticKey + ) + } + ) + let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain) + try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) + + let oldSession = try #require( + aliceManager.getSession(for: alicePeerID) as? BlockingDecryptNoiseSession + ) + let oldGeneration = try #require(aliceManager.sessionGeneration(for: alicePeerID)) + + // Prepare a fully authenticated responder candidate without promoting + // it yet. Its final XX message is the exact operation that replaces + // the old `sessions[peerID]` entry. + let replacementInitiator = NoiseSession( + peerID: bobPeerID, + role: .initiator, + keychain: keychain, + localStaticKey: bobStaticKey + ) + let message1 = try replacementInitiator.startHandshake() + let message2 = try #require( + try aliceManager.handleIncomingHandshake(from: alicePeerID, message: message1) + ) + let message3 = try #require(try replacementInitiator.processHandshakeMessage(message2)) + + let ciphertext = try bobManager.encrypt(Data("old session".utf8), for: bobPeerID) + oldSession.pauseNextDecrypt() + + let decryptResult = ConcurrentTestResult<(plaintext: Data, sessionGeneration: UUID)>() + var promotionResultForCleanup: ConcurrentTestResult? + defer { + // A failed startup requirement must not strand a late thread in + // the blocking test double after the test has returned. + oldSession.resumeDecrypt() + _ = decryptResult.wait(timeout: TestConstants.settleTimeout) + if let promotionResultForCleanup { + _ = promotionResultForCleanup.wait(timeout: TestConstants.settleTimeout) + } + } + + let decryptThread = Thread { + decryptResult.capture { + try aliceManager.decryptWithSessionGeneration(ciphertext, from: self.alicePeerID) + } + } + decryptThread.name = "NoiseCoverageTests.staleDecrypt.decrypt" + decryptThread.qualityOfService = .userInitiated + decryptThread.start() + try #require(oldSession.waitForDecryptStart(timeout: 5)) + + let promotionStarted = DispatchSemaphore(value: 0) + let promotionResult = ConcurrentTestResult() + promotionResultForCleanup = promotionResult + let promotionThread = Thread { + promotionStarted.signal() + promotionResult.capture { + try aliceManager.handleIncomingHandshake(from: self.alicePeerID, message: message3) + } + } + promotionThread.name = "NoiseCoverageTests.staleDecrypt.promote" + promotionThread.qualityOfService = .userInitiated + promotionThread.start() + try #require(promotionStarted.wait(timeout: .now() + TestConstants.settleTimeout) == .success) + #expect( + // test-timing-ok: a NEGATIVE wait — it asserts the promotion has + // NOT completed yet, so a long deadline would only make the suite + // slow while still passing. A starved runner can only make this + // more likely to hold, never less. + promotionResult.wait(timeout: 0.05) == nil, + "Promotion must wait for the exact decrypting-session lease" + ) + + oldSession.resumeDecrypt() + let decrypted = try #require(decryptResult.wait(timeout: TestConstants.settleTimeout)).get() + _ = try #require(promotionResult.wait(timeout: TestConstants.settleTimeout)).get() + + #expect(decrypted.plaintext == Data("old session".utf8)) + #expect(decrypted.sessionGeneration == oldGeneration) + #expect(aliceManager.sessionGeneration(for: alicePeerID) != oldGeneration) + #expect(throws: NoiseEncryptionError.sessionNotEstablished) { + try aliceManager.encrypt( + Data("stale send".utf8), + for: alicePeerID, + expectedSessionGeneration: oldGeneration + ) + } + + var staleCommitRan = false + let staleCommit = aliceManager.withCurrentSessionGeneration( + for: alicePeerID, + expected: decrypted.sessionGeneration + ) { + staleCommitRan = true + return true + } + #expect(staleCommit == nil) + #expect(!staleCommitRan) + } + @Test("Secure noise sessions enforce limits and renegotiation thresholds") func secureNoiseSessionsEnforceLimitsAndThresholds() throws { let initiator = SecureNoiseSession( @@ -844,7 +982,11 @@ private final class SessionCallbackRecorder: @unchecked Sendable { return establishedEntries.map(\.0) } - func recordEstablished(peerID: PeerID, remoteKey: Curve25519.KeyAgreement.PublicKey) { + func recordEstablished( + peerID: PeerID, + remoteKey: Curve25519.KeyAgreement.PublicKey, + sessionGeneration _: UUID + ) { lock.lock() establishedEntries.append((peerID, remoteKey.rawRepresentation)) lock.unlock() @@ -866,3 +1008,62 @@ private final class FailingNoiseSession: NoiseSession { throw Error.synthetic } } + +private final class BlockingDecryptNoiseSession: NoiseSession, @unchecked Sendable { + private let controlLock = NSLock() + private var shouldPauseNextDecrypt = false + private let decryptStarted = DispatchSemaphore(value: 0) + private let resumeDecryptSemaphore = DispatchSemaphore(value: 0) + + func pauseNextDecrypt() { + controlLock.lock() + shouldPauseNextDecrypt = true + controlLock.unlock() + } + + func waitForDecryptStart(timeout: TimeInterval) -> Bool { + decryptStarted.wait(timeout: .now() + timeout) == .success + } + + func resumeDecrypt() { + resumeDecryptSemaphore.signal() + } + + override func decrypt(_ ciphertext: Data) throws -> Data { + controlLock.lock() + let shouldPause = shouldPauseNextDecrypt + shouldPauseNextDecrypt = false + controlLock.unlock() + + if shouldPause { + decryptStarted.signal() + resumeDecryptSemaphore.wait() + } + return try super.decrypt(ciphertext) + } +} + +private final class ConcurrentTestResult: @unchecked Sendable { + private let lock = NSLock() + private let completed = DispatchGroup() + private var storedResult: Result? + + init() { + completed.enter() + } + + func capture(_ operation: () throws -> Value) { + let result = Result(catching: operation) + lock.lock() + storedResult = result + lock.unlock() + completed.leave() + } + + func wait(timeout: TimeInterval) -> Result? { + guard completed.wait(timeout: .now() + timeout) == .success else { return nil } + lock.lock() + defer { lock.unlock() } + return storedResult + } +} diff --git a/bitchatTests/Noise/NoiseProtocolTests.swift b/bitchatTests/Noise/NoiseProtocolTests.swift index f7e8e6be..ac8ef93d 100644 --- a/bitchatTests/Noise/NoiseProtocolTests.swift +++ b/bitchatTests/Noise/NoiseProtocolTests.swift @@ -357,8 +357,18 @@ struct NoiseProtocolTests { @Test func peerRestartDetection() throws { // Establish initial sessions - let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) - let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) + // This test explicitly drives the three synchronous XX messages and + // does not exercise the transport's delayed collision recovery. + let aliceManager = NoiseSessionManager( + localStaticKey: aliceKey, + keychain: mockKeychain, + recentInitiatorCompletionGracePeriod: 0 + ) + let bobManager = NoiseSessionManager( + localStaticKey: bobKey, + keychain: mockKeychain, + recentInitiatorCompletionGracePeriod: 0 + ) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) @@ -377,15 +387,24 @@ struct NoiseProtocolTests { let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: bobPeerID) // Alice should accept the new handshake (clearing old session) - let newHandshake2 = try aliceManager.handleIncomingHandshake( - from: alicePeerID, message: newHandshake1) - #expect(newHandshake2 != nil) + let newHandshake2 = try #require( + try aliceManager.handleIncomingHandshake( + from: alicePeerID, + message: newHandshake1 + ) + ) // Complete the new handshake - let newHandshake3 = try bobManagerRestarted.handleIncomingHandshake( - from: bobPeerID, message: newHandshake2!) - #expect(newHandshake3 != nil) - _ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake3!) + let newHandshake3 = try #require( + try bobManagerRestarted.handleIncomingHandshake( + from: bobPeerID, + message: newHandshake2 + ) + ) + _ = try aliceManager.handleIncomingHandshake( + from: alicePeerID, + message: newHandshake3 + ) // Should be able to exchange messages with new sessions let testMessage = Data("After restart".utf8) @@ -543,8 +562,18 @@ struct NoiseProtocolTests { @Test func nonceDesynchronizationCausesRehandshake() throws { // Test that nonce desynchronization leads to proper re-handshake - let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) - let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) + // This test explicitly drives the three synchronous XX messages and + // does not exercise the transport's delayed collision recovery. + let aliceManager = NoiseSessionManager( + localStaticKey: aliceKey, + keychain: mockKeychain, + recentInitiatorCompletionGracePeriod: 0 + ) + let bobManager = NoiseSessionManager( + localStaticKey: bobKey, + keychain: mockKeychain, + recentInitiatorCompletionGracePeriod: 0 + ) // Establish sessions try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) @@ -572,15 +601,25 @@ struct NoiseProtocolTests { let rehandshake1 = try bobManager.initiateHandshake(with: bobPeerID) // Alice should accept despite having a "valid" (but desynced) session - let rehandshake2 = try aliceManager.handleIncomingHandshake( - from: alicePeerID, message: rehandshake1) - #expect(rehandshake2 != nil, "Alice should accept handshake to fix desync") + let rehandshake2 = try #require( + try aliceManager.handleIncomingHandshake( + from: alicePeerID, + message: rehandshake1 + ), + "Alice should accept handshake to fix desync" + ) // Complete handshake - let rehandshake3 = try bobManager.handleIncomingHandshake( - from: bobPeerID, message: rehandshake2!) - #expect(rehandshake3 != nil) - _ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: rehandshake3!) + let rehandshake3 = try #require( + try bobManager.handleIncomingHandshake( + from: bobPeerID, + message: rehandshake2 + ) + ) + _ = try aliceManager.handleIncomingHandshake( + from: alicePeerID, + message: rehandshake3 + ) // Verify communication works again let testResynced = Data("Resynced".utf8) diff --git a/bitchatTests/Nostr/Fixtures/AndroidLegacyPrivateEnvelopeB7f0b33d.json b/bitchatTests/Nostr/Fixtures/AndroidLegacyPrivateEnvelopeB7f0b33d.json new file mode 100644 index 00000000..692739cf --- /dev/null +++ b/bitchatTests/Nostr/Fixtures/AndroidLegacyPrivateEnvelopeB7f0b33d.json @@ -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"} diff --git a/bitchatTests/Nostr/Fixtures/AndroidLegacyPrivateEnvelopeB7f0b33dGenerator.patch.txt b/bitchatTests/Nostr/Fixtures/AndroidLegacyPrivateEnvelopeB7f0b33dGenerator.patch.txt new file mode 100644 index 00000000..36f57ee7 --- /dev/null +++ b/bitchatTests/Nostr/Fixtures/AndroidLegacyPrivateEnvelopeB7f0b33dGenerator.patch.txt @@ -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() diff --git a/bitchatTests/Nostr/Fixtures/AndroidLegacyPrivateEnvelopeB7f0b33dMetadata.json b/bitchatTests/Nostr/Fixtures/AndroidLegacyPrivateEnvelopeB7f0b33dMetadata.json new file mode 100644 index 00000000..7538f946 --- /dev/null +++ b/bitchatTests/Nostr/Fixtures/AndroidLegacyPrivateEnvelopeB7f0b33dMetadata.json @@ -0,0 +1,10 @@ +{ + "android_commit": "b7f0b33d3a267c770d3d5a65ee2d8c7e755450db", + "generator": "NostrProtocol.createPrivateMessage", + "generator_patch": "AndroidLegacyPrivateEnvelopeB7f0b33dGenerator.patch.txt", + "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" +} diff --git a/bitchatTests/Nostr/Fixtures/LegacyPrivateEnvelope733098bb.json b/bitchatTests/Nostr/Fixtures/LegacyPrivateEnvelope733098bb.json new file mode 100644 index 00000000..21dc126a --- /dev/null +++ b/bitchatTests/Nostr/Fixtures/LegacyPrivateEnvelope733098bb.json @@ -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"]]} diff --git a/bitchatTests/Nostr/Fixtures/LegacyPrivateEnvelope733098bbRecipientKey.json b/bitchatTests/Nostr/Fixtures/LegacyPrivateEnvelope733098bbRecipientKey.json new file mode 100644 index 00000000..2b57bf90 --- /dev/null +++ b/bitchatTests/Nostr/Fixtures/LegacyPrivateEnvelope733098bbRecipientKey.json @@ -0,0 +1 @@ +{"recipient_private_key":"8355a5c110cdfef2e644f4ad5d51c39f253b2c2c80ebb6856379fb16531dc1fa"} diff --git a/bitchatTests/Nostr/GeoRelayDirectoryTests.swift b/bitchatTests/Nostr/GeoRelayDirectoryTests.swift index 516f4a1d..1a4ce385 100644 --- a/bitchatTests/Nostr/GeoRelayDirectoryTests.swift +++ b/bitchatTests/Nostr/GeoRelayDirectoryTests.swift @@ -5,19 +5,25 @@ import XCTest @MainActor final class GeoRelayDirectoryTests: XCTestCase { - func test_parseCSV_normalizesRelaySchemesAndDeduplicatesEntries() { + private func parse(_ csv: String) -> [GeoRelayDirectory.Entry] { + GeoRelayDirectory.validatedEntries( + from: Data(csv.utf8), + policy: .live, + minimumEntries: 1 + ) ?? [] + } + + func test_parseCSV_normalizesSecureRelaySchemesAndDeduplicatesEntries() { let csv = """ relay url,lat,lon wss://one.example/,10,20 https://one.example,10,20 wss://one.example:443/,10,20 - http://two.example/,11,21 + two.example,11,21 wss://two.example:443,11,21 - invalid row - ws://three.example,not-a-lat,22 """ - let parsed = Set(GeoRelayDirectory.parseCSV(csv)) + let parsed = Set(parse(csv)) XCTAssertEqual( parsed, @@ -28,6 +34,136 @@ final class GeoRelayDirectoryTests: XCTestCase { ) } + func test_parseCSV_rejectsWholeDatasetWhenAnyRowOrHeaderIsUnsafe() { + let invalidCSVs = [ + "relay,lat,lon\nrelay.example,1,2\n", + "relay url,lat,lon\nrelay.example,1\n", + "relay url,lat,lon\nhttp://relay.example,1,2\n", + "relay url,lat,lon\nwss://user@relay.example,1,2\n", + "relay url,lat,lon\nwss://relay.example/path,1,2\n", + "relay url,lat,lon\nwss://relay.example?,1,2\n", + "relay url,lat,lon\nwss://relay.example#,1,2\n", + "relay url,lat,lon\nrelay.example:0,1,2\n", + "relay url,lat,lon\nrelay.example:99999,1,2\n", + "relay url,lat,lon\nlocalhost,1,2\n", + "relay url,lat,lon\nr\u{00e9}lay.example,1,2\n", + "relay url,lat,lon\nrelay\u{202e}.example,1,2\n", + "relay url,lat,lon\nrelay.example,NaN,2\n", + "relay url,lat,lon\nrelay.example,1_0,2\n", + "relay url,lat,lon\nrelay.example,\u{0661}\u{0660},2\n", + "relay url,lat,lon\nrelay.example,\u{ff11}\u{ff10},2\n", + "relay url,lat,lon\nrelay.example,91,2\n", + "relay url,lat,lon\nrelay.example,1,181\n", + "relay url,lat,lon\nrelay.example,1,2\nrelay.example,3,4\n" + ] + + for csv in invalidCSVs { + XCTAssertTrue(parse(csv).isEmpty, csv) + } + } + + func test_validatedEntries_enforcesByteRowEntryAndRetentionLimits() { + let restrictive = GeoRelayDirectoryValidationPolicy( + maximumBytes: 100, + maximumRows: 2, + maximumEntries: 2, + minimumRemoteEntries: 1, + minimumRetainedFraction: 0.5 + ) + let one = Data("relay url,lat,lon\none.example,1,2\n".utf8) + let three = Data("relay url,lat,lon\none.example,1,2\ntwo.example,3,4\nthree.example,5,6\n".utf8) + + XCTAssertNil(GeoRelayDirectory.validatedEntries( + from: one, + policy: restrictive, + minimumEntries: 2 + )) + XCTAssertNil(GeoRelayDirectory.validatedEntries( + from: Data(repeating: 0x41, count: 101), + policy: restrictive, + minimumEntries: 1 + )) + XCTAssertNil(GeoRelayDirectory.validatedEntries( + from: three, + policy: restrictive, + minimumEntries: 1 + )) + } + + func test_validatedEntries_requiresExactBaselineEntryOverlap() throws { + let policy = GeoRelayDirectoryValidationPolicy( + maximumBytes: 1_000, + maximumRows: 10, + maximumEntries: 10, + minimumRemoteEntries: 1, + minimumRetainedFraction: 0.5 + ) + let baseline = Set(try XCTUnwrap(GeoRelayDirectory.validatedEntries( + from: Data(""" + relay url,lat,lon + one.example,1,1 + two.example,2,2 + three.example,3,3 + """.utf8), + policy: policy, + minimumEntries: 1 + ))) + let disjoint = Data(""" + relay url,lat,lon + four.example,1,1 + five.example,2,2 + six.example,3,3 + """.utf8) + let rewrittenCoordinates = Data(""" + relay url,lat,lon + one.example,11,11 + two.example,12,12 + three.example,13,13 + """.utf8) + let halfRetained = Data(""" + relay url,lat,lon + wss://one.example:443/,1,1 + https://two.example/,2,2 + replacement.example,4,4 + """.utf8) + + XCTAssertNil(GeoRelayDirectory.validatedEntries( + from: disjoint, + policy: policy, + minimumEntries: 1, + baselineEntries: baseline + )) + XCTAssertNil(GeoRelayDirectory.validatedEntries( + from: rewrittenCoordinates, + policy: policy, + minimumEntries: 1, + baselineEntries: baseline + )) + XCTAssertNotNil(GeoRelayDirectory.validatedEntries( + from: halfRetained, + policy: policy, + minimumEntries: 1, + baselineEntries: baseline + )) + } + + func test_bundledReviewedCSV_passesStrictProductionValidation() throws { + let repositoryRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let data = try Data( + contentsOf: repositoryRoot.appendingPathComponent("relays/online_relays_gps.csv") + ) + + let entries = try XCTUnwrap(GeoRelayDirectory.validatedEntries( + from: data, + policy: .live, + minimumEntries: GeoRelayDirectoryValidationPolicy.live.minimumRemoteEntries + )) + XCTAssertGreaterThan(entries.count, 250) + } + func test_closestRelays_sortsByDistanceForLatLonAndGeohash() { let harness = makeHarness( cacheCSV: """ @@ -154,11 +290,15 @@ final class GeoRelayDirectoryTests: XCTestCase { harness.userDefaults.set(harness.clock.now, forKey: "georelay.lastFetchAt") let directory = GeoRelayDirectory(dependencies: harness.dependencies) + let baselineRequestCount = await harness.fetcher.recordedRequestCount() directory.prefetchIfNeeded() + // Negative check: nothing should have been scheduled, so there is no + // condition to wait on. A modest delay gives a wrongly spawned fetch + // task a chance to run before we compare against the baseline. try? await Task.sleep(nanoseconds: 20_000_000) let requestCount = await harness.fetcher.recordedRequestCount() - XCTAssertEqual(requestCount, 0) + XCTAssertEqual(requestCount, baselineRequestCount) XCTAssertFalse(directory.debugHasRetryTask) } @@ -183,9 +323,11 @@ final class GeoRelayDirectoryTests: XCTestCase { XCTAssertFalse(directory.debugHasRetryTask) directory.prefetchIfNeeded(force: true) + // Negative check against the captured baseline: the forced refetch + // must be skipped, so there is no condition to wait on. try? await Task.sleep(nanoseconds: 20_000_000) let forcedRequestCount = await harness.fetcher.recordedRequestCount() - XCTAssertEqual(forcedRequestCount, 1) + XCTAssertEqual(forcedRequestCount, requestCount) } func test_prefetchIfNeeded_runsRemoteFetchOffMainThread() async { @@ -243,6 +385,53 @@ final class GeoRelayDirectoryTests: XCTestCase { XCTAssertFalse(directory.debugHasRetryTask) } + func test_prefetchIfNeeded_rejectsSharpValidLookingTruncationBeforeCaching() async { + let cached = """ + relay url,lat,lon + old-one.example,1,1 + old-two.example,2,2 + old-three.example,3,3 + """ + let truncated = """ + relay url,lat,lon + attacker.example,9,9 + """ + let recovered = """ + relay url,lat,lon + old-one.example,1,1 + old-two.example,2,2 + new-three.example,6,6 + """ + let harness = makeHarness( + cacheCSV: cached, + fetchResults: [ + .success(Data(truncated.utf8)), + .success(Data(recovered.utf8)) + ], + validationPolicy: GeoRelayDirectoryValidationPolicy( + maximumBytes: 64 * 1024, + maximumRows: 1_000, + maximumEntries: 1_000, + minimumRemoteEntries: 1, + minimumRetainedFraction: 0.5 + ) + ) + let directory = GeoRelayDirectory(dependencies: harness.dependencies) + + directory.prefetchIfNeeded() + + let refreshed = await waitUntil { + directory.entries.contains(where: { $0.host == "new-three.example" }) + } + XCTAssertTrue(refreshed) + XCTAssertFalse(directory.entries.contains(where: { $0.host == "attacker.example" })) + let requestCount = await harness.fetcher.recordedRequestCount() + let retryDelays = await harness.retryRecorder.recordedDelays() + XCTAssertEqual(requestCount, 2) + XCTAssertEqual(retryDelays, [5]) + XCTAssertEqual(harness.fileStore.dataByURL[harness.cacheURL], Data(recovered.utf8)) + } + func test_observers_triggerPrefetchesForTorReadyAndAppActivation() async { let activeNotification = Notification.Name("GeoRelayDirectoryTests.didBecomeActive") let harness = makeHarness( @@ -253,26 +442,43 @@ final class GeoRelayDirectoryTests: XCTestCase { autoStart: true, activeNotificationName: activeNotification ) - var directory: GeoRelayDirectory? = GeoRelayDirectory(dependencies: harness.dependencies) - let initialFetch = await waitUntil { - await harness.fetcher.recordedRequestCount() == 1 + // Wait on the refresh notification rather than the raw request count: + // the request count increments before the directory finishes handling + // the response on the main actor (resetting `isFetching`, recording + // `lastFetchAt`). Posting the next trigger inside that gap would get + // swallowed by the in-flight guard. The notification is posted at the + // end of the synchronous success handler, so once it fires the next + // trigger is guaranteed to be accepted. + var refreshCount = 0 + let refreshObserver = harness.notificationCenter.addObserver( + forName: .geoRelayDirectoryDidRefresh, + object: nil, + queue: .main + ) { _ in + refreshCount += 1 } + defer { harness.notificationCenter.removeObserver(refreshObserver) } + + var directory: GeoRelayDirectory? = GeoRelayDirectory(dependencies: harness.dependencies) + let initialFetch = await waitUntil { refreshCount == 1 } XCTAssertTrue(initialFetch) + var requestCount = await harness.fetcher.recordedRequestCount() + XCTAssertEqual(requestCount, 1) XCTAssertEqual(directory?.debugObserverCount, 2) harness.clock.now = harness.clock.now.addingTimeInterval(6) harness.notificationCenter.post(name: .TorDidBecomeReady, object: nil) - let torTriggered = await waitUntil { - await harness.fetcher.recordedRequestCount() == 2 - } + let torTriggered = await waitUntil { refreshCount == 2 } XCTAssertTrue(torTriggered) + requestCount = await harness.fetcher.recordedRequestCount() + XCTAssertEqual(requestCount, 2) harness.clock.now = harness.clock.now.addingTimeInterval(61) harness.notificationCenter.post(name: activeNotification, object: nil) - let activeTriggered = await waitUntil { - await harness.fetcher.recordedRequestCount() == 3 - } + let activeTriggered = await waitUntil { refreshCount == 3 } XCTAssertTrue(activeTriggered) + requestCount = await harness.fetcher.recordedRequestCount() + XCTAssertEqual(requestCount, 3) weak var weakDirectory: GeoRelayDirectory? weakDirectory = directory @@ -289,7 +495,14 @@ final class GeoRelayDirectoryTests: XCTestCase { fetchFactoryObserver: (@MainActor @Sendable () -> Void)? = nil, fetchObserver: (@Sendable () async -> Void)? = nil, 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 { let userDefaultsSuite = "GeoRelayDirectoryTests.\(UUID().uuidString)" let userDefaults = UserDefaults(suiteName: userDefaultsSuite)! @@ -347,7 +560,8 @@ final class GeoRelayDirectoryTests: XCTestCase { await retryRecorder.record(delay) }, activeNotificationName: activeNotificationName, - autoStart: autoStart + autoStart: autoStart, + validationPolicy: validationPolicy ) return GeoRelayHarness( @@ -362,8 +576,20 @@ 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. + /// Default deliberately far larger than the work being awaited. + /// + /// The directory performs its fetch in a `Task.detached(priority: .utility)`, + /// and utility priority competes with every other suite on a CI runner. At + /// ten seconds the retry-scheduling test timed out at exactly 10.06s with + /// the retry never scheduled — which reads like a missing retry rather than + /// a starved background task. Returning as soon as the condition holds means + /// a longer deadline only extends the genuine-failure case. private func waitUntil( - timeout: TimeInterval = 1.0, + timeout: TimeInterval = TestConstants.settleTimeout, condition: @escaping @MainActor () async -> Bool ) async -> Bool { let deadline = Date().addingTimeInterval(timeout) diff --git a/bitchatTests/Nostr/NostrRelaySettingsTests.swift b/bitchatTests/Nostr/NostrRelaySettingsTests.swift new file mode 100644 index 00000000..30c647ae --- /dev/null +++ b/bitchatTests/Nostr/NostrRelaySettingsTests.swift @@ -0,0 +1,130 @@ +import Foundation +import Testing +@testable import bitchat + +/// The built-in relay set is four well-known hostnames, so a filter blocking +/// four names ends internet-delivered private messages. These cover the +/// hand-added relays that make that recoverable without shipping a build. +struct NostrRelaySettingsTests { + /// Each case gets its own suite so nothing touches the real preferences or + /// races another case. + private func makeDefaults() -> UserDefaults { + let suite = "bitchat.tests.relays.\(UUID().uuidString)" + return UserDefaults(suiteName: suite)! + } + + private let builtIn: Set = [ + "wss://relay.damus.io", + "wss://nos.lol" + ] + + @Test func addNormalizesABareHostname() { + let defaults = makeDefaults() + + // A bare hostname is how relays are usually quoted; wss is the only + // sensible assumption. + let result = NostrRelaySettings.add("relay.example.com", builtIn: builtIn, in: defaults) + + #expect(result == .success("wss://relay.example.com")) + #expect(NostrRelaySettings.customRelays(in: defaults) == ["wss://relay.example.com"]) + } + + @Test func addAcceptsAnOnionAddress() { + let defaults = makeDefaults() + + // The reason this feature exists: an onion relay is not blockable by + // hostname or SNI filtering. + let result = NostrRelaySettings.add( + "wss://exampleonionaddressxyz234567.onion", + builtIn: builtIn, + in: defaults + ) + + #expect(result == .success("wss://exampleonionaddressxyz234567.onion")) + } + + @Test func addRejectsMalformedInput() { + let defaults = makeDefaults() + + #expect(NostrRelaySettings.add("", builtIn: builtIn, in: defaults) == .failure(.malformed)) + #expect(NostrRelaySettings.add(" ", builtIn: builtIn, in: defaults) == .failure(.malformed)) + // A scheme the relay layer cannot dial must not be stored. + #expect(NostrRelaySettings.add("ftp://relay.example.com", builtIn: builtIn, in: defaults) == .failure(.malformed)) + #expect(NostrRelaySettings.customRelays(in: defaults).isEmpty) + } + + @Test func addRejectsDuplicatesAndBuiltIns() { + let defaults = makeDefaults() + #expect(NostrRelaySettings.add("wss://relay.example.com", builtIn: builtIn, in: defaults) == .success("wss://relay.example.com")) + + // Same relay written differently still normalizes to the same URL. + #expect(NostrRelaySettings.add("relay.example.com", builtIn: builtIn, in: defaults) == .failure(.alreadyPresent)) + #expect(NostrRelaySettings.add("WSS://Relay.Example.com", builtIn: builtIn, in: defaults) == .failure(.alreadyPresent)) + // Re-adding a built-in would double-count it in the target list. + #expect(NostrRelaySettings.add("wss://nos.lol", builtIn: builtIn, in: defaults) == .failure(.alreadyPresent)) + + #expect(NostrRelaySettings.customRelays(in: defaults) == ["wss://relay.example.com"]) + } + + @Test func addStopsAtTheLimit() { + let defaults = makeDefaults() + for index in 0.. URL { + // Bundle.module only exists under SwiftPM; the Xcode test targets + // resolve resources through the test bundle (same pattern as + // NoiseProtocolTests' NoiseTestVectors.json loader). + #if SWIFT_PACKAGE + let bundle = Bundle.module + #else + let bundle = Bundle(for: MockKeychain.self) + #endif + return try #require(bundle.url(forResource: name, withExtension: fileExtension)) + } + private static func base64URLDecode(_ s: String) -> Data? { var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") let rem = str.count % 4 @@ -366,4 +585,15 @@ struct NostrProtocolTests { Issue.record("Expected NostrError.invalidEvent, got \(error)") } } + + private func expectInvalidCiphertext(_ operation: () throws -> Void) { + do { + try operation() + Issue.record("Expected NostrError.invalidCiphertext") + } catch NostrError.invalidCiphertext { + return + } catch { + Issue.record("Expected NostrError.invalidCiphertext, got \(error)") + } + } } diff --git a/bitchatTests/PTTBurstPlayerTests.swift b/bitchatTests/PTTBurstPlayerTests.swift index 00881f75..77d60a69 100644 --- a/bitchatTests/PTTBurstPlayerTests.swift +++ b/bitchatTests/PTTBurstPlayerTests.swift @@ -188,7 +188,7 @@ struct PTTBurstPlayerTests { _ condition: () -> Bool, sourceLocation: SourceLocation = #_sourceLocation ) async { - let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + let deadline = ContinuousClock.now.advanced(by: .seconds(TestConstants.settleTimeout)) while !condition(), ContinuousClock.now < deadline { await Task.yield() try? await Task.sleep(nanoseconds: 1_000_000) diff --git a/bitchatTests/Performance/PerformanceBaselineTests.swift b/bitchatTests/Performance/PerformanceBaselineTests.swift index 0cfb6f92..453a4a9a 100644 --- a/bitchatTests/Performance/PerformanceBaselineTests.swift +++ b/bitchatTests/Performance/PerformanceBaselineTests.swift @@ -77,8 +77,10 @@ final class PerformanceBaselineTests: XCTestCase { // MARK: - 1a. Nostr inbound event handling (fresh events) /// `NostrInboundPipeline.handleNostrEvent` for never-seen geo events - /// (kind 20000): signature verification, dedup record, presence/nickname - /// bookkeeping, and public-message ingest scheduling. + /// (kind 20000): dedup record, presence/nickname bookkeeping, and + /// public-message ingest scheduling. Schnorr signature verification is + /// NOT part of this path anymore — it runs exactly once, off the main + /// actor, in `NostrRelayManager` before delivery. func testNostrInboundEventHandling_freshEvents() throws { let events = try Self.makeSignedGeohashEvents(count: 500) // A fresh context per measure pass so every event takes the @@ -106,8 +108,9 @@ final class PerformanceBaselineTests: XCTestCase { /// The dedup-hit path: identical events replayed. Duplicates dominate /// real relay traffic (the same event arrives from several relays), so - /// this path runs hundreds of times a minute in busy geohashes. Note it - /// still pays full Schnorr signature verification before the dedup check. + /// this path runs hundreds of times a minute in busy geohashes. It is a + /// pure dedup lookup: no crypto (verification happens upstream in + /// `NostrRelayManager`, and only for the first-seen copy). func testNostrInboundEventHandling_duplicateEvents() throws { let events = try Self.makeSignedGeohashEvents(count: 500) let context = PerfNostrContext() @@ -501,6 +504,62 @@ final class PerformanceBaselineTests: XCTestCase { reportThroughput("store.append", samples: samples, operations: messageCount, unit: "messages") } + // MARK: - 7b. ConversationStore append at the retention cap + + /// Steady-state public timeline traffic after the 1337-message retention + /// cap has been reached. Every tail append evicts the oldest row, which is + /// the long-lived workload the cold `store.append` benchmark does not + /// exercise. + func testConversationStoreSteadyStateAppend() { + let store = ConversationStore() + let cap = TransportConfig.meshTimelineCap + let messagesPerPass = 500 + let base = Date(timeIntervalSince1970: 1_700_000_000) + + for i in 0..) {} + func confirmPrivateMediaDelivery(_ messageID: String) {} + func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set) -> Bool { + true + } @discardableResult func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool { store.setDeliveryStatus(status, forMessageID: messageID) } + @discardableResult + func setDeliveryStatus( + _ status: DeliveryStatus, + forMessageID messageID: String, + inDirectPeerAliases peerIDs: Set + ) -> Bool { + store.setDeliveryStatus( + status, + forMessageID: messageID, + inDirectPeerAliases: peerIDs + ) + } + func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? { store.deliveryStatus(forMessageID: messageID) } diff --git a/bitchatTests/Performance/perf-floors.json b/bitchatTests/Performance/perf-floors.json index 93abf125..751caf74 100644 --- a/bitchatTests/Performance/perf-floors.json +++ b/bitchatTests/Performance/perf-floors.json @@ -30,6 +30,10 @@ "store.append": 213201, "store.audit": 362 }, + "_reference_local_numbers_2026_07": { + "store.steadyStateAppend_before": 2315, + "store.steadyStateAppend": 53976 + }, "floors": { "nostrInbound.fresh": 450, "nostrInbound.duplicate": 250000, @@ -41,6 +45,7 @@ "pipeline.privateIngest": 3000, "pipeline.publicIngest": 2400, "store.append": 48000, + "store.steadyStateAppend": 10000, "store.audit": 70 }, "_slowest_observed_ci_numbers_2026_06": { @@ -56,4 +61,4 @@ "store.append": 97423, "store.audit": 140 } -} \ No newline at end of file +} diff --git a/bitchatTests/PreviewKeychainManagerTests.swift b/bitchatTests/PreviewKeychainManagerTests.swift index 3c110436..fffc7e25 100644 --- a/bitchatTests/PreviewKeychainManagerTests.swift +++ b/bitchatTests/PreviewKeychainManagerTests.swift @@ -1,10 +1,103 @@ import Foundation +import Security import Testing +import BitFoundation @testable import bitchat @Suite("PreviewKeychainManager Tests") struct PreviewKeychainManagerTests { + @Test("Install lifecycle distinguishes upgrade, reinstall, bootstrap, and unreadable keychain") + func installLifecycleDecision() { + #expect(KeychainManager.installLifecycleAction( + containerKnowsMarker: true, + markerRead: .success(Data([1])) + ) == .markerPresent) + #expect(KeychainManager.installLifecycleAction( + containerKnowsMarker: false, + markerRead: .success(Data([1])) + ) == .clearStaleKeys) + #expect(KeychainManager.installLifecycleAction( + containerKnowsMarker: false, + markerRead: .itemNotFound + ) == .bootstrapMarker) + #expect(KeychainManager.installLifecycleAction( + containerKnowsMarker: false, + markerRead: .deviceLocked + ) == .retryLater) + #expect(KeychainManager.installLifecycleAction( + containerKnowsMarker: false, + cleanupPending: true, + markerRead: .itemNotFound + ) == .clearStaleKeys) + } + + @Test("Accessibility migration covers custom services and retries after any incomplete update") + func accessibilityMigrationCoversEveryApplicationOwnedService() { + let primaryService = "chat.bitchat.test-primary" + var visitedServices: [String] = [] + + let completed = KeychainManager + .migrateAccessibilityForApplicationOwnedServices( + primaryService: primaryService + ) { service in + visitedServices.append(service) + return service == "chat.bitchat.favorites" + ? errSecInteractionNotAllowed + : errSecItemNotFound + } + + #expect(!completed) + #expect(visitedServices.first == primaryService) + #expect(Set(visitedServices).isSuperset(of: [ + "chat.bitchat.nostr", + "chat.bitchat.favorites", + "chat.bitchat.outbox" + ])) + #expect(Set(visitedServices).count == visitedServices.count) + + let retryCompleted = KeychainManager + .migrateAccessibilityForApplicationOwnedServices( + primaryService: primaryService + ) { _ in errSecSuccess } + #expect(retryCompleted) + } + + @Test("Keychain cleanup is complete only when every owned scope is clean") + func keychainCleanupRequiresEveryApplicationOwnedService() { + let primaryService = "chat.bitchat.test-primary" + var visitedServices: [String] = [] + + let partialCleanup = KeychainManager + .deleteApplicationOwnedKeychainServices( + primaryService: primaryService + ) { service in + visitedServices.append(service) + return service == "chat.bitchat.outbox" + ? errSecInteractionNotAllowed + : errSecSuccess + } + + #expect(!partialCleanup) + #expect(visitedServices.first == primaryService) + #expect(Set(visitedServices).isSuperset(of: [ + "chat.bitchat.nostr", + "chat.bitchat.favorites", + "chat.bitchat.outbox" + ])) + #expect(Set(visitedServices).count == visitedServices.count) + + let emptyCleanup = KeychainManager + .deleteApplicationOwnedKeychainServices( + primaryService: primaryService + ) { _ in errSecItemNotFound } + #expect(emptyCleanup) + #expect(KeychainManager.completedApplicationGroupDelete(status: -34018)) + #expect(!KeychainManager.completedApplicationGroupDelete( + status: errSecInteractionNotAllowed + )) + } + @Test("Preview keychain manager stores identity and service-scoped data in memory") func previewKeychainManagerRoundTripsData() { let manager = PreviewKeychainManager() @@ -51,4 +144,132 @@ struct PreviewKeychainManagerTests { Issue.record("Expected preview keychain to be empty after deleteAllKeychainData") } } + + @Test("Failed reinstall cleanup blocks stale data until a successful retry") + func failedReinstallCleanupBlocksEveryNamespaceUntilSuccessfulRetry() { + let gate = KeychainInstallAccessGate() + var cleanupCanComplete = false + var reconciliationAttempts = 0 + var manager: PreviewKeychainManager! + manager = PreviewKeychainManager( + installAccessGate: gate + ) { + reconciliationAttempts += 1 + guard cleanupCanComplete else { return false } + return manager.deleteAllKeychainData() + } + + let staleIdentity = Data([1, 2, 3]) + let staleFavorite = Data([4, 5, 6]) + let staleOutbox = Data([7, 8, 9]) + let staleCustom = Data([10, 11, 12]) + #expect(manager.saveIdentityKey( + staleIdentity, + forKey: "noiseStaticKey" + )) + #expect(manager.saveIdentityKey( + staleIdentity, + forKey: "identity_noiseStaticKey" + )) + #expect(manager.verifyIdentityKeyExists()) + manager.save( + key: "favorite", + data: staleFavorite, + service: "chat.bitchat.favorites", + accessible: nil + ) + manager.save( + key: "outbox", + data: staleOutbox, + service: "chat.bitchat.outbox", + accessible: nil + ) + manager.save( + key: "custom", + data: staleCustom, + service: "chat.bitchat.future-custom", + accessible: nil + ) + + gate.block() + + #expect(manager.getIdentityKey(forKey: "noiseStaticKey") == nil) + #expect(!manager.verifyIdentityKeyExists()) + if case .accessDenied = manager.getIdentityKeyWithResult( + forKey: "noiseStaticKey" + ) { + } else { + Issue.record("Expected blocked identity read to fail closed") + } + + for (key, service) in [ + ("favorite", "chat.bitchat.favorites"), + ("outbox", "chat.bitchat.outbox"), + ("custom", "chat.bitchat.future-custom") + ] { + #expect(manager.load(key: key, service: service) == nil) + if case .accessDenied = manager.loadWithResult( + key: key, + service: service + ) { + } else { + Issue.record( + "Expected blocked \(service) read to fail closed" + ) + } + } + + #expect(!manager.saveIdentityKey( + Data([13]), + forKey: "replacement" + )) + if case .accessDenied = manager.saveIdentityKeyWithResult( + Data([14]), + forKey: "replacement" + ) { + } else { + Issue.record("Expected blocked identity save to fail closed") + } + + let failedAttempts = reconciliationAttempts + #expect(failedAttempts > 0) + cleanupCanComplete = true + + // The first access retries cleanup synchronously. It must not return + // any surviving value from before the reinstall. + #expect(manager.getIdentityKey(forKey: "noiseStaticKey") == nil) + #expect(reconciliationAttempts == failedAttempts + 1) + #expect(manager.load( + key: "favorite", + service: "chat.bitchat.favorites" + ) == nil) + #expect(manager.load( + key: "outbox", + service: "chat.bitchat.outbox" + ) == nil) + #expect(manager.load( + key: "custom", + service: "chat.bitchat.future-custom" + ) == nil) + + let replacementIdentity = Data([21, 22, 23]) + let replacementCustom = Data([24, 25, 26]) + #expect(manager.saveIdentityKey( + replacementIdentity, + forKey: "noiseStaticKey" + )) + #expect(manager.getIdentityKey( + forKey: "noiseStaticKey" + ) == replacementIdentity) + manager.save( + key: "custom", + data: replacementCustom, + service: "chat.bitchat.future-custom", + accessible: nil + ) + #expect(manager.load( + key: "custom", + service: "chat.bitchat.future-custom" + ) == replacementCustom) + } } diff --git a/bitchatTests/Protocols/BitchatFilePacketTests.swift b/bitchatTests/Protocols/BitchatFilePacketTests.swift index 849eda47..2476647f 100644 --- a/bitchatTests/Protocols/BitchatFilePacketTests.swift +++ b/bitchatTests/Protocols/BitchatFilePacketTests.swift @@ -1,3 +1,4 @@ +import BitFoundation import XCTest @testable import bitchat @@ -73,4 +74,85 @@ final class BitchatFilePacketTests: XCTestCase { XCTAssertEqual(decoded.fileSize, UInt64(content.count)) XCTAssertEqual(decoded.content, content) } + + func testPrivateMediaMessageIdentityConvergesAcrossPeerIDAliases() throws { + let senderKey = Data(repeating: 0x11, count: 32) + let recipientKey = Data(repeating: 0x22, count: 32) + let senderStable = PeerID(hexData: senderKey) + let recipientStable = PeerID(hexData: recipientKey) + let fileName = "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg" + + let senderID = try XCTUnwrap(PrivateMediaMessageIdentity.stableID( + senderPeerID: senderStable.toShort(), + recipientPeerID: PeerID(str: "mesh:\(recipientStable.toShort().bare)"), + fileName: fileName + )) + let receiverID = try XCTUnwrap(PrivateMediaMessageIdentity.stableID( + senderPeerID: senderStable, + recipientPeerID: recipientStable.toShort(), + fileName: fileName + )) + + XCTAssertEqual(senderID, receiverID) + XCTAssertTrue(senderID.hasPrefix("media-")) + XCTAssertEqual(senderID.count, 38) + XCTAssertTrue(PrivateMediaMessageIdentity.isStableID(senderID)) + XCTAssertFalse(PrivateMediaMessageIdentity.isStableID("media-\(String(repeating: "A", count: 32))")) + XCTAssertFalse(PrivateMediaMessageIdentity.isStableID("media-\(String(repeating: "a", count: 31))")) + XCTAssertFalse(PrivateMediaMessageIdentity.isStableID(UUID().uuidString)) + } + + func testPrivateMediaMessageIdentitySeparatesDirectionAndFilename() throws { + let alice = PeerID(str: "0011223344556677") + let bob = PeerID(str: "8899aabbccddeeff") + let firstName = "voice_20260725_105708_11111111-1111-1111-1111-111111111111.m4a" + let secondName = "voice_20260725_105709_22222222-2222-2222-2222-222222222222.m4a" + let first = try XCTUnwrap(PrivateMediaMessageIdentity.stableID( + senderPeerID: alice, + recipientPeerID: bob, + fileName: firstName + )) + + XCTAssertNotEqual(first, PrivateMediaMessageIdentity.stableID( + senderPeerID: bob, + recipientPeerID: alice, + fileName: firstName + )) + XCTAssertNotEqual(first, PrivateMediaMessageIdentity.stableID( + senderPeerID: alice, + recipientPeerID: bob, + fileName: secondName + )) + XCTAssertNil(PrivateMediaMessageIdentity.stableID( + senderPeerID: alice, + recipientPeerID: bob, + fileName: nil + )) + XCTAssertNil(PrivateMediaMessageIdentity.stableID( + senderPeerID: alice, + recipientPeerID: bob, + fileName: "photo.jpg" + )) + XCTAssertNil(PrivateMediaMessageIdentity.stableID( + senderPeerID: alice, + recipientPeerID: bob, + fileName: "img_11111111-1111-1111-1111-111111111111.pdf" + )) + XCTAssertNotNil(PrivateMediaMessageIdentity.stableID( + senderPeerID: alice, + recipientPeerID: bob, + fileName: "voice_0011223344556677.m4a" + )) + } + + func testPrivateMediaMessageIdentityMatchesVersionOneGoldenVector() { + XCTAssertEqual( + PrivateMediaMessageIdentity.stableID( + senderPeerID: PeerID(str: "0011223344556677"), + recipientPeerID: PeerID(str: "8899aabbccddeeff"), + fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg" + ), + "media-910bd42c65060ab76bb6406f220c4516" + ) + } } diff --git a/bitchatTests/Protocols/PacketsTests.swift b/bitchatTests/Protocols/PacketsTests.swift index 2368925a..c837ee1d 100644 --- a/bitchatTests/Protocols/PacketsTests.swift +++ b/bitchatTests/Protocols/PacketsTests.swift @@ -145,6 +145,39 @@ struct PacketsTests { #expect(decoded.capabilities?.rawValue == 0x0180) } + @Test + func authenticatedPeerStateUsesVersionedCanonicalTLVs() throws { + let signingKey = Data(repeating: 0xA5, count: 32) + let packet = AuthenticatedPeerStatePacket( + capabilities: [.privateMedia, .vouch], + signingPublicKey: signingKey + ) + + var encoded = try #require(packet.encode()) + #expect(encoded.prefix(5) == Data([0x01, 0x01, 0x02, 0x20, 0x01])) + // Unknown TLVs are forward-compatible and do not alter v1 state. + encoded.append(makeTLV(type: 0x7F, value: Data([0xCA, 0xFE]))) + + #expect(AuthenticatedPeerStatePacket.decode(from: encoded) == packet) + } + + @Test + func authenticatedPeerStateRejectsMalformedAmbiguousOrUnknownVersion() { + let key = Data(repeating: 0x44, count: 32) + let capabilities = makeTLV(type: 0x01, value: Data([0x00, 0x01])) + let signing = makeTLV(type: 0x02, value: key) + + #expect(AuthenticatedPeerStatePacket.decode(from: Data([0x02]) + capabilities + signing) == nil) + #expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + signing) == nil) + #expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + capabilities + capabilities + signing) == nil) + #expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01, 0x01, 0x00]) + signing) == nil) + // 0x0001 is non-minimal little endian; the canonical form is [0x01]. + #expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + makeTLV(type: 0x01, value: Data([0x01, 0x00])) + signing) == nil) + #expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + capabilities + makeTLV(type: 0x02, value: Data(key.dropLast()))) == nil) + #expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + capabilities + Data(signing.dropLast())) == nil) + #expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + makeTLV(type: 0x01, value: Data(repeating: 0x01, count: 9)) + signing) == nil) + } + @Test func privateMessagePacketRejectsUnknownTypeAndTruncation() { let unknownTLV = Data([0x7F, 0x01, 0x41]) diff --git a/bitchatTests/Services/BLEAnnounceHandlerTests.swift b/bitchatTests/Services/BLEAnnounceHandlerTests.swift index 9623573a..d1589303 100644 --- a/bitchatTests/Services/BLEAnnounceHandlerTests.swift +++ b/bitchatTests/Services/BLEAnnounceHandlerTests.swift @@ -6,10 +6,14 @@ import Testing struct BLEAnnounceHandlerTests { private final class Recorder { var existingNoisePublicKey: Data? + var existingSigningPublicKey: Data? + var persistedSigningPublicKey: Data? + var persistedSigningKeyQueries: [PeerID] = [] + var authenticatedSigningPublicKey: Data? var signatureValid = true var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false) var linkBoundToOtherPeer = false - var upsertResult = BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil) + var upsertResult: BLEPeerAnnounceUpdate? = BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil) var dedupSeenIDs: Set = [] var shouldEmitReconnectLogResult = true @@ -36,7 +40,12 @@ struct BLEAnnounceHandlerTests { localPeerID: { localPeerID }, messageTTL: TransportConfig.messageTTLDefault, now: { now }, - existingNoisePublicKey: { _ in recorder.existingNoisePublicKey }, + existingPeerKeys: { _ in (recorder.existingNoisePublicKey, recorder.existingSigningPublicKey) }, + persistedSigningPublicKey: { peerID in + recorder.persistedSigningKeyQueries.append(peerID) + return recorder.persistedSigningPublicKey + }, + authenticatedSigningPublicKey: { _ in recorder.authenticatedSigningPublicKey }, verifySignature: { packet, signingPublicKey in recorder.verifySignatureCalls.append((packet, signingPublicKey)) return recorder.signatureValid @@ -482,6 +491,168 @@ struct BLEAnnounceHandlerTests { #expect(recorder.topologyUpdates.first?.neighbors == neighbors) } + @Test + func matchingPinnedSigningKeyIsAccepted() throws { + let now = Date(timeIntervalSince1970: 1_000) + let noiseKey = Data(repeating: 0x9A, count: 32) + let peerID = PeerID(publicKey: noiseKey) + let packet = try makeAnnouncePacket( + noisePublicKey: noiseKey, + peerID: peerID, + timestamp: timestamp(now), + signature: Data(repeating: 0xEE, count: 64) + ) + + let recorder = Recorder() + recorder.existingNoisePublicKey = noiseKey + // Matches the signing key encoded by makeAnnouncePacket. + recorder.existingSigningPublicKey = Data(repeating: 0x99, count: 32) + let handler = makeHandler(recorder: recorder, now: now) + + handler.handle(packet, from: peerID) + + #expect(recorder.upsertCalls.count == 1) + #expect(recorder.persistedIdentities.count == 1) + } + + @Test + func signingKeyMismatchWithPinnedKeySkipsUpsertAndIdentityPersistence() throws { + let now = Date(timeIntervalSince1970: 1_000) + let noiseKey = Data(repeating: 0x9B, count: 32) + let peerID = PeerID(publicKey: noiseKey) + // Attacker announce: victim's noiseKey/peerID, attacker's signing key + // (0x99 from makeAnnouncePacket) with a "valid" self-signature. + let packet = try makeAnnouncePacket( + noisePublicKey: noiseKey, + peerID: peerID, + timestamp: timestamp(now), + signature: Data(repeating: 0xEE, count: 64) + ) + + let recorder = Recorder() + recorder.existingNoisePublicKey = noiseKey + recorder.existingSigningPublicKey = Data(repeating: 0x42, count: 32) // victim's pinned key + recorder.signatureValid = true + let handler = makeHandler(recorder: recorder, now: now) + + handler.handle(packet, from: peerID) + + #expect(recorder.upsertCalls.isEmpty) + #expect(recorder.persistedIdentities.isEmpty) + #expect(recorder.topologyUpdates.isEmpty) + #expect(recorder.uiEventDeliveries.count == 1) + #expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false) + } + + @Test + func persistedSigningKeyMismatchWithoutRegistryEntryIsRejected() throws { + // Registry has no entry (app restart or offline-peer eviction), but + // the persisted cryptographic identity still pins the victim's + // signing key. An attacker replaying the victim's noiseKey/peerID + // with their own signing key must not be treated as first contact. + let now = Date(timeIntervalSince1970: 1_000) + let noiseKey = Data(repeating: 0x9D, count: 32) + let peerID = PeerID(publicKey: noiseKey) + let packet = try makeAnnouncePacket( + noisePublicKey: noiseKey, + peerID: peerID, + timestamp: timestamp(now), + signature: Data(repeating: 0xEE, count: 64) + ) + + let recorder = Recorder() + recorder.existingNoisePublicKey = nil + recorder.existingSigningPublicKey = nil + recorder.persistedSigningPublicKey = Data(repeating: 0x42, count: 32) // victim's persisted pin + let handler = makeHandler(recorder: recorder, now: now) + + handler.handle(packet, from: peerID) + + #expect(recorder.persistedSigningKeyQueries == [peerID]) + #expect(recorder.upsertCalls.isEmpty) + #expect(recorder.persistedIdentities.isEmpty) + #expect(recorder.topologyUpdates.isEmpty) + #expect(recorder.uiEventDeliveries.count == 1) + #expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false) + } + + @Test + func persistedSigningKeyMatchWithoutRegistryEntryIsAccepted() throws { + // Legitimate returning peer: registry entry evicted, persisted pin + // matches the announced signing key — accepted like a normal announce. + let now = Date(timeIntervalSince1970: 1_000) + let noiseKey = Data(repeating: 0x9E, count: 32) + let peerID = PeerID(publicKey: noiseKey) + let packet = try makeAnnouncePacket( + noisePublicKey: noiseKey, + peerID: peerID, + timestamp: timestamp(now), + signature: Data(repeating: 0xEE, count: 64) + ) + + let recorder = Recorder() + // Matches the signing key encoded by makeAnnouncePacket. + recorder.persistedSigningPublicKey = Data(repeating: 0x99, count: 32) + let handler = makeHandler(recorder: recorder, now: now) + + handler.handle(packet, from: peerID) + + #expect(recorder.upsertCalls.count == 1) + #expect(recorder.persistedIdentities.count == 1) + } + + @Test + func registryPinnedSigningKeySkipsPersistedLookup() throws { + let now = Date(timeIntervalSince1970: 1_000) + let noiseKey = Data(repeating: 0x9F, count: 32) + let peerID = PeerID(publicKey: noiseKey) + let packet = try makeAnnouncePacket( + noisePublicKey: noiseKey, + peerID: peerID, + timestamp: timestamp(now), + signature: Data(repeating: 0xEE, count: 64) + ) + + let recorder = Recorder() + recorder.existingNoisePublicKey = noiseKey + recorder.existingSigningPublicKey = Data(repeating: 0x99, count: 32) + let handler = makeHandler(recorder: recorder, now: now) + + handler.handle(packet, from: peerID) + + #expect(recorder.persistedSigningKeyQueries.isEmpty) + #expect(recorder.upsertCalls.count == 1) + } + + @Test + func registryPinRejectionSkipsTopologyAndIdentityPersistence() throws { + let now = Date(timeIntervalSince1970: 1_000) + let noiseKey = Data(repeating: 0x9C, count: 32) + let peerID = PeerID(publicKey: noiseKey) + let packet = try makeAnnouncePacket( + noisePublicKey: noiseKey, + peerID: peerID, + timestamp: timestamp(now), + signature: Data(repeating: 0xEE, count: 64), + directNeighbors: [Data(repeating: 0xAB, count: 8)] + ) + + // Pre-barrier trust check sees no pinned key (e.g. concurrent race), + // but the registry itself refuses to replace its pinned signing key. + let recorder = Recorder() + recorder.upsertResult = nil + let handler = makeHandler(recorder: recorder, now: now) + + handler.handle(packet, from: peerID) + + #expect(recorder.upsertCalls.count == 1) + #expect(recorder.persistedIdentities.isEmpty) + #expect(recorder.topologyUpdates.isEmpty) + #expect(recorder.uiEventDeliveries.count == 1) + #expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false) + #expect(recorder.afterglowDelays.isEmpty) + } + @Test func keyMismatchWithExistingPeerKeepsAnnounceUnverified() throws { let now = Date(timeIntervalSince1970: 1_000) @@ -506,6 +677,255 @@ struct BLEAnnounceHandlerTests { #expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false) } + @Test + func attackerReplayingVictimNoiseKeyWithOwnSigningKeyIsRejectedEndToEnd() throws { + // Real crypto: the attacker crafts a fully self-consistent announce + // (victim's noiseKey/peerID, attacker's signing key and nickname, + // valid packet signature made with the attacker's key). Without + // signing-key pinning this used to overwrite the victim's registry + // entry and persisted identity. + let victim = NoiseEncryptionService(keychain: MockKeychain()) + let attacker = NoiseEncryptionService(keychain: MockKeychain()) + let victimNoiseKey = victim.getStaticPublicKeyData() + let peerID = PeerID(publicKey: victimNoiseKey) + let now = Date() + + final class RegistryBox { + var registry = BLEPeerRegistry() + var persistedIdentities: [AnnouncementPacket] = [] + } + let box = RegistryBox() + + let environment = BLEAnnounceHandlerEnvironment( + localPeerID: { PeerID(str: "0102030405060708") }, + messageTTL: TransportConfig.messageTTLDefault, + now: { now }, + existingPeerKeys: { peerID in + let info = box.registry.info(for: peerID) + return (info?.noisePublicKey, info?.signingPublicKey) + }, + persistedSigningPublicKey: { _ in nil }, + authenticatedSigningPublicKey: { _ in nil }, + verifySignature: { packet, signingPublicKey in + victim.verifyPacketSignature(packet, publicKey: signingPublicKey) + }, + linkState: { _ in (hasPeripheral: true, hasCentral: false) }, + linkBoundToOtherPeer: { _, _ in false }, + withRegistryBarrier: { body in body() }, + upsertVerifiedAnnounce: { peerID, announcement, isConnected, now in + box.registry.upsertVerifiedAnnounce( + peerID: peerID, + nickname: announcement.nickname, + noisePublicKey: announcement.noisePublicKey, + signingPublicKey: announcement.signingPublicKey, + isConnected: isConnected, + now: now + ) + }, + shouldEmitReconnectLog: { _, _ in false }, + updateTopology: { _, _ in }, + persistIdentity: { announcement in + box.persistedIdentities.append(announcement) + }, + dedupContains: { _ in true }, + dedupMarkProcessed: { _ in }, + deliverAnnounceUIEvents: { _, _, _ in }, + trackPacketSeen: { _ in }, + sendAnnounceBack: {}, + scheduleAfterglow: { _ in } + ) + let handler = BLEAnnounceHandler(environment: environment) + + func makeSignedAnnounce(nickname: String, signer: NoiseEncryptionService) throws -> BitchatPacket { + let announcement = AnnouncementPacket( + nickname: nickname, + noisePublicKey: victimNoiseKey, + signingPublicKey: signer.getSigningPublicKeyData(), + directNeighbors: nil + ) + let payload = try #require(announcement.encode()) + let packet = BitchatPacket( + type: MessageType.announce.rawValue, + senderID: Data(hexString: peerID.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(now.timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: TransportConfig.messageTTLDefault + ) + return try #require(signer.signPacket(packet)) + } + + // Legitimate announce from the victim is accepted and pinned. + let victimAnnounce = try makeSignedAnnounce(nickname: "victim", signer: victim) + handler.handle(victimAnnounce, from: peerID) + + #expect(box.registry.info(for: peerID)?.nickname == "victim") + #expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData()) + #expect(box.persistedIdentities.count == 1) + + // Attacker announce with a valid self-signature must be rejected. + let attackerAnnounce = try makeSignedAnnounce(nickname: "attacker", signer: attacker) + handler.handle(attackerAnnounce, from: peerID) + + #expect(box.registry.info(for: peerID)?.nickname == "victim") + #expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData()) + #expect(box.persistedIdentities.count == 1) + + // The victim's subsequent announces (same pinned key) still work. + let victimRename = try makeSignedAnnounce(nickname: "victim-renamed", signer: victim) + handler.handle(victimRename, from: peerID) + + #expect(box.registry.info(for: peerID)?.nickname == "victim-renamed") + #expect(box.persistedIdentities.count == 2) + } + + @Test + func signingKeyPinSurvivesRegistryEvictionAndRestartEndToEnd() throws { + // Real crypto + real persistence: the victim announces and gets + // pinned, then the registry entry disappears (offline-peer eviction + // via reconcileConnectivity, or app restart which starts with an + // empty registry). The attacker replays the victim's + // noiseKey/peerID with their own signing key and a valid + // self-signature — the persisted identity must still block the + // takeover, and must not be overwritten. The victim (same signing + // key) must be re-accepted. + let victim = NoiseEncryptionService(keychain: MockKeychain()) + let attacker = NoiseEncryptionService(keychain: MockKeychain()) + let victimNoiseKey = victim.getStaticPublicKeyData() + let peerID = PeerID(publicKey: victimNoiseKey) + let now = Date() + + let identityKeychain = MockKeychain() + let identityManager = SecureIdentityStateManager(identityKeychain) + + final class RegistryBox { + var registry = BLEPeerRegistry() + } + let box = RegistryBox() + + func makeEnvironment(identityManager: SecureIdentityStateManager) -> BLEAnnounceHandlerEnvironment { + BLEAnnounceHandlerEnvironment( + localPeerID: { PeerID(str: "0102030405060708") }, + messageTTL: TransportConfig.messageTTLDefault, + now: { now }, + existingPeerKeys: { peerID in + let info = box.registry.info(for: peerID) + return (info?.noisePublicKey, info?.signingPublicKey) + }, + // Mirrors the BLEService wiring: fall back to the persisted + // cryptographic identity. + persistedSigningPublicKey: { peerID in + identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID) + .compactMap { $0.signingPublicKey } + .first + }, + authenticatedSigningPublicKey: { _ in nil }, + verifySignature: { packet, signingPublicKey in + victim.verifyPacketSignature(packet, publicKey: signingPublicKey) + }, + linkState: { _ in (hasPeripheral: true, hasCentral: false) }, + linkBoundToOtherPeer: { _, _ in false }, + withRegistryBarrier: { body in body() }, + upsertVerifiedAnnounce: { peerID, announcement, isConnected, now in + box.registry.upsertVerifiedAnnounce( + peerID: peerID, + nickname: announcement.nickname, + noisePublicKey: announcement.noisePublicKey, + signingPublicKey: announcement.signingPublicKey, + isConnected: isConnected, + now: now + ) + }, + shouldEmitReconnectLog: { _, _ in false }, + updateTopology: { _, _ in }, + persistIdentity: { announcement in + identityManager.upsertCryptographicIdentity( + fingerprint: announcement.noisePublicKey.sha256Fingerprint(), + noisePublicKey: announcement.noisePublicKey, + signingPublicKey: announcement.signingPublicKey, + claimedNickname: announcement.nickname + ) + }, + dedupContains: { _ in true }, + dedupMarkProcessed: { _ in }, + deliverAnnounceUIEvents: { _, _, _ in }, + trackPacketSeen: { _ in }, + sendAnnounceBack: {}, + scheduleAfterglow: { _ in } + ) + } + let handler = BLEAnnounceHandler(environment: makeEnvironment(identityManager: identityManager)) + + func makeSignedAnnounce(nickname: String, signer: NoiseEncryptionService) throws -> BitchatPacket { + let announcement = AnnouncementPacket( + nickname: nickname, + noisePublicKey: victimNoiseKey, + signingPublicKey: signer.getSigningPublicKeyData(), + directNeighbors: nil + ) + let payload = try #require(announcement.encode()) + let packet = BitchatPacket( + type: MessageType.announce.rawValue, + senderID: Data(hexString: peerID.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(now.timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: TransportConfig.messageTTLDefault + ) + return try #require(signer.signPacket(packet)) + } + + func persistedIdentity() -> CryptographicIdentity? { + // queue.sync read; fences the manager's pending barrier writes. + identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID).first + } + + // 1. Victim announces: pinned in the registry and persisted. + handler.handle(try makeSignedAnnounce(nickname: "victim", signer: victim), from: peerID) + #expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData()) + #expect(persistedIdentity()?.signingPublicKey == victim.getSigningPublicKeyData()) + + // 2. Registry entry disappears (eviction / restart). + _ = box.registry.remove(peerID) + #expect(box.registry.info(for: peerID) == nil) + + // 3. Attacker replay with own signing key: rejected via the persisted + // pin, and neither the registry nor the persisted identity change. + handler.handle(try makeSignedAnnounce(nickname: "attacker", signer: attacker), from: peerID) + #expect(box.registry.info(for: peerID) == nil) + #expect(persistedIdentity()?.signingPublicKey == victim.getSigningPublicKeyData()) + #expect(identityManager.getSocialIdentity(for: victimNoiseKey.sha256Fingerprint())?.claimedNickname == "victim") + + // 4. Victim re-announces with the same signing key: accepted again. + handler.handle(try makeSignedAnnounce(nickname: "victim", signer: victim), from: peerID) + #expect(box.registry.info(for: peerID)?.nickname == "victim") + #expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData()) + + // 5. Simulated app restart: a fresh identity manager reloads the pin + // from the (mock) keychain, and a fresh registry starts empty. The + // attacker replay is still rejected. + identityManager.forceSave() + let reloadedManager = SecureIdentityStateManager(identityKeychain) + #expect( + reloadedManager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey + == victim.getSigningPublicKeyData() + ) + box.registry = BLEPeerRegistry() + let restartedHandler = BLEAnnounceHandler(environment: makeEnvironment(identityManager: reloadedManager)) + restartedHandler.handle(try makeSignedAnnounce(nickname: "attacker", signer: attacker), from: peerID) + #expect(box.registry.info(for: peerID) == nil) + #expect( + reloadedManager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey + == victim.getSigningPublicKeyData() + ) + + // ...while the victim is accepted after the restart. + restartedHandler.handle(try makeSignedAnnounce(nickname: "victim", signer: victim), from: peerID) + #expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData()) + } + private func expectNoSideEffects(_ recorder: Recorder) { #expect(recorder.barrierCount == 0) #expect(recorder.upsertCalls.isEmpty) diff --git a/bitchatTests/Services/BLEAnnounceHandlingPolicyTests.swift b/bitchatTests/Services/BLEAnnounceHandlingPolicyTests.swift index c0226b81..97fa211f 100644 --- a/bitchatTests/Services/BLEAnnounceHandlingPolicyTests.swift +++ b/bitchatTests/Services/BLEAnnounceHandlingPolicyTests.swift @@ -123,7 +123,9 @@ struct BLEAnnounceHandlingPolicyTests { hasSignature: false, signatureValid: false, 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)) @@ -136,7 +138,9 @@ struct BLEAnnounceHandlingPolicyTests { hasSignature: true, signatureValid: false, 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)) @@ -148,7 +152,9 @@ struct BLEAnnounceHandlingPolicyTests { hasSignature: true, signatureValid: true, 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)) @@ -162,13 +168,68 @@ struct BLEAnnounceHandlingPolicyTests { hasSignature: true, signatureValid: true, existingNoisePublicKey: noiseKey, - announcedNoisePublicKey: noiseKey + announcedNoisePublicKey: noiseKey, + existingSigningPublicKey: nil, + announcedSigningPublicKey: Data(repeating: 0x99, count: 32) ) #expect(decision == .verified) #expect(decision.isVerified) } + @Test + func trustPolicyRejectsPinnedSigningKeyMismatchEvenWithValidSignature() { + let noiseKey = Data(repeating: 0xCC, count: 32) + + // Attacker replays the victim's noiseKey/peerID with their own signing + // key and a valid self-signature; the pinned key must win. + let decision = BLEAnnounceTrustPolicy.evaluate( + hasSignature: true, + signatureValid: true, + existingNoisePublicKey: noiseKey, + announcedNoisePublicKey: noiseKey, + existingSigningPublicKey: Data(repeating: 0x99, count: 32), + announcedSigningPublicKey: Data(repeating: 0x66, count: 32) + ) + + #expect(decision == .reject(.signingKeyMismatch)) + #expect(!decision.isVerified) + } + + @Test + func trustPolicyAcceptsMatchingPinnedSigningKey() { + let noiseKey = Data(repeating: 0xCC, count: 32) + let signingKey = Data(repeating: 0x99, count: 32) + + let decision = BLEAnnounceTrustPolicy.evaluate( + hasSignature: true, + signatureValid: true, + existingNoisePublicKey: noiseKey, + announcedNoisePublicKey: noiseKey, + existingSigningPublicKey: signingKey, + announcedSigningPublicKey: signingKey + ) + + #expect(decision == .verified) + } + + @Test + func trustPolicyRejectsSigningKeyReplacementAfterNoiseBinding() { + let noiseKey = Data(repeating: 0xCC, count: 32) + let boundSigningKey = Data(repeating: 0x11, count: 32) + + let decision = BLEAnnounceTrustPolicy.evaluate( + hasSignature: true, + signatureValid: true, + existingNoisePublicKey: noiseKey, + announcedNoisePublicKey: noiseKey, + authenticatedSigningPublicKey: boundSigningKey, + announcedSigningPublicKey: Data(repeating: 0x22, count: 32) + ) + + #expect(decision == .reject(.authenticatedSigningKeyMismatch)) + } + @Test func responsePolicyConnectsOnlyForDirectNewOrReconnectedPeers() { let directNew = BLEAnnounceResponsePolicy.plan( diff --git a/bitchatTests/Services/BLEAnnounceThrottleTests.swift b/bitchatTests/Services/BLEAnnounceThrottleTests.swift index 96dde95c..0ce23814 100644 --- a/bitchatTests/Services/BLEAnnounceThrottleTests.swift +++ b/bitchatTests/Services/BLEAnnounceThrottleTests.swift @@ -5,7 +5,7 @@ import Testing struct BLEAnnounceThrottleTests { @Test func firstAnnounceIsAllowed() { - var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2) + let throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2) let shouldSend = throttle.shouldSend(force: false, now: Date(timeIntervalSince1970: 100)) @@ -15,7 +15,7 @@ struct BLEAnnounceThrottleTests { @Test func regularAnnounceUsesNormalMinimumInterval() { let now = Date(timeIntervalSince1970: 100) - var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2) + let throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2) let first = throttle.shouldSend(force: false, now: now) let suppressed = throttle.shouldSend(force: false, now: now.addingTimeInterval(9.9)) @@ -29,7 +29,7 @@ struct BLEAnnounceThrottleTests { @Test func forcedAnnounceUsesShorterMinimumInterval() { let now = Date(timeIntervalSince1970: 100) - var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2) + let throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2) let first = throttle.shouldSend(force: false, now: now) let suppressed = throttle.shouldSend(force: true, now: now.addingTimeInterval(1.9)) @@ -43,10 +43,40 @@ struct BLEAnnounceThrottleTests { @Test func elapsedReportsTimeSinceAcceptedSend() { let now = Date(timeIntervalSince1970: 100) - var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2) + let throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2) _ = throttle.shouldSend(force: false, now: now) #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 } + } } diff --git a/bitchatTests/Services/BLEFileTransferHandlerTests.swift b/bitchatTests/Services/BLEFileTransferHandlerTests.swift index 170aba9a..ea3b3554 100644 --- a/bitchatTests/Services/BLEFileTransferHandlerTests.swift +++ b/bitchatTests/Services/BLEFileTransferHandlerTests.swift @@ -13,17 +13,109 @@ struct BLEFileTransferHandlerTests { var signatureVerifyCount = 0 var signedNameQueries: [PeerID] = [] + var blockedPeers: Set = [] var trackedPackets: [BitchatPacket] = [] var quotaReservations: [Int] = [] var saveCalls: [(data: Data, preferredName: String?, subdirectory: String, fallbackExtension: String?, defaultPrefix: String)] = [] + var receiptStates: [String: BLEPrivateMediaReceiptState] = [:] + var receiptCommits: [(messageID: String, storedURL: URL)] = [] + var receiptCommitSucceeds = true + var removedIncomingFiles: [URL] = [] + var finishedIncomingFileDeliveries: [URL] = [] var lastSeenUpdates: [PeerID] = [] + var deliveryAcks: [(messageID: String, peerID: PeerID)] = [] var deliveredMessages: [BitchatMessage] = [] + var shouldAcceptDelivery = true + var deliveryOutcome = TransportEventDeliveryOutcome.accepted + var saveOverride: (( + _ data: Data, + _ preferredName: String?, + _ subdirectory: String, + _ fallbackExtension: String?, + _ defaultPrefix: String + ) -> URL?)? + var receiptStateOverride: ((String) -> BLEPrivateMediaReceiptState)? + var receiptCommitOverride: ((String, URL) -> Bool)? + var removeIncomingFileOverride: ((URL) -> Void)? + var finishIncomingFileDeliveryOverride: ((URL) -> Void)? } private let localPeerID = PeerID(str: "0102030405060708") private let remotePeerID = PeerID(str: "1122334455667788") private let sampleSigningKey = Data(repeating: 0xAB, count: 32) + @Test @MainActor + func deliveryGateFinalizesInitialRejection() { + var completions = 0 + var finalizations = 0 + + TransportEventDeliveryGate.attempt( + shouldDeliver: { false }, + deliver: { + Issue.record("delivery sink must not run") + return .accepted + }, + completion: { completions += 1 }, + finalization: { _ in finalizations += 1 } + ) + + #expect(completions == 0) + #expect(finalizations == 1) + } + + @Test @MainActor + func deliveryGateFinalizesMissingOrRejectingSink() { + var completions = 0 + var finalizations = 0 + + TransportEventDeliveryGate.attempt( + shouldDeliver: { true }, + deliver: { .rejected }, + completion: { completions += 1 }, + finalization: { _ in finalizations += 1 } + ) + + #expect(completions == 0) + #expect(finalizations == 1) + } + + @Test @MainActor + func deliveryGateFinalizesPostInsertionRejection() { + var deliveryChecks = 0 + var completions = 0 + var finalizations = 0 + + TransportEventDeliveryGate.attempt( + shouldDeliver: { + deliveryChecks += 1 + return deliveryChecks == 1 + }, + deliver: { .accepted }, + completion: { completions += 1 }, + finalization: { _ in finalizations += 1 } + ) + + #expect(deliveryChecks == 2) + #expect(completions == 0) + #expect(finalizations == 1) + } + + @Test @MainActor + func deliveryGatePreservesInvokedUnconfirmedOutcomeWithoutAck() { + var completions = 0 + var outcomes: [TransportEventDeliveryOutcome] = [] + + TransportEventDeliveryGate.attempt( + shouldDeliver: { true }, + deliver: { .invokedUnconfirmed }, + completion: { completions += 1 }, + finalization: { outcomes.append($0) } + ) + + #expect(completions == 0) + #expect(outcomes == [.invokedUnconfirmed]) + } + private func makeHandler(recorder: Recorder) -> BLEFileTransferHandler { let environment = BLEFileTransferHandlerEnvironment( localPeerID: { [localPeerID] in localPeerID }, @@ -33,6 +125,7 @@ struct BLEFileTransferHandlerTests { recorder.signatureVerifyCount += 1 return recorder.signatureVerifies }, + localSigningPublicKey: { [sampleSigningKey] in sampleSigningKey }, signedSenderDisplayName: { _, peerID in recorder.signedNameQueries.append(peerID) return recorder.signedName @@ -45,13 +138,56 @@ struct BLEFileTransferHandlerTests { }, saveIncomingFile: { data, preferredName, subdirectory, fallbackExtension, defaultPrefix in recorder.saveCalls.append((data, preferredName, subdirectory, fallbackExtension, defaultPrefix)) + if let saveOverride = recorder.saveOverride { + return saveOverride(data, preferredName, subdirectory, fallbackExtension, defaultPrefix) + } return recorder.saveResult }, + privateMediaReceiptState: { messageID in + if let receiptStateOverride = recorder.receiptStateOverride { + return receiptStateOverride(messageID) + } + return recorder.receiptStates[messageID] ?? .absent + }, + commitPrivateMediaFile: { messageID, storedURL in + recorder.receiptCommits.append((messageID, storedURL)) + if let receiptCommitOverride = recorder.receiptCommitOverride { + return receiptCommitOverride(messageID, storedURL) + } + guard recorder.receiptCommitSucceeds else { return false } + recorder.receiptStates[messageID] = .accepted(storedURL) + return true + }, + removeIncomingFile: { storedURL in + recorder.removedIncomingFiles.append(storedURL) + recorder.removeIncomingFileOverride?(storedURL) + }, + finishIncomingFileDelivery: { storedURL in + recorder.finishedIncomingFileDeliveries.append(storedURL) + recorder.finishIncomingFileDeliveryOverride?(storedURL) + }, + isPrivateMediaSenderBlocked: { peerID in + recorder.blockedPeers.contains(peerID) + }, updatePeerLastSeen: { peerID in recorder.lastSeenUpdates.append(peerID) }, - deliverMessage: { message in + acknowledgePrivateMedia: { messageID, peerID in + recorder.deliveryAcks.append((messageID, peerID)) + }, + deliverMessage: { message, shouldDeliver, completion, finalization in + var outcome = TransportEventDeliveryOutcome.rejected + defer { finalization(outcome) } + guard recorder.shouldAcceptDelivery else { return } + guard shouldDeliver() else { return } recorder.deliveredMessages.append(message) + guard shouldDeliver() else { return } + if recorder.deliveryOutcome == .invokedUnconfirmed { + outcome = .invokedUnconfirmed + return + } + outcome = .accepted + completion() } ) return BLEFileTransferHandler(environment: environment) @@ -92,12 +228,11 @@ struct BLEFileTransferHandlerTests { @Test func selfEchoIsDropped() throws { let recorder = Recorder() + recorder.signatureVerifies = true let handler = makeHandler(recorder: recorder) let packet = try makeFileTransferPacket(sender: localPeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8), ttl: 3) - // The relay pipeline already suppresses self-originated packets, so the - // handler reports "relayable" rather than treating the echo as forged. - #expect(handler.handle(packet, from: localPeerID)) + #expect(!handler.handle(packet, from: localPeerID)) expectNoSideEffects(recorder) } @@ -120,7 +255,12 @@ struct BLEFileTransferHandlerTests { let recorder = Recorder() recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)] let handler = makeHandler(recorder: recorder) - let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8)) + let packet = try makeFileTransferPacket( + sender: remotePeerID, + mimeType: "application/pdf", + content: Data("%PDF-1.7".utf8), + hasSignature: false + ) // Failed sender authentication must also stop the packet from being // relayed to downstream nodes. @@ -129,7 +269,7 @@ struct BLEFileTransferHandlerTests { // Broadcast files carry an attacker-controllable senderID, so — like // public messages — a connected-but-unverified peer must present a valid // packet signature. No signing key + no signed identity means dropped. - #expect(recorder.signedNameQueries == [remotePeerID]) + #expect(recorder.signedNameQueries.isEmpty) #expect(recorder.trackedPackets.isEmpty) #expect(recorder.deliveredMessages.isEmpty) } @@ -153,12 +293,11 @@ struct BLEFileTransferHandlerTests { } @Test - func selfBroadcastReplayIsDeliveredWithoutSignatureCheck() throws { - // Our own broadcast file replayed via gossip sync arrives with ttl==0 - // (so it is not treated as a self-echo) and cannot be verified against - // the peer registry — it must still be accepted, matching - // BLEPublicMessageHandler's self exemption. + func signedSelfBroadcastReplayIsDelivered() throws { + // Our own broadcast file replayed via gossip sync arrives with ttl==0; + // it is verified against our local signing key before delivery. let recorder = Recorder() + recorder.signatureVerifies = true let handler = makeHandler(recorder: recorder) let packet = try makeFileTransferPacket( sender: localPeerID, @@ -169,7 +308,7 @@ struct BLEFileTransferHandlerTests { #expect(handler.handle(packet, from: localPeerID)) - #expect(recorder.signatureVerifyCount == 0) + #expect(recorder.signatureVerifyCount == 1) #expect(recorder.signedNameQueries.isEmpty) #expect(recorder.deliveredMessages.count == 1) #expect(recorder.deliveredMessages.first?.sender == "Me") @@ -205,7 +344,8 @@ struct BLEFileTransferHandlerTests { sender: remotePeerID, mimeType: "audio/mp4", content: m4a, - fileName: "voice_1122334455667788" + fileName: "voice_1122334455667788", + hasSignature: false ) // The spoofed note must be dropped locally AND not relayed onward. @@ -215,7 +355,7 @@ struct BLEFileTransferHandlerTests { } @Test - func privateFileFromConnectedUnverifiedPeerIsAccepted() throws { + func rawDirectedFileWithoutVerifiableSignatureIsDroppedWithoutWriteOrRelay() throws { let recorder = Recorder() recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)] let handler = makeHandler(recorder: recorder) @@ -223,23 +363,25 @@ struct BLEFileTransferHandlerTests { sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8), - recipientID: Data(hexString: localPeerID.id) + recipientID: Data(hexString: localPeerID.id), + hasSignature: false ) - #expect(handler.handle(packet, from: remotePeerID)) + #expect(!handler.handle(packet, from: remotePeerID)) - // Directed transfers keep the lenient connected-peer path (no broadcast - // exposure); no signature check is required. #expect(recorder.signatureVerifyCount == 0) #expect(recorder.signedNameQueries.isEmpty) - #expect(recorder.deliveredMessages.count == 1) - #expect(recorder.deliveredMessages.first?.isPrivate == true) + #expect(recorder.trackedPackets.isEmpty) + #expect(recorder.quotaReservations.isEmpty) + #expect(recorder.saveCalls.isEmpty) + #expect(recorder.deliveredMessages.isEmpty) } @Test func fileDirectedToAnotherPeerIsIgnored() throws { let recorder = Recorder() - recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)] + recorder.signatureVerifies = true let handler = makeHandler(recorder: recorder) let packet = try makeFileTransferPacket( sender: remotePeerID, @@ -260,7 +402,8 @@ struct BLEFileTransferHandlerTests { @Test func privateFileUpdatesLastSeenAndDeliversPrivateMessage() throws { let recorder = Recorder() - recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)] + recorder.signatureVerifies = true let handler = makeHandler(recorder: recorder) let packet = try makeFileTransferPacket( sender: remotePeerID, @@ -276,12 +419,539 @@ struct BLEFileTransferHandlerTests { #expect(recorder.lastSeenUpdates == [remotePeerID]) #expect(recorder.deliveredMessages.count == 1) #expect(recorder.deliveredMessages.first?.isPrivate == true) + #expect(recorder.deliveredMessages.first?.id.hasPrefix("media-") == false) // Must be explicit: BitchatMessage defaults private messages to // .sending, which the media views render as an in-flight send // (empty reveal mask, disabled reveal tap). #expect(recorder.deliveredMessages.first?.deliveryStatus == .delivered(to: "Me", at: Date(timeIntervalSince1970: 900))) } + @Test + func bit8EncryptedPrivateFileKeepsStableIDAndAckWithoutBit9Proof() throws { + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] + let handler = makeHandler(recorder: recorder) + let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128) + let fileName = "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg" + let file = BitchatFilePacket( + fileName: fileName, + fileSize: UInt64(content.count), + mimeType: "image/jpeg", + content: content + ) + let payload = try #require(file.encode()) + let timestamp = Date(timeIntervalSince1970: 1_234) + + #expect(handler.handlePrivatePayload(payload, from: remotePeerID, timestamp: timestamp)) + + #expect(recorder.signatureVerifyCount == 0) + #expect(recorder.signedNameQueries.isEmpty) + #expect(recorder.trackedPackets.isEmpty) + #expect(recorder.quotaReservations == [content.count]) + #expect(recorder.saveCalls.first?.data == content) + #expect(recorder.lastSeenUpdates == [remotePeerID]) + #expect(recorder.deliveredMessages.count == 1) + #expect(recorder.deliveredMessages.first?.isPrivate == true) + #expect(recorder.deliveredMessages.first?.timestamp == timestamp) + #expect(recorder.deliveredMessages.first?.id == PrivateMediaMessageIdentity.stableID( + senderPeerID: remotePeerID, + recipientPeerID: localPeerID, + fileName: fileName + )) + #expect(recorder.receiptCommits.count == 1) + #expect(recorder.deliveryAcks.count == 1) + #expect(recorder.deliveryAcks.first?.messageID == recorder.deliveredMessages.first?.id) + } + + @Test + func rejectedStableDeliveryReleasesPendingPayloadOwnership() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "private-media-handler-rejected-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: root) } + let store = BLEIncomingFileStore(baseDirectory: root) + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo( + remotePeerID, + nickname: "Alice", + isVerified: true + )] + recorder.shouldAcceptDelivery = false + recorder.saveOverride = { + store.save( + data: $0, + preferredName: $1, + subdirectory: $2, + fallbackExtension: $3, + defaultPrefix: $4 + ) + } + recorder.receiptStateOverride = { + store.privateMediaReceiptState(messageID: $0) + } + recorder.receiptCommitOverride = { + store.commitPrivateMediaFile(messageID: $0, storedURL: $1) + } + recorder.removeIncomingFileOverride = { + store.removeIncomingFile(at: $0) + } + recorder.finishIncomingFileDeliveryOverride = { + store.finishIncomingFileDelivery(at: $0) + } + + let content = Data([0xFF, 0xD8, 0xFF, 0xD9]) + let fileName = + "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg" + let file = BitchatFilePacket( + fileName: fileName, + fileSize: UInt64(content.count), + mimeType: "image/jpeg", + content: content + ) + let payload = try #require(file.encode()) + let stableID = try #require(PrivateMediaMessageIdentity.stableID( + senderPeerID: remotePeerID, + recipientPeerID: localPeerID, + fileName: fileName + )) + + #expect(makeHandler(recorder: recorder).handlePrivatePayload( + payload, + from: remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_234) + )) + #expect(recorder.deliveredMessages.isEmpty) + #expect(recorder.deliveryAcks.isEmpty) + #expect(recorder.finishedIncomingFileDeliveries.count == 1) + #expect(store.reservePrivateMediaDeletion( + messageIDs: [stableID], + payloadRelativePaths: [:] + ) != nil) + } + + @Test + func rawLegacyPrivateFileWithRetryShapedNameNeverUsesReceiptLedger() throws { + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo( + remotePeerID, + nickname: "Alice", + isVerified: true, + signingPublicKey: sampleSigningKey + )] + recorder.signatureVerifies = true + let handler = makeHandler(recorder: recorder) + let content = Data([0xFF, 0xD8, 0xFF, 0xD9]) + let packet = try makeFileTransferPacket( + sender: remotePeerID, + mimeType: "image/jpeg", + content: content, + recipientID: Data(hexString: localPeerID.id), + fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg" + ) + + #expect(handler.handle(packet, from: remotePeerID)) + #expect(recorder.receiptCommits.isEmpty) + #expect(recorder.deliveryAcks.isEmpty) + #expect(recorder.deliveredMessages.count == 1) + #expect(recorder.deliveredMessages.first?.id.hasPrefix("media-") == false) + } + + @Test + func rejectedRawDeliveryRemovesUIUnownedPayload() throws { + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo( + remotePeerID, + nickname: "Alice", + isVerified: true, + signingPublicKey: sampleSigningKey + )] + recorder.signatureVerifies = true + recorder.shouldAcceptDelivery = false + let handler = makeHandler(recorder: recorder) + let packet = try makeFileTransferPacket( + sender: remotePeerID, + mimeType: "image/jpeg", + content: Data([0xFF, 0xD8, 0xFF, 0xD9]), + recipientID: Data(hexString: localPeerID.id), + fileName: "raw-rejected.jpg" + ) + + #expect(handler.handle(packet, from: remotePeerID)) + #expect(recorder.deliveredMessages.isEmpty) + #expect(recorder.removedIncomingFiles.count == 1) + #expect(recorder.removedIncomingFiles.first == recorder.saveResult) + #expect(recorder.finishedIncomingFileDeliveries.isEmpty) + } + + @Test + func plainDelegateRawDeliveryPreservesPayloadWithoutSynchronousAck() throws { + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo( + remotePeerID, + nickname: "Alice", + isVerified: true, + signingPublicKey: sampleSigningKey + )] + recorder.signatureVerifies = true + recorder.deliveryOutcome = .invokedUnconfirmed + let handler = makeHandler(recorder: recorder) + let packet = try makeFileTransferPacket( + sender: remotePeerID, + mimeType: "image/jpeg", + content: Data([0xFF, 0xD8, 0xFF, 0xD9]), + recipientID: Data(hexString: localPeerID.id), + fileName: "plain-delegate.jpg" + ) + + #expect(handler.handle(packet, from: remotePeerID)) + #expect(recorder.deliveredMessages.count == 1) + #expect(recorder.deliveryAcks.isEmpty) + #expect(recorder.removedIncomingFiles.isEmpty) + #expect(recorder.finishedIncomingFileDeliveries.count == 1) + #expect( + recorder.finishedIncomingFileDeliveries.first + == recorder.saveResult + ) + } + + @Test + func repeatedLegacyPrivateImageNamesKeepDistinctRandomMessageIDs() throws { + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] + let handler = makeHandler(recorder: recorder) + let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128) + let file = BitchatFilePacket( + fileName: "photo.jpg", + fileSize: UInt64(content.count), + mimeType: "image/jpeg", + content: content + ) + let payload = try #require(file.encode()) + + #expect(handler.handlePrivatePayload( + payload, + from: remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_234) + )) + #expect(handler.handlePrivatePayload( + payload, + from: remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_235) + )) + + #expect(recorder.deliveredMessages.count == 2) + #expect(recorder.deliveredMessages[0].id != recorder.deliveredMessages[1].id) + #expect(recorder.deliveredMessages.allSatisfy { !$0.id.hasPrefix("media-") }) + } + + @Test + func lostCapabilityProofThenStableRetryReusesDurableIDWithoutSecondDiskWrite() throws { + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] + let handler = makeHandler(recorder: recorder) + let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128) + let fileName = "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg" + let file = BitchatFilePacket( + fileName: fileName, + fileSize: UInt64(content.count), + mimeType: "image/jpeg", + content: content + ) + let payload = try #require(file.encode()) + let expectedID = try #require(PrivateMediaMessageIdentity.stableID( + senderPeerID: remotePeerID, + recipientPeerID: localPeerID, + fileName: fileName + )) + + // First encrypted arrival may precede the sender's authenticated bit-9 + // proof. It still uses the bit-8 stable ID/ACK contract. + #expect(handler.handlePrivatePayload( + payload, + from: remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_234) + )) + // A later automatic retry after proof must resolve the same durable ID + // rather than create a legacy random-ID bubble. + #expect(handler.handlePrivatePayload( + payload, + from: remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_235) + )) + + #expect(recorder.quotaReservations == [content.count]) + #expect(recorder.saveCalls.count == 1) + // The handler re-offers a durable duplicate so a relaunched UI can + // restore its bubble; the synchronous conversation sink deduplicates. + #expect(recorder.deliveredMessages.count == 2) + #expect(recorder.lastSeenUpdates == [remotePeerID, remotePeerID]) + #expect(recorder.deliveryAcks.count == 2) + #expect(recorder.deliveryAcks.allSatisfy { + $0.messageID == expectedID && $0.peerID == remotePeerID + }) + } + + @Test + func acceptedPrivateMediaAfterRelaunchRedeliversDurableURLBeforeAck() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "private-media-handler-relaunch-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: root) } + let store = BLEIncomingFileStore(baseDirectory: root) + let content = Data([0xFF, 0xD8, 0xFF, 0xD9]) + let file = BitchatFilePacket( + fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg", + fileSize: UInt64(content.count), + mimeType: "image/jpeg", + content: content + ) + let payload = try #require(file.encode()) + + func configure(_ recorder: Recorder) { + recorder.peers = [remotePeerID: makePeerInfo( + remotePeerID, + nickname: "Alice", + isVerified: true + )] + recorder.saveOverride = { data, preferredName, subdirectory, fallbackExtension, defaultPrefix in + store.save( + data: data, + preferredName: preferredName, + subdirectory: subdirectory, + fallbackExtension: fallbackExtension, + defaultPrefix: defaultPrefix + ) + } + recorder.receiptStateOverride = { + store.privateMediaReceiptState(messageID: $0) + } + recorder.receiptCommitOverride = { + store.commitPrivateMediaFile(messageID: $0, storedURL: $1) + } + recorder.removeIncomingFileOverride = { + store.removeIncomingFile(at: $0) + } + recorder.finishIncomingFileDeliveryOverride = { + store.finishIncomingFileDelivery(at: $0) + } + } + + let first = Recorder() + configure(first) + #expect(makeHandler(recorder: first).handlePrivatePayload( + payload, + from: remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_234) + )) + let originalMessage = try #require(first.deliveredMessages.first) + #expect(first.deliveryAcks.count == 1) + + // A fresh handler models process relaunch: its in-memory reservation + // cache is empty, so only the durable receipt can suppress disk work. + let relaunched = Recorder() + configure(relaunched) + #expect(makeHandler(recorder: relaunched).handlePrivatePayload( + payload, + from: remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_235) + )) + + #expect(relaunched.quotaReservations.isEmpty) + #expect(relaunched.saveCalls.isEmpty) + #expect(relaunched.receiptCommits.isEmpty) + #expect(relaunched.deliveredMessages.count == 1) + #expect(relaunched.deliveredMessages.first?.id == originalMessage.id) + #expect(relaunched.deliveredMessages.first?.content == originalMessage.content) + #expect(relaunched.deliveryAcks.count == 1) + #expect(relaunched.deliveryAcks.first?.messageID == originalMessage.id) + } + + @Test + func inFlightStableDuplicateIsNotAcknowledgedAndFailedSaveRemainsRetryable() throws { + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] + let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128) + let file = BitchatFilePacket( + fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg", + fileSize: UInt64(content.count), + mimeType: "image/jpeg", + content: content + ) + let payload = try #require(file.encode()) + var handler: BLEFileTransferHandler! + var nestedResult: Bool? + var failFirstSave = true + recorder.saveOverride = { _, _, _, _, _ in + if failFirstSave { + failFirstSave = false + nestedResult = handler.handlePrivatePayload( + payload, + from: self.remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_235) + ) + return nil + } + return recorder.saveResult + } + handler = makeHandler(recorder: recorder) + + // The nested arrival sees the first reservation as pending. It is + // coalesced without an ACK; then the first durable save fails. + #expect(!handler.handlePrivatePayload( + payload, + from: remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_234) + )) + #expect(nestedResult == true) + #expect(recorder.saveCalls.count == 1) + #expect(recorder.deliveryAcks.isEmpty) + #expect(recorder.deliveredMessages.isEmpty) + + // Failure released the reservation, so the sender's later retry can + // persist and deliver normally. + #expect(handler.handlePrivatePayload( + payload, + from: remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_236) + )) + #expect(recorder.saveCalls.count == 2) + #expect(recorder.deliveryAcks.count == 1) + #expect(recorder.deliveredMessages.count == 1) + } + + @Test + func unavailableDurableReceiptStateWithholdsDiskDeliveryAndAck() throws { + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo( + remotePeerID, + nickname: "Alice", + isVerified: true + )] + let fileName = + "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg" + let messageID = try #require(PrivateMediaMessageIdentity.stableID( + senderPeerID: remotePeerID, + recipientPeerID: localPeerID, + fileName: fileName + )) + recorder.receiptStates[messageID] = .unavailable + let handler = makeHandler(recorder: recorder) + let content = Data([0xFF, 0xD8, 0xFF, 0xD9]) + let payload = try #require(BitchatFilePacket( + fileName: fileName, + fileSize: UInt64(content.count), + mimeType: "image/jpeg", + content: content + ).encode()) + + #expect(handler.handlePrivatePayload( + payload, + from: remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_234) + )) + #expect(recorder.quotaReservations.isEmpty) + #expect(recorder.saveCalls.isEmpty) + #expect(recorder.receiptCommits.isEmpty) + #expect(recorder.deliveredMessages.isEmpty) + #expect(recorder.deliveryAcks.isEmpty) + } + + @Test + func durableReceiptCommitFailureRollsBackAndWithholdsDeliveryAck() throws { + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo( + remotePeerID, + nickname: "Alice", + isVerified: true + )] + recorder.receiptCommitSucceeds = false + let handler = makeHandler(recorder: recorder) + let content = Data([0xFF, 0xD8, 0xFF, 0xD9]) + let payload = try #require(BitchatFilePacket( + fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg", + fileSize: UInt64(content.count), + mimeType: "image/jpeg", + content: content + ).encode()) + + #expect(!handler.handlePrivatePayload( + payload, + from: remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_234) + )) + #expect(recorder.saveCalls.count == 1) + #expect(recorder.receiptCommits.count == 1) + #expect(recorder.removedIncomingFiles.count == 1) + #expect(recorder.removedIncomingFiles.first == recorder.saveResult) + #expect(recorder.deliveredMessages.isEmpty) + #expect(recorder.deliveryAcks.isEmpty) + } + + @Test + func blockedPrivateMediaIsDroppedBeforeQuotaDiskAndDedupState() throws { + let recorder = Recorder() + recorder.blockedPeers = [remotePeerID] + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] + let handler = makeHandler(recorder: recorder) + let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128) + let file = BitchatFilePacket( + fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg", + fileSize: UInt64(content.count), + mimeType: "image/jpeg", + content: content + ) + let payload = try #require(file.encode()) + + #expect(handler.handlePrivatePayload( + payload, + from: remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_234) + )) + + #expect(recorder.quotaReservations.isEmpty) + #expect(recorder.saveCalls.isEmpty) + #expect(recorder.lastSeenUpdates.isEmpty) + #expect(recorder.deliveryAcks.isEmpty) + #expect(recorder.deliveredMessages.isEmpty) + + // Unblocking must allow a retry through; the blocked attempt cannot + // poison the stable-ID dedup reservation. + recorder.blockedPeers = [] + #expect(handler.handlePrivatePayload( + payload, + from: remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_235) + )) + #expect(recorder.saveCalls.count == 1) + #expect(recorder.deliveredMessages.count == 1) + #expect(recorder.deliveryAcks.count == 1) + } + + @Test + func decryptedPrivateFileOverPayloadCapIsRejectedBeforeQuotaOrDiskWrite() { + let recorder = Recorder() + let handler = makeHandler(recorder: recorder) + let oversizedCount = FileTransferLimits.maxPayloadBytes + 1 + var length = UInt32(oversizedCount).bigEndian + var payload = Data([0x04]) // BitchatFilePacket CONTENT TLV + withUnsafeBytes(of: &length) { payload.append(contentsOf: $0) } + payload.append(Data(repeating: 0x41, count: oversizedCount)) + + #expect(!handler.handlePrivatePayload( + payload, + from: remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_234) + )) + + #expect(recorder.quotaReservations.isEmpty) + #expect(recorder.saveCalls.isEmpty) + #expect(recorder.lastSeenUpdates.isEmpty) + #expect(recorder.deliveryAcks.isEmpty) + #expect(recorder.deliveredMessages.isEmpty) + } + @Test func malformedPayloadIsTrackedForSyncButDropped() { let recorder = Recorder() @@ -294,7 +964,7 @@ struct BLEFileTransferHandlerTests { recipientID: nil, timestamp: 900_000, payload: Data([0x01, 0x02, 0x03]), - signature: nil, + signature: Data(repeating: 0x5A, count: 64), ttl: TransportConfig.messageTTLDefault ) @@ -370,6 +1040,341 @@ struct BLEFileTransferHandlerTests { #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 + func panicWipeDeletesEveryManagedMediaFileAndRecreatesEmptyDirectories() throws { + let base = FileManager.default.temporaryDirectory + .appendingPathComponent("panic-media-wipe-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: base) } + let store = BLEIncomingFileStore(baseDirectory: base) + let subdirectories = [ + "voicenotes/incoming", + "voicenotes/outgoing", + "images/incoming", + "images/outgoing", + "files/incoming", + "files/outgoing" + ] + + for subdirectory in subdirectories { + let directory = base + .appendingPathComponent("files", isDirectory: true) + .appendingPathComponent(subdirectory, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data("secret".utf8).write(to: directory.appendingPathComponent("artifact.bin")) + } + let unmanaged = base.appendingPathComponent("files/legacy/secret.bin") + try FileManager.default.createDirectory(at: unmanaged.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("legacy".utf8).write(to: unmanaged) + + try store.panicWipe() + + #expect(!FileManager.default.fileExists(atPath: unmanaged.path)) + for subdirectory in subdirectories { + let directory = base + .appendingPathComponent("files", isDirectory: true) + .appendingPathComponent(subdirectory, isDirectory: true) + var isDirectory: ObjCBool = false + #expect(FileManager.default.fileExists(atPath: directory.path, isDirectory: &isDirectory)) + #expect(isDirectory.boolValue) + #expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).isEmpty) + } + } + + @Test + func panicWipeClearsCachedPrivateMediaReceiptDecisions() throws { + let base = FileManager.default.temporaryDirectory + .appendingPathComponent( + "panic-receipt-cache-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: base) } + let messageID = "media-00112233445566778899aabbccddeeff" + + let seed = BLEPrivateMediaReceiptStore(baseDirectory: base) + let payload = base + .appendingPathComponent( + "files/images/incoming", + isDirectory: true + ) + .appendingPathComponent("panic-receipt.jpg") + try FileManager.default.createDirectory( + at: payload.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data("secret".utf8).write(to: payload, options: .atomic) + #expect(seed.commitAccepted( + messageID: messageID, + storedURL: payload + )) + #expect(seed.recordDeleted(messageID: messageID)) + + // Production wiring: receipt lookups run against the service's OWN + // incoming-file store while the panic wipe runs on the separate store + // `PanicRecoveryOperations.live()` constructs. The test must reset + // the instance BLEService uses, not a same-instance shortcut. + let keychain = MockKeychain() + let identityManager = MockIdentityManager(keychain) + let service = BLEService( + keychain: keychain, + idBridge: NostrIdentityBridge(keychain: MockKeychainHelper()), + identityManager: identityManager, + initializeBluetoothManagers: false, + incomingFileStore: BLEIncomingFileStore(baseDirectory: base) + ) + #expect( + service._test_privateMediaReceiptState(messageID: messageID) + == .tombstoned + ) + + service.suspendForPanicReset() + // A receive callback drained during suspension can still consult the + // ledger and re-cache the pre-wipe decision before media deletion. + #expect( + service._test_privateMediaReceiptState(messageID: messageID) + == .tombstoned + ) + + // The wipe itself runs on the recovery operations' distinct store, + // exactly like ChatViewModel's panic transaction. + let recoveryStore = BLEIncomingFileStore(baseDirectory: base) + try recoveryStore.panicWipe() + service.completePanicReset(restartServices: false) + + #expect( + service._test_privateMediaReceiptState(messageID: messageID) + == .absent + ) + } + + @Test + func panicWipeInvalidatesPayloadCoordinationReservations() throws { + let base = FileManager.default.temporaryDirectory + .appendingPathComponent( + "panic-payload-coordination-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: base) } + let store = BLEIncomingFileStore(baseDirectory: base) + let pendingName = "pending-before-panic.jpg" + let pendingURL = try #require(store.save( + data: Data("old".utf8), + preferredName: pendingName, + subdirectory: "images/incoming", + fallbackExtension: "jpg", + defaultPrefix: "image" + )) + let messageID = "media-aabbccddeeff00112233445566778899" + let reservation = try #require(store.reservePrivateMediaDeletion( + messageIDs: [messageID], + payloadRelativePaths: [ + messageID: "images/incoming/delete-before-panic.jpg" + ] + )) + + try store.panicWipe() + + #expect(!store.commitPrivateMediaDeletion( + reservation: reservation, + messageIDs: [messageID], + payloadRelativePaths: [ + messageID: "images/incoming/delete-before-panic.jpg" + ], + protectedPayloadRelativePaths: [] + )) + let postPanicURL = try #require(store.save( + data: Data("new".utf8), + preferredName: pendingName, + subdirectory: "images/incoming", + fallbackExtension: "jpg", + defaultPrefix: "image" + )) + #expect(pendingURL.lastPathComponent == pendingName) + #expect(postPanicURL.lastPathComponent == pendingName) + } + + @Test + func panicWipeAttemptsDeletionWhenMarkerPersistenceFails() throws { + enum MarkerFailure: Error { case unavailable } + + let base = FileManager.default.temporaryDirectory + .appendingPathComponent( + "panic-marker-failure-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: base) } + let secret = base + .appendingPathComponent("files/images/outgoing", isDirectory: true) + .appendingPathComponent("secret.jpg") + try FileManager.default.createDirectory( + at: secret.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data("secret".utf8).write(to: secret) + let store = BLEIncomingFileStore( + baseDirectory: base, + panicMarkerWriter: { _, _ in throw MarkerFailure.unavailable } + ) + + do { + try store.panicWipe(hasDurablePendingMarker: false) + Issue.record("Expected the missing durable marker to fail closed") + } catch { + // The marker error is reported only after the deletion attempt. + } + + #expect(!FileManager.default.fileExists(atPath: secret.path)) + #expect( + FileManager.default.fileExists( + atPath: secret.deletingLastPathComponent().path + ) + ) + } + + @Test + func externalMarkerAllowsDeletionToCommitWhenFileMarkerFails() throws { + enum MarkerFailure: Error { case unavailable } + + let base = FileManager.default.temporaryDirectory + .appendingPathComponent( + "panic-external-marker-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: base) } + let secret = base + .appendingPathComponent("files/voicenotes/incoming", isDirectory: true) + .appendingPathComponent("secret.m4a") + try FileManager.default.createDirectory( + at: secret.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data("secret".utf8).write(to: secret) + let store = BLEIncomingFileStore( + baseDirectory: base, + panicMarkerWriter: { _, _ in throw MarkerFailure.unavailable } + ) + + try store.panicWipe(hasDurablePendingMarker: true) + + #expect(!FileManager.default.fileExists(atPath: secret.path)) + } + + @Test + func panicRecoveryMarkerPersistsUntilExplicitCommit() throws { + let base = FileManager.default.temporaryDirectory + .appendingPathComponent( + "panic-recovery-marker-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: base) } + let store = BLEIncomingFileStore(baseDirectory: base) + + try store.markPanicRecoveryPending() + #expect(try store.isPanicRecoveryPending()) + try store.panicWipe(hasDurablePendingMarker: true) + #expect(try store.isPanicRecoveryPending()) + + try store.completePanicRecovery() + + #expect(try !store.isPanicRecoveryPending()) + } + private func expectNoSideEffects(_ recorder: Recorder) { #expect(recorder.signedNameQueries.isEmpty) #expect(recorder.trackedPackets.isEmpty) @@ -403,7 +1408,8 @@ struct BLEFileTransferHandlerTests { content: Data, ttl: UInt8 = TransportConfig.messageTTLDefault, recipientID: Data? = nil, - fileName: String = "sample" + fileName: String = "sample", + hasSignature: Bool = true ) throws -> BitchatPacket { let filePacket = BitchatFilePacket( fileName: fileName, @@ -418,7 +1424,7 @@ struct BLEFileTransferHandlerTests { recipientID: recipientID, timestamp: 900_000, payload: payload, - signature: nil, + signature: hasSignature ? Data(repeating: 0x5A, count: 64) : nil, ttl: ttl ) } diff --git a/bitchatTests/Services/BLEFragmentAssemblyBufferTests.swift b/bitchatTests/Services/BLEFragmentAssemblyBufferTests.swift index a4423ec9..ed4da0a1 100644 --- a/bitchatTests/Services/BLEFragmentAssemblyBufferTests.swift +++ b/bitchatTests/Services/BLEFragmentAssemblyBufferTests.swift @@ -117,6 +117,35 @@ struct BLEFragmentAssemblyBufferTests { } } + @Test + func encryptedPrivateFileAssemblyGetsFramedFileHeadroom() throws { + var buffer = BLEFragmentAssemblyBuffer() + let fragmentID = Data(repeating: 0x15, count: 8) + let first = try #require(BLEFragmentHeader(packet: makeFragmentPacket( + fragmentID: fragmentID, + index: 0, + total: 2, + originalType: MessageType.noiseEncrypted.rawValue, + fragmentData: Data(repeating: 0x01, count: FileTransferLimits.maxPayloadBytes) + ))) + let second = try #require(BLEFragmentHeader(packet: makeFragmentPacket( + fragmentID: fragmentID, + index: 1, + total: 2, + originalType: MessageType.noiseEncrypted.rawValue, + fragmentData: Data([0x02]) + ))) + + _ = buffer.append(first, maxInFlightAssemblies: 8) + let result = buffer.append(second, maxInFlightAssemblies: 8) + + if case let .complete(_, data, _) = result { + #expect(data.count == FileTransferLimits.maxPayloadBytes + 1) + } else { + Issue.record("Expected encrypted private-file assembly to use framed-file limit") + } + } + @Test func removeExpiredDropsOldAssemblies() throws { var buffer = BLEFragmentAssemblyBuffer() diff --git a/bitchatTests/Services/BLELocalIdentityStateStoreTests.swift b/bitchatTests/Services/BLELocalIdentityStateStoreTests.swift new file mode 100644 index 00000000..ed10ab2f --- /dev/null +++ b/bitchatTests/Services/BLELocalIdentityStateStoreTests.swift @@ -0,0 +1,57 @@ +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +struct BLELocalIdentityStateStoreTests { + @Test + func identityReplacementUpdatesWireBytesAtomically() throws { + let initial = PeerID(str: "0011223344556677") + let replacement = PeerID(str: "8899aabbccddeeff") + let store = BLELocalIdentityStateStore(peerID: initial, nickname: "alice") + + store.replacePeerIdentity(with: replacement) + + let snapshot = store.snapshot() + #expect(snapshot.peerID == replacement) + #expect(snapshot.peerIDData == Data(hexString: replacement.id)) + #expect(snapshot.nickname == "alice") + } + + @Test + func concurrentReadsNeverObserveSplitIdentityState() { + let peerIDs = [ + PeerID(str: "0011223344556677"), + PeerID(str: "8899aabbccddeeff") + ] + let store = BLELocalIdentityStateStore(peerID: peerIDs[0], nickname: "alice") + let failures = LockedFailureRecorder() + + DispatchQueue.concurrentPerform(iterations: 2_000) { index in + if index.isMultiple(of: 2) { + store.replacePeerIdentity(with: peerIDs[index % peerIDs.count]) + } else { + store.setNickname(index.isMultiple(of: 3) ? "alice" : "bob") + } + + let snapshot = store.snapshot() + let expectedWireID = Data(hexString: snapshot.peerID.id) ?? Data() + if snapshot.peerIDData != expectedWireID { + failures.record() + } + } + + #expect(!failures.hasFailure) + } +} + +private final class LockedFailureRecorder: @unchecked Sendable { + private let lock = NSLock() + private var failed = false + + var hasFailure: Bool { lock.withLock { failed } } + + func record() { + lock.withLock { failed = true } + } +} diff --git a/bitchatTests/Services/BLENoisePacketHandlerTests.swift b/bitchatTests/Services/BLENoisePacketHandlerTests.swift index 0f40d316..cdfa6f81 100644 --- a/bitchatTests/Services/BLENoisePacketHandlerTests.swift +++ b/bitchatTests/Services/BLENoisePacketHandlerTests.swift @@ -1,4 +1,5 @@ import BitFoundation +import CryptoKit import Foundation import Testing @testable import bitchat @@ -8,8 +9,14 @@ struct BLENoisePacketHandlerTests { private final class Recorder { var handshakeResult: Result = .success(nil) + var handshakeAuthenticated = false var hasSession = false + let sessionGeneration = UUID() + var awaitingResponderHandshake = false var decryptResult: Result = .success(Data()) + var currentDate = Date(timeIntervalSince1970: 1_000) + var transportGenerationReady = false + var forcedServiceDecryptError: Error? var processedHandshakes: [(peerID: PeerID, message: Data)] = [] var hasSessionQueries: [PeerID] = [] @@ -18,6 +25,7 @@ struct BLENoisePacketHandlerTests { var lastSeenUpdates: [PeerID] = [] var decryptCalls: [(payload: Data, peerID: PeerID)] = [] var clearedSessions: [PeerID] = [] + var authenticatedPeerStates: [(peerID: PeerID, payload: Data, generation: UUID)] = [] var deliveries: [(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)] = [] /// Ordered side-effect log to assert recovery sequencing. var events: [String] = [] @@ -31,19 +39,27 @@ struct BLENoisePacketHandlerTests { recorder: Recorder, now: Date = Date(timeIntervalSince1970: 1_000) ) -> BLENoisePacketHandler { + recorder.currentDate = now let environment = BLENoisePacketHandlerEnvironment( localPeerID: { [localPeerID] in localPeerID }, localPeerIDData: { [localPeerIDData] in localPeerIDData }, messageTTL: TransportConfig.messageTTLDefault, - now: { now }, + now: { recorder.currentDate }, processHandshakeMessage: { peerID, message in recorder.processedHandshakes.append((peerID, message)) - return try recorder.handshakeResult.get() + return NoiseHandshakeProcessingResult( + response: try recorder.handshakeResult.get(), + didEstablishAuthenticatedSession: + recorder.handshakeAuthenticated + ) }, hasNoiseSession: { peerID in recorder.hasSessionQueries.append(peerID) return recorder.hasSession }, + isAwaitingResponderHandshakeCompletion: { _ in + recorder.awaitingResponderHandshake + }, initiateHandshake: { peerID in recorder.initiatedHandshakes.append(peerID) recorder.events.append("initiateHandshake") @@ -56,12 +72,18 @@ struct BLENoisePacketHandlerTests { }, decrypt: { payload, peerID in recorder.decryptCalls.append((payload, peerID)) - return try recorder.decryptResult.get() + return BLENoiseDecryptionResult( + plaintext: try recorder.decryptResult.get(), + sessionGeneration: recorder.sessionGeneration + ) }, clearSession: { peerID in recorder.clearedSessions.append(peerID) recorder.events.append("clearSession") }, + handleAuthenticatedPeerState: { peerID, payload, generation in + recorder.authenticatedPeerStates.append((peerID, payload, generation)) + }, deliverNoisePayload: { peerID, type, payload, timestamp in recorder.deliveries.append((peerID, type, payload, timestamp)) } @@ -69,6 +91,120 @@ struct BLENoisePacketHandlerTests { return BLENoisePacketHandler(environment: environment) } + private func makeServiceBackedHandler( + service: NoiseEncryptionService, + localPeerID: PeerID, + recorder: Recorder, + transportGenerationIsReady: + @escaping (UUID) -> Bool + ) -> BLENoisePacketHandler { + BLENoisePacketHandler( + environment: BLENoisePacketHandlerEnvironment( + localPeerID: { localPeerID }, + localPeerIDData: { + Data(hexString: localPeerID.id) ?? Data() + }, + messageTTL: TransportConfig.messageTTLDefault, + now: { recorder.currentDate }, + processHandshakeMessage: { peerID, message in + try service.processHandshakeMessageWithResult( + from: peerID, + message: message + ) + }, + hasNoiseSession: { peerID in + service.hasSession(with: peerID) + }, + isAwaitingResponderHandshakeCompletion: { peerID in + service.isAwaitingResponderHandshakeCompletion( + with: peerID + ) + }, + initiateHandshake: { peerID in + recorder.initiatedHandshakes.append(peerID) + }, + broadcastPacket: { packet in + recorder.broadcastPackets.append(packet) + }, + updatePeerLastSeen: { peerID in + recorder.lastSeenUpdates.append(peerID) + }, + decrypt: { payload, peerID in + recorder.decryptCalls.append((payload, peerID)) + if let error = recorder.forcedServiceDecryptError { + throw error + } + let result = + try service.decryptWithSessionGeneration( + payload, + from: peerID, + establishedGenerationIsReady: + transportGenerationIsReady + ) + return BLENoiseDecryptionResult( + plaintext: result.plaintext, + sessionGeneration: result.sessionGeneration + ) + }, + clearSession: { peerID in + recorder.clearedSessions.append(peerID) + service.clearSession(for: peerID) + }, + handleAuthenticatedPeerState: { + peerID, payload, generation in + recorder.authenticatedPeerStates.append( + (peerID, payload, generation) + ) + }, + deliverNoisePayload: { + peerID, type, payload, timestamp in + recorder.deliveries.append( + (peerID, type, payload, timestamp) + ) + } + ) + ) + } + + private func establishedServices() throws -> ( + sender: NoiseEncryptionService, + receiver: NoiseEncryptionService, + senderPeerID: PeerID, + receiverPeerID: PeerID + ) { + let sender = NoiseEncryptionService(keychain: MockKeychain()) + let receiver = NoiseEncryptionService(keychain: MockKeychain()) + let senderPeerID = PeerID( + publicKey: sender.getStaticPublicKeyData() + ) + let receiverPeerID = PeerID( + publicKey: receiver.getStaticPublicKeyData() + ) + let message1 = try sender.initiateHandshake(with: receiverPeerID) + let message2 = try #require( + try receiver.processHandshakeMessage( + from: senderPeerID, + message: message1 + ) + ) + let message3 = try #require( + try sender.processHandshakeMessage( + from: receiverPeerID, + message: message2 + ) + ) + _ = try receiver.processHandshakeMessage( + from: senderPeerID, + message: message3 + ) + return ( + sender, + receiver, + senderPeerID, + receiverPeerID + ) + } + // MARK: Handshake @Test @@ -110,6 +246,24 @@ struct BLENoisePacketHandlerTests { #expect(recorder.initiatedHandshakes.isEmpty) } + @Test + func handshakeResultPreservesExactCandidateAuthentication() { + let recorder = Recorder() + recorder.handshakeAuthenticated = true + let handler = makeHandler(recorder: recorder) + let packet = makeHandshakePacket( + recipientID: Data(hexString: localPeerID.id) + ) + + let result = handler.handleHandshakeWithResult( + packet, + from: remotePeerID + ) + + #expect(result.processed) + #expect(result.didEstablishAuthenticatedSession) + } + @Test func handshakeForAnotherPeerIsIgnored() { let recorder = Recorder() @@ -152,6 +306,39 @@ struct BLENoisePacketHandlerTests { #expect(recorder.initiatedHandshakes.isEmpty) } + @Test + func peerIdentityMismatchDoesNotRecreateHandshakeState() { + let recorder = Recorder() + recorder.handshakeResult = .failure(NoiseSessionError.peerIdentityMismatch) + recorder.hasSession = false + let handler = makeHandler(recorder: recorder) + let packet = makeHandshakePacket(recipientID: Data(hexString: localPeerID.id)) + + #expect(!handler.handleHandshake(packet, from: remotePeerID)) + + #expect(recorder.hasSessionQueries.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + #expect(recorder.broadcastPackets.isEmpty) + } + + @Test + func managedHandshakeFailureDoesNotStartASecondRecovery() { + let recorder = Recorder() + recorder.handshakeResult = .failure( + NoiseManagedHandshakeFailure(underlying: TestError()) + ) + recorder.hasSession = false + let handler = makeHandler(recorder: recorder) + let packet = makeHandshakePacket( + recipientID: Data(hexString: localPeerID.id) + ) + + #expect(!handler.handleHandshake(packet, from: remotePeerID)) + #expect(recorder.hasSessionQueries.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + #expect(recorder.broadcastPackets.isEmpty) + } + // MARK: Encrypted @Test @@ -206,6 +393,25 @@ struct BLENoisePacketHandlerTests { #expect(recorder.initiatedHandshakes.isEmpty) } + @Test + func authenticatedPeerStateIsConsumedByTransportNotDeliveredToUI() { + let recorder = Recorder() + recorder.decryptResult = .success(Data([ + NoisePayloadType.authenticatedPeerState.rawValue, + 0x01, 0x02, 0x03 + ])) + let handler = makeHandler(recorder: recorder) + let packet = makeEncryptedPacket(recipientID: Data(hexString: localPeerID.id)) + + handler.handleEncrypted(packet, from: remotePeerID) + + #expect(recorder.authenticatedPeerStates.count == 1) + #expect(recorder.authenticatedPeerStates.first?.peerID == remotePeerID) + #expect(recorder.authenticatedPeerStates.first?.payload == Data([0x01, 0x02, 0x03])) + #expect(recorder.authenticatedPeerStates.first?.generation == recorder.sessionGeneration) + #expect(recorder.deliveries.isEmpty) + } + @Test func emptyDecryptedPayloadIsIgnored() { let recorder = Recorder() @@ -281,6 +487,799 @@ struct BLENoisePacketHandlerTests { #expect(recorder.deliveries.isEmpty) } + @Test + func earlyCiphertextIsRetriedAfterResponderHandshakeCompletes() { + let recorder = Recorder() + recorder.hasSession = true + recorder.awaitingResponderHandshake = true + recorder.decryptResult = .failure( + CryptoKitError.authenticationFailure + ) + let handler = makeHandler(recorder: recorder) + let encrypted = makeEncryptedPacket( + recipientID: Data(hexString: localPeerID.id) + ) + + handler.handleEncrypted(encrypted, from: remotePeerID) + + #expect(recorder.decryptCalls.count == 1) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + + recorder.awaitingResponderHandshake = false + recorder.decryptResult = .success( + Data([NoisePayloadType.privateMessage.rawValue, 0xCA, 0xFE]) + ) + handler.handleSessionAuthenticated(remotePeerID) + handler.handleSessionAuthenticated(remotePeerID) + + #expect(recorder.decryptCalls.count == 2) + #expect(recorder.deliveries.count == 1) + #expect(recorder.deliveries.first?.type == .privateMessage) + #expect(recorder.deliveries.first?.payload == Data([0xCA, 0xFE])) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func panicResetDiscardsDeferredCiphertextBeforeFutureAuthentication() { + let recorder = Recorder() + recorder.hasSession = true + recorder.awaitingResponderHandshake = true + recorder.decryptResult = .failure( + CryptoKitError.authenticationFailure + ) + let handler = makeHandler(recorder: recorder) + let prePanicCiphertext = makeEncryptedPacket( + recipientID: Data(hexString: localPeerID.id), + payload: Data( + count: NoiseSecurityConstants.maxMessageSize + + NoiseSecurityConstants.transportCiphertextOverhead + ) + ) + + handler.handleEncrypted(prePanicCiphertext, from: remotePeerID) + #expect(recorder.decryptCalls.count == 1) + + handler.resetForPanic() + + // Three maximum-sized packets fit only when reset also zeroed the + // global byte accounting. They model ciphertext received under the + // replacement identity before that responder handshake completes. + for index in 0..<3 { + handler.handleEncrypted( + makeEncryptedPacket( + recipientID: Data(hexString: localPeerID.id), + timestamp: UInt64(901_000 + index), + payload: Data( + count: NoiseSecurityConstants.maxMessageSize + + NoiseSecurityConstants.transportCiphertextOverhead + ) + ), + from: remotePeerID + ) + } + #expect(recorder.decryptCalls.count == 4) + + recorder.awaitingResponderHandshake = false + recorder.decryptResult = .success( + Data([NoisePayloadType.privateMessage.rawValue, 0xCA, 0xFE]) + ) + handler.handleSessionAuthenticated(remotePeerID) + + // Only the three post-reset packets replay; the pre-panic packet does + // not survive into the replacement session. + #expect(recorder.decryptCalls.count == 7) + #expect(recorder.deliveries.count == 3) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func ciphertextQueuedAheadOfEstablishmentCallbackDoesNotConsumeNonce() + throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let alicePeerID = PeerID( + publicKey: alice.getStaticPublicKeyData() + ) + let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) + + let message1 = try alice.initiateHandshake(with: bobPeerID) + let message2 = try #require( + try bob.processHandshakeMessage( + from: alicePeerID, + message: message1 + ) + ) + let message3 = try #require( + try alice.processHandshakeMessage( + from: bobPeerID, + message: message2 + ) + ) + let typedPayload = Data([ + NoisePayloadType.privateMessage.rawValue, + 0xCA, 0xFE + ]) + let ciphertext = try alice.encrypt( + typedPayload, + for: bobPeerID + ) + + // Manager promotion has completed, but the serialized BLE callback is + // deliberately still behind this ciphertext. + _ = try bob.processHandshakeMessage( + from: alicePeerID, + message: message3 + ) + let recorder = Recorder() + recorder.transportGenerationReady = false + let handler = makeServiceBackedHandler( + service: bob, + localPeerID: bobPeerID, + recorder: recorder, + transportGenerationIsReady: { _ in + recorder.transportGenerationReady + } + ) + let packet = makeEncryptedPacket( + recipientID: Data(hexString: bobPeerID.id), + payload: ciphertext + ) + + handler.handleEncrypted(packet, from: alicePeerID) + #expect(recorder.deliveries.isEmpty) + #expect(recorder.clearedSessions.isEmpty) + + // The exact ciphertext must still authenticate, proving the readiness + // rejection happened before the receive nonce was consumed. + recorder.transportGenerationReady = true + handler.handleSessionAuthenticated(alicePeerID) + + #expect(recorder.decryptCalls.count == 2) + #expect(recorder.deliveries.count == 1) + #expect(recorder.deliveries.first?.type == .privateMessage) + #expect(recorder.deliveries.first?.payload == Data([0xCA, 0xFE])) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func ciphertextQueuedAheadOfRestoreCallbackDoesNotConsumeNonce() + throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let mallory = NoiseEncryptionService(keychain: MockKeychain()) + let alicePeerID = PeerID( + publicKey: alice.getStaticPublicKeyData() + ) + let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) + + let initial1 = try alice.initiateHandshake(with: bobPeerID) + let initial2 = try #require( + try bob.processHandshakeMessage( + from: alicePeerID, + message: initial1 + ) + ) + let initial3 = try #require( + try alice.processHandshakeMessage( + from: bobPeerID, + message: initial2 + ) + ) + _ = try bob.processHandshakeMessage( + from: alicePeerID, + message: initial3 + ) + let typedPayload = Data([ + NoisePayloadType.privateMessage.rawValue, + 0xBE, 0xEF + ]) + let delayedCiphertext = try alice.encrypt( + typedPayload, + for: bobPeerID + ) + + let forged1 = try mallory.initiateHandshake(with: bobPeerID) + let forged2 = try #require( + try bob.processHandshakeMessage( + from: alicePeerID, + message: forged1 + ) + ) + let forged3 = try #require( + try mallory.processHandshakeMessage( + from: bobPeerID, + message: forged2 + ) + ) + #expect(throws: NoiseSessionError.peerIdentityMismatch) { + try bob.processHandshakeMessage( + from: alicePeerID, + message: forged3 + ) + } + #expect(bob.hasEstablishedSession(with: alicePeerID)) + + let recorder = Recorder() + recorder.transportGenerationReady = false + let handler = makeServiceBackedHandler( + service: bob, + localPeerID: bobPeerID, + recorder: recorder, + transportGenerationIsReady: { _ in + recorder.transportGenerationReady + } + ) + let packet = makeEncryptedPacket( + recipientID: Data(hexString: bobPeerID.id), + payload: delayedCiphertext + ) + + // Manager rollback is visible, while the BLE restore callback is + // deliberately still queued behind this ciphertext. + handler.handleEncrypted(packet, from: alicePeerID) + #expect(recorder.deliveries.isEmpty) + #expect(recorder.clearedSessions.isEmpty) + + recorder.transportGenerationReady = true + handler.handleSessionAuthenticated(alicePeerID) + + #expect(recorder.decryptCalls.count == 2) + #expect(recorder.deliveries.count == 1) + #expect(recorder.deliveries.first?.type == .privateMessage) + #expect(recorder.deliveries.first?.payload == Data([0xBE, 0xEF])) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func oversizedCiphertextCannotEvictEstablishedTransport() throws { + let pair = try establishedServices() + let recorder = Recorder() + recorder.transportGenerationReady = true + let handler = makeServiceBackedHandler( + service: pair.receiver, + localPeerID: pair.receiverPeerID, + recorder: recorder, + transportGenerationIsReady: { _ in + recorder.transportGenerationReady + } + ) + + handler.handleEncrypted( + makeEncryptedPacket( + recipientID: Data(hexString: pair.receiverPeerID.id), + payload: Data( + count: + NoiseSecurityConstants + .maxPrivateFileCiphertextSize + 1 + ) + ), + from: pair.senderPeerID + ) + #expect( + pair.receiver.hasEstablishedSession(with: pair.senderPeerID) + ) + #expect(recorder.clearedSessions.isEmpty) + + let valid = try pair.sender.encrypt( + Data([NoisePayloadType.privateMessage.rawValue, 0x01]), + for: pair.receiverPeerID + ) + handler.handleEncrypted( + makeEncryptedPacket( + recipientID: Data(hexString: pair.receiverPeerID.id), + payload: valid + ), + from: pair.senderPeerID + ) + + #expect(recorder.deliveries.count == 1) + #expect(recorder.deliveries.first?.payload == Data([0x01])) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func forgedAuthenticationFailureCannotEvictEstablishedTransport() + throws { + let pair = try establishedServices() + let recorder = Recorder() + recorder.transportGenerationReady = true + let handler = makeServiceBackedHandler( + service: pair.receiver, + localPeerID: pair.receiverPeerID, + recorder: recorder, + transportGenerationIsReady: { _ in + recorder.transportGenerationReady + } + ) + let valid = try pair.sender.encrypt( + Data([NoisePayloadType.privateMessage.rawValue, 0x02]), + for: pair.receiverPeerID + ) + var forged = valid + forged[forged.index(before: forged.endIndex)] ^= 0xFF + + handler.handleEncrypted( + makeEncryptedPacket( + recipientID: Data(hexString: pair.receiverPeerID.id), + payload: forged + ), + from: pair.senderPeerID + ) + #expect( + pair.receiver.hasEstablishedSession(with: pair.senderPeerID) + ) + #expect(recorder.clearedSessions.isEmpty) + + // Authentication failure leaves nonce state untouched, so the exact + // original ciphertext remains valid. + handler.handleEncrypted( + makeEncryptedPacket( + recipientID: Data(hexString: pair.receiverPeerID.id), + payload: valid + ), + from: pair.senderPeerID + ) + + #expect(recorder.deliveries.count == 1) + #expect(recorder.deliveries.first?.payload == Data([0x02])) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func replayCannotEvictEstablishedTransportOrBlockNextNonce() throws { + let pair = try establishedServices() + let recorder = Recorder() + recorder.transportGenerationReady = true + let handler = makeServiceBackedHandler( + service: pair.receiver, + localPeerID: pair.receiverPeerID, + recorder: recorder, + transportGenerationIsReady: { _ in + recorder.transportGenerationReady + } + ) + let first = try pair.sender.encrypt( + Data([NoisePayloadType.privateMessage.rawValue, 0x03]), + for: pair.receiverPeerID + ) + let firstPacket = makeEncryptedPacket( + recipientID: Data(hexString: pair.receiverPeerID.id), + payload: first + ) + + handler.handleEncrypted(firstPacket, from: pair.senderPeerID) + handler.handleEncrypted(firstPacket, from: pair.senderPeerID) + #expect( + pair.receiver.hasEstablishedSession(with: pair.senderPeerID) + ) + #expect(recorder.clearedSessions.isEmpty) + + let next = try pair.sender.encrypt( + Data([NoisePayloadType.privateMessage.rawValue, 0x04]), + for: pair.receiverPeerID + ) + handler.handleEncrypted( + makeEncryptedPacket( + recipientID: Data(hexString: pair.receiverPeerID.id), + payload: next + ), + from: pair.senderPeerID + ) + + #expect(recorder.deliveries.count == 2) + #expect(recorder.deliveries.map { $0.payload } == [ + Data([0x03]), Data([0x04]) + ]) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func rateLimitFailureCannotEvictEstablishedTransportOrConsumeNonce() + throws { + let pair = try establishedServices() + let recorder = Recorder() + recorder.transportGenerationReady = true + recorder.forcedServiceDecryptError = + NoiseSecurityError.rateLimitExceeded + let handler = makeServiceBackedHandler( + service: pair.receiver, + localPeerID: pair.receiverPeerID, + recorder: recorder, + transportGenerationIsReady: { _ in + recorder.transportGenerationReady + } + ) + let valid = try pair.sender.encrypt( + Data([NoisePayloadType.privateMessage.rawValue, 0x05]), + for: pair.receiverPeerID + ) + + handler.handleEncrypted( + makeEncryptedPacket( + recipientID: Data(hexString: pair.receiverPeerID.id), + payload: Data(repeating: 0xA5, count: 20) + ), + from: pair.senderPeerID + ) + #expect( + pair.receiver.hasEstablishedSession(with: pair.senderPeerID) + ) + #expect(recorder.clearedSessions.isEmpty) + + recorder.forcedServiceDecryptError = nil + handler.handleEncrypted( + makeEncryptedPacket( + recipientID: Data(hexString: pair.receiverPeerID.id), + payload: valid + ), + from: pair.senderPeerID + ) + + #expect(recorder.deliveries.count == 1) + #expect(recorder.deliveries.first?.payload == Data([0x05])) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func maximumPrivateFileCiphertextIsEligibleForDeferredRetry() { + let recorder = Recorder() + recorder.hasSession = true + recorder.awaitingResponderHandshake = true + recorder.decryptResult = .failure( + CryptoKitError.authenticationFailure + ) + let handler = makeHandler(recorder: recorder) + let encrypted = makeEncryptedPacket( + recipientID: Data(hexString: localPeerID.id), + payload: Data( + count: NoiseSecurityConstants.maxPrivateFileCiphertextSize + ) + ) + + handler.handleEncrypted(encrypted, from: remotePeerID) + recorder.awaitingResponderHandshake = false + recorder.decryptResult = .success( + Data([NoisePayloadType.privateFile.rawValue, 0x01]) + ) + handler.handleSessionAuthenticated(remotePeerID) + + #expect(recorder.decryptCalls.count == 2) + #expect(recorder.deliveries.count == 1) + #expect(recorder.deliveries.first?.type == .privateFile) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func oversizedEarlyCiphertextIsNotDeferred() { + let recorder = Recorder() + recorder.hasSession = true + recorder.awaitingResponderHandshake = true + recorder.decryptResult = .failure( + CryptoKitError.authenticationFailure + ) + let handler = makeHandler(recorder: recorder) + let encrypted = makeEncryptedPacket( + recipientID: Data(hexString: localPeerID.id), + payload: Data( + count: + NoiseSecurityConstants.maxPrivateFileCiphertextSize + 1 + ) + ) + + handler.handleEncrypted(encrypted, from: remotePeerID) + recorder.awaitingResponderHandshake = false + recorder.decryptResult = .success( + Data([NoisePayloadType.delivered.rawValue, 0x01]) + ) + handler.handleSessionAuthenticated(remotePeerID) + + #expect(recorder.decryptCalls.count == 1) + #expect(recorder.deliveries.isEmpty) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func missingSessionCiphertextIsRetriedAfterResponderHandshakeCompletes() { + let recorder = Recorder() + recorder.hasSession = true + recorder.awaitingResponderHandshake = true + recorder.decryptResult = .failure( + NoiseEncryptionError.sessionNotEstablished + ) + let handler = makeHandler(recorder: recorder) + let encrypted = makeEncryptedPacket( + recipientID: Data(hexString: localPeerID.id) + ) + + handler.handleEncrypted(encrypted, from: remotePeerID) + #expect(recorder.decryptCalls.count == 1) + #expect(recorder.initiatedHandshakes.isEmpty) + + recorder.awaitingResponderHandshake = false + recorder.decryptResult = .success( + Data([NoisePayloadType.delivered.rawValue, 0x01]) + ) + handler.handleSessionAuthenticated(remotePeerID) + + #expect(recorder.decryptCalls.count == 2) + #expect(recorder.deliveries.count == 1) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func lowNonceCiphertextIsRetriedAfterResponderHandshakeCompletes() { + let recorder = Recorder() + recorder.hasSession = true + recorder.awaitingResponderHandshake = true + recorder.decryptResult = .failure(NoiseError.replayDetected) + let handler = makeHandler(recorder: recorder) + let encrypted = makeEncryptedPacket( + recipientID: Data(hexString: localPeerID.id) + ) + + handler.handleEncrypted(encrypted, from: remotePeerID) + recorder.awaitingResponderHandshake = false + recorder.decryptResult = .success( + Data([NoisePayloadType.readReceipt.rawValue, 0x02]) + ) + handler.handleSessionAuthenticated(remotePeerID) + + #expect(recorder.decryptCalls.count == 2) + #expect(recorder.deliveries.count == 1) + #expect(recorder.deliveries.first?.type == .readReceipt) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func invalidDeferredCiphertextDoesNotClearAuthenticatedSession() { + let recorder = Recorder() + recorder.hasSession = true + recorder.awaitingResponderHandshake = true + recorder.decryptResult = .failure( + CryptoKitError.authenticationFailure + ) + let handler = makeHandler(recorder: recorder) + let encrypted = makeEncryptedPacket( + recipientID: Data(hexString: localPeerID.id) + ) + + handler.handleEncrypted(encrypted, from: remotePeerID) + recorder.awaitingResponderHandshake = false + handler.handleSessionAuthenticated(remotePeerID) + + #expect(recorder.decryptCalls.count == 2) + #expect(recorder.deliveries.isEmpty) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func nonCipherFailureDuringResponderHandshakeIsDroppedNotDeferred() { + let recorder = Recorder() + recorder.hasSession = true + recorder.awaitingResponderHandshake = true + recorder.decryptResult = .failure(TestError()) + let handler = makeHandler(recorder: recorder) + handler.handleEncrypted( + makeEncryptedPacket( + recipientID: Data(hexString: localPeerID.id) + ), + from: remotePeerID + ) + + recorder.awaitingResponderHandshake = false + recorder.decryptResult = .success( + Data([NoisePayloadType.delivered.rawValue, 0x01]) + ) + handler.handleSessionAuthenticated(remotePeerID) + + #expect(recorder.decryptCalls.count == 1) + #expect(recorder.deliveries.isEmpty) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func earlyCiphertextBufferIsBoundedPerPeer() { + let recorder = Recorder() + recorder.hasSession = true + recorder.awaitingResponderHandshake = true + recorder.decryptResult = .failure( + CryptoKitError.authenticationFailure + ) + let handler = makeHandler(recorder: recorder) + + for index in 0..<5 { + handler.handleEncrypted( + makeEncryptedPacket( + recipientID: Data(hexString: localPeerID.id), + timestamp: UInt64(900_000 + index) + ), + from: remotePeerID + ) + } + #expect(recorder.decryptCalls.count == 5) + + recorder.awaitingResponderHandshake = false + recorder.decryptResult = .success( + Data([NoisePayloadType.delivered.rawValue, 0x01]) + ) + handler.handleSessionAuthenticated(remotePeerID) + + #expect(recorder.decryptCalls.count == 9) + #expect(recorder.deliveries.count == 4) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func earlyCiphertextBufferIsBoundedGlobally() { + let recorder = Recorder() + recorder.hasSession = true + recorder.awaitingResponderHandshake = true + recorder.decryptResult = .failure( + CryptoKitError.authenticationFailure + ) + let handler = makeHandler(recorder: recorder) + let peers = (1...33).map { + PeerID(str: String(format: "%016llx", UInt64($0))) + } + let packet = makeEncryptedPacket( + recipientID: Data(hexString: localPeerID.id) + ) + + for peerID in peers { + handler.handleEncrypted(packet, from: peerID) + } + #expect(recorder.decryptCalls.count == 33) + + recorder.awaitingResponderHandshake = false + recorder.decryptResult = .success( + Data([NoisePayloadType.delivered.rawValue, 0x01]) + ) + for peerID in peers { + handler.handleSessionAuthenticated(peerID) + } + + #expect(recorder.decryptCalls.count == 65) + #expect(recorder.deliveries.count == 32) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func earlyCiphertextBufferKeepsPrivateFileRoomAndByteBound() { + let recorder = Recorder() + recorder.hasSession = true + recorder.awaitingResponderHandshake = true + recorder.decryptResult = .failure( + CryptoKitError.authenticationFailure + ) + let handler = makeHandler(recorder: recorder) + let peers = [ + PeerID(str: "0000000000000001"), + PeerID(str: "0000000000000002"), + PeerID(str: "0000000000000003") + ] + + handler.handleEncrypted( + makeEncryptedPacket( + recipientID: Data(hexString: localPeerID.id), + payload: Data( + count: + NoiseSecurityConstants.maxPrivateFileCiphertextSize + ) + ), + from: peers[0] + ) + handler.handleEncrypted( + makeEncryptedPacket( + recipientID: Data(hexString: localPeerID.id), + payload: Data(count: 256 * 1024) + ), + from: peers[1] + ) + handler.handleEncrypted( + makeEncryptedPacket( + recipientID: Data(hexString: localPeerID.id), + payload: Data([0x01]) + ), + from: peers[2] + ) + + recorder.awaitingResponderHandshake = false + recorder.decryptResult = .success( + Data([NoisePayloadType.delivered.rawValue, 0x01]) + ) + for peerID in peers { + handler.handleSessionAuthenticated(peerID) + } + + #expect(recorder.decryptCalls.count == 5) + #expect(recorder.deliveries.count == 2) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func expiredEarlyCiphertextIsNotRetried() { + let recorder = Recorder() + recorder.hasSession = true + recorder.awaitingResponderHandshake = true + recorder.decryptResult = .failure( + CryptoKitError.authenticationFailure + ) + let handler = makeHandler(recorder: recorder) + handler.handleEncrypted( + makeEncryptedPacket( + recipientID: Data(hexString: localPeerID.id) + ), + from: remotePeerID + ) + + recorder.currentDate = + recorder.currentDate.addingTimeInterval( + NoiseSecurityConstants.ordinaryResponderHandshakeTimeout + + 0.001 + ) + recorder.awaitingResponderHandshake = false + recorder.decryptResult = .success( + Data([NoisePayloadType.delivered.rawValue, 0x01]) + ) + handler.handleSessionAuthenticated(remotePeerID) + + #expect(recorder.decryptCalls.count == 1) + #expect(recorder.deliveries.isEmpty) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func earlyCiphertextSurvivesResponderHandshakeWindow() { + let recorder = Recorder() + recorder.hasSession = true + recorder.awaitingResponderHandshake = true + recorder.decryptResult = .failure( + CryptoKitError.authenticationFailure + ) + let handler = makeHandler(recorder: recorder) + handler.handleEncrypted( + makeEncryptedPacket( + recipientID: Data(hexString: localPeerID.id) + ), + from: remotePeerID + ) + + recorder.currentDate = + recorder.currentDate.addingTimeInterval( + NoiseSecurityConstants.ordinaryResponderHandshakeTimeout + - 0.001 + ) + recorder.awaitingResponderHandshake = false + recorder.decryptResult = .success( + Data([NoisePayloadType.delivered.rawValue, 0x01]) + ) + handler.handleSessionAuthenticated(remotePeerID) + + #expect(recorder.decryptCalls.count == 2) + #expect(recorder.deliveries.count == 1) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + private func makeHandshakePacket(recipientID: Data?) -> BitchatPacket { BitchatPacket( type: MessageType.noiseHandshake.rawValue, @@ -295,14 +1294,15 @@ struct BLENoisePacketHandlerTests { private func makeEncryptedPacket( recipientID: Data?, - timestamp: UInt64 = 900_000 + timestamp: UInt64 = 900_000, + payload: Data = Data([0xC0, 0xFF, 0xEE]) ) -> BitchatPacket { BitchatPacket( type: MessageType.noiseEncrypted.rawValue, senderID: Data(hexString: remotePeerID.id) ?? Data(), recipientID: recipientID, timestamp: timestamp, - payload: Data([0xC0, 0xFF, 0xEE]), + payload: payload, signature: nil, ttl: TransportConfig.messageTTLDefault ) diff --git a/bitchatTests/Services/BLENoisePayloadFactoryTests.swift b/bitchatTests/Services/BLENoisePayloadFactoryTests.swift index ab743c9a..c1722f8a 100644 --- a/bitchatTests/Services/BLENoisePayloadFactoryTests.swift +++ b/bitchatTests/Services/BLENoisePayloadFactoryTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import BitFoundation @testable import bitchat struct BLENoisePayloadFactoryTests { @@ -31,4 +32,63 @@ struct BLENoisePayloadFactoryTests { #expect(payload == Data([NoisePayloadType.verifyChallenge.rawValue, 0xCA, 0xFE])) } + + @Test + func privateFilePayloadPrefixesCanonicalFilePacket() throws { + let content = Data("%PDF-secret".utf8) + let file = BitchatFilePacket( + fileName: "secret.pdf", + fileSize: UInt64(content.count), + mimeType: "application/pdf", + content: content + ) + + let payload = try #require(BLENoisePayloadFactory.privateFile(file)) + + #expect(payload.first == 0x20, "Encrypted files must use Android's deployed wire value") + let decoded = try #require(BitchatFilePacket.decode(Data(payload.dropFirst()))) + #expect(decoded.fileName == "secret.pdf") + #expect(decoded.mimeType == "application/pdf") + #expect(decoded.content == content) + } + + @Test + func androidB7f0b33PrivateFilePlaintextFixtureIsByteCompatible() throws { + // Runtime-emitted by Android commit b7f0b33d from + // BitchatFilePacket("a.txt", 3, "text/plain", [01, 02, 03]) and + // NoisePayload(type = FILE_TRANSFER, data = file.encode()).encode(). + let fixtureHex = "20010005612e7478740200040000000303000a746578742f706c61696e0400000003010203" + let fixture = try #require(Data(hexString: fixtureHex)) + + let typed = try #require(NoisePayload.decode(fixture)) + #expect(typed.type == .privateFile) + let file = try #require(BitchatFilePacket.decode(typed.data)) + #expect(file.fileName == "a.txt") + #expect(file.fileSize == 3) + #expect(file.mimeType == "text/plain") + #expect(file.content == Data([0x01, 0x02, 0x03])) + #expect(BLENoisePayloadFactory.privateFile(file) == fixture) + } + + @Test + func prereleasePrivateFileTypeCanonicalizesOnDecode() throws { + let encoded = Data([NoisePayloadType.prereleasePrivateFileRawValue, 0xCA, 0xFE]) + let decoded = try #require(NoisePayload.decode(encoded)) + + #expect(decoded.type == .privateFile) + #expect(decoded.data == Data([0xCA, 0xFE])) + #expect(decoded.encode().first == 0x20) + } + + @Test + func authenticatedPeerStateUsesPermanent0x21Type() throws { + let state = AuthenticatedPeerStatePacket( + capabilities: .privateMedia, + signingPublicKey: Data(repeating: 0x77, count: 32) + ) + let encoded = try #require(BLENoisePayloadFactory.authenticatedPeerState(state)) + + #expect(encoded.first == 0x21) + #expect(AuthenticatedPeerStatePacket.decode(from: Data(encoded.dropFirst())) == state) + } } diff --git a/bitchatTests/Services/BLENoiseReconnectPolicyTests.swift b/bitchatTests/Services/BLENoiseReconnectPolicyTests.swift new file mode 100644 index 00000000..8e8f025b --- /dev/null +++ b/bitchatTests/Services/BLENoiseReconnectPolicyTests.swift @@ -0,0 +1,118 @@ +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +@Suite("BLE Noise reconnect policy") +struct BLENoiseReconnectPolicyTests { + @Test("Revalidation requires a cached session and no authenticated link") + func revalidationPreconditions() { + var policy = BLENoiseReconnectPolicy() + let link = BLEIngressLinkID.peripheral("peripheral-a") + let now = Date(timeIntervalSince1970: 1_000) + + let withoutSession = policy.shouldRevalidate( + on: link, + hasEstablishedSession: false, + isNoiseAuthenticatedLink: false, + hasAuthenticatedPeerLink: false, + now: now + ) + #expect(!withoutSession) + let authenticated = policy.shouldRevalidate( + on: link, + hasEstablishedSession: true, + isNoiseAuthenticatedLink: true, + hasAuthenticatedPeerLink: true, + now: now + ) + #expect(!authenticated) + let eligible = policy.shouldRevalidate( + on: link, + hasEstablishedSession: true, + isNoiseAuthenticatedLink: false, + hasAuthenticatedPeerLink: false, + now: now + ) + #expect(eligible) + } + + @Test("Revalidation is once per link epoch or after sixty seconds") + func revalidationIsBoundPerLinkEpoch() { + var policy = BLENoiseReconnectPolicy() + let link = BLEIngressLinkID.central("central-a") + let start = Date(timeIntervalSince1970: 2_000) + + let initial = policy.shouldRevalidate( + on: link, + hasEstablishedSession: true, + isNoiseAuthenticatedLink: false, + hasAuthenticatedPeerLink: false, + now: start + ) + #expect(initial) + let duringCooldown = policy.shouldRevalidate( + on: link, + hasEstablishedSession: true, + isNoiseAuthenticatedLink: false, + hasAuthenticatedPeerLink: false, + now: start.addingTimeInterval(59.999) + ) + #expect(!duringCooldown) + let afterCooldown = policy.shouldRevalidate( + on: link, + hasEstablishedSession: true, + isNoiseAuthenticatedLink: false, + hasAuthenticatedPeerLink: false, + now: start.addingTimeInterval(60) + ) + #expect(afterCooldown) + + policy.endLinkEpoch(link) + let nextEpoch = policy.shouldRevalidate( + on: link, + hasEstablishedSession: true, + isNoiseAuthenticatedLink: false, + hasAuthenticatedPeerLink: false, + now: start.addingTimeInterval(60.001) + ) + #expect(nextEpoch) + } + + @Test("An authenticated sibling suppresses redundant reconnect") + func authenticatedSiblingSuppressesReconnect() { + var policy = BLENoiseReconnectPolicy() + let link = BLEIngressLinkID.peripheral("unproven-sibling") + let start = Date(timeIntervalSince1970: 3_000) + + let suppressed = policy.shouldRevalidate( + on: link, + hasEstablishedSession: true, + isNoiseAuthenticatedLink: false, + hasAuthenticatedPeerLink: true, + now: start + ) + #expect(!suppressed) + let eligible = policy.shouldRevalidate( + on: link, + hasEstablishedSession: true, + isNoiseAuthenticatedLink: false, + hasAuthenticatedPeerLink: false, + now: start + ) + #expect(eligible) + } + + @Test("Reserved replacement bit is not advertised") + func reservedReplacementBitIsNotAdvertised() { + #expect( + !PeerCapabilities.localSupported.contains( + .nonDestructiveNoiseReplacement + ) + ) + #expect(PeerCapabilities.localSupported.contains(.privateMedia)) + #expect( + PeerCapabilities.localSupported.contains(.privateMediaReceipts) + ) + } +} diff --git a/bitchatTests/Services/BLENoiseSessionQueuesTests.swift b/bitchatTests/Services/BLENoiseSessionQueuesTests.swift index 33cb0fe7..a933118e 100644 --- a/bitchatTests/Services/BLENoiseSessionQueuesTests.swift +++ b/bitchatTests/Services/BLENoiseSessionQueuesTests.swift @@ -48,7 +48,10 @@ struct BLENoiseSessionQueuesTests { queues.appendTypedPayload(Data([0x01]), for: peerID) queues.appendTypedPayload(Data([0x02]), for: peerID) - #expect(queues.takeTypedPayloads(for: peerID) == [Data([0x01]), Data([0x02])]) + #expect(queues.takeTypedPayloads(for: peerID) == [ + BLEPendingTypedPayload(payload: Data([0x01]), transferId: nil), + BLEPendingTypedPayload(payload: Data([0x02]), transferId: nil) + ]) #expect(queues.takeTypedPayloads(for: peerID).isEmpty) #expect(queues.takePrivateMessages(for: peerID).map(\.messageID) == ["m1"]) } @@ -64,4 +67,21 @@ struct BLENoiseSessionQueuesTests { #expect(queues.isEmpty) } + + @Test + func transferIDSurvivesHandshakeQueueAndCanBeCancelledBeforeDrain() { + let peerID = PeerID(str: "aaaaaaaaaaaaaaaa") + var queues = BLENoiseSessionQueues() + + queues.appendTypedPayload(Data([0x20, 0xAA]), transferId: "media-1", for: peerID) + queues.appendTypedPayload(Data([0x01, 0xBB]), for: peerID) + + let removed = queues.removeTypedPayload(transferId: "media-1") + let removedAgain = queues.removeTypedPayload(transferId: "media-1") + #expect(removed) + #expect(!removedAgain) + #expect(queues.takeTypedPayloads(for: peerID) == [ + BLEPendingTypedPayload(payload: Data([0x01, 0xBB]), transferId: nil) + ]) + } } diff --git a/bitchatTests/Services/BLEOutboundFragmentPlannerTests.swift b/bitchatTests/Services/BLEOutboundFragmentPlannerTests.swift index d89260ed..c58d5bf5 100644 --- a/bitchatTests/Services/BLEOutboundFragmentPlannerTests.swift +++ b/bitchatTests/Services/BLEOutboundFragmentPlannerTests.swift @@ -108,6 +108,58 @@ struct BLEOutboundFragmentPlannerTests { ) == nil) } + @Test("private media v1 accepts exactly 256 fragments and rejects 257") + func privateMediaCrossPlatformFragmentBoundary() throws { + let maxPayload = makePayload(count: 160 * 1024, seed: 0xFACE_CAFE) + + func plan(payloadCount: Int) throws -> BLEOutboundFragmentPlan { + let packet = BitchatPacket( + type: MessageType.noiseEncrypted.rawValue, + senderID: Data(hexString: "0011223344556677") ?? Data(), + recipientID: Data(hexString: "8877665544332211"), + timestamp: 0x0102030405, + payload: Data(maxPayload.prefix(payloadCount)), + signature: nil, + ttl: 3, + version: 2 + ) + return try #require(BLEOutboundFragmentPlanner.makePlan( + for: BLEOutboundFragmentTransferRequest( + packet: packet, + pad: false, + maxChunk: nil, + directedPeer: PeerID(str: "8877665544332211"), + transferId: "boundary" + ), + defaultChunkSize: TransportConfig.bleDefaultFragmentSize, + bleMaxMTU: 512, + fragmentID: Data(repeating: 0xD4, count: 8) + )) + } + + func firstPlan(withAtLeast target: Int) throws -> BLEOutboundFragmentPlan { + var low = 1 + var high = maxPayload.count + while low < high { + let mid = low + (high - low) / 2 + if try plan(payloadCount: mid).totalFragments >= target { + high = mid + } else { + low = mid + 1 + } + } + return try plan(payloadCount: low) + } + + let at256 = try firstPlan(withAtLeast: 256) + let at257 = try firstPlan(withAtLeast: 257) + + #expect(at256.totalFragments == 256) + #expect(BLEOutboundFragmentPlanner.isPrivateMediaV1Compatible(at256)) + #expect(at257.totalFragments == 257) + #expect(!BLEOutboundFragmentPlanner.isPrivateMediaV1Compatible(at257)) + } + private func makePacket( payload: Data, route: [Data]? = nil, diff --git a/bitchatTests/Services/BLEOutboundFragmentTransferSchedulerTests.swift b/bitchatTests/Services/BLEOutboundFragmentTransferSchedulerTests.swift index 722f3e33..8ceb1ac9 100644 --- a/bitchatTests/Services/BLEOutboundFragmentTransferSchedulerTests.swift +++ b/bitchatTests/Services/BLEOutboundFragmentTransferSchedulerTests.swift @@ -20,6 +20,24 @@ struct BLEOutboundFragmentTransferSchedulerTests { } } + @Test + func explicitTransferIDReservesEncryptedPrivateFileFragments() { + var scheduler = BLEOutboundFragmentTransferScheduler() + let request = makeRequest( + type: MessageType.noiseEncrypted.rawValue, + transferId: "private-media" + ) + + let result = scheduler.submit(request, maxConcurrentTransfers: 1) + + if case let .start(_, reservedTransferId) = result { + #expect(reservedTransferId == "private-media") + #expect(scheduler.activeCount == 1) + } else { + Issue.record("Expected encrypted private media to reserve its progress slot") + } + } + @Test func submitQueuesFileTransferWhenSlotsAreFull() { var scheduler = BLEOutboundFragmentTransferScheduler() diff --git a/bitchatTests/Services/BLEPeerRegistryTests.swift b/bitchatTests/Services/BLEPeerRegistryTests.swift index 01b69eae..204c8a3a 100644 --- a/bitchatTests/Services/BLEPeerRegistryTests.swift +++ b/bitchatTests/Services/BLEPeerRegistryTests.swift @@ -6,12 +6,12 @@ import Testing @Suite("BLE peer registry tests") struct BLEPeerRegistryTests { @Test("upserted announces track new, reconnect, and rename transitions") - func upsertVerifiedAnnounceTracksTransitions() { + func upsertVerifiedAnnounceTracksTransitions() throws { var registry = BLEPeerRegistry() let peerID = PeerID(str: "1122334455667788") let firstSeen = Date(timeIntervalSince1970: 100) - let first = registry.upsertVerifiedAnnounce( + let firstResult = registry.upsertVerifiedAnnounce( peerID: peerID, nickname: "alice", noisePublicKey: Data([1, 2, 3]), @@ -19,6 +19,7 @@ struct BLEPeerRegistryTests { isConnected: true, now: firstSeen ) + let first = try #require(firstResult) #expect(first.isNewPeer) #expect(!first.wasDisconnected) @@ -27,7 +28,7 @@ struct BLEPeerRegistryTests { #expect(registry.nickname(for: peerID, connectedOnly: true) == "alice") registry.markDisconnected(peerID) - let reconnect = registry.upsertVerifiedAnnounce( + let reconnectResult = registry.upsertVerifiedAnnounce( peerID: peerID, nickname: "alice-renamed", noisePublicKey: Data([1, 2, 3]), @@ -35,6 +36,7 @@ struct BLEPeerRegistryTests { isConnected: true, now: firstSeen.addingTimeInterval(1) ) + let reconnect = try #require(reconnectResult) #expect(!reconnect.isNewPeer) #expect(reconnect.wasDisconnected) @@ -42,6 +44,116 @@ struct BLEPeerRegistryTests { #expect(registry.info(for: peerID)?.nickname == "alice-renamed") } + @Test("pinned signing key cannot be silently replaced by a later announce") + func upsertVerifiedAnnounceRefusesToReplacePinnedSigningKey() throws { + var registry = BLEPeerRegistry() + let peerID = PeerID(str: "1122334455667788") + let noiseKey = Data(repeating: 0x11, count: 32) + let victimSigningKey = Data(repeating: 0x42, count: 32) + let attackerSigningKey = Data(repeating: 0x66, count: 32) + let firstSeen = Date(timeIntervalSince1970: 100) + + let pinResult = registry.upsertVerifiedAnnounce( + peerID: peerID, + nickname: "victim", + noisePublicKey: noiseKey, + signingPublicKey: victimSigningKey, + isConnected: true, + now: firstSeen + ) + #expect(pinResult != nil) + + // Attacker replays the victim's noiseKey/peerID with their own + // signing key and nickname; the upsert must be refused wholesale. + let attack = registry.upsertVerifiedAnnounce( + peerID: peerID, + nickname: "attacker", + noisePublicKey: noiseKey, + signingPublicKey: attackerSigningKey, + isConnected: true, + now: firstSeen.addingTimeInterval(1) + ) + + #expect(attack == nil) + let info = try #require(registry.info(for: peerID)) + #expect(info.nickname == "victim") + #expect(info.signingPublicKey == victimSigningKey) + + // A legitimate re-announce with the pinned key is still accepted. + let legit = registry.upsertVerifiedAnnounce( + peerID: peerID, + nickname: "victim-renamed", + noisePublicKey: noiseKey, + signingPublicKey: victimSigningKey, + isConnected: true, + now: firstSeen.addingTimeInterval(2) + ) + #expect(legit != nil) + #expect(registry.info(for: peerID)?.nickname == "victim-renamed") + } + + @Test("announce without a signing key keeps the pinned key") + func upsertVerifiedAnnounceKeepsPinnedSigningKeyWhenAnnounceOmitsIt() throws { + var registry = BLEPeerRegistry() + let peerID = PeerID(str: "1122334455667788") + let noiseKey = Data(repeating: 0x11, count: 32) + let signingKey = Data(repeating: 0x42, count: 32) + let firstSeen = Date(timeIntervalSince1970: 100) + + let initialResult = registry.upsertVerifiedAnnounce( + peerID: peerID, + nickname: "alice", + noisePublicKey: noiseKey, + signingPublicKey: signingKey, + isConnected: true, + now: firstSeen + ) + #expect(initialResult != nil) + + let update = registry.upsertVerifiedAnnounce( + peerID: peerID, + nickname: "alice", + noisePublicKey: noiseKey, + signingPublicKey: nil, + isConnected: true, + now: firstSeen.addingTimeInterval(1) + ) + + #expect(update != nil) + #expect(registry.info(for: peerID)?.signingPublicKey == signingKey) + } + + @Test("registry preserves absent versus explicit empty capabilities") + func capabilitiesPresenceIsPreserved() { + var registry = BLEPeerRegistry() + let oldPeer = PeerID(str: "1122334455667788") + let modernPeer = PeerID(str: "8877665544332211") + + _ = registry.upsertVerifiedAnnounce( + peerID: oldPeer, + nickname: "old", + noisePublicKey: Data(repeating: 0x11, count: 32), + signingPublicKey: Data(repeating: 0x12, count: 32), + isConnected: true, + now: Date(), + capabilities: nil + ) + _ = registry.upsertVerifiedAnnounce( + peerID: modernPeer, + nickname: "modern", + noisePublicKey: Data(repeating: 0x21, count: 32), + signingPublicKey: Data(repeating: 0x22, count: 32), + isConnected: true, + now: Date(), + capabilities: [] + ) + + #expect(registry.capabilities(for: oldPeer).isEmpty) + #expect(!registry.capabilitiesWereExplicitlyAdvertised(for: oldPeer)) + #expect(registry.capabilities(for: modernPeer).isEmpty) + #expect(registry.capabilitiesWereExplicitlyAdvertised(for: modernPeer)) + } + @Test("reachability keeps recent verified offline peers only when mesh is attached") func reachabilityRequiresMeshAttachmentForOfflinePeers() { let offlinePeer = PeerID(str: "1122334455667788") diff --git a/bitchatTests/Services/BLEPrivateMediaReceiptStoreTests.swift b/bitchatTests/Services/BLEPrivateMediaReceiptStoreTests.swift new file mode 100644 index 00000000..a443b856 --- /dev/null +++ b/bitchatTests/Services/BLEPrivateMediaReceiptStoreTests.swift @@ -0,0 +1,951 @@ +import Foundation +import Testing +@testable import bitchat + +struct BLEPrivateMediaReceiptStoreTests { + private struct TestError: Error {} + private struct ReceiptFixture: Codable { + let kind: String + let relativePath: String? + let recordedAt: Date + } + private struct JournalEntryFixture: Codable { + let relativePaths: [String] + let recordedAt: Date + } + private struct JournalFixture: Codable { + let version: Int + let entries: [String: JournalEntryFixture] + } + + private let messageID = "media-00112233445566778899aabbccddeeff" + private let secondMessageID = "media-ffeeddccbbaa99887766554433221100" + + @Test + func acceptedReceiptPersistsAcrossStoreInstances() throws { + let root = makeRoot("persist") + defer { try? FileManager.default.removeItem(at: root) } + let payload = try makePayload(in: root) + + let first = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(first.commitAccepted(messageID: messageID, storedURL: payload)) + #expect(first.state(for: messageID) == .accepted(payload)) + + let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(relaunched.state(for: messageID) == .accepted(payload)) + } + + @Test + func acceptedReceiptRejectsOutgoingPayloadAndCrossIDPathReuse() throws { + let root = makeRoot("accepted-ownership") + defer { try? FileManager.default.removeItem(at: root) } + let incoming = try makePayload(in: root) + let outgoingDirectory = root.appendingPathComponent( + "files/images/outgoing", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: outgoingDirectory, + withIntermediateDirectories: true + ) + let outgoing = outgoingDirectory.appendingPathComponent("image.jpg") + try Data([0x01]).write(to: outgoing) + let store = BLEPrivateMediaReceiptStore(baseDirectory: root) + + #expect(!store.commitAccepted( + messageID: messageID, + storedURL: outgoing + )) + #expect(store.commitAccepted( + messageID: messageID, + storedURL: incoming + )) + #expect(!store.commitAccepted( + messageID: secondMessageID, + storedURL: incoming + )) + #expect(FileManager.default.fileExists(atPath: incoming.path)) + #expect(FileManager.default.fileExists(atPath: outgoing.path)) + } + + @Test + func liveAcceptedPathSurvivesTTLAndCapacityPressure() throws { + let root = makeRoot("accepted-retention") + defer { try? FileManager.default.removeItem(at: root) } + let firstPayload = try makePayload(in: root, name: "first.jpg") + let secondPayload = try makePayload(in: root, name: "second.jpg") + let recordedAt = Date(timeIntervalSince1970: 2_000) + let seed = BLEPrivateMediaReceiptStore( + baseDirectory: root, + capacity: 1, + ttl: 1, + now: { recordedAt } + ) + #expect(seed.commitAccepted( + messageID: messageID, + storedURL: firstPayload + )) + + let relaunched = BLEPrivateMediaReceiptStore( + baseDirectory: root, + capacity: 1, + ttl: 1, + now: { recordedAt.addingTimeInterval(2) } + ) + #expect(relaunched.state(for: messageID) == .accepted(firstPayload)) + #expect(!relaunched.commitAccepted( + messageID: secondMessageID, + storedURL: secondPayload + )) + #expect(relaunched.state(for: messageID) == .accepted(firstPayload)) + } + + @Test + func incomingAllocationDoesNotReuseReceiptOwnedMissingPath() throws { + let root = makeRoot("accepted-reservation") + defer { try? FileManager.default.removeItem(at: root) } + let original = try makePayload(in: root) + #expect(BLEPrivateMediaReceiptStore( + baseDirectory: root + ).commitAccepted( + messageID: messageID, + storedURL: original + )) + try FileManager.default.removeItem(at: original) + + let stored = BLEIncomingFileStore(baseDirectory: root).save( + data: Data([0x03]), + preferredName: "image.jpg", + subdirectory: "images/incoming", + fallbackExtension: "jpg", + defaultPrefix: "image" + ) + + #expect(stored?.lastPathComponent != "image.jpg") + #expect(stored.map { + FileManager.default.fileExists(atPath: $0.path) + } == true) + } + + @Test + func pendingRawArrivalBlocksDeletionFallbackPathReuse() throws { + let root = makeRoot("pending-raw-arrival") + defer { try? FileManager.default.removeItem(at: root) } + let incoming = BLEIncomingFileStore(baseDirectory: root) + + // The old stable bubble names image.jpg, but its receipt and payload + // have already been pruned. A raw arrival saves that basename before + // its main-actor bubble is inserted. + let rawArrival = incoming.save( + data: Data([0x03]), + preferredName: "image.jpg", + subdirectory: "images/incoming", + fallbackExtension: "jpg", + defaultPrefix: "image" + ) + #expect(rawArrival?.lastPathComponent == "image.jpg") + + let reservation = incoming.reservePrivateMediaDeletion( + messageIDs: [messageID], + payloadRelativePaths: [ + messageID: "images/incoming/image.jpg" + ] + ) + #expect(reservation == nil) + #expect(rawArrival.map { + FileManager.default.fileExists(atPath: $0.path) + } == true) + } + + @Test + func quotaDoesNotEvictOrReusePendingDeliveryPath() throws { + let root = makeRoot("pending-quota") + defer { try? FileManager.default.removeItem(at: root) } + let incoming = BLEIncomingFileStore( + baseDirectory: root, + quotaBytes: 1 + ) + let first = try #require(incoming.save( + data: Data([0x01, 0x02]), + preferredName: "image.jpg", + subdirectory: "images/incoming", + fallbackExtension: "jpg", + defaultPrefix: "image" + )) + + incoming.enforceQuota(reservingBytes: 1) + #expect(FileManager.default.fileExists(atPath: first.path)) + + let second = try #require(incoming.save( + data: Data([0x03]), + preferredName: "image.jpg", + subdirectory: "images/incoming", + fallbackExtension: "jpg", + defaultPrefix: "image" + )) + #expect(second != first) + #expect(second.lastPathComponent == "image (1).jpg") + #expect(FileManager.default.fileExists(atPath: first.path)) + #expect(FileManager.default.fileExists(atPath: second.path)) + } + + @Test + func invalidReceiptAndJournalCannotTargetOutgoingPayload() throws { + let root = makeRoot("invalid-owned-path") + defer { try? FileManager.default.removeItem(at: root) } + let outgoingDirectory = root.appendingPathComponent( + "files/images/outgoing", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: outgoingDirectory, + withIntermediateDirectories: true + ) + let victim = outgoingDirectory.appendingPathComponent("victim.jpg") + try Data([0x02]).write(to: victim) + let receiptURL = receiptRecord(in: root) + try FileManager.default.createDirectory( + at: receiptURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let fixture = ReceiptFixture( + kind: "tombstone", + relativePath: "images/outgoing/victim.jpg", + recordedAt: Date() + ) + try JSONEncoder().encode(fixture).write( + to: receiptURL, + options: .atomic + ) + + #expect( + BLEPrivateMediaReceiptStore(baseDirectory: root) + .state(for: messageID) == .unavailable + ) + #expect(FileManager.default.fileExists(atPath: victim.path)) + + // The invalid record was quarantined aside, never executed. Clear it + // 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( + version: 1, + entries: [messageID: JournalEntryFixture( + relativePaths: ["images/outgoing/victim.jpg"], + recordedAt: fixture.recordedAt + )] + ) + try JSONEncoder().encode(journal).write( + to: deletionJournal(in: root), + options: .atomic + ) + + #expect( + BLEPrivateMediaReceiptStore(baseDirectory: root) + .state(for: messageID) == .unavailable + ) + #expect(FileManager.default.fileExists(atPath: victim.path)) + } + + @Test + func directoryEnumerationFailureIsUnavailableAndRetriesWithoutCachingEmpty() throws { + let root = makeRoot("list-failure") + defer { try? FileManager.default.removeItem(at: root) } + let payload = try makePayload(in: root) + #expect(BLEPrivateMediaReceiptStore(baseDirectory: root).commitAccepted( + messageID: messageID, + storedURL: payload + )) + let record = receiptRecord(in: root) + #expect(FileManager.default.fileExists(atPath: record.path)) + + var shouldFail = true + let store = BLEPrivateMediaReceiptStore( + baseDirectory: root, + directoryReader: { directory in + if shouldFail { + shouldFail = false + throw TestError() + } + return try FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + ) + } + ) + + #expect(store.state(for: messageID) == .unavailable) + #expect(FileManager.default.fileExists(atPath: record.path)) + #expect(store.state(for: messageID) == .accepted(payload)) + } + + @Test + func recordReadFailureQuarantinesOnlyThatRecord() throws { + let root = makeRoot("read-failure") + defer { try? FileManager.default.removeItem(at: root) } + let payload = try makePayload(in: root) + #expect(BLEPrivateMediaReceiptStore(baseDirectory: root).commitAccepted( + messageID: messageID, + storedURL: payload + )) + let record = receiptRecord(in: root) + let durableBytes = try Data(contentsOf: record) + + let store = BLEPrivateMediaReceiptStore( + baseDirectory: root, + dataReader: { url in + if url.lastPathComponent.hasPrefix(self.messageID) { + throw TestError() + } + return try Data(contentsOf: url) + } + ) + + #expect(store.state(for: messageID) == .unavailable) + // The record was moved aside, bytes intact, not deleted. + #expect(!FileManager.default.fileExists(atPath: record.path)) + let quarantined = quarantinedRecord(in: root) + #expect(FileManager.default.fileExists(atPath: quarantined.path)) + #expect(try Data(contentsOf: quarantined) == durableBytes) + // The quarantine is sticky for this ID; no absent/accepted flapping. + #expect(store.state(for: messageID) == .unavailable) + } + + @Test + func decodeFailureQuarantinesRecordWithoutRetryOrDeletion() throws { + let root = makeRoot("decode-failure") + defer { try? FileManager.default.removeItem(at: root) } + let payload = try makePayload(in: root) + #expect(BLEPrivateMediaReceiptStore(baseDirectory: root).commitAccepted( + messageID: messageID, + storedURL: payload + )) + let record = receiptRecord(in: root) + let corruptBytes = Data("{not-json".utf8) + try corruptBytes.write(to: record, options: .atomic) + + let store = BLEPrivateMediaReceiptStore(baseDirectory: root) + // Only this ID fails closed; it cannot be re-recorded past the + // quarantine either. + #expect(store.state(for: messageID) == .unavailable) + #expect(!store.commitAccepted(messageID: messageID, storedURL: payload)) + #expect(!store.recordDeleted(messageID: messageID)) + #expect(store.state(for: messageID) == .unavailable) + + // The unreadable bytes were preserved at the quarantine name. + let quarantined = quarantinedRecord(in: root) + #expect(!FileManager.default.fileExists(atPath: record.path)) + #expect(FileManager.default.fileExists(atPath: quarantined.path)) + #expect(try Data(contentsOf: quarantined) == corruptBytes) + + // A relaunch stays fail-closed for this ID without ever re-reading + // the quarantined file. + let readURLs = ReadTracker() + let relaunched = BLEPrivateMediaReceiptStore( + baseDirectory: root, + dataReader: { url in + readURLs.append(url) + return try Data(contentsOf: url) + } + ) + #expect(relaunched.state(for: messageID) == .unavailable) + #expect(!readURLs.urls.contains { + $0.lastPathComponent == quarantined.lastPathComponent + }) + } + + @Test + func corruptRecordDoesNotBlockAnotherSendersMedia() throws { + let root = makeRoot("quarantine-isolation") + defer { try? FileManager.default.removeItem(at: root) } + let otherMessageID = "media-ffeeddccbbaa99887766554433221100" + let corruptPayload = try makePayload(in: root, name: "corrupt.jpg") + let healthyPayload = try makePayload(in: root, name: "healthy.jpg") + + let seed = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(seed.commitAccepted( + messageID: messageID, + storedURL: corruptPayload + )) + #expect(seed.commitAccepted( + messageID: otherMessageID, + storedURL: healthyPayload + )) + try Data("{not-json".utf8).write( + to: receiptRecord(in: root), + options: .atomic + ) + + let store = BLEPrivateMediaReceiptStore(baseDirectory: root) + // Only the damaged record fails closed; the other sender's ledger + // entry keeps serving and new decisions still commit durably. + #expect(store.state(for: messageID) == .unavailable) + #expect(store.state(for: otherMessageID) == .accepted(healthyPayload)) + + let freshMessageID = "media-0102030405060708090a0b0c0d0e0f10" + let freshPayload = try makePayload(in: root, name: "fresh.jpg") + #expect(store.commitAccepted( + messageID: freshMessageID, + storedURL: freshPayload + )) + #expect(store.state(for: freshMessageID) == .accepted(freshPayload)) + } + + @Test + func unreadableTombstoneNeverBecomesAbsentOrGetsDeleted() throws { + let root = makeRoot("tombstone-decode") + defer { try? FileManager.default.removeItem(at: root) } + let payload = try makePayload(in: root) + let seed = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(seed.commitAccepted(messageID: messageID, storedURL: payload)) + #expect(seed.recordDeleted(messageID: messageID)) + #expect(!FileManager.default.fileExists(atPath: payload.path)) + + let record = receiptRecord(in: root) + let corruptBytes = Data([0xFF, 0x00, 0x7B]) + try corruptBytes.write(to: record, options: .atomic) + + let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(relaunched.state(for: messageID) == .unavailable) + // The quarantined tombstone can never flip to absent — a sender retry + // must not resurrect explicitly deleted media even when the payload + // bytes arrive again. + try Data([0xFF, 0xD8, 0xFF, 0xD9]).write(to: payload) + #expect(!relaunched.commitAccepted( + 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 + func deletionBatchCommitsEveryIDBeforeRemovingPayloads() throws { + let root = makeRoot("batch") + defer { try? FileManager.default.removeItem(at: root) } + let firstPayload = try makePayload(in: root, name: "first.jpg") + let secondPayload = try makePayload(in: root, name: "second.jpg") + let store = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(store.commitAccepted( + messageID: messageID, + storedURL: firstPayload + )) + #expect(store.commitAccepted( + messageID: secondMessageID, + storedURL: secondPayload + )) + + #expect(store.recordDeleted( + messageIDs: [secondMessageID, messageID] + )) + + #expect(store.state(for: messageID) == .tombstoned) + #expect(store.state(for: secondMessageID) == .tombstoned) + #expect(!FileManager.default.fileExists(atPath: firstPayload.path)) + #expect(!FileManager.default.fileExists(atPath: secondPayload.path)) + #expect(!FileManager.default.fileExists( + atPath: deletionJournal(in: root).path + )) + } + + @Test + func failedJournalCommitPreservesAcceptedStateAndPayloads() throws { + let root = makeRoot("journal-failure") + defer { try? FileManager.default.removeItem(at: root) } + let firstPayload = try makePayload(in: root, name: "first.jpg") + let secondPayload = try makePayload(in: root, name: "second.jpg") + let seed = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(seed.commitAccepted( + messageID: messageID, + storedURL: firstPayload + )) + #expect(seed.commitAccepted( + messageID: secondMessageID, + storedURL: secondPayload + )) + + let failing = BLEPrivateMediaReceiptStore( + baseDirectory: root, + dataWriter: { _, _, _ in throw TestError() } + ) + #expect(!failing.recordDeleted( + messageIDs: [messageID, secondMessageID] + )) + + // A failed commit must not install a volatile tombstone. Otherwise a + // sender retry could be ACKed although the caller kept both bubbles. + #expect(failing.state(for: messageID) == .accepted(firstPayload)) + #expect( + failing.state(for: secondMessageID) == .accepted(secondPayload) + ) + #expect(FileManager.default.fileExists(atPath: firstPayload.path)) + #expect(FileManager.default.fileExists(atPath: secondPayload.path)) + #expect(!FileManager.default.fileExists( + atPath: deletionJournal(in: root).path + )) + } + + @Test + func journalRecoversBatchAfterCrashDuringMaterialization() throws { + let root = makeRoot("crash-recovery") + defer { try? FileManager.default.removeItem(at: root) } + let firstPayload = try makePayload(in: root, name: "first.jpg") + let secondPayload = try makePayload(in: root, name: "second.jpg") + let seed = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(seed.commitAccepted( + messageID: messageID, + storedURL: firstPayload + )) + #expect(seed.commitAccepted( + messageID: secondMessageID, + storedURL: secondPayload + )) + + let interrupted = BLEPrivateMediaReceiptStore( + baseDirectory: root, + dataWriter: { data, url, options in + let isJournal = + url.lastPathComponent == ".deletion-journal.json" + let isFirstRecord = + url.deletingPathExtension().lastPathComponent == messageID + guard isJournal || isFirstRecord else { + throw TestError() + } + try data.write(to: url, options: options) + } + ) + #expect(interrupted.recordDeleted( + messageIDs: [messageID, secondMessageID] + )) + #expect(FileManager.default.fileExists( + atPath: deletionJournal(in: root).path + )) + #expect(!FileManager.default.fileExists(atPath: firstPayload.path)) + #expect(FileManager.default.fileExists(atPath: secondPayload.path)) + #expect(interrupted.state(for: messageID) == .tombstoned) + #expect(interrupted.state(for: secondMessageID) == .tombstoned) + + let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(relaunched.state(for: messageID) == .tombstoned) + #expect(relaunched.state(for: secondMessageID) == .tombstoned) + #expect(!FileManager.default.fileExists(atPath: firstPayload.path)) + #expect(!FileManager.default.fileExists(atPath: secondPayload.path)) + #expect(!FileManager.default.fileExists( + atPath: deletionJournal(in: root).path + )) + } + + @Test + func journalRetriesPayloadUnlinkAfterRelaunch() throws { + let root = makeRoot("unlink-recovery") + defer { try? FileManager.default.removeItem(at: root) } + let payload = try makePayload(in: root) + let seed = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(seed.commitAccepted( + messageID: messageID, + storedURL: payload + )) + + let unlinkFailure = BLEPrivateMediaReceiptStore( + baseDirectory: root, + payloadRemover: { _ in throw TestError() } + ) + #expect(unlinkFailure.recordDeleted(messageID: messageID)) + #expect(unlinkFailure.state(for: messageID) == .tombstoned) + #expect(FileManager.default.fileExists(atPath: payload.path)) + #expect(FileManager.default.fileExists( + atPath: deletionJournal(in: root).path + )) + + let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(relaunched.state(for: messageID) == .tombstoned) + #expect(!FileManager.default.fileExists(atPath: payload.path)) + #expect(!FileManager.default.fileExists( + atPath: deletionJournal(in: root).path + )) + } + + @Test + func expiredReceiptUsesFallbackPathForCrashSafeCleanup() throws { + let root = makeRoot("expired-fallback") + defer { try? FileManager.default.removeItem(at: root) } + let payload = try makePayload(in: root) + let recordedAt = Date(timeIntervalSince1970: 1_000) + let seed = BLEPrivateMediaReceiptStore( + baseDirectory: root, + ttl: 10, + now: { recordedAt } + ) + #expect(seed.commitAccepted( + messageID: messageID, + storedURL: payload + )) + // Simulate a receipt pruned by an older app version while its bubble + // and payload remain. Current code retains live accepted-path owners. + try FileManager.default.removeItem(at: receiptRecord(in: root)) + + let afterExpiry = recordedAt.addingTimeInterval(11) + let interrupted = BLEPrivateMediaReceiptStore( + baseDirectory: root, + ttl: 10, + now: { afterExpiry }, + payloadRemover: { _ in throw TestError() } + ) + #expect(interrupted.recordDeleted( + messageIDs: [messageID], + payloadRelativePaths: [ + messageID: "images/incoming/image.jpg" + ] + )) + #expect(interrupted.state(for: messageID) == .tombstoned) + #expect(FileManager.default.fileExists(atPath: payload.path)) + #expect(FileManager.default.fileExists( + atPath: deletionJournal(in: root).path + )) + + let relaunched = BLEPrivateMediaReceiptStore( + baseDirectory: root, + ttl: 10, + now: { afterExpiry } + ) + #expect(relaunched.state(for: messageID) == .tombstoned) + #expect(!FileManager.default.fileExists(atPath: payload.path)) + #expect(!FileManager.default.fileExists( + atPath: deletionJournal(in: root).path + )) + } + + @Test + func retryAtSuffixedPathDeletesReceiptAndBubblePayloads() throws { + let root = makeRoot("retry-suffix") + defer { try? FileManager.default.removeItem(at: root) } + let original = try makePayload(in: root) + let seed = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(seed.commitAccepted( + messageID: messageID, + storedURL: original + )) + + // Simulate an older build pruning only the receipt. A retry must use a + // suffixed filename while the old bubble still references image.jpg. + try FileManager.default.removeItem(at: receiptRecord(in: root)) + let retry = try makePayload(in: root, name: "image (1).jpg") + let retried = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(retried.commitAccepted( + messageID: messageID, + storedURL: retry + )) + + #expect(retried.recordDeleted( + messageIDs: [messageID], + payloadRelativePaths: [ + messageID: "images/incoming/image.jpg" + ] + )) + #expect(retried.state(for: messageID) == .tombstoned) + #expect(!FileManager.default.fileExists(atPath: original.path)) + #expect(!FileManager.default.fileExists(atPath: retry.path)) + #expect(!FileManager.default.fileExists( + atPath: deletionJournal(in: root).path + )) + + let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(relaunched.state(for: messageID) == .tombstoned) + #expect(!FileManager.default.fileExists(atPath: original.path)) + #expect(!FileManager.default.fileExists(atPath: retry.path)) + } + + @Test + func protectedUIPathRejectsAcceptedReceiptDeletion() throws { + let root = makeRoot("protected-ui-owner") + defer { try? FileManager.default.removeItem(at: root) } + let payload = try makePayload(in: root) + let store = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(store.commitAccepted( + messageID: messageID, + storedURL: payload + )) + + #expect(!store.recordDeleted( + messageIDs: [messageID], + protectedPayloadRelativePaths: [ + "images/incoming/image.jpg" + ] + )) + #expect(store.state(for: messageID) == .accepted(payload)) + #expect(FileManager.default.fileExists(atPath: payload.path)) + } + + @Test + func pathlessNewDeletionFailsWithoutChangingReceiverState() { + let root = makeRoot("pathless-delete") + defer { try? FileManager.default.removeItem(at: root) } + let store = BLEPrivateMediaReceiptStore(baseDirectory: root) + + #expect(!store.recordDeleted(messageID: messageID)) + #expect(store.state(for: messageID) == .absent) + #expect(!FileManager.default.fileExists( + atPath: deletionJournal(in: root).path + )) + } + + @Test + func completedTombstoneNeverDeletesReusedPayloadPath() throws { + let root = makeRoot("path-reuse") + defer { try? FileManager.default.removeItem(at: root) } + let original = try makePayload(in: root) + let store = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(store.commitAccepted( + messageID: messageID, + storedURL: original + )) + #expect(store.recordDeleted(messageID: messageID)) + #expect(!FileManager.default.fileExists(atPath: original.path)) + + let reused = try makePayload(in: root) + #expect(store.commitAccepted( + messageID: secondMessageID, + storedURL: reused + )) + #expect(store.state(for: messageID) == .tombstoned) + #expect(FileManager.default.fileExists(atPath: reused.path)) + + let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(relaunched.state(for: messageID) == .tombstoned) + #expect(FileManager.default.fileExists(atPath: reused.path)) + #expect( + relaunched.state(for: secondMessageID) == .accepted(reused) + ) + } + + @Test + func expiredLegacyPathfulTombstoneDoesNotDeleteAcceptedOwner() throws { + let root = makeRoot("legacy-path-conflict") + defer { try? FileManager.default.removeItem(at: root) } + let payload = try makePayload(in: root) + let receiptDirectory = receiptRecord(in: root) + .deletingLastPathComponent() + try FileManager.default.createDirectory( + at: receiptDirectory, + withIntermediateDirectories: true + ) + let recordedAt = Date(timeIntervalSince1970: 3_000) + try JSONEncoder().encode(ReceiptFixture( + kind: "tombstone", + relativePath: "images/incoming/image.jpg", + recordedAt: recordedAt + )).write( + to: receiptRecord(in: root), + options: .atomic + ) + try JSONEncoder().encode(ReceiptFixture( + kind: "accepted", + relativePath: "images/incoming/image.jpg", + recordedAt: recordedAt + )).write( + to: receiptRecord( + in: root, + messageID: secondMessageID + ), + options: .atomic + ) + + let store = BLEPrivateMediaReceiptStore( + baseDirectory: root, + ttl: 1, + now: { recordedAt.addingTimeInterval(2) } + ) + #expect(store.state(for: messageID) == .absent) + #expect(FileManager.default.fileExists(atPath: payload.path)) + #expect( + store.state(for: secondMessageID) == .accepted(payload) + ) + #expect(FileManager.default.fileExists(atPath: payload.path)) + } + + @Test + func expiredLegacyPathfulTombstonePreservesAmbiguousPayload() throws { + let root = makeRoot("legacy-expired-cleanup") + defer { try? FileManager.default.removeItem(at: root) } + let payload = try makePayload(in: root) + let receiptURL = receiptRecord(in: root) + try FileManager.default.createDirectory( + at: receiptURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let recordedAt = Date(timeIntervalSince1970: 3_000) + try JSONEncoder().encode(ReceiptFixture( + kind: "tombstone", + relativePath: "images/incoming/image.jpg", + recordedAt: recordedAt + )).write(to: receiptURL, options: .atomic) + + let store = BLEPrivateMediaReceiptStore( + baseDirectory: root, + ttl: 1, + now: { recordedAt.addingTimeInterval(2) } + ) + + #expect(store.state(for: messageID) == .absent) + #expect(FileManager.default.fileExists(atPath: payload.path)) + #expect(!FileManager.default.fileExists(atPath: receiptURL.path)) + } + + @Test + func deletionJournalNeverRecursivelyRemovesDirectoryTarget() throws { + let root = makeRoot("journal-directory") + defer { try? FileManager.default.removeItem(at: root) } + let directory = root.appendingPathComponent( + "files/images/incoming/archive", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + let child = directory.appendingPathComponent("child.jpg") + try Data([0x01]).write(to: child) + let receiptDirectory = receiptRecord(in: root) + .deletingLastPathComponent() + try FileManager.default.createDirectory( + at: receiptDirectory, + withIntermediateDirectories: true + ) + #expect(!BLEPrivateMediaReceiptStore( + baseDirectory: root + ).recordDeleted( + messageIDs: [messageID], + payloadRelativePaths: [ + messageID: "images/incoming/archive" + ] + )) + #expect(!FileManager.default.fileExists( + atPath: deletionJournal(in: root).path + )) + try JSONEncoder().encode(JournalFixture( + version: 1, + entries: [messageID: JournalEntryFixture( + relativePaths: ["images/incoming/archive"], + recordedAt: Date() + )] + )).write(to: deletionJournal(in: root), options: .atomic) + + let store = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(store.state(for: messageID) == .tombstoned) + #expect(FileManager.default.fileExists(atPath: directory.path)) + #expect(FileManager.default.fileExists(atPath: child.path)) + #expect(FileManager.default.fileExists( + atPath: deletionJournal(in: root).path + )) + } + + @Test + func corruptDeletionJournalFailsClosedWithoutRemovingPayload() throws { + let root = makeRoot("corrupt-journal") + defer { try? FileManager.default.removeItem(at: root) } + let payload = try makePayload(in: root) + #expect(BLEPrivateMediaReceiptStore( + baseDirectory: root + ).commitAccepted( + messageID: messageID, + storedURL: payload + )) + let journal = deletionJournal(in: root) + try Data("{not-json".utf8).write(to: journal, options: .atomic) + + let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(relaunched.state(for: messageID) == .unavailable) + #expect(!relaunched.commitAccepted( + messageID: messageID, + storedURL: payload + )) + #expect(FileManager.default.fileExists(atPath: payload.path)) + #expect(try Data(contentsOf: journal) == Data("{not-json".utf8)) + } + + @Test + func unreleasedAggregateLedgerIsIgnoredAndLeftUntouched() throws { + let root = makeRoot("no-legacy-migration") + defer { try? FileManager.default.removeItem(at: root) } + let files = root.appendingPathComponent("files", isDirectory: true) + try FileManager.default.createDirectory( + at: files, + withIntermediateDirectories: true + ) + let legacy = files.appendingPathComponent( + ".private-media-receipts.json", + isDirectory: false + ) + let bytes = Data( + #"{"entries":{"media-00112233445566778899aabbccddeeff":{"relativePath":"images/incoming/old.jpg","acceptedAt":0}}}"# + .utf8 + ) + try bytes.write(to: legacy, options: .atomic) + + let store = BLEPrivateMediaReceiptStore(baseDirectory: root) + #expect(store.state(for: messageID) == .absent) + #expect(try Data(contentsOf: legacy) == bytes) + } + + private func makeRoot(_ label: String) -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent( + "private-media-receipt-\(label)-\(UUID().uuidString)", + isDirectory: true + ) + } + + private func makePayload( + in root: URL, + name: String = "image.jpg" + ) throws -> URL { + let directory = root.appendingPathComponent( + "files/images/incoming", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + let payload = directory.appendingPathComponent(name) + try Data([0xFF, 0xD8, 0xFF, 0xD9]).write(to: payload) + return payload + } + + private func receiptRecord( + in root: URL, + messageID requestedMessageID: String? = nil + ) -> URL { + root + .appendingPathComponent( + "files/.private-media-receipts", + isDirectory: true + ) + .appendingPathComponent(requestedMessageID ?? messageID) + .appendingPathExtension("json") + } + + private func quarantinedRecord(in root: URL) -> URL { + receiptRecord(in: root).appendingPathExtension("corrupt") + } + + private func deletionJournal(in root: URL) -> URL { + root + .appendingPathComponent( + "files/.private-media-receipts", + isDirectory: true + ) + .appendingPathComponent(".deletion-journal.json") + } +} + +/// Collects the URLs a store's data reader touched. A reference type so the +/// `@Sendable`-shaped reader closure can record without mutating captures. +private final class ReadTracker: @unchecked Sendable { + private(set) var urls: [URL] = [] + func append(_ url: URL) { urls.append(url) } +} diff --git a/bitchatTests/Services/BridgeCourierServiceTests.swift b/bitchatTests/Services/BridgeCourierServiceTests.swift index 6464c12b..edc7ab72 100644 --- a/bitchatTests/Services/BridgeCourierServiceTests.swift +++ b/bitchatTests/Services/BridgeCourierServiceTests.swift @@ -203,6 +203,134 @@ struct BridgeCourierServiceTests { #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 { let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent("bridge-dedup-\(UUID().uuidString).json") @@ -297,8 +425,10 @@ struct BridgeCourierServiceTests { #expect(firstResults == [false]) // 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) - #expect(fixture.service.pendingDrops.last?.dedupKey == firstID) + #expect(fixture.sealRequests.count == sealCountBeforeRetry + 1) + #expect(fixture.service.pendingDrops.count == BridgeCourierService.Limits.maxPendingDrops) } @Test func oversizeDropConsumesSlotInsteadOfChurning() { diff --git a/bitchatTests/Services/FavoritesPersistenceServiceTests.swift b/bitchatTests/Services/FavoritesPersistenceServiceTests.swift index aea80020..29eff020 100644 --- a/bitchatTests/Services/FavoritesPersistenceServiceTests.swift +++ b/bitchatTests/Services/FavoritesPersistenceServiceTests.swift @@ -15,7 +15,7 @@ final class FavoritesPersistenceServiceTests: XCTestCase { service.addFavorite(peerNoisePublicKey: peerKey, peerNostrPublicKey: "npub1alice", peerNickname: "Alice") - wait(for: [expectation], timeout: 1.0) + wait(for: [expectation], timeout: TestConstants.settleTimeout) XCTAssertTrue(service.isFavorite(peerKey)) XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNickname, "Alice") XCTAssertNotNil(keychain.load(key: storageKey, service: serviceKey)) diff --git a/bitchatTests/Services/GeohashPresenceServiceTests.swift b/bitchatTests/Services/GeohashPresenceServiceTests.swift index 65c33f0c..127c0052 100644 --- a/bitchatTests/Services/GeohashPresenceServiceTests.swift +++ b/bitchatTests/Services/GeohashPresenceServiceTests.swift @@ -76,6 +76,7 @@ final class GeohashPresenceServiceTests: XCTestCase { burstMaxDelay: 0 ) + service.start() service.performHeartbeat() let sentAllAllowedChannels = await waitUntil { sentGeohashes.count == 3 } @@ -83,7 +84,7 @@ final class GeohashPresenceServiceTests: XCTestCase { XCTAssertEqual(Set(sentGeohashes), Set(["9q", "9q8y", "9q8yy"])) XCTAssertEqual(Set(lookedUpGeohashes), Set(["9q", "9q8y", "9q8yy"])) XCTAssertEqual(sleptNanoseconds.count, 3) - XCTAssertEqual(scheduler.intervals, [17]) + XCTAssertEqual(scheduler.intervals, [17, 17]) } func test_performHeartbeat_skipsBroadcastWhenTorIsNotReady() async { @@ -97,11 +98,12 @@ final class GeohashPresenceServiceTests: XCTestCase { loopMaxInterval: 21 ) + service.start() service.performHeartbeat() try? await Task.sleep(nanoseconds: 20_000_000) XCTAssertEqual(sendCount, 0) - XCTAssertEqual(scheduler.intervals, [21]) + XCTAssertEqual(scheduler.intervals, [21, 21]) } func test_performHeartbeat_skipsBroadcastWhenAppIsBackgrounded() async { @@ -115,11 +117,45 @@ final class GeohashPresenceServiceTests: XCTestCase { loopMaxInterval: 22 ) + service.start() service.performHeartbeat() try? await Task.sleep(nanoseconds: 20_000_000) XCTAssertEqual(sendCount, 0) - XCTAssertEqual(scheduler.intervals, [22]) + XCTAssertEqual(scheduler.intervals, [22, 22]) + } + + func test_stopForPanic_cancelsTimerAndSuppressesDelayedBroadcast() async throws { + let identity = try NostrIdentity.generate() + let scheduler = MockGeohashPresenceScheduler() + var sleeperContinuation: CheckedContinuation? + var sendCount = 0 + let service = makeService( + scheduler: scheduler, + deriveIdentity: { _ in identity }, + relaySender: { _, _ in sendCount += 1 }, + sleeper: { _ in + await withCheckedContinuation { continuation in + sleeperContinuation = continuation + } + }, + burstMinDelay: 1, + burstMaxDelay: 1 + ) + + service.start() + service.performHeartbeat() + let delayStarted = await waitUntil { + sleeperContinuation != nil + } + XCTAssertTrue(delayStarted) + + service.stopForPanic() + sleeperContinuation?.resume() + try? await Task.sleep(nanoseconds: 20_000_000) + + XCTAssertEqual(sendCount, 0) + XCTAssertEqual(scheduler.timers.first?.invalidateCallCount, 1) } func test_broadcastPresence_skipsSendWhenNoRelaysAreAvailable() async throws { @@ -191,7 +227,7 @@ final class GeohashPresenceServiceTests: XCTestCase { } private func waitUntil( - timeout: TimeInterval = 1.0, + timeout: TimeInterval = TestConstants.settleTimeout, condition: @escaping @MainActor () -> Bool ) async -> Bool { let deadline = Date().addingTimeInterval(timeout) diff --git a/bitchatTests/Services/LocationStateManagerTests.swift b/bitchatTests/Services/LocationStateManagerTests.swift index 69515f76..46114d26 100644 --- a/bitchatTests/Services/LocationStateManagerTests.swift +++ b/bitchatTests/Services/LocationStateManagerTests.swift @@ -355,7 +355,7 @@ final class LocationStateManagerTests: XCTestCase { } private func waitUntil( - timeout: TimeInterval = 1.0, + timeout: TimeInterval = TestConstants.settleTimeout, condition: @escaping @MainActor () -> Bool ) async -> Bool { let deadline = Date().addingTimeInterval(timeout) diff --git a/bitchatTests/Services/MediaRetentionTests.swift b/bitchatTests/Services/MediaRetentionTests.swift new file mode 100644 index 00000000..18b33ca7 --- /dev/null +++ b/bitchatTests/Services/MediaRetentionTests.swift @@ -0,0 +1,117 @@ +import Foundation +import Testing +@testable import bitchat + +/// Media used to be bounded only by a 100 MB incoming quota, so a received +/// photo or a sent voice note could sit on disk indefinitely — outliving the +/// conversation it belonged to, which is exactly what a seized device gives up. +/// These cover the age-based sweep that bounds it in time as well. +struct MediaRetentionTests { + private func makeRoot() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("media-retention-\(UUID().uuidString)", isDirectory: true) + } + + private func write( + _ name: String, + in directory: URL, + modified: Date + ) throws -> URL { + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + let url = directory.appendingPathComponent(name) + try Data([0xFF, 0xD8, 0xFF, 0xD9]).write(to: url) + try FileManager.default.setAttributes( + [.modificationDate: modified], + ofItemAtPath: url.path + ) + return url + } + + @Test + func expiresOutgoingMediaPastRetentionAndKeepsFreshMedia() throws { + let root = makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let store = BLEIncomingFileStore(baseDirectory: root) + let outgoing = root.appendingPathComponent("files/images/outgoing", isDirectory: true) + + // Outgoing media had no lifetime at all before this sweep: the quota + // only ever considered incoming directories. + let stale = try write( + "sent_old.jpg", + in: outgoing, + modified: Date(timeIntervalSinceNow: -8 * 24 * 60 * 60) + ) + let fresh = try write( + "sent_new.jpg", + in: outgoing, + modified: Date(timeIntervalSinceNow: -60) + ) + + let removed = store.expireAgedMedia() + + #expect(removed == 1) + #expect(!FileManager.default.fileExists(atPath: stale.path)) + #expect(FileManager.default.fileExists(atPath: fresh.path)) + } + + @Test + func expiresIncomingMediaPastRetention() throws { + let root = makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let store = BLEIncomingFileStore(baseDirectory: root) + let incoming = try store.incomingDirectory(subdirectory: "voicenotes/incoming") + + let stale = try write( + "received.m4a", + in: incoming, + modified: Date(timeIntervalSinceNow: -8 * 24 * 60 * 60) + ) + + #expect(store.expireAgedMedia() == 1) + #expect(!FileManager.default.fileExists(atPath: stale.path)) + } + + @Test + func retentionSweepSkipsInFlightLiveCaptures() throws { + let root = makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let store = BLEIncomingFileStore(baseDirectory: root) + let incoming = try store.incomingDirectory(subdirectory: "voicenotes/incoming") + + // Deleting a live capture mid-stream unlinks the inode under the + // coordinator's open FileHandle, so age must not override the guard. + let inFlight = try write( + "\(BLEIncomingFileStore.liveCapturePrefix)00112233445566ff_dm.aac", + in: incoming, + modified: Date(timeIntervalSinceNow: -30 * 24 * 60 * 60) + ) + + #expect(store.expireAgedMedia() == 0) + #expect(FileManager.default.fileExists(atPath: inFlight.path)) + } + + @Test + func nonPositiveRetentionIsANoOp() throws { + let root = makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let store = BLEIncomingFileStore(baseDirectory: root) + let incoming = try store.incomingDirectory(subdirectory: "images/incoming") + + let file = try write( + "received.jpg", + in: incoming, + modified: Date(timeIntervalSinceNow: -365 * 24 * 60 * 60) + ) + + #expect(store.expireAgedMedia(retention: 0) == 0) + #expect(FileManager.default.fileExists(atPath: file.path)) + } + + @Test + func defaultRetentionIsSevenDays() { + #expect(BLEIncomingFileStore.defaultMediaRetention == 7 * 24 * 60 * 60) + } +} diff --git a/bitchatTests/Services/MessageOutboxStoreTests.swift b/bitchatTests/Services/MessageOutboxStoreTests.swift index c54b9f99..c8059827 100644 --- a/bitchatTests/Services/MessageOutboxStoreTests.swift +++ b/bitchatTests/Services/MessageOutboxStoreTests.swift @@ -207,6 +207,42 @@ struct MessageOutboxStoreTests { #expect(MessageOutboxStore(keychain: keychain, fileURL: fileURL).load().isEmpty) } + @Test func deferredScopedRemovalTombstoneFiltersOnlySelectedPeer() { + let fileURL = makeTempURL() + defer { try? FileManager.default.removeItem(at: fileURL) } + let keychain = MockKeychain() + let acknowledgedPeer = PeerID(str: "0000000000000001") + let otherPeer = PeerID(str: "0000000000000002") + MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([ + acknowledgedPeer: [makeMessage("shared-id", content: "for acknowledged peer")], + otherPeer: [makeMessage("shared-id", content: "for other peer")] + ]) + + var protectedDataUnavailable = true + let restored = MessageOutboxStore( + keychain: keychain, + fileURL: fileURL, + readData: { url in + if protectedDataUnavailable { + throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) + } + return try Data(contentsOf: url) + } + ) + #expect(restored.load().isEmpty) + restored.recordRemoval(messageID: "shared-id", for: [acknowledgedPeer]) + restored.save([:]) + + protectedDataUnavailable = false + let recovered = restored.retryDeferredLoad() + #expect(recovered?[acknowledgedPeer] == nil) + #expect(recovered?[otherPeer]?.map(\.messageID) == ["shared-id"]) + + let relaunched = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load() + #expect(relaunched[acknowledgedPeer] == nil) + #expect(relaunched[otherPeer]?.map(\.messageID) == ["shared-id"]) + } + @Test func wipeRemovesFileAndKey() { let fileURL = makeTempURL() let keychain = MockKeychain() diff --git a/bitchatTests/Services/MessageRouterTests.swift b/bitchatTests/Services/MessageRouterTests.swift index 6c9d4ea1..a34acd7e 100644 --- a/bitchatTests/Services/MessageRouterTests.swift +++ b/bitchatTests/Services/MessageRouterTests.swift @@ -79,18 +79,231 @@ struct MessageRouterTests { } @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 transport = MockTransport() transport.connectedPeers.insert(peerID) transport.reachablePeers.insert(peerID) + transport.securePeers = [peerID] let router = MessageRouter(transports: [transport]) router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m7") #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) + + // 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 @@ -392,10 +605,31 @@ struct MessageRouterTests { #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 - 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 peerKey = Data(repeating: 0xAB, count: 32) let courier = PeerID(str: "00000000000000cc") @@ -415,7 +649,11 @@ struct MessageRouterTests { #expect(transport.sentPrivateMessages.map(\.messageID) == ["cs2"]) #expect(transport.sentCourierMessages.isEmpty) 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 @@ -527,6 +765,52 @@ struct MessageRouterTests { #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 @Test @MainActor @@ -582,6 +866,82 @@ struct MessageRouterTests { #expect(transport2.sentPrivateMessages.isEmpty) } + @Test @MainActor + func scopedDeliveryAckClearsOnlySelectedPeerWhenMessageIDsCollide() async { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("router-outbox-scoped-ack-\(UUID().uuidString).sealed") + defer { try? FileManager.default.removeItem(at: fileURL) } + let keychain = MockKeychain() + let acknowledgedPeer = PeerID(str: "00000000000000d1") + let otherPeer = PeerID(str: "00000000000000d2") + let transport = MockTransport() + let router = MessageRouter( + transports: [transport], + outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL) + ) + router.sendPrivate("For acknowledged peer", to: acknowledgedPeer, recipientNickname: "One", messageID: "shared-id") + router.sendPrivate("For other peer", to: otherPeer, recipientNickname: "Two", messageID: "shared-id") + + #expect(router.markDelivered("shared-id", for: [acknowledgedPeer])) + transport.reachablePeers.formUnion([acknowledgedPeer, otherPeer]) + router.flushOutbox(for: acknowledgedPeer) + router.flushOutbox(for: otherPeer) + + #expect(transport.sentPrivateMessages.map(\.peerID) == [otherPeer]) + let persisted = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load() + #expect(persisted[acknowledgedPeer] == nil) + #expect(persisted[otherPeer]?.map(\.messageID) == ["shared-id"]) + } + + @Test @MainActor + func scopedAckWhileColdLoadIsLockedPreventsOnlyTargetPeerResurrection() async { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("router-locked-scoped-ack-\(UUID().uuidString).sealed") + defer { try? FileManager.default.removeItem(at: fileURL) } + let keychain = MockKeychain() + let acknowledgedPeer = PeerID(str: "00000000000000d3") + let otherPeer = PeerID(str: "00000000000000d4") + let durable = MessageOutboxStore.QueuedMessage( + content: "Queued before reboot", + nickname: "Peer", + messageID: "shared-locked-id", + timestamp: Date() + ) + MessageOutboxStore(keychain: keychain, fileURL: fileURL).save([ + acknowledgedPeer: [durable], + otherPeer: [durable] + ]) + + var protectedDataUnavailable = true + let restoredStore = MessageOutboxStore( + keychain: keychain, + fileURL: fileURL, + readData: { url in + if protectedDataUnavailable { + throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) + } + return try Data(contentsOf: url) + } + ) + let transport = MockTransport() + transport.reachablePeers.formUnion([acknowledgedPeer, otherPeer]) + let router = MessageRouter(transports: [transport], outboxStore: restoredStore) + + router.markDelivered( + "shared-locked-id", + from: [acknowledgedPeer] + ) + protectedDataUnavailable = false + restoredStore.retryDeferredLoad() + await Task.yield() + await Task.yield() + + #expect(transport.sentPrivateMessages.map(\.peerID) == [otherPeer]) + let persisted = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load() + #expect(persisted[acknowledgedPeer] == nil) + #expect(persisted[otherPeer]?.map(\.messageID) == ["shared-locked-id"]) + } + @Test @MainActor func protectedDataRecoveryMergesDurableAndLockedWakeMessagesIntoRouter() async { let fileURL = FileManager.default.temporaryDirectory @@ -780,12 +1140,14 @@ struct MessageRouterTests { protectedDataUnavailable = false restoredStore.retryDeferredLoad() // captures unseen durable + known wake - // Secure direct flush removes the wake message before recovery's - // MainActor merge. It must remain removed, while the unseen durable - // message still arrives through the pending recovery claim. + // A secure direct retry followed by its delivery ack removes the wake + // message before recovery's MainActor merge. It must remain removed, + // while the unseen durable message still arrives through the pending + // recovery claim. transport.connectedPeers.insert(peerID) transport.securePeers = [peerID] router.flushOutbox(for: peerID) + router.markDelivered("recovery-gap-known") await Task.yield() await Task.yield() @@ -856,7 +1218,8 @@ struct MessageRouterTests { restoredStore.retryDeferredLoad() // persists D+W and queues recovery transport.connectedPeers.insert(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 // MessageRouter receives the pending recovery callback. diff --git a/bitchatTests/Services/NetworkActivationServiceTests.swift b/bitchatTests/Services/NetworkActivationServiceTests.swift index 72d9f6be..7faa4681 100644 --- a/bitchatTests/Services/NetworkActivationServiceTests.swift +++ b/bitchatTests/Services/NetworkActivationServiceTests.swift @@ -63,7 +63,7 @@ final class NetworkActivationServiceTests: XCTestCase { context.service.start() context.service.setUserTorEnabled(false) - wait(for: [notified], timeout: 1.0) + wait(for: [notified], timeout: TestConstants.negativeWaitWindow) context.notificationCenter.removeObserver(token) XCTAssertFalse(context.service.userTorEnabled) @@ -91,9 +91,114 @@ final class NetworkActivationServiceTests: XCTestCase { XCTAssertGreaterThanOrEqual(context.relayController.connectCallCount, 1) } + func test_stopForPanic_synchronouslyStopsAndIgnoresPublisherUpdates() async { + let context = makeService(permission: .authorized, favorites: []) + + context.service.start() + context.service.stopForPanic() + let connectCountAfterStop = context.relayController.connectCallCount + let startCountAfterStop = context.torController.startIfNeededCallCount + + context.favoritesSubject.send([Data([0x01])]) + context.reachability.set(false) + context.reachability.set(true) + try? await Task.sleep(nanoseconds: 30_000_000) + + XCTAssertFalse(context.service.activationAllowed) + XCTAssertEqual(context.reachability.stopCallCount, 1) + XCTAssertEqual(context.torController.autoStartAllowedValues.last, false) + XCTAssertEqual(context.proxyController.proxyModes.last, false) + XCTAssertGreaterThanOrEqual( + context.torController.shutdownCompletelyCallCount, + 1 + ) + XCTAssertGreaterThanOrEqual( + context.relayController.disconnectCallCount, + 1 + ) + XCTAssertEqual( + context.relayController.connectCallCount, + connectCountAfterStop + ) + XCTAssertEqual( + context.torController.startIfNeededCallCount, + startCountAfterStop + ) + } + + func test_start_afterPanicStop_reestablishesSubscriptions() { + let context = makeService(permission: .authorized, favorites: []) + + context.service.start() + context.service.stopForPanic() + context.service.start() + + XCTAssertTrue(context.service.activationAllowed) + XCTAssertEqual(context.reachability.startCallCount, 2) + XCTAssertEqual(context.relayController.connectCallCount, 2) + } + + /// Teleporting into a geohash needs no location permission, so someone who + /// denied location and has no mutual favorites could previously sit in a + /// channel that never connected: the gate suppressed Tor and the relays, + /// and nothing explained why. + func test_start_enablesNetworkForALocationChannelWithoutPermissionOrFavorites() { + let context = makeService( + permission: .denied, + favorites: [], + selectedChannel: .location(GeohashChannel(level: .city, geohash: "u4pruy")) + ) + + context.service.start() + + XCTAssertTrue(context.service.activationAllowed) + XCTAssertEqual(context.torController.startIfNeededCallCount, 1) + XCTAssertEqual(context.relayController.connectCallCount, 1) + } + + func test_selectedChannelPublisher_activatesOnEnteringALocationChannel() async { + let channelSubject = CurrentValueSubject(.mesh) + let context = makeService( + permission: .denied, + favorites: [], + selectedChannelSubject: channelSubject + ) + + context.service.start() + XCTAssertFalse(context.service.activationAllowed) + + channelSubject.send(.location(GeohashChannel(level: .city, geohash: "u4pruy"))) + + let activated = await waitUntil { context.service.activationAllowed } + XCTAssertTrue(activated) + } + + /// Leaving the channel must close the gate again, or the exception would + /// quietly become permanent for the rest of the session. + func test_selectedChannelPublisher_deactivatesOnReturningToMesh() async { + let channelSubject = CurrentValueSubject( + .location(GeohashChannel(level: .city, geohash: "u4pruy")) + ) + let context = makeService( + permission: .denied, + favorites: [], + selectedChannelSubject: channelSubject + ) + + context.service.start() + XCTAssertTrue(context.service.activationAllowed) + + channelSubject.send(.mesh) + + let deactivated = await waitUntil { !context.service.activationAllowed } + XCTAssertTrue(deactivated) + } + private func makeService( permission: LocationChannelManager.PermissionState, - favorites: Set + favorites: Set, + selectedChannel: ChannelID = .mesh, + selectedChannelSubject: CurrentValueSubject? = nil ) -> NetworkActivationTestContext { let suiteName = "NetworkActivationServiceTests-\(UUID().uuidString)" let storage = UserDefaults(suiteName: suiteName)! @@ -101,9 +206,12 @@ final class NetworkActivationServiceTests: XCTestCase { let permissionSubject = CurrentValueSubject(permission) let favoritesSubject = CurrentValueSubject, Never>(favorites) + let channelSubject = selectedChannelSubject + ?? CurrentValueSubject(selectedChannel) let torController = MockNetworkActivationTorController() let relayController = MockNetworkActivationRelayController() let proxyController = MockNetworkActivationProxyController() + let reachability = MockNetworkActivationReachability() let notificationCenter = NotificationCenter() let service = NetworkActivationService( storage: storage, @@ -111,7 +219,12 @@ final class NetworkActivationServiceTests: XCTestCase { mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(), permissionProvider: { permissionSubject.value }, mutualFavoritesProvider: { favoritesSubject.value }, - reachabilityMonitor: AlwaysReachableMonitor(), + selectedChannelPublisher: channelSubject.eraseToAnyPublisher(), + locationChannelSelectedProvider: { + if case .location = channelSubject.value { return true } + return false + }, + reachabilityMonitor: reachability, torController: torController, relayController: relayController, proxyController: proxyController, @@ -121,6 +234,7 @@ final class NetworkActivationServiceTests: XCTestCase { service: service, storage: storage, favoritesSubject: favoritesSubject, + reachability: reachability, torController: torController, relayController: relayController, proxyController: proxyController, @@ -129,7 +243,7 @@ final class NetworkActivationServiceTests: XCTestCase { } private func waitUntil( - timeout: TimeInterval = 1.0, + timeout: TimeInterval = TestConstants.settleTimeout, condition: @escaping @MainActor () -> Bool ) async -> Bool { let deadline = Date().addingTimeInterval(timeout) @@ -148,12 +262,38 @@ private struct NetworkActivationTestContext { let service: NetworkActivationService let storage: UserDefaults let favoritesSubject: CurrentValueSubject, Never> + let reachability: MockNetworkActivationReachability let torController: MockNetworkActivationTorController let relayController: MockNetworkActivationRelayController let proxyController: MockNetworkActivationProxyController let notificationCenter: NotificationCenter } +@MainActor +private final class MockNetworkActivationReachability: + NetworkReachabilityMonitoring { + private let subject = CurrentValueSubject(true) + private(set) var startCallCount = 0 + private(set) var stopCallCount = 0 + + var isReachable: Bool { subject.value } + var reachabilityPublisher: AnyPublisher { + subject.removeDuplicates().dropFirst().eraseToAnyPublisher() + } + + func start() { + startCallCount += 1 + } + + func stop() { + stopCallCount += 1 + } + + func set(_ reachable: Bool) { + subject.send(reachable) + } +} + @MainActor private final class MockNetworkActivationTorController: NetworkActivationTorControlling { private(set) var autoStartAllowedValues: [Bool] = [] diff --git a/bitchatTests/Services/NetworkReachabilityGateTests.swift b/bitchatTests/Services/NetworkReachabilityGateTests.swift index fee973aa..525f417a 100644 --- a/bitchatTests/Services/NetworkReachabilityGateTests.swift +++ b/bitchatTests/Services/NetworkReachabilityGateTests.swift @@ -69,25 +69,45 @@ final class NetworkReachabilityGateTests: XCTestCase { XCTAssertNil(d.pendingRemaining(at: t0.addingTimeInterval(2.5))) } - func test_monitor_duplicateUpdatesDoNotPostponeOfflineCommit() async { - let monitor = NWPathReachabilityMonitor(debounceInterval: 1.0) + /// Wiring only: a duplicate mid-window still yields exactly one committed + /// `false`, published through the monitor's debounce. + /// + /// This deliberately makes no assertion about *when* the flush fires. It + /// used to bound elapsed wall-clock time at 1.4 s to prove the deadline was + /// not restarted, which flaked on loaded CI runners — one observed run took + /// 3.75 s, because `Task.sleep` and the `asyncAfter` flush are both real + /// time and neither is bounded above on a busy machine. No wall-clock bound + /// can distinguish "deadline preserved" from "runner is slow", so the timing + /// property is asserted where it is computable instead: + /// `test_debounce_duplicateObservationsPreservePendingDeadline` drives + /// `ReachabilityDebounce` with injected timestamps and checks + /// `pendingRemaining` directly. + /// + /// The clock is injected here so the debounce arithmetic is deterministic + /// even though the flush itself is scheduled in real time. + func test_monitor_duplicateUpdatesCommitOnceThroughTheDebounce() async { + let clock = MutableDate(now: Date(timeIntervalSince1970: 1_784_000_000)) + let monitor = NWPathReachabilityMonitor( + debounceInterval: 0.2, + now: { clock.now } + ) var received: [Bool] = [] let cancellable = monitor.reachabilityPublisher.sink { received.append($0) } defer { cancellable.cancel() } - let start = Date() monitor.ingest(reachable: false) - try? await Task.sleep(nanoseconds: 500_000_000) - // Duplicate unsatisfied update mid-window (e.g. interface detail change - // while still offline) must not restart the debounce window. + // Duplicate unsatisfied update mid-window (e.g. an interface detail + // change while still offline). + clock.now = clock.now.addingTimeInterval(0.1) monitor.ingest(reachable: false) + // Past the original deadline, so the scheduled flush commits. + clock.now = clock.now.addingTimeInterval(0.2) - let committed = await waitUntil(timeout: 2.0) { !received.isEmpty } + // Generous: this is a liveness check, not a latency bound. A real + // regression — never committing — still fails, just later. + let committed = await waitUntil(timeout: 10.0) { !received.isEmpty } XCTAssertTrue(committed) XCTAssertEqual(received, [false]) - // The flush must fire at the original ~1.0s deadline, not ~1.5s - // (a full interval after the duplicate). - XCTAssertLessThan(Date().timeIntervalSince(start), 1.4) } // MARK: - Service gating @@ -179,7 +199,7 @@ final class NetworkReachabilityGateTests: XCTestCase { } private func waitUntil( - timeout: TimeInterval = 1.0, + timeout: TimeInterval = TestConstants.settleTimeout, condition: @escaping @MainActor () -> Bool ) async -> Bool { let deadline = Date().addingTimeInterval(timeout) @@ -213,6 +233,7 @@ private final class ControllableReachabilityMonitor: NetworkReachabilityMonitori subject.removeDuplicates().dropFirst().eraseToAnyPublisher() } func start() { startCalled = true } + func stop() { startCalled = false } func set(_ reachable: Bool) { subject.send(reachable) } } @@ -238,3 +259,13 @@ private final class GateMockProxyController: NetworkActivationProxyControlling { private(set) var proxyModes: [Bool] = [] func setProxyMode(useTor: Bool) { proxyModes.append(useTor) } } + +/// Controllable clock, so debounce arithmetic is deterministic even where the +/// flush itself is scheduled in real time. +private final class MutableDate: @unchecked Sendable { + var now: Date + + init(now: Date) { + self.now = now + } +} diff --git a/bitchatTests/Services/NoiseEncryptionServiceTests.swift b/bitchatTests/Services/NoiseEncryptionServiceTests.swift index d4b0b79a..a2031983 100644 --- a/bitchatTests/Services/NoiseEncryptionServiceTests.swift +++ b/bitchatTests/Services/NoiseEncryptionServiceTests.swift @@ -3,7 +3,7 @@ import Testing import BitFoundation @testable import bitchat -@Suite("NoiseEncryptionService Tests") +@Suite("NoiseEncryptionService Tests", .serialized) struct NoiseEncryptionServiceTests { @Test("Encryption status accessors cover all cases") @@ -91,39 +91,1244 @@ struct NoiseEncryptionServiceTests { func handshakeEncryptionAndFingerprintLifecycle() async throws { let alice = NoiseEncryptionService(keychain: MockKeychain()) let bob = NoiseEncryptionService(keychain: MockKeychain()) - let alicePeerID = PeerID(str: "0011223344556677") - let bobPeerID = PeerID(str: "8899aabbccddeeff") + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) let recorder = AuthenticationRecorder() #expect(alice.onPeerAuthenticated == nil) + #expect(bob.onPeerAuthenticatedWithGeneration == nil) alice.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:)) bob.onPeerAuthenticated = recorder.record(peerID:fingerprint:) + bob.onPeerAuthenticatedWithGeneration = recorder.record( + peerID:fingerprint:sessionGeneration: + ) - try establishSessions(alice: alice, bob: bob, alicePeerID: alicePeerID, bobPeerID: bobPeerID) + try establishSessions(alice: alice, bob: bob) - let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: 5.0) + let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: TestConstants.settleTimeout) #expect(authenticated) - #expect(alice.hasEstablishedSession(with: alicePeerID)) - #expect(bob.hasEstablishedSession(with: bobPeerID)) - #expect(alice.hasSession(with: alicePeerID)) - #expect(bob.hasSession(with: bobPeerID)) - #expect(alice.getPeerPublicKeyData(alicePeerID)?.count == 32) - #expect(bob.getPeerPublicKeyData(bobPeerID)?.count == 32) - #expect(alice.getPeerFingerprint(alicePeerID) != nil) - #expect(bob.getPeerFingerprint(bobPeerID) != nil) + let generationAuthenticated = await TestHelpers.waitUntil( + { recorder.generationCount >= 1 }, + timeout: TestConstants.settleTimeout + ) + #expect(generationAuthenticated) + #expect(alice.hasEstablishedSession(with: bobPeerID)) + #expect(bob.hasEstablishedSession(with: alicePeerID)) + #expect(alice.hasSession(with: bobPeerID)) + #expect(bob.hasSession(with: alicePeerID)) + #expect(alice.getPeerPublicKeyData(bobPeerID)?.count == 32) + #expect(bob.getPeerPublicKeyData(alicePeerID)?.count == 32) + #expect(alice.getPeerFingerprint(bobPeerID) != nil) + #expect(bob.getPeerFingerprint(alicePeerID) != nil) + #expect(recorder.generation(for: alicePeerID) == bob.sessionGeneration(for: alicePeerID)) let plaintext = Data("secret payload".utf8) - let ciphertext = try alice.encrypt(plaintext, for: alicePeerID) - let decrypted = try bob.decrypt(ciphertext, from: bobPeerID) + let ciphertext = try alice.encrypt(plaintext, for: bobPeerID) + let decrypted = try bob.decrypt(ciphertext, from: alicePeerID) #expect(decrypted == plaintext) - alice.clearSession(for: alicePeerID) - #expect(!alice.hasSession(with: alicePeerID)) - #expect(alice.getPeerFingerprint(alicePeerID) == nil) + alice.clearSession(for: bobPeerID) + #expect(!alice.hasSession(with: bobPeerID)) + #expect(alice.getPeerFingerprint(bobPeerID) == nil) bob.clearEphemeralStateForPanic() - #expect(!bob.hasSession(with: bobPeerID)) - #expect(bob.getPeerFingerprint(bobPeerID) == nil) + #expect(!bob.hasSession(with: alicePeerID)) + #expect(bob.getPeerFingerprint(alicePeerID) == nil) + } + + @Test("Handshake rejects a claimed peer ID that does not match the authenticated static key") + func handshakeRejectsClaimedPeerIDStaticKeyMismatch() async throws { + let receiver = NoiseEncryptionService(keychain: MockKeychain()) + let claimedAlice = NoiseEncryptionService(keychain: MockKeychain()) + let mallory = NoiseEncryptionService(keychain: MockKeychain()) + let receiverPeerID = PeerID(publicKey: receiver.getStaticPublicKeyData()) + let claimedAlicePeerID = PeerID(publicKey: claimedAlice.getStaticPublicKeyData()) + let recorder = AuthenticationRecorder() + receiver.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:)) + + let message1 = try mallory.initiateHandshake(with: receiverPeerID) + let message2 = try #require( + try receiver.processHandshakeMessage(from: claimedAlicePeerID, message: message1) + ) + let message3 = try #require( + try mallory.processHandshakeMessage(from: receiverPeerID, message: message2) + ) + + do { + _ = try receiver.processHandshakeMessage(from: claimedAlicePeerID, message: message3) + Issue.record("Expected the authenticated Mallory key to be rejected for Alice's peer ID") + } catch let error as NoiseSessionError { + #expect(error == .peerIdentityMismatch) + } catch { + Issue.record("Unexpected mismatch error: \(error)") + } + + #expect(!receiver.hasSession(with: claimedAlicePeerID)) + let emittedAuthentication = await TestHelpers.waitUntil( + { recorder.count > 0 }, + timeout: TestConstants.negativeWaitWindow + ) + #expect(!emittedAuthentication) + } + + @Test("Failed forged reconnect restores the established peer session") + func forgedReconnectRestoresEstablishedSession() async throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let receiver = NoiseEncryptionService(keychain: MockKeychain()) + let mallory = NoiseEncryptionService(keychain: MockKeychain()) + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + let receiverPeerID = PeerID(publicKey: receiver.getStaticPublicKeyData()) + let recorder = AuthenticationRecorder() + receiver.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:)) + + try establishSessions(alice: alice, bob: receiver) + let initialAuthentication = await TestHelpers.waitUntil( + { recorder.count == 1 }, + timeout: TestConstants.longTimeout + ) + #expect(initialAuthentication) + + let before = try alice.encrypt(Data("before".utf8), for: receiverPeerID) + #expect(try receiver.decrypt(before, from: alicePeerID) == Data("before".utf8)) + + let forgedMessage1 = try mallory.initiateHandshake(with: receiverPeerID) + let forgedMessage2 = try #require( + try receiver.processHandshakeMessage(from: alicePeerID, message: forgedMessage1) + ) + // Outbound/session-generation APIs fail closed while the old + // transport is retained solely for receive and bounded rollback. + #expect(!receiver.hasEstablishedSession(with: alicePeerID)) + let forgedMessage3 = try #require( + try mallory.processHandshakeMessage(from: receiverPeerID, message: forgedMessage2) + ) + + do { + _ = try receiver.processHandshakeMessage(from: alicePeerID, message: forgedMessage3) + Issue.record("Expected forged reconnect to fail peer binding") + } catch let error as NoiseSessionError { + #expect(error == .peerIdentityMismatch) + } catch { + Issue.record("Unexpected reconnect error: \(error)") + } + + #expect(receiver.hasEstablishedSession(with: alicePeerID)) + let after = try alice.encrypt(Data("after".utf8), for: receiverPeerID) + #expect(try receiver.decrypt(after, from: alicePeerID) == Data("after".utf8)) + let emittedReplacementAuthentication = await TestHelpers.waitUntil( + { recorder.count > 1 }, + timeout: TestConstants.negativeWaitWindow + ) + #expect(!emittedReplacementAuthentication) + } + + @Test("Valid ordinary rehandshake atomically replaces the established session") + func validOrdinaryRehandshakeReplacesEstablishedSession() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let receiver = NoiseEncryptionService(keychain: MockKeychain()) + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + let receiverPeerID = PeerID(publicKey: receiver.getStaticPublicKeyData()) + + try establishSessions(alice: alice, bob: receiver) + alice.clearSession(for: receiverPeerID) + + let message1 = try alice.initiateHandshake(with: receiverPeerID) + let message2 = try #require( + try receiver.processHandshakeMessage(from: alicePeerID, message: message1) + ) + #expect(!receiver.hasEstablishedSession(with: alicePeerID)) + let message3 = try #require( + try alice.processHandshakeMessage(from: receiverPeerID, message: message2) + ) + _ = try receiver.processHandshakeMessage(from: alicePeerID, message: message3) + + #expect(alice.hasEstablishedSession(with: receiverPeerID)) + #expect(receiver.hasEstablishedSession(with: alicePeerID)) + let ciphertext = try alice.encrypt(Data("new session".utf8), for: receiverPeerID) + #expect(try receiver.decrypt(ciphertext, from: alicePeerID) == Data("new session".utf8)) + } + + @Test("Automatic rekey exposes and completes its exact handshake bytes") + func automaticRekeyHandshakeIsNotStranded() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) + try establishSessions(alice: alice, bob: bob) + let originalGeneration = try #require(alice.sessionGeneration(for: bobPeerID)) + var leaseRan = false + let leased = alice.withCurrentSessionGeneration( + for: bobPeerID, + expected: originalGeneration + ) { + leaseRan = true + return true + } + #expect(leased == true) + #expect(leaseRan) + + var emittedPeerID: PeerID? + var emittedInitiation: NoiseHandshakeInitiation? + alice.onRekeyHandshakeReady = { peerID, initiation in + emittedPeerID = peerID + emittedInitiation = initiation + } + try alice._test_initiateAutomaticRekey(for: bobPeerID) + + #expect(emittedPeerID == bobPeerID) + #expect(alice.sessionGeneration(for: bobPeerID) == nil) + leaseRan = false + let staleLease = alice.withCurrentSessionGeneration( + for: bobPeerID, + expected: originalGeneration + ) { + leaseRan = true + return true + } + #expect(staleLease == nil) + #expect(!leaseRan) + let initiation = try #require(emittedInitiation) + let message1 = try #require( + alice.claimHandshakeInitiation(initiation, for: bobPeerID) + ) + #expect(!message1.isEmpty) + #expect(alice.hasSession(with: bobPeerID)) + #expect(!alice.hasEstablishedSession(with: bobPeerID)) + + let message2 = try #require( + try bob.processHandshakeMessage(from: alicePeerID, message: message1) + ) + let message3 = try #require( + try alice.processHandshakeMessage(from: bobPeerID, message: message2) + ) + _ = try bob.processHandshakeMessage(from: alicePeerID, message: message3) + + #expect(alice.hasEstablishedSession(with: bobPeerID)) + #expect(bob.hasEstablishedSession(with: alicePeerID)) + #expect(alice.sessionGeneration(for: bobPeerID) != originalGeneration) + } + + @Test("Large private-file payloads use the bounded Noise extension") + func largePrivateFileNoiseRoundTrip() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) + try establishSessions(alice: alice, bob: bob) + + let content = Data("%PDF-1.7\n".utf8) + Data(repeating: 0x51, count: 96 * 1024) + let file = BitchatFilePacket( + fileName: "large-private.pdf", + fileSize: UInt64(content.count), + mimeType: "application/pdf", + content: content + ) + let typedPayload = try #require(BLENoisePayloadFactory.privateFile(file)) + #expect(typedPayload.count > NoiseSecurityConstants.maxMessageSize) + #expect(typedPayload.first == NoisePayloadType.privateFile.rawValue) + #expect( + typedPayload.count <= NoiseSecurityConstants.maxPrivateFilePlaintextSize, + "typedBytes=\(typedPayload.count) limit=\(NoiseSecurityConstants.maxPrivateFilePlaintextSize)" + ) + + do { + _ = try alice.encrypt(typedPayload, for: bobPeerID) + Issue.record("Ordinary Noise payload path must retain its 64 KiB ceiling") + } catch NoiseSecurityError.messageTooLarge { + // Expected: only the purpose-specific private-file API may extend it. + } + + let ciphertext: Data + do { + ciphertext = try alice.encryptPrivateFilePayload(typedPayload, for: bobPeerID) + } catch { + Issue.record("Private-file encryption failed: \(error)") + return + } + let decrypted: Data + do { + decrypted = try bob.decrypt(ciphertext, from: alicePeerID) + } catch { + Issue.record("Private-file decryption failed: \(error); ciphertextBytes=\(ciphertext.count)") + return + } + + #expect(ciphertext.range(of: content) == nil) + #expect(decrypted == typedPayload) + } + + @Test("Concurrent BLE starts preserve one ordinary attempt") + func duplicateHandshakeIfNeededPreservesFirstAttempt() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) + let starts = HandshakeInitiationRecorder() + + DispatchQueue.concurrentPerform(iterations: 20) { _ in + do { + starts.record( + try alice.initiateHandshakeIfNeeded(with: bobPeerID) + ) + } catch { + starts.record(error: error) + } + } + + #expect(starts.errorCount == 0) + let attempt = try #require(starts.initiations.first) + #expect(starts.initiations.count == 1) + let message1 = try #require( + alice.claimHandshakeInitiation(attempt, for: bobPeerID) + ) + let message2 = try #require( + try bob.processHandshakeMessage(from: alicePeerID, message: message1) + ) + let message3 = try #require( + try alice.processHandshakeMessage(from: bobPeerID, message: message2) + ) + _ = try bob.processHandshakeMessage(from: alicePeerID, message: message3) + + let ciphertext = try alice.encrypt( + Data("one atomic start".utf8), + for: bobPeerID + ) + #expect( + try bob.decrypt(ciphertext, from: alicePeerID) + == Data("one atomic start".utf8) + ) + } + + @Test("Restarted peer establishes against a retained ordinary session") + func restartedPeerCompletesRetainedRemoteRehandshake() throws { + let aliceKeychain = MockKeychain() + let alice = NoiseEncryptionService(keychain: aliceKeychain) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) + try establishSessions(alice: alice, bob: bob) + + let restartedAlice = NoiseEncryptionService(keychain: aliceKeychain) + let attempt = try #require( + try restartedAlice.initiateHandshakeIfNeeded(with: bobPeerID) + ) + #expect( + try restartedAlice.initiateHandshakeIfNeeded(with: bobPeerID) + == nil + ) + let message1 = try #require( + restartedAlice.claimHandshakeInitiation( + attempt, + for: bobPeerID + ) + ) + let message2 = try #require( + try bob.processHandshakeMessage(from: alicePeerID, message: message1) + ) + let message3 = try #require( + try restartedAlice.processHandshakeMessage( + from: bobPeerID, + message: message2 + ) + ) + _ = try bob.processHandshakeMessage(from: alicePeerID, message: message3) + + let forward = try restartedAlice.encrypt( + Data("after restart".utf8), + for: bobPeerID + ) + #expect( + try bob.decrypt(forward, from: alicePeerID) + == Data("after restart".utf8) + ) + } + + @Test("Crossed ordinary initiations choose one deterministic initiator") + func crossedOrdinaryInitiationsResolveDeterministically() throws { + let endpoints = orderedServices() + let lowerAttempt = try #require( + try endpoints.lower.initiateHandshakeIfNeeded( + with: endpoints.higherPeerID + ) + ) + let higherAttempt = try #require( + try endpoints.higher.initiateHandshakeIfNeeded( + with: endpoints.lowerPeerID + ) + ) + let lowerMessage1 = try #require( + endpoints.lower.claimHandshakeInitiation( + lowerAttempt, + for: endpoints.higherPeerID + ) + ) + let higherMessage1 = try #require( + endpoints.higher.claimHandshakeInitiation( + higherAttempt, + for: endpoints.lowerPeerID + ) + ) + + #expect( + try endpoints.lower.processHandshakeMessage( + from: endpoints.higherPeerID, + message: higherMessage1 + ) == nil + ) + let message2 = try #require( + try endpoints.higher.processHandshakeMessage( + from: endpoints.lowerPeerID, + message: lowerMessage1 + ) + ) + let message3 = try #require( + try endpoints.lower.processHandshakeMessage( + from: endpoints.higherPeerID, + message: message2 + ) + ) + _ = try endpoints.higher.processHandshakeMessage( + from: endpoints.lowerPeerID, + message: message3 + ) + + let ciphertext = try endpoints.lower.encrypt( + Data("crossed".utf8), + for: endpoints.higherPeerID + ) + #expect( + try endpoints.higher.decrypt( + ciphertext, + from: endpoints.lowerPeerID + ) == Data("crossed".utf8) + ) + } + + @Test("Delayed losing message one cannot replace the fresh winner") + func delayedCrossedInitiationIsSuppressed() throws { + let endpoints = orderedServices() + let lowerAttempt = try #require( + try endpoints.lower.initiateHandshakeIfNeeded( + with: endpoints.higherPeerID + ) + ) + let higherAttempt = try #require( + try endpoints.higher.initiateHandshakeIfNeeded( + with: endpoints.lowerPeerID + ) + ) + let lowerMessage1 = try #require( + endpoints.lower.claimHandshakeInitiation( + lowerAttempt, + for: endpoints.higherPeerID + ) + ) + let delayedHigherMessage1 = try #require( + endpoints.higher.claimHandshakeInitiation( + higherAttempt, + for: endpoints.lowerPeerID + ) + ) + + let message2 = try #require( + try endpoints.higher.processHandshakeMessage( + from: endpoints.lowerPeerID, + message: lowerMessage1 + ) + ) + let message3 = try #require( + try endpoints.lower.processHandshakeMessage( + from: endpoints.higherPeerID, + message: message2 + ) + ) + #expect( + try endpoints.lower.processHandshakeMessage( + from: endpoints.higherPeerID, + message: delayedHigherMessage1 + ) == nil + ) + _ = try endpoints.higher.processHandshakeMessage( + from: endpoints.lowerPeerID, + message: message3 + ) + + let ciphertext = try endpoints.higher.encrypt( + Data("winner intact".utf8), + for: endpoints.lowerPeerID + ) + #expect( + try endpoints.lower.decrypt( + ciphertext, + from: endpoints.higherPeerID + ) == Data("winner intact".utf8) + ) + } + + @Test("Automatic rekey token dies if an inbound initiation wins first") + func automaticRekeyClaimsOnlyAtTransportHandoff() throws { + let endpoints = orderedServices() + try establishSessions( + alice: endpoints.lower, + bob: endpoints.higher + ) + + var preparedRekey: NoiseHandshakeInitiation? + endpoints.higher.onRekeyHandshakeReady = { _, initiation in + preparedRekey = initiation + } + try endpoints.higher._test_initiateAutomaticRekey( + for: endpoints.lowerPeerID + ) + let staleRekey = try #require(preparedRekey) + + let winningAttempt = try endpoints.lower.initiateReconnectHandshake( + with: endpoints.higherPeerID + ) + let winningMessage1 = try #require( + endpoints.lower.claimHandshakeInitiation( + winningAttempt, + for: endpoints.higherPeerID + ) + ) + let message2 = try #require( + try endpoints.higher.processHandshakeMessage( + from: endpoints.lowerPeerID, + message: winningMessage1 + ) + ) + #expect( + endpoints.higher.claimHandshakeInitiation( + staleRekey, + for: endpoints.lowerPeerID + ) == nil + ) + let message3 = try #require( + try endpoints.lower.processHandshakeMessage( + from: endpoints.higherPeerID, + message: message2 + ) + ) + _ = try endpoints.higher.processHandshakeMessage( + from: endpoints.lowerPeerID, + message: message3 + ) + #expect( + endpoints.higher.hasEstablishedSession( + with: endpoints.lowerPeerID + ) + ) + } + + @Test("Ordinary timeout produces exactly one bounded retry") + func ordinaryInitiationTimeoutIsBounded() async throws { + let service = NoiseEncryptionService( + keychain: MockKeychain(), + ordinaryHandshakeTimeout: 0.03 + ) + let peerID = PeerID(str: "1021324354657687") + let recorder = HandshakeStartRecorder() + service.onHandshakeRecoveryRequired = { [weak service] request in + guard let service else { return } + do { + let payload = try claimPreparedRecoveryPayload( + service, + request: request + ) + recorder.recordTimeout() + recorder.record(message: payload) + } catch { + recorder.record(error: error) + } + } + + let first = try #require( + try service.initiateHandshakeIfNeeded( + with: peerID, + retryOnTimeout: true + ) + ) + #expect( + service.claimHandshakeInitiation(first, for: peerID) != nil + ) + let retried = await TestHelpers.waitUntil( + { recorder.messages.count == 1 }, + timeout: TestConstants.longTimeout + ) + #expect(retried) + let retryExpired = await TestHelpers.waitUntil( + { !service.hasSession(with: peerID) }, + timeout: TestConstants.longTimeout + ) + #expect(retryExpired) + #expect(recorder.timeoutCount == 1) + #expect(recorder.errorCount == 0) + } + + @Test("Claim gives an attempt a full on-wire timeout window") + func handshakeClaimRearmsDeadline() async throws { + let timeoutInterval: TimeInterval = 1 + let service = NoiseEncryptionService( + keychain: MockKeychain(), + ordinaryHandshakeTimeout: timeoutInterval + ) + let peerID = PeerID(str: "1021324354657687") + let recorder = HandshakeStartRecorder() + service.onHandshakeRecoveryRequired = { [weak service] request in + let firedAt = DispatchTime.now().uptimeNanoseconds + service?.cancelHandshakeRecovery(request) + recorder.recordTimeout(at: firedAt) + } + + let attempt = try #require( + try service.initiateHandshakeIfNeeded( + with: peerID, + retryOnTimeout: true + ) + ) + try? await Task.sleep(nanoseconds: 500_000_000) + let claimed = service.claimHandshakeInitiation(attempt, for: peerID) + let claimedAt = DispatchTime.now().uptimeNanoseconds + #expect(claimed == attempt.payload) + let expired = await TestHelpers.waitUntil( + { recorder.timeoutCount == 1 }, + timeout: 5 + ) + #expect(expired) + let firedAt = try #require(recorder.firstTimeoutUptimeNanoseconds) + try #require(firedAt >= claimedAt) + let elapsed = TimeInterval(firedAt - claimedAt) / 1_000_000_000 + // A non-rearmed deadline would fire roughly 0.5 seconds after the + // claim. Measure on the timeout queue instead of relying on a task to + // resume inside a narrow pre-deadline window under parallel CI load. + #expect(elapsed >= timeoutInterval * 0.75) + #expect(!service.hasSession(with: peerID)) + #expect(recorder.timeoutCount == 1) + } + + @Test("Duplicate spoofed message one cannot extend rollback or repause during cooldown") + func pacedMessageOneCannotHoldOutboundPaused() async throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService( + keychain: MockKeychain(), + // Generous for the same reason as the quarantine-restore test + // (#1483): this timeout also arms during the `establishSessions` + // setup handshake below, where bob is the responder. At 0.06 a + // preempted runner could fire it mid-setup, tear down the half-open + // responder, and make message 3 be answered as a fresh initiation. + ordinaryResponderHandshakeTimeout: 1.0, + ordinaryReconnectRollbackCooldown: 0.3 + ) + let mallory = NoiseEncryptionService(keychain: MockKeychain()) + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) + let recovery = HandshakeStartRecorder() + bob.onHandshakeRecoveryRequired = { [weak bob] request in + bob?.cancelHandshakeRecovery(request) + recovery.recordTimeout() + } + try establishSessions(alice: alice, bob: bob) + + let spoofedMessage1 = try mallory.initiateHandshake(with: bobPeerID) + _ = try #require( + try bob.processHandshakeMessage( + from: alicePeerID, + message: spoofedMessage1 + ) + ) + // Exercise replacement before yielding: the test runner may resume a + // short sleep after the fixed responder deadline under parallel load. + _ = try #require( + try bob.processHandshakeMessage( + from: alicePeerID, + message: spoofedMessage1 + ) + ) + + let restored = await TestHelpers.waitUntil( + { bob.hasEstablishedSession(with: alicePeerID) }, + timeout: TestConstants.longTimeout + ) + #expect(restored) + let callbackArrived = await TestHelpers.waitUntil( + { recovery.timeoutCount == 1 }, + timeout: TestConstants.longTimeout + ) + #expect(callbackArrived) + + // Still inside cooldown: the same unauthenticated initiation is + // coalesced without removing the restored outbound generation. + #expect( + try bob.processHandshakeMessage( + from: alicePeerID, + message: spoofedMessage1 + ) == nil + ) + #expect(bob.hasEstablishedSession(with: alicePeerID)) + let ciphertext = try alice.encrypt( + Data("not repaused".utf8), + for: bobPeerID + ) + #expect( + try bob.decrypt(ciphertext, from: alicePeerID) + == Data("not repaused".utf8) + ) + try? await Task.sleep(nanoseconds: 100_000_000) + #expect(recovery.timeoutCount == 1) + } + + @Test("Lost reconnect message three restores then retries once") + func lostReconnectCompletionGetsOneLocalRetry() async throws { + let alice = NoiseEncryptionService( + keychain: MockKeychain(), + ordinaryHandshakeTimeout: 0.04 + ) + let bob = NoiseEncryptionService( + keychain: MockKeychain(), + ordinaryHandshakeTimeout: 0.04, + // Also arms during the `establishSessions` setup handshake below, + // where bob is the responder. Observed failing on a loaded CI + // runner with exactly the signature #1483 documented: the setup's + // `#expect(finalMessage == nil)` saw a 96-byte message 2, because + // the half-open responder had already been torn down and message 3 + // was answered as a fresh initiation. + ordinaryResponderHandshakeTimeout: 1.0 + ) + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) + try establishSessions(alice: alice, bob: bob) + alice.clearSession(for: bobPeerID) + + let recovery = HandshakeStartRecorder() + bob.onHandshakeRecoveryRequired = { [weak bob] request in + guard let bob else { return } + do { + recovery.recordTimeout() + recovery.record( + message: try claimPreparedRecoveryPayload( + bob, + request: request + ) + ) + } catch { + recovery.record(error: error) + } + } + + let message1 = try alice.initiateHandshake(with: bobPeerID) + let message2 = try #require( + try bob.processHandshakeMessage(from: alicePeerID, message: message1) + ) + _ = try #require( + try alice.processHandshakeMessage(from: bobPeerID, message: message2) + ) + // Drop message 3. Bob restores its old receive-only transport and + // initiates one bounded convergence retry; drop that message 1 too. + let retryPrepared = await TestHelpers.waitUntil( + { recovery.messages.count == 1 }, + timeout: TestConstants.longTimeout + ) + #expect(retryPrepared) + let retryExpired = await TestHelpers.waitUntil( + { !bob.hasSession(with: alicePeerID) }, + timeout: TestConstants.longTimeout + ) + #expect(retryExpired) + #expect(recovery.timeoutCount == 1) + #expect(recovery.errorCount == 0) + } + + @Test("Deterministic responder recovers once from an always-yield peer") + func yieldedResponderRecoversFromLegacyDoubleYield() async throws { + let timeoutInterval: TimeInterval = 1 + let endpoints = orderedServices( + ordinaryHandshakeTimeout: timeoutInterval, + ordinaryResponderHandshakeTimeout: timeoutInterval + ) + let modern = endpoints.higher + let legacy = endpoints.lower + let recovery = HandshakeStartRecorder() + modern.onHandshakeRecoveryRequired = { request in + // Preparing here would arm the retry before the test task can + // forward message 1. Record the token so preparation and the + // simulated on-wire exchange remain synchronous. + recovery.recordTimeout() + recovery.record(request: request) + } + + let modernAttempt = try #require( + try modern.initiateHandshakeIfNeeded( + with: endpoints.lowerPeerID, + retryOnTimeout: true + ) + ) + let legacyAttempt = try #require( + try legacy.initiateHandshakeIfNeeded( + with: endpoints.higherPeerID + ) + ) + let modernMessage1 = try #require( + modern.claimHandshakeInitiation( + modernAttempt, + for: endpoints.lowerPeerID + ) + ) + let legacyMessage1 = try #require( + legacy.claimHandshakeInitiation( + legacyAttempt, + for: endpoints.higherPeerID + ) + ) + + let modernMessage2 = try #require( + try modern.processHandshakeMessage( + from: endpoints.lowerPeerID, + message: legacyMessage1 + ) + ) + // Released peers yielded regardless of ID. Clearing their local + // initiator reproduces that role choice without changing wire bytes. + legacy.clearSession(for: endpoints.higherPeerID) + let legacyMessage2 = try #require( + try legacy.processHandshakeMessage( + from: endpoints.higherPeerID, + message: modernMessage1 + ) + ) + + do { + _ = try modern.processHandshakeMessage( + from: endpoints.lowerPeerID, + message: legacyMessage2 + ) + Issue.record("Expected the crossed responder message to fail") + } catch is NoiseManagedHandshakeFailure { + // The manager owns the single retry. + } catch { + Issue.record("Unexpected managed failure: \(error)") + } + do { + _ = try legacy.processHandshakeMessage( + from: endpoints.higherPeerID, + message: modernMessage2 + ) + Issue.record("Expected the legacy responder message to fail") + } catch { + // Expected; this side did not own retry intent. + } + + let recoveryRequested = await TestHelpers.waitUntil( + { recovery.requests.count == 1 }, + timeout: 5 + ) + #expect(recoveryRequested) + let recoveryRequest = try #require(recovery.requests.first) + let retryMessage1 = try #require( + try claimPreparedRecoveryPayload( + modern, + request: recoveryRequest + ) + ) + let retryMessage2 = try #require( + try legacy.processHandshakeMessage( + from: endpoints.higherPeerID, + message: retryMessage1 + ) + ) + let retryMessage3 = try #require( + try modern.processHandshakeMessage( + from: endpoints.lowerPeerID, + message: retryMessage2 + ) + ) + _ = try legacy.processHandshakeMessage( + from: endpoints.higherPeerID, + message: retryMessage3 + ) + try? await Task.sleep( + nanoseconds: UInt64(timeoutInterval * 1_200_000_000) + ) + #expect(recovery.timeoutCount == 1) + let ciphertext = try modern.encrypt( + Data("legacy converged".utf8), + for: endpoints.lowerPeerID + ) + #expect( + try legacy.decrypt( + ciphertext, + from: endpoints.higherPeerID + ) == Data("legacy converged".utf8) + ) + } + + @Test("Immediate legacy restart during completion grace converges once") + func immediateLegacyRestartDuringCompletionGrace() async throws { + let firstKeychain = MockKeychain() + let secondKeychain = MockKeychain() + let first = NoiseEncryptionService( + keychain: firstKeychain, + recentInitiatorCompletionGracePeriod: 0.03 + ) + let second = NoiseEncryptionService( + keychain: secondKeychain, + recentInitiatorCompletionGracePeriod: 0.03 + ) + let firstPeerID = PeerID(publicKey: first.getStaticPublicKeyData()) + let secondPeerID = PeerID(publicKey: second.getStaticPublicKeyData()) + + let lower: NoiseEncryptionService + let higher: NoiseEncryptionService + let higherKeychain: MockKeychain + let lowerPeerID: PeerID + let higherPeerID: PeerID + if firstPeerID < secondPeerID { + lower = first + lowerPeerID = firstPeerID + higher = second + higherKeychain = secondKeychain + higherPeerID = secondPeerID + } else { + lower = second + lowerPeerID = secondPeerID + higher = first + higherKeychain = firstKeychain + higherPeerID = firstPeerID + } + try establishSessions(alice: lower, bob: higher) + + let restartedHigher = NoiseEncryptionService(keychain: higherKeychain) + let recovery = HandshakeStartRecorder() + lower.onHandshakeRecoveryRequired = { [weak lower] request in + guard let lower else { return } + do { + recovery.recordTimeout() + recovery.record( + message: try claimPreparedRecoveryPayload( + lower, + request: request + ) + ) + } catch { + recovery.record(error: error) + } + } + + let restartAttempt = try #require( + try restartedHigher.initiateHandshakeIfNeeded(with: lowerPeerID) + ) + let restartMessage1 = try #require( + restartedHigher.claimHandshakeInitiation( + restartAttempt, + for: lowerPeerID + ) + ) + #expect( + try lower.processHandshakeMessage( + from: higherPeerID, + message: restartMessage1 + ) == nil + ) + #expect( + try lower.processHandshakeMessage( + from: higherPeerID, + message: restartMessage1 + ) == nil + ) + #expect(lower.hasEstablishedSession(with: higherPeerID)) + + let requested = await TestHelpers.waitUntil( + { recovery.messages.count == 1 }, + timeout: TestConstants.longTimeout + ) + #expect(requested) + let retryMessage1 = try #require(recovery.messages.first) + let retryMessage2 = try #require( + try restartedHigher.processHandshakeMessage( + from: lowerPeerID, + message: retryMessage1 + ) + ) + let retryMessage3 = try #require( + try lower.processHandshakeMessage( + from: higherPeerID, + message: retryMessage2 + ) + ) + _ = try restartedHigher.processHandshakeMessage( + from: lowerPeerID, + message: retryMessage3 + ) + try? await Task.sleep(nanoseconds: 100_000_000) + #expect(recovery.timeoutCount == 1) + #expect(recovery.errorCount == 0) + let ciphertext = try restartedHigher.encrypt( + Data("restart converged".utf8), + for: lowerPeerID + ) + #expect( + try lower.decrypt(ciphertext, from: higherPeerID) + == Data("restart converged".utf8) + ) + } + + @Test("Atomic reconnect retires old sending keys before message one") + func atomicReconnectQueuesUntilOrdinaryHandshakeCompletes() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) + + try establishSessions(alice: alice, bob: bob) + let oldCiphertext = try alice.encrypt(Data("old transport".utf8), for: bobPeerID) + #expect(try bob.decrypt(oldCiphertext, from: alicePeerID) == Data("old transport".utf8)) + + let initiation = try alice.initiateReconnectHandshake(with: bobPeerID) + #expect(alice.hasSession(with: bobPeerID)) + #expect(!alice.hasEstablishedSession(with: bobPeerID)) + do { + _ = try alice.encrypt(Data("must queue".utf8), for: bobPeerID) + Issue.record("Expected encryption to wait for the reconnect") + } catch NoiseEncryptionError.handshakeRequired { + // Expected: BLE queues behind the ordinary handshaking session. + } catch { + Issue.record("Unexpected in-window encryption error: \(error)") + } + + let message1 = try #require( + alice.claimHandshakeInitiation(initiation, for: bobPeerID) + ) + bob.clearSession(for: alicePeerID) + let message2 = try #require( + try bob.processHandshakeMessage(from: alicePeerID, message: message1) + ) + let message3 = try #require( + try alice.processHandshakeMessage(from: bobPeerID, message: message2) + ) + _ = try bob.processHandshakeMessage(from: alicePeerID, message: message3) + + let fresh = try alice.encrypt(Data("fresh transport".utf8), for: bobPeerID) + #expect(try bob.decrypt(fresh, from: alicePeerID) == Data("fresh transport".utf8)) + } + + @Test("Inbound reconnect quarantines old sending keys until identity proof") + func inboundReconnectQuarantinesOldTransport() throws { + let restarted = NoiseEncryptionService(keychain: MockKeychain()) + let retained = NoiseEncryptionService(keychain: MockKeychain()) + let restartedPeerID = PeerID(publicKey: restarted.getStaticPublicKeyData()) + let retainedPeerID = PeerID(publicKey: retained.getStaticPublicKeyData()) + + try establishSessions(alice: restarted, bob: retained) + let inFlightOldCiphertext = try restarted.encrypt( + Data("old receive-only transport".utf8), + for: retainedPeerID + ) + restarted.clearSession(for: retainedPeerID) + + let message1 = try restarted.initiateHandshake(with: retainedPeerID) + let message2 = try #require( + try retained.processHandshakeMessage( + from: restartedPeerID, + message: message1 + ) + ) + #expect(!retained.hasEstablishedSession(with: restartedPeerID)) + #expect( + try retained.decrypt( + inFlightOldCiphertext, + from: restartedPeerID + ) == Data("old receive-only transport".utf8) + ) + do { + _ = try retained.encrypt( + Data("must queue at responder".utf8), + for: restartedPeerID + ) + Issue.record("Expected quarantined responder encryption to wait") + } catch NoiseEncryptionError.handshakeRequired { + // Expected. + } catch { + Issue.record("Unexpected quarantine encryption error: \(error)") + } + + let message3 = try #require( + try restarted.processHandshakeMessage( + from: retainedPeerID, + message: message2 + ) + ) + _ = try retained.processHandshakeMessage( + from: restartedPeerID, + message: message3 + ) + + let fresh = try retained.encrypt( + Data("identity proved".utf8), + for: restartedPeerID + ) + #expect( + try restarted.decrypt(fresh, from: retainedPeerID) + == Data("identity proved".utf8) + ) + } + + @Test("Malformed handshake bytes cannot tear down an established session") + func establishedSessionIgnoresNonInitialHandshakeGarbage() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) + try establishSessions(alice: alice, bob: bob) + + #expect( + try bob.processHandshakeMessage( + from: alicePeerID, + message: Data(repeating: 0xA5, count: 31) + ) == nil + ) + #expect(bob.hasEstablishedSession(with: alicePeerID)) + let ciphertext = try alice.encrypt( + Data("session survived".utf8), + for: bobPeerID + ) + #expect( + try bob.decrypt(ciphertext, from: alicePeerID) + == Data("session survived".utf8) + ) + } + + @Test("Forged reconnect restores the quarantined transport") + func forgedReconnectRestoresQuarantinedTransport() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let mallory = NoiseEncryptionService(keychain: MockKeychain()) + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) + + try establishSessions(alice: alice, bob: bob) + + let forgedMessage1 = try mallory.initiateHandshake(with: bobPeerID) + let forgedMessage2 = try #require( + try bob.processHandshakeMessage( + from: alicePeerID, + message: forgedMessage1 + ) + ) + #expect(!bob.hasEstablishedSession(with: alicePeerID)) + let forgedMessage3 = try #require( + try mallory.processHandshakeMessage( + from: bobPeerID, + message: forgedMessage2 + ) + ) + + do { + _ = try bob.processHandshakeMessage( + from: alicePeerID, + message: forgedMessage3 + ) + Issue.record("Expected forged static identity to be rejected") + } catch NoiseSessionError.peerIdentityMismatch { + // Expected; the manager restores the quarantined transport. + } catch { + Issue.record("Unexpected forged reconnect error: \(error)") + } + + #expect(bob.hasEstablishedSession(with: alicePeerID)) + let oldTransport = try alice.encrypt( + Data("rollback survived".utf8), + for: bobPeerID + ) + #expect( + try bob.decrypt(oldTransport, from: alicePeerID) + == Data("rollback survived".utf8) + ) + } + + @Test("Lost reconnect completion restores the quarantined transport") + func timedOutReconnectRestoresQuarantinedTransport() async throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + // The injected responder timeout also arms during the ordinary setup + // handshake below (bob is its responder), where the only work between + // message 1 and message 3 is two consecutive synchronous statements. + // It must be generous enough that a preempted runner cannot let the + // timeout fire mid-setup and tear down the half-open responder — at + // 20ms a loaded 2-core CI runner did exactly that, so message 3 was + // answered as a fresh initiation (96-byte message 2) and nothing was + // ever quarantined. + let bob = NoiseEncryptionService( + keychain: MockKeychain(), + ordinaryResponderHandshakeTimeout: 1.0 + ) + let mallory = NoiseEncryptionService(keychain: MockKeychain()) + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) + + try establishSessions(alice: alice, bob: bob) + let forgedMessage1 = try mallory.initiateHandshake(with: bobPeerID) + _ = try #require( + try bob.processHandshakeMessage( + from: alicePeerID, + message: forgedMessage1 + ) + ) + #expect(!bob.hasEstablishedSession(with: alicePeerID)) + + // Poll instead of sleeping a fixed interval: the responder timeout + // fires on bob's manager queue at the quarantine deadline, and a + // starved runner can delay that work item well past the deadline. + let restored = await TestHelpers.waitUntil( + { bob.hasEstablishedSession(with: alicePeerID) }, + timeout: TestConstants.longTimeout + ) + try #require( + restored, + "Responder timeout should restore the quarantined transport" + ) + let oldTransport = try alice.encrypt( + Data("timeout rollback".utf8), + for: bobPeerID + ) + #expect( + try bob.decrypt(oldTransport, from: alicePeerID) + == Data("timeout rollback".utf8) + ) + } + + @Test("Failed reconnect authorization preserves the established transport") + func failedReconnectAuthorizationPreservesSession() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) + + try establishSessions(alice: alice, bob: bob) + // The initiator exchange consumed two authorizations for this peer. + for _ in 2.. ( + lower: NoiseEncryptionService, + lowerPeerID: PeerID, + higher: NoiseEncryptionService, + higherPeerID: PeerID +) { + let first = NoiseEncryptionService( + keychain: MockKeychain(), + ordinaryHandshakeTimeout: ordinaryHandshakeTimeout, + ordinaryResponderHandshakeTimeout: + ordinaryResponderHandshakeTimeout + ) + let second = NoiseEncryptionService( + keychain: MockKeychain(), + ordinaryHandshakeTimeout: ordinaryHandshakeTimeout, + ordinaryResponderHandshakeTimeout: + ordinaryResponderHandshakeTimeout + ) + let firstPeerID = PeerID(publicKey: first.getStaticPublicKeyData()) + let secondPeerID = PeerID(publicKey: second.getStaticPublicKeyData()) + if firstPeerID < secondPeerID { + return (first, firstPeerID, second, secondPeerID) + } + return (second, secondPeerID, first, firstPeerID) +} + +private func claimPreparedRecoveryPayload( + _ service: NoiseEncryptionService, + request: NoiseHandshakeRecoveryRequest +) throws -> Data? { + guard let preparation = + try service.prepareHandshakeRecovery(request) else { + return nil + } + switch preparation { + case .ordinary(let initiation): + return service.claimHandshakeInitiation( + initiation, + for: request.peerID + ) + case .transferred: + return nil + } +} + private final class AuthenticationRecorder: @unchecked Sendable { private let lock = NSLock() private var entries: [(PeerID, String)] = [] + private var generationEntries: [(PeerID, UUID)] = [] var count: Int { lock.lock() @@ -224,9 +1563,125 @@ private final class AuthenticationRecorder: @unchecked Sendable { return entries.count } + var generationCount: Int { + lock.lock() + defer { lock.unlock() } + return generationEntries.count + } + func record(peerID: PeerID, fingerprint: String) { lock.lock() entries.append((peerID, fingerprint)) lock.unlock() } + + func record(peerID: PeerID, fingerprint _: String, sessionGeneration: UUID) { + lock.lock() + generationEntries.append((peerID, sessionGeneration)) + lock.unlock() + } + + func generation(for peerID: PeerID) -> UUID? { + lock.lock() + defer { lock.unlock() } + return generationEntries.last { $0.0 == peerID }?.1 + } +} + +private final class HandshakeInitiationRecorder: @unchecked Sendable { + private let lock = NSLock() + private var storedInitiations: [NoiseHandshakeInitiation] = [] + private var storedErrorCount = 0 + + var initiations: [NoiseHandshakeInitiation] { + lock.lock() + defer { lock.unlock() } + return storedInitiations + } + + var errorCount: Int { + lock.lock() + defer { lock.unlock() } + return storedErrorCount + } + + func record(_ initiation: NoiseHandshakeInitiation?) { + guard let initiation else { return } + lock.lock() + storedInitiations.append(initiation) + lock.unlock() + } + + func record(error _: Error) { + lock.lock() + storedErrorCount += 1 + lock.unlock() + } +} + +private final class HandshakeStartRecorder: @unchecked Sendable { + private let lock = NSLock() + private var storedMessages: [Data] = [] + private var storedRequests: [NoiseHandshakeRecoveryRequest] = [] + private var storedErrorCount = 0 + private var storedTimeoutCount = 0 + private var storedTimeoutUptimes: [UInt64] = [] + + var messages: [Data] { + lock.lock() + defer { lock.unlock() } + return storedMessages + } + + var requests: [NoiseHandshakeRecoveryRequest] { + lock.lock() + defer { lock.unlock() } + return storedRequests + } + + var errorCount: Int { + lock.lock() + defer { lock.unlock() } + return storedErrorCount + } + + var timeoutCount: Int { + lock.lock() + defer { lock.unlock() } + return storedTimeoutCount + } + + var firstTimeoutUptimeNanoseconds: UInt64? { + lock.lock() + defer { lock.unlock() } + return storedTimeoutUptimes.first + } + + func record(message: Data?) { + guard let message else { return } + lock.lock() + storedMessages.append(message) + lock.unlock() + } + + func record(request: NoiseHandshakeRecoveryRequest) { + lock.lock() + storedRequests.append(request) + lock.unlock() + } + + func record(error _: Error) { + lock.lock() + storedErrorCount += 1 + lock.unlock() + } + + func recordTimeout( + at uptimeNanoseconds: UInt64 = DispatchTime.now().uptimeNanoseconds + ) { + lock.lock() + storedTimeoutCount += 1 + storedTimeoutUptimes.append(uptimeNanoseconds) + lock.unlock() + } } diff --git a/bitchatTests/Services/NostrRelayManagerTests.swift b/bitchatTests/Services/NostrRelayManagerTests.swift index 6deaa2b6..e6c79201 100644 --- a/bitchatTests/Services/NostrRelayManagerTests.swift +++ b/bitchatTests/Services/NostrRelayManagerTests.swift @@ -44,6 +44,46 @@ final class NostrRelayManagerTests: XCTestCase { XCTAssertTrue(context.sessionFactory.allConnections.allSatisfy { $0.cancelCallCount >= 1 }) } + /// A relay removed while its connection is queued behind Tor bootstrap + /// must stay removed: draining the pending set used to resurrect it, + /// because `dropRelays` never touched `pendingTorConnectionURLs` and a + /// custom relay is in neither the default set nor the allow-list filter. + func test_relayRemovedWhileWaitingForTor_staysRemovedWhenTorBecomesReady() async { + let customURL = "wss://custom-removed.example" + let center = NotificationCenter() + let customRelays = MutableRelayList(urls: [customURL]) + let context = makeContext( + permission: .authorized, + userTorEnabled: true, + torEnforced: true, + torIsReady: false, + notificationCenter: center, + customRelays: customRelays + ) + + // Defaults plus the custom relay all queue while Tor bootstraps. + context.manager.connect() + XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) + XCTAssertEqual(context.torWaiter.awaitCallCount, 1) + + // The relay is removed by hand before Tor is ready. + customRelays.urls = [] + center.post(name: NostrRelaySettings.didChangeNotification, object: nil) + // The settings sink hops through the main queue; let it land. + try? await Task.sleep(nanoseconds: 20_000_000) + + context.torWaiter.resolve(true) + + let defaultsConnected = await waitUntil { + context.sessionFactory.requestedURLs.count == self.expectedDefaultRelayCount + } + XCTAssertTrue(defaultsConnected) + XCTAssertFalse( + context.sessionFactory.requestedURLs.contains(customURL), + "a relay removed while Tor was bootstrapping must not reconnect when the pending queue drains" + ) + } + func test_connect_waitsForTorReadinessBeforeCreatingSessions() async { let context = makeContext(permission: .authorized, userTorEnabled: true, torEnforced: true, torIsReady: false) @@ -754,11 +794,15 @@ final class NostrRelayManagerTests: XCTestCase { try context.sessionFactory.latestConnection(for: firstRelayURL)?.emitEventMessage(subscriptionID: "events", event: event) try context.sessionFactory.latestConnection(for: secondRelayURL)?.emitEventMessage(subscriptionID: "events", event: event) - let countedOnBothRelays = await waitUntil { + // Wait on the DELIVERY-side state: handler dispatch and the duplicate + // drop both land on the second main hop, after off-main verification. + let settled = await waitUntil { 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(countedOnBothRelays) + XCTAssertTrue(settled) XCTAssertEqual(receivedIDs, [event.id]) XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 1) XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount(forSubscriptionID: "events"), 1) @@ -791,12 +835,17 @@ final class NostrRelayManagerTests: XCTestCase { ) } - let countedOnEveryRelay = await waitUntil { + // Wait on the DELIVERY-side state: the winner's handler dispatch and + // the losers' duplicate drops both land on the second main hop, after + // off-main verification — messagesReceived (first hop) settles sooner. + let settled = await waitUntil { relayURLs.allSatisfy { relayURL in context.manager.relays.first(where: { $0.url == relayURL })?.messagesReceived == 1 - } + } && + receivedIDs == [event.id] && + context.manager.debugDuplicateInboundEventDropCount == relayURLs.count - 1 } - XCTAssertTrue(countedOnEveryRelay) + XCTAssertTrue(settled) XCTAssertEqual(receivedIDs, [event.id]) XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, relayURLs.count - 1) XCTAssertEqual( @@ -829,16 +878,153 @@ final class NostrRelayManagerTests: XCTestCase { try context.sessionFactory.latestConnection(for: firstRelayURL)?.emitEventMessage(subscriptionID: "events", event: invalidEvent) try context.sessionFactory.latestConnection(for: secondRelayURL)?.emitEventMessage(subscriptionID: "events", event: event) - let countedOnBothRelays = await waitUntil { + // 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 + context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1 && + receivedIDs == [event.id] } - XCTAssertTrue(countedOnBothRelays) + XCTAssertTrue(genuineDelivered) XCTAssertEqual(receivedIDs, [event.id]) XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 0) XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount(forSubscriptionID: "events"), 0) } + /// The relay boundary is the single signature-verification point for the + /// whole inbound path (downstream pipelines no longer re-verify), so a + /// tampered gift wrap (kind 1059, the DM/mailbox path) must be dropped + /// here — and must not poison the dedup cache against the genuine copy. + func test_receiveGiftWrap_tamperedSignatureIsDroppedAndDoesNotPoisonDedup() async throws { + let firstRelayURL = "wss://giftwrap-one.example" + let secondRelayURL = "wss://giftwrap-two.example" + let context = makeContext(permission: .denied) + let sender = try NostrIdentity.generate() + let recipient = try NostrIdentity.generate() + let giftWrap = try NostrProtocol.createPrivateMessage( + content: "psst", + recipientPubkey: recipient.publicKeyHex, + senderIdentity: sender + ) + let tampered = invalidSignatureCopy(of: giftWrap) + var receivedIDs: [String] = [] + + context.manager.subscribe( + filter: makeFilter(), + id: "gift-wraps", + relayUrls: [firstRelayURL, secondRelayURL] + ) { event in + receivedIDs.append(event.id) + } + let subscriptionsSent = await waitUntil { + context.sessionFactory.latestConnection(for: firstRelayURL)?.sentStrings.count == 1 && + context.sessionFactory.latestConnection(for: secondRelayURL)?.sentStrings.count == 1 + } + XCTAssertTrue(subscriptionsSent) + + try context.sessionFactory.latestConnection(for: firstRelayURL)?.emitEventMessage(subscriptionID: "gift-wraps", event: tampered) + try context.sessionFactory.latestConnection(for: secondRelayURL)?.emitEventMessage(subscriptionID: "gift-wraps", event: giftWrap) + + // Wait on the DELIVERY-side state (second main hop, after off-main + // verification), not just messagesReceived (first main hop) — the + // handler only fires after verify + a second hop. + let genuineDelivered = await waitUntil { + context.manager.relays.first(where: { $0.url == firstRelayURL })?.messagesReceived == 1 && + context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1 && + receivedIDs == [giftWrap.id] + } + XCTAssertTrue(genuineDelivered) + XCTAssertEqual(receivedIDs, [giftWrap.id]) + XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 0) + } + + /// Signature verification runs off-main in a per-relay serial consumer; + /// several frames buffered on one socket must still be delivered to the + /// handler in that relay's arrival order. + func test_receiveEvent_deliversBackToBackEventsInArrivalOrder() async throws { + let relayURL = "wss://ordered.example" + let context = makeContext(permission: .denied) + let events = try (0..<12).map { try makeSignedEvent(content: "ordered-\($0)") } + var receivedIDs: [String] = [] + + context.manager.subscribe(filter: makeFilter(), id: "ordered", relayUrls: [relayURL]) { event in + receivedIDs.append(event.id) + } + let subscriptionSent = await waitUntil { + context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1 + } + XCTAssertTrue(subscriptionSent) + + for event in events { + try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: "ordered", event: event) + } + + let allDelivered = await waitUntil(timeout: TestConstants.settleTimeout) { + 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: TestConstants.settleTimeout) { 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: TestConstants.settleTimeout) { + busyDeliveredCount == busyEvents.count + } + XCTAssertTrue(allDelivered) + } + func test_receiveEvent_withoutHandlerStillTracksReceivedCount() async throws { let relayURL = "wss://missing-handler.example" let context = makeContext(permission: .denied) @@ -1589,6 +1775,58 @@ final class NostrRelayManagerTests: XCTestCase { XCTAssertGreaterThan(Set(factors).count, 1) } + // MARK: - Hand-added relays + + /// Adding a relay has to take effect without a restart: the whole point is + /// recovering reachability when the built-in hostnames are blocked. + @MainActor + func testAddedRelayJoinsTheTargetSetOnSettingsChange() async { + let center = NotificationCenter() + let custom = MutableRelayList(urls: []) + let context = makeContext( + permission: .authorized, + notificationCenter: center, + customRelays: custom + ) + + XCTAssertFalse(context.manager.relays.contains { $0.url == "wss://added.example.com" }) + + custom.urls = ["wss://added.example.com"] + center.post(name: NostrRelaySettings.didChangeNotification, object: nil) + + let joined = await waitUntil { + context.manager.relays.contains { $0.url == "wss://added.example.com" } + } + XCTAssertTrue(joined) + } + + /// Removing a relay must actually close it. The teardown path iterates the + /// current target list, and a removed relay is no longer in it, so without + /// an explicit reconcile against the previous set its socket and queued + /// sends would linger. + @MainActor + func testRemovedRelayLeavesTheTargetSet() async { + let center = NotificationCenter() + let custom = MutableRelayList(urls: ["wss://added.example.com"]) + let context = makeContext( + permission: .authorized, + notificationCenter: center, + customRelays: custom + ) + + XCTAssertTrue(context.manager.relays.contains { $0.url == "wss://added.example.com" }) + + custom.urls = [] + center.post(name: NostrRelaySettings.didChangeNotification, object: nil) + + let dropped = await waitUntil { + !context.manager.relays.contains { $0.url == "wss://added.example.com" } + } + XCTAssertTrue(dropped) + // The built-in relays are untouched by a custom-relay removal. + XCTAssertTrue(context.manager.relays.contains { $0.url == "wss://nos.lol" }) + } + private func makeContext( permission: LocationChannelManager.PermissionState, favorites: Set = [], @@ -1597,6 +1835,8 @@ final class NostrRelayManagerTests: XCTestCase { torEnforced: Bool = false, torIsReady: Bool = true, torIsForeground: Bool = true, + notificationCenter: NotificationCenter = NotificationCenter(), + customRelays: MutableRelayList = MutableRelayList(urls: []), jitterUnit: @escaping () -> Double = { 0.5 } // 0.5 -> jitter factor 1.0 (no jitter) ) -> RelayManagerTestContext { let permissionSubject = CurrentValueSubject(permission) @@ -1624,7 +1864,9 @@ final class NostrRelayManagerTests: XCTestCase { scheduler.schedule(delay: delay, action: action) }, now: { clock.now }, - jitterUnit: jitterUnit + jitterUnit: jitterUnit, + notificationCenter: notificationCenter, + customRelays: { customRelays.urls } ) ) return RelayManagerTestContext( @@ -1665,7 +1907,7 @@ final class NostrRelayManagerTests: XCTestCase { } private func waitUntil( - timeout: TimeInterval = 1.0, + timeout: TimeInterval = TestConstants.settleTimeout, condition: @escaping @MainActor () -> Bool ) async -> Bool { let deadline = Date().addingTimeInterval(timeout) @@ -1699,6 +1941,16 @@ private final class MutableClock { } } +/// Stand-in for the persisted hand-added relay list, so tests can change it +/// without writing to shared preferences. +private final class MutableRelayList { + var urls: [String] + + init(urls: [String]) { + self.urls = urls + } +} + /// Deterministic jitter source: returns the queued values in order, then a /// neutral 0.5 (jitter factor 1.0) once exhausted. private final class JitterSequence { @@ -1840,7 +2092,11 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol { } func receive(completionHandler: @escaping (Result) -> Void) { - receiveHandler = completionHandler + if !pendingResults.isEmpty { + completionHandler(pendingResults.removeFirst()) + } else { + receiveHandler = completionHandler + } } func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) { @@ -1873,15 +2129,24 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol { } func emitRawString(_ string: String) throws { - let handler = receiveHandler - receiveHandler = nil - handler?(.success(.string(string))) + deliver(.success(.string(string))) } private func emit(jsonObject: Any) throws { let data = try JSONSerialization.data(withJSONObject: jsonObject) - let handler = receiveHandler - receiveHandler = nil - handler?(.success(.data(data))) + deliver(.success(.data(data))) + } + + // Frames emitted before the manager re-arms `receive` are queued so + // back-to-back emissions model a socket with several buffered frames. + private var pendingResults: [Result] = [] + + private func deliver(_ result: Result) { + if let handler = receiveHandler { + receiveHandler = nil + handler(result) + } else { + pendingResults.append(result) + } } } diff --git a/bitchatTests/Services/NostrTransportTests.swift b/bitchatTests/Services/NostrTransportTests.swift index e8fd19b5..5f657000 100644 --- a/bitchatTests/Services/NostrTransportTests.swift +++ b/bitchatTests/Services/NostrTransportTests.swift @@ -164,7 +164,7 @@ struct NostrTransportTests { transport.sendPrivateMessage("hello over nostr", to: shortPeerID, recipientNickname: "Carol", messageID: "pm-1") - let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0) + let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: TestConstants.settleTimeout) #expect(didSend) let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient) let privateMessage = try decodePrivateMessage(from: result.payload) @@ -209,7 +209,7 @@ struct NostrTransportTests { transport.sendFavoriteNotification(to: fullPeerID, isFavorite: true) - let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0) + let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: TestConstants.settleTimeout) #expect(didSend) let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient) let privateMessage = try decodePrivateMessage(from: result.payload) @@ -250,7 +250,7 @@ struct NostrTransportTests { transport.sendDeliveryAck(for: "ack-1", to: fullPeerID) - let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0) + let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: TestConstants.settleTimeout) #expect(didSend) let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient) @@ -288,7 +288,7 @@ struct NostrTransportTests { messageID: "geo-1" ) - let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0) + let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: TestConstants.settleTimeout) #expect(didSend) let event = probe.sentEvents[0] let result = try decodeEmbeddedPayload(from: event, recipient: recipient) diff --git a/bitchatTests/Services/NotificationRedactionTests.swift b/bitchatTests/Services/NotificationRedactionTests.swift new file mode 100644 index 00000000..be5c8109 --- /dev/null +++ b/bitchatTests/Services/NotificationRedactionTests.swift @@ -0,0 +1,130 @@ +import BitFoundation +import Foundation +import Testing +import UserNotifications +@testable import bitchat + +/// Lock-screen notifications used to carry the DM body and the sender's +/// nickname verbatim, and the geohash in the title, so a locked phone lying on +/// a table narrated conversations to anyone looking at it. These cover the +/// redaction that is now the default. +/// +/// The preference is injected rather than written to shared preferences. These +/// run in the same process as `NotificationServiceTests`, and mutating the +/// global store made whichever ran second depend on the other's cleanup — which +/// passed locally and failed in CI. +struct NotificationRedactionTests { + private final class RecordingDeliverer: NotificationRequestDelivering { + var requests: [UNNotificationRequest] = [] + + func add(_ request: UNNotificationRequest) { + requests.append(request) + } + } + + private struct StubAuthorizer: NotificationAuthorizing { + func requestAuthorization( + options: UNAuthorizationOptions, + completionHandler: @escaping (Bool, Error?) -> Void + ) { + completionHandler(true, nil) + } + } + + private func makeService( + hidePreviews: Bool + ) -> (NotificationService, RecordingDeliverer) { + let deliverer = RecordingDeliverer() + let service = NotificationService( + isRunningTestsProvider: { false }, + authorizer: StubAuthorizer(), + requestDeliverer: deliverer, + hidePreviewsProvider: { hidePreviews } + ) + return (service, deliverer) + } + + private func isolatedDefaults() -> UserDefaults { + UserDefaults(suiteName: "bitchat.tests.notifications.\(UUID().uuidString)")! + } + + @Test func previewsAreHiddenByDefault() { + // A fresh install must start quiet rather than opt-in quiet. + #expect(NotificationPrivacySettings.hideMessagePreviews(in: isolatedDefaults())) + } + + @Test func theSettingRoundTrips() { + let defaults = isolatedDefaults() + + NotificationPrivacySettings.setHideMessagePreviews(false, in: defaults) + #expect(!NotificationPrivacySettings.hideMessagePreviews(in: defaults)) + + // Panic wipe restores the safe default rather than the last choice. + NotificationPrivacySettings.reset(in: defaults) + #expect(NotificationPrivacySettings.hideMessagePreviews(in: defaults)) + } + + @Test func redactedDirectMessageWithholdsSenderAndBody() throws { + let (service, deliverer) = makeService(hidePreviews: true) + + service.sendPrivateMessageNotification( + from: "alice", + message: "meet at the north gate", + peerID: PeerID(str: "00112233445566ff") + ) + + let content = try #require(deliverer.requests.first).content + #expect(!content.title.contains("alice")) + #expect(!content.body.contains("north gate")) + #expect(!content.title.isEmpty) + // Still routable: userInfo is never rendered on the lock screen. + #expect(content.userInfo["peerID"] as? String == "00112233445566ff") + } + + @Test func redactedMentionWithholdsSenderAndBody() throws { + let (service, deliverer) = makeService(hidePreviews: true) + + service.sendMentionNotification(from: "bob", message: "regroup now") + + let content = try #require(deliverer.requests.first).content + #expect(!content.title.contains("bob")) + #expect(!content.body.contains("regroup")) + } + + @Test func redactedGeohashActivityWithholdsTheGeohash() throws { + let (service, deliverer) = makeService(hidePreviews: true) + + service.sendGeohashActivityNotification( + geohash: "u4pruyd", + bodyPreview: "someone said something" + ) + + let content = try #require(deliverer.requests.first).content + #expect(!content.title.contains("u4pruyd")) + #expect(!content.body.contains("someone said")) + // The deep link still carries it: tapping must land in the channel. + #expect(content.userInfo["deeplink"] as? String == "bitchat://geohash/u4pruyd") + } + + @Test func previewsShownWhenTheSettingIsOff() { + let (service, deliverer) = makeService(hidePreviews: false) + + service.sendPrivateMessageNotification( + from: "alice", + message: "meet at the north gate", + peerID: PeerID(str: "00112233445566ff") + ) + service.sendGeohashActivityNotification( + geohash: "u4pruyd", + bodyPreview: "someone said something" + ) + + #expect(deliverer.requests.count == 2) + let dm = deliverer.requests[0].content + #expect(dm.title.contains("alice")) + #expect(dm.body == "meet at the north gate") + let geo = deliverer.requests[1].content + #expect(geo.title.contains("u4pruyd")) + #expect(geo.body == "someone said something") + } +} diff --git a/bitchatTests/Services/NotificationServiceTests.swift b/bitchatTests/Services/NotificationServiceTests.swift index 9ffad1f4..384f0075 100644 --- a/bitchatTests/Services/NotificationServiceTests.swift +++ b/bitchatTests/Services/NotificationServiceTests.swift @@ -56,12 +56,15 @@ final class NotificationServiceTests: XCTestCase { XCTAssertNil(request?.trigger) } + /// Previews shown: the opt-in behavior. Stated explicitly rather than + /// inherited from the shared preference, which now defaults to hidden. func test_sendPrivateMessageNotification_populatesPeerMetadata() { let deliverer = RecordingNotificationRequestDeliverer() let service = NotificationService( isRunningTestsProvider: { false }, authorizer: RecordingNotificationAuthorizer(), - requestDeliverer: deliverer + requestDeliverer: deliverer, + hidePreviewsProvider: { false } ) let peerID = PeerID(str: "deadbeefdeadbeef") @@ -74,6 +77,27 @@ final class NotificationServiceTests: XCTestCase { XCTAssertEqual(request?.content.userInfo["senderName"] as? String, "Alice") } + /// Previews hidden: the default. The routing payload has to survive + /// redaction, or tapping the alert would not open the conversation. + func test_sendPrivateMessageNotification_withPreviewsHidden_keepsRoutingButDropsContent() { + let deliverer = RecordingNotificationRequestDeliverer() + let service = NotificationService( + isRunningTestsProvider: { false }, + authorizer: RecordingNotificationAuthorizer(), + requestDeliverer: deliverer, + hidePreviewsProvider: { true } + ) + let peerID = PeerID(str: "deadbeefdeadbeef") + + service.sendPrivateMessageNotification(from: "Alice", message: "hi", peerID: peerID) + + let request = deliverer.requests.singleValue + XCTAssertFalse(request?.content.title.contains("Alice") ?? true) + XCTAssertFalse(request?.content.body.contains("hi") ?? true) + XCTAssertFalse(request?.content.title.isEmpty ?? true) + XCTAssertEqual(request?.content.userInfo["peerID"] as? String, peerID.id) + } + func test_wrapperNotifications_setExpectedIdentifiersAndDeepLinks() { let deliverer = RecordingNotificationRequestDeliverer() let service = NotificationService( diff --git a/bitchatTests/Services/SecureIdentityStateManagerTests.swift b/bitchatTests/Services/SecureIdentityStateManagerTests.swift index a280d02e..aae65dab 100644 --- a/bitchatTests/Services/SecureIdentityStateManagerTests.swift +++ b/bitchatTests/Services/SecureIdentityStateManagerTests.swift @@ -79,6 +79,100 @@ final class SecureIdentityStateManagerTests: XCTestCase { XCTAssertEqual(matches.first?.signingPublicKey, signingPublicKey) } + func test_upsertCryptographicIdentity_refusesToReplacePinnedSigningKey() async { + let manager = SecureIdentityStateManager(MockKeychain()) + let noisePublicKey = Data(repeating: 0x11, count: 32) + let fingerprint = noisePublicKey.sha256Fingerprint() + let peerID = PeerID(publicKey: noisePublicKey) + let victimSigningKey = Data(repeating: 0x22, count: 32) + let attackerSigningKey = Data(repeating: 0x66, count: 32) + + manager.upsertCryptographicIdentity( + fingerprint: fingerprint, + noisePublicKey: noisePublicKey, + signingPublicKey: victimSigningKey, + claimedNickname: "victim" + ) + let pinned = await waitUntil { + manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey == victimSigningKey + } + XCTAssertTrue(pinned) + + // Attacker upsert with a different signing key must be refused in + // full — signing key AND claimed nickname stay the victim's. + manager.upsertCryptographicIdentity( + fingerprint: fingerprint, + noisePublicKey: noisePublicKey, + signingPublicKey: attackerSigningKey, + claimedNickname: "attacker" + ) + + // Synchronous reads fence the manager's pending barrier writes. + XCTAssertEqual( + manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey, + victimSigningKey + ) + XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.claimedNickname, "victim") + + // The legitimate peer (same signing key) can still update. + manager.upsertCryptographicIdentity( + fingerprint: fingerprint, + noisePublicKey: noisePublicKey, + signingPublicKey: victimSigningKey, + claimedNickname: "victim-renamed" + ) + let renamed = await waitUntil { + manager.getSocialIdentity(for: fingerprint)?.claimedNickname == "victim-renamed" + } + XCTAssertTrue(renamed) + XCTAssertEqual( + manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey, + victimSigningKey + ) + } + + func test_cryptographicIdentity_persistsAcrossReinitAndKeepsSigningKeyPin() async { + let keychain = MockKeychain() + let manager = SecureIdentityStateManager(keychain) + let noisePublicKey = Data(repeating: 0x13, count: 32) + let fingerprint = noisePublicKey.sha256Fingerprint() + let peerID = PeerID(publicKey: noisePublicKey) + let victimSigningKey = Data(repeating: 0x24, count: 32) + let attackerSigningKey = Data(repeating: 0x77, count: 32) + + manager.upsertCryptographicIdentity( + fingerprint: fingerprint, + noisePublicKey: noisePublicKey, + signingPublicKey: victimSigningKey, + claimedNickname: "victim" + ) + let pinned = await waitUntil { + manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey == victimSigningKey + } + XCTAssertTrue(pinned) + manager.forceSave() + + // Simulated app restart: the pin must survive and still refuse a + // different signing key. + let reloaded = SecureIdentityStateManager(keychain) + XCTAssertEqual( + reloaded.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey, + victimSigningKey + ) + + reloaded.upsertCryptographicIdentity( + fingerprint: fingerprint, + noisePublicKey: noisePublicKey, + signingPublicKey: attackerSigningKey, + claimedNickname: "attacker" + ) + XCTAssertEqual( + reloaded.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey, + victimSigningKey + ) + XCTAssertEqual(reloaded.getSocialIdentity(for: fingerprint)?.claimedNickname, "victim") + } + func test_setBlocked_clearsFavoriteState() async { let manager = SecureIdentityStateManager(MockKeychain()) let fingerprint = String(repeating: "ab", count: 32) @@ -399,6 +493,59 @@ final class SecureIdentityStateManagerTests: XCTestCase { XCTAssertTrue(cleared) } + func test_privateMediaCapabilityPinPersistsMonotonicallyAndPanicClearRemovesIt() async { + let keychain = MockKeychain() + let fingerprint = Data(repeating: 0x42, count: 32).sha256Fingerprint() + let manager = SecureIdentityStateManager(keychain) + + XCTAssertFalse(manager.hasObservedPrivateMediaCapability(fingerprint: fingerprint)) + manager.markPrivateMediaCapable(fingerprint: fingerprint) + XCTAssertTrue( + manager.hasObservedPrivateMediaCapability(fingerprint: fingerprint), + "pin insertion must be synchronously visible to the next downgrade decision" + ) + + // Re-marking is idempotent, and the encrypted cache carries the pin + // across launches. + manager.markPrivateMediaCapable(fingerprint: fingerprint) + manager.forceSave() + let reloaded = SecureIdentityStateManager(keychain) + XCTAssertTrue(reloaded.hasObservedPrivateMediaCapability(fingerprint: fingerprint)) + + // ChatViewModel's panic path calls this same wipe after deleting + // keychain data; the in-memory pin must disappear immediately too. + reloaded.clearAllIdentityData() + let cleared = await waitUntil { + !reloaded.hasObservedPrivateMediaCapability(fingerprint: fingerprint) + } + XCTAssertTrue(cleared) + } + + func test_noiseAuthenticatedSigningKeyBindingPersistsAndPanicClearRemovesIt() async { + let keychain = MockKeychain() + let fingerprint = Data(repeating: 0x31, count: 32).sha256Fingerprint() + let firstKey = Data(repeating: 0x41, count: 32) + let rotatedKey = Data(repeating: 0x42, count: 32) + let manager = SecureIdentityStateManager(keychain) + + manager.bindAuthenticatedSigningPublicKey(firstKey, fingerprint: fingerprint) + XCTAssertEqual(manager.authenticatedSigningPublicKey(forFingerprint: fingerprint), firstKey) + // A later authenticated Noise session may legitimately rotate the + // announcement signing key. + manager.bindAuthenticatedSigningPublicKey(rotatedKey, fingerprint: fingerprint) + XCTAssertEqual(manager.authenticatedSigningPublicKey(forFingerprint: fingerprint), rotatedKey) + + manager.forceSave() + let reloaded = SecureIdentityStateManager(keychain) + XCTAssertEqual(reloaded.authenticatedSigningPublicKey(forFingerprint: fingerprint), rotatedKey) + + reloaded.clearAllIdentityData() + let cleared = await waitUntil { + reloaded.authenticatedSigningPublicKey(forFingerprint: fingerprint) == nil + } + XCTAssertTrue(cleared) + } + func test_forceSave_withFailingCacheWriteDoesNotPersistCache() async { let keychain = FailingCacheSaveKeychain() let manager = SecureIdentityStateManager(keychain) @@ -415,7 +562,7 @@ final class SecureIdentityStateManagerTests: XCTestCase { } private func waitUntil( - timeout: TimeInterval = 1.0, + timeout: TimeInterval = TestConstants.settleTimeout, condition: @escaping () -> Bool ) async -> Bool { let deadline = Date().addingTimeInterval(timeout) diff --git a/bitchatTests/Services/SecureIdentityStateManagerVouchTests.swift b/bitchatTests/Services/SecureIdentityStateManagerVouchTests.swift index 6ab46490..ae71c91a 100644 --- a/bitchatTests/Services/SecureIdentityStateManagerVouchTests.swift +++ b/bitchatTests/Services/SecureIdentityStateManagerVouchTests.swift @@ -320,7 +320,7 @@ struct SecureIdentityStateManagerVouchTests { // MARK: - Helpers private func waitUntil( - timeout: TimeInterval = 1.0, + timeout: TimeInterval = TestConstants.settleTimeout, condition: @escaping () -> Bool ) async -> Bool { let deadline = Date().addingTimeInterval(timeout) diff --git a/bitchatTests/Services/TorPreferenceReadTests.swift b/bitchatTests/Services/TorPreferenceReadTests.swift new file mode 100644 index 00000000..b9f198e3 --- /dev/null +++ b/bitchatTests/Services/TorPreferenceReadTests.swift @@ -0,0 +1,38 @@ +import Foundation +import Testing +@testable import bitchat + +/// The geo-relay directory refresh runs off the main actor and has to decide +/// whether waiting for Tor is meaningful. It previously waited unconditionally, +/// so with Tor switched off — and `TorManager` therefore shut down — every +/// refresh spent the full bootstrap timeout and the directory froze on its +/// cached copy. +struct TorPreferenceReadTests { + private func makeDefaults() -> UserDefaults { + UserDefaults(suiteName: "bitchat.tests.tor.\(UUID().uuidString)")! + } + + @Test func defaultsToOnWhenNothingHasBeenStored() { + // Fail safe: an unwritten preference must not read as "Tor off", which + // would let a fetch go direct. + #expect(NetworkActivationService.persistedTorPreference(in: makeDefaults())) + } + + @Test func reflectsTheStoredPreference() { + let defaults = makeDefaults() + + defaults.set(false, forKey: NetworkActivationService.torPreferenceKey) + #expect(!NetworkActivationService.persistedTorPreference(in: defaults)) + + defaults.set(true, forKey: NetworkActivationService.torPreferenceKey) + #expect(NetworkActivationService.persistedTorPreference(in: defaults)) + } + + @Test func nonBooleanStoredValueReadsAsOn() { + let defaults = makeDefaults() + defaults.set("nonsense", forKey: NetworkActivationService.torPreferenceKey) + + // Same fail-safe direction: anything unrecognized means keep using Tor. + #expect(NetworkActivationService.persistedTorPreference(in: defaults)) + } +} diff --git a/bitchatTests/Services/TransferProgressManagerTests.swift b/bitchatTests/Services/TransferProgressManagerTests.swift index 0bf1395f..9023c7f5 100644 --- a/bitchatTests/Services/TransferProgressManagerTests.swift +++ b/bitchatTests/Services/TransferProgressManagerTests.swift @@ -49,7 +49,7 @@ struct TransferProgressManagerTests { recorder.append("updated:\(id):\(sent):\(total)") case .completed(let id, let total): recorder.append("completed:\(id):\(total)") - case .cancelled: + case .cancelled, .rejected: break } } @@ -85,7 +85,7 @@ struct TransferProgressManagerTests { recorder.append("started:\(id):\(total)") case .cancelled(let id, let sent, let total): recorder.append("cancelled:\(id):\(sent):\(total)") - case .updated, .completed: + case .updated, .completed, .rejected: break } } @@ -105,6 +105,28 @@ struct TransferProgressManagerTests { #expect(manager.snapshot(id: transferID) == nil) _ = cancellable } + + @Test("Preflight policy rejection publishes a visible failure reason") + @MainActor + func rejectBeforeStartPublishesReason() async { + let manager = TransferProgressManager() + let transferID = "transfer-visible-reject" + let recorder = EventRecorder() + let cancellable = manager.publisher.sink { event in + if case .rejected(let id, let reason) = event { + recorder.append("rejected:\(id):\(reason)") + } + } + + manager.rejectBeforeStart(id: transferID, reason: "upgrade required") + + let didReceive = await TestHelpers.waitUntil({ + recorder.values == ["rejected:\(transferID):upgrade required"] + }, timeout: 5.0) + #expect(didReceive) + #expect(manager.snapshot(id: transferID) == nil) + _ = cancellable + } } private final class EventRecorder: @unchecked Sendable { diff --git a/bitchatTests/Services/UnifiedPeerServiceTests.swift b/bitchatTests/Services/UnifiedPeerServiceTests.swift index a81003ec..4bc6f775 100644 --- a/bitchatTests/Services/UnifiedPeerServiceTests.swift +++ b/bitchatTests/Services/UnifiedPeerServiceTests.swift @@ -266,6 +266,11 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol { verified.removeAll() } + func markPrivateMediaCapable(fingerprint: String) {} + func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool { false } + func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String) {} + func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data? { nil } + func removeEphemeralSession(peerID: PeerID) {} func setVerified(fingerprint: String, verified: Bool) { diff --git a/bitchatTests/SharedContentHandoffTests.swift b/bitchatTests/SharedContentHandoffTests.swift new file mode 100644 index 00000000..ff205ad6 --- /dev/null +++ b/bitchatTests/SharedContentHandoffTests.swift @@ -0,0 +1,173 @@ +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) + } +} diff --git a/bitchatTests/Sync/GossipSyncBoardTests.swift b/bitchatTests/Sync/GossipSyncBoardTests.swift index 7fe8bdcf..6e5b5357 100644 --- a/bitchatTests/Sync/GossipSyncBoardTests.swift +++ b/bitchatTests/Sync/GossipSyncBoardTests.swift @@ -48,7 +48,7 @@ struct GossipSyncBoardTests { let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .board) manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request) - try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout) + try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout) let sent = try #require(delegate.packets.first) #expect(sent.type == MessageType.boardPost.rawValue) #expect(sent.isRSR) @@ -69,7 +69,7 @@ struct GossipSyncBoardTests { let boardRequest = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .board) manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: boardRequest) - try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout) + try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout) #expect(delegate.packets.count == 1) #expect(delegate.packets.first?.type == MessageType.boardPost.rawValue) } diff --git a/bitchatTests/Sync/RequestSyncManagerTests.swift b/bitchatTests/Sync/RequestSyncManagerTests.swift index aada4bfc..497271e6 100644 --- a/bitchatTests/Sync/RequestSyncManagerTests.swift +++ b/bitchatTests/Sync/RequestSyncManagerTests.swift @@ -63,7 +63,7 @@ final class RequestSyncManagerTests: XCTestCase { } private func waitUntil( - timeout: TimeInterval = 1.0, + timeout: TimeInterval = TestConstants.settleTimeout, condition: @escaping () -> Bool ) async -> Bool { let deadline = Date().addingTimeInterval(timeout) diff --git a/bitchatTests/TestUtilities/TestConstants.swift b/bitchatTests/TestUtilities/TestConstants.swift index 83f5b46b..fb0cb65c 100644 --- a/bitchatTests/TestUtilities/TestConstants.swift +++ b/bitchatTests/TestUtilities/TestConstants.swift @@ -11,14 +11,54 @@ import Foundation struct TestConstants { static let defaultTimeout: TimeInterval = 5.0 - static let shortTimeout: TimeInterval = 1.0 /// For positive waits on work that hops through `Task.detached` or /// background queues: those contend with every parallel test worker for /// the global executor, so a loaded CI runner can exceed /// `defaultTimeout`. `waitUntil` returns as soon as the condition holds, /// so passing runs never pay the longer timeout. static let longTimeout: TimeInterval = 10.0 - + + /// **Default deadline for any "wait until this async thing settles" helper.** + /// + /// Four separate tests flaked on CI during July 2026 with the same root + /// cause, and it is worth stating the rule rather than re-learning it a + /// fifth time: *a wait deadline is not a latency budget.* It exists so a + /// genuine hang eventually fails the suite. Size it for the worst-case + /// scheduler, never for how long the operation "should" take. + /// + /// A CI runner executes many suites at once. Work behind `@MainActor`, + /// `Task.detached(priority: .utility)`, or a `DispatchQueue.asyncAfter` can + /// be starved for seconds — one observed run took 3.75 s for a 1 s + /// operation. Deadlines sized to the operation (the old 1 s defaults) turn + /// that starvation into a red build that reads like a product bug. + /// + /// This costs nothing when tests pass, because every helper returns as soon + /// as its condition holds. It only extends the genuine-failure case. + /// + /// `TestTimingHygieneTests` enforces that wait helpers default to at least + /// `minimumSettleTimeout`. + static let settleTimeout: TimeInterval = 30.0 + + /// Floor enforced by `TestTimingHygieneTests`. Anything below this is a + /// latency assumption in disguise. + static let minimumSettleTimeout: TimeInterval = 10.0 + + /// For waits whose **expected outcome is `false`** — "prove this does not + /// happen". + /// + /// The floor above is wrong for these, and inverted: a negative wait always + /// runs its deadline out, so `settleTimeout` would spend 30 s per case + /// proving nothing extra. Starvation cannot cause a false failure here + /// either — a starved runner only makes the thing *less* likely to happen, + /// so the assertion still holds. Short is correct, and naming it says the + /// polarity out loud instead of leaving a bare literal that reads like the + /// mistake this file exists to prevent. + /// + /// `TestTimingHygieneTests` accepts this by name. Using it for a wait you + /// expect to succeed reintroduces exactly the flake class it sits next to. + static let negativeWaitWindow: TimeInterval = 1.0 + + static let testNickname1 = "Alice" static let testNickname2 = "Bob" static let testNickname3 = "Charlie" diff --git a/bitchatTests/TestUtilities/TestTimingHygieneTests.swift b/bitchatTests/TestUtilities/TestTimingHygieneTests.swift new file mode 100644 index 00000000..c6a15147 --- /dev/null +++ b/bitchatTests/TestUtilities/TestTimingHygieneTests.swift @@ -0,0 +1,177 @@ +import Foundation +import Testing + +/// Guards the test suite against the flake class that produced four separate +/// red builds in July 2026: **treating a wait deadline as a latency budget.** +/// +/// A CI runner executes many suites at once, so work behind `@MainActor`, +/// `Task.detached(priority: .utility)`, or `DispatchQueue.asyncAfter` can be +/// starved for seconds. One observed run took 3.75 s for a 1 s operation. +/// Deadlines sized to how long the operation "should" take turn that starvation +/// into a red build that reads like a product bug, and the debugging cost lands +/// on whoever opened an unrelated PR. +/// +/// Two rules, both enforced below: +/// +/// 1. A wait helper's default deadline must be at least +/// `TestConstants.minimumSettleTimeout`. Waits return as soon as their +/// condition holds, so a generous deadline is free in the passing case. +/// 2. No test asserts an *upper bound* on elapsed wall-clock time. Such an +/// assertion cannot distinguish the behaviour under test from a slow +/// machine, so it can only be flaky. Assert the property somewhere it is +/// computable — with an injected clock, on the pure logic — instead. +/// +/// Both rules can be waived per line with `\(Self.waiver)` plus a reason, for +/// the rare case where the timing itself is genuinely the thing under test. +struct TestTimingHygieneTests { + /// Opt-out marker. Reviewers should expect a reason next to it. + static let waiver = "test-timing-ok:" + + private static let testsRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // TestUtilities + .deletingLastPathComponent() // bitchatTests + + private struct Line { + let file: String + let number: Int + let text: String + /// True when the waiver appears on this line or in the comment block + /// immediately above it, so a reason can be written at readable length + /// rather than crammed onto the end of the code line. + let waived: Bool + } + + private static func swiftLines() throws -> [Line] { + let enumerator = FileManager.default.enumerator( + at: testsRoot, + includingPropertiesForKeys: nil + ) + var out: [Line] = [] + while let url = enumerator?.nextObject() as? URL { + guard url.pathExtension == "swift" else { continue } + // This file necessarily contains the patterns it bans. + guard url.lastPathComponent != "TestTimingHygieneTests.swift" else { continue } + let name = url.lastPathComponent + let texts = try String(contentsOf: url, encoding: .utf8) + .components(separatedBy: .newlines) + for (index, text) in texts.enumerated() { + // Scan back over an unbroken run of comment lines. + var waived = text.contains(waiver) + var back = index - 1 + while !waived, back >= 0 { + let above = texts[back].trimmingCharacters(in: .whitespaces) + guard above.hasPrefix("//") else { break } + waived = above.contains(waiver) + back -= 1 + } + out.append(Line(file: name, number: index + 1, text: text, waived: waived)) + } + } + return out + } + + private static func isWaived(_ line: Line) -> Bool { + line.waived + } + + /// Rule 1: no wait helper may default to a deadline below the floor. + @Test func waitHelpersDoNotDefaultToShortDeadlines() throws { + let lines = try Self.swiftLines() + #expect(!lines.isEmpty, "hygiene scan found no test sources — check the path") + + // Two shapes, both of which have flaked here: + // a declaration default — `timeout: TimeInterval = 2.5` + // a wait call site — `wait(for:…, timeout: 1.0)`, `waitUntil(timeout: 5.0)` + // + // Deliberately NOT matched: a bare `timeout:` label on something that is + // not a wait, such as the injected production handshake timeouts in the + // Noise tests. Those are the behaviour under test, and a short value is + // correct there. + let patterns = [ + #"(?:timeout|deadline)\s*:\s*TimeInterval\s*=\s*([0-9]+(?:\.[0-9]+)?)"#, + #"(?:wait|waitUntil|waitFor|fulfillment)\s*\([^)]*\btimeout:\s*([0-9]+(?:\.[0-9]+)?)"# + ].map { try? NSRegularExpression(pattern: $0) }.compactMap { $0 } + #expect(patterns.count == 2, "hygiene regexes failed to compile") + + // Named constants hide the same mistake behind a symbol, and did: the + // fifth flake of the session was `timeout: TestConstants.shortTimeout` + // (1 s) on a positive wait, which a literals-only scan cannot see. + // `shortTimeout` itself is deleted (Periphery flagged it dead once its + // last wait site converted); the ban stays so it cannot come back. + // `negativeWaitWindow` is deliberately absent — short is correct there. + let bannedConstants = ["shortTimeout", "defaultTimeout"] + + var offenders: [String] = [] + for line in lines where !Self.isWaived(line) { + let range = NSRange(line.text.startIndex..., in: line.text) + var flagged = false + for pattern in patterns { + guard let match = pattern.firstMatch(in: line.text, range: range), + let valueRange = Range(match.range(at: 1), in: line.text), + let value = TimeInterval(line.text[valueRange]), + value < TestConstants.minimumSettleTimeout else { continue } + offenders.append("\(line.file):\(line.number) — \(value)s: \(line.text.trimmingCharacters(in: .whitespaces))") + flagged = true + break + } + guard !flagged else { continue } + for name in bannedConstants + where line.text.contains("timeout: TestConstants.\(name)") { + offenders.append("\(line.file):\(line.number) — TestConstants.\(name): \(line.text.trimmingCharacters(in: .whitespaces))") + break + } + } + + #expect( + offenders.isEmpty, + """ + Wait deadlines below \(TestConstants.minimumSettleTimeout)s are latency \ + assumptions and will flake on a loaded runner. Use \ + TestConstants.settleTimeout, or add "\(Self.waiver) " if the \ + timing really is what the test asserts. + + \(offenders.joined(separator: "\n")) + """ + ) + } + + /// Rule 2: no test bounds elapsed wall-clock time from above. + /// + /// This is the assertion that started it all — `XCTAssertLessThan( + /// Date().timeIntervalSince(start), 1.4)` proving a debounce deadline was + /// not restarted. It cannot separate "behaved correctly" from "runner was + /// busy", so it only ever fails for the wrong reason. + @Test func testsDoNotAssertUpperBoundsOnElapsedTime() throws { + let lines = try Self.swiftLines() + + let elapsedAssertion = try NSRegularExpression( + pattern: #"(?:XCTAssertLessThan|XCTAssertLessThanOrEqual)\s*\(\s*(?:Date\(\)\.timeIntervalSince|[A-Za-z_][A-Za-z0-9_]*\.timeIntervalSince|ContinuousClock)"# + ) + + var offenders: [String] = [] + for line in lines where !Self.isWaived(line) { + let range = NSRange(line.text.startIndex..., in: line.text) + guard elapsedAssertion.firstMatch(in: line.text, range: range) != nil else { continue } + offenders.append("\(line.file):\(line.number) — \(line.text.trimmingCharacters(in: .whitespaces))") + } + + #expect( + offenders.isEmpty, + """ + An upper bound on elapsed wall-clock time cannot distinguish the \ + behaviour under test from a slow machine. Assert the property where \ + it is computable — inject a clock, or test the pure logic — or add \ + "\(Self.waiver) ". + + \(offenders.joined(separator: "\n")) + """ + ) + } + + /// The floor must stay meaningfully above the operations being waited on, + /// and the default must satisfy the rule this file enforces. + @Test func settleTimeoutsAreSelfConsistent() { + #expect(TestConstants.settleTimeout >= TestConstants.minimumSettleTimeout) + #expect(TestConstants.minimumSettleTimeout > TestConstants.defaultTimeout) + } +} diff --git a/bitchatTests/ViewSmokeTests.swift b/bitchatTests/ViewSmokeTests.swift index 6c104e53..ec7b3b62 100644 --- a/bitchatTests/ViewSmokeTests.swift +++ b/bitchatTests/ViewSmokeTests.swift @@ -41,6 +41,7 @@ private struct SmokeFeatureModels { let conversationUIModel: ConversationUIModel let peerListModel: PeerListModel let boardAlertsModel: BoardAlertsModel + let sharedContentImportModel: SharedContentImportModel } @MainActor @@ -99,7 +100,8 @@ private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatur verificationModel: verificationModel, conversationUIModel: conversationUIModel, peerListModel: peerListModel, - boardAlertsModel: boardAlertsModel + boardAlertsModel: boardAlertsModel, + sharedContentImportModel: SharedContentImportModel(store: nil) ) } @@ -118,6 +120,7 @@ private func installSmokeEnvironment( .environmentObject(featureModels.conversationUIModel) .environmentObject(featureModels.peerListModel) .environmentObject(featureModels.boardAlertsModel) + .environmentObject(featureModels.sharedContentImportModel) } @MainActor @@ -551,6 +554,103 @@ struct ViewSmokeTests { #expect(featureModels.privateConversationModel.selectedHeaderState?.headerPeerID == peerID) } + @Test("Root Bluetooth alert waits for location and notices sheets") + func rootBluetoothAlertGuard_includesHeaderSheets() { + #expect(!ContentRootModalPresentationState().hasPresentation) + #expect( + ContentRootModalPresentationState( + isLocationChannelsSheetPresented: true + ).hasPresentation + ) + #expect( + ContentRootModalPresentationState( + isNoticesSheetPresented: true + ).hasPresentation + ) + } + + @Test("People-sheet Bluetooth alert waits for local verification sheet") + func peopleSheetBluetoothAlertGuard_includesVerificationSheet() { + #expect(!ContentPeopleSheetModalPresentationState().hasPresentation) + #expect( + ContentPeopleSheetModalPresentationState( + isVerificationSheetPresented: true + ).hasPresentation + ) + } + + @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") + @MainActor + func rootBluetoothAlertGuard_tracksScreenshotPrivacyState() { + let (viewModel, _, _) = makeSmokeViewModel() + let featureModels = makeSmokeFeatureModels(for: viewModel) + + #expect( + !ContentRootModalPresentationState( + appChromeModel: featureModels.appChromeModel + ).hasPresentation + ) + + featureModels.appChromeModel.showScreenshotPrivacyWarning = true + + #expect( + ContentRootModalPresentationState( + appChromeModel: featureModels.appChromeModel + ).hasPresentation + ) + } + + @Test("People-sheet Bluetooth alert waits for legacy media consent") + @MainActor + func peopleSheetBluetoothAlertGuard_tracksLegacyConsentState() async { + let (viewModel, _, _) = makeSmokeViewModel() + let featureModels = makeSmokeFeatureModels(for: viewModel) + + #expect( + !ContentPeopleSheetModalPresentationState( + legacyPrivateMediaConsentRequest: + featureModels.conversationUIModel + .legacyPrivateMediaConsentRequest + ).hasPresentation + ) + + viewModel.enqueueLegacyPrivateMediaConsent( + for: PeerID(str: "5152535455565758"), + transferId: "legacy-consent-transfer", + messageID: "legacy-consent-message" + ) { _ in } + defer { viewModel.cancelAllLegacyPrivateMediaConsents() } + + let consentPropagated = await TestHelpers.waitUntil { + featureModels.conversationUIModel + .legacyPrivateMediaConsentRequest != nil + } + #expect( + consentPropagated + ) + #expect( + ContentPeopleSheetModalPresentationState( + legacyPrivateMediaConsentRequest: + featureModels.conversationUIModel + .legacyPrivateMediaConsentRequest + ).hasPresentation + ) + } + @Test func geohashAndTextMessageViews_renderCoreBranches() { let (viewModel, _, _) = makeSmokeViewModel() diff --git a/bitchatTests/VoiceCaptureSessionTests.swift b/bitchatTests/VoiceCaptureSessionTests.swift index 40853f2c..bfa0ac96 100644 --- a/bitchatTests/VoiceCaptureSessionTests.swift +++ b/bitchatTests/VoiceCaptureSessionTests.swift @@ -61,6 +61,7 @@ private final class GatedVoiceCaptureSession: VoiceCaptureSession { private let startError: Error? private(set) var finishStarted = false private(set) var cancelCount = 0 + private(set) var panicCancelCount = 0 private var finishContinuation: CheckedContinuation? init(startError: Error? = nil) { @@ -83,6 +84,10 @@ private final class GatedVoiceCaptureSession: VoiceCaptureSession { cancelCount += 1 } + func panicCancelSynchronously() { + panicCancelCount += 1 + } + func resolveFinish(with url: URL?) { let continuation = finishContinuation finishContinuation = nil @@ -96,7 +101,7 @@ struct VoiceCaptureSessionTests { _ condition: () -> Bool, sourceLocation: SourceLocation = #_sourceLocation ) async { - let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + let deadline = ContinuousClock.now.advanced(by: .seconds(TestConstants.settleTimeout)) while !condition(), ContinuousClock.now < deadline { await Task.yield() try? await Task.sleep(nanoseconds: 1_000_000) @@ -204,4 +209,57 @@ struct VoiceCaptureSessionTests { } #expect(viewModel.state == .idle) } + + @Test func panicSynchronouslyCancelsActiveCaptureAndResetsUI() async { + let session = GatedVoiceCaptureSession() + let viewModel = VoiceRecordingViewModel() + viewModel.sessionProvider = { session } + + viewModel.start(shouldShow: true) + await waitUntil { self.isRecording(viewModel.state) } + + viewModel.panicWipe() + + #expect(session.panicCancelCount == 1) + #expect(viewModel.state == .idle) + #expect(!viewModel.isLiveStreaming) + } + + @Test func panicInvalidatesARecordingAlreadyFinalizing() async throws { + let session = GatedVoiceCaptureSession() + let viewModel = VoiceRecordingViewModel() + viewModel.sessionProvider = { session } + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("voice-panic-\(UUID().uuidString).m4a") + try Data([0x01]).write(to: url) + var delivered = false + + viewModel.start(shouldShow: true) + await waitUntil { self.isRecording(viewModel.state) } + viewModel.finish { _ in delivered = true } + await waitUntil { session.finishStarted } + + viewModel.panicWipe() + session.resolveFinish(with: url) + await waitUntil { + !FileManager.default.fileExists(atPath: url.path) + } + + #expect(!delivered) + #expect(viewModel.state == .idle) + } + + @Test func liveSessionPanicStopsCaptureWithoutSendingControl() { + let capture = StubPTTCapture(stopResult: (nil, 0)) + var sentPackets: [Data] = [] + let session = PTTLiveVoiceSession( + sendPacket: { sentPackets.append($0) }, + capture: capture + ) + + session.panicCancelSynchronously() + + #expect(capture.cancelCount == 1) + #expect(sentPackets.isEmpty) + } } diff --git a/bitchatTests/VoiceNotePlaybackControllerTests.swift b/bitchatTests/VoiceNotePlaybackControllerTests.swift index f7853f1d..29991d0e 100644 --- a/bitchatTests/VoiceNotePlaybackControllerTests.swift +++ b/bitchatTests/VoiceNotePlaybackControllerTests.swift @@ -59,11 +59,24 @@ struct VoiceNotePlaybackControllerTests { return url } + /// Waits for an async settle, then asserts. + /// + /// The deadline is deliberately far larger than the work it waits on. Every + /// condition here depends on a `@MainActor` Task that playback schedules + /// (the session acquire and its failure path), and on a CI runner executing + /// many suites in parallel that Task can simply not be scheduled for + /// seconds. At five seconds this timed out on CI and reported *two* + /// failures — the wait itself, and the `!isPlaying` that the un-run failure + /// path had not yet reset — which reads like a playback bug rather than a + /// starved scheduler. + /// + /// A generous deadline costs nothing when the condition holds, since this + /// returns as soon as it does; it only extends the genuine-failure case. private func waitUntil( _ condition: () -> Bool, sourceLocation: SourceLocation = #_sourceLocation ) async { - let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + let deadline = ContinuousClock.now.advanced(by: .seconds(TestConstants.settleTimeout)) while !condition(), ContinuousClock.now < deadline { await Task.yield() try? await Task.sleep(nanoseconds: 1_000_000) diff --git a/bitchatTests/VoiceRecorderTests.swift b/bitchatTests/VoiceRecorderTests.swift index dfaa62d5..c9b6839e 100644 --- a/bitchatTests/VoiceRecorderTests.swift +++ b/bitchatTests/VoiceRecorderTests.swift @@ -13,17 +13,16 @@ import Testing private final class VoiceRecorderTestSession: SessionApplying, @unchecked Sendable { private let lock = NSLock() private let activationGate = DispatchSemaphore(value: 0) + private let activationBeganGate = DispatchSemaphore(value: 0) private let shouldGateFirstActivation: Bool private var gatedFirstActivation = false private var _activationCalls: [Bool] = [] - private var _activationBegan = false init(gateFirstActivation: Bool = false) { self.shouldGateFirstActivation = gateFirstActivation } var activationCalls: [Bool] { lock.withLock { _activationCalls } } - var activationBegan: Bool { lock.withLock { _activationBegan } } func setCategory(_ category: AudioSessionCoordinator.Category) throws {} @@ -32,14 +31,28 @@ private final class VoiceRecorderTestSession: SessionApplying, @unchecked Sendab _activationCalls.append(active) guard active, shouldGateFirstActivation, !gatedFirstActivation else { return false } gatedFirstActivation = true - _activationBegan = true return true } if shouldWait { + activationBeganGate.signal() 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() { activationGate.signal() } @@ -142,26 +155,38 @@ private final class TestVoiceAudioRecorderFactory: VoiceAudioRecorderCreating { /// this remains deterministic when the full test suite saturates the executor. private final class VoiceRecorderPaddingGate: @unchecked Sendable { private let lock = NSLock() - private var _entered = false + private let enteredGate = DispatchSemaphore(value: 0) private var isOpen = false private var openWaiters: [CheckedContinuation] = [] - var entered: Bool { lock.withLock { _entered } } - func wait() async { await withCheckedContinuation { continuation in let resumeImmediately = lock.withLock { () -> Bool in - _entered = true guard !isOpen else { return true } openWaiters.append(continuation) return false } + enteredGate.signal() if resumeImmediately { 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() { let waiters = lock.withLock { () -> [CheckedContinuation] in isOpen = true @@ -181,18 +206,6 @@ struct VoiceRecorderTests { 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 { let directory = try makeTemporaryDirectory() defer { try? FileManager.default.removeItem(at: directory) } @@ -210,7 +223,7 @@ struct VoiceRecorderTests { let owner = VoiceRecorder.RecordingOwner() let startTask = Task { try await voiceRecorder.startRecording(owner: owner) } - await waitUntil { session.activationBegan } + #expect(await session.waitUntilActivationBegan()) await voiceRecorder.cancelRecording(owner: owner) session.resumeActivation() @@ -308,7 +321,7 @@ struct VoiceRecorderTests { try await finishingHold.start() let firstURL = try #require(factory.urls.first) let finishTask = Task { await finishingHold.finish() } - await waitUntil { paddingGate.entered } + #expect(await paddingGate.waitUntilEntered()) await #expect(throws: VoiceRecorder.RecorderError.recordingInProgress) { try await rejectedHold.start() @@ -360,6 +373,35 @@ struct VoiceRecorderTests { #expect(FileManager.default.fileExists(atPath: secondURL.path)) } + @Test func classicSessionPanicStopsRecorderAndDeletesFileBeforeReturning() async throws { + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let rawSession = VoiceRecorderTestSession() + let coordinator = AudioSessionCoordinator(session: rawSession) + let factory = TestVoiceAudioRecorderFactory(plans: [.success]) + let voiceRecorder = VoiceRecorder( + sessionCoordinator: coordinator, + recorderFactory: factory, + permissionGranted: { true }, + paddingInterval: 0, + outputDirectory: directory + ) + let capture = VoiceNoteCaptureSession(recorder: voiceRecorder) + + try await capture.start() + let url = try #require(factory.urls.first) + let recorder = try #require(factory.recorders.first) + + capture.panicCancelSynchronously() + + #expect(recorder.stopCallCount == 1) + #expect(!recorder.isRecording) + #expect(!FileManager.default.fileExists(atPath: url.path)) + await coordinator.drain() + #expect(rawSession.activationCalls == [true, false]) + } + private func verifyFailedStart( firstPlan: TestVoiceAudioRecorderFactory.Plan, expectedPrepareCalls: Int, diff --git a/docs/PRIVATE-MEDIA-MIGRATION.md b/docs/PRIVATE-MEDIA-MIGRATION.md new file mode 100644 index 00000000..dd90e162 --- /dev/null +++ b/docs/PRIVATE-MEDIA-MIGRATION.md @@ -0,0 +1,122 @@ +# Private-media wire migration + +Private files use the `BitchatFilePacket` TLV shared by iOS and Android. The +preferred direct-message wire form encrypts that complete TLV inside the +peer's Noise session before BLE fragmentation. + +## Wire values and capability + +- `NoisePayloadType.privateFile` is `0x20`, the value already deployed by the + Android client. New sends must use this value. +- iOS temporarily accepts `0x09`, which appeared in prerelease builds of the + private-media change. Decoders canonicalize it to `privateFile`; they never + emit it. +- `NoisePayloadType.authenticatedPeerState` is permanently assigned `0x21`. + It is emitted after every completed/rekeyed Noise XX session and echoed at + most once when the remote state arrives, so message-3/proof reordering over + different mesh links converges. This type is part of the protocol security + boundary and is not removed when the media migration ends. +- The `0x21` payload starts with version `0x01`, followed by one-byte + type/length/value fields. Version 1 requires canonical TLV `0x01` (the + minimal little-endian `PeerCapabilities` bitfield, 1-8 bytes) and TLV `0x02` + (the 32-byte Ed25519 announcement signing key). Duplicate required fields, + non-minimal capabilities, malformed lengths, missing fields, and unknown + versions are ignored without changing state. Unknown TLVs are skipped. +- The public `PeerCapabilities.privateMedia` announce bit is a discovery hint: + it starts a Noise handshake, but never selects encrypted sending or creates + a pin. A private transfer waits boundedly for the exact session's encrypted + `0x21`. A valid bit-8 proof selects Noise `0x20`; a valid no-bit proof or a + no-proof timeout reaches the explicit legacy-consent path for an unpinned + peer. No timeout automatically sends raw bytes. +- `PeerCapabilities.privateMediaReceipts` is bit 9. Its exact-session + authenticated proof enables bounded sender-side automatic retry; a public + announce never does. It does not replace bit 8: encrypted `0x20` media from + bit-8-only prior iOS clients keeps the same deterministic stable ID and + delivery ACK. Receivers durably commit that ID before UI delivery or ACK, so + a lost proof followed by a later bit-9 retry cannot create a second, + random-ID bubble. +- An unpinned peer with a stable Noise key but without that capability is + eligible for one signed, directed + `fileTransfer`, matching the pre-migration wire form used by older iOS and + accepted by current Android clients, only after the sender confirms a + per-send warning that the file is not end-to-end encrypted and mesh relays + can see it. The + consent is consumed by that invocation and is never remembered. +- A signed announce never creates a pin by itself: an attacker can copy a + victim's public Noise key, supply its own Ed25519 key and capability bits, + and self-sign an internally consistent announce. Only successfully + decrypted `0x21` state pins the authenticated Noise fingerprint and binds + the Ed25519 key used by later announces/public messages. A later valid + no-bit `0x21` is treated as a downgrade, and raw fallback is blocked even if + a caller presents legacy consent. Public no-bit announces cannot overwrite + current session-authenticated state. +- During migration, both an absent capabilities TLV and an explicit TLV + without `privateMedia` are legacy-eligible when that stable fingerprint is + not pinned. This supports clients that added capability advertisement before + encrypted media. Neither shape bypasses a previously authenticated pin. + +Older clients decrypt and ignore unknown inner type `0x21`; they do not need to +understand it to continue using text or the warned legacy media path. They are +never inferred capable merely because the handshake succeeded. + +Removal gates are independent and must not share an arbitrary calendar date: + +- Remove the `0x09` receive alias only after every TestFlight/internal build + that emitted it has expired and minimum-supported-client policy excludes it. +- Remove the signed directed raw `0x22` fallback only after minimum-supported + iOS and Android clients emit authenticated bit-8 `0x21` state and the legacy + population has aged out. +- Nostr kind `1059` compatibility is a separate envelope migration. Its dual + publish/removal gate is not evidence that either BLE compatibility shape can + be removed. + +## Security boundary + +The encrypted form provides Noise confidentiality and peer authentication. +The fallback is signed and its signature is required on receive, so relays +cannot forge its sender or contents. It is not confidential: relays can see +the raw file TLV. The UI says this explicitly and asks on every send. A peer +without a stable Noise key from a verified registry entry cannot use the +fallback. Keep it only for the mixed-version migration, and remove it only +after minimum-supported Android and iOS releases emit authenticated bit-8 +`0x21` state and the legacy population has aged out. Never replace it with an +unsigned fallback, persist blanket consent, or send both forms. + +Incoming clients accept all three migration-era shapes: + +| Sender | Inbound form | Result | +| --- | --- | --- | +| Current Android | Noise `0x20` | Decrypt and deliver | +| Prerelease iOS | Noise `0x09` | Decrypt, canonicalize, and deliver | +| Older client | Signed directed `fileTransfer` | Verify signature and deliver | +| Forged/unsigned raw sender | Directed `fileTransfer` | Reject | + +Panic wipe clears the persistent capability pins together with the rest of +the encrypted identity cache. + +This migration path is mesh-Noise-only (BLE and compatible direct mesh links). +Nostr private-media transport is unchanged and remains a follow-up. Nostr +inbound paths explicitly ignore `0x21`; do not infer the mesh consent fallback +or capability-pin semantics for Nostr delivery. + +## Size interoperability + +iOS bounds inbound file content at 1 MiB and applies the expanded allocation +budget only after a large Noise ciphertext authenticates to `0x20` or the +temporary `0x09` alias. Ordinary Noise messages retain their 64 KiB limit. + +Current Android builds cap each reassembly at 256 fragments. Depending on the +negotiated BLE packet size and routing overhead, that is roughly 110-120 KiB, +well below iOS's absolute inbound ceiling. That cap only applies to those +receivers, which take private media exclusively over the directed raw-file +migration fallback (they do not implement the encrypted `0x20` path). +Private-media v1 therefore runs the actual route-aware BLE fragment planner +before a consented legacy send and rejects any plan above 256 fragments with a +visible failure. Encrypted sends go only to peers that advertised the +`privateMedia` capability — modern clients that reassemble up to the full +receiver ceiling (10,000 fragments) — so they are not held to Android's cap and +iOS→iOS photos in the ~120-512 KiB range keep working. This fragment-count +contract, rather than a guessed byte threshold, stays correct as route overhead +changes. A future Android client that adopts `0x20` but still caps its +reassembler would need to negotiate an explicit per-peer fragment limit +(tracked as a #1434 follow-up). diff --git a/docs/TOR-INTEGRATION.md b/docs/TOR-INTEGRATION.md index 165acfe3..46f80951 100644 --- a/docs/TOR-INTEGRATION.md +++ b/docs/TOR-INTEGRATION.md @@ -1,47 +1,53 @@ -Tor-by-default integration (scaffold) +# Tor integration -Overview -- All network traffic is routed via a local Tor SOCKS5 proxy by default, with fail-closed behavior when Tor isn’t ready. There are no user-visible settings. -- This repo vendors an Arti-backed Swift package under `localPackages/Arti`, including a Rust static-library xcframework linked by SwiftPM. +## Overview -Key pieces -- TorManager - - Boots Tor, manages a DataDirectory under Application Support, exposes SOCKS at 127.0.0.1:39050, and provides awaitReady(). - - Fails closed by default until Tor is bootstrapped. For local development only, define BITCHAT_DEV_ALLOW_CLEARNET to bypass Tor. -- TorURLSession - - Provides a shared URLSession configured with a SOCKS5 proxy when Tor is enforced/ready. - - NostrRelayManager and GeoRelayDirectory now use this session and await Tor readiness before starting network activity. +Internet traffic — Nostr relay sockets and the geo-relay directory fetch — is routed through Tor by default, fail-closed: when Tor is wanted but not ready, requests queue rather than falling back to clearnet. -Artifact maintenance -- Binary provenance, rebuild steps, and current hashes are documented in `docs/ARTI-BINARY-PROVENANCE.md`. +Tor is provided by **Arti, in-process**, vendored as a Swift package under `localPackages/Arti` wrapping a Rust static-library xcframework. There is no `tor` binary, no `torrc`, and no control port. A SOCKS5 listener on `127.0.0.1:39050` is the only interface. + +## Key pieces + +- **`TorManager`** — owns the Arti client and its data directory under Application Support, exposes the SOCKS port, and provides `awaitReady()`. + - `torEnforced` is compile-time: true unless `BITCHAT_DEV_ALLOW_CLEARNET` is defined. It is not set anywhere in `Configs/` or the project file, so release builds enforce. + - `isStarting`, `bootstrapProgress`, and `bootstrapSummary` describe an attempt in flight. + - `bootstrapDidStall` becomes true when an attempt spends its whole 75-second deadline without completing, and posts `.TorBootstrapDidStall`. This is the state a network that blocks Tor produces, and it is deliberately distinct from `isStarting`: without it the UI says "starting tor…" indefinitely. It is cleared on each new start or restart. +- **`TorURLSession`** — a shared `URLSession` with the SOCKS proxy configured when proxying is on, and an unproxied session when it is off. `setProxyMode(useTor:)` is the switch, driven by `NetworkActivationService`. +- **`NetworkActivationService`** — decides whether Tor may run at all. Tor starts when the activation policy permits it *and* the Tor preference is on. `persistedTorPreference(in:)` is a `nonisolated` read of that preference for callers off the main actor. + +Both network call sites go through `TorURLSession`: `NostrRelayManager` (relay websockets) and `GeoRelayDirectory` (directory CSV refresh). There is no other outbound network in the app or the share extension. + +## The Tor preference is user-visible + +The earlier version of this document said there are no user-visible settings. There is one: a **tor routing** toggle in settings, persisted under `networkActivationService.userTorEnabled`, defaulting to on. + +Turning it off is a real change in exposure, not a performance tweak. Every fail-closed guard is conditioned on the preference, so with it off: + +- relay websockets connect directly, and every relay operator sees the device IP — including relays carrying private messages; +- the geo-relay directory fetch also goes direct. + +The settings UI states this while the toggle is off. + +`GeoRelayDirectory` keys its Tor wait on the *preference*, not on live readiness, and this distinction is load-bearing. With Tor off, waiting for a client that has been shut down would spend the full bootstrap timeout on every refresh and freeze the directory on its cached copy. With Tor on but not ready, the wait must still fail so the fetch is skipped rather than silently leaking the IP. + +## Relays + +Private messages target the built-in relay set plus any relays added by hand (`NostrRelaySettings`, capped at 8, `.onion` addresses accepted). The built-in set is four well-known clearnet hostnames, so a filter blocking four names would otherwise end internet-delivered private messages until a new build shipped. + +## Artifact maintenance + +- Binary provenance, rebuild steps, and current hashes: `docs/ARTI-BINARY-PROVENANCE.md`, enforced by `.github/workflows/arti-provenance.yml`. - The xcframework must include iOS device, iOS simulator, and macOS arm64 slices. -- Any refresh should review the Rust source, `Cargo.lock`, generated header, build script, and new hashes together. +- Any refresh reviews the Rust source, `Cargo.lock`, generated header, build script, and new hashes together. A binary-only update is not acceptable. -Verification - - On app launch, TorManager.startIfNeeded() is called implicitly by awaitReady(). - - NostrRelayManager.connect() awaits readiness, then creates WebSocket tasks via TorURLSession.shared. - - GeoRelayDirectory.fetchRemote() awaits readiness, then fetches via TorURLSession.shared. +## Known gap: no bridges or pluggable transports -Optional macOS optimization - - Detect a system Tor binary (e.g., /opt/homebrew/bin/tor) and run it as a subprocess to avoid bundling. Keep the embedded fallback for portability. +`arti-client` is built with `default-features = false` and features `["tokio", "rustls"]` only — no `pt-client`, no `bridge-client` — and `arti-bitchat/src/lib.rs` bootstraps from stock configuration with no bridge lines and no configurable directory authorities. -torrc template -The generated torrc (under Application Support/bitchat/tor/torrc) is: +So in a country that blocks Tor by blocking the public relays and directory authorities, bootstrap never completes. The app reports that clearly now instead of appearing to start forever, and the BLE mesh is unaffected, but there is no circumvention path: obfs4, snowflake, and meek are all unavailable. - DataDirectory /bitchat/tor - ClientOnly 1 - SOCKSPort 127.0.0.1:39050 - ControlPort 127.0.0.1:39051 - CookieAuthentication 1 - AvoidDiskWrites 1 - MaxClientCircuitsPending 8 +Closing this means enabling the pluggable-transport features, plumbing bridge configuration through the FFI and a settings surface, and rebuilding the xcframework under the pinned toolchain with a provenance-manifest update. That is the single largest remaining gap in censorship resilience for the internet transport. -Dev bypass (local only) -- To temporarily allow direct network without Tor for local development: - - Add Swift compiler flag: BITCHAT_DEV_ALLOW_CLEARNET - - This enables a clearnet session in TorURLSession when Tor isn’t present. - - Never enable this in release builds. +## Dev bypass (local only) -Notes -- We intentionally do not change any app-level APIs: consumers simply use TorURLSession via existing code paths. -- When Tor is missing in release builds, the app will not connect (fail-closed), logging a clear reason. +Define the Swift compiler flag `BITCHAT_DEV_ALLOW_CLEARNET` to allow direct network access without Tor while developing. Never enable it in release builds. diff --git a/docs/VERIFYING-A-BUILD.md b/docs/VERIFYING-A-BUILD.md new file mode 100644 index 00000000..23b5181b --- /dev/null +++ b/docs/VERIFYING-A-BUILD.md @@ -0,0 +1,94 @@ +# Verifying bitchat + +This document is about a specific risk: getting a copy of bitchat that someone has modified. + +It matters because the repository has been the target of takedown demands. When a repository or a releases page becomes unavailable, mirrors appear, and people who need the app during a shutdown install whatever they can find. That is exactly the moment a trojaned build reaches the people with the most to lose. A modified bitchat can log plaintext, ship keys off the device, or weaken the mesh, and it will look and behave normally while doing it. + +The honest summary is short. **Source can be verified. Compiled apps cannot, unless they come from the App Store.** Everything below elaborates on that. + +## Getting the app + +In order of how much verification is possible: + +1. **The App Store.** Apple verifies the developer signature, and the binary cannot be altered without breaking it. This is the only channel where a compiled build is verifiable end to end, and it is the right recommendation for almost everyone. +2. **Build it yourself from verified source.** See below. Requires a Mac and Xcode, and gives you the strongest guarantee if you can do it. +3. **A compiled build from anywhere else.** Not verifiable. See "Builds from other sources". + +## Verifying source + +Every tagged release has a `SOURCE-MANIFEST.txt` produced by `.github/workflows/source-manifest.yml`. It records the tag, the commit, the git tree hash, and a SHA-256 for every tracked file. + +Keep the downloaded manifest *outside* the source tree (say, `/tmp`) — a stray copy inside the checkout would itself trip the completeness checks below. Then check a copy of the source against it: + +```sh +# From the root of the source you obtained, with the manifest at /tmp +grep -v '^#' /tmp/SOURCE-MANIFEST.txt > /tmp/files.sha256 +shasum -a 256 -c /tmp/files.sha256 +``` + +Any `FAILED` line means that file differs from the released source. Investigate before building. + +That check alone is not enough. `shasum -c` verifies the files the manifest lists and says nothing about files it does not list — and the Xcode project compiles every source file present in the tree automatically, so a hostile mirror can pass the hash check by leaving every listed file intact and *adding* one. Confirm nothing extra is present: + +```sh +# The manifest's path list must match the tree exactly — no missing files, no extras +grep -v '^#' /tmp/SOURCE-MANIFEST.txt | sed 's/^[0-9a-f]* //' | LC_ALL=C sort > /tmp/manifest-paths +find . -type f ! -path './.git/*' | sed 's|^\./||' | LC_ALL=C sort > /tmp/actual-paths +diff /tmp/manifest-paths /tmp/actual-paths # must print nothing +``` + +In a git checkout the same assurance is one command — it also catches extra files, because they show as untracked. `--ignored` matters: `.gitignore` covers paths like `build/`, plain `git status` would not report a planted file there, and Xcode compiles it all the same: + +```sh +git status --porcelain --ignored # must print nothing before you build +``` + +The single value that covers the whole tree is the git tree hash in the manifest header: + +```sh +git rev-parse HEAD^{tree} # must equal the "tree:" line in the manifest +``` + +Note the tree hash covers tracked content only; it does not see untracked files sitting in the working directory, which is why the emptiness checks above come first. + +The manifest itself carries a provenance attestation tying it to the workflow run that produced it, so a manifest handed to you along with a mirror is checkable too: + +```sh +gh attestation verify /tmp/SOURCE-MANIFEST.txt --repo permissionlesstech/bitchat +``` + +That last step is what makes this resistant to a hostile mirror. Without it, whoever gives you the source can give you a matching manifest. + +### If the manifest is unavailable + +Compare against a commit instead. Git object hashes cover content and history, so if you can obtain the expected commit hash through any channel you trust — a second mirror, a maintainer's post elsewhere, someone who cloned earlier — then: + +```sh +git fetch --tags +git rev-parse v1.2.3 # compare against the hash you trust +git verify-tag v1.2.3 # if the tag is signed +``` + +A mirror whose history matches a commit hash you trust from elsewhere is a faithful mirror. + +## Builds from other sources + +If you have an `.ipa`, an `.apk`, or a Mac app from a forum, a chat group, a file locker, or any mirror, you cannot verify it, and this project cannot help you verify it. There is no published signing key for compiled builds and no reproducible-build pipeline, so there is nothing to compare a binary against. + +What to do instead, in order of preference: install from the App Store; build from verified source; or, if neither is possible, treat that build as untrusted — assume anything you type into it may be disclosed, do not use it for anything sensitive, and do not carry it somewhere it being on your phone is itself a risk. + +Do not rely on the app looking right. A modified build has no reason to look different. + +## For maintainers + +Cutting a release: + +- Push the tag. `source-manifest.yml` runs and attaches `SOURCE-MANIFEST.txt` to the release; if the release does not exist yet, collect the manifest from the workflow artifact and attach it when you publish. +- Sign the tag (`git tag -s`). A signed tag lets anyone verify the release came from a key you control, independent of GitHub. This needs a published key fingerprint to be useful — see the gap below. +- Note the commit hash somewhere outside this repository. If the repository is taken down, a hash recorded elsewhere is what lets people verify a mirror. + +Known gaps, so nobody assumes more protection than exists: + +- **No published signing key.** Tags are not currently verifiable against a known key. Publishing a fingerprint through channels independent of GitHub, and signing tags with it from then on, is the missing piece. +- **No verifiable compiled builds outside the App Store.** Closing this needs either a signed-and-notarized release pipeline or a reproducible build, and until one exists the guidance above stands. +- **No non-GitHub source mirror.** Every remote for this project is on the platform the takedown demands were served to. A mirror on independent infrastructure, published before it is needed, would mean a takedown does not remove the ability to verify. diff --git a/docs/privacy-assessment.md b/docs/privacy-assessment.md index 645e5df7..92598407 100644 --- a/docs/privacy-assessment.md +++ b/docs/privacy-assessment.md @@ -18,6 +18,7 @@ The user-facing contract is `PRIVACY_POLICY.md`. This document records implement - Private payloads are end-to-end encrypted, but public mesh, board, bridge, and geohash content is intentionally visible to its participants. - Local storage is bounded where practical and included in panic wipe, but it is not wholly ephemeral. The app persists the stores listed below. - The app and share extension each bundle a privacy manifest declaring their actual required-reason API use. +- A locked device discloses less than an unlocked one, but not nothing. Notification previews are hidden by default and the app-switcher snapshot is covered; see "Locked and Seized Devices". ## BLE Discovery and Metadata @@ -28,7 +29,9 @@ Signed announces can expose: - A bounded set of short direct-neighbor identifiers - A coarse rendezvous geohash when the bridge capability is enabled -The app does not advertise the device's user-assigned name. iOS manages BLE address randomization; bitchat does not attempt to create a stable MAC address. RSSI, timing, traffic volume, and radio fingerprints remain observable to nearby receivers. +The app does not advertise the device's assigned name. iOS manages BLE address randomization; bitchat does not attempt to create a stable MAC address. + +That randomization does not deliver the unlinkability it might suggest, because the application layer publishes stable identifiers above it. The 8-byte peer ID in every packet header is the first 8 bytes of the Noise static key fingerprint, so it does not rotate; announces carry the static keys themselves; and the fixed service UUID makes any bitchat device detectable as such by a passive scanner. A receiver in radio range can therefore recognise a specific device across sessions and locations, and detect that the app is in use at all. RSSI, timing, traffic volume, and radio fingerprints remain observable as well. Ingress validates announce structure, sender binding, signatures, payload sizes, and freshness. Current-link Noise authentication is required before destructive courier handoff or strict directed delivery. Floods, queues, fragments, ingress work, and per-peer state are bounded. @@ -45,21 +48,24 @@ Residual risk: private-message metadata such as timing, radio adjacency, ciphert ## Public Gossip, Boards, and Media -- Recent signed public mesh messages are archived in Application Support for up to 15 minutes so gossip sync survives a relaunch and can cross mesh partitions. +- Recent signed public mesh messages are archived in Application Support for up to 6 hours so gossip sync survives a relaunch and can cross mesh partitions. - Signed public board posts and tombstones persist until author-selected expiry, at most seven days. Stores are bounded by global and per-author quotas. - Group metadata (name, roster, creator, epoch) persists as protected JSON; group keys live in the keychain until leave/removal/wipe. -- Voice notes and images are stored in Application Support. Incoming media has a 100 MB oldest-first quota; outgoing media does not have an equivalent automatic lifetime and remains until cleanup, panic wipe, or app removal. +- Voice notes and images are stored in Application Support. Incoming media has a 100 MB oldest-first quota, and all managed media — incoming and outgoing — is additionally bounded by age: a launch-time sweep deletes anything older than seven days. In-flight live captures and files reserved by a delivery or deletion in progress are exempt regardless of age. Panic wipe invalidates detached preparation work, cancels active transfers, closes live capture files, and removes the managed media tree before returning. Public archives contain content already intended for public mesh/board distribution, but a seized unlocked device can reveal it. Group metadata and media can reveal relationships or content even when the in-memory chat timeline has gone away. ## 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, and XChaCha20-Poly1305. It is not NIP-17, NIP-44, or NIP-59 compatible and does not provide forward secrecy against later compromise of the recipient's static Nostr private key. Relays still see the recipient public-key tag, event timing and size, and network metadata. - Bridge courier drops use a throwaway publisher key, an opaque Noise-sealed envelope, and a day-rotating recipient tag. Only a party already holding the recipient's Noise static key can compute candidate tags. -- Relay publication is considered successful only after an explicit NIP-20 `OK true` from at least one target relay. Rejected, disconnected, timed-out, or merely socket-written events stay retryable. +- Relay publication is considered successful only after an explicit NIP-01 `OK true` from at least one target relay. Rejected, disconnected, timed-out, or merely socket-written events stay retryable. - When mesh bridge is enabled, public mesh messages not marked “nearby only” are signed under a per-cell Nostr identity and published to a neighborhood rendezvous geohash. Presence and public bridge traffic therefore expose a coarse area to relays and participants. - A bridge gateway can carry signed bridge/location events and opaque courier drops for nearby mesh-only peers. It cannot validly publish a neighbor's radio-only message because the author must first sign the bridge event. +- Relays added by hand persist in local preferences (`nostr.customRelays`, at most 8, normalized on read) and are wiped on panic. An added relay names an operator someone chose to route through, so it is treated as sensitive local state rather than inert configuration. `.onion` addresses are accepted, which is the point: the four built-in relays are well-known clearnet hostnames and a filter blocking four names would otherwise end internet-delivered private messages until a new build shipped. +- Turning the Tor preference off routes relay sockets and the relay-directory fetch directly, disclosing the device IP to every relay operator including those carrying private messages. The settings UI states this while the preference is off. + Residual risk: Nostr relay retention and logging are outside project control. Public events may be copied indefinitely. Timing, coarse location, and participation can be correlated even when content is encrypted or per-cell identities are used. ## Location @@ -86,9 +92,24 @@ Residual risk: Nostr relay retention and logging are outside project control. Pu `bitchatShareExtension/PrivacyInfo.xcprivacy` declares app-group UserDefaults reason `1C8F.1`. Both manifests declare no tracking domains and no data collection by the app developer. They must remain bundled in their respective executable bundles. +## Locked and Seized Devices + +The realistic compromise for many of the people this app is built for is not interception but a phone taken and, often, unlocked under coercion. + +- Notification content is rendered by the system on the lock screen, so it is readable without unlocking. Message previews are therefore hidden by default: alerts state that a direct message, mention, or location-channel activity arrived, and withhold the message body, the sender's nickname, and the geohash until the app is opened. `userInfo` still carries the routing peer ID and deep link, neither of which the system displays. The preference is `notifications.hideMessagePreviews`; turning it off restores full previews. +- The window is covered on `willResignActive`, so the snapshot iOS stores for the app switcher shows a placeholder rather than an open conversation. The cover is opaque rather than blurred, and is added synchronously because the capture follows shortly after that notification. Panic wipe separately deletes snapshots already on disk. +- Clearing a mesh timeline erases the on-disk gossip archive behind it, so cleared public history is deleted rather than hidden. The echo watermark still suppresses pre-clear messages this device hears again from peers. +- Managed media is bounded by age as well as size, so a received photo does not outlive its conversation indefinitely. + +Not addressed, and deliberately out of scope here: + +- **No duress mechanism.** There is no decoy passphrase, no wipe-on-failed-authentication, and no biometric or passcode lock on the app itself. A coerced unlock discloses everything the device still holds. Adding one is a product decision as much as an engineering one: in some jurisdictions destroying data on demand is itself an offence, so a mode that *hides* may protect someone better than one that *destroys*, and the choice should be made deliberately rather than by default. +- **macOS gets no file-protection classes.** Every `FileProtectionType` application is inside `#if os(iOS)`; Data Protection on macOS additionally requires an entitlement. The Mac app also has no app-switcher equivalent. +- **Media is not sealed at the app layer.** It relies on the platform default protection class, which is readable once the device has been unlocked since boot. Sealing under a key with the same accessibility would not change that; only a key gated on user authentication would, and that conflicts with receiving media while locked. + ## Panic Wipe Coverage -The panic action clears identity/session state, preferences, location state, groups, prekeys, outbox mail, courier mail, bridge dedup state, gossip archive, board data, managed media, and active subscriptions/transports. New persistent stores must add an explicit wipe hook and a regression test. +The panic action clears identity/session state, preferences, location state, groups, prekeys, outbox mail, courier mail, bridge dedup state, gossip archive, board data, managed media, hand-added relays, and active subscriptions/transports. Managed media deletion completes synchronously, after active media work has been invalidated. Keychain secrets use device-only accessibility, and an install marker detects and clears app keys that survive uninstall before a later reinstall can use them. New persistent stores must add an explicit wipe hook and a regression test. ## Release Review Checklist @@ -97,3 +118,4 @@ The panic action clears identity/session state, preferences, location state, gro - Verify panic wipe reaches any newly added persistent store. - Treat geohash precision, bridge-cell changes, new relay tags, and announce fields as privacy-surface changes. - Re-run real-device Bluetooth, background/locked-device recovery, location revocation, and audio-route checks; simulators cannot validate the physical side of those behaviors. +- Check what a new notification discloses on a locked screen, and that the app-switcher snapshot is covered, whenever notification or scene-lifecycle code changes. diff --git a/localPackages/Arti/Sources/TorManager.swift b/localPackages/Arti/Sources/TorManager.swift index 384f5239..1e9f2eb8 100644 --- a/localPackages/Arti/Sources/TorManager.swift +++ b/localPackages/Arti/Sources/TorManager.swift @@ -48,6 +48,15 @@ public final class TorManager: ObservableObject { @Published private(set) var lastError: Error? @Published private(set) var bootstrapProgress: Int = 0 @Published private(set) var bootstrapSummary: String = "" + /// True once a bootstrap attempt has spent its whole deadline without + /// completing. + /// + /// This separates "still starting" from "not getting through", which are + /// indistinguishable from `isStarting` alone. The second is what a network + /// that blocks Tor looks like from inside the app, and without it the UI + /// says "starting tor…" indefinitely while nothing is happening. Cleared on + /// each new start attempt. + @Published private(set) public var bootstrapDidStall: Bool = false // Internal readiness trackers private var socksReady: Bool = false { didSet { recomputeReady() } } @@ -75,6 +84,10 @@ public final class TorManager: ObservableObject { private var shutdownsInFlight = 0 private var startPendingAfterShutdown = false private var bootstrapMonitorStarted = false + // Fences the detached poll loop: shutdown, dormancy, and restart each bump + // this, so a loop from a previous attempt cannot run out its deadline and + // report a stall over state that a newer lifecycle event already owns. + private var bootstrapGeneration = 0 private var pathMonitor: NWPathMonitor? private var isAppForeground: Bool = true private var lastRestartAt: Date? = nil @@ -96,6 +109,7 @@ public final class TorManager: ObservableObject { guard !didStart else { return } didStart = true isStarting = true + bootstrapDidStall = false startedAt = Date() // Track startup time for grace period SecureLogger.debug("TorManager: startIfNeeded() - startedAt set", category: .session) lastError = nil @@ -258,27 +272,47 @@ public final class TorManager: ObservableObject { private func startBootstrapMonitor() { guard !bootstrapMonitorStarted else { return } bootstrapMonitorStarted = true + bootstrapGeneration += 1 + let generation = bootstrapGeneration Task.detached(priority: .utility) { [weak self] in - await self?.bootstrapPollLoop() + await self?.bootstrapPollLoop(generation: generation) } } - private func bootstrapPollLoop() async { + private func bootstrapPollLoop(generation: Int) async { let deadline = Date().addingTimeInterval(75) + var didComplete = false while Date() < deadline { + guard generation == bootstrapGeneration else { return } let progress = Int(arti_bootstrap_progress()) let summary = getBootstrapSummary() - await MainActor.run { - self.bootstrapProgress = progress - self.bootstrapSummary = summary - if progress >= 100 { self.isStarting = false } - self.recomputeReady() - } + self.bootstrapProgress = progress + self.bootstrapSummary = summary + if progress >= 100 { self.isStarting = false } + self.recomputeReady() - if progress >= 100 { break } + if progress >= 100 { + didComplete = true + break + } try? await Task.sleep(nanoseconds: 1_000_000_000) } + + // Running out the deadline is a reportable outcome, not silence. The + // loop previously just ended, leaving `isStarting` true forever, so a + // blocked network was indistinguishable from a slow one. A deliberate + // shutdown mid-bootstrap is not a stall, hence the generation check. + if !didComplete { + guard generation == bootstrapGeneration else { return } + self.isStarting = false + self.bootstrapDidStall = true + SecureLogger.warning( + "TorManager: bootstrap did not complete within its deadline (progress=\(self.bootstrapProgress)); network may be blocking Tor", + category: .session + ) + NotificationCenter.default.post(name: .TorBootstrapDidStall, object: nil) + } } private func getBootstrapSummary() -> String { @@ -323,6 +357,7 @@ public final class TorManager: ObservableObject { // Clear isStarting so foreground recovery can proceed if bootstrap was interrupted. SecureLogger.debug("TorManager: goDormantOnBackground() called", category: .session) Task { @MainActor in + self.bootstrapGeneration += 1 self.isReady = false self.socksReady = false self.isStarting = false @@ -332,6 +367,7 @@ public final class TorManager: ObservableObject { public func shutdownCompletely() { SecureLogger.debug("TorManager: shutdownCompletely() called", category: .session) startPendingAfterShutdown = false + bootstrapGeneration += 1 shutdownsInFlight += 1 Task.detached { [weak self] in guard let self = self else { return } @@ -369,11 +405,13 @@ public final class TorManager: ObservableObject { SecureLogger.debug("TorManager: restartArti() starting", category: .session) await MainActor.run { NotificationCenter.default.post(name: .TorWillRestart, object: nil) + self.bootstrapGeneration += 1 self.isReady = false self.socksReady = false self.bootstrapProgress = 0 self.bootstrapSummary = "" self.isStarting = true + self.bootstrapDidStall = false self.lastRestartAt = Date() } diff --git a/localPackages/Arti/Sources/TorNotifications.swift b/localPackages/Arti/Sources/TorNotifications.swift index e96bce3b..569bf404 100644 --- a/localPackages/Arti/Sources/TorNotifications.swift +++ b/localPackages/Arti/Sources/TorNotifications.swift @@ -5,4 +5,7 @@ public extension Notification.Name { static let TorWillRestart = Notification.Name("TorWillRestart") static let TorWillStart = Notification.Name("TorWillStart") static let TorUserPreferenceChanged = Notification.Name("TorUserPreferenceChanged") + /// A bootstrap attempt ran out its deadline without completing — the + /// signature of a network that blocks Tor. + static let TorBootstrapDidStall = Notification.Name("TorBootstrapDidStall") } diff --git a/localPackages/BitFoundation/Sources/BitFoundation/MessagePadding.swift b/localPackages/BitFoundation/Sources/BitFoundation/MessagePadding.swift index 0d29c8bc..e9703e97 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/MessagePadding.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/MessagePadding.swift @@ -9,7 +9,16 @@ import struct Foundation.Data /// Provides privacy-preserving message padding to obscure actual content length. -/// Uses PKCS#7-style padding with random bytes to prevent traffic analysis. +/// +/// PKCS#7-style: every pad byte equals the pad length, which is what `unpad` +/// verifies. The bytes are not random. +/// +/// Two limits are worth knowing before relying on this for traffic analysis +/// resistance. Only Noise frames are padded at all (see the outbound packet +/// policy); everything else travels at its natural length. And because the pad +/// length has to fit in one byte, `pad` declines any request needing more than +/// 255 bytes — so a frame far below its target bucket is emitted unpadded +/// rather than padded to a smaller bucket. struct MessagePadding { // Standard block sizes for padding static let blockSizes = [256, 512, 1024, 2048] diff --git a/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift b/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift index 167f9884..b1308c9b 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift @@ -24,6 +24,22 @@ public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable { /// (uplink/downlink carriers for mesh-only peers). Advertised alongside /// a `bridgeGeohash` TLV carrying the rendezvous cell. public static let bridge = PeerCapabilities(rawValue: 1 << 7) + /// Finalized direct-message media encrypted as Noise payload `0x20` + /// before outer BLE fragmentation. Peers that omit this bit require the + /// signed directed raw-file migration fallback. + public static let privateMedia = PeerCapabilities(rawValue: 1 << 8) + /// Stable private-media IDs are durably deduplicated by the receiver and + /// correlated delivery/read receipts permit bounded automatic resend. + /// + /// Bit 8 remains the encrypted-media compatibility contract. Bit 9 only + /// enables sender-side automatic retry after exact-session proof. + public static let privateMediaReceipts = + PeerCapabilities(rawValue: 1 << 9) + /// Reserved for test builds that briefly advertised non-destructive Noise + /// replacement. Current clients intentionally do not advertise or act on + /// this bit; keep it decodable so the wire assignment is never reused. + public static let nonDestructiveNoiseReplacement = + PeerCapabilities(rawValue: 1 << 10) /// Minimal little-endian byte encoding; always at least one byte so an /// empty set is distinguishable from an absent TLV. diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift index 1c83530f..d66f0cdf 100644 --- a/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift @@ -16,11 +16,32 @@ struct PeerCapabilitiesTests { #expect(PeerCapabilities([]).encoded() == Data([0x00])) #expect(PeerCapabilities.prekeys.encoded() == Data([0x01])) #expect(PeerCapabilities.meshDiagnostics.encoded() == Data([0x40])) + #expect(PeerCapabilities.privateMedia.encoded() == Data([0x00, 0x01])) - let high = PeerCapabilities(rawValue: 1 << 9) - #expect(high.encoded() == Data([0x00, 0x02])) + #expect( + PeerCapabilities.privateMediaReceipts.encoded() + == Data([0x00, 0x02]) + ) + #expect( + PeerCapabilities.nonDestructiveNoiseReplacement.encoded() + == Data([0x00, 0x04]) + ) - let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics] + let high = PeerCapabilities(rawValue: 1 << 11) + #expect(high.encoded() == Data([0x00, 0x08])) + + let all: PeerCapabilities = [ + .prekeys, + .wifiBulk, + .gateway, + .groups, + .board, + .vouch, + .meshDiagnostics, + .privateMedia, + .privateMediaReceipts, + .nonDestructiveNoiseReplacement + ] #expect(PeerCapabilities(encoded: all.encoded()) == all) #expect(PeerCapabilities(encoded: high.encoded()) == high) #expect(PeerCapabilities(encoded: PeerCapabilities([]).encoded()) == []) diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/TestConstants.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/TestConstants.swift index 2bb3a0e8..cd0ef876 100644 --- a/localPackages/BitFoundation/Tests/BitFoundationTests/TestConstants.swift +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/TestConstants.swift @@ -11,7 +11,6 @@ import Foundation // Kept local until the test-helper module is split out. struct TestConstants { static let defaultTimeout: TimeInterval = 5.0 - static let shortTimeout: TimeInterval = 1.0 static let longTimeout: TimeInterval = 10.0 static let testNickname1 = "Alice" diff --git a/relays/online_relays_gps.csv b/relays/online_relays_gps.csv index c89c9978..f83e5ad0 100644 --- a/relays/online_relays_gps.csv +++ b/relays/online_relays_gps.csv @@ -1,416 +1,442 @@ Relay URL,Latitude,Longitude -relay.lab.rytswd.com,49.4543,11.0746 -relay.paulstephenborile.com:443,49.4543,11.0746 -relay.binaryrobot.com,43.6532,-79.3832 -nostr-2.21crypto.ch,47.5356,8.73209 -spookstr2.nostr1.com:443,40.7057,-74.0136 -fanfares.nostr1.com:443,40.7057,-74.0136 -x.kojira.io,43.6532,-79.3832 -freelay.sovbit.host,60.1699,24.9384 -nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397 -testnet.samt.st,43.6532,-79.3832 -relay.angor.io,48.1046,11.6002 -relay-arg.zombi.cloudrodion.com,1.35208,103.82 -nostr-01.yakihonne.com,1.32123,103.695 -nostr-relay.cbrx.io,43.6532,-79.3832 -relay.guggero.org,46.5971,9.59652 -nostr.snowbla.de,60.1699,24.9384 -relay.zone667.com,60.1699,24.9384 -nexus.libernet.app:443,43.6532,-79.3832 -relay.islandbitcoin.com,12.8498,77.6545 -relay-testnet.k8s.layer3.news,37.3387,-121.885 -nostr-relay.xbytez.io,50.6924,3.20113 -kasztanowa.bieda.it,43.6532,-79.3832 -nostrcity-club.fly.dev,37.7648,-122.432 -relay.typedcypher.com,51.5072,-0.127586 -nostr.na.social:443,43.6532,-79.3832 -relay.laantungir.net,-19.4692,-42.5315 -relay-dev.satlantis.io:443,40.8302,-74.1299 -rilo.nostria.app,43.6532,-79.3832 -nostr.hekster.org:443,37.3986,-121.964 -nostr-relay.amethyst.name:443,39.0067,-77.4291 -chat-relay.zap-work.com:443,43.6532,-79.3832 -relay.edufeed.org,49.4521,11.0767 -syb.lol:443,43.6532,-79.3832 -relay.sigit.io,50.4754,12.3683 -nostr-relay.xbytez.io:443,50.6924,3.20113 -relay.wavefunc.live,41.8781,-87.6298 -nostr.sathoarder.com,48.5734,7.75211 -myvoiceourstory.org,37.3598,-121.981 -relay.underorion.se,50.1109,8.68213 -nostr.data.haus,50.4754,12.3683 -relay.erybody.com,41.4513,-81.7021 -espelho.girino.org,43.6532,-79.3832 -nostr.pbfs.io:443,50.4754,12.3683 -wot.dergigi.com,64.1476,-21.9392 -nostr.bitcoiner.social:443,47.6743,-117.112 -dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832 -relay.gulugulu.moe,43.6532,-79.3832 -nostr.spicyz.io,43.6532,-79.3832 -relay.cypherflow.ai,48.8575,2.35138 -treuzkas.branruz.com,48.8575,2.35138 -relay1.nostrchat.io,60.1699,24.9384 -kotukonostr.onrender.com,37.7775,-122.397 -nostr.plantroon.com,50.1013,8.62643 -nostr.davenov.com,50.1109,8.68213 -node.kommonzenze.de,49.4521,11.0767 -relay2.veganostr.com,60.1699,24.9384 -armada.sharegap.net,43.6532,-79.3832 -wot.makenomistakes.ca,43.7064,-79.3986 -nostr.2b9t.xyz:443,34.0549,-118.243 -relay.libernet.app:443,43.6532,-79.3832 -relay.dreamith.to:443,43.6532,-79.3832 -relay.lightning.pub:443,39.0438,-77.4874 -nostr.rtvslawenia.com,49.4543,11.0746 -nostr.21crypto.ch,47.5356,8.73209 -relay.ditto.pub:443,43.6532,-79.3832 -relay.plebchain.club,43.6532,-79.3832 -memlay.v0l.io,53.3498,-6.26031 -nostr.chaima.info:443,50.1109,8.68213 -relay.wavlake.com:443,41.2619,-95.8608 -nostr.thalheim.io:443,60.1699,24.9384 -relay.lightning.pub,39.0438,-77.4874 -dev.relay.edufeed.org:443,49.4521,11.0767 -nostr.myshosholoza.co.za:443,52.3913,4.66545 -relay.binaryrobot.com:443,43.6532,-79.3832 -wot.nostr.place,43.6532,-79.3832 -nostr.sathoarder.com:443,48.5734,7.75211 -thecitadel.nostr1.com,40.7057,-74.0136 -relay.artx.market,43.6548,-79.3885 -nos.lol,50.4754,12.3683 -nostr.plantroon.com:443,50.1013,8.62643 -premium.primal.net,43.6532,-79.3832 -nas01xanthosnet.synology.me:7778,47.1285,8.74735 -nostrja-kari.heguro.com,43.6532,-79.3832 -relay.mrmave.work,43.6532,-79.3832 -nostrelay.circum.space,52.6907,4.8181 -mostro-p2p.tech,50.1109,8.68213 -wot.shaving.kiwi,43.6532,-79.3832 -relay.fundstr.me,42.3601,-71.0589 -nostrelay.circum.space:443,52.6907,4.8181 -relay.nostrdice.com,-33.8688,151.209 -relay.getvia.xyz,60.1699,24.9384 -strfry.shock.network:443,39.0438,-77.4874 -relay.nostrmap.net:443,60.1699,24.9384 -relay.nearhood.co.uk,51.5072,-0.127586 -no.str.cr,10.6352,-85.4378 -relay.getsafebox.app:443,43.6532,-79.3832 -relay0.gfcom.info,13.6992,100.694 -nostr.ps1829.com,33.8851,130.883 -relay2.angor.io,48.1046,11.6002 -relay.stickeroo.is-cool.dev,37.3387,-121.885 -ricardo-oem.tailb5546.ts.net,40.7128,-74.006 -relay.typedcypher.com:443,51.5072,-0.127586 -relay.paulstephenborile.com,49.4543,11.0746 -nittom.nostr1.com,40.7057,-74.0136 -conduitl2.fly.dev,37.7648,-122.432 -nostr.rikmeijer.nl,51.7111,5.36809 -relay.thecryptosquid.com,50.4754,12.3683 -spookstr2.nostr1.com,40.7057,-74.0136 -offchain.bostr.online,43.6532,-79.3832 -nostr.planix.org,43.6532,-79.3832 -relay.mccormick.cx,52.3563,4.95714 -0x-nostr-relay.fly.dev,37.7648,-122.432 -nostr.wecsats.io,43.6532,-79.3832 -schnorr.me,43.6532,-79.3832 -relay.satmaxt.xyz,43.6532,-79.3832 -relay.bornheimer.app,51.5072,-0.127586 -relay.nostrhub.fr,48.1045,11.6004 -blossom.gnostr.cloud:443,43.6532,-79.3832 -nostr-02.yakihonne.com:443,1.32123,103.695 -dev.relay.stream,43.6532,-79.3832 -ithurtswhenip.ee,51.5072,-0.127586 -nostr.myshosholoza.co.za,52.3913,4.66545 -relayrs.notoshi.win:443,43.6532,-79.3832 -relay-rpi.edufeed.org:443,49.4521,11.0767 -relay.olas.app:443,60.1699,24.9384 -nostr.unkn0wn.world,46.8499,9.53287 -relay.mitchelltribe.com,39.0438,-77.4874 -yabu.me,35.6092,139.73 -nostr.nodesmap.com,59.3327,18.0656 -dm-test-strfry-generic.samt.st,43.6532,-79.3832 -nostr2.girino.org:443,43.6532,-79.3832 -wot.brightbolt.net,47.6735,-116.781 -strfry.shock.network,39.0438,-77.4874 -relay.kilombino.com,43.6532,-79.3832 -relay.nostr.blockhenge.com,39.0438,-77.4874 -shu04.shugur.net,25.2048,55.2708 -relay-rpi.edufeed.org,49.4521,11.0767 -relay.bullishbounty.com:443,43.6532,-79.3832 -vault.iris.to:443,43.6532,-79.3832 -relay.mostro.network:443,40.8302,-74.1299 -offchain.pub:443,39.1585,-94.5728 -soloco.nl,43.6532,-79.3832 -relay.nostu.be,40.4167,-3.70329 -nostr.pbfs.io,50.4754,12.3683 -relay.directsponsor.net,42.8864,-78.8784 -relay.decentralia.fr,49.4282,10.9796 -relayrs.notoshi.win,43.6532,-79.3832 -nostr-relay.amethyst.name,39.0067,-77.4291 -relay.arx-ccn.com,50.4754,12.3683 -nostr.spaceshell.xyz,43.6532,-79.3832 -relay-fra.zombi.cloudrodion.com,48.8566,2.35222 -rilo.nostria.app:443,43.6532,-79.3832 -relay.trotters.cc:443,43.6532,-79.3832 -nostr.overmind.lol:443,43.6532,-79.3832 -nostr.girino.org:443,43.6532,-79.3832 -bitsat.molonlabe.holdings,51.4012,-1.3147 -nostr.azzamo.net,52.2633,21.0283 -insta-relay.apps3.slidestr.net,40.4167,-3.70329 -bridge.tagomago.me,42.3601,-71.0589 -nostr.thalheim.io,60.1699,24.9384 -relay.artx.market:443,43.6548,-79.3885 -nostr.openhoofd.nl,51.5717,3.70417 -nostr.bond,50.1109,8.68213 -relay.earthly.city,34.1749,-118.54 -nexus.libernet.app,43.6532,-79.3832 -relay.plebeian.market,50.1109,8.68213 -relay.nostr.net,43.6532,-79.3832 -nostr.overmind.lol,43.6532,-79.3832 -relay.ohstr.com,43.6532,-79.3832 -testnet-relay.samt.st:443,40.8302,-74.1299 -relay01.lnfi.network,35.6764,139.65 -relay.mostr.pub:443,43.6532,-79.3832 -wot.nostr.party,36.1659,-86.7844 -relayone.soundhsa.com,39.1008,-94.5811 -relay.mostro.network,40.8302,-74.1299 -ribo.eu.nostria.app,43.6532,-79.3832 -chat-relay.zap-work.com,43.6532,-79.3832 -relay.nostreon.com,60.1699,24.9384 -nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874 -nostr.quali.chat:443,60.1699,24.9384 -relay.internationalright-wing.org:443,-22.5022,-48.7114 -relay.mitchelltribe.com:443,39.0438,-77.4874 -relay.satlantis.io,40.8054,-74.0241 -nittom.nostr1.com:443,40.7057,-74.0136 -nostr.janx.com,43.6532,-79.3832 -nostr.carroarmato0.be:443,50.914,3.21378 -relay.mmwaves.de:443,48.8575,2.35138 -relay.chorus.community:443,48.5333,10.7 -wot.utxo.one,43.6532,-79.3832 -relay.plebeian.market:443,50.1109,8.68213 -relay.cosmicbolt.net,37.3986,-121.964 -x.kojira.io:443,43.6532,-79.3832 -top.testrelay.top,43.6532,-79.3832 -nos.lol:443,50.4754,12.3683 -dev.relay.edufeed.org,49.4521,11.0767 -relayone.geektank.ai:443,39.1008,-94.5811 -relay.nostar.org,43.6532,-79.3832 -nostr.oxtr.dev:443,50.4754,12.3683 -nostr.88mph.life,52.1941,-2.21905 -relay.staging.commonshub.brussels,49.4543,11.0746 -weboftrust.libretechsystems.xyz,55.4724,9.87335 -relay.openfarmtools.org,60.1699,24.9384 -cs-relay.nostrdev.com,50.4754,12.3683 -relay.inforsupports.com,43.6532,-79.3832 -nostr-verified.wellorder.net,45.5201,-122.99 -nostr.hekster.org,37.3986,-121.964 -relay.gulugulu.moe:443,43.6532,-79.3832 -relay.mwaters.net,50.9871,2.12554 -nostrcity-club.fly.dev:443,37.7648,-122.432 -relay.vrtmrz.net:443,43.6532,-79.3832 -relay.nostr.place,43.6532,-79.3832 -relay.wavefunc.live:443,41.8781,-87.6298 -nostr.islandarea.net,35.4669,-97.6473 -purplerelay.com:443,43.6532,-79.3832 -nostr-relay.psfoundation.info:443,39.0438,-77.4874 -r.0kb.io,32.789,-96.7989 -relay-us.zombi.cloudrodion.com,40.7862,-74.0743 -relay.mulatta.io,37.5665,126.978 -strfry.bonsai.com:443,39.0438,-77.4874 -bendernostur.duckdns.org:8443,50.1109,8.68213 -vault.iris.to,43.6532,-79.3832 -ec2.f7z.io,60.1699,24.9384 -nostr.debate.report,50.1109,8.68213 -wot.codingarena.top,50.4754,12.3683 -relay.layer.systems:443,49.0291,8.35695 -relay.degmods.com,50.4754,12.3683 -nostr.mom,50.4754,12.3683 -ribo.us.nostria.app:443,43.6532,-79.3832 -adre.su,59.9311,30.3609 -wot.sudocarlos.com,43.6532,-79.3832 -relay.nostrian-conquest.com,41.223,-111.974 -nostr-relay.nextblockvending.com,47.2343,-119.853 -relay.endfiat.money:443,59.3327,18.0656 -nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 -nostr.carroarmato0.be,50.914,3.21378 -relay.cypherflow.ai:443,48.8575,2.35138 -nostr.girino.org,43.6532,-79.3832 -nostr.thebiglake.org,32.71,-96.6745 -strfry.ymir.cloud,43.6532,-79.3832 -relay.mypathtofire.de,42.8864,-78.8784 -relay.lanacoin-eternity.com,40.8302,-74.1299 -nostr.snowbla.de:443,60.1699,24.9384 -relay.ditto.pub,43.6532,-79.3832 -relay.damus.io,43.6532,-79.3832 -relay.ru.ac.th,13.7607,100.627 -nrs-01.darkcloudarcade.com,39.1008,-94.5811 -testnet-relay.samt.st,40.8302,-74.1299 -antiprimal.net,43.6532,-79.3832 bitchat.nostr1.com,40.7057,-74.0136 -relay.snort.social,53.3498,-6.26031 -relay.mccormick.cx:443,52.3563,4.95714 -relay02.lnfi.network,35.6764,139.65 -srtrelay.c-stellar.net,43.6532,-79.3832 -relay.minibolt.info,43.6532,-79.3832 -nostrride.io,37.3986,-121.964 -articles.layer3.news:443,37.3387,-121.885 -rele.speyhard.fi,51.5072,-0.127586 -relay.aarpia.com,37.3986,-121.964 -nostr.chaima.info,50.1109,8.68213 -relay.wisp.talk:443,49.4543,11.0746 -relay.agorist.space:443,52.3734,4.89406 -strfry.bonsai.com,39.0438,-77.4874 -nostr.hifish.org,47.4244,8.57658 -offchain.pub,39.1585,-94.5728 -nostr.spicyz.io:443,43.6532,-79.3832 -relay.beginningend.com,35.2227,-97.4786 -relay.sharegap.net,43.6532,-79.3832 -nostr.purpura.cloud,43.6532,-79.3832 -nrs-01.darkcloudarcade.com:443,39.1008,-94.5811 -relay.fountain.fm:443,43.6532,-79.3832 -relay.olas.app,60.1699,24.9384 -relay.mmwaves.de,48.8575,2.35138 -relay.openresist.com:443,43.6532,-79.3832 -relay.homeinhk.xyz,35.694,139.754 -relay.libernet.app,43.6532,-79.3832 -relay.comcomponent.com,43.6532,-79.3832 -nostr.tac.lol,47.4748,-122.273 -relay.goodmorningbitcoin.com,43.6532,-79.3832 -relay.nostriot.com:443,41.5695,-83.9786 -bcast.girino.org,43.6532,-79.3832 -nostr.azzamo.net:443,52.2633,21.0283 -relay.islandbitcoin.com:443,12.8498,77.6545 -pool.libernet.app,43.6532,-79.3832 -test.thedude.cloud,50.1109,8.68213 -nostrelites.org,41.8781,-87.6298 -nostr.infero.net,35.6764,139.65 -relay.primal.net,43.6532,-79.3832 -ribo.nostria.app,43.6532,-79.3832 -relay.chorus.community,48.5333,10.7 -bitcoiner.social:443,47.6743,-117.112 -relay.wisp.talk,49.4543,11.0746 -relay.layer.systems,49.0291,8.35695 -relay-dev.satlantis.io,40.8302,-74.1299 -nostr.bitcoiner.social,47.6743,-117.112 -relay.lanavault.space:443,60.1699,24.9384 -relay.staging.plebeian.market,51.5072,-0.127586 -infinity-signal-relay.digitalforlifeagency.workers.dev,43.6532,-79.3832 -relay.fountain.fm,43.6532,-79.3832 -nostr.middling.mydns.jp,35.8099,140.12 -relay.dreamith.to,43.6532,-79.3832 -relay.satmaxt.xyz:443,43.6532,-79.3832 -shu03.shugur.net,25.2048,55.2708 -zealand-charts-craig-thru.trycloudflare.com,43.6532,-79.3832 -nostr.computingcache.com,34.0356,-118.442 -ribo.us.nostria.app,43.6532,-79.3832 -relay.agentry.com,42.8864,-78.8784 -nostr.hifish.org:443,47.4244,8.57658 -nostr.vulpem.com,49.4543,11.0746 -relay.cosmicbolt.net:443,37.3986,-121.964 -nostr-02.yakihonne.com,1.32123,103.695 -r.0kb.io:443,32.789,-96.7989 -nostr-relay.corb.net,38.8353,-104.822 -ribo.eu.nostria.app:443,43.6532,-79.3832 -nostr-relay.psfoundation.info,39.0438,-77.4874 -relay.wellorder.net,45.5201,-122.99 -relay.novospes.com,43.6532,-79.3832 -nostr-dev.wellorder.net,45.5201,-122.99 -relay.endfiat.money,59.3327,18.0656 -relay.angor.io:443,48.1046,11.6002 -relay-fra.zombi.cloudrodion.com:443,48.8566,2.35222 -strfry.openhoofd.nl,51.5717,3.70417 -relay.getsafebox.app,43.6532,-79.3832 -relay.openresist.com,43.6532,-79.3832 -relay5.bitransfer.org,43.6532,-79.3832 -nostr.na.social,43.6532,-79.3832 -portal-relay.pareto.space,49.0291,8.35696 -nostr.notribe.net:443,40.8302,-74.1299 -relay.bitmacro.cloud,43.6532,-79.3832 -no.str.cr:443,10.6352,-85.4378 -relay.klabo.world,47.2343,-119.853 -nostr.notribe.net,40.8302,-74.1299 -relay.staging.plebeian.market:443,51.5072,-0.127586 -relay.nostrmap.net,60.1699,24.9384 -temp.iris.to,43.6532,-79.3832 -nostr.sovereignservices.xyz,43.6532,-79.3832 -nostr.liberty.fans,36.9104,-89.5875 -relay.nostrian-conquest.com:443,41.223,-111.974 -relay.nostriot.com,41.5695,-83.9786 -nostrbtc.com,43.6532,-79.3832 -shu02.shugur.net,21.4902,39.2246 -relay.kalcafe.xyz,37.3986,-121.964 -relay.illuminodes.com,43.6532,-79.3832 -relay.wavlake.com,41.2619,-95.8608 -nostr.ps1829.com:443,33.8851,130.883 -dm-test-strfry-discovery.samt.st,43.6532,-79.3832 -nostr.wecsats.io:443,43.6532,-79.3832 -nostr-pub.wellorder.net,45.5201,-122.99 -nostr.dlcdevkit.com:443,40.0992,-83.1141 -nostr.mom:443,50.4754,12.3683 -ribo.nostria.app:443,43.6532,-79.3832 +relay.fundstr.me,42.3601,-71.0589 nostr.2b9t.xyz,34.0549,-118.243 -nostr.data.haus:443,50.4754,12.3683 +armada.sharegap.net,43.6532,-79.3832 +nostr.chaima.info,51.5072,-0.127586 +nosflare-leefcore.leefcore.workers.dev,43.6532,-79.3832 +ribo.eu.nostria.app:443,43.6532,-79.3832 +relay.lightning.pub,39.0438,-77.4874 +relay.nostu.be,40.4167,-3.70329 +nostr.whitenode45.ddns.net,40.55,-74.4758 +nostr.carroarmato0.be:443,50.914,3.21378 +cdn.satellite.earth,40.8302,-74.1299 +relay2.veganostr.com,60.1699,24.9384 +relay.layer.systems:443,49.0291,8.35695 +relay0.gfcom.info,13.7653,100.647 +relay.mmwaves.de:443,48.8575,2.35138 +offchain.pub,39.1585,-94.5728 +bcast.girino.org,43.6532,-79.3832 staging.yabu.me,35.6092,139.73 -relay.sigit.io:443,50.4754,12.3683 -relay.edufeed.org:443,49.4521,11.0767 -nostr-01.yakihonne.com:443,1.32123,103.695 -reraw.pbla2fish.cc,43.6532,-79.3832 -cs-relay.nostrdev.com:443,50.4754,12.3683 -herbstmeister.com,34.0549,-118.243 +nostr.overpay.com,29.7449,-95.5343 +bridge.tagomago.me,42.3601,-71.0589 +nostr-01.yakihonne.com,1.32123,103.695 +strfry.bonsai.com,39.0438,-77.4874 +relay.sharegap.net,43.6532,-79.3832 +nostr.islandarea.net,35.4669,-97.6473 +dm-test-strfry-generic.samt.st,43.6532,-79.3832 +treuzkas.branruz.com,48.8575,2.35138 +relay-rpi.edufeed.org:443,49.4521,11.0767 +vault.iris.to:443,43.6532,-79.3832 +node.kommonzenze.de,49.4521,11.0767 +nostr.thalheim.io:443,60.1699,24.9384 +soloco.nl,43.6532,-79.3832 +strfry.shock.network,39.0438,-77.4874 +nostr-relay.zimage.com,34.0549,-118.243 +public.crostr.com:443,43.6532,-79.3832 +nostr.sathoarder.com:443,48.5734,7.75211 +relay.angor.io,48.1046,11.6002 +relay.wellorder.net,45.5201,-122.99 +relay.mwaters.net,50.9871,2.12554 +relay.staging.commonshub.brussels,49.4543,11.0746 +nostr-verified.wellorder.net,45.5201,-122.99 +nostr-pub.wellorder.net,45.5201,-122.99 +nostr-2.21crypto.ch,47.5356,8.73209 +relay.kaleidoswap.com,50.8476,4.35717 +relay.libernet.app:443,43.6532,-79.3832 +relay.homeinhk.xyz,35.694,139.754 +relay.manneken.brussels,49.4543,11.0746 +nostr.spicyz.io:443,43.6532,-79.3832 +relay.lanacoin-eternity.com:443,40.8302,-74.1299 +ribo.us.nostria.app:443,43.6532,-79.3832 +relay.loveisbitcoin.com,43.6532,-79.3832 +relay.angor.io:443,48.1046,11.6002 +relay02.lnfi.network,35.6764,139.65 +relay.cosmicbolt.net:443,37.3986,-121.964 +nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397 +nrs-01.darkcloudarcade.com,39.0997,-94.5786 +relay.endfiat.money:443,59.3327,18.0656 +relay.paulstephenborile.com,49.4543,11.0746 +rele.speyhard.fi,51.5072,-0.127586 +relay.froth.zone,60.1699,24.9384 +relay.nostr.blockhenge.com,39.0438,-77.4874 +nrl.ceskar.xyz,50.5145,16.0119 +rilo.nostria.app,43.6532,-79.3832 +nostr.overmind.lol:443,43.6532,-79.3832 +nostr.snowbla.de:443,50.4754,12.3683 +nostrrelay.taylorperron.com,45.5029,-73.5723 +chorus.pjv.me,45.5201,-122.99 +relay.nostr.place,43.6532,-79.3832 +bucket.coracle.social,37.7775,-122.397 +nostr.girino.org:443,43.6532,-79.3832 +relay.aarpia.com,37.3986,-121.964 +nostr.thalheim.io,60.1699,24.9384 +ec2.f7z.io,60.1699,24.9384 +relay.trotters.cc,43.6532,-79.3832 +relay.mccormick.cx:443,52.3563,4.95714 +relay.momostr.pink,43.6532,-79.3832 +relay.nostr.net,43.6532,-79.3832 +conduitl2.fly.dev,37.7648,-122.432 +chat-relay.zap-work.com,43.6532,-79.3832 +relay.ditto.pub,43.6532,-79.3832 +relay.veganostr.com,60.1699,24.9384 relay.minibolt.info:443,43.6532,-79.3832 -relay2.angor.io:443,48.1046,11.6002 -social.amanah.eblessing.co,48.1046,11.6002 -nostr.stakey.net,52.3676,4.90414 +adre.su,59.9311,30.3609 +bitcoinostr.duckdns.org,41.1976,1.11167 nostr.computingcache.com:443,34.0356,-118.442 -slick.mjex.me,39.0418,-77.4744 -fanfares.nostr1.com,40.7057,-74.0136 -bitcoinostr.duckdns.org,43.3434,-3.99532 -nostr.oxtr.dev,50.4754,12.3683 -cache.trustr.ing,43.6548,-79.3885 -purplerelay.com,43.6532,-79.3832 -nostr-kyomu-haskell.onrender.com,37.7775,-122.397 -nostr-relay.corb.net:443,38.8353,-104.822 -relay-dev.gulugulu.moe,43.6532,-79.3832 -prl.plus,55.7628,37.5983 -nostr.tac.lol:443,47.4748,-122.273 -relay.mostr.pub,43.6532,-79.3832 -schnorr.me:443,43.6532,-79.3832 +relay-fra.zombi.cloudrodion.com,48.8566,2.35222 +nostr.hekster.org:443,37.3986,-121.964 +nostr.88mph.life,52.1941,-2.21905 +wot.dergigi.com,64.1476,-21.9392 +nostr.planix.org,43.6532,-79.3832 +relay.satsmarkt.club,52.6907,4.8181 +nostrcity-club.fly.dev:443,37.7648,-122.432 +aeon.libretechsystems.xyz,55.486,9.86577 +testnet.samt.st,43.6532,-79.3832 +nostr.data.haus,50.4754,12.3683 +wot.sudocarlos.com,43.6532,-79.3832 +relay-fra.zombi.cloudrodion.com:443,48.8566,2.35222 +shu01.shugur.net,21.4902,39.2246 +relay.gulugulu.moe:443,43.6532,-79.3832 +relay2.angor.io:443,48.1046,11.6002 +relay.libernet.app,43.6532,-79.3832 +directories-safe-motherboard-recipients.trycloudflare.com,43.6532,-79.3832 +wot.nostr.party,36.1659,-86.7844 +relay.zone667.com,60.1699,24.9384 +nostr.wild-vibes.ts.net,48.8566,2.35222 +relay.nostr.com,50.1109,8.68213 +nostr.iskarion.ddns.net,43.3076,-2.95421 +relay-dev.satlantis.io,39.0438,-77.4874 +relay.sovereignresonance.org,48.9006,2.25929 +relay.nostrian-conquest.com,41.223,-111.974 +relay.aidatanorge.no,43.6532,-79.3832 +strfry.apps3.slidestr.net,40.4167,-3.70329 +relay.klabo.world,47.2343,-119.853 +nostr.data.haus:443,50.4754,12.3683 +testr.nymble.world,40.8054,-74.0241 +relay.inforsupports.com,43.6532,-79.3832 +relay.nostrmap.net:443,60.1699,24.9384 +nostr.stakey.net:443,52.3676,4.90414 dev-relay.nostreon.com,60.1699,24.9384 nostr.islandarea.net:443,35.4669,-97.6473 -bucket.coracle.social,37.7775,-122.397 -blossom.gnostr.cloud,43.6532,-79.3832 -relay.solife.me,43.6532,-79.3832 -nostr.quali.chat,60.1699,24.9384 -relay.vrtmrz.net,43.6532,-79.3832 -relay-dev.gulugulu.moe:443,43.6532,-79.3832 -relay.bullishbounty.com,43.6532,-79.3832 -relay.fckstate.net,59.3293,18.0686 -nostr.rtvslawenia.com:443,49.4543,11.0746 -relay.nostx.io,43.6532,-79.3832 -relay.agorist.space,52.3734,4.89406 -relay.notoshi.win,13.7829,100.546 -dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832 -relay.trotters.cc,43.6532,-79.3832 -relay.lanavault.space,60.1699,24.9384 -public.crostr.com:443,43.6532,-79.3832 -nostr.stakey.net:443,52.3676,4.90414 -relay.nostr.place:443,43.6532,-79.3832 -nostr.dlcdevkit.com,40.0992,-83.1141 -nostr.aruku.ovh,1.27994,103.849 -satsage.xyz,37.3986,-121.964 -strfry.apps3.slidestr.net,40.4167,-3.70329 -nostr2.girino.org,43.6532,-79.3832 -relay.samt.st,40.8302,-74.1299 -articles.layer3.news,37.3387,-121.885 -aeon.libretechsystems.xyz,55.486,9.86577 -relay.routstr.com,59.4016,17.9455 -relay.ohstr.com:443,43.6532,-79.3832 -relay.lanacoin-eternity.com:443,40.8302,-74.1299 -strfry.openhoofd.nl:443,51.5717,3.70417 -nostr.blankfors.se,60.1699,24.9384 -nostr-2.21crypto.ch:443,47.5356,8.73209 -relayone.soundhsa.com:443,39.1008,-94.5811 -relay.lab.rytswd.com:443,49.4543,11.0746 +nostr.rtvslawenia.com,49.4543,11.0746 +relay.bowlafterbowl.com,32.9483,-96.7299 +nostr.quali.chat:443,60.1699,24.9384 +relay.plebeian.market,50.1109,8.68213 +relay-rpi.edufeed.org,49.4521,11.0767 +r.0kb.io,32.789,-96.7989 +nostr.notribe.net:443,40.8302,-74.1299 +relay.getsafebox.app:443,43.6532,-79.3832 +nostr.dlcdevkit.com:443,40.0992,-83.1141 +nostrelites.org,34.9582,-81.9907 +nostr.hoppe-relay.it.com,42.8864,-78.8784 +nostr.thebiglake.org,32.71,-96.6745 +nostr-kyomu-haskell.onrender.com,37.7775,-122.397 +relay.nostriot.com,41.5695,-83.9786 +nostr.christiansass.de,51.7634,7.8887 +relay.btcforplebs.com,43.6532,-79.3832 nostr.tagomago.me,42.3601,-71.0589 -relay.0xchat.com:443,43.6532,-79.3832 +relayone.geektank.ai,39.0997,-94.5786 +relay.dreamith.to:443,43.6532,-79.3832 +nostr.liberty.fans,36.8767,-89.5879 +wot.makenomistakes.ca,43.7064,-79.3986 +relay.goodmorningbitcoin.com,43.6532,-79.3832 +relay.layer.systems,49.0291,8.35695 +relay.paulstephenborile.com:443,49.4543,11.0746 +relay.ohstr.com,43.6532,-79.3832 +nostr-relay.xbytez.io:443,50.6924,3.20113 +nostr.ac,38.958,-77.3592 +ribo.us.nostria.app,43.6532,-79.3832 +nostr.21crypto.ch,47.5356,8.73209 +relay.chorus.community:443,48.5333,10.7 +relay.cypherflow.ai,48.8575,2.35138 +relay.agorist.space:443,52.3734,4.89406 +relay.nostrian-conquest.com:443,41.223,-111.974 +relay.keykeeper.world,40.7824,-74.0711 +relay.getvia.xyz,60.1699,24.9384 +relay.nuts.cash,52.3676,4.90414 +kotukonostr.onrender.com,37.7775,-122.397 +relay.minibolt.info,43.6532,-79.3832 +relay.dwadziesciajeden.pl,52.2297,21.0122 +relay.fountain.fm:443,43.6532,-79.3832 +relay.fountain.fm,43.6532,-79.3832 +nostr-02.uid.ovh,50.9871,2.12554 +relay.lanavault.space:443,60.1699,24.9384 +nostr.carroarmato0.be,50.914,3.21378 +nexus.libernet.app:443,43.6532,-79.3832 +relay.artio.inf.unibe.ch,46.9501,7.43678 +blossom.gnostr.cloud,43.6532,-79.3832 +relay.binaryrobot.com,43.6532,-79.3832 +relay.earthly.city,34.1749,-118.54 +nostr.hifish.org,47.4244,8.57658 +offchain.pub:443,39.1585,-94.5728 +relay.bullishbounty.com:443,43.6532,-79.3832 +strfry.openhoofd.nl:443,51.5717,3.70417 +cs-relay.nostrdev.com:443,50.4754,12.3683 +strfry.ymir.cloud,43.6532,-79.3832 +nostrbtc.com,43.6532,-79.3832 +relay.directsponsor.net,42.8864,-78.8784 +nostr2.girino.org,43.6532,-79.3832 +relay.sigit.io:443,50.4754,12.3683 +relay.getsafebox.app,43.6532,-79.3832 +antiprimal.net,43.6532,-79.3832 +nostr.sathoarder.com,48.5734,7.75211 +inbox.scuba323.com,40.8218,-74.45 +nrs-01.darkcloudarcade.com:443,39.0997,-94.5786 +nostr.tac.lol,47.4748,-122.273 +nostr.davenov.com,50.1109,8.68213 +relay.trotters.cc:443,43.6532,-79.3832 +nostr.plantroon.com:443,50.1013,8.62643 +relay.nostreon.com,60.1699,24.9384 +nostr.easycryptosend.it,43.6532,-79.3832 +nostr-01.yakihonne.com:443,1.32123,103.695 +relay-testnet.k8s.layer3.news,37.3387,-121.885 +nostr.purpura.cloud,43.6532,-79.3832 +insta-relay.apps3.slidestr.net,40.4167,-3.70329 +nostr.mifen.me,43.6532,-79.3832 +testnet-relay.samt.st:443,40.8302,-74.1299 +nostr.2b9t.xyz:443,34.0549,-118.243 +relay.wavlake.com:443,41.2619,-95.8608 +relay.wisp.talk:443,49.4543,11.0746 +relay-dev.satlantis.io:443,39.0438,-77.4874 +relay.satlantis.io,39.0438,-77.4874 +relay.staging.plebeian.market,51.5072,-0.127586 +relay.openfarmtools.org,60.1699,24.9384 +relay.nostrhub.fr,48.1045,11.6004 +nostr-relay.xbytez.io,50.6924,3.20113 +relay.binaryrobot.com:443,43.6532,-79.3832 +relay.samt.st,40.8302,-74.1299 +relay.illuminodes.com,43.6532,-79.3832 +relay.liberbitworld.org,43.6532,-79.3832 +relay.olas.app:443,60.1699,24.9384 +no.str.cr,8.96171,-83.5246 +dm-test-strfry-discovery.samt.st,43.6532,-79.3832 +wot.rejecttheframe.xyz,43.6532,-79.3832 +relay.nostriot.com:443,41.5695,-83.9786 +nostr.plantroon.com,50.1013,8.62643 +nostr-01.uid.ovh,50.9871,2.12554 +relay.openresist.com:443,43.6532,-79.3832 +nostr.overmind.lol,43.6532,-79.3832 +relay.internationalright-wing.org,-22.5022,-48.7114 +nostr.myshosholoza.co.za:443,52.3676,4.90414 +nostr.pbfs.io:443,50.4754,12.3683 +21milionidinostr.duckdns.org,41.8967,12.4822 +nostr.4rs.nl,49.0291,8.35696 +relay.lanavault.space,60.1699,24.9384 +relay.mostr.pub,43.6532,-79.3832 +relay.nostar.org,43.6532,-79.3832 +nostr.mom,50.4754,12.3683 +relay.decentralia.fr,48.122,11.589 +relay.agentry.com,42.8864,-78.8784 +relay2.angor.io,48.1046,11.6002 +slick.mjex.me,39.0418,-77.4744 +relay-us.zombi.cloudrodion.com,40.7862,-74.0743 +relay.vrtmrz.net:443,43.6532,-79.3832 +relay.beginningend.com,35.2227,-97.4786 +chat-relay.zap-work.com:443,43.6532,-79.3832 +relay.underorion.se,50.1109,8.68213 +relay.mitchelltribe.com,39.0438,-77.4874 +relay.qstr.app,51.5072,-0.127586 +relay.cyberguy.fyi,52.6907,4.8181 +strfry.bonsai.com:443,39.0438,-77.4874 +relayone.soundhsa.com:443,39.0997,-94.5786 +relay.sigit.io,50.4754,12.3683 +relay.npubhaus.com,43.6532,-79.3832 +relayrs.notoshi.win,43.6532,-79.3832 +relay.mitchelltribe.com:443,39.0438,-77.4874 +relay.44billion.net,43.6532,-79.3832 +reraw.pbla2fish.cc,43.6532,-79.3832 +articles.layer3.news:443,37.3387,-121.885 +nostr.sovereignservices.xyz,43.6532,-79.3832 +relay.nostx.io,43.6532,-79.3832 +nostr-relay.amethyst.name,39.0067,-77.4291 +0x-nostr-relay.fly.dev,37.7648,-122.432 +relay.ohstr.com:443,43.6532,-79.3832 +00f2e774.relay.dev.thunderegg.us,39.0438,-77.4874 +nostr-relay.cbrx.io,43.6532,-79.3832 +relay.wavlake.com,41.2619,-95.8608 +purplerelay.com:443,43.6532,-79.3832 +nostr-pr02.redscrypt.org,52.3676,4.90414 +fanfares.nostr1.com:443,40.7057,-74.0136 +kasztanowa.bieda.it,43.6532,-79.3832 +relay.flashapp.me,43.6548,-79.3885 +relay.typedcypher.com,51.5072,-0.127586 +nostr.bond,50.1109,8.68213 +nostr.azzamo.net,52.2633,21.0283 +nexus.libernet.app,43.6532,-79.3832 +relay.cosmicbolt.net,37.3986,-121.964 +schnorr.me,43.6532,-79.3832 +relay.mostro.network:443,40.8302,-74.1299 +relay-arg.zombi.cloudrodion.com,1.35208,103.82 +relay.chorus.community,48.5333,10.7 +blossom.gnostr.cloud:443,43.6532,-79.3832 +syb.lol:443,34.0549,-118.243 +relay.dyne.org,49.0291,8.35705 +btc.klendazu.com,41.2861,1.24993 +wot.nostr.place,43.6532,-79.3832 +relay.openresist.com,43.6532,-79.3832 +rilo.nostria.app:443,43.6532,-79.3832 +no.str.cr:443,8.96171,-83.5246 +relay.mostr.pub:443,43.6532,-79.3832 +relay.edufeed.org:443,49.4521,11.0767 +nostr.debate.report,50.1109,8.68213 +relay.satmaxt.xyz:443,43.6532,-79.3832 +relay.artx.market:443,43.6548,-79.3885 +relay-dev.gulugulu.moe,43.6532,-79.3832 +relay.novospes.com,43.6532,-79.3832 +relay.nostr-check.me,43.6532,-79.3832 +nostr.computingcache.com,34.0356,-118.442 +nostr.oxtr.dev,50.4754,12.3683 +relay.fckstate.net,59.3293,18.0686 +relay.vrtmrz.net,43.6532,-79.3832 +relay.bornheimer.app,51.5072,-0.127586 +relay.guggero.org,46.5971,9.59652 +relay01.lnfi.network,35.6764,139.65 +wot.shaving.kiwi,43.6532,-79.3832 +nostr.twinkle.lol,51.902,7.6657 +relay.edufeed.org,49.4521,11.0767 +relay.lanacoin-eternity.com,40.8302,-74.1299 +relay.satmaxt.xyz,43.6532,-79.3832 +nostr.hifish.org:443,47.4244,8.57658 +relay.cypherflow.ai:443,48.8575,2.35138 +infinity-signal-relay.digitalforlifeagency.workers.dev,43.6532,-79.3832 +nostr.na.social:443,43.6532,-79.3832 +nostr.rtvslawenia.com:443,49.4543,11.0746 +relay.mypathtofire.de,42.8864,-78.8784 +public.crostr.com,43.6532,-79.3832 +relay.olas.app,60.1699,24.9384 +relay.agora.social,50.7383,15.0648 +ribo.nostria.app,43.6532,-79.3832 +relay.lab.rytswd.com,49.4543,11.0746 +relay.ditto.pub:443,43.6532,-79.3832 +porchlight.social,43.6532,-79.3832 +nostr.notribe.net,40.8302,-74.1299 +relay.endfiat.money,59.3327,18.0656 +nostr.myshosholoza.co.za,52.3676,4.90414 +relay.nearhood.co.uk,51.5134,-0.0890675 +relay.degmods.com,50.4754,12.3683 +nostr.novacisko.cz,52.2026,20.9397 +prl.plus,55.7628,37.5983 +bruh.samt.st,43.6532,-79.3832 +strfry.openhoofd.nl,51.5717,3.70417 +nostr.spicyz.io,43.6532,-79.3832 +nostr.na.social,43.6532,-79.3832 +nip85.nosfabrica.com,39.0997,-94.5786 +premium.primal.net,43.6532,-79.3832 +fanfares.nostr1.com,40.7057,-74.0136 +relay.scuba323.com,40.8218,-74.45 +nostr2.girino.org:443,43.6532,-79.3832 +relay.mmwaves.de,48.8575,2.35138 +nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874 +strfry.shock.network:443,39.0438,-77.4874 +nostr.snowbla.de,50.4754,12.3683 +nostr.spaceshell.xyz,43.6532,-79.3832 +nostr.quali.chat,60.1699,24.9384 +wot.utxo.one,43.6532,-79.3832 +relay.mccormick.cx,52.3563,4.95714 +mostro-p2p.tech,50.1109,8.68213 +basspistol.org,49.0291,8.35696 +ribo.nostria.app:443,43.6532,-79.3832 +chorus.mikedilger.com:444,-36.8906,174.794 +nostr.oxtr.dev:443,50.4754,12.3683 +nostr.nodesmap.com,59.3327,18.0656 +offchain.bostr.online,43.6532,-79.3832 +purplerelay.com,43.6532,-79.3832 +relayrs.notoshi.win:443,43.6532,-79.3832 +relay.wavefunc.live,41.8781,-87.6298 +relay.dreamith.to,43.6532,-79.3832 +bendernostur.duckdns.org:8443,50.1109,8.68213 +relay.nmail.li,50.9871,2.12554 +nostr-relay.corb.net,39.6478,-104.988 +relay.staging.plebeian.market:443,51.5072,-0.127586 +spamspamspamspam.rest,43.6532,-79.3832 +relay1.gfcom.info,13.9215,100.538 +schnorr.me:443,43.6532,-79.3832 +relay.lab.rytswd.com:443,49.4543,11.0746 +nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 +dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832 +relay.nostrmap.net,60.1699,24.9384 +nostr.relay.hedwig.sh,60.1699,24.9384 +relay.veganostr.com:443,60.1699,24.9384 +relay.wavefunc.live:443,41.8781,-87.6298 +nostr.mikoshi.de,52.52,13.405 +syb.lol,34.0549,-118.243 +relay1.nostrchat.io,60.1699,24.9384 +nostr.wecsats.io:443,43.6532,-79.3832 +nostr.chaima.info:443,51.5072,-0.127586 +nostr.azzamo.net:443,52.2633,21.0283 +relay-can.zombi.cloudrodion.com,43.6532,-79.3832 +nostr.unkn0wn.world,46.8499,9.53287 +relayone.soundhsa.com,39.0997,-94.5786 +x.kojira.io,43.6532,-79.3832 +dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832 +nostrelay.circum.space,52.6907,4.8181 +relay.primal.net,43.6532,-79.3832 +nostr.girino.org,43.6532,-79.3832 +nostr.pbfs.io,50.4754,12.3683 +relay.kalcafe.xyz,37.3986,-121.964 +relay.gulugulu.moe,43.6532,-79.3832 +top.testrelay.top,43.6532,-79.3832 +relay.kilombino.com,43.6532,-79.3832 +nos.lol:443,50.4754,12.3683 +nos.lol,50.4754,12.3683 +relay.nostr.place:443,43.6532,-79.3832 +cache.trustr.ing,43.6548,-79.3885 +relay.internationalright-wing.org:443,-22.5022,-48.7114 +relay.laantungir.net,-19.4692,-42.5315 +relay.lightning.pub:443,39.0438,-77.4874 +nostr.stakey.net,52.3676,4.90414 +articles.layer3.news,37.3387,-121.885 +relay.wisp.talk,49.4543,11.0746 +relay.pyramid.li,47.4093,8.46503 +relay.typedcypher.com:443,51.5072,-0.127586 +dev.relay.stream,43.6532,-79.3832 +relay.bullishbounty.com,43.6532,-79.3832 +nostr.mom:443,50.4754,12.3683 +relay.plebeian.market:443,50.1109,8.68213 +nostr.hekster.org,37.3986,-121.964 +nostrcity-club.fly.dev,37.7648,-122.432 +nostr.vulpem.com,49.4543,11.0746 +relay-dev.gulugulu.moe:443,43.6532,-79.3832 +weboftrust.libretechsystems.xyz,55.4724,9.87335 +nostr-relay.corb.net:443,39.6478,-104.988 +wheat.happytavern.co,43.6532,-79.3832 +relay.mappingbitcoin.com,43.6532,-79.3832 +testnet-relay.samt.st,40.8302,-74.1299 +relay.bitmacro.cloud,43.6532,-79.3832 +dev.relay.edufeed.org,49.4521,11.0767 +myvoiceourstory.org,37.3598,-121.981 +relay.stickeroo.is-cool.dev,37.3387,-121.885 +relay.agorist.space,52.3734,4.89406 +freelay.sovbit.host,60.1699,24.9384 +nostr-dev.wellorder.net,45.5201,-122.99 +nostr.middling.mydns.jp,35.8099,140.12 +cs-relay.nostrdev.com,50.4754,12.3683 +x.kojira.io:443,43.6532,-79.3832 +nostrelay.circum.space:443,52.6907,4.8181 +nostr.janx.com,43.6532,-79.3832 +relay.mrmave.work,43.6532,-79.3832 +espelho.girino.org,43.6532,-79.3832 +hol.is,43.6532,-79.3832 +ribo.eu.nostria.app,43.6532,-79.3832 +nostr.yutakobayashi.com,43.6532,-79.3832 +relay.mostro.network,40.8302,-74.1299 +communities.nos.social,40.8302,-74.1299 +relay.solife.me,43.6532,-79.3832 +yabu.me,35.6092,139.73 +relay.islandbitcoin.com,12.8498,77.6545 +nostr.wecsats.io,43.6532,-79.3832 +nostr.tac.lol:443,47.4748,-122.273 +relay.arx-ccn.com,50.4754,12.3683 +nostrride.io,37.3986,-121.964 +r.0kb.io:443,32.789,-96.7989 +herbstmeister.com,34.0549,-118.243 +relay.artx.market,43.6548,-79.3885 +vault.iris.to,43.6532,-79.3832 +relay.ru.ac.th,13.7607,100.627 +temp.iris.to,43.6532,-79.3832 +social.amanah.eblessing.co,48.1046,11.6002 +nostr-relay.nextblockvending.com,47.2343,-119.853 +wot.codingarena.top,50.4754,12.3683 +relay.sincensura.org,43.6532,-79.3832 +nostr.dlcdevkit.com,40.0992,-83.1141 diff --git a/scripts/check-just-clean-safety.sh b/scripts/check-just-clean-safety.sh new file mode 100644 index 00000000..ca241119 --- /dev/null +++ b/scripts/check-just-clean-safety.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +tracked_justfiles="$(git ls-files | awk 'tolower($0) == "justfile"')" +tracked_justfile_count="$(printf '%s\n' "$tracked_justfiles" | awk 'NF { count++ } END { print count + 0 }')" +if [[ $tracked_justfile_count -ne 1 || $tracked_justfiles != "Justfile" ]]; then + echo "Expected exactly one tracked canonical Justfile; found: ${tracked_justfiles:-none}" >&2 + exit 1 +fi + +if ! grep -Fxq 'clean:' Justfile; then + echo "Clean recipe must not depend on another recipe" >&2 + exit 1 +fi + +clean_recipe="$({ + awk ' + /^clean:/ { in_clean = 1; next } + in_clean && /^[^[:space:]]/ { exit } + in_clean { print } + ' Justfile +})" + +if [[ -z ${clean_recipe//[[:space:]]/} ]]; then + echo "Justfile clean recipe is missing or empty" >&2 + exit 1 +fi + +if ! grep -Fxq 'derived_data := ".DerivedData"' Justfile; then + echo "Derived data path must remain the ignored repo-local .DerivedData directory" >&2 + exit 1 +fi + +clean_forbidden='git[[:space:]]+(checkout|restore|reset|clean)|(^|[[:space:]])(cp|mv)([[:space:]]|$)|bitchat\.xcodeproj|project\.pbxproj|Info\.plist|LaunchScreen|project\.yml|Configs/' +if grep -Eiq "$clean_forbidden" <<<"$clean_recipe"; then + echo "Unsafe source/configuration mutation found in the clean recipe:" >&2 + grep -Ein "$clean_forbidden" <<<"$clean_recipe" >&2 + exit 1 +fi + +if ! grep -Fq 'rm -rf -- "{{derived_data}}" ".build"' <<<"$clean_recipe"; then + echo "Clean recipe must remain limited to the declared repo-local artifact paths" >&2 + exit 1 +fi + +clean_rm_count="$(grep -Ec '^[[:space:]]*@?rm[[:space:]]+-rf([[:space:]]|$)' <<<"$clean_recipe" || true)" +if [[ $clean_rm_count -ne 1 ]]; then + echo "Clean recipe must contain exactly one recursive removal command" >&2 + exit 1 +fi + +expected_clean_recipe=' @echo "Cleaning repo-local build artifacts..." + @rm -rf -- "{{derived_data}}" ".build" + @echo "✅ Cleaned {{derived_data}} and .build; tracked files were untouched"' +if [[ $clean_recipe != "$expected_clean_recipe" ]]; then + echo "Clean recipe contains commands outside the reviewed artifact-only implementation" >&2 + exit 1 +fi + +file_forbidden='git[[:space:]]+(checkout|restore|reset|clean)|rm[[:space:]]+-rf[^#]*(bitchat\.xcodeproj|bitchat/|Configs/)|LaunchScreen\.storyboard\.ios|project\.pbxproj\.backup|Info\.plist\.backup' +if grep -Ein "$file_forbidden" Justfile; then + echo "Unsafe tracked-file recovery/deletion logic found in Justfile" >&2 + exit 1 +fi + +echo "Justfile clean safety check passed" diff --git a/scripts/tests/test_fetch_georelays_workflow.py b/scripts/tests/test_fetch_georelays_workflow.py new file mode 100644 index 00000000..57c5d81d --- /dev/null +++ b/scripts/tests/test_fetch_georelays_workflow.py @@ -0,0 +1,61 @@ +import re +from pathlib import Path +import unittest + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +WORKFLOW_PATH = REPOSITORY_ROOT / ".github/workflows/fetch_georelays.yml" + + +class FetchGeoRelaysWorkflowTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + + def test_write_capable_checkout_action_is_immutable(self) -> None: + checkout = re.search(r"uses: actions/checkout@([0-9a-f]+)", self.workflow) + self.assertIsNotNone(checkout) + self.assertRegex(checkout.group(1), r"^[0-9a-f]{40}$") + self.assertIn("persist-credentials: false", self.workflow) + + def test_pr_failure_has_single_issue_fallback_with_review_metadata(self) -> None: + required_fragments = [ + "issues: write", + "TRACKING_ISSUE_TITLE: GeoRelay update awaiting pull request", + "gh pr create", + "gh issue create", + "gh issue edit", + "compare/main...${UPDATE_BRANCH}?expand=1", + "Upstream commit: $SOURCE_COMMIT", + "Data rows: $DATA_ROWS", + "Unique normalized relays: $UNIQUE_RELAYS", + "SHA-256: $DATA_SHA256", + '[[ -n "$issue_url" ]]', + ] + for fragment in required_fragments: + with self.subTest(fragment=fragment): + self.assertIn(fragment, self.workflow) + + confirmed = self.workflow.index('[[ -n "$issue_url" ]]') + success_summary = self.workflow.index( + "Published GeoRelay tracking issue fallback: $issue_url" + ) + self.assertLess(confirmed, success_summary) + + def test_obsolete_review_state_is_cleaned_without_pushing_main(self) -> None: + self.assertIn("gh pr close", self.workflow) + self.assertIn("gh issue close", self.workflow) + self.assertIn('git push origin --delete "$UPDATE_BRANCH"', self.workflow) + self.assertIn('git switch -C "$UPDATE_BRANCH"', self.workflow) + self.assertNotIn("git push origin main", self.workflow) + self.assertNotIn("git push --force origin main", self.workflow) + + def test_workflow_runs_all_validator_tests(self) -> None: + self.assertIn( + 'python3 -m unittest discover -s scripts/tests -p "test_*.py" -v', + self.workflow, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_validate_georelays.py b/scripts/tests/test_validate_georelays.py new file mode 100644 index 00000000..faf63834 --- /dev/null +++ b/scripts/tests/test_validate_georelays.py @@ -0,0 +1,186 @@ +import tempfile +from pathlib import Path +import sys +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import validate_georelays as validator + + +def csv_bytes(rows: list[str]) -> bytes: + return ("Relay URL,Latitude,Longitude\n" + "\n".join(rows) + "\n").encode() + + +class ValidateGeoRelaysTests(unittest.TestCase): + def test_validates_and_deduplicates_secure_relay_addresses(self) -> None: + data = csv_bytes( + [ + "relay.example.com,10,20", + "wss://relay.example.com:443/,10,20", + "https://second.example.org,11,21", + ] + ) + + summary = validator.validate_bytes(data, minimum_unique_relays=2) + + self.assertEqual(summary.data_rows, 3) + self.assertEqual(summary.unique_relays, 2) + + def test_rejects_insecure_or_non_host_relay_urls(self) -> None: + bad_addresses = [ + "http://relay.example.com", + "ws://relay.example.com", + "wss://user@relay.example.com", + "wss://relay.example.com/path", + "wss://relay.example.com?", + "wss://relay.example.com#", + "relay.example.com:0", + "relay.example.com:99999", + "localhost", + "127.0.0.1", + "relay_example.com", + "relay\u202e.example.com", + ] + + for address in bad_addresses: + with self.subTest(address=address): + with self.assertRaises(validator.ValidationError): + validator.validate_bytes( + csv_bytes([f"{address},10,20"]), + minimum_unique_relays=1, + ) + + def test_rejects_malformed_rows_and_unsafe_coordinates(self) -> None: + bad_rows = [ + "relay.example.com,10", + "relay.example.com,NaN,20", + "relay.example.com,1_0,20", + "relay.example.com,\u0661\u0660,20", + "relay.example.com,\uff11\uff10,20", + "relay.example.com,91,20", + "relay.example.com,10,-181", + "relay.example.com,10,20,extra", + '"relay.example.com",10,20', + ] + + for row in bad_rows: + with self.subTest(row=row): + with self.assertRaises(validator.ValidationError): + validator.validate_bytes(csv_bytes([row]), minimum_unique_relays=1) + + def test_accepts_ascii_coordinate_forms_supported_by_swift_double(self) -> None: + summary = validator.validate_bytes( + csv_bytes( + [ + "one.example.com,+1,-.5", + "two.example.com,1.e1,2E+1", + "three.example.com,01,20.", + ] + ), + minimum_unique_relays=3, + ) + + self.assertEqual(summary.unique_relays, 3) + + def test_rejects_conflicts_limits_and_large_baseline_deltas(self) -> None: + with self.assertRaises(validator.ValidationError): + validator.validate_bytes( + csv_bytes(["relay.example.com,10,20", "relay.example.com,11,21"]), + minimum_unique_relays=1, + ) + with self.assertRaises(validator.ValidationError): + validator.validate_bytes(b"x" * 20, maximum_bytes=10, minimum_unique_relays=1) + with self.assertRaises(validator.ValidationError): + validator.validate_bytes( + csv_bytes(["one.example.com,1,1", "two.example.com,2,2"]), + minimum_unique_relays=3, + ) + + baseline = csv_bytes( + [f"relay-{index}.example.com,{index % 80},{index % 170}" for index in range(120)] + ) + shrunken = csv_bytes( + [f"relay-{index}.example.com,{index % 80},{index % 170}" for index in range(59)] + ) + with self.assertRaises(validator.ValidationError): + validator.validate_update(shrunken, baseline) + + smaller_baseline = csv_bytes( + [f"relay-{index}.example.com,{index % 80},{index % 170}" for index in range(60)] + ) + expanded = csv_bytes( + [f"relay-{index}.example.com,{index % 80},{index % 170}" for index in range(121)] + ) + with self.assertRaises(validator.ValidationError): + validator.validate_update(expanded, smaller_baseline) + + def test_update_requires_exact_normalized_baseline_entry_overlap(self) -> None: + baseline_rows = [ + f"relay-{index}.example.com,{index % 80},{index % 170}" + for index in range(60) + ] + baseline = csv_bytes(baseline_rows) + disjoint = csv_bytes( + [ + f"attacker-{index}.example.com,{index % 80},{index % 170}" + for index in range(60) + ] + ) + rewritten_coordinates = csv_bytes( + [ + f"relay-{index}.example.com,{(index % 80) + 0.5},{index % 170}" + for index in range(60) + ] + ) + + for candidate in (disjoint, rewritten_coordinates): + with self.subTest(candidate=candidate[:80]): + with self.assertRaisesRegex( + validator.ValidationError, + "exact relay-coordinate entries", + ): + validator.validate_update(candidate, baseline) + + half_retained = csv_bytes( + [ + f"wss://relay-{index}.example.com:443/,{index % 80},{index % 170}" + for index in range(30) + ] + + [ + f"replacement-{index}.example.com,{index % 80},{index % 170}" + for index in range(30) + ] + ) + summary = validator.validate_update(half_retained, baseline) + self.assertEqual(summary.unique_relays, 60) + + def test_cli_copies_only_validated_data_and_emits_review_metadata(self) -> None: + rows = [f"relay-{index}.example.com,{index % 80},{index % 170}" for index in range(60)] + data = csv_bytes(rows) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + candidate = root / "candidate.csv" + baseline = root / "baseline.csv" + output = root / "output.csv" + github_output = root / "github-output.txt" + candidate.write_bytes(data) + baseline.write_bytes(data) + + result = validator.main( + [ + "--input", str(candidate), + "--baseline", str(baseline), + "--output", str(output), + "--github-output", str(github_output), + ] + ) + + self.assertEqual(result, 0) + self.assertEqual(output.read_bytes(), data) + metadata = github_output.read_text() + self.assertIn("unique_relays=60", metadata) + self.assertIn("sha256=", metadata) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/validate_georelays.py b/scripts/validate_georelays.py new file mode 100644 index 00000000..513a4dc0 --- /dev/null +++ b/scripts/validate_georelays.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +"""Strict validator for the reviewed georelay CSV update workflow.""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import io +import math +import re +from dataclasses import dataclass +from pathlib import Path +import sys +import unicodedata +from urllib.parse import urlsplit + + +MAX_BYTES = 512 * 1024 +MAX_ROWS = 5_000 +MAX_UNIQUE_RELAYS = 5_000 +MIN_UNIQUE_RELAYS = 50 +MIN_BASELINE_FRACTION = 0.5 +MAX_BASELINE_MULTIPLIER = 2.0 +EXPECTED_HEADER = ("relay url", "latitude", "longitude") +ASCII_DECIMAL_PATTERN = re.compile( + r"[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?\Z" +) + + +class ValidationError(ValueError): + pass + + +@dataclass(frozen=True) +class ValidationSummary: + data_rows: int + unique_relays: int + sha256: str + + +@dataclass(frozen=True) +class _ValidatedDataset: + summary: ValidationSummary + entries: frozenset[tuple[str, float, float]] + + +def _has_disallowed_control(value: str) -> bool: + return any( + unicodedata.category(character) in {"Cc", "Cf"} + and character not in {"\r", "\n", "\t"} + for character in value + ) + + +def normalize_relay_address(raw_value: str) -> str: + value = raw_value.strip() + if not value or _has_disallowed_control(value): + raise ValidationError("relay address is empty or contains control characters") + # urlsplit cannot distinguish an absent query/fragment from an explicitly + # empty one. Reject the delimiters themselves so this validator matches + # URLComponents in the client and reviewed data cannot fail closed there. + if "?" in value or "#" in value: + raise ValidationError(f"relay query or fragment is not allowed: {value}") + + candidate = value if "://" in value else f"wss://{value}" + try: + parsed = urlsplit(candidate) + port = parsed.port + except ValueError as error: + raise ValidationError(f"invalid relay URL: {value}") from error + + if parsed.scheme.lower() not in {"wss", "https"}: + raise ValidationError(f"relay must use wss/https or a bare hostname: {value}") + if parsed.username is not None or parsed.password is not None: + raise ValidationError(f"relay credentials are not allowed: {value}") + if parsed.path not in {"", "/"} or parsed.query or parsed.fragment: + raise ValidationError(f"relay path, query, or fragment is not allowed: {value}") + + host = (parsed.hostname or "").lower() + if not host or len(host) > 253 or not host.isascii(): + raise ValidationError(f"relay hostname is missing or non-ASCII: {value}") + if host.endswith(".") or host == "localhost" or host.endswith((".localhost", ".local", ".internal")): + raise ValidationError(f"local or absolute relay hostname is not allowed: {value}") + + labels = host.split(".") + if len(labels) < 2 or all(label.isdigit() for label in labels): + raise ValidationError(f"relay must use a public DNS hostname: {value}") + for label in labels: + if not 1 <= len(label) <= 63: + raise ValidationError(f"invalid DNS label length: {value}") + if label[0] == "-" or label[-1] == "-": + raise ValidationError(f"DNS labels cannot start or end with '-': {value}") + if any(character not in "abcdefghijklmnopqrstuvwxyz0123456789-" for character in label): + raise ValidationError(f"invalid DNS hostname character: {value}") + + if port is not None and not 1 <= port <= 65_535: + raise ValidationError(f"invalid relay port: {value}") + if port in {None, 443}: + return host + return f"{host}:{port}" + + +def _validated_dataset( + data: bytes, + *, + minimum_unique_relays: int = MIN_UNIQUE_RELAYS, + maximum_bytes: int = MAX_BYTES, + maximum_rows: int = MAX_ROWS, + maximum_unique_relays: int = MAX_UNIQUE_RELAYS, +) -> _ValidatedDataset: + if not data or len(data) > maximum_bytes: + raise ValidationError(f"CSV must contain 1..{maximum_bytes} bytes") + + try: + text = data.decode("utf-8") + except UnicodeDecodeError as error: + raise ValidationError("CSV is not valid UTF-8") from error + if text.startswith("\ufeff"): + raise ValidationError("UTF-8 BOM is not allowed") + if _has_disallowed_control(text): + raise ValidationError("CSV contains disallowed control characters") + # Runtime intentionally implements the fixed three-field schema without + # general CSV quoting. Reject quoted variants here so reviewed workflow + # output and client-side validation cannot disagree. + if '"' in text: + raise ValidationError("quoted CSV fields are not allowed") + + reader = csv.reader(io.StringIO(text, newline=""), strict=True) + try: + header = next(reader) + except (StopIteration, csv.Error) as error: + raise ValidationError("CSV header is missing") from error + normalized_header = tuple(field.strip().lower() for field in header) + if normalized_header != EXPECTED_HEADER: + raise ValidationError(f"unexpected CSV header: {header!r}") + + data_rows = 0 + relays: dict[str, tuple[float, float]] = {} + try: + for row in reader: + if not row or all(not field.strip() for field in row): + continue + data_rows += 1 + if data_rows > maximum_rows: + raise ValidationError(f"CSV exceeds {maximum_rows} data rows") + if len(row) != 3: + raise ValidationError(f"row {reader.line_num} must contain exactly 3 columns") + + address = normalize_relay_address(row[0]) + latitude_text = row[1].strip() + longitude_text = row[2].strip() + if not ASCII_DECIMAL_PATTERN.fullmatch(latitude_text) or not ASCII_DECIMAL_PATTERN.fullmatch(longitude_text): + raise ValidationError( + f"row {reader.line_num} coordinates must be ASCII decimal numbers" + ) + latitude = float(latitude_text) + longitude = float(longitude_text) + if not math.isfinite(latitude) or not -90 <= latitude <= 90: + raise ValidationError(f"row {reader.line_num} latitude is out of range") + if not math.isfinite(longitude) or not -180 <= longitude <= 180: + raise ValidationError(f"row {reader.line_num} longitude is out of range") + + coordinates = (latitude, longitude) + previous = relays.get(address) + if previous is not None and previous != coordinates: + raise ValidationError(f"relay {address} has conflicting coordinates") + relays[address] = coordinates + if len(relays) > maximum_unique_relays: + raise ValidationError(f"CSV exceeds {maximum_unique_relays} unique relays") + except csv.Error as error: + raise ValidationError(f"malformed CSV near line {reader.line_num}") from error + + if len(relays) < minimum_unique_relays: + raise ValidationError( + f"CSV has {len(relays)} unique relays; minimum is {minimum_unique_relays}" + ) + + return _ValidatedDataset( + summary=ValidationSummary( + data_rows=data_rows, + unique_relays=len(relays), + sha256=hashlib.sha256(data).hexdigest(), + ), + entries=frozenset( + (address, coordinates[0], coordinates[1]) + for address, coordinates in relays.items() + ), + ) + + +def validate_bytes( + data: bytes, + *, + minimum_unique_relays: int = MIN_UNIQUE_RELAYS, + maximum_bytes: int = MAX_BYTES, + maximum_rows: int = MAX_ROWS, + maximum_unique_relays: int = MAX_UNIQUE_RELAYS, +) -> ValidationSummary: + return _validated_dataset( + data, + minimum_unique_relays=minimum_unique_relays, + maximum_bytes=maximum_bytes, + maximum_rows=maximum_rows, + maximum_unique_relays=maximum_unique_relays, + ).summary + + +def validate_update(candidate: bytes, baseline: bytes) -> ValidationSummary: + baseline_dataset = _validated_dataset(baseline, minimum_unique_relays=1) + candidate_dataset = _validated_dataset(candidate) + baseline_summary = baseline_dataset.summary + candidate_summary = candidate_dataset.summary + + minimum_from_baseline = math.ceil( + baseline_summary.unique_relays * MIN_BASELINE_FRACTION + ) + maximum_from_baseline = math.floor( + baseline_summary.unique_relays * MAX_BASELINE_MULTIPLIER + ) + if candidate_summary.unique_relays < minimum_from_baseline: + raise ValidationError( + "candidate loses more than half of the baseline's unique relays " + f"({candidate_summary.unique_relays} < {minimum_from_baseline})" + ) + if candidate_summary.unique_relays > maximum_from_baseline: + raise ValidationError( + "candidate more than doubles the baseline's unique relays " + f"({candidate_summary.unique_relays} > {maximum_from_baseline})" + ) + + retained_entries = len(baseline_dataset.entries & candidate_dataset.entries) + if retained_entries < minimum_from_baseline: + raise ValidationError( + "candidate retains fewer than half of the baseline's exact relay-coordinate entries " + f"({retained_entries} < {minimum_from_baseline})" + ) + return candidate_summary + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--input", required=True, type=Path) + parser.add_argument("--baseline", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--github-output", type=Path) + args = parser.parse_args(argv) + + try: + candidate = args.input.read_bytes() + baseline = args.baseline.read_bytes() + summary = validate_update(candidate, baseline) + args.output.write_bytes(candidate) + if args.github_output is not None: + with args.github_output.open("a", encoding="utf-8") as output: + output.write(f"data_rows={summary.data_rows}\n") + output.write(f"unique_relays={summary.unique_relays}\n") + output.write(f"sha256={summary.sha256}\n") + except (OSError, ValidationError) as error: + print(f"georelay validation failed: {error}", file=sys.stderr) + return 1 + + print( + f"validated {summary.unique_relays} unique relays across " + f"{summary.data_rows} rows (sha256 {summary.sha256})" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())