Merge branch 'main' into main

This commit is contained in:
jack 2026-07-27 00:09:33 +01:00 committed by GitHub
commit 4391170729
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
197 changed files with 36027 additions and 3259 deletions

View File

@ -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 }}
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

112
.github/workflows/source-manifest.yml vendored Normal file
View File

@ -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

View File

@ -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.

1
.gitignore vendored
View File

@ -80,3 +80,4 @@ build.log
# Local configs
Local.xcconfig
*.profraw

View File

@ -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)

149
Justfile
View File

@ -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"

View File

@ -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.

View File

@ -63,7 +63,11 @@ let package = Package(
// Only the vector fixture: declaring the whole "Noise"
// directory would claim its .swift test files as resources
// and silently drop them from compilation.
.process("Noise/NoiseTestVectors.json")
.process("Noise/NoiseTestVectors.json"),
// Frozen envelopes produced by the released iOS (733098bb)
// and Android (b7f0b33d) private-DM implementations; prove
// receive compatibility independently of the local generator.
.process("Nostr/Fixtures")
]
)
]

View File

@ -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.<team_id>` (unless you set to something else)
- Entitlements need to be updated manually (TODO: Automate):
- Search and replace `group.chat.bitchat` with `group.<your_bundle_id>` (e.g. `group.chat.bitchat.ABC123`)
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.

45
SECURITY.md Normal file
View File

@ -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`.

View File

@ -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.25.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).
---

View File

@ -70,6 +70,7 @@
A6E32D1B2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchatShareExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Services/SharedContentHandoff.swift,
Services/TransportConfig.swift,
);
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;

View File

@ -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)

View File

@ -20,13 +20,22 @@ final class AppChromeModel: ObservableObject {
@Published var showScreenshotPrivacyWarning = false
private let chatViewModel: ChatViewModel
private let onPanicWipe: () -> Void
private var cancellables = Set<AnyCancellable>()
/// The composer owns capture state above ChatViewModel. ContentView
/// installs this hook so both panic entry points synchronously stop it.
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()
}

View File

@ -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<AnyCancellable>()
@ -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

View File

@ -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..<messages.count {
indexByMessageID[messages[index].id] = index
indexByMessageID[messages[index].id] = indexOffset + index
}
}
private func physicalIndex(forMessageID messageID: String) -> Int? {
guard let logicalIndex = indexByMessageID[messageID] else { return nil }
let index = logicalIndex - indexOffset
guard messages.indices.contains(index) else { return nil }
return index
}
/// Trims oldest messages over the cap; returns the trimmed message IDs.
private func trimIfNeeded() -> [String] {
guard messages.count > cap else { return [] }
@ -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<PeerID>
) -> Bool {
guard !peerIDs.isEmpty,
let indexedIDs = conversationIDsByMessageID[messageID] else {
return false
}
let allowedIDs = Set(peerIDs.map { ConversationID.directPeer($0) })
return applyDeliveryStatus(
status,
forMessageID: messageID,
among: indexedIDs.intersection(allowedIDs)
)
}
private func applyDeliveryStatus(
_ status: DeliveryStatus,
forMessageID messageID: String,
among ids: Set<ConversationID>
) -> Bool {
guard !ids.isEmpty else { return false }
var applied = false
var 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

View File

@ -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

View File

@ -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<Void, Never>
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<Void, Never>? = 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

View File

@ -0,0 +1,100 @@
//
// PrivacyScreen.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
#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

View File

@ -265,10 +265,10 @@ final class PrivateConversationModel: ObservableObject {
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
let peer = chatViewModel.getPeer(byID: headerPeerID)
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
// Geo DMs are always routed over Nostr (NIP-17); their nostr_ keys
// never resolve to a reachable mesh peer, so resolveAvailability would
// report .offline. Report .nostrAvailable so the header shows the
// globe instead of a misleading "offline" tag.
// Geo DMs are always routed through BitChat private envelopes over
// Nostr; their nostr_ keys never resolve to a reachable mesh peer, so
// resolveAvailability would report .offline. Report .nostrAvailable
// so the header shows the globe instead of a misleading "offline" tag.
let availability = conversationPeerID.isGeoDM
? .nostrAvailable
: resolveAvailability(for: headerPeerID, peer: peer)

View File

@ -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
}
}

