mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 09:25:20 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55ee1df0bc | ||
|
|
b255355fed | ||
|
|
6e026c2c22 | ||
|
|
c8e330777a | ||
|
|
91aba8b597 | ||
|
|
d9a6dbfca8 | ||
|
|
68f8f03ad8 | ||
|
|
48026991b2 | ||
|
|
ec795520ee | ||
|
|
40238c5e43 | ||
|
|
a31cd80027 | ||
|
|
974510ad9e | ||
|
|
5aee7f0f98 | ||
|
|
6054248765 | ||
|
|
5f7df63238 | ||
|
|
b081c98dba | ||
|
|
76d3b0f1ed | ||
|
|
aa3021c9ca |
@@ -1,228 +1,42 @@
|
||||
name: Propose GeoRelay Data Update
|
||||
name: Fetch GeoRelays Data
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 6 * * 0"
|
||||
- cron: '0 6 * * 0'
|
||||
workflow_dispatch:
|
||||
|
||||
# Default to read-only. The publishing job receives only the scopes required
|
||||
# to push its branch and publish either a PR or a tracking issue.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: georelay-data-update
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
SOURCE_REPOSITORY: https://github.com/permissionlesstech/georelays.git
|
||||
UPDATE_BRANCH: automation/georelay-data
|
||||
TRACKING_ISSUE_TITLE: GeoRelay update awaiting pull request
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
propose-relay-data:
|
||||
name: Validate and propose relay data
|
||||
update-relay-data:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Checkout reviewed base
|
||||
# Pinned actions/checkout v5 so a mutable action tag cannot change the
|
||||
# code that receives this job's write-capable token.
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0
|
||||
# Do not expose the write token to fetch/validation subprocesses.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Test GeoRelay validator
|
||||
- name: Fetch GeoRelays
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 -m unittest discover -s scripts/tests -p "test_*.py" -v
|
||||
wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
||||
mv nostr_relays.csv ./relays/online_relays_gps.csv
|
||||
|
||||
- name: Fetch candidate over pinned HTTPS policy
|
||||
id: upstream
|
||||
- name: Check for changes
|
||||
id: git-check
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_commit=$(git ls-remote --refs "$SOURCE_REPOSITORY" refs/heads/main | awk 'NR == 1 { print $1 }')
|
||||
if [[ ! "$source_commit" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "::error::Could not resolve an immutable upstream commit"
|
||||
exit 1
|
||||
fi
|
||||
source_url="https://raw.githubusercontent.com/permissionlesstech/georelays/$source_commit/nostr_relays.csv"
|
||||
effective_url=$(curl --fail --show-error --silent --location --proto "=https" --proto-redir "=https" --tlsv1.2 --max-time 60 --retry 3 --retry-all-errors --output "$RUNNER_TEMP/georelays-candidate.csv" --write-out "%{url_effective}" "$source_url")
|
||||
if [[ "$effective_url" != "$source_url" ]]; then
|
||||
echo "::error::Unexpected GeoRelay redirect target: $effective_url"
|
||||
exit 1
|
||||
fi
|
||||
echo "source_commit=$source_commit" >> "$GITHUB_OUTPUT"
|
||||
echo "source_url=$source_url" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate candidate against reviewed baseline
|
||||
id: validation
|
||||
git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Commit and push changes
|
||||
if: steps.git-check.outputs.changes == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 scripts/validate_georelays.py --input "$RUNNER_TEMP/georelays-candidate.csv" --baseline relays/online_relays_gps.csv --output relays/online_relays_gps.csv --github-output "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check for a reviewed-file change
|
||||
id: changes
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git diff --quiet -- relays/online_relays_gps.csv; then
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Upstream GeoRelay data already matches main." >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
git diff --stat -- relays/online_relays_gps.csv
|
||||
fi
|
||||
|
||||
- name: Push automation branch and publish review request
|
||||
if: steps.changes.outputs.changed == 'true'
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add relays/online_relays_gps.csv
|
||||
git commit -m "Automated update of relay data - $(date -u)"
|
||||
git push
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
SOURCE_COMMIT: ${{ steps.upstream.outputs.source_commit }}
|
||||
SOURCE_URL: ${{ steps.upstream.outputs.source_url }}
|
||||
DATA_ROWS: ${{ steps.validation.outputs.data_rows }}
|
||||
UNIQUE_RELAYS: ${{ steps.validation.outputs.unique_relays }}
|
||||
DATA_SHA256: ${{ steps.validation.outputs.sha256 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Scope credential exposure to this final publishing step.
|
||||
gh auth setup-git
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
git switch -C "$UPDATE_BRANCH"
|
||||
git add -- relays/online_relays_gps.csv
|
||||
git diff --cached --quiet && {
|
||||
echo "::error::Expected a staged GeoRelay data change"
|
||||
exit 1
|
||||
}
|
||||
git commit -m "Update reviewed georelay directory" -m "Upstream-commit: $SOURCE_COMMIT"
|
||||
|
||||
remote_ref="refs/remotes/origin/$UPDATE_BRANCH"
|
||||
if git fetch --no-tags origin "+refs/heads/$UPDATE_BRANCH:$remote_ref" 2>/dev/null; then
|
||||
remote_sha=$(git rev-parse "$remote_ref")
|
||||
git push --force-with-lease="refs/heads/$UPDATE_BRANCH:$remote_sha" origin "HEAD:refs/heads/$UPDATE_BRANCH"
|
||||
else
|
||||
git push origin "HEAD:refs/heads/$UPDATE_BRANCH"
|
||||
fi
|
||||
|
||||
body_file="$RUNNER_TEMP/georelay-pr-body.md"
|
||||
{
|
||||
echo "## Automated GeoRelay data proposal"
|
||||
echo
|
||||
echo "- Source: $SOURCE_URL"
|
||||
echo "- Upstream commit: $SOURCE_COMMIT"
|
||||
echo "- Data rows: $DATA_ROWS"
|
||||
echo "- Unique normalized relays: $UNIQUE_RELAYS"
|
||||
echo "- SHA-256: $DATA_SHA256"
|
||||
echo
|
||||
echo "The candidate passed strict UTF-8, schema, size, row-count, secure-host, coordinate, duplicate-conflict, and baseline-delta validation."
|
||||
echo
|
||||
echo "This PR is intentionally not auto-merged. Review the relay additions/removals before merging."
|
||||
} > "$body_file"
|
||||
|
||||
existing_pr=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --base main --head "$UPDATE_BRANCH" --json number --jq '.[0].number // empty')
|
||||
pr_error="$RUNNER_TEMP/georelay-pr-error.txt"
|
||||
pr_url=""
|
||||
if [[ -n "$existing_pr" ]]; then
|
||||
if gh pr edit "$existing_pr" --repo "$GITHUB_REPOSITORY" --title "Update reviewed GeoRelay directory" --body-file "$body_file" 2> "$pr_error"; then
|
||||
pr_url=$(gh pr view "$existing_pr" --repo "$GITHUB_REPOSITORY" --json url --jq .url)
|
||||
fi
|
||||
else
|
||||
if created_pr_url=$(gh pr create --repo "$GITHUB_REPOSITORY" --base main --head "$UPDATE_BRANCH" --title "Update reviewed GeoRelay directory" --body-file "$body_file" 2> "$pr_error"); then
|
||||
pr_url="$created_pr_url"
|
||||
fi
|
||||
fi
|
||||
|
||||
tracking_issue_numbers=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "\"$TRACKING_ISSUE_TITLE\" in:title" --limit 100 --json number,title --jq ".[] | select(.title == \"$TRACKING_ISSUE_TITLE\") | .number")
|
||||
tracking_issues=()
|
||||
if [[ -n "$tracking_issue_numbers" ]]; then
|
||||
mapfile -t tracking_issues <<< "$tracking_issue_numbers"
|
||||
fi
|
||||
|
||||
if [[ -n "$pr_url" ]]; then
|
||||
for issue_number in "${tracking_issues[@]}"; do
|
||||
gh issue close "$issue_number" --repo "$GITHUB_REPOSITORY" --comment "A pull request is now available at $pr_url; closing this fallback tracking issue."
|
||||
done
|
||||
echo "Published GeoRelay review PR: $pr_url" >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::warning::GITHUB_TOKEN could not create or update the GeoRelay pull request; publishing the issues-write fallback."
|
||||
if [[ -s "$pr_error" ]]; then
|
||||
cat "$pr_error" >&2
|
||||
fi
|
||||
|
||||
compare_url="https://github.com/${GITHUB_REPOSITORY}/compare/main...${UPDATE_BRANCH}?expand=1"
|
||||
issue_body_file="$RUNNER_TEMP/georelay-tracking-issue-body.md"
|
||||
{
|
||||
echo "## Validated GeoRelay update awaiting review"
|
||||
echo
|
||||
echo "The automation branch was updated, but this workflow token could not create or update the pull request. Use the compare link below to create it manually."
|
||||
echo
|
||||
echo "- Compare and create PR: $compare_url"
|
||||
echo "- Automation branch: $UPDATE_BRANCH"
|
||||
echo "- Source: $SOURCE_URL"
|
||||
echo "- Upstream commit: $SOURCE_COMMIT"
|
||||
echo "- Data rows: $DATA_ROWS"
|
||||
echo "- Unique normalized relays: $UNIQUE_RELAYS"
|
||||
echo "- SHA-256: $DATA_SHA256"
|
||||
echo
|
||||
echo "The snapshot passed the repository's strict validator before the branch was pushed."
|
||||
} > "$issue_body_file"
|
||||
|
||||
if (( ${#tracking_issues[@]} > 0 )); then
|
||||
primary_issue="${tracking_issues[0]}"
|
||||
gh issue edit "$primary_issue" --repo "$GITHUB_REPOSITORY" --title "$TRACKING_ISSUE_TITLE" --body-file "$issue_body_file"
|
||||
issue_url=$(gh issue view "$primary_issue" --repo "$GITHUB_REPOSITORY" --json url --jq .url)
|
||||
for duplicate_issue in "${tracking_issues[@]:1}"; do
|
||||
gh issue close "$duplicate_issue" --repo "$GITHUB_REPOSITORY" --comment "Closing duplicate GeoRelay automation tracking issue; #$primary_issue is canonical."
|
||||
done
|
||||
else
|
||||
issue_url=$(gh issue create --repo "$GITHUB_REPOSITORY" --title "$TRACKING_ISSUE_TITLE" --body-file "$issue_body_file")
|
||||
fi
|
||||
|
||||
# Do not claim success until the fallback issue was confirmed.
|
||||
[[ -n "$issue_url" ]]
|
||||
echo "Published GeoRelay tracking issue fallback: $issue_url" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Clean obsolete automation review state
|
||||
if: steps.changes.outputs.changed == 'false'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh auth setup-git
|
||||
|
||||
existing_pr=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --base main --head "$UPDATE_BRANCH" --json number --jq '.[0].number // empty')
|
||||
if [[ -n "$existing_pr" ]]; then
|
||||
gh pr close "$existing_pr" --repo "$GITHUB_REPOSITORY" --comment "Upstream now matches the reviewed file on main; closing this obsolete automation proposal."
|
||||
echo "Closed obsolete PR #$existing_pr." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
tracking_issue_numbers=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "\"$TRACKING_ISSUE_TITLE\" in:title" --limit 100 --json number,title --jq ".[] | select(.title == \"$TRACKING_ISSUE_TITLE\") | .number")
|
||||
if [[ -n "$tracking_issue_numbers" ]]; then
|
||||
while IFS= read -r issue_number; do
|
||||
gh issue close "$issue_number" --repo "$GITHUB_REPOSITORY" --comment "Upstream now matches the reviewed file on main; closing this obsolete automation tracker."
|
||||
echo "Closed obsolete tracking issue #$issue_number." >> "$GITHUB_STEP_SUMMARY"
|
||||
done <<< "$tracking_issue_numbers"
|
||||
fi
|
||||
|
||||
if git ls-remote --exit-code --heads origin "refs/heads/$UPDATE_BRANCH" > /dev/null; then
|
||||
git push origin --delete "$UPDATE_BRANCH"
|
||||
echo "Deleted obsolete automation branch $UPDATE_BRANCH." >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
ls_remote_status=$?
|
||||
if (( ls_remote_status != 2 )); then
|
||||
echo "::error::Could not inspect the obsolete automation branch"
|
||||
exit "$ls_remote_status"
|
||||
fi
|
||||
fi
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -94,24 +94,6 @@ jobs:
|
||||
kill "$watchdog_pid" 2>/dev/null || true
|
||||
exit "$status"
|
||||
|
||||
# Read coverage before the serial benchmark command below rebuilds the
|
||||
# test binary without instrumentation. Reporting against that newer
|
||||
# binary makes llvm-cov reject the profile as out of date.
|
||||
# Informational only: there is deliberately no percentage threshold, but
|
||||
# a broken/missing report is a CI configuration error and must be visible.
|
||||
- name: Coverage summary
|
||||
run: |
|
||||
BIN_PATH=$(swift build --show-bin-path --package-path ${{ matrix.path }})
|
||||
PROF="$BIN_PATH/codecov/default.profdata"
|
||||
XCTEST=$(find "$BIN_PATH" -maxdepth 1 -name '*.xctest' | head -1)
|
||||
BINARY="$XCTEST/Contents/MacOS/$(basename "$XCTEST" .xctest)"
|
||||
if [ ! -f "$PROF" ] || [ ! -f "$BINARY" ]; then
|
||||
echo "::error::Coverage profile or test binary is missing"
|
||||
exit 1
|
||||
fi
|
||||
xcrun llvm-cov report "$BINARY" -instr-profile "$PROF" \
|
||||
-ignore-filename-regex='(Tests|\.build|checkouts|Mocks|_PreviewHelpers)'
|
||||
|
||||
# Benchmarks run serially on an otherwise idle runner for stable
|
||||
# numbers; BITCHAT_PERF_LOG captures the PERF[...] lines for the gate.
|
||||
- name: Run performance benchmarks (serial)
|
||||
@@ -133,6 +115,22 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
run: ./scripts/check-perf-floors.sh perf-output.log
|
||||
|
||||
# Informational only: surfaces per-file and total line coverage in the
|
||||
# job log so coverage trends are visible on every PR. No thresholds —
|
||||
# this must never be the reason a build goes red.
|
||||
- name: Coverage summary
|
||||
run: |
|
||||
BIN_PATH=$(swift build --show-bin-path --package-path ${{ matrix.path }})
|
||||
PROF="$BIN_PATH/codecov/default.profdata"
|
||||
XCTEST=$(find "$BIN_PATH" -maxdepth 1 -name '*.xctest' | head -1)
|
||||
BINARY="$XCTEST/Contents/MacOS/$(basename "$XCTEST" .xctest)"
|
||||
if [ -f "$PROF" ] && [ -f "$BINARY" ]; then
|
||||
xcrun llvm-cov report "$BINARY" -instr-profile "$PROF" \
|
||||
-ignore-filename-regex='(Tests|\.build|checkouts|Mocks|_PreviewHelpers)' || true
|
||||
else
|
||||
echo "No coverage data found; skipping summary."
|
||||
fi
|
||||
|
||||
# SPM tests do not link the shipping app targets. This job covers the
|
||||
# iOS-conditional paths and both universal Release link configurations.
|
||||
ios-build:
|
||||
@@ -144,9 +142,6 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Check clean recipe safety
|
||||
run: bash scripts/check-just-clean-safety.sh
|
||||
|
||||
- name: Build iOS (simulator, no signing)
|
||||
# Build both simulator architectures so CI validates every vendored
|
||||
# Arti simulator slice and the configuration that ships.
|
||||
@@ -174,52 +169,6 @@ jobs:
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
build
|
||||
|
||||
# The SwiftPM matrix runs on macOS and cannot execute UIKit/CoreBluetooth
|
||||
# conditional tests. Build the shared iOS test target and run it on the first
|
||||
# available iPhone simulator from the runner image instead of hard-coding a
|
||||
# model that changes when GitHub updates Xcode. The suite intentionally runs
|
||||
# in one test runner: a number of integration tests exercise process-global
|
||||
# stores and notification centers, so overlapping workers can corrupt each
|
||||
# other's fixtures and turn sub-second tests into multi-minute timeouts.
|
||||
ios-tests:
|
||||
name: Run iOS simulator tests
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Select available iPhone simulator
|
||||
id: destination
|
||||
run: |
|
||||
destinations=$(xcodebuild -project bitchat.xcodeproj -scheme "bitchat (iOS)" -showdestinations)
|
||||
destination_id=$(awk -F'id:' '
|
||||
/platform:iOS Simulator/ && /name:iPhone/ && !found {
|
||||
value=$2
|
||||
sub(/,.*/, "", value)
|
||||
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
|
||||
print value
|
||||
found=1
|
||||
}
|
||||
' <<< "$destinations")
|
||||
if [ -z "$destination_id" ]; then
|
||||
echo "::error::No available iPhone simulator destination found"
|
||||
exit 1
|
||||
fi
|
||||
echo "id=$destination_id" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Run iOS tests
|
||||
run: |
|
||||
set -o pipefail
|
||||
xcodebuild -project bitchat.xcodeproj \
|
||||
-scheme "bitchat (iOS)" \
|
||||
-sdk iphonesimulator \
|
||||
-destination "platform=iOS Simulator,id=${{ steps.destination.outputs.id }}" \
|
||||
-parallel-testing-enabled NO \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
test
|
||||
|
||||
# Advisory only: SwiftLint reports style violations without ever failing the
|
||||
# build. Runs in a pinned container (no Xcode plugin, no pbxproj changes) so
|
||||
# it can never break the documented xcodebuild path or block a merge.
|
||||
|
||||
@@ -3,6 +3,3 @@ DEVELOPMENT_TEAM = ABC123
|
||||
|
||||
// Unique bundle id to be able to register and run locally
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.$(DEVELOPMENT_TEAM)
|
||||
|
||||
// App and share extension must use an App Group registered to your team.
|
||||
APP_GROUP_ID = group.chat.bitchat.$(DEVELOPMENT_TEAM)
|
||||
|
||||
@@ -1,66 +1,107 @@
|
||||
# BitChat developer commands
|
||||
#
|
||||
# Builds use a repository-local, ignored DerivedData directory. No recipe
|
||||
# patches, restores, or removes tracked project/configuration files.
|
||||
|
||||
project := "bitchat.xcodeproj"
|
||||
macos_scheme := "bitchat (macOS)"
|
||||
ios_scheme := "bitchat (iOS)"
|
||||
derived_data := ".DerivedData"
|
||||
# BitChat macOS Build Justfile
|
||||
# Handles temporary modifications needed to build and run on macOS
|
||||
|
||||
# Default recipe - shows available commands
|
||||
default:
|
||||
@echo "BitChat developer commands:"
|
||||
@echo " just run Build and run the macOS app"
|
||||
@echo " just build Build the macOS app without signing"
|
||||
@echo " just test Run the SwiftPM test suite"
|
||||
@echo " just test-ios Run tests on the iPhone 17 simulator"
|
||||
@echo " just clean Remove repo-local build artifacts only"
|
||||
@echo " just nuke Also remove nested package build caches"
|
||||
@echo " just check Validate the development environment"
|
||||
@echo "BitChat macOS Build Commands:"
|
||||
@echo " just run - Build and run the macOS app"
|
||||
@echo " just build - Build the macOS app only"
|
||||
@echo " just clean - Clean build artifacts and restore original files"
|
||||
@echo " just check - Check prerequisites"
|
||||
@echo ""
|
||||
@echo "Original files are preserved - modifications are temporary for builds only"
|
||||
|
||||
# Static guard against reintroducing source-restoring or source-deleting clean
|
||||
# behavior. CI runs the same script directly.
|
||||
check-clean-safety:
|
||||
@bash scripts/check-just-clean-safety.sh
|
||||
|
||||
check: check-clean-safety
|
||||
# Check prerequisites
|
||||
check:
|
||||
@echo "Checking prerequisites..."
|
||||
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install full Xcode." && exit 1)
|
||||
@developer_dir="$$(xcode-select -p 2>/dev/null)"; case "$$developer_dir" in *.app/Contents/Developer) ;; *) echo "❌ Full Xcode is not selected. Run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer"; exit 1;; esac
|
||||
@xcodebuild -version
|
||||
@echo "✅ Development environment ready (a signing identity is not required for just build)"
|
||||
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install Xcode from App Store" && exit 1)
|
||||
@xcode-select -p | grep -q "Xcode.app" || (echo "❌ Full Xcode required, not just command line tools. Install from App Store and run:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
|
||||
@test -d "/Applications/Xcode.app" || (echo "❌ Xcode.app not found in Applications folder. Install from App Store" && exit 1)
|
||||
@xcodebuild -version >/dev/null 2>&1 || (echo "❌ Xcode not properly configured. Try:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
|
||||
@security find-identity -v -p codesigning | grep -q "Apple Development\|Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
|
||||
@echo "✅ All prerequisites met"
|
||||
|
||||
build: check
|
||||
# Backup original files
|
||||
backup:
|
||||
@echo "Backing up original project configuration..."
|
||||
@if [ -f bitchat.xcodeproj/project.pbxproj ]; then cp bitchat.xcodeproj/project.pbxproj bitchat.xcodeproj/project.pbxproj.backup; fi
|
||||
@if [ -f bitchat/Info.plist ]; then cp bitchat/Info.plist bitchat/Info.plist.backup; fi
|
||||
|
||||
# Restore original files
|
||||
restore:
|
||||
@echo "Restoring original project configuration..."
|
||||
@if [ -f project.yml.backup ]; then mv project.yml.backup project.yml; fi
|
||||
@# Restore iOS-specific files
|
||||
@if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi
|
||||
@# Use git to restore all modified files except Justfile
|
||||
@git checkout -- project.yml bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Could not restore some files with git"
|
||||
@# Remove any backup files
|
||||
@rm -f bitchat.xcodeproj/project.pbxproj.backup bitchat/Info.plist.backup 2>/dev/null || true
|
||||
|
||||
# Apply macOS-specific modifications
|
||||
patch-for-macos: backup
|
||||
@echo "Temporarily hiding iOS-specific files for macOS build..."
|
||||
@# Move iOS-specific files out of the way temporarily
|
||||
@if [ -f bitchat/LaunchScreen.storyboard ]; then mv bitchat/LaunchScreen.storyboard bitchat/LaunchScreen.storyboard.ios; fi
|
||||
|
||||
# Build the macOS app
|
||||
build: #check generate
|
||||
@echo "Building BitChat for macOS..."
|
||||
@xcodebuild -project "{{project}}" -scheme "{{macos_scheme}}" -configuration Debug -derivedDataPath "{{derived_data}}" CODE_SIGNING_ALLOWED=NO build
|
||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
||||
|
||||
# Run the macOS app
|
||||
run: build
|
||||
@app="{{derived_data}}/Build/Products/Debug/bitchat.app"; test -d "$$app" || (echo "❌ Built app not found at $$app" && exit 1); open "$$app"
|
||||
@echo "Launching BitChat..."
|
||||
@find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}"
|
||||
|
||||
# Backward-compatible alias for the old quick-run recipe.
|
||||
dev-run: run
|
||||
# Clean build artifacts and restore original files
|
||||
clean: restore
|
||||
@echo "Cleaning build artifacts..."
|
||||
@rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true
|
||||
@# Only remove the generated project if we have a backup, otherwise use git
|
||||
@if [ -f bitchat.xcodeproj/project.pbxproj.backup ]; then \
|
||||
rm -rf bitchat.xcodeproj; \
|
||||
else \
|
||||
git checkout -- bitchat.xcodeproj/project.pbxproj 2>/dev/null || echo "⚠️ Could not restore project.pbxproj"; \
|
||||
fi
|
||||
@rm -f project-macos.yml 2>/dev/null || true
|
||||
@echo "✅ Cleaned and restored original files"
|
||||
|
||||
test:
|
||||
@swift test
|
||||
|
||||
test-ios: check
|
||||
@xcodebuild -project "{{project}}" -scheme "{{ios_scheme}}" -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' -derivedDataPath "{{derived_data}}" test
|
||||
|
||||
# Artifact-only cleanup. In particular, this recipe never invokes Git and
|
||||
# never writes, moves, restores, or removes source/configuration files.
|
||||
clean:
|
||||
@echo "Cleaning repo-local build artifacts..."
|
||||
@rm -rf -- "{{derived_data}}" ".build"
|
||||
@echo "✅ Cleaned {{derived_data}} and .build; tracked files were untouched"
|
||||
|
||||
# Retain the familiar command, but keep it artifact-only as well.
|
||||
nuke: clean
|
||||
@echo "Cleaning nested package build caches..."
|
||||
@find localPackages -type d -name .build -prune -exec rm -rf -- {} +
|
||||
@rm -rf -- ".cache"
|
||||
@echo "✅ Removed repository build caches; tracked files were untouched"
|
||||
# Quick run without cleaning (for development)
|
||||
dev-run: check
|
||||
@echo "Quick development build..."
|
||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
||||
@find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}"
|
||||
|
||||
# Show app info
|
||||
info:
|
||||
@echo "BitChat - decentralized mesh messaging"
|
||||
@echo "macOS 13+ and iOS 16+"
|
||||
@echo "Bluetooth mesh behavior requires physical Bluetooth-capable devices"
|
||||
@echo "BitChat - Decentralized Mesh Messaging"
|
||||
@echo "======================================"
|
||||
@echo "• Native macOS SwiftUI app"
|
||||
@echo "• Bluetooth LE mesh networking"
|
||||
@echo "• End-to-end encryption"
|
||||
@echo "• No internet required"
|
||||
@echo "• Works offline with nearby devices"
|
||||
@echo ""
|
||||
@echo "Requirements:"
|
||||
@echo "• macOS 13.0+ (Ventura)"
|
||||
@echo "• Bluetooth LE capable Mac"
|
||||
@echo "• Physical device (no simulator support)"
|
||||
@echo ""
|
||||
@echo "Usage:"
|
||||
@echo "• Set nickname and start chatting"
|
||||
@echo "• Use /join #channel for group chats"
|
||||
@echo "• Use /msg @user for private messages"
|
||||
@echo "• Triple-tap logo for emergency wipe"
|
||||
|
||||
# Force clean everything (nuclear option)
|
||||
nuke:
|
||||
@echo "🧨 Nuclear clean - removing all build artifacts and backups..."
|
||||
@rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true
|
||||
@rm -rf bitchat.xcodeproj 2>/dev/null || true
|
||||
@rm -f bitchat.xcodeproj/project.pbxproj.backup 2>/dev/null || true
|
||||
@rm -f bitchat/Info.plist.backup 2>/dev/null || true
|
||||
@# Restore iOS-specific files if they were moved
|
||||
@if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi
|
||||
@git checkout bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Not a git repo or no changes to restore"
|
||||
@echo "✅ Nuclear clean complete"
|
||||
|
||||
@@ -93,62 +93,30 @@ For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.m
|
||||
|
||||
### Option 1: Using Xcode
|
||||
|
||||
```bash
|
||||
open bitchat.xcodeproj
|
||||
```
|
||||
```bash
|
||||
cd bitchat
|
||||
open bitchat.xcodeproj
|
||||
```
|
||||
|
||||
For a signed device build, create your ignored local configuration and replace
|
||||
the example team ID with your Apple Developer Team ID:
|
||||
|
||||
```bash
|
||||
cp Configs/Local.xcconfig.example Configs/Local.xcconfig
|
||||
```
|
||||
|
||||
`Local.xcconfig.example` derives unique app and App Group identifiers from that
|
||||
team ID. The entitlement files already reference `$(APP_GROUP_ID)`, so tracked
|
||||
project or entitlement files do not need to be edited.
|
||||
|
||||
Useful command-line checks from the repository root:
|
||||
|
||||
```bash
|
||||
# macOS Debug build without signing
|
||||
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" \
|
||||
-configuration Debug CODE_SIGNING_ALLOWED=NO build
|
||||
|
||||
# Full SwiftPM test suite
|
||||
swift test
|
||||
|
||||
# iOS simulator tests
|
||||
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (iOS)" \
|
||||
-sdk iphonesimulator \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 17' test
|
||||
```
|
||||
|
||||
If `iPhone 17` is unavailable, choose an installed simulator from:
|
||||
|
||||
```bash
|
||||
xcodebuild -showdestinations -project bitchat.xcodeproj -scheme "bitchat (iOS)"
|
||||
```
|
||||
To run on a device there're a few steps to prepare the code:
|
||||
- Clone the local configs: `cp Configs/Local.xcconfig.example Configs/Local.xcconfig`
|
||||
- Add your Developer Team ID into the newly created `Configs/Local.xcconfig`
|
||||
- Bundle ID would be set to `chat.bitchat.<team_id>` (unless you set to something else)
|
||||
- Entitlements need to be updated manually (TODO: Automate):
|
||||
- Search and replace `group.chat.bitchat` with `group.<your_bundle_id>` (e.g. `group.chat.bitchat.ABC123`)
|
||||
|
||||
### Option 2: Using `just`
|
||||
|
||||
```bash
|
||||
brew install just
|
||||
just check
|
||||
just run
|
||||
```
|
||||
```bash
|
||||
brew install just
|
||||
```
|
||||
|
||||
`just build` and `just run` use the current `bitchat (macOS)` scheme and keep
|
||||
Xcode output in the ignored `.DerivedData/` directory. They never patch source,
|
||||
project, configuration, or entitlement files.
|
||||
|
||||
`just clean` removes only `.DerivedData/` and `.build/`. It does not invoke Git
|
||||
or restore tracked files, so uncommitted work is preserved. `just test` runs the
|
||||
SwiftPM suite and `just test-ios` runs the iPhone 17 simulator suite.
|
||||
Want to try this on macos: `just run` will set it up and run from source.
|
||||
Run `just clean` afterwards to restore things to original state for mobile app building and development.
|
||||
|
||||
## Localization
|
||||
|
||||
- App localizations live in `bitchat/Localizable.xcstrings`.
|
||||
- Share extension strings are separate in `bitchatShareExtension/Localization/Localizable.xcstrings`.
|
||||
- Base app resources live under `bitchat/Localization/Base.lproj/`. Add new copy to `Localizable.strings` and plural rules to `Localizable.stringsdict`.
|
||||
- Share extension strings are separate in `bitchatShareExtension/Localization/Base.lproj/Localizable.strings`.
|
||||
- Prefer keys that describe intent (`app_info.features.offline.title`) and reuse existing ones where possible.
|
||||
- Run `xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGNING_ALLOWED=NO build` to compile-check any localization updates.
|
||||
|
||||
@@ -39,17 +39,15 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
@Published private(set) var messages: [BitchatMessage] = []
|
||||
@Published private(set) var isUnread: Bool = false
|
||||
|
||||
/// Incrementally-maintained message-ID → logical-index map for O(1)
|
||||
/// dedup and delivery-status lookup. Logical indexes are physical array
|
||||
/// indexes plus `indexOffset`; trimming from the head advances the offset
|
||||
/// instead of rewriting every surviving dictionary entry. This matters
|
||||
/// after the 1337-message cap is reached, when every steady-state tail
|
||||
/// append evicts one old row.
|
||||
///
|
||||
/// Out-of-order inserts and middle removals still reindex only the
|
||||
/// affected suffix. Full filtering resets the offset while rebuilding.
|
||||
/// Incrementally-maintained message-ID → index map for O(1) dedup and
|
||||
/// delivery-status lookup. Kept in sync on every mutation:
|
||||
/// - tail append: single insert
|
||||
/// - out-of-order insert: suffix reindex from the insertion point
|
||||
/// - trim: full rebuild — `removeFirst(k)` is already O(n), so the
|
||||
/// rebuild does not change the asymptotics, and trim only happens once
|
||||
/// the cap (1337) is reached. Simple and correct beats the
|
||||
/// offset-tracking alternative here.
|
||||
private var indexByMessageID: [String: Int] = [:]
|
||||
private var indexOffset = 0
|
||||
|
||||
fileprivate init(id: ConversationID, cap: Int) {
|
||||
self.id = id
|
||||
@@ -63,7 +61,7 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
}
|
||||
|
||||
func message(withID messageID: String) -> BitchatMessage? {
|
||||
guard let index = physicalIndex(forMessageID: messageID) else { return nil }
|
||||
guard let index = indexByMessageID[messageID] else { return nil }
|
||||
return messages[index]
|
||||
}
|
||||
|
||||
@@ -103,7 +101,7 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
reindex(from: index)
|
||||
} else {
|
||||
messages.append(message)
|
||||
indexByMessageID[message.id] = indexOffset + messages.count - 1
|
||||
indexByMessageID[message.id] = messages.count - 1
|
||||
}
|
||||
|
||||
return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded())
|
||||
@@ -113,7 +111,7 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
/// timeline position (in-place updates like media progress reuse the
|
||||
/// original timestamp); a new message goes through ordered insertion.
|
||||
fileprivate func upsert(_ message: BitchatMessage) -> UpsertOutcome {
|
||||
if let index = physicalIndex(forMessageID: message.id) {
|
||||
if let index = indexByMessageID[message.id] {
|
||||
messages[index] = message
|
||||
return .updated
|
||||
}
|
||||
@@ -127,7 +125,7 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
/// `.read` is never downgraded to `.delivered` or `.sent`.
|
||||
/// Returns `true` when the status was applied.
|
||||
fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||
guard let index = physicalIndex(forMessageID: messageID) else { return false }
|
||||
guard let index = indexByMessageID[messageID] else { return false }
|
||||
let message = messages[index]
|
||||
guard !Self.shouldSkipStatusUpdate(current: message.deliveryStatus, new: status) else { return false }
|
||||
|
||||
@@ -144,7 +142,7 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
/// observers still need an @Published emission to re-render.
|
||||
@discardableResult
|
||||
fileprivate func republishMessage(withID messageID: String) -> Bool {
|
||||
guard let index = physicalIndex(forMessageID: messageID) else { return false }
|
||||
guard let index = indexByMessageID[messageID] else { return false }
|
||||
messages[index] = messages[index]
|
||||
return true
|
||||
}
|
||||
@@ -159,14 +157,10 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
/// Removes a single message by ID. Returns the removed message, or
|
||||
/// `nil` when no message with that ID exists.
|
||||
fileprivate func remove(messageID: String) -> BitchatMessage? {
|
||||
guard let index = physicalIndex(forMessageID: messageID) else { return nil }
|
||||
guard let index = indexByMessageID[messageID] else { return nil }
|
||||
let removed = messages.remove(at: index)
|
||||
indexByMessageID.removeValue(forKey: messageID)
|
||||
if index == 0 {
|
||||
indexOffset += 1
|
||||
} else {
|
||||
reindex(from: index)
|
||||
}
|
||||
reindex(from: index)
|
||||
return removed
|
||||
}
|
||||
|
||||
@@ -183,7 +177,6 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
for id in removedIDs {
|
||||
indexByMessageID.removeValue(forKey: id)
|
||||
}
|
||||
indexOffset = 0
|
||||
reindex(from: 0)
|
||||
return removedIDs
|
||||
}
|
||||
@@ -191,7 +184,6 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
fileprivate func clearMessages() {
|
||||
messages.removeAll()
|
||||
indexByMessageID.removeAll()
|
||||
indexOffset = 0
|
||||
}
|
||||
|
||||
// MARK: Diagnostics
|
||||
@@ -213,10 +205,9 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
let message = messages[position]
|
||||
// Count equality + every message resolving to its own position
|
||||
// proves the index is exactly the inverse map (no stale extras).
|
||||
if let logicalIndex = indexByMessageID[message.id] {
|
||||
let expectedIndex = indexOffset + position
|
||||
if logicalIndex != expectedIndex {
|
||||
violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(logicalIndex - indexOffset)")
|
||||
if let index = indexByMessageID[message.id] {
|
||||
if index != position {
|
||||
violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(index)")
|
||||
}
|
||||
} else {
|
||||
violations.append("\(label): message \(message.id.prefix(8))… at \(position) missing from index")
|
||||
@@ -278,17 +269,10 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
|
||||
private func reindex(from start: Int) {
|
||||
for index in start..<messages.count {
|
||||
indexByMessageID[messages[index].id] = indexOffset + index
|
||||
indexByMessageID[messages[index].id] = index
|
||||
}
|
||||
}
|
||||
|
||||
private func physicalIndex(forMessageID messageID: String) -> Int? {
|
||||
guard let logicalIndex = indexByMessageID[messageID] else { return nil }
|
||||
let index = logicalIndex - indexOffset
|
||||
guard messages.indices.contains(index) else { return nil }
|
||||
return index
|
||||
}
|
||||
|
||||
/// Trims oldest messages over the cap; returns the trimmed message IDs.
|
||||
private func trimIfNeeded() -> [String] {
|
||||
guard messages.count > cap else { return [] }
|
||||
@@ -298,7 +282,7 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
indexByMessageID.removeValue(forKey: id)
|
||||
}
|
||||
messages.removeFirst(overflow)
|
||||
indexOffset += overflow
|
||||
reindex(from: 0)
|
||||
return trimmedIDs
|
||||
}
|
||||
}
|
||||
@@ -860,8 +844,8 @@ extension Conversation {
|
||||
/// (positions 0 and 1 swap their index entries). Requires >= 2 messages.
|
||||
func _testCorruptIndexEntries() {
|
||||
guard messages.count >= 2 else { return }
|
||||
indexByMessageID[messages[0].id] = indexOffset + 1
|
||||
indexByMessageID[messages[1].id] = indexOffset
|
||||
indexByMessageID[messages[0].id] = 1
|
||||
indexByMessageID[messages[1].id] = 0
|
||||
}
|
||||
|
||||
/// Drops a message's index entry entirely (count mismatch + missing).
|
||||
@@ -875,8 +859,8 @@ extension Conversation {
|
||||
func _testCorruptOrderingPreservingIndex() {
|
||||
guard messages.count >= 2 else { return }
|
||||
messages.swapAt(0, messages.count - 1)
|
||||
indexByMessageID[messages[0].id] = indexOffset
|
||||
indexByMessageID[messages[messages.count - 1].id] = indexOffset + messages.count - 1
|
||||
indexByMessageID[messages[0].id] = 0
|
||||
indexByMessageID[messages[messages.count - 1].id] = messages.count - 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -916,7 +900,7 @@ extension ConversationStore {
|
||||
extension Conversation {
|
||||
fileprivate func _testAppendBypassingTrim(_ message: BitchatMessage) {
|
||||
messages.append(message)
|
||||
indexByMessageID[message.id] = indexOffset + messages.count - 1
|
||||
indexByMessageID[message.id] = messages.count - 1
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -189,6 +189,18 @@ 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
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
@@ -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.
|
||||
@@ -157,6 +165,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
// 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
|
||||
@@ -214,6 +223,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.
|
||||
@@ -370,6 +380,66 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
return 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.cryptographicIdentities[fingerprint] {
|
||||
cryptoIdentity.signingPublicKey = signingPublicKey
|
||||
self.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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -6,17 +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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -13,3 +13,9 @@ enum NoiseSessionError: Error, Equatable {
|
||||
case alreadyEstablished
|
||||
case peerIdentityMismatch
|
||||
}
|
||||
|
||||
/// The manager owns the exact attempt's one bounded recovery. Packet handling
|
||||
/// must not launch its historical second, immediate restart for this failure.
|
||||
struct NoiseManagedHandshakeFailure: Error {
|
||||
let underlying: Error
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -32,23 +32,6 @@ struct GeoRelayDirectoryDependencies {
|
||||
var retrySleep: (TimeInterval) async -> Void
|
||||
var activeNotificationName: Notification.Name?
|
||||
var autoStart: Bool
|
||||
var validationPolicy: GeoRelayDirectoryValidationPolicy
|
||||
}
|
||||
|
||||
struct GeoRelayDirectoryValidationPolicy: Sendable {
|
||||
let maximumBytes: Int
|
||||
let maximumRows: Int
|
||||
let maximumEntries: Int
|
||||
let minimumRemoteEntries: Int
|
||||
let minimumRetainedFraction: Double
|
||||
|
||||
static let live = GeoRelayDirectoryValidationPolicy(
|
||||
maximumBytes: 512 * 1024,
|
||||
maximumRows: 5_000,
|
||||
maximumEntries: 5_000,
|
||||
minimumRemoteEntries: 50,
|
||||
minimumRetainedFraction: 0.5
|
||||
)
|
||||
}
|
||||
|
||||
private extension GeoRelayDirectoryDependencies {
|
||||
@@ -61,16 +44,12 @@ private extension GeoRelayDirectoryDependencies {
|
||||
#else
|
||||
let activeNotificationName: Notification.Name? = nil
|
||||
#endif
|
||||
let validationPolicy = GeoRelayDirectoryValidationPolicy.live
|
||||
|
||||
return Self(
|
||||
userDefaults: .standard,
|
||||
notificationCenter: .default,
|
||||
now: Date.init,
|
||||
// Runtime refreshes only from bitchat's reviewed copy. Upstream
|
||||
// georelays/main is imported by a validator-backed pull request,
|
||||
// so an upstream mutation cannot immediately retarget clients.
|
||||
remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/bitchat/refs/heads/main/relays/online_relays_gps.csv")!,
|
||||
remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!,
|
||||
fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds,
|
||||
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
|
||||
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
|
||||
@@ -79,27 +58,7 @@ private extension GeoRelayDirectoryDependencies {
|
||||
makeFetchData: {
|
||||
let session = TorURLSession.shared.session
|
||||
return { request in
|
||||
let (bytes, response) = try await session.bytes(for: request)
|
||||
guard let response = response as? HTTPURLResponse,
|
||||
(200...299).contains(response.statusCode),
|
||||
response.url == request.url else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
|
||||
let maximumBytes = validationPolicy.maximumBytes
|
||||
guard response.expectedContentLength <= Int64(maximumBytes) else {
|
||||
throw URLError(.dataLengthExceedsMaximum)
|
||||
}
|
||||
var data = Data()
|
||||
if response.expectedContentLength > 0 {
|
||||
data.reserveCapacity(Int(response.expectedContentLength))
|
||||
}
|
||||
for try await byte in bytes {
|
||||
guard data.count < maximumBytes else {
|
||||
throw URLError(.dataLengthExceedsMaximum)
|
||||
}
|
||||
data.append(byte)
|
||||
}
|
||||
let (data, _) = try await session.data(for: request)
|
||||
return data
|
||||
}
|
||||
},
|
||||
@@ -117,11 +76,7 @@ private extension GeoRelayDirectoryDependencies {
|
||||
)
|
||||
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
// v2 ignores caches populated from the old direct-upstream
|
||||
// trust path and subjects every load to strict validation.
|
||||
let legacyCache = dir.appendingPathComponent("georelays_cache.csv")
|
||||
try? FileManager.default.removeItem(at: legacyCache)
|
||||
return dir.appendingPathComponent("georelays_cache_v2.csv")
|
||||
return dir.appendingPathComponent("georelays_cache.csv")
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
@@ -139,8 +94,7 @@ private extension GeoRelayDirectoryDependencies {
|
||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||
},
|
||||
activeNotificationName: activeNotificationName,
|
||||
autoStart: true,
|
||||
validationPolicy: validationPolicy
|
||||
autoStart: true
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -171,7 +125,7 @@ final class GeoRelayDirectory {
|
||||
}
|
||||
|
||||
private enum DetachedFetchOutcome: Sendable {
|
||||
case success(entries: [Entry], csv: Data)
|
||||
case success(entries: [Entry], csv: String)
|
||||
case torNotReady
|
||||
case invalidData
|
||||
case network(String)
|
||||
@@ -258,8 +212,6 @@ final class GeoRelayDirectory {
|
||||
)
|
||||
let awaitTorReady = dependencies.awaitTorReady
|
||||
let fetchData = dependencies.makeFetchData()
|
||||
let validationPolicy = dependencies.validationPolicy
|
||||
let baselineEntries = Set(entries)
|
||||
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
@@ -267,9 +219,7 @@ final class GeoRelayDirectory {
|
||||
let outcome = await Self.fetchRemoteOutcome(
|
||||
request: request,
|
||||
awaitTorReady: awaitTorReady,
|
||||
fetchData: fetchData,
|
||||
validationPolicy: validationPolicy,
|
||||
baselineEntries: baselineEntries
|
||||
fetchData: fetchData
|
||||
)
|
||||
|
||||
switch outcome {
|
||||
@@ -288,9 +238,7 @@ final class GeoRelayDirectory {
|
||||
nonisolated private static func fetchRemoteOutcome(
|
||||
request: URLRequest,
|
||||
awaitTorReady: @escaping @Sendable () async -> Bool,
|
||||
fetchData: @escaping @Sendable (URLRequest) async throws -> Data,
|
||||
validationPolicy: GeoRelayDirectoryValidationPolicy,
|
||||
baselineEntries: Set<Entry>
|
||||
fetchData: @escaping @Sendable (URLRequest) async throws -> Data
|
||||
) async -> DetachedFetchOutcome {
|
||||
await Task.detached(priority: .utility) {
|
||||
let ready = await awaitTorReady()
|
||||
@@ -298,16 +246,16 @@ final class GeoRelayDirectory {
|
||||
|
||||
do {
|
||||
let data = try await fetchData(request)
|
||||
guard let parsed = Self.validatedEntries(
|
||||
from: data,
|
||||
policy: validationPolicy,
|
||||
minimumEntries: validationPolicy.minimumRemoteEntries,
|
||||
baselineEntries: baselineEntries
|
||||
) else {
|
||||
guard let text = String(data: data, encoding: .utf8) else {
|
||||
return .invalidData
|
||||
}
|
||||
|
||||
return .success(entries: parsed, csv: data)
|
||||
let parsed = Self.parseCSV(text)
|
||||
guard !parsed.isEmpty else {
|
||||
return .invalidData
|
||||
}
|
||||
|
||||
return .success(entries: parsed, csv: text)
|
||||
} catch {
|
||||
return .network(error.localizedDescription)
|
||||
}
|
||||
@@ -321,7 +269,7 @@ final class GeoRelayDirectory {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handleFetchSuccess(entries parsed: [Entry], csv: Data) {
|
||||
private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
|
||||
entries = parsed
|
||||
persistCache(csv)
|
||||
dependencies.userDefaults.set(dependencies.now(), forKey: lastFetchKey)
|
||||
@@ -373,8 +321,9 @@ final class GeoRelayDirectory {
|
||||
cleanupState.retryTask = nil
|
||||
}
|
||||
|
||||
private func persistCache(_ data: Data) {
|
||||
private func persistCache(_ text: String) {
|
||||
guard let url = dependencies.cacheURL() else { return }
|
||||
guard let data = text.data(using: .utf8) else { return }
|
||||
do {
|
||||
try dependencies.writeData(data, url)
|
||||
} catch {
|
||||
@@ -387,12 +336,9 @@ final class GeoRelayDirectory {
|
||||
// Prefer cached file if present
|
||||
if let cache = dependencies.cacheURL(),
|
||||
let data = dependencies.readData(cache),
|
||||
let entries = Self.validatedEntries(
|
||||
from: data,
|
||||
policy: dependencies.validationPolicy,
|
||||
minimumEntries: 1
|
||||
) {
|
||||
return entries
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
let arr = Self.parseCSV(text)
|
||||
if !arr.isEmpty { return arr }
|
||||
}
|
||||
|
||||
// Try bundled resource(s)
|
||||
@@ -400,157 +346,36 @@ final class GeoRelayDirectory {
|
||||
|
||||
for url in bundleCandidates {
|
||||
if let data = dependencies.readData(url),
|
||||
let entries = Self.validatedEntries(
|
||||
from: data,
|
||||
policy: dependencies.validationPolicy,
|
||||
minimumEntries: 1
|
||||
) {
|
||||
return entries
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
let arr = Self.parseCSV(text)
|
||||
if !arr.isEmpty { return arr }
|
||||
}
|
||||
}
|
||||
|
||||
// Try filesystem path (development/test)
|
||||
if let cwd = dependencies.currentDirectoryPath(),
|
||||
let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
||||
let entries = Self.validatedEntries(
|
||||
from: data,
|
||||
policy: dependencies.validationPolicy,
|
||||
minimumEntries: 1
|
||||
) {
|
||||
return entries
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
return Self.parseCSV(text)
|
||||
}
|
||||
|
||||
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
|
||||
return []
|
||||
}
|
||||
|
||||
/// Parses the fixed three-column format as an all-or-nothing trust unit.
|
||||
/// One malformed or conflicting row rejects the complete dataset rather
|
||||
/// than silently shrinking or partially replacing the current directory.
|
||||
nonisolated static func validatedEntries(
|
||||
from data: Data,
|
||||
policy: GeoRelayDirectoryValidationPolicy,
|
||||
minimumEntries: Int,
|
||||
baselineEntries: Set<Entry>? = nil
|
||||
) -> [Entry]? {
|
||||
guard !data.isEmpty, data.count <= policy.maximumBytes,
|
||||
let text = String(data: data, encoding: .utf8),
|
||||
!text.hasPrefix("\u{feff}") else {
|
||||
return nil
|
||||
}
|
||||
|
||||
nonisolated static func parseCSV(_ text: String) -> [Entry] {
|
||||
var result: Set<Entry> = []
|
||||
let lines = text.split(whereSeparator: { $0.isNewline })
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
guard let header = lines.first,
|
||||
lines.count - 1 <= policy.maximumRows else {
|
||||
return nil
|
||||
for (idx, raw) in lines.enumerated() {
|
||||
guard let line = raw.trimmedOrNilIfEmpty else { continue }
|
||||
if idx == 0 && line.lowercased().contains("relay url") { continue }
|
||||
let parts = line.split(separator: ",").map { $0.trimmed }
|
||||
guard parts.count >= 3 else { continue }
|
||||
guard let host = NostrRelayURL.directoryAddress(parts[0]) else { continue }
|
||||
guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue }
|
||||
result.insert(Entry(host: host, lat: lat, lon: lon))
|
||||
}
|
||||
|
||||
let headerParts = header
|
||||
.split(separator: ",", omittingEmptySubsequences: false)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
|
||||
let supportedHeaders = [
|
||||
["relay url", "latitude", "longitude"],
|
||||
["relay url", "lat", "lon"]
|
||||
]
|
||||
guard supportedHeaders.contains(headerParts) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var entriesByHost: [String: Entry] = [:]
|
||||
for line in lines.dropFirst() {
|
||||
let parts = line
|
||||
.split(separator: ",", omittingEmptySubsequences: false)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
guard parts.count == 3,
|
||||
let host = validatedDirectoryAddress(parts[0]),
|
||||
let latitude = Double(parts[1]), latitude.isFinite,
|
||||
(-90.0...90.0).contains(latitude),
|
||||
let longitude = Double(parts[2]), longitude.isFinite,
|
||||
(-180.0...180.0).contains(longitude) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let entry = Entry(host: host, lat: latitude, lon: longitude)
|
||||
if let existing = entriesByHost[host], existing != entry {
|
||||
// One endpoint cannot truthfully occupy two coordinates. Do
|
||||
// not let row ordering choose which location clients trust.
|
||||
return nil
|
||||
}
|
||||
entriesByHost[host] = entry
|
||||
guard entriesByHost.count <= policy.maximumEntries else { return nil }
|
||||
}
|
||||
|
||||
let parsedEntries = Set(entriesByHost.values)
|
||||
guard parsedEntries.count >= minimumEntries else { return nil }
|
||||
|
||||
if let baselineEntries {
|
||||
guard (0...1).contains(policy.minimumRetainedFraction) else { return nil }
|
||||
let requiredOverlap = Int(
|
||||
ceil(Double(baselineEntries.count) * policy.minimumRetainedFraction)
|
||||
)
|
||||
guard parsedEntries.intersection(baselineEntries).count >= requiredOverlap else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return parsedEntries.sorted {
|
||||
($0.host, $0.lat, $0.lon) < ($1.host, $1.lat, $1.lon)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func validatedDirectoryAddress(_ rawValue: String) -> String? {
|
||||
let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !value.isEmpty,
|
||||
value.unicodeScalars.allSatisfy({
|
||||
$0.isASCII && !CharacterSet.controlCharacters.contains($0)
|
||||
}) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let candidate = value.contains("://") ? value : "wss://\(value)"
|
||||
guard let components = URLComponents(string: candidate),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
scheme == "wss" || scheme == "https",
|
||||
components.user == nil,
|
||||
components.password == nil,
|
||||
components.query == nil,
|
||||
components.fragment == nil,
|
||||
components.path.isEmpty || components.path == "/",
|
||||
let rawHost = components.host else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let host = rawHost.lowercased()
|
||||
guard !host.isEmpty, host.count <= 253,
|
||||
host.unicodeScalars.allSatisfy({ $0.isASCII }),
|
||||
!host.hasSuffix("."),
|
||||
host != "localhost",
|
||||
!host.hasSuffix(".localhost"),
|
||||
!host.hasSuffix(".local"),
|
||||
!host.hasSuffix(".internal") else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let labels = host.split(separator: ".", omittingEmptySubsequences: false)
|
||||
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789-")
|
||||
guard labels.count >= 2,
|
||||
!labels.allSatisfy({ $0.allSatisfy(\.isNumber) }),
|
||||
labels.allSatisfy({ label in
|
||||
(1...63).contains(label.count) &&
|
||||
label.first != "-" &&
|
||||
label.last != "-" &&
|
||||
label.unicodeScalars.allSatisfy { allowed.contains($0) }
|
||||
}) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let port = components.port {
|
||||
guard (1...65_535).contains(port) else { return nil }
|
||||
if port != 443 { return "\(host):\(port)" }
|
||||
}
|
||||
return host
|
||||
return Array(result)
|
||||
}
|
||||
|
||||
// MARK: - Observers & Timers
|
||||
|
||||
@@ -39,4 +39,13 @@ enum NostrRelayURL {
|
||||
|
||||
return components.string
|
||||
}
|
||||
|
||||
static func directoryAddress(_ rawValue: String) -> String? {
|
||||
guard var normalized = normalized(rawValue, defaultScheme: "wss") else { return nil }
|
||||
for prefix in ["wss://", "ws://"] where normalized.hasPrefix(prefix) {
|
||||
normalized.removeFirst(prefix.count)
|
||||
break
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,12 +79,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 +116,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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,5 +3,5 @@ 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]
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ struct BLEAnnounceHandlerEnvironment {
|
||||
let now: () -> Date
|
||||
/// Noise public key already recorded for the peer, if any (registry read).
|
||||
let existingNoisePublicKey: (PeerID) -> Data?
|
||||
/// Ed25519 key previously bound to this Noise identity by an authenticated
|
||||
/// peer-state payload, if any (persistent identity-state read).
|
||||
let authenticatedSigningPublicKey: (_ noisePublicKey: Data) -> Data?
|
||||
/// 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).
|
||||
@@ -130,11 +133,21 @@ final class BLEAnnounceHandler {
|
||||
hasSignature: hasSignature,
|
||||
signatureValid: signatureValid,
|
||||
existingNoisePublicKey: existingNoisePublicKey,
|
||||
announcedNoisePublicKey: announcement.noisePublicKey
|
||||
announcedNoisePublicKey: announcement.noisePublicKey,
|
||||
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)
|
||||
}
|
||||
if case .reject(.authenticatedSigningKeyMismatch) = trustDecision {
|
||||
SecureLogger.warning(
|
||||
"⚠️ Announce signing-key replacement rejected for Noise-authenticated peer \(peerID.id.prefix(8))…",
|
||||
category: .security
|
||||
)
|
||||
}
|
||||
let verifiedAnnounce = trustDecision.isVerified
|
||||
|
||||
var isNewPeer = false
|
||||
|
||||
@@ -56,6 +56,7 @@ enum BLEAnnounceTrustRejection: Equatable {
|
||||
case missingSignature
|
||||
case invalidSignature
|
||||
case keyMismatch
|
||||
case authenticatedSigningKeyMismatch
|
||||
}
|
||||
|
||||
enum BLEAnnounceTrustDecision: Equatable {
|
||||
@@ -72,12 +73,19 @@ enum BLEAnnounceTrustPolicy {
|
||||
hasSignature: Bool,
|
||||
signatureValid: Bool,
|
||||
existingNoisePublicKey: Data?,
|
||||
announcedNoisePublicKey: Data
|
||||
announcedNoisePublicKey: Data,
|
||||
authenticatedSigningPublicKey: Data? = nil,
|
||||
announcedSigningPublicKey: Data? = nil
|
||||
) -> BLEAnnounceTrustDecision {
|
||||
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
|
||||
return .reject(.keyMismatch)
|
||||
}
|
||||
|
||||
if let authenticatedSigningPublicKey,
|
||||
announcedSigningPublicKey != authenticatedSigningPublicKey {
|
||||
return .reject(.authenticatedSigningKeyMismatch)
|
||||
}
|
||||
|
||||
guard hasSignature else {
|
||||
return .reject(.missingSignature)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
/// Thread-safe announce admission state.
|
||||
///
|
||||
/// Announce requests originate from the Bluetooth delegate queue, the
|
||||
/// concurrent message queue, and the maintenance timer. Keeping the timestamp
|
||||
/// behind a lock makes admission and maintenance snapshots atomic when those
|
||||
/// request sources race.
|
||||
final class BLEAnnounceThrottle: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
struct BLEAnnounceThrottle {
|
||||
private var lastSent: Date
|
||||
private let normalMinimumInterval: TimeInterval
|
||||
private let forcedMinimumInterval: TimeInterval
|
||||
@@ -23,18 +16,16 @@ final class BLEAnnounceThrottle: @unchecked Sendable {
|
||||
}
|
||||
|
||||
func elapsed(since now: Date) -> TimeInterval {
|
||||
lock.withLock { now.timeIntervalSince(lastSent) }
|
||||
now.timeIntervalSince(lastSent)
|
||||
}
|
||||
|
||||
func shouldSend(force: Bool, now: Date) -> Bool {
|
||||
lock.withLock {
|
||||
let minimumInterval = force ? forcedMinimumInterval : normalMinimumInterval
|
||||
guard now.timeIntervalSince(lastSent) >= minimumInterval else {
|
||||
return false
|
||||
}
|
||||
|
||||
lastSent = now
|
||||
return true
|
||||
mutating func shouldSend(force: Bool, now: Date) -> Bool {
|
||||
let minimumInterval = force ? forcedMinimumInterval : normalMinimumInterval
|
||||
guard elapsed(since: now) >= minimumInterval else {
|
||||
return false
|
||||
}
|
||||
|
||||
lastSent = now
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -46,54 +48,105 @@ final class BLEFileTransferHandler {
|
||||
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,
|
||||
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,
|
||||
env: env
|
||||
)
|
||||
}
|
||||
|
||||
private func storeIncomingPayload(
|
||||
_ payload: Data,
|
||||
from peerID: PeerID,
|
||||
senderNickname: String,
|
||||
timestamp: Date,
|
||||
isPrivate: Bool,
|
||||
env: BLEFileTransferHandlerEnvironment
|
||||
) -> Bool {
|
||||
|
||||
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 true
|
||||
return false
|
||||
}
|
||||
|
||||
// BCH-01-002: Enforce storage quota before saving
|
||||
@@ -106,28 +159,27 @@ final class BLEFileTransferHandler {
|
||||
mime.defaultExtension,
|
||||
mime.category.rawValue
|
||||
) else {
|
||||
return true
|
||||
return false
|
||||
}
|
||||
|
||||
if deliveryPlan.isPrivateMessage {
|
||||
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,
|
||||
timestamp: timestamp,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: deliveryPlan.isPrivateMessage,
|
||||
isPrivate: isPrivate,
|
||||
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)
|
||||
deliveryStatus: isPrivate
|
||||
? .delivered(to: env.localNickname(), at: timestamp)
|
||||
: nil
|
||||
)
|
||||
|
||||
@@ -137,51 +189,38 @@ final class BLEFileTransferHandler {
|
||||
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(
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct BLELocalIdentitySnapshot: Equatable, Sendable {
|
||||
let peerID: PeerID
|
||||
let peerIDData: Data
|
||||
let nickname: String
|
||||
}
|
||||
|
||||
/// Lock-backed local identity state shared by the transport's message,
|
||||
/// Bluetooth, maintenance, and main-actor entry points.
|
||||
///
|
||||
/// `peerID` and its binary wire representation must change as one unit during
|
||||
/// panic rotation. A snapshot also gives announce construction one consistent
|
||||
/// view of the nickname and identity instead of reading three independently
|
||||
/// mutable properties across queues.
|
||||
final class BLELocalIdentityStateStore: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var state: BLELocalIdentitySnapshot
|
||||
|
||||
init(
|
||||
peerID: PeerID = PeerID(str: ""),
|
||||
nickname: String = "anon"
|
||||
) {
|
||||
state = BLELocalIdentitySnapshot(
|
||||
peerID: peerID,
|
||||
peerIDData: Data(hexString: peerID.id) ?? Data(),
|
||||
nickname: nickname
|
||||
)
|
||||
}
|
||||
|
||||
func snapshot() -> BLELocalIdentitySnapshot {
|
||||
lock.withLock { state }
|
||||
}
|
||||
|
||||
func setNickname(_ nickname: String) {
|
||||
lock.withLock {
|
||||
state = BLELocalIdentitySnapshot(
|
||||
peerID: state.peerID,
|
||||
peerIDData: state.peerIDData,
|
||||
nickname: nickname
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func replacePeerIdentity(with peerID: PeerID) {
|
||||
lock.withLock {
|
||||
state = BLELocalIdentitySnapshot(
|
||||
peerID: peerID,
|
||||
peerIDData: Data(hexString: peerID.id) ?? Data(),
|
||||
nickname: state.nickname
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,11 @@ struct BLENoiseHandshakeHandlingResult {
|
||||
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)
|
||||
@@ -35,9 +40,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,
|
||||
@@ -58,8 +70,8 @@ final class BLENoisePacketHandler {
|
||||
}
|
||||
|
||||
/// Returns true when the handshake message was processed successfully.
|
||||
/// Callers use this to distinguish an authenticated replacement completion
|
||||
/// from a rejected candidate while an older session remains established.
|
||||
/// Callers use this to distinguish an authenticated reconnect completion
|
||||
/// from a rejected ordinary responder while rollback state is restored.
|
||||
@discardableResult
|
||||
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
||||
handleHandshakeWithResult(packet, from: peerID).processed
|
||||
@@ -100,8 +112,16 @@ final class BLENoisePacketHandler {
|
||||
didEstablishAuthenticatedSession:
|
||||
result.didEstablishAuthenticatedSession
|
||||
)
|
||||
} catch let managedFailure as NoiseManagedHandshakeFailure {
|
||||
SecureLogger.error(
|
||||
"Failed to process handshake; manager owns recovery: \(managedFailure.underlying)"
|
||||
)
|
||||
return BLENoiseHandshakeHandlingResult(
|
||||
processed: false,
|
||||
didEstablishAuthenticatedSession: false
|
||||
)
|
||||
} catch NoiseSessionError.peerIdentityMismatch {
|
||||
// The candidate was already discarded by the session manager.
|
||||
// The responder was already discarded by the session manager.
|
||||
// Do not let a spoofed claimed ID trigger a fresh outbound
|
||||
// handshake or recreate state for the attacker-selected ID.
|
||||
SecureLogger.warning(
|
||||
@@ -146,20 +166,30 @@ 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.sessionNotEstablished {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,14 @@ 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
|
||||
}
|
||||
|
||||
mutating func upsertVerifiedAnnounce(
|
||||
peerID: PeerID,
|
||||
nickname: String,
|
||||
@@ -181,7 +196,7 @@ struct BLEPeerRegistry {
|
||||
signingPublicKey: Data?,
|
||||
isConnected: Bool,
|
||||
now: Date,
|
||||
capabilities: PeerCapabilities = [],
|
||||
capabilities: PeerCapabilities? = nil,
|
||||
bridgeGeohash: String? = nil
|
||||
) -> BLEPeerAnnounceUpdate {
|
||||
let existing = peers[peerID]
|
||||
@@ -199,7 +214,8 @@ struct BLEPeerRegistry {
|
||||
signingPublicKey: signingPublicKey,
|
||||
isVerifiedNickname: true,
|
||||
lastSeen: now,
|
||||
capabilities: capabilities,
|
||||
capabilities: capabilities ?? [],
|
||||
capabilitiesWereExplicitlyAdvertised: capabilities != nil,
|
||||
bridgeGeohash: bridgeGeohash
|
||||
)
|
||||
|
||||
|
||||
+1664
-128
File diff suppressed because it is too large
Load Diff
@@ -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,24 @@ 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 must be drained for this exact restored generation.
|
||||
var onSessionRestoredWithGeneration: ((_ peerID: PeerID, _ generation: UUID) -> Void)?
|
||||
|
||||
// Add a handler for peer authentication
|
||||
func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) {
|
||||
serviceQueue.async(flags: .barrier) { [weak self] in
|
||||
self?.onPeerAuthenticatedHandlers.append(handler)
|
||||
serviceQueue.sync(flags: .barrier) {
|
||||
onPeerAuthenticatedHandlers.append(handler)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,8 +212,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 +325,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 in
|
||||
self?.onSessionRestoredWithGeneration?(peerID, generation)
|
||||
}
|
||||
sessionManager.onHandshakeRecoveryRequired = { [weak self] request in
|
||||
self?.onHandshakeRecoveryRequired?(request)
|
||||
}
|
||||
|
||||
// Start session maintenance timer
|
||||
@@ -661,6 +714,90 @@ final class NoiseEncryptionService {
|
||||
let handshakeData = try sessionManager.initiateHandshake(with: peerID)
|
||||
return handshakeData
|
||||
}
|
||||
|
||||
/// Atomically admits and prepares one initial ordinary handshake. Returns
|
||||
/// nil when another discovery callback already created a session.
|
||||
func initiateHandshakeIfNeeded(
|
||||
with peerID: PeerID,
|
||||
retryOnTimeout: Bool = false
|
||||
) throws -> NoiseHandshakeInitiation? {
|
||||
guard peerID.isValid else {
|
||||
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
|
||||
throw NoiseSecurityError.invalidPeerID
|
||||
}
|
||||
|
||||
guard let initiation = try sessionManager.initiateHandshakeIfAbsent(
|
||||
with: peerID,
|
||||
notifyOnTimeout: retryOnTimeout,
|
||||
authorize: { [rateLimiter] in
|
||||
guard rateLimiter.allowHandshake(from: peerID) else {
|
||||
SecureLogger.warning(
|
||||
.authenticationFailed(peerID: "Rate limited: \(peerID)")
|
||||
)
|
||||
throw NoiseSecurityError.rateLimitExceeded
|
||||
}
|
||||
}
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
SecureLogger.info(.handshakeStarted(peerID: peerID.id))
|
||||
return initiation
|
||||
}
|
||||
|
||||
/// Atomically prepares an ordinary reconnect for a peer whose cached
|
||||
/// transport belongs to an earlier physical link. Failed authorization or
|
||||
/// handshake setup preserves the established session.
|
||||
func initiateReconnectHandshake(
|
||||
with peerID: PeerID,
|
||||
retryOnTimeout: Bool = false
|
||||
) throws -> NoiseHandshakeInitiation {
|
||||
guard peerID.isValid else {
|
||||
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
|
||||
throw NoiseSecurityError.invalidPeerID
|
||||
}
|
||||
|
||||
return try sessionManager.initiateReconnectHandshake(
|
||||
with: peerID,
|
||||
notifyOnTimeout: retryOnTimeout,
|
||||
authorize: { [rateLimiter] in
|
||||
guard rateLimiter.allowHandshake(from: peerID) else {
|
||||
SecureLogger.warning(
|
||||
.authenticationFailed(peerID: "Rate limited: \(peerID)")
|
||||
)
|
||||
throw NoiseSecurityError.rateLimitExceeded
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func prepareHandshakeRecovery(
|
||||
_ request: NoiseHandshakeRecoveryRequest
|
||||
) throws -> NoiseHandshakeRecoveryPreparation? {
|
||||
try sessionManager.prepareHandshakeRecovery(
|
||||
request,
|
||||
authorizeAttempt: { [rateLimiter] in
|
||||
guard rateLimiter.allowHandshake(from: request.peerID) else {
|
||||
SecureLogger.warning(
|
||||
.authenticationFailed(
|
||||
peerID: "Rate limited: \(request.peerID)"
|
||||
)
|
||||
)
|
||||
throw NoiseSecurityError.rateLimitExceeded
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func cancelHandshakeRecovery(_ request: NoiseHandshakeRecoveryRequest) {
|
||||
sessionManager.cancelHandshakeRecovery(request)
|
||||
}
|
||||
|
||||
func claimHandshakeInitiation(
|
||||
_ initiation: NoiseHandshakeInitiation,
|
||||
for peerID: PeerID
|
||||
) -> Data? {
|
||||
sessionManager.claimHandshakeInitiation(initiation, for: peerID)
|
||||
}
|
||||
|
||||
/// Process an incoming handshake message
|
||||
func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? {
|
||||
@@ -740,11 +877,56 @@ final class NoiseEncryptionService {
|
||||
|
||||
return try sessionManager.encrypt(data, for: peerID)
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
guard rateLimiter.allowMessage(from: peerID) else {
|
||||
throw NoiseSecurityError.rateLimitExceeded
|
||||
}
|
||||
|
||||
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 {
|
||||
// Validate message size
|
||||
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
||||
try decryptWithSessionGeneration(data, from: peerID).plaintext
|
||||
}
|
||||
|
||||
func decryptWithSessionGeneration(
|
||||
_ data: Data,
|
||||
from peerID: PeerID
|
||||
) throws -> (plaintext: Data, sessionGeneration: UUID) {
|
||||
// Standard transport ciphertext has 20 bytes of nonce/tag overhead.
|
||||
// A larger candidate is admitted only up to the framed-file ceiling;
|
||||
// after authenticated decryption it must prove it is `.privateFile`.
|
||||
let isStandardCiphertext = NoiseSecurityValidator.validateCiphertextSize(data)
|
||||
guard isStandardCiphertext || NoiseSecurityValidator.validatePrivateFileCiphertextSize(data) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
|
||||
@@ -753,12 +935,21 @@ final class NoiseEncryptionService {
|
||||
throw NoiseSecurityError.rateLimitExceeded
|
||||
}
|
||||
|
||||
// Check if we have an established session
|
||||
guard hasEstablishedSession(with: peerID) else {
|
||||
// A quarantined transport is deliberately unavailable for outbound
|
||||
// state, but remains receive-only until the responder proves identity
|
||||
// or the bounded rollback restores it.
|
||||
guard sessionManager.hasReceiveSession(for: peerID) else {
|
||||
throw NoiseEncryptionError.sessionNotEstablished
|
||||
}
|
||||
|
||||
return try sessionManager.decrypt(data, from: peerID)
|
||||
let result = try sessionManager.decryptWithSessionGeneration(data, from: peerID)
|
||||
if !isStandardCiphertext {
|
||||
guard NoisePayloadType.isPrivateFile(rawValue: result.plaintext.first),
|
||||
NoiseSecurityValidator.validatePrivateFileMessageSize(result.plaintext) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Peer Management
|
||||
@@ -770,6 +961,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) {
|
||||
@@ -792,24 +1002,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -830,19 +1052,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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -83,6 +83,20 @@ 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
|
||||
}
|
||||
|
||||
protocol TransportEventDelegate: AnyObject {
|
||||
@MainActor func didReceiveTransportEvent(_ event: TransportEvent)
|
||||
}
|
||||
@@ -163,6 +177,12 @@ 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
|
||||
)
|
||||
func cancelTransfer(_ transferId: String)
|
||||
|
||||
// Live voice / push-to-talk (mesh transports only): one encoded
|
||||
@@ -208,6 +228,11 @@ 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
|
||||
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
|
||||
@@ -278,6 +303,16 @@ 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 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 +329,15 @@ 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 cancelTransfer(_ transferId: String) {}
|
||||
|
||||
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
|
||||
|
||||
@@ -9,6 +9,12 @@ 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
|
||||
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
|
||||
|
||||
@@ -6,6 +6,19 @@ import Foundation
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
struct LegacyPrivateMediaConsentRequest: Identifiable, Equatable {
|
||||
let id: UUID
|
||||
let peerID: PeerID
|
||||
let peerName: String
|
||||
let transferId: String
|
||||
let messageID: String
|
||||
}
|
||||
|
||||
struct PendingLegacyPrivateMediaConsent {
|
||||
let request: LegacyPrivateMediaConsentRequest
|
||||
let completion: @MainActor (Bool) -> Void
|
||||
}
|
||||
|
||||
/// The narrow surface `ChatMediaTransferCoordinator` needs from its owner.
|
||||
///
|
||||
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
|
||||
@@ -43,7 +56,24 @@ protocol ChatMediaTransferContext: AnyObject {
|
||||
func recordContentKey(_ key: String, timestamp: Date)
|
||||
|
||||
// MARK: Mesh file transfer
|
||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
|
||||
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
|
||||
func resolvePrivateMediaSendPolicy(
|
||||
to peerID: PeerID,
|
||||
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||
)
|
||||
func requestLegacyPrivateMediaConsent(
|
||||
for peerID: PeerID,
|
||||
transferId: String,
|
||||
messageID: String,
|
||||
completion: @escaping @MainActor (Bool) -> Void
|
||||
)
|
||||
func cancelLegacyPrivateMediaConsent(transferId: String, messageID: String)
|
||||
func sendFilePrivate(
|
||||
_ packet: BitchatFilePacket,
|
||||
to peerID: PeerID,
|
||||
transferId: String,
|
||||
allowLegacyFallback: Bool
|
||||
)
|
||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
|
||||
func cancelTransfer(_ transferId: String)
|
||||
}
|
||||
@@ -59,8 +89,50 @@ extension ChatViewModel: ChatMediaTransferContext {
|
||||
// other contexts or satisfied by existing `ChatViewModel` members. The
|
||||
// members below flatten mesh service accesses.
|
||||
|
||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
||||
meshService.sendFilePrivate(packet, to: peerID, transferId: transferId)
|
||||
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
|
||||
meshService.privateMediaSendPolicy(to: peerID)
|
||||
}
|
||||
|
||||
func resolvePrivateMediaSendPolicy(
|
||||
to peerID: PeerID,
|
||||
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||
) {
|
||||
meshService.resolvePrivateMediaSendPolicy(to: peerID, completion: completion)
|
||||
}
|
||||
|
||||
func requestLegacyPrivateMediaConsent(
|
||||
for peerID: PeerID,
|
||||
transferId: String,
|
||||
messageID: String,
|
||||
completion: @escaping @MainActor (Bool) -> Void
|
||||
) {
|
||||
enqueueLegacyPrivateMediaConsent(
|
||||
for: peerID,
|
||||
transferId: transferId,
|
||||
messageID: messageID,
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
|
||||
func cancelLegacyPrivateMediaConsent(transferId: String, messageID: String) {
|
||||
invalidateLegacyPrivateMediaConsent(
|
||||
transferId: transferId,
|
||||
messageID: messageID
|
||||
)
|
||||
}
|
||||
|
||||
func sendFilePrivate(
|
||||
_ packet: BitchatFilePacket,
|
||||
to peerID: PeerID,
|
||||
transferId: String,
|
||||
allowLegacyFallback: Bool
|
||||
) {
|
||||
meshService.sendFilePrivate(
|
||||
packet,
|
||||
to: peerID,
|
||||
transferId: transferId,
|
||||
allowLegacyFallback: allowLegacyFallback
|
||||
)
|
||||
}
|
||||
|
||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
|
||||
@@ -152,6 +224,7 @@ final class ChatMediaTransferCoordinator {
|
||||
private unowned let context: any ChatMediaTransferContext
|
||||
private let prepareImagePacket: @Sendable (URL) throws -> ChatPreparedImage
|
||||
private let imagePreparationBarrier = ImagePreparationBarrier()
|
||||
private let prepareVoiceNotePacket: @Sendable (URL) throws -> BitchatFilePacket
|
||||
|
||||
private(set) var transferIdToMessageIDs: [String: [String]] = [:]
|
||||
private(set) var messageIDToTransferId: [String: String] = [:]
|
||||
@@ -160,10 +233,14 @@ final class ChatMediaTransferCoordinator {
|
||||
context: any ChatMediaTransferContext,
|
||||
prepareImagePacket: @escaping @Sendable (URL) throws -> ChatPreparedImage = {
|
||||
try ChatMediaPreparation.prepareImagePacket(from: $0)
|
||||
},
|
||||
prepareVoiceNotePacket: @escaping @Sendable (URL) throws -> BitchatFilePacket = {
|
||||
try ChatMediaPreparation.prepareVoiceNotePacket(at: $0)
|
||||
}
|
||||
) {
|
||||
self.context = context
|
||||
self.prepareImagePacket = prepareImagePacket
|
||||
self.prepareVoiceNotePacket = prepareVoiceNotePacket
|
||||
}
|
||||
|
||||
func sendVoiceNote(at url: URL) {
|
||||
@@ -181,22 +258,33 @@ final class ChatMediaTransferCoordinator {
|
||||
)
|
||||
let messageID = message.id
|
||||
let transferId = makeTransferID(messageID: messageID)
|
||||
let generation = imagePreparationBarrier.currentGeneration
|
||||
// Own the transfer before detached preparation begins. Cancel/delete
|
||||
// must be able to invalidate this exact invocation even while file I/O
|
||||
// is still running off the main actor.
|
||||
registerTransfer(transferId: transferId, messageID: messageID)
|
||||
let prepareVoiceNotePacket = self.prepareVoiceNotePacket
|
||||
let barrier = imagePreparationBarrier
|
||||
let generation = barrier.currentGeneration
|
||||
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
Task.detached(priority: .userInitiated) { [weak self, barrier] in
|
||||
do {
|
||||
let packet = try await runBlockingMediaPreparation {
|
||||
try ChatMediaPreparation.prepareVoiceNotePacket(at: url)
|
||||
try prepareVoiceNotePacket(url)
|
||||
}
|
||||
|
||||
await MainActor.run { [weak self] in
|
||||
await MainActor.run { [weak self, barrier] in
|
||||
guard let self,
|
||||
self.imagePreparationBarrier.isCurrent(generation) else {
|
||||
barrier.isCurrent(generation),
|
||||
self.isRegisteredTransfer(transferId, messageID: messageID) else {
|
||||
return
|
||||
}
|
||||
self.registerTransfer(transferId: transferId, messageID: messageID)
|
||||
if let peerID = targetPeer {
|
||||
self.context.sendFilePrivate(packet, to: peerID, transferId: transferId)
|
||||
self.beginPrivateMediaSend(
|
||||
packet,
|
||||
to: peerID,
|
||||
transferId: transferId,
|
||||
messageID: messageID
|
||||
)
|
||||
} else {
|
||||
self.context.sendFileBroadcast(packet, transferId: transferId)
|
||||
}
|
||||
@@ -204,18 +292,20 @@ final class ChatMediaTransferCoordinator {
|
||||
} catch ChatMediaPreparationError.voiceNoteTooLarge(let size) {
|
||||
SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session)
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
await MainActor.run { [weak self] in
|
||||
await MainActor.run { [weak self, barrier] in
|
||||
guard let self,
|
||||
self.imagePreparationBarrier.isCurrent(generation) else {
|
||||
barrier.isCurrent(generation),
|
||||
self.isRegisteredTransfer(transferId, messageID: messageID) else {
|
||||
return
|
||||
}
|
||||
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", comment: "Failure reason shown when a voice note exceeds the size limit"))
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Voice note send failed: \(error)", category: .session)
|
||||
await MainActor.run { [weak self] in
|
||||
await MainActor.run { [weak self, barrier] in
|
||||
guard let self,
|
||||
self.imagePreparationBarrier.isCurrent(generation) else {
|
||||
barrier.isCurrent(generation),
|
||||
self.isRegisteredTransfer(transferId, messageID: messageID) else {
|
||||
return
|
||||
}
|
||||
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", comment: "Failure reason shown when a voice note could not be sent"))
|
||||
@@ -337,7 +427,12 @@ final class ChatMediaTransferCoordinator {
|
||||
let transferId = self.makeTransferID(messageID: messageID)
|
||||
self.registerTransfer(transferId: transferId, messageID: messageID)
|
||||
if let peerID = targetPeer {
|
||||
self.context.sendFilePrivate(prepared.packet, to: peerID, transferId: transferId)
|
||||
self.beginPrivateMediaSend(
|
||||
prepared.packet,
|
||||
to: peerID,
|
||||
transferId: transferId,
|
||||
messageID: messageID
|
||||
)
|
||||
} else {
|
||||
self.context.sendFileBroadcast(prepared.packet, transferId: transferId)
|
||||
}
|
||||
@@ -403,17 +498,127 @@ final class ChatMediaTransferCoordinator {
|
||||
return message
|
||||
}
|
||||
|
||||
private func beginPrivateMediaSend(
|
||||
_ packet: BitchatFilePacket,
|
||||
to peerID: PeerID,
|
||||
transferId: String,
|
||||
messageID: String
|
||||
) {
|
||||
continuePrivateMediaSend(
|
||||
packet,
|
||||
to: peerID,
|
||||
transferId: transferId,
|
||||
messageID: messageID,
|
||||
policy: context.privateMediaSendPolicy(to: peerID)
|
||||
)
|
||||
}
|
||||
|
||||
private func continuePrivateMediaSend(
|
||||
_ packet: BitchatFilePacket,
|
||||
to peerID: PeerID,
|
||||
transferId: String,
|
||||
messageID: String,
|
||||
policy: PrivateMediaSendPolicy
|
||||
) {
|
||||
switch policy {
|
||||
case .encrypted:
|
||||
context.sendFilePrivate(
|
||||
packet,
|
||||
to: peerID,
|
||||
transferId: transferId,
|
||||
allowLegacyFallback: false
|
||||
)
|
||||
|
||||
case .awaitingCapabilityProof:
|
||||
context.resolvePrivateMediaSendPolicy(to: peerID) { [weak self] resolvedPolicy in
|
||||
guard let self,
|
||||
self.isRegisteredTransfer(transferId, messageID: messageID) else {
|
||||
return
|
||||
}
|
||||
guard resolvedPolicy != .awaitingCapabilityProof else {
|
||||
self.handleMediaSendFailure(
|
||||
messageID: messageID,
|
||||
reason: String(
|
||||
localized: "content.delivery.reason.private_media_capability_unresolved",
|
||||
defaultValue: "Could not confirm encrypted media support",
|
||||
comment: "Failure reason when private-media capability negotiation did not resolve"
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
self.continuePrivateMediaSend(
|
||||
packet,
|
||||
to: peerID,
|
||||
transferId: transferId,
|
||||
messageID: messageID,
|
||||
policy: resolvedPolicy
|
||||
)
|
||||
}
|
||||
|
||||
case .legacyRequiresConsent:
|
||||
context.requestLegacyPrivateMediaConsent(
|
||||
for: peerID,
|
||||
transferId: transferId,
|
||||
messageID: messageID
|
||||
) { [weak self] approved in
|
||||
guard let self else { return }
|
||||
// Consent belongs to this exact placeholder/transfer. A late
|
||||
// dialog callback after cancel/delete must never resurrect it.
|
||||
guard self.messageIDToTransferId[messageID] == transferId,
|
||||
self.transferIdToMessageIDs[transferId]?.contains(messageID) == true else {
|
||||
return
|
||||
}
|
||||
guard approved else {
|
||||
self.handleMediaSendFailure(
|
||||
messageID: messageID,
|
||||
reason: String(
|
||||
localized: "content.delivery.reason.legacy_media_declined",
|
||||
defaultValue: "Not sent without end-to-end encryption",
|
||||
comment: "Failure reason after declining the warning for a legacy clear private-media send"
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
self.context.sendFilePrivate(
|
||||
packet,
|
||||
to: peerID,
|
||||
transferId: transferId,
|
||||
allowLegacyFallback: true
|
||||
)
|
||||
}
|
||||
|
||||
case .blockedDowngrade:
|
||||
handleMediaSendFailure(
|
||||
messageID: messageID,
|
||||
reason: String(
|
||||
localized: "content.delivery.reason.private_media_downgrade_blocked",
|
||||
defaultValue: "Encrypted media required; ask this contact to upgrade",
|
||||
comment: "Failure reason when a peer that previously supported encrypted media appears to downgrade"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func registerTransfer(transferId: String, messageID: String) {
|
||||
transferIdToMessageIDs[transferId, default: []].append(messageID)
|
||||
messageIDToTransferId[messageID] = transferId
|
||||
}
|
||||
|
||||
private func isRegisteredTransfer(_ transferId: String, messageID: String) -> Bool {
|
||||
messageIDToTransferId[messageID] == transferId
|
||||
&& transferIdToMessageIDs[transferId]?.contains(messageID) == true
|
||||
}
|
||||
|
||||
func makeTransferID(messageID: String) -> String {
|
||||
"\(messageID)-\(UUID().uuidString)"
|
||||
}
|
||||
|
||||
func clearTransferMapping(for messageID: String) {
|
||||
guard let transferId = messageIDToTransferId.removeValue(forKey: messageID) else { return }
|
||||
context.cancelLegacyPrivateMediaConsent(
|
||||
transferId: transferId,
|
||||
messageID: messageID
|
||||
)
|
||||
guard var queue = transferIdToMessageIDs[transferId] else { return }
|
||||
|
||||
if !queue.isEmpty {
|
||||
@@ -448,6 +653,9 @@ final class ChatMediaTransferCoordinator {
|
||||
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
|
||||
clearTransferMapping(for: messageID)
|
||||
context.removeMessage(withID: messageID, cleanupFile: true)
|
||||
case .rejected(let id, let reason):
|
||||
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
|
||||
handleMediaSendFailure(messageID: messageID, reason: reason)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,6 +696,13 @@ final class ChatMediaTransferCoordinator {
|
||||
}
|
||||
|
||||
func deleteMediaMessage(messageID: String) {
|
||||
// Delete is also a send cancellation. In particular, an approved
|
||||
// legacy-clear send may still be waiting on BLEService.messageQueue;
|
||||
// removing only the UI mapping would let that deferred work transmit.
|
||||
if let transferId = messageIDToTransferId[messageID],
|
||||
transferIdToMessageIDs[transferId]?.first == messageID {
|
||||
context.cancelTransfer(transferId)
|
||||
}
|
||||
clearTransferMapping(for: messageID)
|
||||
context.removeMessage(withID: messageID, cleanupFile: true)
|
||||
}
|
||||
|
||||
@@ -407,6 +407,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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -375,6 +375,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
@Published var showBluetoothAlert = false
|
||||
@Published var bluetoothAlertMessage = ""
|
||||
@Published var bluetoothState: CBManagerState = .unknown
|
||||
@Published private(set) var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest?
|
||||
private var pendingLegacyPrivateMediaConsents: [PendingLegacyPrivateMediaConsent] = []
|
||||
|
||||
private func performDeliveryUpdate(_ update: @escaping @MainActor (ChatDeliveryCoordinator) -> Void) {
|
||||
if Thread.isMainThread {
|
||||
@@ -1268,6 +1270,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
mediaTransferCoordinator.resetForPanic()
|
||||
liveVoiceCoordinator.resetForPanic()
|
||||
|
||||
// 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
|
||||
// the legacy mirror empty with it)
|
||||
@@ -1922,4 +1928,91 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
publicConversationCoordinator.sendHapticFeedback(for: message)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
extension ChatViewModel {
|
||||
func enqueueLegacyPrivateMediaConsent(
|
||||
for peerID: PeerID,
|
||||
transferId: String,
|
||||
messageID: String,
|
||||
completion: @escaping @MainActor (Bool) -> Void
|
||||
) {
|
||||
let request = LegacyPrivateMediaConsentRequest(
|
||||
id: UUID(),
|
||||
peerID: peerID,
|
||||
peerName: nicknameForPeer(peerID),
|
||||
transferId: transferId,
|
||||
messageID: messageID
|
||||
)
|
||||
pendingLegacyPrivateMediaConsents.append(PendingLegacyPrivateMediaConsent(
|
||||
request: request,
|
||||
completion: completion
|
||||
))
|
||||
if legacyPrivateMediaConsentRequest == nil {
|
||||
legacyPrivateMediaConsentRequest = request
|
||||
}
|
||||
}
|
||||
|
||||
func resolveLegacyPrivateMediaConsent(requestID: UUID, approved: Bool) {
|
||||
// SwiftUI may report both the selected button and the presentation
|
||||
// binding's dismissal. Resolve only the exact request that was shown;
|
||||
// a duplicate callback for it must not consume the next queued send.
|
||||
guard legacyPrivateMediaConsentRequest?.id == requestID,
|
||||
pendingLegacyPrivateMediaConsents.first?.request.id == requestID else {
|
||||
return
|
||||
}
|
||||
let resolved = pendingLegacyPrivateMediaConsents.removeFirst()
|
||||
// Drive the boolean presentation state through false before showing
|
||||
// the next queued per-send warning. Otherwise SwiftUI sees true→true,
|
||||
// closes the first dialog, and never presents the second.
|
||||
legacyPrivateMediaConsentRequest = nil
|
||||
resolved.completion(approved)
|
||||
presentNextLegacyPrivateMediaConsentDeferred()
|
||||
}
|
||||
|
||||
func invalidateLegacyPrivateMediaConsent(transferId: String, messageID: String) {
|
||||
let invalidatedIDs = Set(
|
||||
pendingLegacyPrivateMediaConsents.compactMap { pending -> UUID? in
|
||||
let request = pending.request
|
||||
return request.transferId == transferId && request.messageID == messageID
|
||||
? request.id
|
||||
: nil
|
||||
}
|
||||
)
|
||||
guard !invalidatedIDs.isEmpty else { return }
|
||||
|
||||
pendingLegacyPrivateMediaConsents.removeAll {
|
||||
invalidatedIDs.contains($0.request.id)
|
||||
}
|
||||
if let currentID = legacyPrivateMediaConsentRequest?.id,
|
||||
invalidatedIDs.contains(currentID) {
|
||||
legacyPrivateMediaConsentRequest = nil
|
||||
presentNextLegacyPrivateMediaConsentDeferred()
|
||||
}
|
||||
}
|
||||
|
||||
func cancelAllLegacyPrivateMediaConsents() {
|
||||
let pending = pendingLegacyPrivateMediaConsents
|
||||
pendingLegacyPrivateMediaConsents.removeAll()
|
||||
legacyPrivateMediaConsentRequest = nil
|
||||
for item in pending {
|
||||
item.completion(false)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentNextLegacyPrivateMediaConsentDeferred() {
|
||||
guard legacyPrivateMediaConsentRequest == nil,
|
||||
let nextRequestID = pendingLegacyPrivateMediaConsents.first?.request.id else {
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self,
|
||||
self.legacyPrivateMediaConsentRequest == nil,
|
||||
self.pendingLegacyPrivateMediaConsents.first?.request.id == nextRequestID else {
|
||||
return
|
||||
}
|
||||
self.legacyPrivateMediaConsentRequest = self.pendingLegacyPrivateMediaConsents[0].request
|
||||
}
|
||||
}
|
||||
}
|
||||
// End of ChatViewModel class
|
||||
|
||||
@@ -310,7 +310,7 @@ final class NostrInboundPipeline {
|
||||
// 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:
|
||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState:
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -366,7 +366,7 @@ final class NostrInboundPipeline {
|
||||
// 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:
|
||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState:
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -449,7 +449,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ struct ContentPeopleSheetView: View {
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
let legacyConsentRequest = conversationUIModel.legacyPrivateMediaConsentRequest
|
||||
NavigationStack {
|
||||
Group {
|
||||
if privateConversationModel.selectedPeerID != nil {
|
||||
@@ -97,6 +98,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
|
||||
|
||||
@@ -502,14 +502,26 @@ struct BLEServiceCoreTests {
|
||||
)
|
||||
let replay = try #require(victim.signPacket(unsigned), "Failed to sign replayed announce")
|
||||
#expect(ble._test_recordIngressIfNew(packet: replay, linkID: attackerLink))
|
||||
let rebindGate = VerifiedDirectRebindGate()
|
||||
ble._test_afterVerifiedDirectRebindEnqueued = rebindGate.pause
|
||||
defer {
|
||||
rebindGate.release()
|
||||
ble._test_afterVerifiedDirectRebindEnqueued = nil
|
||||
}
|
||||
ble._test_handlePacket(replay, fromPeerID: victimPeerID, preseedPeer: false)
|
||||
|
||||
let rebound = await TestHelpers.waitUntil(
|
||||
{ ble._test_centralBinding(attackerLink) == victimPeerID },
|
||||
let announcePaused = await TestHelpers.waitUntil(
|
||||
{ rebindGate.hasPaused },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(rebound)
|
||||
#expect(ble.canDeliverSecurely(to: victimPeerID))
|
||||
try #require(announcePaused)
|
||||
|
||||
// Rebind and ordinary reconnect preparation are one bleQueue
|
||||
// critical section. Once the binding is visible, stale sending keys
|
||||
// must already be unavailable.
|
||||
#expect(ble._test_centralBinding(attackerLink) == victimPeerID)
|
||||
#expect(!ble.canDeliverSecurely(to: victimPeerID))
|
||||
rebindGate.release()
|
||||
|
||||
let outbound = OutboundPacketTap()
|
||||
ble._test_onOutboundPacket = { outbound.record($0) }
|
||||
@@ -537,20 +549,26 @@ struct BLEServiceCoreTests {
|
||||
|
||||
// Preserve a working victim session while an unauthenticated
|
||||
// replacement candidate arrives on a newly bound physical link.
|
||||
let message1 = try ble._test_noiseInitiateHandshake(with: victimPeerID)
|
||||
// Establish BLE as responder so the replacement candidate below is
|
||||
// not coalesced by the initiator-completion grace path.
|
||||
let message1 = try victim.initiateHandshake(with: ble.myPeerID)
|
||||
let message2 = try #require(
|
||||
try victim.processHandshakeMessage(from: ble.myPeerID, message: message1)
|
||||
)
|
||||
let message3 = try #require(
|
||||
try ble._test_noiseProcessHandshakeMessage(
|
||||
from: victimPeerID,
|
||||
message: message1
|
||||
)
|
||||
)
|
||||
let message3 = try #require(
|
||||
try victim.processHandshakeMessage(
|
||||
from: ble.myPeerID,
|
||||
message: message2
|
||||
)
|
||||
)
|
||||
_ = try victim.processHandshakeMessage(
|
||||
from: ble.myPeerID,
|
||||
_ = try ble._test_noiseProcessHandshakeMessage(
|
||||
from: victimPeerID,
|
||||
message: message3
|
||||
)
|
||||
await ble._test_drainNoiseMessagePipeline()
|
||||
#expect(ble.canDeliverSecurely(to: victimPeerID))
|
||||
|
||||
let centralUUID = "central-replacement-xx-message-one"
|
||||
@@ -614,7 +632,134 @@ struct BLEServiceCoreTests {
|
||||
for: victimPeerID
|
||||
)
|
||||
)
|
||||
#expect(ble.canDeliverSecurely(to: victimPeerID))
|
||||
// Ordinary reconnect hardening quarantines the cached transport while
|
||||
// this candidate proves the claimed identity. It must be unavailable
|
||||
// for sending as well as unable to authenticate this ingress link.
|
||||
#expect(!ble.canDeliverSecurely(to: victimPeerID))
|
||||
}
|
||||
|
||||
@Test
|
||||
func failedInboundReconnectRestoresAndDrainsTypedPayloadQueue() async throws {
|
||||
let ble = makeService()
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||
|
||||
// Establish BLE as responder so the following inbound reconnect is
|
||||
// not intentionally coalesced by the initiator-completion grace path.
|
||||
let message1 = try alice.initiateHandshake(with: ble.myPeerID)
|
||||
let message2 = try #require(
|
||||
try ble._test_noiseProcessHandshakeMessage(
|
||||
from: alicePeerID,
|
||||
message: message1
|
||||
)
|
||||
)
|
||||
let message3 = try #require(
|
||||
try alice.processHandshakeMessage(
|
||||
from: ble.myPeerID,
|
||||
message: message2
|
||||
)
|
||||
)
|
||||
_ = try ble._test_noiseProcessHandshakeMessage(
|
||||
from: alicePeerID,
|
||||
message: message3
|
||||
)
|
||||
await ble._test_drainNoiseMessagePipeline()
|
||||
#expect(ble.canDeliverSecurely(to: alicePeerID))
|
||||
|
||||
let outbound = OutboundPacketTap()
|
||||
ble._test_onOutboundPacket = outbound.record
|
||||
let forgedMessage1 = try mallory.initiateHandshake(with: ble.myPeerID)
|
||||
let firstPacket = BitchatPacket(
|
||||
type: MessageType.noiseHandshake.rawValue,
|
||||
senderID: Data(hexString: alicePeerID.id) ?? Data(),
|
||||
recipientID: Data(hexString: ble.myPeerID.id),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1_000),
|
||||
payload: forgedMessage1,
|
||||
signature: nil,
|
||||
ttl: 7
|
||||
)
|
||||
ble._test_handlePacket(firstPacket, fromPeerID: alicePeerID)
|
||||
|
||||
let responseReady = await TestHelpers.waitUntil(
|
||||
{
|
||||
outbound.snapshot().contains {
|
||||
$0.type == MessageType.noiseHandshake.rawValue
|
||||
&& PeerID(hexData: $0.senderID) == ble.myPeerID
|
||||
&& $0.payload.count
|
||||
!= NoiseSecurityConstants.xxInitialMessageSize
|
||||
}
|
||||
},
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
try #require(responseReady)
|
||||
let forgedMessage2 = try #require(
|
||||
outbound.snapshot().first {
|
||||
$0.type == MessageType.noiseHandshake.rawValue
|
||||
&& PeerID(hexData: $0.senderID) == ble.myPeerID
|
||||
&& $0.payload.count
|
||||
!= NoiseSecurityConstants.xxInitialMessageSize
|
||||
}?.payload
|
||||
)
|
||||
#expect(!ble.canDeliverSecurely(to: alicePeerID))
|
||||
|
||||
// Typed control traffic must queue behind the ordinary responder,
|
||||
// rather than attempting encryption and disappearing.
|
||||
let privateMessageID = "quarantine-pm-\(UUID().uuidString)"
|
||||
ble.sendPrivateMessage(
|
||||
"queued private message",
|
||||
to: alicePeerID,
|
||||
recipientNickname: "Alice",
|
||||
messageID: privateMessageID
|
||||
)
|
||||
ble.sendGroupInvite(Data("queued-during-quarantine".utf8), to: alicePeerID)
|
||||
await ble._test_drainNoiseMessagePipeline()
|
||||
#expect(outbound.count(ofType: .noiseEncrypted) == 0)
|
||||
|
||||
let forgedMessage3 = try #require(
|
||||
try mallory.processHandshakeMessage(
|
||||
from: ble.myPeerID,
|
||||
message: forgedMessage2
|
||||
)
|
||||
)
|
||||
let thirdPacket = 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: forgedMessage3,
|
||||
signature: nil,
|
||||
ttl: 7
|
||||
)
|
||||
ble._test_handlePacket(thirdPacket, fromPeerID: alicePeerID)
|
||||
|
||||
// Restore re-enters the generation-bound authentication transition:
|
||||
// authenticated state and both outbound queues drain exactly once.
|
||||
let drained = await TestHelpers.waitUntil(
|
||||
{ outbound.count(ofType: .noiseEncrypted) >= 3 },
|
||||
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 == 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
|
||||
@@ -943,6 +1088,40 @@ private final class OutboundPacketTap {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return packets.filter { $0.type == type.rawValue }.count
|
||||
}
|
||||
|
||||
func snapshot() -> [BitchatPacket] {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return packets
|
||||
}
|
||||
}
|
||||
|
||||
private final class VerifiedDirectRebindGate: @unchecked Sendable {
|
||||
private let condition = NSCondition()
|
||||
private var paused = false
|
||||
private var released = false
|
||||
|
||||
var hasPaused: Bool {
|
||||
condition.lock()
|
||||
defer { condition.unlock() }
|
||||
return paused
|
||||
}
|
||||
|
||||
func pause() {
|
||||
condition.lock()
|
||||
paused = true
|
||||
condition.broadcast()
|
||||
while !released {
|
||||
condition.wait()
|
||||
}
|
||||
condition.unlock()
|
||||
}
|
||||
|
||||
func release() {
|
||||
condition.lock()
|
||||
released = true
|
||||
condition.broadcast()
|
||||
condition.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
private final class ReceivePacketHandoffGate: @unchecked Sendable {
|
||||
|
||||
@@ -7,10 +7,9 @@
|
||||
// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` /
|
||||
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
|
||||
//
|
||||
// Scope note: the async media-preparation pipelines (`ImageUtils`,
|
||||
// `ChatMediaPreparation`) run real file/codec work and remain covered by
|
||||
// `ChatMediaPreparationTests`; here we cover message enqueueing, transfer
|
||||
// bookkeeping, and the blocked-context guards.
|
||||
// Real file/codec work remains covered by `ChatMediaPreparationTests`. These
|
||||
// tests inject a paused voice-note preparer to exercise cancellation ownership
|
||||
// across the detached-preparation/MainActor boundary deterministically.
|
||||
//
|
||||
|
||||
import Testing
|
||||
@@ -91,11 +90,72 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
||||
|
||||
// Mesh file transfer
|
||||
private(set) var privateFileSends: [(peerID: PeerID, transferId: String)] = []
|
||||
private(set) var privateFileLegacyAllowances: [Bool] = []
|
||||
private(set) var broadcastFileSends: [String] = []
|
||||
private(set) var cancelledTransfers: [String] = []
|
||||
var privateMediaPolicy: PrivateMediaSendPolicy = .encrypted
|
||||
var resolvedPrivateMediaPolicy: PrivateMediaSendPolicy?
|
||||
private(set) var legacyConsentRequests: [(
|
||||
id: UUID,
|
||||
peerID: PeerID,
|
||||
transferId: String,
|
||||
messageID: String
|
||||
)] = []
|
||||
private(set) var invalidatedLegacyConsents: [(transferId: String, messageID: String)] = []
|
||||
private var pendingLegacyConsentIDs: [UUID] = []
|
||||
private var legacyConsentCompletions: [UUID: @MainActor (Bool) -> Void] = [:]
|
||||
|
||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
||||
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
|
||||
privateMediaPolicy
|
||||
}
|
||||
|
||||
func resolvePrivateMediaSendPolicy(
|
||||
to peerID: PeerID,
|
||||
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||
) {
|
||||
completion(resolvedPrivateMediaPolicy ?? privateMediaPolicy)
|
||||
}
|
||||
|
||||
func requestLegacyPrivateMediaConsent(
|
||||
for peerID: PeerID,
|
||||
transferId: String,
|
||||
messageID: String,
|
||||
completion: @escaping @MainActor (Bool) -> Void
|
||||
) {
|
||||
let id = UUID()
|
||||
legacyConsentRequests.append((id, peerID, transferId, messageID))
|
||||
pendingLegacyConsentIDs.append(id)
|
||||
legacyConsentCompletions[id] = completion
|
||||
}
|
||||
|
||||
func cancelLegacyPrivateMediaConsent(transferId: String, messageID: String) {
|
||||
invalidatedLegacyConsents.append((transferId, messageID))
|
||||
let matchingIDs = Set(legacyConsentRequests.compactMap { request in
|
||||
request.transferId == transferId && request.messageID == messageID
|
||||
? request.id
|
||||
: nil
|
||||
})
|
||||
pendingLegacyConsentIDs.removeAll { matchingIDs.contains($0) }
|
||||
}
|
||||
|
||||
func resolveNextLegacyConsent(_ approved: Bool) {
|
||||
guard !pendingLegacyConsentIDs.isEmpty else { return }
|
||||
let id = pendingLegacyConsentIDs.removeFirst()
|
||||
legacyConsentCompletions[id]?(approved)
|
||||
}
|
||||
|
||||
func invokeLegacyConsentEvenIfInvalidated(id: UUID, approved: Bool) {
|
||||
legacyConsentCompletions[id]?(approved)
|
||||
}
|
||||
|
||||
func sendFilePrivate(
|
||||
_ packet: BitchatFilePacket,
|
||||
to peerID: PeerID,
|
||||
transferId: String,
|
||||
allowLegacyFallback: Bool
|
||||
) {
|
||||
privateFileSends.append((peerID, transferId))
|
||||
privateFileLegacyAllowances.append(allowLegacyFallback)
|
||||
}
|
||||
|
||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
|
||||
@@ -107,6 +167,56 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
||||
}
|
||||
}
|
||||
|
||||
private final class PausedVoiceNotePreparer: @unchecked Sendable {
|
||||
private let condition = NSCondition()
|
||||
private var started = false
|
||||
private var released = false
|
||||
private var finished = false
|
||||
private let packet: BitchatFilePacket
|
||||
|
||||
init() {
|
||||
let content = Data("voice".utf8)
|
||||
packet = BitchatFilePacket(
|
||||
fileName: "paused.m4a",
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "audio/mp4",
|
||||
content: content
|
||||
)
|
||||
}
|
||||
|
||||
func prepare(_: URL) throws -> BitchatFilePacket {
|
||||
condition.lock()
|
||||
started = true
|
||||
condition.broadcast()
|
||||
while !released {
|
||||
condition.wait()
|
||||
}
|
||||
finished = true
|
||||
condition.broadcast()
|
||||
condition.unlock()
|
||||
return packet
|
||||
}
|
||||
|
||||
var hasStarted: Bool {
|
||||
condition.lock()
|
||||
defer { condition.unlock() }
|
||||
return started
|
||||
}
|
||||
|
||||
var hasFinished: Bool {
|
||||
condition.lock()
|
||||
defer { condition.unlock() }
|
||||
return finished
|
||||
}
|
||||
|
||||
func release() {
|
||||
condition.lock()
|
||||
released = true
|
||||
condition.broadcast()
|
||||
condition.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Coordinator Tests Against Mock Context
|
||||
|
||||
/// Exercises `ChatMediaTransferCoordinator` against
|
||||
@@ -171,6 +281,14 @@ struct ChatMediaTransferCoordinatorContextTests {
|
||||
#expect(context.removedMessages.count == 1)
|
||||
#expect(context.removedMessages.first?.messageID == "m2")
|
||||
#expect(context.removedMessages.first?.cleanupFile == true)
|
||||
|
||||
// A pre-start rejection keeps the placeholder visible and failed,
|
||||
// including queued post-handshake encryption failures.
|
||||
coordinator.registerTransfer(transferId: "t3", messageID: "m3")
|
||||
coordinator.handleTransferEvent(.rejected(id: "t3", reason: "encryption failed"))
|
||||
#expect(context.deliveryStatusUpdates.last?.messageID == "m3")
|
||||
#expect(context.deliveryStatusUpdates.last?.status == .failed(reason: "encryption failed"))
|
||||
#expect(coordinator.messageIDToTransferId["m3"] == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
@@ -301,6 +419,20 @@ struct ChatMediaTransferCoordinatorContextTests {
|
||||
))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func deleteMediaMessage_cancelsApprovedTransferBeforeRemovingMapping() {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||
coordinator.registerTransfer(transferId: "approved-delete", messageID: "message-delete")
|
||||
|
||||
coordinator.deleteMediaMessage(messageID: "message-delete")
|
||||
|
||||
#expect(context.cancelledTransfers == ["approved-delete"])
|
||||
#expect(coordinator.messageIDToTransferId["message-delete"] == nil)
|
||||
#expect(context.removedMessages.map(\.messageID) == ["message-delete"])
|
||||
#expect(context.removedMessages.first?.cleanupFile == true)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendVoiceNote_blockedContextRemovesFileAndExplains() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
@@ -319,6 +451,251 @@ struct ChatMediaTransferCoordinatorContextTests {
|
||||
#expect(context.appendedPublicMessages.isEmpty)
|
||||
#expect(coordinator.transferIdToMessageIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func cancelVoiceNoteDuringDetachedPreparationCannotSendOrRestoreMapping() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let peerID = PeerID(str: "5566778899aabbcc")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
let preparer = PausedVoiceNotePreparer()
|
||||
let coordinator = ChatMediaTransferCoordinator(
|
||||
context: context,
|
||||
prepareVoiceNotePacket: { url in try preparer.prepare(url) }
|
||||
)
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("paused-private-\(UUID().uuidString).m4a")
|
||||
try Data("voice".utf8).write(to: url)
|
||||
defer {
|
||||
preparer.release()
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
#expect(await TestHelpers.waitUntil({ preparer.hasStarted }, timeout: TestConstants.longTimeout))
|
||||
let messageID = try #require(context.privateChats[peerID]?.first?.id)
|
||||
let transferId = try #require(coordinator.messageIDToTransferId[messageID])
|
||||
|
||||
coordinator.cancelMediaSend(messageID: messageID)
|
||||
preparer.release()
|
||||
#expect(await TestHelpers.waitUntil({ preparer.hasFinished }, timeout: TestConstants.longTimeout))
|
||||
for _ in 0..<10 { await Task.yield() }
|
||||
|
||||
#expect(context.cancelledTransfers == [transferId])
|
||||
#expect(context.privateFileSends.isEmpty)
|
||||
#expect(context.broadcastFileSends.isEmpty)
|
||||
#expect(coordinator.messageIDToTransferId[messageID] == nil)
|
||||
#expect(coordinator.transferIdToMessageIDs[transferId] == nil)
|
||||
#expect(context.removedMessages.map(\.messageID) == [messageID])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func deletePublicVoiceNoteDuringDetachedPreparationCannotBroadcastOrRestoreMapping() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let preparer = PausedVoiceNotePreparer()
|
||||
let coordinator = ChatMediaTransferCoordinator(
|
||||
context: context,
|
||||
prepareVoiceNotePacket: { url in try preparer.prepare(url) }
|
||||
)
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("paused-public-\(UUID().uuidString).m4a")
|
||||
try Data("voice".utf8).write(to: url)
|
||||
defer {
|
||||
preparer.release()
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
#expect(await TestHelpers.waitUntil({ preparer.hasStarted }, timeout: TestConstants.longTimeout))
|
||||
let messageID = try #require(context.appendedPublicMessages.first?.message.id)
|
||||
let transferId = try #require(coordinator.messageIDToTransferId[messageID])
|
||||
|
||||
coordinator.deleteMediaMessage(messageID: messageID)
|
||||
preparer.release()
|
||||
#expect(await TestHelpers.waitUntil({ preparer.hasFinished }, timeout: TestConstants.longTimeout))
|
||||
for _ in 0..<10 { await Task.yield() }
|
||||
|
||||
#expect(context.cancelledTransfers == [transferId])
|
||||
#expect(context.broadcastFileSends.isEmpty)
|
||||
#expect(context.privateFileSends.isEmpty)
|
||||
#expect(coordinator.messageIDToTransferId[messageID] == nil)
|
||||
#expect(coordinator.transferIdToMessageIDs[transferId] == nil)
|
||||
#expect(context.removedMessages.map(\.messageID) == [messageID])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func voicePreparationFailureMarksPlaceholderFailedAndClearsEarlyMapping() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let peerID = PeerID(str: "66778899aabbccdd")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
let coordinator = ChatMediaTransferCoordinator(
|
||||
context: context,
|
||||
prepareVoiceNotePacket: { _ in
|
||||
throw ChatMediaPreparationError.voiceNoteTooLarge(bytes: 999_999)
|
||||
}
|
||||
)
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("failing-private-\(UUID().uuidString).m4a")
|
||||
try Data("voice".utf8).write(to: url)
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{
|
||||
context.deliveryStatusUpdates.contains { update in
|
||||
if case .failed = update.status { return true }
|
||||
return false
|
||||
}
|
||||
},
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
let messageID = try #require(context.privateChats[peerID]?.first?.id)
|
||||
|
||||
#expect(coordinator.messageIDToTransferId[messageID] == nil)
|
||||
#expect(coordinator.transferIdToMessageIDs.isEmpty)
|
||||
#expect(context.privateFileSends.isEmpty)
|
||||
#expect(context.broadcastFileSends.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func legacyPrivateVoiceNoteWaitsForPerSendConsent() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.privateMediaPolicy = .legacyRequiresConsent
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("legacy-consent-\(UUID().uuidString).m4a")
|
||||
try (Data([0x00, 0x00, 0x00, 0x18]) + Data("ftypM4A voice".utf8)).write(to: url)
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
|
||||
let prompted = await TestHelpers.waitUntil(
|
||||
{ context.legacyConsentRequests.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(prompted)
|
||||
#expect(context.legacyConsentRequests.map { $0.peerID } == [peerID])
|
||||
#expect(context.privateFileSends.isEmpty)
|
||||
|
||||
context.resolveNextLegacyConsent(true)
|
||||
|
||||
#expect(context.privateFileSends.count == 1)
|
||||
#expect(context.privateFileLegacyAllowances == [true])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func capabilityProofTimeoutTransitionsToConsentWithoutAutomaticRawSend() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||
let peerID = PeerID(str: "1020304050607080")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.privateMediaPolicy = .awaitingCapabilityProof
|
||||
context.resolvedPrivateMediaPolicy = .legacyRequiresConsent
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("proof-timeout-consent-\(UUID().uuidString).m4a")
|
||||
try Data("voice".utf8).write(to: url)
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
|
||||
let prompted = await TestHelpers.waitUntil(
|
||||
{ context.legacyConsentRequests.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(prompted)
|
||||
#expect(context.privateFileSends.isEmpty)
|
||||
context.resolveNextLegacyConsent(false)
|
||||
#expect(context.privateFileSends.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func legacyConsentApprovalAfterCancelCannotSend() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||
let peerID = PeerID(str: "2233445566778899")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.privateMediaPolicy = .legacyRequiresConsent
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("legacy-cancel-\(UUID().uuidString).m4a")
|
||||
try Data("voice".utf8).write(to: url)
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
let prompted = await TestHelpers.waitUntil(
|
||||
{ context.legacyConsentRequests.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(prompted)
|
||||
let request = try #require(context.legacyConsentRequests.first)
|
||||
|
||||
coordinator.cancelMediaSend(messageID: request.messageID)
|
||||
#expect(context.invalidatedLegacyConsents.contains {
|
||||
$0.transferId == request.transferId && $0.messageID == request.messageID
|
||||
})
|
||||
|
||||
// Model a stale framework callback that escaped active invalidation.
|
||||
// The coordinator's transfer/message binding check is the final gate.
|
||||
context.invokeLegacyConsentEvenIfInvalidated(id: request.id, approved: true)
|
||||
#expect(context.privateFileSends.isEmpty)
|
||||
#expect(coordinator.messageIDToTransferId[request.messageID] == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func legacyConsentApprovalAfterDeleteCannotSend() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||
let peerID = PeerID(str: "33445566778899aa")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.privateMediaPolicy = .legacyRequiresConsent
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("legacy-delete-\(UUID().uuidString).m4a")
|
||||
try Data("voice".utf8).write(to: url)
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
let prompted = await TestHelpers.waitUntil(
|
||||
{ context.legacyConsentRequests.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(prompted)
|
||||
let request = try #require(context.legacyConsentRequests.first)
|
||||
|
||||
coordinator.deleteMediaMessage(messageID: request.messageID)
|
||||
context.invokeLegacyConsentEvenIfInvalidated(id: request.id, approved: true)
|
||||
|
||||
#expect(context.invalidatedLegacyConsents.contains {
|
||||
$0.transferId == request.transferId && $0.messageID == request.messageID
|
||||
})
|
||||
#expect(context.privateFileSends.isEmpty)
|
||||
#expect(coordinator.messageIDToTransferId[request.messageID] == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func pinnedPrivateMediaDowngradeNeverPromptsOrSends() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.privateMediaPolicy = .blockedDowngrade
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("blocked-downgrade-\(UUID().uuidString).m4a")
|
||||
try Data("voice".utf8).write(to: url)
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
|
||||
let failed = await TestHelpers.waitUntil(
|
||||
{ context.deliveryStatusUpdates.contains { update in
|
||||
if case .failed = update.status { return true }
|
||||
return false
|
||||
} },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(failed)
|
||||
#expect(context.legacyConsentRequests.isEmpty)
|
||||
#expect(context.privateFileSends.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
private final class PausedImagePreparer: @unchecked Sendable {
|
||||
|
||||
@@ -1048,6 +1048,89 @@ struct ChatViewModelMediaTransferTests {
|
||||
#expect(viewModel.transferIdToMessageIDs.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func legacyPrivateMediaConsentRequestsArePerSendAndQueued() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let firstPeer = PeerID(str: "1111111111111111")
|
||||
let secondPeer = PeerID(str: "2222222222222222")
|
||||
var decisions: [Bool] = []
|
||||
|
||||
viewModel.enqueueLegacyPrivateMediaConsent(
|
||||
for: firstPeer,
|
||||
transferId: "transfer-1",
|
||||
messageID: "message-1"
|
||||
) { decisions.append($0) }
|
||||
viewModel.enqueueLegacyPrivateMediaConsent(
|
||||
for: secondPeer,
|
||||
transferId: "transfer-2",
|
||||
messageID: "message-2"
|
||||
) { decisions.append($0) }
|
||||
|
||||
#expect(viewModel.legacyPrivateMediaConsentRequest?.peerID == firstPeer)
|
||||
let firstRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id)
|
||||
viewModel.resolveLegacyPrivateMediaConsent(requestID: firstRequestID, approved: true)
|
||||
let showedSecond = await TestHelpers.waitUntil(
|
||||
{ viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(showedSecond)
|
||||
let secondRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id)
|
||||
|
||||
// A button action and the dialog binding may both resolve the first
|
||||
// ID. The stale second callback must not consume the queued request.
|
||||
viewModel.resolveLegacyPrivateMediaConsent(requestID: firstRequestID, approved: false)
|
||||
#expect(decisions == [true])
|
||||
#expect(viewModel.legacyPrivateMediaConsentRequest?.id == secondRequestID)
|
||||
|
||||
viewModel.resolveLegacyPrivateMediaConsent(requestID: secondRequestID, approved: false)
|
||||
|
||||
#expect(decisions == [true, false])
|
||||
#expect(viewModel.legacyPrivateMediaConsentRequest == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func invalidatingPresentedLegacyConsentAdvancesQueueAndStaleResolutionNoops() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let firstPeer = PeerID(str: "3333333333333333")
|
||||
let secondPeer = PeerID(str: "4444444444444444")
|
||||
var decisions: [String] = []
|
||||
|
||||
viewModel.enqueueLegacyPrivateMediaConsent(
|
||||
for: firstPeer,
|
||||
transferId: "transfer-cancelled",
|
||||
messageID: "message-cancelled"
|
||||
) { decisions.append("first:\($0)") }
|
||||
viewModel.enqueueLegacyPrivateMediaConsent(
|
||||
for: secondPeer,
|
||||
transferId: "transfer-kept",
|
||||
messageID: "message-kept"
|
||||
) { decisions.append("second:\($0)") }
|
||||
|
||||
let cancelledRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id)
|
||||
viewModel.invalidateLegacyPrivateMediaConsent(
|
||||
transferId: "transfer-cancelled",
|
||||
messageID: "message-cancelled"
|
||||
)
|
||||
let advanced = await TestHelpers.waitUntil(
|
||||
{ viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(advanced)
|
||||
#expect(decisions.isEmpty, "Invalidation drops the request rather than resolving its send")
|
||||
|
||||
viewModel.resolveLegacyPrivateMediaConsent(
|
||||
requestID: cancelledRequestID,
|
||||
approved: true
|
||||
)
|
||||
#expect(viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer)
|
||||
#expect(decisions.isEmpty)
|
||||
|
||||
let keptRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id)
|
||||
viewModel.resolveLegacyPrivateMediaConsent(requestID: keptRequestID, approved: true)
|
||||
#expect(decisions == ["second:true"])
|
||||
#expect(viewModel.legacyPrivateMediaConsentRequest == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendVoiceNote_oversizedFileFailsAndDeletesTempFile() async throws {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
|
||||
@@ -45,154 +45,6 @@ private func makeDirectConversationID(_ suffix: String) -> ConversationID {
|
||||
))
|
||||
}
|
||||
|
||||
/// Deliberately simple O(n) model used to differentially test the store's
|
||||
/// optimized logical-index bookkeeping. It models observable behavior only;
|
||||
/// it has no offset or ID index and therefore cannot reproduce the same bug.
|
||||
private struct ReferenceConversationTimeline {
|
||||
struct Message: Equatable {
|
||||
let id: String
|
||||
let timestamp: Date
|
||||
let content: String
|
||||
var deliveryStatus: DeliveryStatus?
|
||||
|
||||
init(_ message: BitchatMessage) {
|
||||
id = message.id
|
||||
timestamp = message.timestamp
|
||||
content = message.content
|
||||
deliveryStatus = message.deliveryStatus
|
||||
}
|
||||
}
|
||||
|
||||
struct AppendResult {
|
||||
let inserted: Bool
|
||||
let trimmedCount: Int
|
||||
}
|
||||
|
||||
let cap: Int
|
||||
private(set) var messages: [Message] = []
|
||||
|
||||
func contains(_ id: String) -> Bool {
|
||||
messages.contains { $0.id == id }
|
||||
}
|
||||
|
||||
mutating func append(_ message: BitchatMessage) -> AppendResult {
|
||||
guard !contains(message.id) else {
|
||||
return AppendResult(inserted: false, trimmedCount: 0)
|
||||
}
|
||||
|
||||
let snapshot = Message(message)
|
||||
var low = 0
|
||||
var high = messages.count
|
||||
while low < high {
|
||||
let mid = (low + high) / 2
|
||||
if messages[mid].timestamp <= snapshot.timestamp {
|
||||
low = mid + 1
|
||||
} else {
|
||||
high = mid
|
||||
}
|
||||
}
|
||||
messages.insert(snapshot, at: low)
|
||||
|
||||
let overflow = max(0, messages.count - cap)
|
||||
if overflow > 0 {
|
||||
messages.removeFirst(overflow)
|
||||
}
|
||||
return AppendResult(inserted: true, trimmedCount: overflow)
|
||||
}
|
||||
|
||||
mutating func upsert(_ message: BitchatMessage) -> Int {
|
||||
if let index = messages.firstIndex(where: { $0.id == message.id }) {
|
||||
messages[index] = Message(message)
|
||||
return 0
|
||||
}
|
||||
return append(message).trimmedCount
|
||||
}
|
||||
|
||||
mutating func applyDeliveryStatus(_ status: DeliveryStatus, to id: String) -> Bool {
|
||||
guard let index = messages.firstIndex(where: { $0.id == id }),
|
||||
messages[index].deliveryStatus != status else {
|
||||
return false
|
||||
}
|
||||
// The differential stream uses only unique `.delivered` values (or
|
||||
// an exact repeat), so no-downgrade policy is intentionally outside
|
||||
// this index-focused reference model.
|
||||
messages[index].deliveryStatus = status
|
||||
return true
|
||||
}
|
||||
|
||||
mutating func remove(at index: Int) -> Message {
|
||||
messages.remove(at: index)
|
||||
}
|
||||
|
||||
mutating func removeAll(where predicate: (Message) -> Bool) {
|
||||
messages.removeAll(where: predicate)
|
||||
}
|
||||
|
||||
mutating func clear() {
|
||||
messages.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
private struct ConversationStoreDifferentialRNG {
|
||||
private var state: UInt64
|
||||
|
||||
init(seed: UInt64) {
|
||||
state = seed
|
||||
}
|
||||
|
||||
mutating func next() -> UInt64 {
|
||||
state &+= 0x9E37_79B9_7F4A_7C15
|
||||
var value = state
|
||||
value = (value ^ (value >> 30)) &* 0xBF58_476D_1CE4_E5B9
|
||||
value = (value ^ (value >> 27)) &* 0x94D0_49BB_1331_11EB
|
||||
return value ^ (value >> 31)
|
||||
}
|
||||
|
||||
mutating func index(upperBound: Int) -> Int {
|
||||
precondition(upperBound > 0)
|
||||
return Int(next() % UInt64(upperBound))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func expectStore(
|
||||
_ store: ConversationStore,
|
||||
matches reference: ReferenceConversationTimeline,
|
||||
issuedIDs: [String],
|
||||
checkpoint: String
|
||||
) {
|
||||
let conversation = store.conversation(for: .mesh)
|
||||
let actual = conversation.messages.map(ReferenceConversationTimeline.Message.init)
|
||||
#expect(actual == reference.messages, "timeline mismatch at \(checkpoint)")
|
||||
|
||||
let lookupSnapshot = reference.messages.compactMap { expected in
|
||||
conversation.message(withID: expected.id).map(ReferenceConversationTimeline.Message.init)
|
||||
}
|
||||
#expect(lookupSnapshot == reference.messages, "ID lookup mismatch at \(checkpoint)")
|
||||
#expect(
|
||||
Set(conversation.messageIDs) == Set(reference.messages.map(\.id)),
|
||||
"per-conversation ID set mismatch at \(checkpoint)"
|
||||
)
|
||||
|
||||
if !reference.messages.isEmpty {
|
||||
for index in Set([0, reference.messages.count / 2, reference.messages.count - 1]) {
|
||||
let id = reference.messages[index].id
|
||||
#expect(store.conversationIDs(forMessageID: id) == [.mesh], "store ID map mismatch at \(checkpoint)")
|
||||
}
|
||||
}
|
||||
|
||||
let activeIDs = Set(reference.messages.map(\.id))
|
||||
var checkedStaleIDs = 0
|
||||
for id in issuedIDs.reversed() where !activeIDs.contains(id) {
|
||||
#expect(conversation.message(withID: id) == nil, "stale conversation index entry at \(checkpoint)")
|
||||
#expect(store.conversationIDs(forMessageID: id).isEmpty, "stale store ID map entry at \(checkpoint)")
|
||||
checkedStaleIDs += 1
|
||||
if checkedStaleIDs == 16 { break }
|
||||
}
|
||||
|
||||
#expect(store.auditInvariants().isEmpty, "invariant audit failed at \(checkpoint)")
|
||||
}
|
||||
|
||||
@Suite("ConversationStore")
|
||||
struct ConversationStoreTests {
|
||||
|
||||
@@ -288,282 +140,6 @@ struct ConversationStoreTests {
|
||||
#expect(conversation.message(withID: probeID)?.deliveryStatus == .sent)
|
||||
}
|
||||
|
||||
@Test("steady-state cap trimming keeps lookups exact across mixed mutations")
|
||||
@MainActor
|
||||
func steadyStateCapTrimmingKeepsLogicalIndexExact() {
|
||||
let store = ConversationStore()
|
||||
let conversation = store.conversation(for: .mesh)
|
||||
let overflow = 64
|
||||
|
||||
for i in 0..<(conversation.cap + overflow) {
|
||||
store.append(makeMessage(id: "m\(i)", timestamp: TimeInterval(i)), to: .mesh)
|
||||
}
|
||||
|
||||
#expect(conversation.messages.first?.id == "m\(overflow)")
|
||||
#expect(conversation.message(withID: "m\(overflow)")?.id == "m\(overflow)")
|
||||
|
||||
// Exercise a suffix reindex after the head offset has advanced, then
|
||||
// trim the old head. The late row becomes the new first element.
|
||||
let late = makeMessage(id: "late", timestamp: TimeInterval(overflow) + 0.5)
|
||||
#expect(store.append(late, to: .mesh))
|
||||
#expect(conversation.messages.first?.id == "late")
|
||||
#expect(conversation.message(withID: "m\(overflow + 1)")?.id == "m\(overflow + 1)")
|
||||
|
||||
// Head and middle removals, an in-place upsert, and a status update
|
||||
// must all resolve through the same logical index representation.
|
||||
#expect(store.removeMessage(withID: "late", from: .mesh)?.id == "late")
|
||||
let middleID = "m\(overflow + conversation.cap / 2)"
|
||||
#expect(store.removeMessage(withID: middleID, from: .mesh)?.id == middleID)
|
||||
|
||||
let probeID = "m\(overflow + 10)"
|
||||
store.upsertByID(
|
||||
makeMessage(id: probeID, timestamp: TimeInterval(overflow + 10), content: "edited"),
|
||||
in: .mesh
|
||||
)
|
||||
#expect(conversation.message(withID: probeID)?.content == "edited")
|
||||
#expect(store.setDeliveryStatus(.sent, forMessageID: probeID, in: .mesh))
|
||||
#expect(conversation.message(withID: probeID)?.deliveryStatus == .sent)
|
||||
#expect(store.auditInvariants().isEmpty)
|
||||
|
||||
// Clearing resets the logical offset as well as the maps.
|
||||
store.clear(.mesh)
|
||||
#expect(store.append(makeMessage(id: "after-clear", timestamp: 10_000), to: .mesh))
|
||||
#expect(conversation.message(withID: "after-clear")?.id == "after-clear")
|
||||
#expect(store.auditInvariants().isEmpty)
|
||||
}
|
||||
|
||||
@Test("logical index offset matches a reference model under adversarial mutations")
|
||||
@MainActor
|
||||
func logicalIndexOffsetDifferentialStress() async {
|
||||
let store = ConversationStore()
|
||||
let cap = store.conversation(for: .mesh).cap
|
||||
var reference = ReferenceConversationTimeline(cap: cap)
|
||||
var rng = ConversationStoreDifferentialRNG(seed: 0xC0FF_EE13_37CA_FE42)
|
||||
var issuedIDs: [String] = []
|
||||
var nextID = 0
|
||||
var nextTailTimestamp: TimeInterval = 1_700_000_000
|
||||
var trimmedCount = 0
|
||||
|
||||
var tailAppendCount = 0
|
||||
var outOfOrderCount = 0
|
||||
var duplicateOrReuseCount = 0
|
||||
var headRemovalCount = 0
|
||||
var middleRemovalCount = 0
|
||||
var upsertCount = 0
|
||||
var deliveryUpdateCount = 0
|
||||
var filterCount = 0
|
||||
var clearCount = 0
|
||||
|
||||
func issueMessage(timestamp: TimeInterval? = nil, tag: String) -> BitchatMessage {
|
||||
let number = nextID
|
||||
nextID += 1
|
||||
let id = "diff-\(number)"
|
||||
issuedIDs.append(id)
|
||||
let resolvedTimestamp: TimeInterval
|
||||
if let timestamp {
|
||||
resolvedTimestamp = timestamp
|
||||
} else {
|
||||
resolvedTimestamp = nextTailTimestamp
|
||||
nextTailTimestamp += 1
|
||||
}
|
||||
let dropMarker = number.isMultiple(of: 11) ? " [drop]" : ""
|
||||
return makeMessage(
|
||||
id: id,
|
||||
timestamp: resolvedTimestamp,
|
||||
content: "\(tag) \(number)\(dropMarker)"
|
||||
)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func appendAndCompare(_ message: BitchatMessage, checkpoint: String) -> ReferenceConversationTimeline.AppendResult {
|
||||
let expected = reference.append(message)
|
||||
let actual = store.append(message, to: .mesh)
|
||||
#expect(actual == expected.inserted, "append result mismatch at \(checkpoint)")
|
||||
trimmedCount += expected.trimmedCount
|
||||
return expected
|
||||
}
|
||||
|
||||
func refill(extra: Int, checkpoint: String) async {
|
||||
let appendCount = max(0, cap - reference.messages.count) + extra
|
||||
for index in 0..<appendCount {
|
||||
appendAndCompare(
|
||||
issueMessage(tag: "refill"),
|
||||
checkpoint: "\(checkpoint)-\(index)"
|
||||
)
|
||||
if index.isMultiple(of: 64) {
|
||||
await Task.yield()
|
||||
}
|
||||
}
|
||||
expectStore(store, matches: reference, issuedIDs: issuedIDs, checkpoint: checkpoint)
|
||||
}
|
||||
|
||||
// Start well into steady state so the offset is already non-zero
|
||||
// before any mixed operations begin.
|
||||
await refill(extra: 384, checkpoint: "initial steady-state fill")
|
||||
|
||||
for step in 0..<1_200 {
|
||||
if step == 300 || step == 900 {
|
||||
store.removeMessages(from: .mesh) { $0.content.contains("[drop]") }
|
||||
reference.removeAll { $0.content.contains("[drop]") }
|
||||
filterCount += 1
|
||||
expectStore(
|
||||
store,
|
||||
matches: reference,
|
||||
issuedIDs: issuedIDs,
|
||||
checkpoint: "filter at step \(step)"
|
||||
)
|
||||
}
|
||||
|
||||
if step == 600 {
|
||||
store.clear(.mesh)
|
||||
reference.clear()
|
||||
clearCount += 1
|
||||
expectStore(
|
||||
store,
|
||||
matches: reference,
|
||||
issuedIDs: issuedIDs,
|
||||
checkpoint: "clear at step \(step)"
|
||||
)
|
||||
}
|
||||
|
||||
switch rng.index(upperBound: 100) {
|
||||
case 0..<35:
|
||||
appendAndCompare(issueMessage(tag: "tail"), checkpoint: "tail append \(step)")
|
||||
tailAppendCount += 1
|
||||
|
||||
case 35..<55:
|
||||
if reference.messages.isEmpty {
|
||||
appendAndCompare(issueMessage(tag: "tail-fallback"), checkpoint: "OOO fallback \(step)")
|
||||
} else {
|
||||
let target = reference.messages[rng.index(upperBound: reference.messages.count)]
|
||||
let jitter = [-0.25, 0.0, 0.25][rng.index(upperBound: 3)]
|
||||
let timestamp = target.timestamp.timeIntervalSince1970 + jitter
|
||||
appendAndCompare(
|
||||
issueMessage(timestamp: timestamp, tag: "out-of-order"),
|
||||
checkpoint: "out-of-order append \(step)"
|
||||
)
|
||||
outOfOrderCount += 1
|
||||
}
|
||||
|
||||
case 55..<65:
|
||||
if issuedIDs.isEmpty {
|
||||
appendAndCompare(issueMessage(tag: "reuse-fallback"), checkpoint: "reuse fallback \(step)")
|
||||
} else {
|
||||
let reusedID = issuedIDs[rng.index(upperBound: issuedIDs.count)]
|
||||
let message = makeMessage(
|
||||
id: reusedID,
|
||||
timestamp: nextTailTimestamp,
|
||||
content: "duplicate-or-trimmed-reuse \(step)"
|
||||
)
|
||||
nextTailTimestamp += 1
|
||||
appendAndCompare(message, checkpoint: "duplicate or reuse \(step)")
|
||||
duplicateOrReuseCount += 1
|
||||
}
|
||||
|
||||
case 65..<73:
|
||||
if !reference.messages.isEmpty {
|
||||
let expected = reference.remove(at: 0)
|
||||
let actual = store.removeMessage(withID: expected.id, from: .mesh)
|
||||
.map(ReferenceConversationTimeline.Message.init)
|
||||
#expect(actual == expected, "head removal mismatch at step \(step)")
|
||||
headRemovalCount += 1
|
||||
}
|
||||
|
||||
case 73..<81:
|
||||
if !reference.messages.isEmpty {
|
||||
let middleStart = reference.messages.count / 4
|
||||
let middleWidth = max(1, reference.messages.count / 2)
|
||||
let index = min(
|
||||
reference.messages.count - 1,
|
||||
middleStart + rng.index(upperBound: middleWidth)
|
||||
)
|
||||
let expected = reference.remove(at: index)
|
||||
let actual = store.removeMessage(withID: expected.id, from: .mesh)
|
||||
.map(ReferenceConversationTimeline.Message.init)
|
||||
#expect(actual == expected, "middle removal mismatch at step \(step)")
|
||||
middleRemovalCount += 1
|
||||
}
|
||||
|
||||
case 81..<90:
|
||||
let message: BitchatMessage
|
||||
if step.isMultiple(of: 4) || reference.messages.isEmpty {
|
||||
let timestamp = reference.messages.isEmpty
|
||||
? nil
|
||||
: reference.messages[rng.index(upperBound: reference.messages.count)]
|
||||
.timestamp.timeIntervalSince1970
|
||||
message = issueMessage(timestamp: timestamp, tag: "upsert-new")
|
||||
} else {
|
||||
let current = reference.messages[rng.index(upperBound: reference.messages.count)]
|
||||
message = makeMessage(
|
||||
id: current.id,
|
||||
timestamp: current.timestamp.timeIntervalSince1970,
|
||||
content: "upsert-existing \(step)",
|
||||
deliveryStatus: current.deliveryStatus
|
||||
)
|
||||
}
|
||||
trimmedCount += reference.upsert(message)
|
||||
store.upsertByID(message, in: .mesh)
|
||||
upsertCount += 1
|
||||
|
||||
default:
|
||||
let id: String
|
||||
let repeatedStatus: DeliveryStatus?
|
||||
if step.isMultiple(of: 6) || reference.messages.isEmpty {
|
||||
id = "missing-\(step)"
|
||||
repeatedStatus = nil
|
||||
} else {
|
||||
let current = reference.messages[rng.index(upperBound: reference.messages.count)]
|
||||
id = current.id
|
||||
repeatedStatus = current.deliveryStatus
|
||||
}
|
||||
let status: DeliveryStatus
|
||||
if step.isMultiple(of: 4), let repeatedStatus {
|
||||
status = repeatedStatus
|
||||
} else {
|
||||
status = .delivered(
|
||||
to: "peer",
|
||||
at: Date(timeIntervalSince1970: 2_000_000_000 + Double(step))
|
||||
)
|
||||
}
|
||||
let expected = reference.applyDeliveryStatus(status, to: id)
|
||||
let actual = store.setDeliveryStatus(status, forMessageID: id, in: .mesh)
|
||||
#expect(actual == expected, "delivery update mismatch at step \(step)")
|
||||
deliveryUpdateCount += 1
|
||||
}
|
||||
|
||||
expectStore(
|
||||
store,
|
||||
matches: reference,
|
||||
issuedIDs: issuedIDs,
|
||||
checkpoint: "mixed operation \(step)"
|
||||
)
|
||||
|
||||
// This intentionally expensive MainActor stress test runs beside
|
||||
// async audio/UI tests in SwiftPM's parallel phase. Cooperatively
|
||||
// release the actor so their bounded waits can make progress.
|
||||
await Task.yield()
|
||||
|
||||
if (step + 1).isMultiple(of: 100) {
|
||||
await refill(extra: 32, checkpoint: "periodic refill after step \(step)")
|
||||
}
|
||||
}
|
||||
|
||||
// Guarantee another long run of one-row evictions after every other
|
||||
// mutation family has perturbed and rebuilt the offset/index state.
|
||||
await refill(extra: 512, checkpoint: "final steady-state trim run")
|
||||
|
||||
#expect(trimmedCount > 1_200)
|
||||
#expect(tailAppendCount > 300)
|
||||
#expect(outOfOrderCount > 150)
|
||||
#expect(duplicateOrReuseCount > 75)
|
||||
#expect(headRemovalCount > 50)
|
||||
#expect(middleRemovalCount > 50)
|
||||
#expect(upsertCount > 75)
|
||||
#expect(deliveryUpdateCount > 75)
|
||||
#expect(filterCount == 2)
|
||||
#expect(clearCount == 1)
|
||||
}
|
||||
|
||||
// MARK: - Upsert
|
||||
|
||||
@Test("upsertByID replaces in place and appends when absent")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ import Testing
|
||||
@testable import BitFoundation // to avoid unnecessary public's
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("Integration Tests", .serialized)
|
||||
struct IntegrationTests {
|
||||
|
||||
private var helper = TestNetworkHelper()
|
||||
@@ -272,8 +273,18 @@ struct IntegrationTests {
|
||||
// Re-establish Noise handshake explicitly via managers
|
||||
do {
|
||||
let m1 = try helper.noiseManagers["Bob"]!.initiateHandshake(with: helper.nodes["Alice"]!.peerID)
|
||||
let m2 = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m1)!
|
||||
let m3 = try helper.noiseManagers["Bob"]!.handleIncomingHandshake(from: helper.nodes["Alice"]!.peerID, message: m2)!
|
||||
let m2 = try #require(
|
||||
try helper.noiseManagers["Alice"]!.handleIncomingHandshake(
|
||||
from: helper.nodes["Bob"]!.peerID,
|
||||
message: m1
|
||||
)
|
||||
)
|
||||
let m3 = try #require(
|
||||
try helper.noiseManagers["Bob"]!.handleIncomingHandshake(
|
||||
from: helper.nodes["Alice"]!.peerID,
|
||||
message: m2
|
||||
)
|
||||
)
|
||||
_ = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m3)
|
||||
} catch {
|
||||
Issue.record("Failed to re-establish Noise session after restart: \(error)")
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import Testing
|
||||
@testable import BitFoundation // to avoid unnecessary public's
|
||||
@testable import bitchat
|
||||
|
||||
@@ -27,9 +28,14 @@ final class TestNetworkHelper {
|
||||
node.mockNickname = name
|
||||
nodes[name] = node
|
||||
|
||||
// Create/replace Noise manager for this node
|
||||
// This synchronous helper directly drives all three XX messages and
|
||||
// has no transport callback loop for delayed collision recovery.
|
||||
let key = Curve25519.KeyAgreement.PrivateKey()
|
||||
noiseManagers[name] = NoiseSessionManager(localStaticKey: key, keychain: mockKeychain)
|
||||
noiseManagers[name] = NoiseSessionManager(
|
||||
localStaticKey: key,
|
||||
keychain: mockKeychain,
|
||||
recentInitiatorCompletionGracePeriod: 0
|
||||
)
|
||||
return node
|
||||
}
|
||||
|
||||
@@ -108,8 +114,18 @@ final class TestNetworkHelper {
|
||||
let peer2ID = nodes[node2]?.peerID else { return }
|
||||
|
||||
let msg1 = try manager1.initiateHandshake(with: peer2ID)
|
||||
let msg2 = try manager2.handleIncomingHandshake(from: peer1ID, message: msg1)!
|
||||
let msg3 = try manager1.handleIncomingHandshake(from: peer2ID, message: msg2)!
|
||||
let msg2 = try #require(
|
||||
try manager2.handleIncomingHandshake(
|
||||
from: peer1ID,
|
||||
message: msg1
|
||||
)
|
||||
)
|
||||
let msg3 = try #require(
|
||||
try manager1.handleIncomingHandshake(
|
||||
from: peer2ID,
|
||||
message: msg2
|
||||
)
|
||||
)
|
||||
_ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
private var blockedFingerprints: Set<String> = []
|
||||
private var blockedNostrPubkeys: Set<String> = []
|
||||
private var socialIdentities: [String: SocialIdentity] = [:]
|
||||
private var privateMediaCapableFingerprints: Set<String> = []
|
||||
private var authenticatedSigningKeys: [String: Data] = [:]
|
||||
|
||||
init(_: KeychainManagerProtocol) {}
|
||||
|
||||
@@ -87,7 +89,10 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState) {}
|
||||
|
||||
func clearAllIdentityData() {}
|
||||
func clearAllIdentityData() {
|
||||
privateMediaCapableFingerprints.removeAll()
|
||||
authenticatedSigningKeys.removeAll()
|
||||
}
|
||||
|
||||
func removeEphemeralSession(peerID: PeerID) {}
|
||||
|
||||
@@ -101,6 +106,22 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
Set()
|
||||
}
|
||||
|
||||
func markPrivateMediaCapable(fingerprint: String) {
|
||||
privateMediaCapableFingerprints.insert(fingerprint)
|
||||
}
|
||||
|
||||
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool {
|
||||
privateMediaCapableFingerprints.contains(fingerprint)
|
||||
}
|
||||
|
||||
func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String) {
|
||||
authenticatedSigningKeys[fingerprint] = signingPublicKey
|
||||
}
|
||||
|
||||
func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data? {
|
||||
authenticatedSigningKeys[fingerprint]
|
||||
}
|
||||
|
||||
// MARK: Vouching (transitive verification)
|
||||
|
||||
private var vouchesByVouchee: [String: [VouchRecord]] = [:]
|
||||
|
||||
@@ -36,6 +36,7 @@ final class MockTransport: Transport {
|
||||
private(set) var sentFavoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = []
|
||||
private(set) var sentBroadcastFiles: [(packet: BitchatFilePacket, transferID: String)] = []
|
||||
private(set) var sentPrivateFiles: [(packet: BitchatFilePacket, peerID: PeerID, transferID: String)] = []
|
||||
private(set) var sentPrivateFileLegacyAllowances: [Bool] = []
|
||||
private(set) var cancelledTransfers: [String] = []
|
||||
private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||
private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||
@@ -58,6 +59,7 @@ final class MockTransport: Transport {
|
||||
var peerNicknames: [PeerID: String] = [:]
|
||||
var peerFingerprints: [PeerID: String] = [:]
|
||||
var peerNoiseStates: [PeerID: LazyHandshakeState] = [:]
|
||||
var privateMediaPolicies: [PeerID: PrivateMediaSendPolicy] = [:]
|
||||
private let mockKeychain = MockKeychain()
|
||||
|
||||
// MARK: - Transport Protocol Implementation
|
||||
@@ -186,6 +188,29 @@ final class MockTransport: Transport {
|
||||
|
||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
||||
sentPrivateFiles.append((packet, peerID, transferId))
|
||||
sentPrivateFileLegacyAllowances.append(false)
|
||||
}
|
||||
|
||||
func sendFilePrivate(
|
||||
_ packet: BitchatFilePacket,
|
||||
to peerID: PeerID,
|
||||
transferId: String,
|
||||
allowLegacyFallback: Bool
|
||||
) {
|
||||
sentPrivateFiles.append((packet, peerID, transferId))
|
||||
sentPrivateFileLegacyAllowances.append(allowLegacyFallback)
|
||||
}
|
||||
|
||||
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
|
||||
privateMediaPolicies[peerID] ?? .encrypted
|
||||
}
|
||||
|
||||
func resolvePrivateMediaSendPolicy(
|
||||
to peerID: PeerID,
|
||||
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||
) {
|
||||
let policy = privateMediaPolicies[peerID] ?? .encrypted
|
||||
Task { @MainActor in completion(policy) }
|
||||
}
|
||||
|
||||
func cancelTransfer(_ transferId: String) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import BitFoundation
|
||||
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("Noise Coverage Tests")
|
||||
@Suite("Noise Coverage Tests", .serialized)
|
||||
struct NoiseCoverageTests {
|
||||
private let keychain = MockKeychain()
|
||||
private let aliceStaticKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
@@ -542,8 +542,12 @@ struct NoiseCoverageTests {
|
||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain)
|
||||
let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
|
||||
|
||||
aliceManager.onSessionEstablished = establishedRecorder.recordEstablished(peerID:remoteKey:)
|
||||
bobManager.onSessionEstablished = establishedRecorder.recordEstablished(peerID:remoteKey:)
|
||||
aliceManager.onSessionEstablished = establishedRecorder.recordEstablished(
|
||||
peerID:remoteKey:sessionGeneration:
|
||||
)
|
||||
bobManager.onSessionEstablished = establishedRecorder.recordEstablished(
|
||||
peerID:remoteKey:sessionGeneration:
|
||||
)
|
||||
|
||||
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||
|
||||
@@ -629,8 +633,16 @@ struct NoiseCoverageTests {
|
||||
)
|
||||
let replacementSession = try #require(manager.getSession(for: alicePeerID))
|
||||
|
||||
#expect(replacementResponse != nil)
|
||||
#expect(replacementSession !== restartedSession)
|
||||
let localPeerID = PeerID(
|
||||
publicKey: aliceStaticKey.publicKey.rawRepresentation
|
||||
)
|
||||
if localPeerID < alicePeerID {
|
||||
#expect(replacementResponse == nil)
|
||||
#expect(replacementSession === restartedSession)
|
||||
} else {
|
||||
#expect(replacementResponse != nil)
|
||||
#expect(replacementSession !== restartedSession)
|
||||
}
|
||||
|
||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain)
|
||||
let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
|
||||
@@ -650,13 +662,128 @@ struct NoiseCoverageTests {
|
||||
try aliceManager.initiateHandshake(with: alicePeerID)
|
||||
}
|
||||
|
||||
try aliceManager.initiateRekey(for: alicePeerID)
|
||||
let rekeyInitiation = try aliceManager.initiateRekey(for: alicePeerID)
|
||||
let rekeyHandshake = try #require(
|
||||
aliceManager.claimHandshakeInitiation(
|
||||
rekeyInitiation,
|
||||
for: alicePeerID
|
||||
)
|
||||
)
|
||||
#expect(!rekeyHandshake.isEmpty)
|
||||
let rekeyedSession = try #require(aliceManager.getSession(for: alicePeerID))
|
||||
|
||||
#expect(rekeyedSession !== establishedSession)
|
||||
#expect(rekeyedSession.getState() == .handshaking)
|
||||
}
|
||||
|
||||
@Test("A stale decrypt generation cannot commit across session promotion")
|
||||
func staleDecryptGenerationCannotCommitAcrossPromotion() throws {
|
||||
let aliceManager = NoiseSessionManager(
|
||||
localStaticKey: aliceStaticKey,
|
||||
keychain: keychain,
|
||||
recentInitiatorCompletionGracePeriod: 0,
|
||||
sessionFactory: { peerID, role in
|
||||
BlockingDecryptNoiseSession(
|
||||
peerID: peerID,
|
||||
role: role,
|
||||
keychain: self.keychain,
|
||||
localStaticKey: self.aliceStaticKey
|
||||
)
|
||||
}
|
||||
)
|
||||
let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
|
||||
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||
|
||||
let oldSession = try #require(
|
||||
aliceManager.getSession(for: alicePeerID) as? BlockingDecryptNoiseSession
|
||||
)
|
||||
let oldGeneration = try #require(aliceManager.sessionGeneration(for: alicePeerID))
|
||||
|
||||
// Prepare a fully authenticated responder candidate without promoting
|
||||
// it yet. Its final XX message is the exact operation that replaces
|
||||
// the old `sessions[peerID]` entry.
|
||||
let replacementInitiator = NoiseSession(
|
||||
peerID: bobPeerID,
|
||||
role: .initiator,
|
||||
keychain: keychain,
|
||||
localStaticKey: bobStaticKey
|
||||
)
|
||||
let message1 = try replacementInitiator.startHandshake()
|
||||
let message2 = try #require(
|
||||
try aliceManager.handleIncomingHandshake(from: alicePeerID, message: message1)
|
||||
)
|
||||
let message3 = try #require(try replacementInitiator.processHandshakeMessage(message2))
|
||||
|
||||
let ciphertext = try bobManager.encrypt(Data("old session".utf8), for: bobPeerID)
|
||||
oldSession.pauseNextDecrypt()
|
||||
|
||||
let decryptResult = ConcurrentTestResult<(plaintext: Data, sessionGeneration: UUID)>()
|
||||
var promotionResultForCleanup: ConcurrentTestResult<Data?>?
|
||||
defer {
|
||||
// A failed startup requirement must not strand a late thread in
|
||||
// the blocking test double after the test has returned.
|
||||
oldSession.resumeDecrypt()
|
||||
_ = decryptResult.wait(timeout: 5)
|
||||
if let promotionResultForCleanup {
|
||||
_ = promotionResultForCleanup.wait(timeout: 5)
|
||||
}
|
||||
}
|
||||
|
||||
let decryptThread = Thread {
|
||||
decryptResult.capture {
|
||||
try aliceManager.decryptWithSessionGeneration(ciphertext, from: self.alicePeerID)
|
||||
}
|
||||
}
|
||||
decryptThread.name = "NoiseCoverageTests.staleDecrypt.decrypt"
|
||||
decryptThread.qualityOfService = .userInitiated
|
||||
decryptThread.start()
|
||||
try #require(oldSession.waitForDecryptStart(timeout: 5))
|
||||
|
||||
let promotionStarted = DispatchSemaphore(value: 0)
|
||||
let promotionResult = ConcurrentTestResult<Data?>()
|
||||
promotionResultForCleanup = promotionResult
|
||||
let promotionThread = Thread {
|
||||
promotionStarted.signal()
|
||||
promotionResult.capture {
|
||||
try aliceManager.handleIncomingHandshake(from: self.alicePeerID, message: message3)
|
||||
}
|
||||
}
|
||||
promotionThread.name = "NoiseCoverageTests.staleDecrypt.promote"
|
||||
promotionThread.qualityOfService = .userInitiated
|
||||
promotionThread.start()
|
||||
try #require(promotionStarted.wait(timeout: .now() + 5) == .success)
|
||||
#expect(
|
||||
promotionResult.wait(timeout: 0.05) == nil,
|
||||
"Promotion must wait for the exact decrypting-session lease"
|
||||
)
|
||||
|
||||
oldSession.resumeDecrypt()
|
||||
let decrypted = try #require(decryptResult.wait(timeout: 5)).get()
|
||||
_ = try #require(promotionResult.wait(timeout: 5)).get()
|
||||
|
||||
#expect(decrypted.plaintext == Data("old session".utf8))
|
||||
#expect(decrypted.sessionGeneration == oldGeneration)
|
||||
#expect(aliceManager.sessionGeneration(for: alicePeerID) != oldGeneration)
|
||||
#expect(throws: NoiseEncryptionError.sessionNotEstablished) {
|
||||
try aliceManager.encrypt(
|
||||
Data("stale send".utf8),
|
||||
for: alicePeerID,
|
||||
expectedSessionGeneration: oldGeneration
|
||||
)
|
||||
}
|
||||
|
||||
var staleCommitRan = false
|
||||
let staleCommit = aliceManager.withCurrentSessionGeneration(
|
||||
for: alicePeerID,
|
||||
expected: decrypted.sessionGeneration
|
||||
) {
|
||||
staleCommitRan = true
|
||||
return true
|
||||
}
|
||||
#expect(staleCommit == nil)
|
||||
#expect(!staleCommitRan)
|
||||
}
|
||||
|
||||
@Test("Secure noise sessions enforce limits and renegotiation thresholds")
|
||||
func secureNoiseSessionsEnforceLimitsAndThresholds() throws {
|
||||
let initiator = SecureNoiseSession(
|
||||
@@ -851,7 +978,11 @@ private final class SessionCallbackRecorder: @unchecked Sendable {
|
||||
return establishedEntries.map(\.0)
|
||||
}
|
||||
|
||||
func recordEstablished(peerID: PeerID, remoteKey: Curve25519.KeyAgreement.PublicKey) {
|
||||
func recordEstablished(
|
||||
peerID: PeerID,
|
||||
remoteKey: Curve25519.KeyAgreement.PublicKey,
|
||||
sessionGeneration _: UUID
|
||||
) {
|
||||
lock.lock()
|
||||
establishedEntries.append((peerID, remoteKey.rawRepresentation))
|
||||
lock.unlock()
|
||||
@@ -873,3 +1004,62 @@ private final class FailingNoiseSession: NoiseSession {
|
||||
throw Error.synthetic
|
||||
}
|
||||
}
|
||||
|
||||
private final class BlockingDecryptNoiseSession: NoiseSession, @unchecked Sendable {
|
||||
private let controlLock = NSLock()
|
||||
private var shouldPauseNextDecrypt = false
|
||||
private let decryptStarted = DispatchSemaphore(value: 0)
|
||||
private let resumeDecryptSemaphore = DispatchSemaphore(value: 0)
|
||||
|
||||
func pauseNextDecrypt() {
|
||||
controlLock.lock()
|
||||
shouldPauseNextDecrypt = true
|
||||
controlLock.unlock()
|
||||
}
|
||||
|
||||
func waitForDecryptStart(timeout: TimeInterval) -> Bool {
|
||||
decryptStarted.wait(timeout: .now() + timeout) == .success
|
||||
}
|
||||
|
||||
func resumeDecrypt() {
|
||||
resumeDecryptSemaphore.signal()
|
||||
}
|
||||
|
||||
override func decrypt(_ ciphertext: Data) throws -> Data {
|
||||
controlLock.lock()
|
||||
let shouldPause = shouldPauseNextDecrypt
|
||||
shouldPauseNextDecrypt = false
|
||||
controlLock.unlock()
|
||||
|
||||
if shouldPause {
|
||||
decryptStarted.signal()
|
||||
resumeDecryptSemaphore.wait()
|
||||
}
|
||||
return try super.decrypt(ciphertext)
|
||||
}
|
||||
}
|
||||
|
||||
private final class ConcurrentTestResult<Value>: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private let completed = DispatchGroup()
|
||||
private var storedResult: Result<Value, Error>?
|
||||
|
||||
init() {
|
||||
completed.enter()
|
||||
}
|
||||
|
||||
func capture(_ operation: () throws -> Value) {
|
||||
let result = Result(catching: operation)
|
||||
lock.lock()
|
||||
storedResult = result
|
||||
lock.unlock()
|
||||
completed.leave()
|
||||
}
|
||||
|
||||
func wait(timeout: TimeInterval) -> Result<Value, Error>? {
|
||||
guard completed.wait(timeout: .now() + timeout) == .success else { return nil }
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return storedResult
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,8 +357,18 @@ struct NoiseProtocolTests {
|
||||
|
||||
@Test func peerRestartDetection() throws {
|
||||
// Establish initial sessions
|
||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
||||
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
||||
// This test explicitly drives the three synchronous XX messages and
|
||||
// does not exercise the transport's delayed collision recovery.
|
||||
let aliceManager = NoiseSessionManager(
|
||||
localStaticKey: aliceKey,
|
||||
keychain: mockKeychain,
|
||||
recentInitiatorCompletionGracePeriod: 0
|
||||
)
|
||||
let bobManager = NoiseSessionManager(
|
||||
localStaticKey: bobKey,
|
||||
keychain: mockKeychain,
|
||||
recentInitiatorCompletionGracePeriod: 0
|
||||
)
|
||||
|
||||
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||
|
||||
@@ -377,15 +387,24 @@ struct NoiseProtocolTests {
|
||||
let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: bobPeerID)
|
||||
|
||||
// Alice should accept the new handshake (clearing old session)
|
||||
let newHandshake2 = try aliceManager.handleIncomingHandshake(
|
||||
from: alicePeerID, message: newHandshake1)
|
||||
#expect(newHandshake2 != nil)
|
||||
let newHandshake2 = try #require(
|
||||
try aliceManager.handleIncomingHandshake(
|
||||
from: alicePeerID,
|
||||
message: newHandshake1
|
||||
)
|
||||
)
|
||||
|
||||
// Complete the new handshake
|
||||
let newHandshake3 = try bobManagerRestarted.handleIncomingHandshake(
|
||||
from: bobPeerID, message: newHandshake2!)
|
||||
#expect(newHandshake3 != nil)
|
||||
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake3!)
|
||||
let newHandshake3 = try #require(
|
||||
try bobManagerRestarted.handleIncomingHandshake(
|
||||
from: bobPeerID,
|
||||
message: newHandshake2
|
||||
)
|
||||
)
|
||||
_ = try aliceManager.handleIncomingHandshake(
|
||||
from: alicePeerID,
|
||||
message: newHandshake3
|
||||
)
|
||||
|
||||
// Should be able to exchange messages with new sessions
|
||||
let testMessage = Data("After restart".utf8)
|
||||
@@ -543,8 +562,18 @@ struct NoiseProtocolTests {
|
||||
|
||||
@Test func nonceDesynchronizationCausesRehandshake() throws {
|
||||
// Test that nonce desynchronization leads to proper re-handshake
|
||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
||||
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
||||
// This test explicitly drives the three synchronous XX messages and
|
||||
// does not exercise the transport's delayed collision recovery.
|
||||
let aliceManager = NoiseSessionManager(
|
||||
localStaticKey: aliceKey,
|
||||
keychain: mockKeychain,
|
||||
recentInitiatorCompletionGracePeriod: 0
|
||||
)
|
||||
let bobManager = NoiseSessionManager(
|
||||
localStaticKey: bobKey,
|
||||
keychain: mockKeychain,
|
||||
recentInitiatorCompletionGracePeriod: 0
|
||||
)
|
||||
|
||||
// Establish sessions
|
||||
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||
@@ -572,15 +601,25 @@ struct NoiseProtocolTests {
|
||||
let rehandshake1 = try bobManager.initiateHandshake(with: bobPeerID)
|
||||
|
||||
// Alice should accept despite having a "valid" (but desynced) session
|
||||
let rehandshake2 = try aliceManager.handleIncomingHandshake(
|
||||
from: alicePeerID, message: rehandshake1)
|
||||
#expect(rehandshake2 != nil, "Alice should accept handshake to fix desync")
|
||||
let rehandshake2 = try #require(
|
||||
try aliceManager.handleIncomingHandshake(
|
||||
from: alicePeerID,
|
||||
message: rehandshake1
|
||||
),
|
||||
"Alice should accept handshake to fix desync"
|
||||
)
|
||||
|
||||
// Complete handshake
|
||||
let rehandshake3 = try bobManager.handleIncomingHandshake(
|
||||
from: bobPeerID, message: rehandshake2!)
|
||||
#expect(rehandshake3 != nil)
|
||||
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: rehandshake3!)
|
||||
let rehandshake3 = try #require(
|
||||
try bobManager.handleIncomingHandshake(
|
||||
from: bobPeerID,
|
||||
message: rehandshake2
|
||||
)
|
||||
)
|
||||
_ = try aliceManager.handleIncomingHandshake(
|
||||
from: alicePeerID,
|
||||
message: rehandshake3
|
||||
)
|
||||
|
||||
// Verify communication works again
|
||||
let testResynced = Data("Resynced".utf8)
|
||||
|
||||
@@ -5,25 +5,19 @@ import XCTest
|
||||
|
||||
@MainActor
|
||||
final class GeoRelayDirectoryTests: XCTestCase {
|
||||
private func parse(_ csv: String) -> [GeoRelayDirectory.Entry] {
|
||||
GeoRelayDirectory.validatedEntries(
|
||||
from: Data(csv.utf8),
|
||||
policy: .live,
|
||||
minimumEntries: 1
|
||||
) ?? []
|
||||
}
|
||||
|
||||
func test_parseCSV_normalizesSecureRelaySchemesAndDeduplicatesEntries() {
|
||||
func test_parseCSV_normalizesRelaySchemesAndDeduplicatesEntries() {
|
||||
let csv = """
|
||||
relay url,lat,lon
|
||||
wss://one.example/,10,20
|
||||
https://one.example,10,20
|
||||
wss://one.example:443/,10,20
|
||||
two.example,11,21
|
||||
http://two.example/,11,21
|
||||
wss://two.example:443,11,21
|
||||
invalid row
|
||||
ws://three.example,not-a-lat,22
|
||||
"""
|
||||
|
||||
let parsed = Set(parse(csv))
|
||||
let parsed = Set(GeoRelayDirectory.parseCSV(csv))
|
||||
|
||||
XCTAssertEqual(
|
||||
parsed,
|
||||
@@ -34,136 +28,6 @@ final class GeoRelayDirectoryTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
func test_parseCSV_rejectsWholeDatasetWhenAnyRowOrHeaderIsUnsafe() {
|
||||
let invalidCSVs = [
|
||||
"relay,lat,lon\nrelay.example,1,2\n",
|
||||
"relay url,lat,lon\nrelay.example,1\n",
|
||||
"relay url,lat,lon\nhttp://relay.example,1,2\n",
|
||||
"relay url,lat,lon\nwss://user@relay.example,1,2\n",
|
||||
"relay url,lat,lon\nwss://relay.example/path,1,2\n",
|
||||
"relay url,lat,lon\nwss://relay.example?,1,2\n",
|
||||
"relay url,lat,lon\nwss://relay.example#,1,2\n",
|
||||
"relay url,lat,lon\nrelay.example:0,1,2\n",
|
||||
"relay url,lat,lon\nrelay.example:99999,1,2\n",
|
||||
"relay url,lat,lon\nlocalhost,1,2\n",
|
||||
"relay url,lat,lon\nr\u{00e9}lay.example,1,2\n",
|
||||
"relay url,lat,lon\nrelay\u{202e}.example,1,2\n",
|
||||
"relay url,lat,lon\nrelay.example,NaN,2\n",
|
||||
"relay url,lat,lon\nrelay.example,1_0,2\n",
|
||||
"relay url,lat,lon\nrelay.example,\u{0661}\u{0660},2\n",
|
||||
"relay url,lat,lon\nrelay.example,\u{ff11}\u{ff10},2\n",
|
||||
"relay url,lat,lon\nrelay.example,91,2\n",
|
||||
"relay url,lat,lon\nrelay.example,1,181\n",
|
||||
"relay url,lat,lon\nrelay.example,1,2\nrelay.example,3,4\n"
|
||||
]
|
||||
|
||||
for csv in invalidCSVs {
|
||||
XCTAssertTrue(parse(csv).isEmpty, csv)
|
||||
}
|
||||
}
|
||||
|
||||
func test_validatedEntries_enforcesByteRowEntryAndRetentionLimits() {
|
||||
let restrictive = GeoRelayDirectoryValidationPolicy(
|
||||
maximumBytes: 100,
|
||||
maximumRows: 2,
|
||||
maximumEntries: 2,
|
||||
minimumRemoteEntries: 1,
|
||||
minimumRetainedFraction: 0.5
|
||||
)
|
||||
let one = Data("relay url,lat,lon\none.example,1,2\n".utf8)
|
||||
let three = Data("relay url,lat,lon\none.example,1,2\ntwo.example,3,4\nthree.example,5,6\n".utf8)
|
||||
|
||||
XCTAssertNil(GeoRelayDirectory.validatedEntries(
|
||||
from: one,
|
||||
policy: restrictive,
|
||||
minimumEntries: 2
|
||||
))
|
||||
XCTAssertNil(GeoRelayDirectory.validatedEntries(
|
||||
from: Data(repeating: 0x41, count: 101),
|
||||
policy: restrictive,
|
||||
minimumEntries: 1
|
||||
))
|
||||
XCTAssertNil(GeoRelayDirectory.validatedEntries(
|
||||
from: three,
|
||||
policy: restrictive,
|
||||
minimumEntries: 1
|
||||
))
|
||||
}
|
||||
|
||||
func test_validatedEntries_requiresExactBaselineEntryOverlap() throws {
|
||||
let policy = GeoRelayDirectoryValidationPolicy(
|
||||
maximumBytes: 1_000,
|
||||
maximumRows: 10,
|
||||
maximumEntries: 10,
|
||||
minimumRemoteEntries: 1,
|
||||
minimumRetainedFraction: 0.5
|
||||
)
|
||||
let baseline = Set(try XCTUnwrap(GeoRelayDirectory.validatedEntries(
|
||||
from: Data("""
|
||||
relay url,lat,lon
|
||||
one.example,1,1
|
||||
two.example,2,2
|
||||
three.example,3,3
|
||||
""".utf8),
|
||||
policy: policy,
|
||||
minimumEntries: 1
|
||||
)))
|
||||
let disjoint = Data("""
|
||||
relay url,lat,lon
|
||||
four.example,1,1
|
||||
five.example,2,2
|
||||
six.example,3,3
|
||||
""".utf8)
|
||||
let rewrittenCoordinates = Data("""
|
||||
relay url,lat,lon
|
||||
one.example,11,11
|
||||
two.example,12,12
|
||||
three.example,13,13
|
||||
""".utf8)
|
||||
let halfRetained = Data("""
|
||||
relay url,lat,lon
|
||||
wss://one.example:443/,1,1
|
||||
https://two.example/,2,2
|
||||
replacement.example,4,4
|
||||
""".utf8)
|
||||
|
||||
XCTAssertNil(GeoRelayDirectory.validatedEntries(
|
||||
from: disjoint,
|
||||
policy: policy,
|
||||
minimumEntries: 1,
|
||||
baselineEntries: baseline
|
||||
))
|
||||
XCTAssertNil(GeoRelayDirectory.validatedEntries(
|
||||
from: rewrittenCoordinates,
|
||||
policy: policy,
|
||||
minimumEntries: 1,
|
||||
baselineEntries: baseline
|
||||
))
|
||||
XCTAssertNotNil(GeoRelayDirectory.validatedEntries(
|
||||
from: halfRetained,
|
||||
policy: policy,
|
||||
minimumEntries: 1,
|
||||
baselineEntries: baseline
|
||||
))
|
||||
}
|
||||
|
||||
func test_bundledReviewedCSV_passesStrictProductionValidation() throws {
|
||||
let repositoryRoot = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
let data = try Data(
|
||||
contentsOf: repositoryRoot.appendingPathComponent("relays/online_relays_gps.csv")
|
||||
)
|
||||
|
||||
let entries = try XCTUnwrap(GeoRelayDirectory.validatedEntries(
|
||||
from: data,
|
||||
policy: .live,
|
||||
minimumEntries: GeoRelayDirectoryValidationPolicy.live.minimumRemoteEntries
|
||||
))
|
||||
XCTAssertGreaterThan(entries.count, 250)
|
||||
}
|
||||
|
||||
func test_closestRelays_sortsByDistanceForLatLonAndGeohash() {
|
||||
let harness = makeHarness(
|
||||
cacheCSV: """
|
||||
@@ -379,53 +243,6 @@ final class GeoRelayDirectoryTests: XCTestCase {
|
||||
XCTAssertFalse(directory.debugHasRetryTask)
|
||||
}
|
||||
|
||||
func test_prefetchIfNeeded_rejectsSharpValidLookingTruncationBeforeCaching() async {
|
||||
let cached = """
|
||||
relay url,lat,lon
|
||||
old-one.example,1,1
|
||||
old-two.example,2,2
|
||||
old-three.example,3,3
|
||||
"""
|
||||
let truncated = """
|
||||
relay url,lat,lon
|
||||
attacker.example,9,9
|
||||
"""
|
||||
let recovered = """
|
||||
relay url,lat,lon
|
||||
old-one.example,1,1
|
||||
old-two.example,2,2
|
||||
new-three.example,6,6
|
||||
"""
|
||||
let harness = makeHarness(
|
||||
cacheCSV: cached,
|
||||
fetchResults: [
|
||||
.success(Data(truncated.utf8)),
|
||||
.success(Data(recovered.utf8))
|
||||
],
|
||||
validationPolicy: GeoRelayDirectoryValidationPolicy(
|
||||
maximumBytes: 64 * 1024,
|
||||
maximumRows: 1_000,
|
||||
maximumEntries: 1_000,
|
||||
minimumRemoteEntries: 1,
|
||||
minimumRetainedFraction: 0.5
|
||||
)
|
||||
)
|
||||
let directory = GeoRelayDirectory(dependencies: harness.dependencies)
|
||||
|
||||
directory.prefetchIfNeeded()
|
||||
|
||||
let refreshed = await waitUntil {
|
||||
directory.entries.contains(where: { $0.host == "new-three.example" })
|
||||
}
|
||||
XCTAssertTrue(refreshed)
|
||||
XCTAssertFalse(directory.entries.contains(where: { $0.host == "attacker.example" }))
|
||||
let requestCount = await harness.fetcher.recordedRequestCount()
|
||||
let retryDelays = await harness.retryRecorder.recordedDelays()
|
||||
XCTAssertEqual(requestCount, 2)
|
||||
XCTAssertEqual(retryDelays, [5])
|
||||
XCTAssertEqual(harness.fileStore.dataByURL[harness.cacheURL], Data(recovered.utf8))
|
||||
}
|
||||
|
||||
func test_observers_triggerPrefetchesForTorReadyAndAppActivation() async {
|
||||
let activeNotification = Notification.Name("GeoRelayDirectoryTests.didBecomeActive")
|
||||
let harness = makeHarness(
|
||||
@@ -472,14 +289,7 @@ final class GeoRelayDirectoryTests: XCTestCase {
|
||||
fetchFactoryObserver: (@MainActor @Sendable () -> Void)? = nil,
|
||||
fetchObserver: (@Sendable () async -> Void)? = nil,
|
||||
autoStart: Bool = false,
|
||||
activeNotificationName: Notification.Name? = nil,
|
||||
validationPolicy: GeoRelayDirectoryValidationPolicy = GeoRelayDirectoryValidationPolicy(
|
||||
maximumBytes: 64 * 1024,
|
||||
maximumRows: 1_000,
|
||||
maximumEntries: 1_000,
|
||||
minimumRemoteEntries: 1,
|
||||
minimumRetainedFraction: 0
|
||||
)
|
||||
activeNotificationName: Notification.Name? = nil
|
||||
) -> GeoRelayHarness {
|
||||
let userDefaultsSuite = "GeoRelayDirectoryTests.\(UUID().uuidString)"
|
||||
let userDefaults = UserDefaults(suiteName: userDefaultsSuite)!
|
||||
@@ -537,8 +347,7 @@ final class GeoRelayDirectoryTests: XCTestCase {
|
||||
await retryRecorder.record(delay)
|
||||
},
|
||||
activeNotificationName: activeNotificationName,
|
||||
autoStart: autoStart,
|
||||
validationPolicy: validationPolicy
|
||||
autoStart: autoStart
|
||||
)
|
||||
|
||||
return GeoRelayHarness(
|
||||
|
||||
@@ -501,62 +501,6 @@ final class PerformanceBaselineTests: XCTestCase {
|
||||
reportThroughput("store.append", samples: samples, operations: messageCount, unit: "messages")
|
||||
}
|
||||
|
||||
// MARK: - 7b. ConversationStore append at the retention cap
|
||||
|
||||
/// Steady-state public timeline traffic after the 1337-message retention
|
||||
/// cap has been reached. Every tail append evicts the oldest row, which is
|
||||
/// the long-lived workload the cold `store.append` benchmark does not
|
||||
/// exercise.
|
||||
func testConversationStoreSteadyStateAppend() {
|
||||
let store = ConversationStore()
|
||||
let cap = TransportConfig.meshTimelineCap
|
||||
let messagesPerPass = 500
|
||||
let base = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
|
||||
for i in 0..<cap {
|
||||
store.append(
|
||||
BitchatMessage(
|
||||
id: "perf-steady-seed-\(i)",
|
||||
sender: "perfsender",
|
||||
content: "steady-state seed \(i)",
|
||||
timestamp: base.addingTimeInterval(Double(i)),
|
||||
isRelay: false
|
||||
),
|
||||
to: .mesh
|
||||
)
|
||||
}
|
||||
|
||||
var pass = 0
|
||||
var samples: [TimeInterval] = []
|
||||
measure {
|
||||
let startIndex = cap + pass * messagesPerPass
|
||||
let start = Date()
|
||||
for offset in 0..<messagesPerPass {
|
||||
let i = startIndex + offset
|
||||
store.append(
|
||||
BitchatMessage(
|
||||
id: "perf-steady-\(i)",
|
||||
sender: "perfsender",
|
||||
content: "steady-state message \(i)",
|
||||
timestamp: base.addingTimeInterval(Double(i)),
|
||||
isRelay: false
|
||||
),
|
||||
to: .mesh
|
||||
)
|
||||
}
|
||||
samples.append(Date().timeIntervalSince(start))
|
||||
pass += 1
|
||||
XCTAssertEqual(store.conversation(for: .mesh).messages.count, cap)
|
||||
}
|
||||
|
||||
reportThroughput(
|
||||
"store.steadyStateAppend",
|
||||
samples: samples,
|
||||
operations: messagesPerPass,
|
||||
unit: "messages"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 8. ConversationStore invariant audit (field observability)
|
||||
|
||||
/// `ConversationStore.auditInvariants()` over a realistic 5k-message
|
||||
|
||||
@@ -30,10 +30,6 @@
|
||||
"store.append": 213201,
|
||||
"store.audit": 362
|
||||
},
|
||||
"_reference_local_numbers_2026_07": {
|
||||
"store.steadyStateAppend_before": 2315,
|
||||
"store.steadyStateAppend": 53976
|
||||
},
|
||||
"floors": {
|
||||
"nostrInbound.fresh": 450,
|
||||
"nostrInbound.duplicate": 250000,
|
||||
@@ -45,7 +41,6 @@
|
||||
"pipeline.privateIngest": 3000,
|
||||
"pipeline.publicIngest": 2400,
|
||||
"store.append": 48000,
|
||||
"store.steadyStateAppend": 10000,
|
||||
"store.audit": 70
|
||||
},
|
||||
"_slowest_observed_ci_numbers_2026_06": {
|
||||
@@ -61,4 +56,4 @@
|
||||
"store.append": 97423,
|
||||
"store.audit": 140
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -145,6 +145,39 @@ struct PacketsTests {
|
||||
#expect(decoded.capabilities?.rawValue == 0x0180)
|
||||
}
|
||||
|
||||
@Test
|
||||
func authenticatedPeerStateUsesVersionedCanonicalTLVs() throws {
|
||||
let signingKey = Data(repeating: 0xA5, count: 32)
|
||||
let packet = AuthenticatedPeerStatePacket(
|
||||
capabilities: [.privateMedia, .vouch],
|
||||
signingPublicKey: signingKey
|
||||
)
|
||||
|
||||
var encoded = try #require(packet.encode())
|
||||
#expect(encoded.prefix(5) == Data([0x01, 0x01, 0x02, 0x20, 0x01]))
|
||||
// Unknown TLVs are forward-compatible and do not alter v1 state.
|
||||
encoded.append(makeTLV(type: 0x7F, value: Data([0xCA, 0xFE])))
|
||||
|
||||
#expect(AuthenticatedPeerStatePacket.decode(from: encoded) == packet)
|
||||
}
|
||||
|
||||
@Test
|
||||
func authenticatedPeerStateRejectsMalformedAmbiguousOrUnknownVersion() {
|
||||
let key = Data(repeating: 0x44, count: 32)
|
||||
let capabilities = makeTLV(type: 0x01, value: Data([0x00, 0x01]))
|
||||
let signing = makeTLV(type: 0x02, value: key)
|
||||
|
||||
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x02]) + capabilities + signing) == nil)
|
||||
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + signing) == nil)
|
||||
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + capabilities + capabilities + signing) == nil)
|
||||
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01, 0x01, 0x00]) + signing) == nil)
|
||||
// 0x0001 is non-minimal little endian; the canonical form is [0x01].
|
||||
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + makeTLV(type: 0x01, value: Data([0x01, 0x00])) + signing) == nil)
|
||||
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + capabilities + makeTLV(type: 0x02, value: Data(key.dropLast()))) == nil)
|
||||
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + capabilities + Data(signing.dropLast())) == nil)
|
||||
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + makeTLV(type: 0x01, value: Data(repeating: 0x01, count: 9)) + signing) == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func privateMessagePacketRejectsUnknownTypeAndTruncation() {
|
||||
let unknownTLV = Data([0x7F, 0x01, 0x41])
|
||||
|
||||
@@ -6,6 +6,7 @@ import Testing
|
||||
struct BLEAnnounceHandlerTests {
|
||||
private final class Recorder {
|
||||
var existingNoisePublicKey: Data?
|
||||
var authenticatedSigningPublicKey: Data?
|
||||
var signatureValid = true
|
||||
var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false)
|
||||
var linkBoundToOtherPeer = false
|
||||
@@ -37,6 +38,7 @@ struct BLEAnnounceHandlerTests {
|
||||
messageTTL: TransportConfig.messageTTLDefault,
|
||||
now: { now },
|
||||
existingNoisePublicKey: { _ in recorder.existingNoisePublicKey },
|
||||
authenticatedSigningPublicKey: { _ in recorder.authenticatedSigningPublicKey },
|
||||
verifySignature: { packet, signingPublicKey in
|
||||
recorder.verifySignatureCalls.append((packet, signingPublicKey))
|
||||
return recorder.signatureValid
|
||||
|
||||
@@ -169,6 +169,23 @@ struct BLEAnnounceHandlingPolicyTests {
|
||||
#expect(decision.isVerified)
|
||||
}
|
||||
|
||||
@Test
|
||||
func trustPolicyRejectsSigningKeyReplacementAfterNoiseBinding() {
|
||||
let noiseKey = Data(repeating: 0xCC, count: 32)
|
||||
let boundSigningKey = Data(repeating: 0x11, count: 32)
|
||||
|
||||
let decision = BLEAnnounceTrustPolicy.evaluate(
|
||||
hasSignature: true,
|
||||
signatureValid: true,
|
||||
existingNoisePublicKey: noiseKey,
|
||||
announcedNoisePublicKey: noiseKey,
|
||||
authenticatedSigningPublicKey: boundSigningKey,
|
||||
announcedSigningPublicKey: Data(repeating: 0x22, count: 32)
|
||||
)
|
||||
|
||||
#expect(decision == .reject(.authenticatedSigningKeyMismatch))
|
||||
}
|
||||
|
||||
@Test
|
||||
func responsePolicyConnectsOnlyForDirectNewOrReconnectedPeers() {
|
||||
let directNew = BLEAnnounceResponsePolicy.plan(
|
||||
|
||||
@@ -5,7 +5,7 @@ import Testing
|
||||
struct BLEAnnounceThrottleTests {
|
||||
@Test
|
||||
func firstAnnounceIsAllowed() {
|
||||
let throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||
var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||
|
||||
let shouldSend = throttle.shouldSend(force: false, now: Date(timeIntervalSince1970: 100))
|
||||
|
||||
@@ -15,7 +15,7 @@ struct BLEAnnounceThrottleTests {
|
||||
@Test
|
||||
func regularAnnounceUsesNormalMinimumInterval() {
|
||||
let now = Date(timeIntervalSince1970: 100)
|
||||
let throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||
var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||
|
||||
let first = throttle.shouldSend(force: false, now: now)
|
||||
let suppressed = throttle.shouldSend(force: false, now: now.addingTimeInterval(9.9))
|
||||
@@ -29,7 +29,7 @@ struct BLEAnnounceThrottleTests {
|
||||
@Test
|
||||
func forcedAnnounceUsesShorterMinimumInterval() {
|
||||
let now = Date(timeIntervalSince1970: 100)
|
||||
let throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||
var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||
|
||||
let first = throttle.shouldSend(force: false, now: now)
|
||||
let suppressed = throttle.shouldSend(force: true, now: now.addingTimeInterval(1.9))
|
||||
@@ -43,40 +43,10 @@ struct BLEAnnounceThrottleTests {
|
||||
@Test
|
||||
func elapsedReportsTimeSinceAcceptedSend() {
|
||||
let now = Date(timeIntervalSince1970: 100)
|
||||
let throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||
var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||
|
||||
_ = throttle.shouldSend(force: false, now: now)
|
||||
|
||||
#expect(throttle.elapsed(since: now.addingTimeInterval(3)) == 3)
|
||||
}
|
||||
|
||||
@Test
|
||||
func concurrentRequestsAdmitOnlyOneAnnounce() {
|
||||
let now = Date(timeIntervalSince1970: 100)
|
||||
let throttle = BLEAnnounceThrottle(
|
||||
normalMinimumInterval: 10,
|
||||
forcedMinimumInterval: 2
|
||||
)
|
||||
let accepted = LockedCounter()
|
||||
|
||||
DispatchQueue.concurrentPerform(iterations: 1_000) { _ in
|
||||
if throttle.shouldSend(force: false, now: now) {
|
||||
accepted.increment()
|
||||
}
|
||||
}
|
||||
|
||||
#expect(accepted.value == 1)
|
||||
#expect(throttle.elapsed(since: now.addingTimeInterval(3)) == 3)
|
||||
}
|
||||
}
|
||||
|
||||
private final class LockedCounter: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var count = 0
|
||||
|
||||
var value: Int { lock.withLock { count } }
|
||||
|
||||
func increment() {
|
||||
lock.withLock { count += 1 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ struct BLEFileTransferHandlerTests {
|
||||
recorder.signatureVerifyCount += 1
|
||||
return recorder.signatureVerifies
|
||||
},
|
||||
localSigningPublicKey: { [sampleSigningKey] in sampleSigningKey },
|
||||
signedSenderDisplayName: { _, peerID in
|
||||
recorder.signedNameQueries.append(peerID)
|
||||
return recorder.signedName
|
||||
@@ -92,12 +93,11 @@ struct BLEFileTransferHandlerTests {
|
||||
@Test
|
||||
func selfEchoIsDropped() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.signatureVerifies = true
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = try makeFileTransferPacket(sender: localPeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8), ttl: 3)
|
||||
|
||||
// The relay pipeline already suppresses self-originated packets, so the
|
||||
// handler reports "relayable" rather than treating the echo as forged.
|
||||
#expect(handler.handle(packet, from: localPeerID))
|
||||
#expect(!handler.handle(packet, from: localPeerID))
|
||||
|
||||
expectNoSideEffects(recorder)
|
||||
}
|
||||
@@ -120,7 +120,12 @@ struct BLEFileTransferHandlerTests {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)]
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8))
|
||||
let packet = try makeFileTransferPacket(
|
||||
sender: remotePeerID,
|
||||
mimeType: "application/pdf",
|
||||
content: Data("%PDF-1.7".utf8),
|
||||
hasSignature: false
|
||||
)
|
||||
|
||||
// Failed sender authentication must also stop the packet from being
|
||||
// relayed to downstream nodes.
|
||||
@@ -129,7 +134,7 @@ struct BLEFileTransferHandlerTests {
|
||||
// Broadcast files carry an attacker-controllable senderID, so — like
|
||||
// public messages — a connected-but-unverified peer must present a valid
|
||||
// packet signature. No signing key + no signed identity means dropped.
|
||||
#expect(recorder.signedNameQueries == [remotePeerID])
|
||||
#expect(recorder.signedNameQueries.isEmpty)
|
||||
#expect(recorder.trackedPackets.isEmpty)
|
||||
#expect(recorder.deliveredMessages.isEmpty)
|
||||
}
|
||||
@@ -153,12 +158,11 @@ struct BLEFileTransferHandlerTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func selfBroadcastReplayIsDeliveredWithoutSignatureCheck() throws {
|
||||
// Our own broadcast file replayed via gossip sync arrives with ttl==0
|
||||
// (so it is not treated as a self-echo) and cannot be verified against
|
||||
// the peer registry — it must still be accepted, matching
|
||||
// BLEPublicMessageHandler's self exemption.
|
||||
func signedSelfBroadcastReplayIsDelivered() throws {
|
||||
// Our own broadcast file replayed via gossip sync arrives with ttl==0;
|
||||
// it is verified against our local signing key before delivery.
|
||||
let recorder = Recorder()
|
||||
recorder.signatureVerifies = true
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = try makeFileTransferPacket(
|
||||
sender: localPeerID,
|
||||
@@ -169,7 +173,7 @@ struct BLEFileTransferHandlerTests {
|
||||
|
||||
#expect(handler.handle(packet, from: localPeerID))
|
||||
|
||||
#expect(recorder.signatureVerifyCount == 0)
|
||||
#expect(recorder.signatureVerifyCount == 1)
|
||||
#expect(recorder.signedNameQueries.isEmpty)
|
||||
#expect(recorder.deliveredMessages.count == 1)
|
||||
#expect(recorder.deliveredMessages.first?.sender == "Me")
|
||||
@@ -205,7 +209,8 @@ struct BLEFileTransferHandlerTests {
|
||||
sender: remotePeerID,
|
||||
mimeType: "audio/mp4",
|
||||
content: m4a,
|
||||
fileName: "voice_1122334455667788"
|
||||
fileName: "voice_1122334455667788",
|
||||
hasSignature: false
|
||||
)
|
||||
|
||||
// The spoofed note must be dropped locally AND not relayed onward.
|
||||
@@ -215,7 +220,7 @@ struct BLEFileTransferHandlerTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func privateFileFromConnectedUnverifiedPeerIsAccepted() throws {
|
||||
func rawDirectedFileWithoutVerifiableSignatureIsDroppedWithoutWriteOrRelay() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)]
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
@@ -223,23 +228,25 @@ struct BLEFileTransferHandlerTests {
|
||||
sender: remotePeerID,
|
||||
mimeType: "application/pdf",
|
||||
content: Data("%PDF-1.7".utf8),
|
||||
recipientID: Data(hexString: localPeerID.id)
|
||||
recipientID: Data(hexString: localPeerID.id),
|
||||
hasSignature: false
|
||||
)
|
||||
|
||||
#expect(handler.handle(packet, from: remotePeerID))
|
||||
#expect(!handler.handle(packet, from: remotePeerID))
|
||||
|
||||
// Directed transfers keep the lenient connected-peer path (no broadcast
|
||||
// exposure); no signature check is required.
|
||||
#expect(recorder.signatureVerifyCount == 0)
|
||||
#expect(recorder.signedNameQueries.isEmpty)
|
||||
#expect(recorder.deliveredMessages.count == 1)
|
||||
#expect(recorder.deliveredMessages.first?.isPrivate == true)
|
||||
#expect(recorder.trackedPackets.isEmpty)
|
||||
#expect(recorder.quotaReservations.isEmpty)
|
||||
#expect(recorder.saveCalls.isEmpty)
|
||||
#expect(recorder.deliveredMessages.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func fileDirectedToAnotherPeerIsIgnored() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)]
|
||||
recorder.signatureVerifies = true
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = try makeFileTransferPacket(
|
||||
sender: remotePeerID,
|
||||
@@ -260,7 +267,8 @@ struct BLEFileTransferHandlerTests {
|
||||
@Test
|
||||
func privateFileUpdatesLastSeenAndDeliversPrivateMessage() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)]
|
||||
recorder.signatureVerifies = true
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = try makeFileTransferPacket(
|
||||
sender: remotePeerID,
|
||||
@@ -282,6 +290,56 @@ struct BLEFileTransferHandlerTests {
|
||||
#expect(recorder.deliveredMessages.first?.deliveryStatus == .delivered(to: "Me", at: Date(timeIntervalSince1970: 900)))
|
||||
}
|
||||
|
||||
@Test
|
||||
func decryptedPrivateFileUsesValidationQuotaAndPrivateDeliveryWithoutRawSignature() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
|
||||
let file = BitchatFilePacket(
|
||||
fileName: "secret.jpg",
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "image/jpeg",
|
||||
content: content
|
||||
)
|
||||
let payload = try #require(file.encode())
|
||||
let timestamp = Date(timeIntervalSince1970: 1_234)
|
||||
|
||||
#expect(handler.handlePrivatePayload(payload, from: remotePeerID, timestamp: timestamp))
|
||||
|
||||
#expect(recorder.signatureVerifyCount == 0)
|
||||
#expect(recorder.signedNameQueries.isEmpty)
|
||||
#expect(recorder.trackedPackets.isEmpty)
|
||||
#expect(recorder.quotaReservations == [content.count])
|
||||
#expect(recorder.saveCalls.first?.data == content)
|
||||
#expect(recorder.lastSeenUpdates == [remotePeerID])
|
||||
#expect(recorder.deliveredMessages.count == 1)
|
||||
#expect(recorder.deliveredMessages.first?.isPrivate == true)
|
||||
#expect(recorder.deliveredMessages.first?.timestamp == timestamp)
|
||||
}
|
||||
|
||||
@Test
|
||||
func decryptedPrivateFileOverPayloadCapIsRejectedBeforeQuotaOrDiskWrite() {
|
||||
let recorder = Recorder()
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let oversizedCount = FileTransferLimits.maxPayloadBytes + 1
|
||||
var length = UInt32(oversizedCount).bigEndian
|
||||
var payload = Data([0x04]) // BitchatFilePacket CONTENT TLV
|
||||
withUnsafeBytes(of: &length) { payload.append(contentsOf: $0) }
|
||||
payload.append(Data(repeating: 0x41, count: oversizedCount))
|
||||
|
||||
#expect(!handler.handlePrivatePayload(
|
||||
payload,
|
||||
from: remotePeerID,
|
||||
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||
))
|
||||
|
||||
#expect(recorder.quotaReservations.isEmpty)
|
||||
#expect(recorder.saveCalls.isEmpty)
|
||||
#expect(recorder.lastSeenUpdates.isEmpty)
|
||||
#expect(recorder.deliveredMessages.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func malformedPayloadIsTrackedForSyncButDropped() {
|
||||
let recorder = Recorder()
|
||||
@@ -294,7 +352,7 @@ struct BLEFileTransferHandlerTests {
|
||||
recipientID: nil,
|
||||
timestamp: 900_000,
|
||||
payload: Data([0x01, 0x02, 0x03]),
|
||||
signature: nil,
|
||||
signature: Data(repeating: 0x5A, count: 64),
|
||||
ttl: TransportConfig.messageTTLDefault
|
||||
)
|
||||
|
||||
@@ -529,7 +587,8 @@ struct BLEFileTransferHandlerTests {
|
||||
content: Data,
|
||||
ttl: UInt8 = TransportConfig.messageTTLDefault,
|
||||
recipientID: Data? = nil,
|
||||
fileName: String = "sample"
|
||||
fileName: String = "sample",
|
||||
hasSignature: Bool = true
|
||||
) throws -> BitchatPacket {
|
||||
let filePacket = BitchatFilePacket(
|
||||
fileName: fileName,
|
||||
@@ -544,7 +603,7 @@ struct BLEFileTransferHandlerTests {
|
||||
recipientID: recipientID,
|
||||
timestamp: 900_000,
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
signature: hasSignature ? Data(repeating: 0x5A, count: 64) : nil,
|
||||
ttl: ttl
|
||||
)
|
||||
}
|
||||
|
||||
@@ -117,6 +117,35 @@ struct BLEFragmentAssemblyBufferTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func encryptedPrivateFileAssemblyGetsFramedFileHeadroom() throws {
|
||||
var buffer = BLEFragmentAssemblyBuffer()
|
||||
let fragmentID = Data(repeating: 0x15, count: 8)
|
||||
let first = try #require(BLEFragmentHeader(packet: makeFragmentPacket(
|
||||
fragmentID: fragmentID,
|
||||
index: 0,
|
||||
total: 2,
|
||||
originalType: MessageType.noiseEncrypted.rawValue,
|
||||
fragmentData: Data(repeating: 0x01, count: FileTransferLimits.maxPayloadBytes)
|
||||
)))
|
||||
let second = try #require(BLEFragmentHeader(packet: makeFragmentPacket(
|
||||
fragmentID: fragmentID,
|
||||
index: 1,
|
||||
total: 2,
|
||||
originalType: MessageType.noiseEncrypted.rawValue,
|
||||
fragmentData: Data([0x02])
|
||||
)))
|
||||
|
||||
_ = buffer.append(first, maxInFlightAssemblies: 8)
|
||||
let result = buffer.append(second, maxInFlightAssemblies: 8)
|
||||
|
||||
if case let .complete(_, data, _) = result {
|
||||
#expect(data.count == FileTransferLimits.maxPayloadBytes + 1)
|
||||
} else {
|
||||
Issue.record("Expected encrypted private-file assembly to use framed-file limit")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func removeExpiredDropsOldAssemblies() throws {
|
||||
var buffer = BLEFragmentAssemblyBuffer()
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BLELocalIdentityStateStoreTests {
|
||||
@Test
|
||||
func identityReplacementUpdatesWireBytesAtomically() throws {
|
||||
let initial = PeerID(str: "0011223344556677")
|
||||
let replacement = PeerID(str: "8899aabbccddeeff")
|
||||
let store = BLELocalIdentityStateStore(peerID: initial, nickname: "alice")
|
||||
|
||||
store.replacePeerIdentity(with: replacement)
|
||||
|
||||
let snapshot = store.snapshot()
|
||||
#expect(snapshot.peerID == replacement)
|
||||
#expect(snapshot.peerIDData == Data(hexString: replacement.id))
|
||||
#expect(snapshot.nickname == "alice")
|
||||
}
|
||||
|
||||
@Test
|
||||
func concurrentReadsNeverObserveSplitIdentityState() {
|
||||
let peerIDs = [
|
||||
PeerID(str: "0011223344556677"),
|
||||
PeerID(str: "8899aabbccddeeff")
|
||||
]
|
||||
let store = BLELocalIdentityStateStore(peerID: peerIDs[0], nickname: "alice")
|
||||
let failures = LockedFailureRecorder()
|
||||
|
||||
DispatchQueue.concurrentPerform(iterations: 2_000) { index in
|
||||
if index.isMultiple(of: 2) {
|
||||
store.replacePeerIdentity(with: peerIDs[index % peerIDs.count])
|
||||
} else {
|
||||
store.setNickname(index.isMultiple(of: 3) ? "alice" : "bob")
|
||||
}
|
||||
|
||||
let snapshot = store.snapshot()
|
||||
let expectedWireID = Data(hexString: snapshot.peerID.id) ?? Data()
|
||||
if snapshot.peerIDData != expectedWireID {
|
||||
failures.record()
|
||||
}
|
||||
}
|
||||
|
||||
#expect(!failures.hasFailure)
|
||||
}
|
||||
}
|
||||
|
||||
private final class LockedFailureRecorder: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var failed = false
|
||||
|
||||
var hasFailure: Bool { lock.withLock { failed } }
|
||||
|
||||
func record() {
|
||||
lock.withLock { failed = true }
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ struct BLENoisePacketHandlerTests {
|
||||
var handshakeResult: Result<Data?, Error> = .success(nil)
|
||||
var handshakeAuthenticated = false
|
||||
var hasSession = false
|
||||
let sessionGeneration = UUID()
|
||||
var decryptResult: Result<Data, Error> = .success(Data())
|
||||
|
||||
var processedHandshakes: [(peerID: PeerID, message: Data)] = []
|
||||
@@ -19,6 +20,7 @@ struct BLENoisePacketHandlerTests {
|
||||
var lastSeenUpdates: [PeerID] = []
|
||||
var decryptCalls: [(payload: Data, peerID: PeerID)] = []
|
||||
var clearedSessions: [PeerID] = []
|
||||
var authenticatedPeerStates: [(peerID: PeerID, payload: Data, generation: UUID)] = []
|
||||
var deliveries: [(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)] = []
|
||||
/// Ordered side-effect log to assert recovery sequencing.
|
||||
var events: [String] = []
|
||||
@@ -61,12 +63,18 @@ struct BLENoisePacketHandlerTests {
|
||||
},
|
||||
decrypt: { payload, peerID in
|
||||
recorder.decryptCalls.append((payload, peerID))
|
||||
return try recorder.decryptResult.get()
|
||||
return BLENoiseDecryptionResult(
|
||||
plaintext: try recorder.decryptResult.get(),
|
||||
sessionGeneration: recorder.sessionGeneration
|
||||
)
|
||||
},
|
||||
clearSession: { peerID in
|
||||
recorder.clearedSessions.append(peerID)
|
||||
recorder.events.append("clearSession")
|
||||
},
|
||||
handleAuthenticatedPeerState: { peerID, payload, generation in
|
||||
recorder.authenticatedPeerStates.append((peerID, payload, generation))
|
||||
},
|
||||
deliverNoisePayload: { peerID, type, payload, timestamp in
|
||||
recorder.deliveries.append((peerID, type, payload, timestamp))
|
||||
}
|
||||
@@ -190,6 +198,24 @@ struct BLENoisePacketHandlerTests {
|
||||
#expect(recorder.broadcastPackets.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func managedHandshakeFailureDoesNotStartASecondRecovery() {
|
||||
let recorder = Recorder()
|
||||
recorder.handshakeResult = .failure(
|
||||
NoiseManagedHandshakeFailure(underlying: TestError())
|
||||
)
|
||||
recorder.hasSession = false
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = makeHandshakePacket(
|
||||
recipientID: Data(hexString: localPeerID.id)
|
||||
)
|
||||
|
||||
#expect(!handler.handleHandshake(packet, from: remotePeerID))
|
||||
#expect(recorder.hasSessionQueries.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
#expect(recorder.broadcastPackets.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: Encrypted
|
||||
|
||||
@Test
|
||||
@@ -244,6 +270,25 @@ struct BLENoisePacketHandlerTests {
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func authenticatedPeerStateIsConsumedByTransportNotDeliveredToUI() {
|
||||
let recorder = Recorder()
|
||||
recorder.decryptResult = .success(Data([
|
||||
NoisePayloadType.authenticatedPeerState.rawValue,
|
||||
0x01, 0x02, 0x03
|
||||
]))
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = makeEncryptedPacket(recipientID: Data(hexString: localPeerID.id))
|
||||
|
||||
handler.handleEncrypted(packet, from: remotePeerID)
|
||||
|
||||
#expect(recorder.authenticatedPeerStates.count == 1)
|
||||
#expect(recorder.authenticatedPeerStates.first?.peerID == remotePeerID)
|
||||
#expect(recorder.authenticatedPeerStates.first?.payload == Data([0x01, 0x02, 0x03]))
|
||||
#expect(recorder.authenticatedPeerStates.first?.generation == recorder.sessionGeneration)
|
||||
#expect(recorder.deliveries.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func emptyDecryptedPayloadIsIgnored() {
|
||||
let recorder = Recorder()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
struct BLENoisePayloadFactoryTests {
|
||||
@@ -31,4 +32,63 @@ struct BLENoisePayloadFactoryTests {
|
||||
|
||||
#expect(payload == Data([NoisePayloadType.verifyChallenge.rawValue, 0xCA, 0xFE]))
|
||||
}
|
||||
|
||||
@Test
|
||||
func privateFilePayloadPrefixesCanonicalFilePacket() throws {
|
||||
let content = Data("%PDF-secret".utf8)
|
||||
let file = BitchatFilePacket(
|
||||
fileName: "secret.pdf",
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "application/pdf",
|
||||
content: content
|
||||
)
|
||||
|
||||
let payload = try #require(BLENoisePayloadFactory.privateFile(file))
|
||||
|
||||
#expect(payload.first == 0x20, "Encrypted files must use Android's deployed wire value")
|
||||
let decoded = try #require(BitchatFilePacket.decode(Data(payload.dropFirst())))
|
||||
#expect(decoded.fileName == "secret.pdf")
|
||||
#expect(decoded.mimeType == "application/pdf")
|
||||
#expect(decoded.content == content)
|
||||
}
|
||||
|
||||
@Test
|
||||
func androidB7f0b33PrivateFilePlaintextFixtureIsByteCompatible() throws {
|
||||
// Runtime-emitted by Android commit b7f0b33d from
|
||||
// BitchatFilePacket("a.txt", 3, "text/plain", [01, 02, 03]) and
|
||||
// NoisePayload(type = FILE_TRANSFER, data = file.encode()).encode().
|
||||
let fixtureHex = "20010005612e7478740200040000000303000a746578742f706c61696e0400000003010203"
|
||||
let fixture = try #require(Data(hexString: fixtureHex))
|
||||
|
||||
let typed = try #require(NoisePayload.decode(fixture))
|
||||
#expect(typed.type == .privateFile)
|
||||
let file = try #require(BitchatFilePacket.decode(typed.data))
|
||||
#expect(file.fileName == "a.txt")
|
||||
#expect(file.fileSize == 3)
|
||||
#expect(file.mimeType == "text/plain")
|
||||
#expect(file.content == Data([0x01, 0x02, 0x03]))
|
||||
#expect(BLENoisePayloadFactory.privateFile(file) == fixture)
|
||||
}
|
||||
|
||||
@Test
|
||||
func prereleasePrivateFileTypeCanonicalizesOnDecode() throws {
|
||||
let encoded = Data([NoisePayloadType.prereleasePrivateFileRawValue, 0xCA, 0xFE])
|
||||
let decoded = try #require(NoisePayload.decode(encoded))
|
||||
|
||||
#expect(decoded.type == .privateFile)
|
||||
#expect(decoded.data == Data([0xCA, 0xFE]))
|
||||
#expect(decoded.encode().first == 0x20)
|
||||
}
|
||||
|
||||
@Test
|
||||
func authenticatedPeerStateUsesPermanent0x21Type() throws {
|
||||
let state = AuthenticatedPeerStatePacket(
|
||||
capabilities: .privateMedia,
|
||||
signingPublicKey: Data(repeating: 0x77, count: 32)
|
||||
)
|
||||
let encoded = try #require(BLENoisePayloadFactory.authenticatedPeerState(state))
|
||||
|
||||
#expect(encoded.first == 0x21)
|
||||
#expect(AuthenticatedPeerStatePacket.decode(from: Data(encoded.dropFirst())) == state)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("BLE Noise reconnect policy")
|
||||
struct BLENoiseReconnectPolicyTests {
|
||||
@Test("Revalidation requires a cached session and no authenticated link")
|
||||
func revalidationPreconditions() {
|
||||
var policy = BLENoiseReconnectPolicy()
|
||||
let link = BLEIngressLinkID.peripheral("peripheral-a")
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
|
||||
let withoutSession = policy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: false,
|
||||
isNoiseAuthenticatedLink: false,
|
||||
hasAuthenticatedPeerLink: false,
|
||||
now: now
|
||||
)
|
||||
#expect(!withoutSession)
|
||||
let authenticated = policy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: true,
|
||||
isNoiseAuthenticatedLink: true,
|
||||
hasAuthenticatedPeerLink: true,
|
||||
now: now
|
||||
)
|
||||
#expect(!authenticated)
|
||||
let eligible = policy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: true,
|
||||
isNoiseAuthenticatedLink: false,
|
||||
hasAuthenticatedPeerLink: false,
|
||||
now: now
|
||||
)
|
||||
#expect(eligible)
|
||||
}
|
||||
|
||||
@Test("Revalidation is once per link epoch or after sixty seconds")
|
||||
func revalidationIsBoundPerLinkEpoch() {
|
||||
var policy = BLENoiseReconnectPolicy()
|
||||
let link = BLEIngressLinkID.central("central-a")
|
||||
let start = Date(timeIntervalSince1970: 2_000)
|
||||
|
||||
let initial = policy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: true,
|
||||
isNoiseAuthenticatedLink: false,
|
||||
hasAuthenticatedPeerLink: false,
|
||||
now: start
|
||||
)
|
||||
#expect(initial)
|
||||
let duringCooldown = policy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: true,
|
||||
isNoiseAuthenticatedLink: false,
|
||||
hasAuthenticatedPeerLink: false,
|
||||
now: start.addingTimeInterval(59.999)
|
||||
)
|
||||
#expect(!duringCooldown)
|
||||
let afterCooldown = policy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: true,
|
||||
isNoiseAuthenticatedLink: false,
|
||||
hasAuthenticatedPeerLink: false,
|
||||
now: start.addingTimeInterval(60)
|
||||
)
|
||||
#expect(afterCooldown)
|
||||
|
||||
policy.endLinkEpoch(link)
|
||||
let nextEpoch = policy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: true,
|
||||
isNoiseAuthenticatedLink: false,
|
||||
hasAuthenticatedPeerLink: false,
|
||||
now: start.addingTimeInterval(60.001)
|
||||
)
|
||||
#expect(nextEpoch)
|
||||
}
|
||||
|
||||
@Test("An authenticated sibling suppresses redundant reconnect")
|
||||
func authenticatedSiblingSuppressesReconnect() {
|
||||
var policy = BLENoiseReconnectPolicy()
|
||||
let link = BLEIngressLinkID.peripheral("unproven-sibling")
|
||||
let start = Date(timeIntervalSince1970: 3_000)
|
||||
|
||||
let suppressed = policy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: true,
|
||||
isNoiseAuthenticatedLink: false,
|
||||
hasAuthenticatedPeerLink: true,
|
||||
now: start
|
||||
)
|
||||
#expect(!suppressed)
|
||||
let eligible = policy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: true,
|
||||
isNoiseAuthenticatedLink: false,
|
||||
hasAuthenticatedPeerLink: false,
|
||||
now: start
|
||||
)
|
||||
#expect(eligible)
|
||||
}
|
||||
|
||||
@Test("Reserved replacement bit is not advertised")
|
||||
func reservedReplacementBitIsNotAdvertised() {
|
||||
#expect(
|
||||
!PeerCapabilities.localSupported.contains(
|
||||
.nonDestructiveNoiseReplacement
|
||||
)
|
||||
)
|
||||
#expect(PeerCapabilities.localSupported.contains(.privateMedia))
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,10 @@ struct BLENoiseSessionQueuesTests {
|
||||
queues.appendTypedPayload(Data([0x01]), for: peerID)
|
||||
queues.appendTypedPayload(Data([0x02]), for: peerID)
|
||||
|
||||
#expect(queues.takeTypedPayloads(for: peerID) == [Data([0x01]), Data([0x02])])
|
||||
#expect(queues.takeTypedPayloads(for: peerID) == [
|
||||
BLEPendingTypedPayload(payload: Data([0x01]), transferId: nil),
|
||||
BLEPendingTypedPayload(payload: Data([0x02]), transferId: nil)
|
||||
])
|
||||
#expect(queues.takeTypedPayloads(for: peerID).isEmpty)
|
||||
#expect(queues.takePrivateMessages(for: peerID).map(\.messageID) == ["m1"])
|
||||
}
|
||||
@@ -64,4 +67,21 @@ struct BLENoiseSessionQueuesTests {
|
||||
|
||||
#expect(queues.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func transferIDSurvivesHandshakeQueueAndCanBeCancelledBeforeDrain() {
|
||||
let peerID = PeerID(str: "aaaaaaaaaaaaaaaa")
|
||||
var queues = BLENoiseSessionQueues()
|
||||
|
||||
queues.appendTypedPayload(Data([0x20, 0xAA]), transferId: "media-1", for: peerID)
|
||||
queues.appendTypedPayload(Data([0x01, 0xBB]), for: peerID)
|
||||
|
||||
let removed = queues.removeTypedPayload(transferId: "media-1")
|
||||
let removedAgain = queues.removeTypedPayload(transferId: "media-1")
|
||||
#expect(removed)
|
||||
#expect(!removedAgain)
|
||||
#expect(queues.takeTypedPayloads(for: peerID) == [
|
||||
BLEPendingTypedPayload(payload: Data([0x01, 0xBB]), transferId: nil)
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,58 @@ struct BLEOutboundFragmentPlannerTests {
|
||||
) == nil)
|
||||
}
|
||||
|
||||
@Test("private media v1 accepts exactly 256 fragments and rejects 257")
|
||||
func privateMediaCrossPlatformFragmentBoundary() throws {
|
||||
let maxPayload = makePayload(count: 160 * 1024, seed: 0xFACE_CAFE)
|
||||
|
||||
func plan(payloadCount: Int) throws -> BLEOutboundFragmentPlan {
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: "0011223344556677") ?? Data(),
|
||||
recipientID: Data(hexString: "8877665544332211"),
|
||||
timestamp: 0x0102030405,
|
||||
payload: Data(maxPayload.prefix(payloadCount)),
|
||||
signature: nil,
|
||||
ttl: 3,
|
||||
version: 2
|
||||
)
|
||||
return try #require(BLEOutboundFragmentPlanner.makePlan(
|
||||
for: BLEOutboundFragmentTransferRequest(
|
||||
packet: packet,
|
||||
pad: false,
|
||||
maxChunk: nil,
|
||||
directedPeer: PeerID(str: "8877665544332211"),
|
||||
transferId: "boundary"
|
||||
),
|
||||
defaultChunkSize: TransportConfig.bleDefaultFragmentSize,
|
||||
bleMaxMTU: 512,
|
||||
fragmentID: Data(repeating: 0xD4, count: 8)
|
||||
))
|
||||
}
|
||||
|
||||
func firstPlan(withAtLeast target: Int) throws -> BLEOutboundFragmentPlan {
|
||||
var low = 1
|
||||
var high = maxPayload.count
|
||||
while low < high {
|
||||
let mid = low + (high - low) / 2
|
||||
if try plan(payloadCount: mid).totalFragments >= target {
|
||||
high = mid
|
||||
} else {
|
||||
low = mid + 1
|
||||
}
|
||||
}
|
||||
return try plan(payloadCount: low)
|
||||
}
|
||||
|
||||
let at256 = try firstPlan(withAtLeast: 256)
|
||||
let at257 = try firstPlan(withAtLeast: 257)
|
||||
|
||||
#expect(at256.totalFragments == 256)
|
||||
#expect(BLEOutboundFragmentPlanner.isPrivateMediaV1Compatible(at256))
|
||||
#expect(at257.totalFragments == 257)
|
||||
#expect(!BLEOutboundFragmentPlanner.isPrivateMediaV1Compatible(at257))
|
||||
}
|
||||
|
||||
private func makePacket(
|
||||
payload: Data,
|
||||
route: [Data]? = nil,
|
||||
|
||||
@@ -20,6 +20,24 @@ struct BLEOutboundFragmentTransferSchedulerTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func explicitTransferIDReservesEncryptedPrivateFileFragments() {
|
||||
var scheduler = BLEOutboundFragmentTransferScheduler()
|
||||
let request = makeRequest(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
transferId: "private-media"
|
||||
)
|
||||
|
||||
let result = scheduler.submit(request, maxConcurrentTransfers: 1)
|
||||
|
||||
if case let .start(_, reservedTransferId) = result {
|
||||
#expect(reservedTransferId == "private-media")
|
||||
#expect(scheduler.activeCount == 1)
|
||||
} else {
|
||||
Issue.record("Expected encrypted private media to reserve its progress slot")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func submitQueuesFileTransferWhenSlotsAreFull() {
|
||||
var scheduler = BLEOutboundFragmentTransferScheduler()
|
||||
|
||||
@@ -42,6 +42,37 @@ struct BLEPeerRegistryTests {
|
||||
#expect(registry.info(for: peerID)?.nickname == "alice-renamed")
|
||||
}
|
||||
|
||||
@Test("registry preserves absent versus explicit empty capabilities")
|
||||
func capabilitiesPresenceIsPreserved() {
|
||||
var registry = BLEPeerRegistry()
|
||||
let oldPeer = PeerID(str: "1122334455667788")
|
||||
let modernPeer = PeerID(str: "8877665544332211")
|
||||
|
||||
_ = registry.upsertVerifiedAnnounce(
|
||||
peerID: oldPeer,
|
||||
nickname: "old",
|
||||
noisePublicKey: Data(repeating: 0x11, count: 32),
|
||||
signingPublicKey: Data(repeating: 0x12, count: 32),
|
||||
isConnected: true,
|
||||
now: Date(),
|
||||
capabilities: nil
|
||||
)
|
||||
_ = registry.upsertVerifiedAnnounce(
|
||||
peerID: modernPeer,
|
||||
nickname: "modern",
|
||||
noisePublicKey: Data(repeating: 0x21, count: 32),
|
||||
signingPublicKey: Data(repeating: 0x22, count: 32),
|
||||
isConnected: true,
|
||||
now: Date(),
|
||||
capabilities: []
|
||||
)
|
||||
|
||||
#expect(registry.capabilities(for: oldPeer).isEmpty)
|
||||
#expect(!registry.capabilitiesWereExplicitlyAdvertised(for: oldPeer))
|
||||
#expect(registry.capabilities(for: modernPeer).isEmpty)
|
||||
#expect(registry.capabilitiesWereExplicitlyAdvertised(for: modernPeer))
|
||||
}
|
||||
|
||||
@Test("reachability keeps recent verified offline peers only when mesh is attached")
|
||||
func reachabilityRequiresMeshAttachmentForOfflinePeers() {
|
||||
let offlinePeer = PeerID(str: "1122334455667788")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -399,6 +399,59 @@ final class SecureIdentityStateManagerTests: XCTestCase {
|
||||
XCTAssertTrue(cleared)
|
||||
}
|
||||
|
||||
func test_privateMediaCapabilityPinPersistsMonotonicallyAndPanicClearRemovesIt() async {
|
||||
let keychain = MockKeychain()
|
||||
let fingerprint = Data(repeating: 0x42, count: 32).sha256Fingerprint()
|
||||
let manager = SecureIdentityStateManager(keychain)
|
||||
|
||||
XCTAssertFalse(manager.hasObservedPrivateMediaCapability(fingerprint: fingerprint))
|
||||
manager.markPrivateMediaCapable(fingerprint: fingerprint)
|
||||
XCTAssertTrue(
|
||||
manager.hasObservedPrivateMediaCapability(fingerprint: fingerprint),
|
||||
"pin insertion must be synchronously visible to the next downgrade decision"
|
||||
)
|
||||
|
||||
// Re-marking is idempotent, and the encrypted cache carries the pin
|
||||
// across launches.
|
||||
manager.markPrivateMediaCapable(fingerprint: fingerprint)
|
||||
manager.forceSave()
|
||||
let reloaded = SecureIdentityStateManager(keychain)
|
||||
XCTAssertTrue(reloaded.hasObservedPrivateMediaCapability(fingerprint: fingerprint))
|
||||
|
||||
// ChatViewModel's panic path calls this same wipe after deleting
|
||||
// keychain data; the in-memory pin must disappear immediately too.
|
||||
reloaded.clearAllIdentityData()
|
||||
let cleared = await waitUntil {
|
||||
!reloaded.hasObservedPrivateMediaCapability(fingerprint: fingerprint)
|
||||
}
|
||||
XCTAssertTrue(cleared)
|
||||
}
|
||||
|
||||
func test_noiseAuthenticatedSigningKeyBindingPersistsAndPanicClearRemovesIt() async {
|
||||
let keychain = MockKeychain()
|
||||
let fingerprint = Data(repeating: 0x31, count: 32).sha256Fingerprint()
|
||||
let firstKey = Data(repeating: 0x41, count: 32)
|
||||
let rotatedKey = Data(repeating: 0x42, count: 32)
|
||||
let manager = SecureIdentityStateManager(keychain)
|
||||
|
||||
manager.bindAuthenticatedSigningPublicKey(firstKey, fingerprint: fingerprint)
|
||||
XCTAssertEqual(manager.authenticatedSigningPublicKey(forFingerprint: fingerprint), firstKey)
|
||||
// A later authenticated Noise session may legitimately rotate the
|
||||
// announcement signing key.
|
||||
manager.bindAuthenticatedSigningPublicKey(rotatedKey, fingerprint: fingerprint)
|
||||
XCTAssertEqual(manager.authenticatedSigningPublicKey(forFingerprint: fingerprint), rotatedKey)
|
||||
|
||||
manager.forceSave()
|
||||
let reloaded = SecureIdentityStateManager(keychain)
|
||||
XCTAssertEqual(reloaded.authenticatedSigningPublicKey(forFingerprint: fingerprint), rotatedKey)
|
||||
|
||||
reloaded.clearAllIdentityData()
|
||||
let cleared = await waitUntil {
|
||||
reloaded.authenticatedSigningPublicKey(forFingerprint: fingerprint) == nil
|
||||
}
|
||||
XCTAssertTrue(cleared)
|
||||
}
|
||||
|
||||
func test_forceSave_withFailingCacheWriteDoesNotPersistCache() async {
|
||||
let keychain = FailingCacheSaveKeychain()
|
||||
let manager = SecureIdentityStateManager(keychain)
|
||||
|
||||
@@ -49,7 +49,7 @@ struct TransferProgressManagerTests {
|
||||
recorder.append("updated:\(id):\(sent):\(total)")
|
||||
case .completed(let id, let total):
|
||||
recorder.append("completed:\(id):\(total)")
|
||||
case .cancelled:
|
||||
case .cancelled, .rejected:
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,7 @@ struct TransferProgressManagerTests {
|
||||
recorder.append("started:\(id):\(total)")
|
||||
case .cancelled(let id, let sent, let total):
|
||||
recorder.append("cancelled:\(id):\(sent):\(total)")
|
||||
case .updated, .completed:
|
||||
case .updated, .completed, .rejected:
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,28 @@ struct TransferProgressManagerTests {
|
||||
#expect(manager.snapshot(id: transferID) == nil)
|
||||
_ = cancellable
|
||||
}
|
||||
|
||||
@Test("Preflight policy rejection publishes a visible failure reason")
|
||||
@MainActor
|
||||
func rejectBeforeStartPublishesReason() async {
|
||||
let manager = TransferProgressManager()
|
||||
let transferID = "transfer-visible-reject"
|
||||
let recorder = EventRecorder()
|
||||
let cancellable = manager.publisher.sink { event in
|
||||
if case .rejected(let id, let reason) = event {
|
||||
recorder.append("rejected:\(id):\(reason)")
|
||||
}
|
||||
}
|
||||
|
||||
manager.rejectBeforeStart(id: transferID, reason: "upgrade required")
|
||||
|
||||
let didReceive = await TestHelpers.waitUntil({
|
||||
recorder.values == ["rejected:\(transferID):upgrade required"]
|
||||
}, timeout: 5.0)
|
||||
#expect(didReceive)
|
||||
#expect(manager.snapshot(id: transferID) == nil)
|
||||
_ = cancellable
|
||||
}
|
||||
}
|
||||
|
||||
private final class EventRecorder: @unchecked Sendable {
|
||||
|
||||
@@ -266,6 +266,11 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
verified.removeAll()
|
||||
}
|
||||
|
||||
func markPrivateMediaCapable(fingerprint: String) {}
|
||||
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool { false }
|
||||
func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String) {}
|
||||
func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data? { nil }
|
||||
|
||||
func removeEphemeralSession(peerID: PeerID) {}
|
||||
|
||||
func setVerified(fingerprint: String, verified: Bool) {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# Private-media wire migration
|
||||
|
||||
Private files use the `BitchatFilePacket` TLV shared by iOS and Android. The
|
||||
preferred direct-message wire form encrypts that complete TLV inside the
|
||||
peer's Noise session before BLE fragmentation.
|
||||
|
||||
## Wire values and capability
|
||||
|
||||
- `NoisePayloadType.privateFile` is `0x20`, the value already deployed by the
|
||||
Android client. New sends must use this value.
|
||||
- iOS temporarily accepts `0x09`, which appeared in prerelease builds of the
|
||||
private-media change. Decoders canonicalize it to `privateFile`; they never
|
||||
emit it.
|
||||
- `NoisePayloadType.authenticatedPeerState` is permanently assigned `0x21`.
|
||||
It is emitted after every completed/rekeyed Noise XX session and echoed at
|
||||
most once when the remote state arrives, so message-3/proof reordering over
|
||||
different mesh links converges. This type is part of the protocol security
|
||||
boundary and is not removed when the media migration ends.
|
||||
- The `0x21` payload starts with version `0x01`, followed by one-byte
|
||||
type/length/value fields. Version 1 requires canonical TLV `0x01` (the
|
||||
minimal little-endian `PeerCapabilities` bitfield, 1-8 bytes) and TLV `0x02`
|
||||
(the 32-byte Ed25519 announcement signing key). Duplicate required fields,
|
||||
non-minimal capabilities, malformed lengths, missing fields, and unknown
|
||||
versions are ignored without changing state. Unknown TLVs are skipped.
|
||||
- The public `PeerCapabilities.privateMedia` announce bit is a discovery hint:
|
||||
it starts a Noise handshake, but never selects encrypted sending or creates
|
||||
a pin. A private transfer waits boundedly for the exact session's encrypted
|
||||
`0x21`. A valid bit-8 proof selects Noise `0x20`; a valid no-bit proof or a
|
||||
no-proof timeout reaches the explicit legacy-consent path for an unpinned
|
||||
peer. No timeout automatically sends raw bytes.
|
||||
- An unpinned peer with a stable Noise key but without that capability is
|
||||
eligible for one signed, directed
|
||||
`fileTransfer`, matching the pre-migration wire form used by older iOS and
|
||||
accepted by current Android clients, only after the sender confirms a
|
||||
per-send warning that the file is not end-to-end encrypted and mesh relays
|
||||
can see it. The
|
||||
consent is consumed by that invocation and is never remembered.
|
||||
- A signed announce never creates a pin by itself: an attacker can copy a
|
||||
victim's public Noise key, supply its own Ed25519 key and capability bits,
|
||||
and self-sign an internally consistent announce. Only successfully
|
||||
decrypted `0x21` state pins the authenticated Noise fingerprint and binds
|
||||
the Ed25519 key used by later announces/public messages. A later valid
|
||||
no-bit `0x21` is treated as a downgrade, and raw fallback is blocked even if
|
||||
a caller presents legacy consent. Public no-bit announces cannot overwrite
|
||||
current session-authenticated state.
|
||||
- During migration, both an absent capabilities TLV and an explicit TLV
|
||||
without `privateMedia` are legacy-eligible when that stable fingerprint is
|
||||
not pinned. This supports clients that added capability advertisement before
|
||||
encrypted media. Neither shape bypasses a previously authenticated pin.
|
||||
|
||||
Older clients decrypt and ignore unknown inner type `0x21`; they do not need to
|
||||
understand it to continue using text or the warned legacy media path. They are
|
||||
never inferred capable merely because the handshake succeeded.
|
||||
|
||||
Removal gates are independent and must not share an arbitrary calendar date:
|
||||
|
||||
- Remove the `0x09` receive alias only after every TestFlight/internal build
|
||||
that emitted it has expired and minimum-supported-client policy excludes it.
|
||||
- Remove the signed directed raw `0x22` fallback only after minimum-supported
|
||||
iOS and Android clients emit authenticated bit-8 `0x21` state and the legacy
|
||||
population has aged out.
|
||||
- Nostr kind `1059` compatibility is a separate envelope migration. Its dual
|
||||
publish/removal gate is not evidence that either BLE compatibility shape can
|
||||
be removed.
|
||||
|
||||
## Security boundary
|
||||
|
||||
The encrypted form provides Noise confidentiality and peer authentication.
|
||||
The fallback is signed and its signature is required on receive, so relays
|
||||
cannot forge its sender or contents. It is not confidential: relays can see
|
||||
the raw file TLV. The UI says this explicitly and asks on every send. A peer
|
||||
without a stable Noise key from a verified registry entry cannot use the
|
||||
fallback. Keep it only for the mixed-version migration, and remove it only
|
||||
after minimum-supported Android and iOS releases emit authenticated bit-8
|
||||
`0x21` state and the legacy population has aged out. Never replace it with an
|
||||
unsigned fallback, persist blanket consent, or send both forms.
|
||||
|
||||
Incoming clients accept all three migration-era shapes:
|
||||
|
||||
| Sender | Inbound form | Result |
|
||||
| --- | --- | --- |
|
||||
| Current Android | Noise `0x20` | Decrypt and deliver |
|
||||
| Prerelease iOS | Noise `0x09` | Decrypt, canonicalize, and deliver |
|
||||
| Older client | Signed directed `fileTransfer` | Verify signature and deliver |
|
||||
| Forged/unsigned raw sender | Directed `fileTransfer` | Reject |
|
||||
|
||||
Panic wipe clears the persistent capability pins together with the rest of
|
||||
the encrypted identity cache.
|
||||
|
||||
This migration path is mesh-Noise-only (BLE and compatible direct mesh links).
|
||||
Nostr private-media transport is unchanged and remains a follow-up. Nostr
|
||||
inbound paths explicitly ignore `0x21`; do not infer the mesh consent fallback
|
||||
or capability-pin semantics for Nostr delivery.
|
||||
|
||||
## Size interoperability
|
||||
|
||||
iOS bounds inbound file content at 1 MiB and applies the expanded allocation
|
||||
budget only after a large Noise ciphertext authenticates to `0x20` or the
|
||||
temporary `0x09` alias. Ordinary Noise messages retain their 64 KiB limit.
|
||||
|
||||
Current Android builds cap each reassembly at 256 fragments. Depending on the
|
||||
negotiated BLE packet size and routing overhead, that is roughly 110-120 KiB,
|
||||
well below iOS's absolute inbound ceiling. Private-media v1 therefore runs the
|
||||
actual route-aware BLE fragment planner before both encrypted and consented
|
||||
legacy sends and rejects any plan above 256 fragments with a visible failure.
|
||||
This fragment-count contract, rather than a guessed byte threshold, stays
|
||||
correct as route overhead changes.
|
||||
@@ -24,6 +24,15 @@ public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable {
|
||||
/// (uplink/downlink carriers for mesh-only peers). Advertised alongside
|
||||
/// a `bridgeGeohash` TLV carrying the rendezvous cell.
|
||||
public static let bridge = PeerCapabilities(rawValue: 1 << 7)
|
||||
/// Finalized direct-message media encrypted as Noise payload `0x20`
|
||||
/// before outer BLE fragmentation. Peers that omit this bit require the
|
||||
/// signed directed raw-file migration fallback.
|
||||
public static let privateMedia = PeerCapabilities(rawValue: 1 << 8)
|
||||
/// Reserved for test builds that briefly advertised non-destructive Noise
|
||||
/// replacement. Current clients intentionally do not advertise or act on
|
||||
/// this bit; keep it decodable so the wire assignment is never reused.
|
||||
public static let nonDestructiveNoiseReplacement =
|
||||
PeerCapabilities(rawValue: 1 << 10)
|
||||
|
||||
/// Minimal little-endian byte encoding; always at least one byte so an
|
||||
/// empty set is distinguishable from an absent TLV.
|
||||
|
||||
@@ -16,11 +16,16 @@ struct PeerCapabilitiesTests {
|
||||
#expect(PeerCapabilities([]).encoded() == Data([0x00]))
|
||||
#expect(PeerCapabilities.prekeys.encoded() == Data([0x01]))
|
||||
#expect(PeerCapabilities.meshDiagnostics.encoded() == Data([0x40]))
|
||||
#expect(PeerCapabilities.privateMedia.encoded() == Data([0x00, 0x01]))
|
||||
|
||||
let high = PeerCapabilities(rawValue: 1 << 9)
|
||||
#expect(high.encoded() == Data([0x00, 0x02]))
|
||||
#expect(
|
||||
PeerCapabilities.nonDestructiveNoiseReplacement.encoded()
|
||||
== Data([0x00, 0x04])
|
||||
)
|
||||
|
||||
let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics]
|
||||
let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics, .privateMedia]
|
||||
#expect(PeerCapabilities(encoded: all.encoded()) == all)
|
||||
#expect(PeerCapabilities(encoded: high.encoded()) == high)
|
||||
#expect(PeerCapabilities(encoded: PeerCapabilities([]).encoded()) == [])
|
||||
|
||||
Vendored
+405
-431
@@ -1,442 +1,416 @@
|
||||
Relay URL,Latitude,Longitude
|
||||
bitchat.nostr1.com,40.7057,-74.0136
|
||||
relay.fundstr.me,42.3601,-71.0589
|
||||
nostr.2b9t.xyz,34.0549,-118.243
|
||||
armada.sharegap.net,43.6532,-79.3832
|
||||
nostr.chaima.info,51.5072,-0.127586
|
||||
nosflare-leefcore.leefcore.workers.dev,43.6532,-79.3832
|
||||
ribo.eu.nostria.app:443,43.6532,-79.3832
|
||||
relay.lightning.pub,39.0438,-77.4874
|
||||
relay.nostu.be,40.4167,-3.70329
|
||||
nostr.whitenode45.ddns.net,40.55,-74.4758
|
||||
nostr.carroarmato0.be:443,50.914,3.21378
|
||||
cdn.satellite.earth,40.8302,-74.1299
|
||||
relay2.veganostr.com,60.1699,24.9384
|
||||
relay.layer.systems:443,49.0291,8.35695
|
||||
relay0.gfcom.info,13.7653,100.647
|
||||
relay.mmwaves.de:443,48.8575,2.35138
|
||||
offchain.pub,39.1585,-94.5728
|
||||
bcast.girino.org,43.6532,-79.3832
|
||||
staging.yabu.me,35.6092,139.73
|
||||
nostr.overpay.com,29.7449,-95.5343
|
||||
bridge.tagomago.me,42.3601,-71.0589
|
||||
nostr-01.yakihonne.com,1.32123,103.695
|
||||
strfry.bonsai.com,39.0438,-77.4874
|
||||
relay.sharegap.net,43.6532,-79.3832
|
||||
nostr.islandarea.net,35.4669,-97.6473
|
||||
dm-test-strfry-generic.samt.st,43.6532,-79.3832
|
||||
treuzkas.branruz.com,48.8575,2.35138
|
||||
relay-rpi.edufeed.org:443,49.4521,11.0767
|
||||
vault.iris.to:443,43.6532,-79.3832
|
||||
node.kommonzenze.de,49.4521,11.0767
|
||||
nostr.thalheim.io:443,60.1699,24.9384
|
||||
soloco.nl,43.6532,-79.3832
|
||||
strfry.shock.network,39.0438,-77.4874
|
||||
nostr-relay.zimage.com,34.0549,-118.243
|
||||
public.crostr.com:443,43.6532,-79.3832
|
||||
nostr.sathoarder.com:443,48.5734,7.75211
|
||||
relay.angor.io,48.1046,11.6002
|
||||
relay.wellorder.net,45.5201,-122.99
|
||||
relay.mwaters.net,50.9871,2.12554
|
||||
relay.staging.commonshub.brussels,49.4543,11.0746
|
||||
nostr-verified.wellorder.net,45.5201,-122.99
|
||||
nostr-pub.wellorder.net,45.5201,-122.99
|
||||
relay.lab.rytswd.com,49.4543,11.0746
|
||||
relay.paulstephenborile.com:443,49.4543,11.0746
|
||||
relay.binaryrobot.com,43.6532,-79.3832
|
||||
nostr-2.21crypto.ch,47.5356,8.73209
|
||||
relay.kaleidoswap.com,50.8476,4.35717
|
||||
relay.libernet.app:443,43.6532,-79.3832
|
||||
relay.homeinhk.xyz,35.694,139.754
|
||||
relay.manneken.brussels,49.4543,11.0746
|
||||
nostr.spicyz.io:443,43.6532,-79.3832
|
||||
relay.lanacoin-eternity.com:443,40.8302,-74.1299
|
||||
ribo.us.nostria.app:443,43.6532,-79.3832
|
||||
relay.loveisbitcoin.com,43.6532,-79.3832
|
||||
relay.angor.io:443,48.1046,11.6002
|
||||
relay02.lnfi.network,35.6764,139.65
|
||||
relay.cosmicbolt.net:443,37.3986,-121.964
|
||||
spookstr2.nostr1.com:443,40.7057,-74.0136
|
||||
fanfares.nostr1.com:443,40.7057,-74.0136
|
||||
x.kojira.io,43.6532,-79.3832
|
||||
freelay.sovbit.host,60.1699,24.9384
|
||||
nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397
|
||||
nrs-01.darkcloudarcade.com,39.0997,-94.5786
|
||||
relay.endfiat.money:443,59.3327,18.0656
|
||||
relay.paulstephenborile.com,49.4543,11.0746
|
||||
rele.speyhard.fi,51.5072,-0.127586
|
||||
relay.froth.zone,60.1699,24.9384
|
||||
relay.nostr.blockhenge.com,39.0438,-77.4874
|
||||
nrl.ceskar.xyz,50.5145,16.0119
|
||||
rilo.nostria.app,43.6532,-79.3832
|
||||
nostr.overmind.lol:443,43.6532,-79.3832
|
||||
nostr.snowbla.de:443,50.4754,12.3683
|
||||
nostrrelay.taylorperron.com,45.5029,-73.5723
|
||||
chorus.pjv.me,45.5201,-122.99
|
||||
relay.nostr.place,43.6532,-79.3832
|
||||
bucket.coracle.social,37.7775,-122.397
|
||||
nostr.girino.org:443,43.6532,-79.3832
|
||||
relay.aarpia.com,37.3986,-121.964
|
||||
nostr.thalheim.io,60.1699,24.9384
|
||||
ec2.f7z.io,60.1699,24.9384
|
||||
relay.trotters.cc,43.6532,-79.3832
|
||||
relay.mccormick.cx:443,52.3563,4.95714
|
||||
relay.momostr.pink,43.6532,-79.3832
|
||||
relay.nostr.net,43.6532,-79.3832
|
||||
conduitl2.fly.dev,37.7648,-122.432
|
||||
chat-relay.zap-work.com,43.6532,-79.3832
|
||||
relay.ditto.pub,43.6532,-79.3832
|
||||
relay.veganostr.com,60.1699,24.9384
|
||||
relay.minibolt.info:443,43.6532,-79.3832
|
||||
adre.su,59.9311,30.3609
|
||||
bitcoinostr.duckdns.org,41.1976,1.11167
|
||||
nostr.computingcache.com:443,34.0356,-118.442
|
||||
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
|
||||
nostr.hekster.org:443,37.3986,-121.964
|
||||
nostr.88mph.life,52.1941,-2.21905
|
||||
wot.dergigi.com,64.1476,-21.9392
|
||||
nostr.planix.org,43.6532,-79.3832
|
||||
relay.satsmarkt.club,52.6907,4.8181
|
||||
nostrcity-club.fly.dev:443,37.7648,-122.432
|
||||
aeon.libretechsystems.xyz,55.486,9.86577
|
||||
testnet.samt.st,43.6532,-79.3832
|
||||
nostr.data.haus,50.4754,12.3683
|
||||
wot.sudocarlos.com,43.6532,-79.3832
|
||||
relay-fra.zombi.cloudrodion.com:443,48.8566,2.35222
|
||||
shu01.shugur.net,21.4902,39.2246
|
||||
relay.gulugulu.moe:443,43.6532,-79.3832
|
||||
relay2.angor.io:443,48.1046,11.6002
|
||||
relay.libernet.app,43.6532,-79.3832
|
||||
directories-safe-motherboard-recipients.trycloudflare.com,43.6532,-79.3832
|
||||
wot.nostr.party,36.1659,-86.7844
|
||||
relay.angor.io,48.1046,11.6002
|
||||
relay-arg.zombi.cloudrodion.com,1.35208,103.82
|
||||
nostr-01.yakihonne.com,1.32123,103.695
|
||||
nostr-relay.cbrx.io,43.6532,-79.3832
|
||||
relay.guggero.org,46.5971,9.59652
|
||||
nostr.snowbla.de,60.1699,24.9384
|
||||
relay.zone667.com,60.1699,24.9384
|
||||
nostr.wild-vibes.ts.net,48.8566,2.35222
|
||||
relay.nostr.com,50.1109,8.68213
|
||||
nostr.iskarion.ddns.net,43.3076,-2.95421
|
||||
relay-dev.satlantis.io,39.0438,-77.4874
|
||||
relay.sovereignresonance.org,48.9006,2.25929
|
||||
relay.nostrian-conquest.com,41.223,-111.974
|
||||
relay.aidatanorge.no,43.6532,-79.3832
|
||||
strfry.apps3.slidestr.net,40.4167,-3.70329
|
||||
relay.klabo.world,47.2343,-119.853
|
||||
nostr.data.haus:443,50.4754,12.3683
|
||||
testr.nymble.world,40.8054,-74.0241
|
||||
relay.inforsupports.com,43.6532,-79.3832
|
||||
nexus.libernet.app:443,43.6532,-79.3832
|
||||
relay.islandbitcoin.com,12.8498,77.6545
|
||||
relay-testnet.k8s.layer3.news,37.3387,-121.885
|
||||
nostr-relay.xbytez.io,50.6924,3.20113
|
||||
kasztanowa.bieda.it,43.6532,-79.3832
|
||||
nostrcity-club.fly.dev,37.7648,-122.432
|
||||
relay.typedcypher.com,51.5072,-0.127586
|
||||
nostr.na.social:443,43.6532,-79.3832
|
||||
relay.laantungir.net,-19.4692,-42.5315
|
||||
relay-dev.satlantis.io:443,40.8302,-74.1299
|
||||
rilo.nostria.app,43.6532,-79.3832
|
||||
nostr.hekster.org:443,37.3986,-121.964
|
||||
nostr-relay.amethyst.name:443,39.0067,-77.4291
|
||||
chat-relay.zap-work.com:443,43.6532,-79.3832
|
||||
relay.edufeed.org,49.4521,11.0767
|
||||
syb.lol:443,43.6532,-79.3832
|
||||
relay.sigit.io,50.4754,12.3683
|
||||
nostr-relay.xbytez.io:443,50.6924,3.20113
|
||||
relay.wavefunc.live,41.8781,-87.6298
|
||||
nostr.sathoarder.com,48.5734,7.75211
|
||||
myvoiceourstory.org,37.3598,-121.981
|
||||
relay.underorion.se,50.1109,8.68213
|
||||
nostr.data.haus,50.4754,12.3683
|
||||
relay.erybody.com,41.4513,-81.7021
|
||||
espelho.girino.org,43.6532,-79.3832
|
||||
nostr.pbfs.io:443,50.4754,12.3683
|
||||
wot.dergigi.com,64.1476,-21.9392
|
||||
nostr.bitcoiner.social:443,47.6743,-117.112
|
||||
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
|
||||
relay.gulugulu.moe,43.6532,-79.3832
|
||||
nostr.spicyz.io,43.6532,-79.3832
|
||||
relay.cypherflow.ai,48.8575,2.35138
|
||||
treuzkas.branruz.com,48.8575,2.35138
|
||||
relay1.nostrchat.io,60.1699,24.9384
|
||||
kotukonostr.onrender.com,37.7775,-122.397
|
||||
nostr.plantroon.com,50.1013,8.62643
|
||||
nostr.davenov.com,50.1109,8.68213
|
||||
node.kommonzenze.de,49.4521,11.0767
|
||||
relay2.veganostr.com,60.1699,24.9384
|
||||
armada.sharegap.net,43.6532,-79.3832
|
||||
wot.makenomistakes.ca,43.7064,-79.3986
|
||||
nostr.2b9t.xyz:443,34.0549,-118.243
|
||||
relay.libernet.app:443,43.6532,-79.3832
|
||||
relay.dreamith.to:443,43.6532,-79.3832
|
||||
relay.lightning.pub:443,39.0438,-77.4874
|
||||
nostr.rtvslawenia.com,49.4543,11.0746
|
||||
nostr.21crypto.ch,47.5356,8.73209
|
||||
relay.ditto.pub:443,43.6532,-79.3832
|
||||
relay.plebchain.club,43.6532,-79.3832
|
||||
memlay.v0l.io,53.3498,-6.26031
|
||||
nostr.chaima.info:443,50.1109,8.68213
|
||||
relay.wavlake.com:443,41.2619,-95.8608
|
||||
nostr.thalheim.io:443,60.1699,24.9384
|
||||
relay.lightning.pub,39.0438,-77.4874
|
||||
dev.relay.edufeed.org:443,49.4521,11.0767
|
||||
nostr.myshosholoza.co.za:443,52.3913,4.66545
|
||||
relay.binaryrobot.com:443,43.6532,-79.3832
|
||||
wot.nostr.place,43.6532,-79.3832
|
||||
nostr.sathoarder.com:443,48.5734,7.75211
|
||||
thecitadel.nostr1.com,40.7057,-74.0136
|
||||
relay.artx.market,43.6548,-79.3885
|
||||
nos.lol,50.4754,12.3683
|
||||
nostr.plantroon.com:443,50.1013,8.62643
|
||||
premium.primal.net,43.6532,-79.3832
|
||||
nas01xanthosnet.synology.me:7778,47.1285,8.74735
|
||||
nostrja-kari.heguro.com,43.6532,-79.3832
|
||||
relay.mrmave.work,43.6532,-79.3832
|
||||
nostrelay.circum.space,52.6907,4.8181
|
||||
mostro-p2p.tech,50.1109,8.68213
|
||||
wot.shaving.kiwi,43.6532,-79.3832
|
||||
relay.fundstr.me,42.3601,-71.0589
|
||||
nostrelay.circum.space:443,52.6907,4.8181
|
||||
relay.nostrdice.com,-33.8688,151.209
|
||||
relay.getvia.xyz,60.1699,24.9384
|
||||
strfry.shock.network:443,39.0438,-77.4874
|
||||
relay.nostrmap.net:443,60.1699,24.9384
|
||||
nostr.stakey.net:443,52.3676,4.90414
|
||||
relay.nearhood.co.uk,51.5072,-0.127586
|
||||
no.str.cr,10.6352,-85.4378
|
||||
relay.getsafebox.app:443,43.6532,-79.3832
|
||||
relay0.gfcom.info,13.6992,100.694
|
||||
nostr.ps1829.com,33.8851,130.883
|
||||
relay2.angor.io,48.1046,11.6002
|
||||
relay.stickeroo.is-cool.dev,37.3387,-121.885
|
||||
ricardo-oem.tailb5546.ts.net,40.7128,-74.006
|
||||
relay.typedcypher.com:443,51.5072,-0.127586
|
||||
relay.paulstephenborile.com,49.4543,11.0746
|
||||
nittom.nostr1.com,40.7057,-74.0136
|
||||
conduitl2.fly.dev,37.7648,-122.432
|
||||
nostr.rikmeijer.nl,51.7111,5.36809
|
||||
relay.thecryptosquid.com,50.4754,12.3683
|
||||
spookstr2.nostr1.com,40.7057,-74.0136
|
||||
offchain.bostr.online,43.6532,-79.3832
|
||||
nostr.planix.org,43.6532,-79.3832
|
||||
relay.mccormick.cx,52.3563,4.95714
|
||||
0x-nostr-relay.fly.dev,37.7648,-122.432
|
||||
nostr.wecsats.io,43.6532,-79.3832
|
||||
schnorr.me,43.6532,-79.3832
|
||||
relay.satmaxt.xyz,43.6532,-79.3832
|
||||
relay.bornheimer.app,51.5072,-0.127586
|
||||
relay.nostrhub.fr,48.1045,11.6004
|
||||
blossom.gnostr.cloud:443,43.6532,-79.3832
|
||||
nostr-02.yakihonne.com:443,1.32123,103.695
|
||||
dev.relay.stream,43.6532,-79.3832
|
||||
ithurtswhenip.ee,51.5072,-0.127586
|
||||
nostr.myshosholoza.co.za,52.3913,4.66545
|
||||
relayrs.notoshi.win:443,43.6532,-79.3832
|
||||
relay-rpi.edufeed.org:443,49.4521,11.0767
|
||||
relay.olas.app:443,60.1699,24.9384
|
||||
nostr.unkn0wn.world,46.8499,9.53287
|
||||
relay.mitchelltribe.com,39.0438,-77.4874
|
||||
yabu.me,35.6092,139.73
|
||||
nostr.nodesmap.com,59.3327,18.0656
|
||||
dm-test-strfry-generic.samt.st,43.6532,-79.3832
|
||||
nostr2.girino.org:443,43.6532,-79.3832
|
||||
wot.brightbolt.net,47.6735,-116.781
|
||||
strfry.shock.network,39.0438,-77.4874
|
||||
relay.kilombino.com,43.6532,-79.3832
|
||||
relay.nostr.blockhenge.com,39.0438,-77.4874
|
||||
shu04.shugur.net,25.2048,55.2708
|
||||
relay-rpi.edufeed.org,49.4521,11.0767
|
||||
relay.bullishbounty.com:443,43.6532,-79.3832
|
||||
vault.iris.to:443,43.6532,-79.3832
|
||||
relay.mostro.network:443,40.8302,-74.1299
|
||||
offchain.pub:443,39.1585,-94.5728
|
||||
soloco.nl,43.6532,-79.3832
|
||||
relay.nostu.be,40.4167,-3.70329
|
||||
nostr.pbfs.io,50.4754,12.3683
|
||||
relay.directsponsor.net,42.8864,-78.8784
|
||||
relay.decentralia.fr,49.4282,10.9796
|
||||
relayrs.notoshi.win,43.6532,-79.3832
|
||||
nostr-relay.amethyst.name,39.0067,-77.4291
|
||||
relay.arx-ccn.com,50.4754,12.3683
|
||||
nostr.spaceshell.xyz,43.6532,-79.3832
|
||||
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
|
||||
rilo.nostria.app:443,43.6532,-79.3832
|
||||
relay.trotters.cc:443,43.6532,-79.3832
|
||||
nostr.overmind.lol:443,43.6532,-79.3832
|
||||
nostr.girino.org:443,43.6532,-79.3832
|
||||
bitsat.molonlabe.holdings,51.4012,-1.3147
|
||||
nostr.azzamo.net,52.2633,21.0283
|
||||
insta-relay.apps3.slidestr.net,40.4167,-3.70329
|
||||
bridge.tagomago.me,42.3601,-71.0589
|
||||
nostr.thalheim.io,60.1699,24.9384
|
||||
relay.artx.market:443,43.6548,-79.3885
|
||||
nostr.openhoofd.nl,51.5717,3.70417
|
||||
nostr.bond,50.1109,8.68213
|
||||
relay.earthly.city,34.1749,-118.54
|
||||
nexus.libernet.app,43.6532,-79.3832
|
||||
relay.plebeian.market,50.1109,8.68213
|
||||
relay.nostr.net,43.6532,-79.3832
|
||||
nostr.overmind.lol,43.6532,-79.3832
|
||||
relay.ohstr.com,43.6532,-79.3832
|
||||
testnet-relay.samt.st:443,40.8302,-74.1299
|
||||
relay01.lnfi.network,35.6764,139.65
|
||||
relay.mostr.pub:443,43.6532,-79.3832
|
||||
wot.nostr.party,36.1659,-86.7844
|
||||
relayone.soundhsa.com,39.1008,-94.5811
|
||||
relay.mostro.network,40.8302,-74.1299
|
||||
ribo.eu.nostria.app,43.6532,-79.3832
|
||||
chat-relay.zap-work.com,43.6532,-79.3832
|
||||
relay.nostreon.com,60.1699,24.9384
|
||||
nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874
|
||||
nostr.quali.chat:443,60.1699,24.9384
|
||||
relay.internationalright-wing.org:443,-22.5022,-48.7114
|
||||
relay.mitchelltribe.com:443,39.0438,-77.4874
|
||||
relay.satlantis.io,40.8054,-74.0241
|
||||
nittom.nostr1.com:443,40.7057,-74.0136
|
||||
nostr.janx.com,43.6532,-79.3832
|
||||
nostr.carroarmato0.be:443,50.914,3.21378
|
||||
relay.mmwaves.de:443,48.8575,2.35138
|
||||
relay.chorus.community:443,48.5333,10.7
|
||||
wot.utxo.one,43.6532,-79.3832
|
||||
relay.plebeian.market:443,50.1109,8.68213
|
||||
relay.cosmicbolt.net,37.3986,-121.964
|
||||
x.kojira.io:443,43.6532,-79.3832
|
||||
top.testrelay.top,43.6532,-79.3832
|
||||
nos.lol:443,50.4754,12.3683
|
||||
dev.relay.edufeed.org,49.4521,11.0767
|
||||
relayone.geektank.ai:443,39.1008,-94.5811
|
||||
relay.nostar.org,43.6532,-79.3832
|
||||
nostr.oxtr.dev:443,50.4754,12.3683
|
||||
nostr.88mph.life,52.1941,-2.21905
|
||||
relay.staging.commonshub.brussels,49.4543,11.0746
|
||||
weboftrust.libretechsystems.xyz,55.4724,9.87335
|
||||
relay.openfarmtools.org,60.1699,24.9384
|
||||
cs-relay.nostrdev.com,50.4754,12.3683
|
||||
relay.inforsupports.com,43.6532,-79.3832
|
||||
nostr-verified.wellorder.net,45.5201,-122.99
|
||||
nostr.hekster.org,37.3986,-121.964
|
||||
relay.gulugulu.moe:443,43.6532,-79.3832
|
||||
relay.mwaters.net,50.9871,2.12554
|
||||
nostrcity-club.fly.dev:443,37.7648,-122.432
|
||||
relay.vrtmrz.net:443,43.6532,-79.3832
|
||||
relay.nostr.place,43.6532,-79.3832
|
||||
relay.wavefunc.live:443,41.8781,-87.6298
|
||||
nostr.islandarea.net,35.4669,-97.6473
|
||||
purplerelay.com:443,43.6532,-79.3832
|
||||
nostr-relay.psfoundation.info:443,39.0438,-77.4874
|
||||
r.0kb.io,32.789,-96.7989
|
||||
relay-us.zombi.cloudrodion.com,40.7862,-74.0743
|
||||
relay.mulatta.io,37.5665,126.978
|
||||
strfry.bonsai.com:443,39.0438,-77.4874
|
||||
bendernostur.duckdns.org:8443,50.1109,8.68213
|
||||
vault.iris.to,43.6532,-79.3832
|
||||
ec2.f7z.io,60.1699,24.9384
|
||||
nostr.debate.report,50.1109,8.68213
|
||||
wot.codingarena.top,50.4754,12.3683
|
||||
relay.layer.systems:443,49.0291,8.35695
|
||||
relay.degmods.com,50.4754,12.3683
|
||||
nostr.mom,50.4754,12.3683
|
||||
ribo.us.nostria.app:443,43.6532,-79.3832
|
||||
adre.su,59.9311,30.3609
|
||||
wot.sudocarlos.com,43.6532,-79.3832
|
||||
relay.nostrian-conquest.com,41.223,-111.974
|
||||
nostr-relay.nextblockvending.com,47.2343,-119.853
|
||||
relay.endfiat.money:443,59.3327,18.0656
|
||||
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
|
||||
nostr.carroarmato0.be,50.914,3.21378
|
||||
relay.cypherflow.ai:443,48.8575,2.35138
|
||||
nostr.girino.org,43.6532,-79.3832
|
||||
nostr.thebiglake.org,32.71,-96.6745
|
||||
strfry.ymir.cloud,43.6532,-79.3832
|
||||
relay.mypathtofire.de,42.8864,-78.8784
|
||||
relay.lanacoin-eternity.com,40.8302,-74.1299
|
||||
nostr.snowbla.de:443,60.1699,24.9384
|
||||
relay.ditto.pub,43.6532,-79.3832
|
||||
relay.damus.io,43.6532,-79.3832
|
||||
relay.ru.ac.th,13.7607,100.627
|
||||
nrs-01.darkcloudarcade.com,39.1008,-94.5811
|
||||
testnet-relay.samt.st,40.8302,-74.1299
|
||||
antiprimal.net,43.6532,-79.3832
|
||||
bitchat.nostr1.com,40.7057,-74.0136
|
||||
relay.snort.social,53.3498,-6.26031
|
||||
relay.mccormick.cx:443,52.3563,4.95714
|
||||
relay02.lnfi.network,35.6764,139.65
|
||||
srtrelay.c-stellar.net,43.6532,-79.3832
|
||||
relay.minibolt.info,43.6532,-79.3832
|
||||
nostrride.io,37.3986,-121.964
|
||||
articles.layer3.news:443,37.3387,-121.885
|
||||
rele.speyhard.fi,51.5072,-0.127586
|
||||
relay.aarpia.com,37.3986,-121.964
|
||||
nostr.chaima.info,50.1109,8.68213
|
||||
relay.wisp.talk:443,49.4543,11.0746
|
||||
relay.agorist.space:443,52.3734,4.89406
|
||||
strfry.bonsai.com,39.0438,-77.4874
|
||||
nostr.hifish.org,47.4244,8.57658
|
||||
offchain.pub,39.1585,-94.5728
|
||||
nostr.spicyz.io:443,43.6532,-79.3832
|
||||
relay.beginningend.com,35.2227,-97.4786
|
||||
relay.sharegap.net,43.6532,-79.3832
|
||||
nostr.purpura.cloud,43.6532,-79.3832
|
||||
nrs-01.darkcloudarcade.com:443,39.1008,-94.5811
|
||||
relay.fountain.fm:443,43.6532,-79.3832
|
||||
relay.olas.app,60.1699,24.9384
|
||||
relay.mmwaves.de,48.8575,2.35138
|
||||
relay.openresist.com:443,43.6532,-79.3832
|
||||
relay.homeinhk.xyz,35.694,139.754
|
||||
relay.libernet.app,43.6532,-79.3832
|
||||
relay.comcomponent.com,43.6532,-79.3832
|
||||
nostr.tac.lol,47.4748,-122.273
|
||||
relay.goodmorningbitcoin.com,43.6532,-79.3832
|
||||
relay.nostriot.com:443,41.5695,-83.9786
|
||||
bcast.girino.org,43.6532,-79.3832
|
||||
nostr.azzamo.net:443,52.2633,21.0283
|
||||
relay.islandbitcoin.com:443,12.8498,77.6545
|
||||
pool.libernet.app,43.6532,-79.3832
|
||||
test.thedude.cloud,50.1109,8.68213
|
||||
nostrelites.org,41.8781,-87.6298
|
||||
nostr.infero.net,35.6764,139.65
|
||||
relay.primal.net,43.6532,-79.3832
|
||||
ribo.nostria.app,43.6532,-79.3832
|
||||
relay.chorus.community,48.5333,10.7
|
||||
bitcoiner.social:443,47.6743,-117.112
|
||||
relay.wisp.talk,49.4543,11.0746
|
||||
relay.layer.systems,49.0291,8.35695
|
||||
relay-dev.satlantis.io,40.8302,-74.1299
|
||||
nostr.bitcoiner.social,47.6743,-117.112
|
||||
relay.lanavault.space:443,60.1699,24.9384
|
||||
relay.staging.plebeian.market,51.5072,-0.127586
|
||||
infinity-signal-relay.digitalforlifeagency.workers.dev,43.6532,-79.3832
|
||||
relay.fountain.fm,43.6532,-79.3832
|
||||
nostr.middling.mydns.jp,35.8099,140.12
|
||||
relay.dreamith.to,43.6532,-79.3832
|
||||
relay.satmaxt.xyz:443,43.6532,-79.3832
|
||||
shu03.shugur.net,25.2048,55.2708
|
||||
zealand-charts-craig-thru.trycloudflare.com,43.6532,-79.3832
|
||||
nostr.computingcache.com,34.0356,-118.442
|
||||
ribo.us.nostria.app,43.6532,-79.3832
|
||||
relay.agentry.com,42.8864,-78.8784
|
||||
nostr.hifish.org:443,47.4244,8.57658
|
||||
nostr.vulpem.com,49.4543,11.0746
|
||||
relay.cosmicbolt.net:443,37.3986,-121.964
|
||||
nostr-02.yakihonne.com,1.32123,103.695
|
||||
r.0kb.io:443,32.789,-96.7989
|
||||
nostr-relay.corb.net,38.8353,-104.822
|
||||
ribo.eu.nostria.app:443,43.6532,-79.3832
|
||||
nostr-relay.psfoundation.info,39.0438,-77.4874
|
||||
relay.wellorder.net,45.5201,-122.99
|
||||
relay.novospes.com,43.6532,-79.3832
|
||||
nostr-dev.wellorder.net,45.5201,-122.99
|
||||
relay.endfiat.money,59.3327,18.0656
|
||||
relay.angor.io:443,48.1046,11.6002
|
||||
relay-fra.zombi.cloudrodion.com:443,48.8566,2.35222
|
||||
strfry.openhoofd.nl,51.5717,3.70417
|
||||
relay.getsafebox.app,43.6532,-79.3832
|
||||
relay.openresist.com,43.6532,-79.3832
|
||||
relay5.bitransfer.org,43.6532,-79.3832
|
||||
nostr.na.social,43.6532,-79.3832
|
||||
portal-relay.pareto.space,49.0291,8.35696
|
||||
nostr.notribe.net:443,40.8302,-74.1299
|
||||
relay.bitmacro.cloud,43.6532,-79.3832
|
||||
no.str.cr:443,10.6352,-85.4378
|
||||
relay.klabo.world,47.2343,-119.853
|
||||
nostr.notribe.net,40.8302,-74.1299
|
||||
relay.staging.plebeian.market:443,51.5072,-0.127586
|
||||
relay.nostrmap.net,60.1699,24.9384
|
||||
temp.iris.to,43.6532,-79.3832
|
||||
nostr.sovereignservices.xyz,43.6532,-79.3832
|
||||
nostr.liberty.fans,36.9104,-89.5875
|
||||
relay.nostrian-conquest.com:443,41.223,-111.974
|
||||
relay.nostriot.com,41.5695,-83.9786
|
||||
nostrbtc.com,43.6532,-79.3832
|
||||
shu02.shugur.net,21.4902,39.2246
|
||||
relay.kalcafe.xyz,37.3986,-121.964
|
||||
relay.illuminodes.com,43.6532,-79.3832
|
||||
relay.wavlake.com,41.2619,-95.8608
|
||||
nostr.ps1829.com:443,33.8851,130.883
|
||||
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
|
||||
nostr.wecsats.io:443,43.6532,-79.3832
|
||||
nostr-pub.wellorder.net,45.5201,-122.99
|
||||
nostr.dlcdevkit.com:443,40.0992,-83.1141
|
||||
nostr.mom:443,50.4754,12.3683
|
||||
ribo.nostria.app:443,43.6532,-79.3832
|
||||
nostr.2b9t.xyz,34.0549,-118.243
|
||||
nostr.data.haus:443,50.4754,12.3683
|
||||
staging.yabu.me,35.6092,139.73
|
||||
relay.sigit.io:443,50.4754,12.3683
|
||||
relay.edufeed.org:443,49.4521,11.0767
|
||||
nostr-01.yakihonne.com:443,1.32123,103.695
|
||||
reraw.pbla2fish.cc,43.6532,-79.3832
|
||||
cs-relay.nostrdev.com:443,50.4754,12.3683
|
||||
herbstmeister.com,34.0549,-118.243
|
||||
relay.minibolt.info:443,43.6532,-79.3832
|
||||
relay2.angor.io:443,48.1046,11.6002
|
||||
social.amanah.eblessing.co,48.1046,11.6002
|
||||
nostr.stakey.net,52.3676,4.90414
|
||||
nostr.computingcache.com:443,34.0356,-118.442
|
||||
slick.mjex.me,39.0418,-77.4744
|
||||
fanfares.nostr1.com,40.7057,-74.0136
|
||||
bitcoinostr.duckdns.org,43.3434,-3.99532
|
||||
nostr.oxtr.dev,50.4754,12.3683
|
||||
cache.trustr.ing,43.6548,-79.3885
|
||||
purplerelay.com,43.6532,-79.3832
|
||||
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
|
||||
nostr-relay.corb.net:443,38.8353,-104.822
|
||||
relay-dev.gulugulu.moe,43.6532,-79.3832
|
||||
prl.plus,55.7628,37.5983
|
||||
nostr.tac.lol:443,47.4748,-122.273
|
||||
relay.mostr.pub,43.6532,-79.3832
|
||||
schnorr.me:443,43.6532,-79.3832
|
||||
dev-relay.nostreon.com,60.1699,24.9384
|
||||
nostr.islandarea.net:443,35.4669,-97.6473
|
||||
nostr.rtvslawenia.com,49.4543,11.0746
|
||||
relay.bowlafterbowl.com,32.9483,-96.7299
|
||||
nostr.quali.chat:443,60.1699,24.9384
|
||||
relay.plebeian.market,50.1109,8.68213
|
||||
relay-rpi.edufeed.org,49.4521,11.0767
|
||||
r.0kb.io,32.789,-96.7989
|
||||
nostr.notribe.net:443,40.8302,-74.1299
|
||||
relay.getsafebox.app:443,43.6532,-79.3832
|
||||
nostr.dlcdevkit.com:443,40.0992,-83.1141
|
||||
nostrelites.org,34.9582,-81.9907
|
||||
nostr.hoppe-relay.it.com,42.8864,-78.8784
|
||||
nostr.thebiglake.org,32.71,-96.6745
|
||||
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
|
||||
relay.nostriot.com,41.5695,-83.9786
|
||||
nostr.christiansass.de,51.7634,7.8887
|
||||
relay.btcforplebs.com,43.6532,-79.3832
|
||||
nostr.tagomago.me,42.3601,-71.0589
|
||||
relayone.geektank.ai,39.0997,-94.5786
|
||||
relay.dreamith.to:443,43.6532,-79.3832
|
||||
nostr.liberty.fans,36.8767,-89.5879
|
||||
wot.makenomistakes.ca,43.7064,-79.3986
|
||||
relay.goodmorningbitcoin.com,43.6532,-79.3832
|
||||
relay.layer.systems,49.0291,8.35695
|
||||
relay.paulstephenborile.com:443,49.4543,11.0746
|
||||
relay.ohstr.com,43.6532,-79.3832
|
||||
nostr-relay.xbytez.io:443,50.6924,3.20113
|
||||
nostr.ac,38.958,-77.3592
|
||||
ribo.us.nostria.app,43.6532,-79.3832
|
||||
nostr.21crypto.ch,47.5356,8.73209
|
||||
relay.chorus.community:443,48.5333,10.7
|
||||
relay.cypherflow.ai,48.8575,2.35138
|
||||
relay.agorist.space:443,52.3734,4.89406
|
||||
relay.nostrian-conquest.com:443,41.223,-111.974
|
||||
relay.keykeeper.world,40.7824,-74.0711
|
||||
relay.getvia.xyz,60.1699,24.9384
|
||||
relay.nuts.cash,52.3676,4.90414
|
||||
kotukonostr.onrender.com,37.7775,-122.397
|
||||
relay.minibolt.info,43.6532,-79.3832
|
||||
relay.dwadziesciajeden.pl,52.2297,21.0122
|
||||
relay.fountain.fm:443,43.6532,-79.3832
|
||||
relay.fountain.fm,43.6532,-79.3832
|
||||
nostr-02.uid.ovh,50.9871,2.12554
|
||||
relay.lanavault.space:443,60.1699,24.9384
|
||||
nostr.carroarmato0.be,50.914,3.21378
|
||||
nexus.libernet.app:443,43.6532,-79.3832
|
||||
relay.artio.inf.unibe.ch,46.9501,7.43678
|
||||
bucket.coracle.social,37.7775,-122.397
|
||||
blossom.gnostr.cloud,43.6532,-79.3832
|
||||
relay.binaryrobot.com,43.6532,-79.3832
|
||||
relay.earthly.city,34.1749,-118.54
|
||||
nostr.hifish.org,47.4244,8.57658
|
||||
offchain.pub:443,39.1585,-94.5728
|
||||
relay.bullishbounty.com:443,43.6532,-79.3832
|
||||
strfry.openhoofd.nl:443,51.5717,3.70417
|
||||
cs-relay.nostrdev.com:443,50.4754,12.3683
|
||||
strfry.ymir.cloud,43.6532,-79.3832
|
||||
nostrbtc.com,43.6532,-79.3832
|
||||
relay.directsponsor.net,42.8864,-78.8784
|
||||
nostr2.girino.org,43.6532,-79.3832
|
||||
relay.sigit.io:443,50.4754,12.3683
|
||||
relay.getsafebox.app,43.6532,-79.3832
|
||||
antiprimal.net,43.6532,-79.3832
|
||||
nostr.sathoarder.com,48.5734,7.75211
|
||||
inbox.scuba323.com,40.8218,-74.45
|
||||
nrs-01.darkcloudarcade.com:443,39.0997,-94.5786
|
||||
nostr.tac.lol,47.4748,-122.273
|
||||
nostr.davenov.com,50.1109,8.68213
|
||||
relay.trotters.cc:443,43.6532,-79.3832
|
||||
nostr.plantroon.com:443,50.1013,8.62643
|
||||
relay.nostreon.com,60.1699,24.9384
|
||||
nostr.easycryptosend.it,43.6532,-79.3832
|
||||
nostr-01.yakihonne.com:443,1.32123,103.695
|
||||
relay-testnet.k8s.layer3.news,37.3387,-121.885
|
||||
nostr.purpura.cloud,43.6532,-79.3832
|
||||
insta-relay.apps3.slidestr.net,40.4167,-3.70329
|
||||
nostr.mifen.me,43.6532,-79.3832
|
||||
testnet-relay.samt.st:443,40.8302,-74.1299
|
||||
nostr.2b9t.xyz:443,34.0549,-118.243
|
||||
relay.wavlake.com:443,41.2619,-95.8608
|
||||
relay.wisp.talk:443,49.4543,11.0746
|
||||
relay-dev.satlantis.io:443,39.0438,-77.4874
|
||||
relay.satlantis.io,39.0438,-77.4874
|
||||
relay.staging.plebeian.market,51.5072,-0.127586
|
||||
relay.openfarmtools.org,60.1699,24.9384
|
||||
relay.nostrhub.fr,48.1045,11.6004
|
||||
nostr-relay.xbytez.io,50.6924,3.20113
|
||||
relay.binaryrobot.com:443,43.6532,-79.3832
|
||||
relay.samt.st,40.8302,-74.1299
|
||||
relay.illuminodes.com,43.6532,-79.3832
|
||||
relay.liberbitworld.org,43.6532,-79.3832
|
||||
relay.olas.app:443,60.1699,24.9384
|
||||
no.str.cr,8.96171,-83.5246
|
||||
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
|
||||
wot.rejecttheframe.xyz,43.6532,-79.3832
|
||||
relay.nostriot.com:443,41.5695,-83.9786
|
||||
nostr.plantroon.com,50.1013,8.62643
|
||||
nostr-01.uid.ovh,50.9871,2.12554
|
||||
relay.openresist.com:443,43.6532,-79.3832
|
||||
nostr.overmind.lol,43.6532,-79.3832
|
||||
relay.internationalright-wing.org,-22.5022,-48.7114
|
||||
nostr.myshosholoza.co.za:443,52.3676,4.90414
|
||||
nostr.pbfs.io:443,50.4754,12.3683
|
||||
21milionidinostr.duckdns.org,41.8967,12.4822
|
||||
nostr.4rs.nl,49.0291,8.35696
|
||||
relay.lanavault.space,60.1699,24.9384
|
||||
relay.mostr.pub,43.6532,-79.3832
|
||||
relay.nostar.org,43.6532,-79.3832
|
||||
nostr.mom,50.4754,12.3683
|
||||
relay.decentralia.fr,48.122,11.589
|
||||
relay.agentry.com,42.8864,-78.8784
|
||||
relay2.angor.io,48.1046,11.6002
|
||||
slick.mjex.me,39.0418,-77.4744
|
||||
relay-us.zombi.cloudrodion.com,40.7862,-74.0743
|
||||
relay.vrtmrz.net:443,43.6532,-79.3832
|
||||
relay.beginningend.com,35.2227,-97.4786
|
||||
chat-relay.zap-work.com:443,43.6532,-79.3832
|
||||
relay.underorion.se,50.1109,8.68213
|
||||
relay.mitchelltribe.com,39.0438,-77.4874
|
||||
relay.qstr.app,51.5072,-0.127586
|
||||
relay.cyberguy.fyi,52.6907,4.8181
|
||||
strfry.bonsai.com:443,39.0438,-77.4874
|
||||
relayone.soundhsa.com:443,39.0997,-94.5786
|
||||
relay.sigit.io,50.4754,12.3683
|
||||
relay.npubhaus.com,43.6532,-79.3832
|
||||
relayrs.notoshi.win,43.6532,-79.3832
|
||||
relay.mitchelltribe.com:443,39.0438,-77.4874
|
||||
relay.44billion.net,43.6532,-79.3832
|
||||
reraw.pbla2fish.cc,43.6532,-79.3832
|
||||
articles.layer3.news:443,37.3387,-121.885
|
||||
nostr.sovereignservices.xyz,43.6532,-79.3832
|
||||
relay.nostx.io,43.6532,-79.3832
|
||||
nostr-relay.amethyst.name,39.0067,-77.4291
|
||||
0x-nostr-relay.fly.dev,37.7648,-122.432
|
||||
relay.ohstr.com:443,43.6532,-79.3832
|
||||
00f2e774.relay.dev.thunderegg.us,39.0438,-77.4874
|
||||
nostr-relay.cbrx.io,43.6532,-79.3832
|
||||
relay.wavlake.com,41.2619,-95.8608
|
||||
purplerelay.com:443,43.6532,-79.3832
|
||||
nostr-pr02.redscrypt.org,52.3676,4.90414
|
||||
fanfares.nostr1.com:443,40.7057,-74.0136
|
||||
kasztanowa.bieda.it,43.6532,-79.3832
|
||||
relay.flashapp.me,43.6548,-79.3885
|
||||
relay.typedcypher.com,51.5072,-0.127586
|
||||
nostr.bond,50.1109,8.68213
|
||||
nostr.azzamo.net,52.2633,21.0283
|
||||
nexus.libernet.app,43.6532,-79.3832
|
||||
relay.cosmicbolt.net,37.3986,-121.964
|
||||
schnorr.me,43.6532,-79.3832
|
||||
relay.mostro.network:443,40.8302,-74.1299
|
||||
relay-arg.zombi.cloudrodion.com,1.35208,103.82
|
||||
relay.chorus.community,48.5333,10.7
|
||||
blossom.gnostr.cloud:443,43.6532,-79.3832
|
||||
syb.lol:443,34.0549,-118.243
|
||||
relay.dyne.org,49.0291,8.35705
|
||||
btc.klendazu.com,41.2861,1.24993
|
||||
wot.nostr.place,43.6532,-79.3832
|
||||
relay.openresist.com,43.6532,-79.3832
|
||||
rilo.nostria.app:443,43.6532,-79.3832
|
||||
no.str.cr:443,8.96171,-83.5246
|
||||
relay.mostr.pub:443,43.6532,-79.3832
|
||||
relay.edufeed.org:443,49.4521,11.0767
|
||||
nostr.debate.report,50.1109,8.68213
|
||||
relay.satmaxt.xyz:443,43.6532,-79.3832
|
||||
relay.artx.market:443,43.6548,-79.3885
|
||||
relay-dev.gulugulu.moe,43.6532,-79.3832
|
||||
relay.novospes.com,43.6532,-79.3832
|
||||
relay.nostr-check.me,43.6532,-79.3832
|
||||
nostr.computingcache.com,34.0356,-118.442
|
||||
nostr.oxtr.dev,50.4754,12.3683
|
||||
relay.fckstate.net,59.3293,18.0686
|
||||
relay.vrtmrz.net,43.6532,-79.3832
|
||||
relay.bornheimer.app,51.5072,-0.127586
|
||||
relay.guggero.org,46.5971,9.59652
|
||||
relay01.lnfi.network,35.6764,139.65
|
||||
wot.shaving.kiwi,43.6532,-79.3832
|
||||
nostr.twinkle.lol,51.902,7.6657
|
||||
relay.edufeed.org,49.4521,11.0767
|
||||
relay.lanacoin-eternity.com,40.8302,-74.1299
|
||||
relay.satmaxt.xyz,43.6532,-79.3832
|
||||
nostr.hifish.org:443,47.4244,8.57658
|
||||
relay.cypherflow.ai:443,48.8575,2.35138
|
||||
infinity-signal-relay.digitalforlifeagency.workers.dev,43.6532,-79.3832
|
||||
nostr.na.social:443,43.6532,-79.3832
|
||||
nostr.rtvslawenia.com:443,49.4543,11.0746
|
||||
relay.mypathtofire.de,42.8864,-78.8784
|
||||
public.crostr.com,43.6532,-79.3832
|
||||
relay.olas.app,60.1699,24.9384
|
||||
relay.agora.social,50.7383,15.0648
|
||||
ribo.nostria.app,43.6532,-79.3832
|
||||
relay.lab.rytswd.com,49.4543,11.0746
|
||||
relay.ditto.pub:443,43.6532,-79.3832
|
||||
porchlight.social,43.6532,-79.3832
|
||||
nostr.notribe.net,40.8302,-74.1299
|
||||
relay.endfiat.money,59.3327,18.0656
|
||||
nostr.myshosholoza.co.za,52.3676,4.90414
|
||||
relay.nearhood.co.uk,51.5134,-0.0890675
|
||||
relay.degmods.com,50.4754,12.3683
|
||||
nostr.novacisko.cz,52.2026,20.9397
|
||||
prl.plus,55.7628,37.5983
|
||||
bruh.samt.st,43.6532,-79.3832
|
||||
strfry.openhoofd.nl,51.5717,3.70417
|
||||
nostr.spicyz.io,43.6532,-79.3832
|
||||
nostr.na.social,43.6532,-79.3832
|
||||
nip85.nosfabrica.com,39.0997,-94.5786
|
||||
premium.primal.net,43.6532,-79.3832
|
||||
fanfares.nostr1.com,40.7057,-74.0136
|
||||
relay.scuba323.com,40.8218,-74.45
|
||||
nostr2.girino.org:443,43.6532,-79.3832
|
||||
relay.mmwaves.de,48.8575,2.35138
|
||||
nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874
|
||||
strfry.shock.network:443,39.0438,-77.4874
|
||||
nostr.snowbla.de,50.4754,12.3683
|
||||
nostr.spaceshell.xyz,43.6532,-79.3832
|
||||
nostr.quali.chat,60.1699,24.9384
|
||||
wot.utxo.one,43.6532,-79.3832
|
||||
relay.mccormick.cx,52.3563,4.95714
|
||||
mostro-p2p.tech,50.1109,8.68213
|
||||
basspistol.org,49.0291,8.35696
|
||||
ribo.nostria.app:443,43.6532,-79.3832
|
||||
chorus.mikedilger.com:444,-36.8906,174.794
|
||||
nostr.oxtr.dev:443,50.4754,12.3683
|
||||
nostr.nodesmap.com,59.3327,18.0656
|
||||
offchain.bostr.online,43.6532,-79.3832
|
||||
purplerelay.com,43.6532,-79.3832
|
||||
relayrs.notoshi.win:443,43.6532,-79.3832
|
||||
relay.wavefunc.live,41.8781,-87.6298
|
||||
relay.dreamith.to,43.6532,-79.3832
|
||||
bendernostur.duckdns.org:8443,50.1109,8.68213
|
||||
relay.nmail.li,50.9871,2.12554
|
||||
nostr-relay.corb.net,39.6478,-104.988
|
||||
relay.staging.plebeian.market:443,51.5072,-0.127586
|
||||
spamspamspamspam.rest,43.6532,-79.3832
|
||||
relay1.gfcom.info,13.9215,100.538
|
||||
schnorr.me:443,43.6532,-79.3832
|
||||
relay.lab.rytswd.com:443,49.4543,11.0746
|
||||
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
|
||||
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
|
||||
relay.nostrmap.net,60.1699,24.9384
|
||||
nostr.relay.hedwig.sh,60.1699,24.9384
|
||||
relay.veganostr.com:443,60.1699,24.9384
|
||||
relay.wavefunc.live:443,41.8781,-87.6298
|
||||
nostr.mikoshi.de,52.52,13.405
|
||||
syb.lol,34.0549,-118.243
|
||||
relay1.nostrchat.io,60.1699,24.9384
|
||||
nostr.wecsats.io:443,43.6532,-79.3832
|
||||
nostr.chaima.info:443,51.5072,-0.127586
|
||||
nostr.azzamo.net:443,52.2633,21.0283
|
||||
relay-can.zombi.cloudrodion.com,43.6532,-79.3832
|
||||
nostr.unkn0wn.world,46.8499,9.53287
|
||||
relayone.soundhsa.com,39.0997,-94.5786
|
||||
x.kojira.io,43.6532,-79.3832
|
||||
dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
|
||||
nostrelay.circum.space,52.6907,4.8181
|
||||
relay.primal.net,43.6532,-79.3832
|
||||
nostr.girino.org,43.6532,-79.3832
|
||||
nostr.pbfs.io,50.4754,12.3683
|
||||
relay.kalcafe.xyz,37.3986,-121.964
|
||||
relay.gulugulu.moe,43.6532,-79.3832
|
||||
top.testrelay.top,43.6532,-79.3832
|
||||
relay.kilombino.com,43.6532,-79.3832
|
||||
nos.lol:443,50.4754,12.3683
|
||||
nos.lol,50.4754,12.3683
|
||||
relay.nostr.place:443,43.6532,-79.3832
|
||||
cache.trustr.ing,43.6548,-79.3885
|
||||
relay.internationalright-wing.org:443,-22.5022,-48.7114
|
||||
relay.laantungir.net,-19.4692,-42.5315
|
||||
relay.lightning.pub:443,39.0438,-77.4874
|
||||
nostr.stakey.net,52.3676,4.90414
|
||||
articles.layer3.news,37.3387,-121.885
|
||||
relay.wisp.talk,49.4543,11.0746
|
||||
relay.pyramid.li,47.4093,8.46503
|
||||
relay.typedcypher.com:443,51.5072,-0.127586
|
||||
dev.relay.stream,43.6532,-79.3832
|
||||
relay.bullishbounty.com,43.6532,-79.3832
|
||||
nostr.mom:443,50.4754,12.3683
|
||||
relay.plebeian.market:443,50.1109,8.68213
|
||||
nostr.hekster.org,37.3986,-121.964
|
||||
nostrcity-club.fly.dev,37.7648,-122.432
|
||||
nostr.vulpem.com,49.4543,11.0746
|
||||
relay-dev.gulugulu.moe:443,43.6532,-79.3832
|
||||
weboftrust.libretechsystems.xyz,55.4724,9.87335
|
||||
nostr-relay.corb.net:443,39.6478,-104.988
|
||||
wheat.happytavern.co,43.6532,-79.3832
|
||||
relay.mappingbitcoin.com,43.6532,-79.3832
|
||||
testnet-relay.samt.st,40.8302,-74.1299
|
||||
relay.bitmacro.cloud,43.6532,-79.3832
|
||||
dev.relay.edufeed.org,49.4521,11.0767
|
||||
myvoiceourstory.org,37.3598,-121.981
|
||||
relay.stickeroo.is-cool.dev,37.3387,-121.885
|
||||
relay.agorist.space,52.3734,4.89406
|
||||
freelay.sovbit.host,60.1699,24.9384
|
||||
nostr-dev.wellorder.net,45.5201,-122.99
|
||||
nostr.middling.mydns.jp,35.8099,140.12
|
||||
cs-relay.nostrdev.com,50.4754,12.3683
|
||||
x.kojira.io:443,43.6532,-79.3832
|
||||
nostrelay.circum.space:443,52.6907,4.8181
|
||||
nostr.janx.com,43.6532,-79.3832
|
||||
relay.mrmave.work,43.6532,-79.3832
|
||||
espelho.girino.org,43.6532,-79.3832
|
||||
hol.is,43.6532,-79.3832
|
||||
ribo.eu.nostria.app,43.6532,-79.3832
|
||||
nostr.yutakobayashi.com,43.6532,-79.3832
|
||||
relay.mostro.network,40.8302,-74.1299
|
||||
communities.nos.social,40.8302,-74.1299
|
||||
relay.solife.me,43.6532,-79.3832
|
||||
yabu.me,35.6092,139.73
|
||||
relay.islandbitcoin.com,12.8498,77.6545
|
||||
nostr.wecsats.io,43.6532,-79.3832
|
||||
nostr.tac.lol:443,47.4748,-122.273
|
||||
relay.arx-ccn.com,50.4754,12.3683
|
||||
nostrride.io,37.3986,-121.964
|
||||
r.0kb.io:443,32.789,-96.7989
|
||||
herbstmeister.com,34.0549,-118.243
|
||||
relay.artx.market,43.6548,-79.3885
|
||||
vault.iris.to,43.6532,-79.3832
|
||||
relay.ru.ac.th,13.7607,100.627
|
||||
temp.iris.to,43.6532,-79.3832
|
||||
social.amanah.eblessing.co,48.1046,11.6002
|
||||
nostr-relay.nextblockvending.com,47.2343,-119.853
|
||||
wot.codingarena.top,50.4754,12.3683
|
||||
relay.sincensura.org,43.6532,-79.3832
|
||||
nostr.quali.chat,60.1699,24.9384
|
||||
relay.vrtmrz.net,43.6532,-79.3832
|
||||
relay-dev.gulugulu.moe:443,43.6532,-79.3832
|
||||
relay.bullishbounty.com,43.6532,-79.3832
|
||||
relay.fckstate.net,59.3293,18.0686
|
||||
nostr.rtvslawenia.com:443,49.4543,11.0746
|
||||
relay.nostx.io,43.6532,-79.3832
|
||||
relay.agorist.space,52.3734,4.89406
|
||||
relay.notoshi.win,13.7829,100.546
|
||||
dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
|
||||
relay.trotters.cc,43.6532,-79.3832
|
||||
relay.lanavault.space,60.1699,24.9384
|
||||
public.crostr.com:443,43.6532,-79.3832
|
||||
nostr.stakey.net:443,52.3676,4.90414
|
||||
relay.nostr.place:443,43.6532,-79.3832
|
||||
nostr.dlcdevkit.com,40.0992,-83.1141
|
||||
nostr.aruku.ovh,1.27994,103.849
|
||||
satsage.xyz,37.3986,-121.964
|
||||
strfry.apps3.slidestr.net,40.4167,-3.70329
|
||||
nostr2.girino.org,43.6532,-79.3832
|
||||
relay.samt.st,40.8302,-74.1299
|
||||
articles.layer3.news,37.3387,-121.885
|
||||
aeon.libretechsystems.xyz,55.486,9.86577
|
||||
relay.routstr.com,59.4016,17.9455
|
||||
relay.ohstr.com:443,43.6532,-79.3832
|
||||
relay.lanacoin-eternity.com:443,40.8302,-74.1299
|
||||
strfry.openhoofd.nl:443,51.5717,3.70417
|
||||
nostr.blankfors.se,60.1699,24.9384
|
||||
nostr-2.21crypto.ch:443,47.5356,8.73209
|
||||
relayone.soundhsa.com:443,39.1008,-94.5811
|
||||
relay.lab.rytswd.com:443,49.4543,11.0746
|
||||
nostr.tagomago.me,42.3601,-71.0589
|
||||
relay.0xchat.com:443,43.6532,-79.3832
|
||||
|
||||
|
@@ -1,69 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$repo_root"
|
||||
|
||||
tracked_justfiles="$(git ls-files | awk 'tolower($0) == "justfile"')"
|
||||
tracked_justfile_count="$(printf '%s\n' "$tracked_justfiles" | awk 'NF { count++ } END { print count + 0 }')"
|
||||
if [[ $tracked_justfile_count -ne 1 || $tracked_justfiles != "Justfile" ]]; then
|
||||
echo "Expected exactly one tracked canonical Justfile; found: ${tracked_justfiles:-none}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fxq 'clean:' Justfile; then
|
||||
echo "Clean recipe must not depend on another recipe" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
clean_recipe="$({
|
||||
awk '
|
||||
/^clean:/ { in_clean = 1; next }
|
||||
in_clean && /^[^[:space:]]/ { exit }
|
||||
in_clean { print }
|
||||
' Justfile
|
||||
})"
|
||||
|
||||
if [[ -z ${clean_recipe//[[:space:]]/} ]]; then
|
||||
echo "Justfile clean recipe is missing or empty" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fxq 'derived_data := ".DerivedData"' Justfile; then
|
||||
echo "Derived data path must remain the ignored repo-local .DerivedData directory" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
clean_forbidden='git[[:space:]]+(checkout|restore|reset|clean)|(^|[[:space:]])(cp|mv)([[:space:]]|$)|bitchat\.xcodeproj|project\.pbxproj|Info\.plist|LaunchScreen|project\.yml|Configs/'
|
||||
if grep -Eiq "$clean_forbidden" <<<"$clean_recipe"; then
|
||||
echo "Unsafe source/configuration mutation found in the clean recipe:" >&2
|
||||
grep -Ein "$clean_forbidden" <<<"$clean_recipe" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq 'rm -rf -- "{{derived_data}}" ".build"' <<<"$clean_recipe"; then
|
||||
echo "Clean recipe must remain limited to the declared repo-local artifact paths" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
clean_rm_count="$(grep -Ec '^[[:space:]]*@?rm[[:space:]]+-rf([[:space:]]|$)' <<<"$clean_recipe" || true)"
|
||||
if [[ $clean_rm_count -ne 1 ]]; then
|
||||
echo "Clean recipe must contain exactly one recursive removal command" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
expected_clean_recipe=' @echo "Cleaning repo-local build artifacts..."
|
||||
@rm -rf -- "{{derived_data}}" ".build"
|
||||
@echo "✅ Cleaned {{derived_data}} and .build; tracked files were untouched"'
|
||||
if [[ $clean_recipe != "$expected_clean_recipe" ]]; then
|
||||
echo "Clean recipe contains commands outside the reviewed artifact-only implementation" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
file_forbidden='git[[:space:]]+(checkout|restore|reset|clean)|rm[[:space:]]+-rf[^#]*(bitchat\.xcodeproj|bitchat/|Configs/)|LaunchScreen\.storyboard\.ios|project\.pbxproj\.backup|Info\.plist\.backup'
|
||||
if grep -Ein "$file_forbidden" Justfile; then
|
||||
echo "Unsafe tracked-file recovery/deletion logic found in Justfile" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Justfile clean safety check passed"
|
||||
@@ -1,61 +0,0 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
|
||||
WORKFLOW_PATH = REPOSITORY_ROOT / ".github/workflows/fetch_georelays.yml"
|
||||
|
||||
|
||||
class FetchGeoRelaysWorkflowTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.workflow = WORKFLOW_PATH.read_text(encoding="utf-8")
|
||||
|
||||
def test_write_capable_checkout_action_is_immutable(self) -> None:
|
||||
checkout = re.search(r"uses: actions/checkout@([0-9a-f]+)", self.workflow)
|
||||
self.assertIsNotNone(checkout)
|
||||
self.assertRegex(checkout.group(1), r"^[0-9a-f]{40}$")
|
||||
self.assertIn("persist-credentials: false", self.workflow)
|
||||
|
||||
def test_pr_failure_has_single_issue_fallback_with_review_metadata(self) -> None:
|
||||
required_fragments = [
|
||||
"issues: write",
|
||||
"TRACKING_ISSUE_TITLE: GeoRelay update awaiting pull request",
|
||||
"gh pr create",
|
||||
"gh issue create",
|
||||
"gh issue edit",
|
||||
"compare/main...${UPDATE_BRANCH}?expand=1",
|
||||
"Upstream commit: $SOURCE_COMMIT",
|
||||
"Data rows: $DATA_ROWS",
|
||||
"Unique normalized relays: $UNIQUE_RELAYS",
|
||||
"SHA-256: $DATA_SHA256",
|
||||
'[[ -n "$issue_url" ]]',
|
||||
]
|
||||
for fragment in required_fragments:
|
||||
with self.subTest(fragment=fragment):
|
||||
self.assertIn(fragment, self.workflow)
|
||||
|
||||
confirmed = self.workflow.index('[[ -n "$issue_url" ]]')
|
||||
success_summary = self.workflow.index(
|
||||
"Published GeoRelay tracking issue fallback: $issue_url"
|
||||
)
|
||||
self.assertLess(confirmed, success_summary)
|
||||
|
||||
def test_obsolete_review_state_is_cleaned_without_pushing_main(self) -> None:
|
||||
self.assertIn("gh pr close", self.workflow)
|
||||
self.assertIn("gh issue close", self.workflow)
|
||||
self.assertIn('git push origin --delete "$UPDATE_BRANCH"', self.workflow)
|
||||
self.assertIn('git switch -C "$UPDATE_BRANCH"', self.workflow)
|
||||
self.assertNotIn("git push origin main", self.workflow)
|
||||
self.assertNotIn("git push --force origin main", self.workflow)
|
||||
|
||||
def test_workflow_runs_all_validator_tests(self) -> None:
|
||||
self.assertIn(
|
||||
'python3 -m unittest discover -s scripts/tests -p "test_*.py" -v',
|
||||
self.workflow,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,186 +0,0 @@
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
import validate_georelays as validator
|
||||
|
||||
|
||||
def csv_bytes(rows: list[str]) -> bytes:
|
||||
return ("Relay URL,Latitude,Longitude\n" + "\n".join(rows) + "\n").encode()
|
||||
|
||||
|
||||
class ValidateGeoRelaysTests(unittest.TestCase):
|
||||
def test_validates_and_deduplicates_secure_relay_addresses(self) -> None:
|
||||
data = csv_bytes(
|
||||
[
|
||||
"relay.example.com,10,20",
|
||||
"wss://relay.example.com:443/,10,20",
|
||||
"https://second.example.org,11,21",
|
||||
]
|
||||
)
|
||||
|
||||
summary = validator.validate_bytes(data, minimum_unique_relays=2)
|
||||
|
||||
self.assertEqual(summary.data_rows, 3)
|
||||
self.assertEqual(summary.unique_relays, 2)
|
||||
|
||||
def test_rejects_insecure_or_non_host_relay_urls(self) -> None:
|
||||
bad_addresses = [
|
||||
"http://relay.example.com",
|
||||
"ws://relay.example.com",
|
||||
"wss://user@relay.example.com",
|
||||
"wss://relay.example.com/path",
|
||||
"wss://relay.example.com?",
|
||||
"wss://relay.example.com#",
|
||||
"relay.example.com:0",
|
||||
"relay.example.com:99999",
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"relay_example.com",
|
||||
"relay\u202e.example.com",
|
||||
]
|
||||
|
||||
for address in bad_addresses:
|
||||
with self.subTest(address=address):
|
||||
with self.assertRaises(validator.ValidationError):
|
||||
validator.validate_bytes(
|
||||
csv_bytes([f"{address},10,20"]),
|
||||
minimum_unique_relays=1,
|
||||
)
|
||||
|
||||
def test_rejects_malformed_rows_and_unsafe_coordinates(self) -> None:
|
||||
bad_rows = [
|
||||
"relay.example.com,10",
|
||||
"relay.example.com,NaN,20",
|
||||
"relay.example.com,1_0,20",
|
||||
"relay.example.com,\u0661\u0660,20",
|
||||
"relay.example.com,\uff11\uff10,20",
|
||||
"relay.example.com,91,20",
|
||||
"relay.example.com,10,-181",
|
||||
"relay.example.com,10,20,extra",
|
||||
'"relay.example.com",10,20',
|
||||
]
|
||||
|
||||
for row in bad_rows:
|
||||
with self.subTest(row=row):
|
||||
with self.assertRaises(validator.ValidationError):
|
||||
validator.validate_bytes(csv_bytes([row]), minimum_unique_relays=1)
|
||||
|
||||
def test_accepts_ascii_coordinate_forms_supported_by_swift_double(self) -> None:
|
||||
summary = validator.validate_bytes(
|
||||
csv_bytes(
|
||||
[
|
||||
"one.example.com,+1,-.5",
|
||||
"two.example.com,1.e1,2E+1",
|
||||
"three.example.com,01,20.",
|
||||
]
|
||||
),
|
||||
minimum_unique_relays=3,
|
||||
)
|
||||
|
||||
self.assertEqual(summary.unique_relays, 3)
|
||||
|
||||
def test_rejects_conflicts_limits_and_large_baseline_deltas(self) -> None:
|
||||
with self.assertRaises(validator.ValidationError):
|
||||
validator.validate_bytes(
|
||||
csv_bytes(["relay.example.com,10,20", "relay.example.com,11,21"]),
|
||||
minimum_unique_relays=1,
|
||||
)
|
||||
with self.assertRaises(validator.ValidationError):
|
||||
validator.validate_bytes(b"x" * 20, maximum_bytes=10, minimum_unique_relays=1)
|
||||
with self.assertRaises(validator.ValidationError):
|
||||
validator.validate_bytes(
|
||||
csv_bytes(["one.example.com,1,1", "two.example.com,2,2"]),
|
||||
minimum_unique_relays=3,
|
||||
)
|
||||
|
||||
baseline = csv_bytes(
|
||||
[f"relay-{index}.example.com,{index % 80},{index % 170}" for index in range(120)]
|
||||
)
|
||||
shrunken = csv_bytes(
|
||||
[f"relay-{index}.example.com,{index % 80},{index % 170}" for index in range(59)]
|
||||
)
|
||||
with self.assertRaises(validator.ValidationError):
|
||||
validator.validate_update(shrunken, baseline)
|
||||
|
||||
smaller_baseline = csv_bytes(
|
||||
[f"relay-{index}.example.com,{index % 80},{index % 170}" for index in range(60)]
|
||||
)
|
||||
expanded = csv_bytes(
|
||||
[f"relay-{index}.example.com,{index % 80},{index % 170}" for index in range(121)]
|
||||
)
|
||||
with self.assertRaises(validator.ValidationError):
|
||||
validator.validate_update(expanded, smaller_baseline)
|
||||
|
||||
def test_update_requires_exact_normalized_baseline_entry_overlap(self) -> None:
|
||||
baseline_rows = [
|
||||
f"relay-{index}.example.com,{index % 80},{index % 170}"
|
||||
for index in range(60)
|
||||
]
|
||||
baseline = csv_bytes(baseline_rows)
|
||||
disjoint = csv_bytes(
|
||||
[
|
||||
f"attacker-{index}.example.com,{index % 80},{index % 170}"
|
||||
for index in range(60)
|
||||
]
|
||||
)
|
||||
rewritten_coordinates = csv_bytes(
|
||||
[
|
||||
f"relay-{index}.example.com,{(index % 80) + 0.5},{index % 170}"
|
||||
for index in range(60)
|
||||
]
|
||||
)
|
||||
|
||||
for candidate in (disjoint, rewritten_coordinates):
|
||||
with self.subTest(candidate=candidate[:80]):
|
||||
with self.assertRaisesRegex(
|
||||
validator.ValidationError,
|
||||
"exact relay-coordinate entries",
|
||||
):
|
||||
validator.validate_update(candidate, baseline)
|
||||
|
||||
half_retained = csv_bytes(
|
||||
[
|
||||
f"wss://relay-{index}.example.com:443/,{index % 80},{index % 170}"
|
||||
for index in range(30)
|
||||
]
|
||||
+ [
|
||||
f"replacement-{index}.example.com,{index % 80},{index % 170}"
|
||||
for index in range(30)
|
||||
]
|
||||
)
|
||||
summary = validator.validate_update(half_retained, baseline)
|
||||
self.assertEqual(summary.unique_relays, 60)
|
||||
|
||||
def test_cli_copies_only_validated_data_and_emits_review_metadata(self) -> None:
|
||||
rows = [f"relay-{index}.example.com,{index % 80},{index % 170}" for index in range(60)]
|
||||
data = csv_bytes(rows)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
candidate = root / "candidate.csv"
|
||||
baseline = root / "baseline.csv"
|
||||
output = root / "output.csv"
|
||||
github_output = root / "github-output.txt"
|
||||
candidate.write_bytes(data)
|
||||
baseline.write_bytes(data)
|
||||
|
||||
result = validator.main(
|
||||
[
|
||||
"--input", str(candidate),
|
||||
"--baseline", str(baseline),
|
||||
"--output", str(output),
|
||||
"--github-output", str(github_output),
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(output.read_bytes(), data)
|
||||
metadata = github_output.read_text()
|
||||
self.assertIn("unique_relays=60", metadata)
|
||||
self.assertIn("sha256=", metadata)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,271 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Strict validator for the reviewed georelay CSV update workflow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import hashlib
|
||||
import io
|
||||
import math
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unicodedata
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
MAX_BYTES = 512 * 1024
|
||||
MAX_ROWS = 5_000
|
||||
MAX_UNIQUE_RELAYS = 5_000
|
||||
MIN_UNIQUE_RELAYS = 50
|
||||
MIN_BASELINE_FRACTION = 0.5
|
||||
MAX_BASELINE_MULTIPLIER = 2.0
|
||||
EXPECTED_HEADER = ("relay url", "latitude", "longitude")
|
||||
ASCII_DECIMAL_PATTERN = re.compile(
|
||||
r"[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?\Z"
|
||||
)
|
||||
|
||||
|
||||
class ValidationError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ValidationSummary:
|
||||
data_rows: int
|
||||
unique_relays: int
|
||||
sha256: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ValidatedDataset:
|
||||
summary: ValidationSummary
|
||||
entries: frozenset[tuple[str, float, float]]
|
||||
|
||||
|
||||
def _has_disallowed_control(value: str) -> bool:
|
||||
return any(
|
||||
unicodedata.category(character) in {"Cc", "Cf"}
|
||||
and character not in {"\r", "\n", "\t"}
|
||||
for character in value
|
||||
)
|
||||
|
||||
|
||||
def normalize_relay_address(raw_value: str) -> str:
|
||||
value = raw_value.strip()
|
||||
if not value or _has_disallowed_control(value):
|
||||
raise ValidationError("relay address is empty or contains control characters")
|
||||
# urlsplit cannot distinguish an absent query/fragment from an explicitly
|
||||
# empty one. Reject the delimiters themselves so this validator matches
|
||||
# URLComponents in the client and reviewed data cannot fail closed there.
|
||||
if "?" in value or "#" in value:
|
||||
raise ValidationError(f"relay query or fragment is not allowed: {value}")
|
||||
|
||||
candidate = value if "://" in value else f"wss://{value}"
|
||||
try:
|
||||
parsed = urlsplit(candidate)
|
||||
port = parsed.port
|
||||
except ValueError as error:
|
||||
raise ValidationError(f"invalid relay URL: {value}") from error
|
||||
|
||||
if parsed.scheme.lower() not in {"wss", "https"}:
|
||||
raise ValidationError(f"relay must use wss/https or a bare hostname: {value}")
|
||||
if parsed.username is not None or parsed.password is not None:
|
||||
raise ValidationError(f"relay credentials are not allowed: {value}")
|
||||
if parsed.path not in {"", "/"} or parsed.query or parsed.fragment:
|
||||
raise ValidationError(f"relay path, query, or fragment is not allowed: {value}")
|
||||
|
||||
host = (parsed.hostname or "").lower()
|
||||
if not host or len(host) > 253 or not host.isascii():
|
||||
raise ValidationError(f"relay hostname is missing or non-ASCII: {value}")
|
||||
if host.endswith(".") or host == "localhost" or host.endswith((".localhost", ".local", ".internal")):
|
||||
raise ValidationError(f"local or absolute relay hostname is not allowed: {value}")
|
||||
|
||||
labels = host.split(".")
|
||||
if len(labels) < 2 or all(label.isdigit() for label in labels):
|
||||
raise ValidationError(f"relay must use a public DNS hostname: {value}")
|
||||
for label in labels:
|
||||
if not 1 <= len(label) <= 63:
|
||||
raise ValidationError(f"invalid DNS label length: {value}")
|
||||
if label[0] == "-" or label[-1] == "-":
|
||||
raise ValidationError(f"DNS labels cannot start or end with '-': {value}")
|
||||
if any(character not in "abcdefghijklmnopqrstuvwxyz0123456789-" for character in label):
|
||||
raise ValidationError(f"invalid DNS hostname character: {value}")
|
||||
|
||||
if port is not None and not 1 <= port <= 65_535:
|
||||
raise ValidationError(f"invalid relay port: {value}")
|
||||
if port in {None, 443}:
|
||||
return host
|
||||
return f"{host}:{port}"
|
||||
|
||||
|
||||
def _validated_dataset(
|
||||
data: bytes,
|
||||
*,
|
||||
minimum_unique_relays: int = MIN_UNIQUE_RELAYS,
|
||||
maximum_bytes: int = MAX_BYTES,
|
||||
maximum_rows: int = MAX_ROWS,
|
||||
maximum_unique_relays: int = MAX_UNIQUE_RELAYS,
|
||||
) -> _ValidatedDataset:
|
||||
if not data or len(data) > maximum_bytes:
|
||||
raise ValidationError(f"CSV must contain 1..{maximum_bytes} bytes")
|
||||
|
||||
try:
|
||||
text = data.decode("utf-8")
|
||||
except UnicodeDecodeError as error:
|
||||
raise ValidationError("CSV is not valid UTF-8") from error
|
||||
if text.startswith("\ufeff"):
|
||||
raise ValidationError("UTF-8 BOM is not allowed")
|
||||
if _has_disallowed_control(text):
|
||||
raise ValidationError("CSV contains disallowed control characters")
|
||||
# Runtime intentionally implements the fixed three-field schema without
|
||||
# general CSV quoting. Reject quoted variants here so reviewed workflow
|
||||
# output and client-side validation cannot disagree.
|
||||
if '"' in text:
|
||||
raise ValidationError("quoted CSV fields are not allowed")
|
||||
|
||||
reader = csv.reader(io.StringIO(text, newline=""), strict=True)
|
||||
try:
|
||||
header = next(reader)
|
||||
except (StopIteration, csv.Error) as error:
|
||||
raise ValidationError("CSV header is missing") from error
|
||||
normalized_header = tuple(field.strip().lower() for field in header)
|
||||
if normalized_header != EXPECTED_HEADER:
|
||||
raise ValidationError(f"unexpected CSV header: {header!r}")
|
||||
|
||||
data_rows = 0
|
||||
relays: dict[str, tuple[float, float]] = {}
|
||||
try:
|
||||
for row in reader:
|
||||
if not row or all(not field.strip() for field in row):
|
||||
continue
|
||||
data_rows += 1
|
||||
if data_rows > maximum_rows:
|
||||
raise ValidationError(f"CSV exceeds {maximum_rows} data rows")
|
||||
if len(row) != 3:
|
||||
raise ValidationError(f"row {reader.line_num} must contain exactly 3 columns")
|
||||
|
||||
address = normalize_relay_address(row[0])
|
||||
latitude_text = row[1].strip()
|
||||
longitude_text = row[2].strip()
|
||||
if not ASCII_DECIMAL_PATTERN.fullmatch(latitude_text) or not ASCII_DECIMAL_PATTERN.fullmatch(longitude_text):
|
||||
raise ValidationError(
|
||||
f"row {reader.line_num} coordinates must be ASCII decimal numbers"
|
||||
)
|
||||
latitude = float(latitude_text)
|
||||
longitude = float(longitude_text)
|
||||
if not math.isfinite(latitude) or not -90 <= latitude <= 90:
|
||||
raise ValidationError(f"row {reader.line_num} latitude is out of range")
|
||||
if not math.isfinite(longitude) or not -180 <= longitude <= 180:
|
||||
raise ValidationError(f"row {reader.line_num} longitude is out of range")
|
||||
|
||||
coordinates = (latitude, longitude)
|
||||
previous = relays.get(address)
|
||||
if previous is not None and previous != coordinates:
|
||||
raise ValidationError(f"relay {address} has conflicting coordinates")
|
||||
relays[address] = coordinates
|
||||
if len(relays) > maximum_unique_relays:
|
||||
raise ValidationError(f"CSV exceeds {maximum_unique_relays} unique relays")
|
||||
except csv.Error as error:
|
||||
raise ValidationError(f"malformed CSV near line {reader.line_num}") from error
|
||||
|
||||
if len(relays) < minimum_unique_relays:
|
||||
raise ValidationError(
|
||||
f"CSV has {len(relays)} unique relays; minimum is {minimum_unique_relays}"
|
||||
)
|
||||
|
||||
return _ValidatedDataset(
|
||||
summary=ValidationSummary(
|
||||
data_rows=data_rows,
|
||||
unique_relays=len(relays),
|
||||
sha256=hashlib.sha256(data).hexdigest(),
|
||||
),
|
||||
entries=frozenset(
|
||||
(address, coordinates[0], coordinates[1])
|
||||
for address, coordinates in relays.items()
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def validate_bytes(
|
||||
data: bytes,
|
||||
*,
|
||||
minimum_unique_relays: int = MIN_UNIQUE_RELAYS,
|
||||
maximum_bytes: int = MAX_BYTES,
|
||||
maximum_rows: int = MAX_ROWS,
|
||||
maximum_unique_relays: int = MAX_UNIQUE_RELAYS,
|
||||
) -> ValidationSummary:
|
||||
return _validated_dataset(
|
||||
data,
|
||||
minimum_unique_relays=minimum_unique_relays,
|
||||
maximum_bytes=maximum_bytes,
|
||||
maximum_rows=maximum_rows,
|
||||
maximum_unique_relays=maximum_unique_relays,
|
||||
).summary
|
||||
|
||||
|
||||
def validate_update(candidate: bytes, baseline: bytes) -> ValidationSummary:
|
||||
baseline_dataset = _validated_dataset(baseline, minimum_unique_relays=1)
|
||||
candidate_dataset = _validated_dataset(candidate)
|
||||
baseline_summary = baseline_dataset.summary
|
||||
candidate_summary = candidate_dataset.summary
|
||||
|
||||
minimum_from_baseline = math.ceil(
|
||||
baseline_summary.unique_relays * MIN_BASELINE_FRACTION
|
||||
)
|
||||
maximum_from_baseline = math.floor(
|
||||
baseline_summary.unique_relays * MAX_BASELINE_MULTIPLIER
|
||||
)
|
||||
if candidate_summary.unique_relays < minimum_from_baseline:
|
||||
raise ValidationError(
|
||||
"candidate loses more than half of the baseline's unique relays "
|
||||
f"({candidate_summary.unique_relays} < {minimum_from_baseline})"
|
||||
)
|
||||
if candidate_summary.unique_relays > maximum_from_baseline:
|
||||
raise ValidationError(
|
||||
"candidate more than doubles the baseline's unique relays "
|
||||
f"({candidate_summary.unique_relays} > {maximum_from_baseline})"
|
||||
)
|
||||
|
||||
retained_entries = len(baseline_dataset.entries & candidate_dataset.entries)
|
||||
if retained_entries < minimum_from_baseline:
|
||||
raise ValidationError(
|
||||
"candidate retains fewer than half of the baseline's exact relay-coordinate entries "
|
||||
f"({retained_entries} < {minimum_from_baseline})"
|
||||
)
|
||||
return candidate_summary
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--input", required=True, type=Path)
|
||||
parser.add_argument("--baseline", required=True, type=Path)
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
parser.add_argument("--github-output", type=Path)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
candidate = args.input.read_bytes()
|
||||
baseline = args.baseline.read_bytes()
|
||||
summary = validate_update(candidate, baseline)
|
||||
args.output.write_bytes(candidate)
|
||||
if args.github_output is not None:
|
||||
with args.github_output.open("a", encoding="utf-8") as output:
|
||||
output.write(f"data_rows={summary.data_rows}\n")
|
||||
output.write(f"unique_relays={summary.unique_relays}\n")
|
||||
output.write(f"sha256={summary.sha256}\n")
|
||||
except (OSError, ValidationError) as error:
|
||||
print(f"georelay validation failed: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(
|
||||
f"validated {summary.unique_relays} unique relays across "
|
||||
f"{summary.data_rows} rows (sha256 {summary.sha256})"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user