View File

@ -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) {

View File

@ -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())

View File

@ -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.

View File

@ -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<String>] = [:]
// Verified fingerprints (cryptographic proof)
var verifiedFingerprints: Set<String> = []
// Last interaction timestamps (privacy: optional)
var lastInteractions: [String: Date] = [:]
var lastInteractions: [String: Date] = [:]
// Blocked Nostr pubkeys (lowercased hex) for geohash chats
var blockedNostrPubkeys: Set<String> = []
// Vouching (transitive verification). All three fields are Optional so
// caches persisted before this feature decode cleanly 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<String>? = 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<String>].self, forKey: .nicknameIndex) ?? [:]
verifiedFingerprints = try container.decodeIfPresent(Set<String>.self, forKey: .verifiedFingerprints) ?? []
lastInteractions = try container.decodeIfPresent([String: Date].self, forKey: .lastInteractions) ?? [:]
blockedNostrPubkeys = try container.decodeIfPresent(Set<String>.self, forKey: .blockedNostrPubkeys) ?? []
vouchesByVouchee = try container.decodeIfPresent([String: [VouchRecord]].self, forKey: .vouchesByVouchee)
vouchBatchSentAt = try container.decodeIfPresent([String: Date].self, forKey: .vouchBatchSentAt)
verifiedAt = try container.decodeIfPresent([String: Date].self, forKey: .verifiedAt)
privateMediaCapableFingerprints = try container.decodeIfPresent(Set<String>.self, forKey: .privateMediaCapableFingerprints)
authenticatedSigningKeysByFingerprint = try container.decodeIfPresent([String: Data].self, forKey: .authenticatedSigningKeysByFingerprint)
cryptographicIdentities = try container.decodeIfPresent([String: CryptographicIdentity].self, forKey: .cryptographicIdentities) ?? [:]
version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1
}
}
//

View File

@ -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<UInt8>()
// 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

File diff suppressed because it is too large Load Diff

View File

@ -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
}

View File

@ -6,14 +6,60 @@
// For more information, see <https://unlicense.org>
//
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

View File

@ -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 {

View File

@ -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
}

File diff suppressed because it is too large Load Diff

View File

@ -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
}

View File

@ -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<Entry>
) 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<Entry> = []
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<Entry>? = 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

View File

@ -1,7 +1,8 @@
import Foundation
import P256K
/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
/// Manages the secp256k1 identity used by BitChat's Nostr relay features,
/// including the proprietary private-envelope transport.
struct NostrIdentity: Codable {
let privateKey: Data
let publicKey: Data

View File

@ -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<CryptoKit.SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
salt: Data(),

View File

@ -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<ChannelID, Never> = 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<String>()
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<String> = []
private func reloadDefaultRelays() {
var seen = Set<String>()
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<String> { 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..<messageQueue.count).reversed() {
var item = messageQueue[index]
item.pendingRelays.subtract(Self.defaultRelaySet)
if item.pendingRelays.isEmpty {
messageQueue.remove(at: index)
} else {
messageQueue[index] = item
}
}
messageQueueLock.unlock()
relays.removeAll { Self.defaultRelaySet.contains($0.url) }
updateConnectionStatus()
dropRelays(defaultRelaySet)
}
}
/// Closes and forgets a set of relays: connection, inbound pipeline,
/// subscriptions, queued sends addressed only to them, and the published row.
private func dropRelays(_ urls: Set<String>) {
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..<messageQueue.count).reversed() {
var item = messageQueue[index]
item.pendingRelays.subtract(urls)
if item.pendingRelays.isEmpty {
messageQueue.remove(at: index)
} else {
messageQueue[index] = item
}
}
messageQueueLock.unlock()
// A relay queued while Tor bootstraps would otherwise reconnect when
// the queue drains, overriding the explicit removal.
pendingTorConnectionURLs.subtract(urls)
relays.removeAll { urls.contains($0.url) }
updateConnectionStatus()
}
private func allowedRelayList(from urls: [String]) -> [String] {
var seen = Set<String>()
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<InboundFrame>.Continuation] = [:]
private var tasks: [String: Task<Void, Never>] = [:]
/// Start a relay's stream + consumer if one does not already exist.
/// Returns true when a new pipeline was created. The bounded
/// `.bufferingNewest` policy makes a single relay shed its OWN oldest
/// frames under a flood, never other relays' frames. Buffered memory per
/// relay is bounded (not eliminated) at the frame cap times the per-frame
/// byte cap (`maximumMessageSize`) see TransportConfig.
func startPipeline(
for relayUrl: String,
makeConsumer: (AsyncStream<InboundFrame>) -> Task<Void, Never>
) -> Bool {
lock.lock()
defer { lock.unlock() }
if continuations[relayUrl] != nil { return false }
let (stream, continuation) = AsyncStream<InboundFrame>.makeStream(
bufferingPolicy: .bufferingNewest(TransportConfig.nostrInboundPerRelayBufferCap)
)
continuations[relayUrl] = continuation
tasks[relayUrl] = makeConsumer(stream)
return true
}
/// Route a frame to a relay's stream. No-op if the relay has no live
/// pipeline (socket already torn down) the frame is simply dropped, which
/// is safe for best-effort Nostr inbound.
func yield(_ frame: InboundFrame, to relayUrl: String) {
lock.lock()
let continuation = continuations[relayUrl]
lock.unlock()
continuation?.yield(frame)
}
/// Finish a relay's stream. The consumer drains any already-buffered frames
/// before exiting, so in-flight verified events are still delivered.
func finishPipeline(for relayUrl: String) {
lock.lock()
let continuation = continuations.removeValue(forKey: relayUrl)
tasks.removeValue(forKey: relayUrl)
lock.unlock()
continuation?.finish()
}
func finishAll() {
lock.lock()
let allContinuations = continuations
continuations.removeAll()
tasks.removeAll()
lock.unlock()
for continuation in allContinuations.values {
continuation.finish()
}
}
}
private enum ParsedInbound {
case event(subId: String, event: NostrEvent)
case ok(eventId: String, success: Bool, reason: String)

View File

@ -0,0 +1,92 @@
//
// NostrRelaySettings.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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<String>()
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<String>,
in defaults: UserDefaults = .standard
) -> Result<String, AddFailure> {
// 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)
}
}

View File

@ -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
}
}

View File

@ -154,3 +154,90 @@ struct BitchatFilePacket {
)
}
}
/// Wire-compatible identity for private media exchanged by clients using the
/// current iOS entropy-bearing filenames, without extending the deployed file
/// TLV. Android clients reject unknown file tags, so eligible senders and
/// receivers derive the receipt key from fields already on the wire.
///
/// Locally-created image and voice-note filenames contain a UUID or live-voice
/// burst ID. Including the normalized direction keeps a reused filename
/// distinct across chats while allowing short and full Noise-key peer IDs to
/// converge. Android and older-iOS timestamp-only names remain ineligible and
/// retain their legacy random local IDs (transfer-compatible, no receipts).
enum PrivateMediaMessageIdentity {
private static let domain = Data("bitchat-private-media-message-v1".utf8)
private static let idPrefix = "media-"
private static let digestHexLength = 32
static func isStableID(_ candidate: String) -> Bool {
guard candidate.hasPrefix(idPrefix) else { return false }
let digest = candidate.dropFirst(idPrefix.count)
guard digest.utf8.count == digestHexLength else { return false }
return digest.utf8.allSatisfy { byte in
(UInt8(ascii: "0")...UInt8(ascii: "9")).contains(byte)
|| (UInt8(ascii: "a")...UInt8(ascii: "f")).contains(byte)
}
}
static func stableID(
senderPeerID: PeerID,
recipientPeerID: PeerID,
fileName: String?
) -> String? {
guard let fileName, !fileName.isEmpty else { return nil }
let leafName = (fileName as NSString).lastPathComponent
guard leafName == fileName else { return nil }
let path = leafName as NSString
let stem = path.deletingPathExtension
let fileExtension = path.pathExtension.lowercased()
switch true {
case stem.hasPrefix("img_"):
guard fileExtension == "jpg" || fileExtension == "jpeg" else { return nil }
case stem.hasPrefix("voice_"):
guard fileExtension == "m4a" else { return nil }
default:
return nil
}
let entropyToken = stem.split(separator: "_").last.map(String.init)
let hasUUIDEntropy = entropyToken.flatMap(UUID.init(uuidString:)) != nil
let voiceBurstID = stem.hasPrefix("voice_")
? String(stem.dropFirst("voice_".count))
: ""
let hasBurstEntropy = voiceBurstID.count == 16
&& voiceBurstID.allSatisfy(\.isHexDigit)
guard hasUUIDEntropy || hasBurstEntropy else {
return nil
}
let fields = [
Data(senderPeerID.toShort().bare.utf8),
Data(recipientPeerID.toShort().bare.utf8),
Data(leafName.utf8)
]
var input = domain
for field in fields {
guard let length = UInt32(exactly: field.count) else { return nil }
var bigEndianLength = length.bigEndian
withUnsafeBytes(of: &bigEndianLength) {
input.append(contentsOf: $0)
}
input.append(field)
}
return "\(idPrefix)\(input.sha256Hex().prefix(digestHexLength))"
}
static func stableID(
for packet: BitchatFilePacket,
senderPeerID: PeerID,
recipientPeerID: PeerID
) -> String? {
stableID(
senderPeerID: senderPeerID,
recipientPeerID: recipientPeerID,
fileName: packet.fileName
)
}
}

View File

@ -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"

View File

@ -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

View File

@ -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
]
}

View File

@ -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

View File

@ -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)
}

View File

@ -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
}
}
}

View File

@ -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<String> = []
func reserve(
_ messageID: String,
receiptState: () -> BLEPrivateMediaReceiptState
) -> Reservation {
lock.lock()
defer { lock.unlock() }
if pending.contains(messageID) {
return .pending
}
switch receiptState() {
case .accepted(let existingURL):
return .accepted(existingURL)
case .tombstoned:
return .tombstoned
case .unavailable:
return .unavailable
case .absent:
break
}
pending.insert(messageID)
return .reserved
}
func finish(_ messageID: String) {
lock.lock()
defer { lock.unlock() }
pending.remove(messageID)
}
}
/// Orchestrates inbound file transfers: self-echo policy, sender display-name
@ -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 ?? "<empty>")' 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_<burstID>.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)
}
}

View File

@ -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
}

View File

@ -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<String> = []
var deletionReservations: [UUID: Set<String>] = [:]
}
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<String>()) {
$0.formUnion($1)
}
let allocationReservations = deletionPaths.union(
payloadCoordination.pendingDeliveryPaths
)
let destination = uniqueFileURL(
in: base,
fileName: sanitized,
reservedPaths: (reservedPaths ?? []).union(
allocationReservations
),
forceRandomizedName: reservedPaths == nil
)
try data.write(to: destination, options: .atomic)
payloadCoordination.pendingDeliveryPaths.insert(
destination.standardizedFileURL.path
)
return destination
} catch {
SecureLogger.error("❌ Failed to persist incoming media: \(error)", category: .session)
@ -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<String>
) -> Bool {
payloadCoordination.lock.lock()
defer {
payloadCoordination.deletionReservations.removeValue(
forKey: reservation.id
)
payloadCoordination.lock.unlock()
}
guard payloadCoordination.deletionReservations[reservation.id] != nil
else {
return false
}
return privateMediaReceipts.recordDeleted(
messageIDs: messageIDs,
payloadRelativePaths: payloadRelativePaths,
protectedPayloadRelativePaths: protectedPayloadRelativePaths
)
}
/// 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<String>()) {
$0.formUnion($1)
}
let protectedPaths = activeDeletionPaths.union(
payloadCoordination.pendingDeliveryPaths
)
var freedSpace: Int64 = 0
for file in allFiles.sorted(by: { $0.modified < $1.modified }) {
guard freedSpace < needToFree else { break }
guard !file.url.lastPathComponent.hasPrefix(Self.liveCapturePrefix) else { continue }
guard !protectedPaths.contains(
file.url.standardizedFileURL.path
) else {
continue
}
do {
try fileManager.removeItem(at: file.url)
freedSpace += file.size
@ -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<String>()) {
$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<String>,
forceRandomizedName: Bool
) -> URL {
let directoryPath = directory.standardizedFileURL.path
func isInsideDirectory(_ url: URL) -> Bool {
url.standardizedFileURL.path.hasPrefix(directoryPath + "/")
}
func isAvailable(_ url: URL) -> Bool {
!reservedPaths.contains(url.standardizedFileURL.path)
&& !fileManager.fileExists(atPath: url.path)
}
var candidate = directory.appendingPathComponent(fileName)
guard isInsideDirectory(candidate) else {
@ -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
}
}

View File

@ -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
)
}
}
}

View File

@ -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
}
}
}
}

View File

@ -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)

View File

@ -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()
}
}

View File

@ -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
}
}

View File

@ -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?,

View File

@ -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

View File

@ -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
)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -322,7 +322,7 @@ final class CourierStore {
/// Envelopes eligible to park on relays as bridge courier drops. Merely
/// offering one does not start its cooldown: the caller commits that only
/// after a relay explicitly accepts the event via NIP-20 OK.
/// after a relay explicitly accepts the event via NIP-01 `OK`.
func envelopesForBridgePublish(cooldown: TimeInterval) -> [CourierEnvelope] {
let date = now()
return queue.sync {

View File

@ -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<String>()
/// 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<String>] = [:]
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<PeerID>) {
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<String>, from snapshot: Snapshot) -> Snapshot {
@ -760,9 +793,28 @@ final class MessageOutboxStore {
return filtered
}
private static func removing(
_ messageIDsByPeer: [PeerID: Set<String>],
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 {

View File

@ -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
)
}

View File

@ -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.

View File

@ -45,6 +45,9 @@ final class GeohashPresenceService: ObservableObject {
private var subscriptions = Set<AnyCancellable>()
private var heartbeatTimer: GeohashPresenceTimerProtocol?
private var pendingBroadcastTasks: [UUID: Task<Void, Never>] = [:]
private var heartbeatGeneration: UInt64 = 0
private var started = false
private let availableChannelsProvider: () -> [GeohashChannel]
private let locationChanges: AnyPublisher<[GeohashChannel], Never>
private let torReadyPublisher: AnyPublisher<Void, Never>
@ -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
}
}

View File

@ -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<String>()
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,

View File

@ -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"

View File

@ -172,8 +172,8 @@ final class MessageDeduplicationService {
/// Cache for Nostr ACK deduplication (messageId:ackType:senderPubkey format)
private let nostrAckCache: LRUDeduplicationCache<Bool>
/// Optional cross-launch persistence for the Nostr event cache. NIP-59
/// randomizes gift-wrap timestamps, so DM subscriptions look back 24h and
/// 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()

View File

@ -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<Void, Never>?
private var bridgeDepositsInFlight = Set<String>()
private var bridgeDepositsInFlight = Set<PeerMessageKey>()
private var outbox: [PeerID: [QueuedMessage]] = [:]
/// Peer/message pairs whose latest router-owned transmission used an
/// already-established secure session and still await an ack. Peer scope
/// is required because message IDs are not globally unique across direct
/// conversations. This deliberately excludes messages handed to BLE while
/// a handshake is pending: BLE owns those sends and drains its queue after
/// authentication, so retrying them here would duplicate every normal
/// first-handshake DM.
private var secureTransmissions = Set<PeerMessageKey>()
// Outbox limits to prevent unbounded memory growth
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<PeerID>) {
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<PeerID>()
var retriedMessageIDs = Set<String>()
var outboxChanged = false
let currentDate = now()
var candidates: [Candidate] = []
for (aliasOrder, peerID) in peerIDAliases.enumerated() {
guard visitedPeerIDs.insert(peerID).inserted else { continue }
guard let queued = outbox[peerID], !queued.isEmpty,
let transport = connectedTransport(for: peerID),
transport.canDeliverSecurely(to: peerID) else {
continue
}
for (queueOrder, message) in queued.enumerated() {
let key = PeerMessageKey(peerID: peerID, messageID: message.messageID)
guard secureTransmissions.contains(key) else { continue }
candidates.append((
peerID: peerID,
message: message,
aliasOrder: aliasOrder,
queueOrder: queueOrder
))
}
}
// Conversation migration can leave retained messages split across the
// ephemeral and stable outbox keys. Merge both queues into one
// chronological stream so callback alias order cannot send newer mail
// ahead of older mail.
candidates.sort { lhs, rhs in
if lhs.message.timestamp != rhs.message.timestamp {
return lhs.message.timestamp < rhs.message.timestamp
}
if lhs.aliasOrder != rhs.aliasOrder {
return lhs.aliasOrder < rhs.aliasOrder
}
if lhs.queueOrder != rhs.queueOrder {
return lhs.queueOrder < rhs.queueOrder
}
return lhs.message.messageID < rhs.message.messageID
}
for candidate in candidates {
let peerID = candidate.peerID
let message = candidate.message
let key = PeerMessageKey(peerID: peerID, messageID: message.messageID)
guard retriedMessageIDs.insert(message.messageID).inserted,
secureTransmissions.contains(key),
queuedMessage(message.messageID, for: peerID) != nil,
let transport = connectedTransport(for: peerID),
transport.canDeliverSecurely(to: peerID) else {
continue
}
if currentDate.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds {
if removeQueuedMessage(message.messageID, for: peerID) {
dropMessage(message.messageID, for: peerID)
outboxChanged = true
}
continue
}
guard message.sendAttempts < Self.maxSendAttempts else {
SecureLogger.warning(
"📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) secure attempts",
category: .session
)
if removeQueuedMessage(message.messageID, for: peerID) {
dropMessage(message.messageID, for: peerID)
outboxChanged = true
}
continue
}
SecureLogger.debug(
"Auth retry -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))",
category: .session
)
transport.sendPrivateMessage(
message.content,
to: peerID,
recipientNickname: message.nickname,
messageID: message.messageID
)
metrics?.record(.outboxResent)
outboxChanged = incrementSendAttemptsIfQueued(message.messageID, for: peerID) || outboxChanged
}
if outboxChanged {
persistOutbox()
}
}
func flushOutbox(for peerID: PeerID) {
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() {

View File

@ -42,13 +42,18 @@ final class NetworkActivationService: ObservableObject {
private var cancellables = Set<AnyCancellable>()
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<LocationChannelManager.PermissionState, Never>
private let mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>
private let selectedChannelPublisher: AnyPublisher<ChannelID, Never>
private let permissionProvider: () -> LocationChannelManager.PermissionState
private let mutualFavoritesProvider: () -> Set<Data>
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<Set<Data>, Never>,
permissionProvider: @escaping () -> LocationChannelManager.PermissionState,
mutualFavoritesProvider: @escaping () -> Set<Data>,
selectedChannelPublisher: AnyPublisher<ChannelID, Never> = 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

View File

@ -27,6 +27,8 @@ protocol NetworkReachabilityMonitoring: AnyObject {
var reachabilityPublisher: AnyPublisher<Bool, Never> { 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<Bool, Never> {
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) {

View File

@ -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<Result>(
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

View File

@ -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).

View File

@ -0,0 +1,44 @@
//
// NotificationPrivacySettings.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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)
}
}

View File

@ -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) {

View File

@ -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)
}
}
}

View File

@ -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<Event, Never>()
@ -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 {

View File

@ -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<String>,
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 {

View File

@ -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 ~64256 KiB anyway. 512 KiB
// leaves an order-of-magnitude margin over anything we produce or expect
// while halving the URLSession default (1 MiB), so a hostile relay's
// worst-case buffered pile-up per connection is
// nostrInboundPerRelayBufferCap × 512 KiB = 128 MiB instead of 256 MiB.
static let nostrInboundMaxFrameBytes: Int = 512 * 1024
// Conversation store diagnostics (field observability)
// Sample interval for the periodic store-audit "OK" heartbeat line
@ -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

View File

@ -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.

View File

@ -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<PeerID>
) -> 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<PeerID>)
/// 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<PeerID>) -> 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<PeerID>
) -> 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<PeerID>) {
messageRouter.markDelivered(messageID, from: peerIDs)
}
func confirmPrivateMediaDelivery(_ messageID: String) {
mediaTransferCoordinator.confirmPrivateMediaDelivery(
messageID: messageID
)
}
func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set<PeerID>) -> Bool {
peerIDs.contains { peerID in
privateMessages(for: peerID).contains { message in
message.id == messageID && message.senderPeerID == myPeerID
}
}
}
}
@ -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<PeerID>
) -> 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
}

View File

@ -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)
}

File diff suppressed because it is too large Load Diff

View File

@ -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 {

View File

@ -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)
}

View File

@ -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

View File

@ -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<PeerID>
) -> 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<PeerID>
) -> 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<PeerID> {
var aliases: Set<PeerID> = [peerID]
// The active authenticated Noise key is authoritative. A cached
// ephemeralstable mapping can predate an identity replacement, so
// use it only when the live session cannot provide its static key.
if let keyData = context.noiseSessionPublicKeyData(for: peerID) {
aliases.insert(PeerID(hexData: keyData))
} else if let stablePeerID = context.cachedStablePeerID(for: peerID) {
aliases.insert(stablePeerID)
}
return aliases
}
}

View File

@ -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,

View File

@ -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<String>
let localPeerID: PeerID
let nickname: String
let outgoingMedia: [BitchatMessage]
}
/// Manages the application state and business logic for BitChat.
/// Acts as the primary coordinator between UI components and backend services,
/// implementing the BitchatDelegate protocol to handle network events.
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<String>] {
// Identity handoff removes the source conversation and inserts its
// rows elsewhere. The old source may then be recreated by a new
// arrival before journal I/O finishes, so always scan all direct
// conversations. Only IDs exclusive at command time may follow a
// migration; shared aliases remain outside the source.
var plan: [ConversationID: Set<String>] = [:]
for (conversationID, conversation) in
conversations.conversationsByID {
guard case .direct = conversationID else { continue }
let eligibleIDs = conversationID == selectedConversationID
? capturedMessageIDs
: capturedExclusiveIDs
let matchingIDs = Set(conversation.messages.map(\.id))
.intersection(eligibleIDs)
if !matchingIDs.isEmpty {
plan[conversationID] = matchingIDs
}
}
return plan
}
func hasRemainingCopy(
of messageID: String,
after plan: [ConversationID: Set<String>]
) -> Bool {
conversations.conversationsByID.contains { conversationID, conversation in
guard case .direct = conversationID else { return false }
return conversation.messages.contains { message in
message.id == messageID
&& plan[conversationID]?.contains(messageID) != true
}
}
}
@MainActor
func continueClear(
persisted: Bool,
durableStableIDs: Set<String>
) {
guard privateChatClearGeneration == generation else {
completion()
return
}
guard persisted else {
SecureLogger.error(
"Refusing to clear private chat without durable media tombstones peer=\(peerID.id.prefix(8))",
category: .session
)
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/<bundle_id>/
#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 truetrue,
// 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

View File

@ -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
}
}
}

View File

@ -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)

View File

@ -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
}
}

View File

@ -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,

View File

@ -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<Bool> {
Binding(
get: { locationChannelsModel.userTorEnabled },

View File

@ -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()

View File

@ -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<Bool>.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<Bool> {
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<Bool> {
Binding(
get: {
scenePhase == .active
&& voiceRecordingVM.showAlert
&& !hasModalPresentationBesidesVoiceAlert
},
set: { isPresented in
guard !isPresented,
scenePhase == .active,
!hasModalPresentationBesidesVoiceAlert else {
return
}
voiceRecordingVM.showAlert = false
}
)
}
var body: some View {
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

View File

@ -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<Bool> {
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<Bool> {
Binding(
get: {
scenePhase == .active
&& voiceRecordingVM.showAlert
&& !hasRootModalPresentationBesidesVoiceAlert
},
set: { isPresented in
guard !isPresented,
scenePhase == .active,
!hasRootModalPresentationBesidesVoiceAlert else {
return
}
voiceRecordingVM.showAlert = false
}
)
}
var body: some View {
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)
}
}

View File

@ -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)

View File

@ -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 denregistrer dans bitchat" } },
"he" : { "stringUnit" : { "state" : "needs_review", "value" : "לא ניתן לשמור ב-bitchat" } },
"hi" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat में सेव नहीं किया जा सका" } },
"id" : { "stringUnit" : { "state" : "needs_review", "value" : "Tidak dapat menyimpan ke bitchat" } },
"it" : { "stringUnit" : { "state" : "needs_review", "value" : "Impossibile salvare in bitchat" } },
"ja" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat に保存できませんでした" } },
"ko" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat에 저장할 수 없습니다" } },
"ms" : { "stringUnit" : { "state" : "needs_review", "value" : "Tidak dapat menyimpan ke bitchat" } },
"ne" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat मा सुरक्षित गर्न सकिएन" } },
"nl" : { "stringUnit" : { "state" : "needs_review", "value" : "Kon niet opslaan in bitchat" } },
"pl" : { "stringUnit" : { "state" : "needs_review", "value" : "Nie udało się zapisać w bitchat" } },
"pt" : { "stringUnit" : { "state" : "needs_review", "value" : "Não foi possível guardar no bitchat" } },
"pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "Não foi possível salvar no bitchat" } },
"ru" : { "stringUnit" : { "state" : "needs_review", "value" : "Не удалось сохранить в bitchat" } },
"sv" : { "stringUnit" : { "state" : "needs_review", "value" : "Kunde inte spara i bitchat" } },
"ta" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat-இல் சேமிக்க முடியவில்லை" } },
"th" : { "stringUnit" : { "state" : "needs_review", "value" : "บันทึกไปยัง bitchat ไม่ได้" } },
"tr" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchate kaydedilemedi" } },
"uk" : { "stringUnit" : { "state" : "needs_review", "value" : "Не вдалося зберегти в bitchat" } },
"ur" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat میں محفوظ نہیں ہو سکا" } },
"vi" : { "stringUnit" : { "state" : "needs_review", "value" : "Không thể lưu vào bitchat" } },
"zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "无法保存到 bitchat" } },
"zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "無法儲存到 bitchat" } }
}
},
"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 lapp pour vérifier" } },
"he" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ נשמר ב-bitchat — יש לפתוח את האפליקציה לבדיקה" } },
"hi" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat में सेव किया गया — समीक्षा के लिए ऐप खोलें" } },
"id" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Disimpan di bitchat — buka aplikasi untuk meninjau" } },
"it" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Salvato in bitchat — apri lapp per controllare" } },
"ja" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat に保存しました — アプリを開いて確認してください" } },
"ko" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat에 저장됨 — 앱을 열어 검토하세요" } },
"ms" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Disimpan dalam bitchat — buka aplikasi untuk menyemak" } },
"ne" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat मा सुरक्षित गरियो — समीक्षा गर्न एप खोल्नुहोस्" } },
"nl" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Opgeslagen in bitchat — open de app om te bekijken" } },
"pl" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Zapisano w bitchat — otwórz aplikację, aby sprawdzić" } },
"pt" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Guardado no bitchat — abra a app para rever" } },
"pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Salvo no bitchat — abra o app para revisar" } },
"ru" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Сохранено в bitchat — откройте приложение для проверки" } },
"sv" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Sparat i bitchat — öppna appen för att granska" } },
"ta" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat-இல் சேமிக்கப்பட்டது — மதிப்பாய்வு செய்ய செயலியைத் திறக்கவும்" } },
"th" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ บันทึกใน bitchat แล้ว — เปิดแอปเพื่อตรวจสอบ" } },
"tr" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchate kaydedildi — incelemek için uygulamayıı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 查看" } }
}
}
},

View File

@ -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) {

View File

@ -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)
}
}

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