mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-09-19 05:00:48 +00:00
Compare commits
60 Commits
733098bb63
...
9edb7c26ef
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9edb7c26ef | ||
|
|
6f32363774 | ||
|
|
59a9f628df | ||
|
|
7b39d72bec | ||
|
|
3a75567f5c | ||
|
|
f269617004 | ||
|
|
5780405dce | ||
|
|
1d0dc58221 | ||
|
|
6414a59851 | ||
|
|
6c8499a603 | ||
|
|
e2bd13a7f2 | ||
|
|
e7f4ef0912 | ||
|
|
4ef5558d7b | ||
|
|
81837d7202 | ||
|
|
ab835e58c9 | ||
|
|
e8f95e9a88 | ||
|
|
b49400ff0c | ||
|
|
e2b409e466 | ||
|
|
4226f01503 | ||
|
|
cdebdd9347 | ||
|
|
2f5b56ce57 | ||
|
|
a0b7985cbe | ||
|
|
2c22b117b2 | ||
|
|
d39467f7d3 | ||
|
|
c6b7096b2f | ||
|
|
eadd3a20c1 | ||
|
|
0152196ac2 | ||
|
|
14e7b428d9 | ||
|
|
c671e3df66 | ||
|
|
132120a88e | ||
|
|
c1ce9029d8 | ||
|
|
c079d2ab5d | ||
|
|
229a41557e | ||
|
|
934b2cd2d3 | ||
|
|
a1711bd399 | ||
|
|
a4d294015a | ||
|
|
660632ef6b | ||
|
|
c72bb4ca2e | ||
|
|
d6bd4f0681 | ||
|
|
fb451bc6d0 | ||
|
|
a9ceddab21 | ||
|
|
e3e97d51ec | ||
|
|
78a81e5b57 | ||
|
|
e9275cb3d8 | ||
|
|
55f824a11f | ||
|
|
2d96fd99a1 | ||
|
|
10886428ca | ||
|
|
bcb21f2116 | ||
|
|
b96a41054e | ||
|
|
d326cecb63 | ||
|
|
b59ad97dd6 | ||
|
|
58ccb30575 | ||
|
|
400ac4c904 | ||
|
|
565d2b7773 | ||
|
|
6b71ad2a64 | ||
|
|
16324c819f | ||
|
|
cd727c6867 | ||
|
|
fb8fe39713 | ||
|
|
ca18843bb0 | ||
|
|
593fd7d737 |
232
.github/workflows/fetch_georelays.yml
vendored
232
.github/workflows/fetch_georelays.yml
vendored
@ -1,42 +1,228 @@
|
||||
name: Fetch GeoRelays Data
|
||||
name: Propose GeoRelay Data Update
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * 0'
|
||||
- cron: "0 6 * * 0"
|
||||
workflow_dispatch:
|
||||
|
||||
# Default to read-only. The publishing job receives only the scopes required
|
||||
# to push its branch and publish either a PR or a tracking issue.
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: georelay-data-update
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
SOURCE_REPOSITORY: https://github.com/permissionlesstech/georelays.git
|
||||
UPDATE_BRANCH: automation/georelay-data
|
||||
TRACKING_ISSUE_TITLE: GeoRelay update awaiting pull request
|
||||
|
||||
jobs:
|
||||
update-relay-data:
|
||||
propose-relay-data:
|
||||
name: Validate and propose relay data
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Checkout reviewed base
|
||||
# Pinned actions/checkout v5 so a mutable action tag cannot change the
|
||||
# code that receives this job's write-capable token.
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
# Do not expose the write token to fetch/validation subprocesses.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Fetch GeoRelays
|
||||
- name: Test GeoRelay validator
|
||||
run: |
|
||||
wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
||||
mv nostr_relays.csv ./relays/online_relays_gps.csv
|
||||
set -euo pipefail
|
||||
python3 -m unittest discover -s scripts/tests -p "test_*.py" -v
|
||||
|
||||
- name: Check for changes
|
||||
id: git-check
|
||||
- name: Fetch candidate over pinned HTTPS policy
|
||||
id: upstream
|
||||
run: |
|
||||
git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Commit and push changes
|
||||
if: steps.git-check.outputs.changes == 'true'
|
||||
set -euo pipefail
|
||||
source_commit=$(git ls-remote --refs "$SOURCE_REPOSITORY" refs/heads/main | awk 'NR == 1 { print $1 }')
|
||||
if [[ ! "$source_commit" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "::error::Could not resolve an immutable upstream commit"
|
||||
exit 1
|
||||
fi
|
||||
source_url="https://raw.githubusercontent.com/permissionlesstech/georelays/$source_commit/nostr_relays.csv"
|
||||
effective_url=$(curl --fail --show-error --silent --location --proto "=https" --proto-redir "=https" --tlsv1.2 --max-time 60 --retry 3 --retry-all-errors --output "$RUNNER_TEMP/georelays-candidate.csv" --write-out "%{url_effective}" "$source_url")
|
||||
if [[ "$effective_url" != "$source_url" ]]; then
|
||||
echo "::error::Unexpected GeoRelay redirect target: $effective_url"
|
||||
exit 1
|
||||
fi
|
||||
echo "source_commit=$source_commit" >> "$GITHUB_OUTPUT"
|
||||
echo "source_url=$source_url" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate candidate against reviewed baseline
|
||||
id: validation
|
||||
run: |
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add relays/online_relays_gps.csv
|
||||
git commit -m "Automated update of relay data - $(date -u)"
|
||||
git push
|
||||
set -euo pipefail
|
||||
python3 scripts/validate_georelays.py --input "$RUNNER_TEMP/georelays-candidate.csv" --baseline relays/online_relays_gps.csv --output relays/online_relays_gps.csv --github-output "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check for a reviewed-file change
|
||||
id: changes
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git diff --quiet -- relays/online_relays_gps.csv; then
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Upstream GeoRelay data already matches main." >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
git diff --stat -- relays/online_relays_gps.csv
|
||||
fi
|
||||
|
||||
- name: Push automation branch and publish review request
|
||||
if: steps.changes.outputs.changed == 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
SOURCE_COMMIT: ${{ steps.upstream.outputs.source_commit }}
|
||||
SOURCE_URL: ${{ steps.upstream.outputs.source_url }}
|
||||
DATA_ROWS: ${{ steps.validation.outputs.data_rows }}
|
||||
UNIQUE_RELAYS: ${{ steps.validation.outputs.unique_relays }}
|
||||
DATA_SHA256: ${{ steps.validation.outputs.sha256 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Scope credential exposure to this final publishing step.
|
||||
gh auth setup-git
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
git switch -C "$UPDATE_BRANCH"
|
||||
git add -- relays/online_relays_gps.csv
|
||||
git diff --cached --quiet && {
|
||||
echo "::error::Expected a staged GeoRelay data change"
|
||||
exit 1
|
||||
}
|
||||
git commit -m "Update reviewed georelay directory" -m "Upstream-commit: $SOURCE_COMMIT"
|
||||
|
||||
remote_ref="refs/remotes/origin/$UPDATE_BRANCH"
|
||||
if git fetch --no-tags origin "+refs/heads/$UPDATE_BRANCH:$remote_ref" 2>/dev/null; then
|
||||
remote_sha=$(git rev-parse "$remote_ref")
|
||||
git push --force-with-lease="refs/heads/$UPDATE_BRANCH:$remote_sha" origin "HEAD:refs/heads/$UPDATE_BRANCH"
|
||||
else
|
||||
git push origin "HEAD:refs/heads/$UPDATE_BRANCH"
|
||||
fi
|
||||
|
||||
body_file="$RUNNER_TEMP/georelay-pr-body.md"
|
||||
{
|
||||
echo "## Automated GeoRelay data proposal"
|
||||
echo
|
||||
echo "- Source: $SOURCE_URL"
|
||||
echo "- Upstream commit: $SOURCE_COMMIT"
|
||||
echo "- Data rows: $DATA_ROWS"
|
||||
echo "- Unique normalized relays: $UNIQUE_RELAYS"
|
||||
echo "- SHA-256: $DATA_SHA256"
|
||||
echo
|
||||
echo "The candidate passed strict UTF-8, schema, size, row-count, secure-host, coordinate, duplicate-conflict, and baseline-delta validation."
|
||||
echo
|
||||
echo "This PR is intentionally not auto-merged. Review the relay additions/removals before merging."
|
||||
} > "$body_file"
|
||||
|
||||
existing_pr=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --base main --head "$UPDATE_BRANCH" --json number --jq '.[0].number // empty')
|
||||
pr_error="$RUNNER_TEMP/georelay-pr-error.txt"
|
||||
pr_url=""
|
||||
if [[ -n "$existing_pr" ]]; then
|
||||
if gh pr edit "$existing_pr" --repo "$GITHUB_REPOSITORY" --title "Update reviewed GeoRelay directory" --body-file "$body_file" 2> "$pr_error"; then
|
||||
pr_url=$(gh pr view "$existing_pr" --repo "$GITHUB_REPOSITORY" --json url --jq .url)
|
||||
fi
|
||||
else
|
||||
if created_pr_url=$(gh pr create --repo "$GITHUB_REPOSITORY" --base main --head "$UPDATE_BRANCH" --title "Update reviewed GeoRelay directory" --body-file "$body_file" 2> "$pr_error"); then
|
||||
pr_url="$created_pr_url"
|
||||
fi
|
||||
fi
|
||||
|
||||
tracking_issue_numbers=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "\"$TRACKING_ISSUE_TITLE\" in:title" --limit 100 --json number,title --jq ".[] | select(.title == \"$TRACKING_ISSUE_TITLE\") | .number")
|
||||
tracking_issues=()
|
||||
if [[ -n "$tracking_issue_numbers" ]]; then
|
||||
mapfile -t tracking_issues <<< "$tracking_issue_numbers"
|
||||
fi
|
||||
|
||||
if [[ -n "$pr_url" ]]; then
|
||||
for issue_number in "${tracking_issues[@]}"; do
|
||||
gh issue close "$issue_number" --repo "$GITHUB_REPOSITORY" --comment "A pull request is now available at $pr_url; closing this fallback tracking issue."
|
||||
done
|
||||
echo "Published GeoRelay review PR: $pr_url" >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::warning::GITHUB_TOKEN could not create or update the GeoRelay pull request; publishing the issues-write fallback."
|
||||
if [[ -s "$pr_error" ]]; then
|
||||
cat "$pr_error" >&2
|
||||
fi
|
||||
|
||||
compare_url="https://github.com/${GITHUB_REPOSITORY}/compare/main...${UPDATE_BRANCH}?expand=1"
|
||||
issue_body_file="$RUNNER_TEMP/georelay-tracking-issue-body.md"
|
||||
{
|
||||
echo "## Validated GeoRelay update awaiting review"
|
||||
echo
|
||||
echo "The automation branch was updated, but this workflow token could not create or update the pull request. Use the compare link below to create it manually."
|
||||
echo
|
||||
echo "- Compare and create PR: $compare_url"
|
||||
echo "- Automation branch: $UPDATE_BRANCH"
|
||||
echo "- Source: $SOURCE_URL"
|
||||
echo "- Upstream commit: $SOURCE_COMMIT"
|
||||
echo "- Data rows: $DATA_ROWS"
|
||||
echo "- Unique normalized relays: $UNIQUE_RELAYS"
|
||||
echo "- SHA-256: $DATA_SHA256"
|
||||
echo
|
||||
echo "The snapshot passed the repository's strict validator before the branch was pushed."
|
||||
} > "$issue_body_file"
|
||||
|
||||
if (( ${#tracking_issues[@]} > 0 )); then
|
||||
primary_issue="${tracking_issues[0]}"
|
||||
gh issue edit "$primary_issue" --repo "$GITHUB_REPOSITORY" --title "$TRACKING_ISSUE_TITLE" --body-file "$issue_body_file"
|
||||
issue_url=$(gh issue view "$primary_issue" --repo "$GITHUB_REPOSITORY" --json url --jq .url)
|
||||
for duplicate_issue in "${tracking_issues[@]:1}"; do
|
||||
gh issue close "$duplicate_issue" --repo "$GITHUB_REPOSITORY" --comment "Closing duplicate GeoRelay automation tracking issue; #$primary_issue is canonical."
|
||||
done
|
||||
else
|
||||
issue_url=$(gh issue create --repo "$GITHUB_REPOSITORY" --title "$TRACKING_ISSUE_TITLE" --body-file "$issue_body_file")
|
||||
fi
|
||||
|
||||
# Do not claim success until the fallback issue was confirmed.
|
||||
[[ -n "$issue_url" ]]
|
||||
echo "Published GeoRelay tracking issue fallback: $issue_url" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Clean obsolete automation review state
|
||||
if: steps.changes.outputs.changed == 'false'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh auth setup-git
|
||||
|
||||
existing_pr=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --base main --head "$UPDATE_BRANCH" --json number --jq '.[0].number // empty')
|
||||
if [[ -n "$existing_pr" ]]; then
|
||||
gh pr close "$existing_pr" --repo "$GITHUB_REPOSITORY" --comment "Upstream now matches the reviewed file on main; closing this obsolete automation proposal."
|
||||
echo "Closed obsolete PR #$existing_pr." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
tracking_issue_numbers=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "\"$TRACKING_ISSUE_TITLE\" in:title" --limit 100 --json number,title --jq ".[] | select(.title == \"$TRACKING_ISSUE_TITLE\") | .number")
|
||||
if [[ -n "$tracking_issue_numbers" ]]; then
|
||||
while IFS= read -r issue_number; do
|
||||
gh issue close "$issue_number" --repo "$GITHUB_REPOSITORY" --comment "Upstream now matches the reviewed file on main; closing this obsolete automation tracker."
|
||||
echo "Closed obsolete tracking issue #$issue_number." >> "$GITHUB_STEP_SUMMARY"
|
||||
done <<< "$tracking_issue_numbers"
|
||||
fi
|
||||
|
||||
if git ls-remote --exit-code --heads origin "refs/heads/$UPDATE_BRANCH" > /dev/null; then
|
||||
git push origin --delete "$UPDATE_BRANCH"
|
||||
echo "Deleted obsolete automation branch $UPDATE_BRANCH." >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
ls_remote_status=$?
|
||||
if (( ls_remote_status != 2 )); then
|
||||
echo "::error::Could not inspect the obsolete automation branch"
|
||||
exit "$ls_remote_status"
|
||||
fi
|
||||
fi
|
||||
|
||||
112
.github/workflows/source-manifest.yml
vendored
Normal file
112
.github/workflows/source-manifest.yml
vendored
Normal file
@ -0,0 +1,112 @@
|
||||
name: Source manifest
|
||||
|
||||
# Publishes a hash manifest for every tagged release so a copy of the source
|
||||
# obtained from somewhere other than this repository can be checked against it.
|
||||
#
|
||||
# This exists because the repository has been the target of takedown demands.
|
||||
# When that succeeds, mirrors appear, and without a manifest there is no way to
|
||||
# tell a faithful mirror from a modified one. The manifest is attested to this
|
||||
# workflow run, so its own provenance is verifiable with `gh attestation verify`.
|
||||
#
|
||||
# Scope, stated plainly: this verifies SOURCE. It does not verify any compiled
|
||||
# app. See docs/VERIFYING-A-BUILD.md.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Tag or commit to produce a manifest for'
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
manifest:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # attach the manifest to the release
|
||||
id-token: write # provenance attestation
|
||||
attestations: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.ref || github.ref }}
|
||||
# Full history so the commit the tag names can be recorded exactly.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Build manifest
|
||||
id: build
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ref_name="${{ github.event.inputs.ref || github.ref_name }}"
|
||||
commit="$(git rev-parse HEAD)"
|
||||
tree="$(git rev-parse HEAD^{tree})"
|
||||
|
||||
# Hash every tracked file, in a stable order, with NUL separation so
|
||||
# paths containing spaces or newlines cannot shift the columns.
|
||||
git ls-files -z \
|
||||
| sort -z \
|
||||
| xargs -0 sha256sum \
|
||||
> files.sha256
|
||||
|
||||
{
|
||||
echo "# bitchat source manifest"
|
||||
echo "#"
|
||||
echo "# ref: ${ref_name}"
|
||||
echo "# commit: ${commit}"
|
||||
echo "# tree: ${tree}"
|
||||
echo "# files: $(wc -l < files.sha256 | tr -d ' ')"
|
||||
echo "#"
|
||||
echo "# Verify a checkout of this ref with:"
|
||||
echo "# shasum -a 256 -c files.sha256"
|
||||
echo "# Hash checking alone ignores files this manifest does not list, and"
|
||||
echo "# the Xcode project compiles any source file present in the tree. So"
|
||||
echo "# also confirm nothing extra is present:"
|
||||
echo "# git status --porcelain --ignored # git checkout: must print nothing"
|
||||
echo "# or, for a tarball, diff this manifest's path list against find(1)."
|
||||
echo "# Full instructions: docs/VERIFYING-A-BUILD.md"
|
||||
echo "# The git tree hash above is the single value covering all tracked content:"
|
||||
echo "# git rev-parse HEAD^{tree}"
|
||||
echo "#"
|
||||
} > SOURCE-MANIFEST.txt
|
||||
cat files.sha256 >> SOURCE-MANIFEST.txt
|
||||
|
||||
echo "commit=${commit}" >> "$GITHUB_OUTPUT"
|
||||
echo "tree=${tree}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Self-check the manifest
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# A manifest that does not validate against the tree it was made from
|
||||
# is worse than none, so fail loudly rather than publishing it.
|
||||
grep -v '^#' SOURCE-MANIFEST.txt > check.sha256
|
||||
sha256sum -c check.sha256 > /dev/null
|
||||
echo "manifest validates against this checkout"
|
||||
|
||||
- name: Attest the manifest
|
||||
uses: actions/attest-build-provenance@v1
|
||||
with:
|
||||
subject-path: SOURCE-MANIFEST.txt
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: source-manifest
|
||||
path: SOURCE-MANIFEST.txt
|
||||
|
||||
- name: Attach to release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# A tag may be pushed before its release exists; only attach when
|
||||
# there is a release to attach to, and never fail the run over it.
|
||||
if gh release view "${{ github.ref_name }}" >/dev/null 2>&1; then
|
||||
gh release upload "${{ github.ref_name }}" SOURCE-MANIFEST.txt --clobber
|
||||
else
|
||||
echo "no release for ${{ github.ref_name }} yet; manifest is available as a workflow artifact"
|
||||
fi
|
||||
127
.github/workflows/swift-tests.yml
vendored
127
.github/workflows/swift-tests.yml
vendored
@ -94,6 +94,24 @@ jobs:
|
||||
kill "$watchdog_pid" 2>/dev/null || true
|
||||
exit "$status"
|
||||
|
||||
# Read coverage before the serial benchmark command below rebuilds the
|
||||
# test binary without instrumentation. Reporting against that newer
|
||||
# binary makes llvm-cov reject the profile as out of date.
|
||||
# Informational only: there is deliberately no percentage threshold, but
|
||||
# a broken/missing report is a CI configuration error and must be visible.
|
||||
- name: Coverage summary
|
||||
run: |
|
||||
BIN_PATH=$(swift build --show-bin-path --package-path ${{ matrix.path }})
|
||||
PROF="$BIN_PATH/codecov/default.profdata"
|
||||
XCTEST=$(find "$BIN_PATH" -maxdepth 1 -name '*.xctest' | head -1)
|
||||
BINARY="$XCTEST/Contents/MacOS/$(basename "$XCTEST" .xctest)"
|
||||
if [ ! -f "$PROF" ] || [ ! -f "$BINARY" ]; then
|
||||
echo "::error::Coverage profile or test binary is missing"
|
||||
exit 1
|
||||
fi
|
||||
xcrun llvm-cov report "$BINARY" -instr-profile "$PROF" \
|
||||
-ignore-filename-regex='(Tests|\.build|checkouts|Mocks|_PreviewHelpers)'
|
||||
|
||||
# Benchmarks run serially on an otherwise idle runner for stable
|
||||
# numbers; BITCHAT_PERF_LOG captures the PERF[...] lines for the gate.
|
||||
- name: Run performance benchmarks (serial)
|
||||
@ -115,22 +133,6 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
run: ./scripts/check-perf-floors.sh perf-output.log
|
||||
|
||||
# Informational only: surfaces per-file and total line coverage in the
|
||||
# job log so coverage trends are visible on every PR. No thresholds —
|
||||
# this must never be the reason a build goes red.
|
||||
- name: Coverage summary
|
||||
run: |
|
||||
BIN_PATH=$(swift build --show-bin-path --package-path ${{ matrix.path }})
|
||||
PROF="$BIN_PATH/codecov/default.profdata"
|
||||
XCTEST=$(find "$BIN_PATH" -maxdepth 1 -name '*.xctest' | head -1)
|
||||
BINARY="$XCTEST/Contents/MacOS/$(basename "$XCTEST" .xctest)"
|
||||
if [ -f "$PROF" ] && [ -f "$BINARY" ]; then
|
||||
xcrun llvm-cov report "$BINARY" -instr-profile "$PROF" \
|
||||
-ignore-filename-regex='(Tests|\.build|checkouts|Mocks|_PreviewHelpers)' || true
|
||||
else
|
||||
echo "No coverage data found; skipping summary."
|
||||
fi
|
||||
|
||||
# SPM tests do not link the shipping app targets. This job covers the
|
||||
# iOS-conditional paths and both universal Release link configurations.
|
||||
ios-build:
|
||||
@ -142,6 +144,9 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Check clean recipe safety
|
||||
run: bash scripts/check-just-clean-safety.sh
|
||||
|
||||
- name: Build iOS (simulator, no signing)
|
||||
# Build both simulator architectures so CI validates every vendored
|
||||
# Arti simulator slice and the configuration that ships.
|
||||
@ -169,6 +174,96 @@ jobs:
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
build
|
||||
|
||||
# The SwiftPM matrix runs on macOS and cannot execute UIKit/CoreBluetooth
|
||||
# conditional tests. Build the shared iOS test target and run it on the first
|
||||
# available iPhone simulator from the runner image instead of hard-coding a
|
||||
# model that changes when GitHub updates Xcode. The suite intentionally runs
|
||||
# in one test runner: a number of integration tests exercise process-global
|
||||
# stores and notification centers, so overlapping workers can corrupt each
|
||||
# other's fixtures and turn sub-second tests into multi-minute timeouts.
|
||||
ios-tests:
|
||||
name: Run iOS simulator tests
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
# Some runner images list only placeholder destinations (rows carrying
|
||||
# "error:" or "unavailable") or ship the selected Xcode without a
|
||||
# matching iOS simulator runtime, so this walks three paths in order:
|
||||
# a usable xcodebuild destination, an existing simctl device, and
|
||||
# finally creating a device from the newest installed iOS runtime.
|
||||
- name: Select available iPhone simulator
|
||||
id: destination
|
||||
run: |
|
||||
set -uo pipefail
|
||||
destinations=$(xcodebuild -project bitchat.xcodeproj -scheme "bitchat (iOS)" -showdestinations 2>/dev/null || true)
|
||||
destination_id=$(awk -F'id:' '
|
||||
/platform:iOS Simulator/ && /name:iPhone/ \
|
||||
&& !/error/ && !/unavailable/ && !found {
|
||||
value=$2
|
||||
sub(/,.*/, "", value)
|
||||
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
|
||||
print value
|
||||
found=1
|
||||
}
|
||||
' <<< "$destinations")
|
||||
|
||||
if [ -n "$destination_id" ]; then
|
||||
echo "Selected destination via xcodebuild -showdestinations: $destination_id"
|
||||
else
|
||||
echo "No usable iPhone destination in -showdestinations output; falling back to simctl"
|
||||
destination_id=$(xcrun simctl list devices available --json | jq -r '
|
||||
[.devices | to_entries[]
|
||||
| select(.key | contains("iOS"))
|
||||
| .value[]
|
||||
| select(.isAvailable and (.name | startswith("iPhone")))]
|
||||
| first.udid // empty')
|
||||
if [ -n "$destination_id" ]; then
|
||||
echo "Selected existing simctl device: $destination_id"
|
||||
else
|
||||
echo "No available iPhone simulator device; creating one"
|
||||
# Newest installed iOS runtime plus an iPhone device type that
|
||||
# runtime itself reports as supported, so the pair always match.
|
||||
create_spec=$(xcrun simctl list runtimes --json | jq -r '
|
||||
[.runtimes[] | select(.platform == "iOS" and .isAvailable)]
|
||||
| sort_by(.version | split(".") | map(tonumber))
|
||||
| last // empty
|
||||
| .identifier as $runtime
|
||||
| ([(.supportedDeviceTypes // [])[]
|
||||
| select(.productFamily == "iPhone"
|
||||
or (.name // "" | startswith("iPhone")))]
|
||||
| first.identifier // empty) as $devicetype
|
||||
| "\($devicetype) \($runtime)"')
|
||||
read -r devicetype runtime <<< "$create_spec" || true
|
||||
if [ -z "${devicetype:-}" ] || [ -z "${runtime:-}" ]; then
|
||||
echo "::error::No iPhone simulator destination found and none creatable (no installed iOS runtime with an iPhone device type)"
|
||||
exit 1
|
||||
fi
|
||||
destination_id=$(xcrun simctl create ci-iphone "$devicetype" "$runtime") || {
|
||||
echo "::error::simctl create failed for $devicetype on $runtime"
|
||||
exit 1
|
||||
}
|
||||
echo "Created simulator $destination_id ($devicetype, $runtime)"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Using iPhone simulator destination id: $destination_id"
|
||||
echo "id=$destination_id" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Run iOS tests
|
||||
run: |
|
||||
set -o pipefail
|
||||
xcodebuild -project bitchat.xcodeproj \
|
||||
-scheme "bitchat (iOS)" \
|
||||
-sdk iphonesimulator \
|
||||
-destination "platform=iOS Simulator,id=${{ steps.destination.outputs.id }}" \
|
||||
-parallel-testing-enabled NO \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
test
|
||||
|
||||
# Advisory only: SwiftLint reports style violations without ever failing the
|
||||
# build. Runs in a pinned container (no Xcode plugin, no pbxproj changes) so
|
||||
# it can never break the documented xcodebuild path or block a merge.
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -80,3 +80,4 @@ build.log
|
||||
|
||||
# Local configs
|
||||
Local.xcconfig
|
||||
*.profraw
|
||||
|
||||
@ -1 +1 @@
|
||||
{"v1":{"usrs":["param-buf-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-dataDir-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","param-len-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-socksPort-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","s:13BitFoundation16PeerCapabilitiesV8wifiBulkACvpZ","s:13BitFoundation18KeychainReadResultO18isRecoverableErrorSbvp","s:13BitFoundation23KeychainManagerProtocolP11secureClearyySSzF","s:18bitchatTests_macOS12MockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC11resetCountsyyF","s:18bitchatTests_macOS20TrackingMockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC25totalSecureClearCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC26secureClearStringCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC27_secureClearStringCallCount06_AB6D1M24FD239F2969C82F4108818260LLSivp","s:18bitchatTests_macOS24FailingCacheSaveKeychain33_22380C7A11A569A0B83FA83F34C498A7LLC11secureClearyySSzF","s:18bitchatTests_macOS24MockGeohashPresenceTimer33_483587EFB96650EE130EFB09BBA2A1AALLC7handleryycvp","s:3Tor0A7ManagerC21goDormantOnBackgroundyyF","s:7bitchat10AppRuntimeC24handleScreenshotCaptured33_C8B369AD8BC1D9963A50CEDA77A4332ALLyyF","s:7bitchat10AppRuntimeC33handleDidBecomeActiveNotificationyyF","s:7bitchat10BLEServiceC18logBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LLyySSF","s:7bitchat10BLEServiceC20centralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC22captureBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LL7contextySS_tF","s:7bitchat10BLEServiceC23peripheralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC29scheduleBluetoothStatusSample33_69191C53E68500C17D98DBCF2BDA7100LL5after7contextySd_SStF","s:7bitchat10QRScanViewV8isActiveSbvp","s:7bitchat15BLEPeerRegistryV5countSivp","s:7bitchat15KeychainManagerC11secureClearyySSzF","s:7bitchat15PaymentChipViewV7openURL33_10AC50641B1EBCD52E5092A2E521D236LL7SwiftUI13OpenURLActionVvp","s:7bitchat15TransportConfigO29uiBatchDispatchStaggerSecondsSdvpZ","s:7bitchat15TransportConfigO35uiShareExtensionDismissDelaySecondsSdvpZ","s:7bitchat15TransportConfigO38bleBackgroundPendingConnectSlotReserveSivpZ","s:7bitchat17GossipSyncManagerC10persistNowyyF","s:7bitchat17NostrRelayManagerC15InboundEventKey33_E4160FE8A9A2C9D6308EAAD5A8B5CB07LLV7eventIDSSvp","s:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}}
|
||||
{"v1":{"usrs":["param-buf-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-dataDir-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","param-len-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-socksPort-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","s:13BitFoundation16PeerCapabilitiesV8wifiBulkACvpZ","s:13BitFoundation18KeychainReadResultO18isRecoverableErrorSbvp","s:13BitFoundation23KeychainManagerProtocolP11secureClearyySSzF","s:18bitchatTests_macOS12MockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC11resetCountsyyF","s:18bitchatTests_macOS20TrackingMockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC25totalSecureClearCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC26secureClearStringCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC27_secureClearStringCallCount06_AB6D1M24FD239F2969C82F4108818260LLSivp","s:18bitchatTests_macOS24FailingCacheSaveKeychain33_22380C7A11A569A0B83FA83F34C498A7LLC11secureClearyySSzF","s:18bitchatTests_macOS24MockGeohashPresenceTimer33_483587EFB96650EE130EFB09BBA2A1AALLC7handleryycvp","s:3Tor0A7ManagerC21goDormantOnBackgroundyyF","s:7bitchat10AppRuntimeC24handleScreenshotCaptured33_C8B369AD8BC1D9963A50CEDA77A4332ALLyyF","s:7bitchat10AppRuntimeC33handleDidBecomeActiveNotificationyyF","s:7bitchat10BLEServiceC18logBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LLyySSF","s:7bitchat10BLEServiceC18logBluetoothStatusyySSF","s:7bitchat10BLEServiceC20centralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC22captureBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LL7contextySS_tF","s:7bitchat10BLEServiceC23peripheralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC29scheduleBluetoothStatusSample33_69191C53E68500C17D98DBCF2BDA7100LL5after7contextySd_SStF","s:7bitchat10QRScanViewV8isActiveSbvp","s:7bitchat15BLEPeerRegistryV5countSivp","s:7bitchat15KeychainManagerC11secureClearyySSzF","s:7bitchat15PaymentChipViewV7openURL33_10AC50641B1EBCD52E5092A2E521D236LL7SwiftUI13OpenURLActionVvp","s:7bitchat15TransportConfigO29uiBatchDispatchStaggerSecondsSdvpZ","s:7bitchat15TransportConfigO35uiShareExtensionDismissDelaySecondsSdvpZ","s:7bitchat15TransportConfigO38bleBackgroundPendingConnectSlotReserveSivpZ","s:7bitchat17GossipSyncManagerC10persistNowyyF","s:7bitchat17NostrRelayManagerC15InboundEventKey33_E4160FE8A9A2C9D6308EAAD5A8B5CB07LLV7eventIDSSvp","s:7bitchat18BLERadioControllerC14candidateCountSivp","s:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}}
|
||||
@ -3,3 +3,6 @@ DEVELOPMENT_TEAM = ABC123
|
||||
|
||||
// Unique bundle id to be able to register and run locally
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.$(DEVELOPMENT_TEAM)
|
||||
|
||||
// App and share extension must use an App Group registered to your team.
|
||||
APP_GROUP_ID = group.chat.bitchat.$(DEVELOPMENT_TEAM)
|
||||
|
||||
149
Justfile
149
Justfile
@ -1,107 +1,66 @@
|
||||
# BitChat macOS Build Justfile
|
||||
# Handles temporary modifications needed to build and run on macOS
|
||||
# BitChat developer commands
|
||||
#
|
||||
# Builds use a repository-local, ignored DerivedData directory. No recipe
|
||||
# patches, restores, or removes tracked project/configuration files.
|
||||
|
||||
project := "bitchat.xcodeproj"
|
||||
macos_scheme := "bitchat (macOS)"
|
||||
ios_scheme := "bitchat (iOS)"
|
||||
derived_data := ".DerivedData"
|
||||
|
||||
# Default recipe - shows available commands
|
||||
default:
|
||||
@echo "BitChat macOS Build Commands:"
|
||||
@echo " just run - Build and run the macOS app"
|
||||
@echo " just build - Build the macOS app only"
|
||||
@echo " just clean - Clean build artifacts and restore original files"
|
||||
@echo " just check - Check prerequisites"
|
||||
@echo ""
|
||||
@echo "Original files are preserved - modifications are temporary for builds only"
|
||||
@echo "BitChat developer commands:"
|
||||
@echo " just run Build and run the macOS app"
|
||||
@echo " just build Build the macOS app without signing"
|
||||
@echo " just test Run the SwiftPM test suite"
|
||||
@echo " just test-ios Run tests on the iPhone 17 simulator"
|
||||
@echo " just clean Remove repo-local build artifacts only"
|
||||
@echo " just nuke Also remove nested package build caches"
|
||||
@echo " just check Validate the development environment"
|
||||
|
||||
# Check prerequisites
|
||||
check:
|
||||
# Static guard against reintroducing source-restoring or source-deleting clean
|
||||
# behavior. CI runs the same script directly.
|
||||
check-clean-safety:
|
||||
@bash scripts/check-just-clean-safety.sh
|
||||
|
||||
check: check-clean-safety
|
||||
@echo "Checking prerequisites..."
|
||||
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install Xcode from App Store" && exit 1)
|
||||
@xcode-select -p | grep -q "Xcode.app" || (echo "❌ Full Xcode required, not just command line tools. Install from App Store and run:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
|
||||
@test -d "/Applications/Xcode.app" || (echo "❌ Xcode.app not found in Applications folder. Install from App Store" && exit 1)
|
||||
@xcodebuild -version >/dev/null 2>&1 || (echo "❌ Xcode not properly configured. Try:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
|
||||
@security find-identity -v -p codesigning | grep -q "Apple Development\|Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
|
||||
@echo "✅ All prerequisites met"
|
||||
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install full Xcode." && exit 1)
|
||||
@developer_dir="$(xcode-select -p 2>/dev/null)"; case "$developer_dir" in *.app/Contents/Developer) ;; *) echo "❌ Full Xcode is not selected. Run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer"; exit 1;; esac
|
||||
@xcodebuild -version
|
||||
@echo "✅ Development environment ready (a signing identity is not required for just build)"
|
||||
|
||||
# Backup original files
|
||||
backup:
|
||||
@echo "Backing up original project configuration..."
|
||||
@if [ -f bitchat.xcodeproj/project.pbxproj ]; then cp bitchat.xcodeproj/project.pbxproj bitchat.xcodeproj/project.pbxproj.backup; fi
|
||||
@if [ -f bitchat/Info.plist ]; then cp bitchat/Info.plist bitchat/Info.plist.backup; fi
|
||||
|
||||
# Restore original files
|
||||
restore:
|
||||
@echo "Restoring original project configuration..."
|
||||
@if [ -f project.yml.backup ]; then mv project.yml.backup project.yml; fi
|
||||
@# Restore iOS-specific files
|
||||
@if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi
|
||||
@# Use git to restore all modified files except Justfile
|
||||
@git checkout -- project.yml bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Could not restore some files with git"
|
||||
@# Remove any backup files
|
||||
@rm -f bitchat.xcodeproj/project.pbxproj.backup bitchat/Info.plist.backup 2>/dev/null || true
|
||||
|
||||
# Apply macOS-specific modifications
|
||||
patch-for-macos: backup
|
||||
@echo "Temporarily hiding iOS-specific files for macOS build..."
|
||||
@# Move iOS-specific files out of the way temporarily
|
||||
@if [ -f bitchat/LaunchScreen.storyboard ]; then mv bitchat/LaunchScreen.storyboard bitchat/LaunchScreen.storyboard.ios; fi
|
||||
|
||||
# Build the macOS app
|
||||
build: #check generate
|
||||
build: check
|
||||
@echo "Building BitChat for macOS..."
|
||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
||||
@xcodebuild -project "{{project}}" -scheme "{{macos_scheme}}" -configuration Debug -derivedDataPath "{{derived_data}}" CODE_SIGNING_ALLOWED=NO build
|
||||
|
||||
# Run the macOS app
|
||||
run: build
|
||||
@echo "Launching BitChat..."
|
||||
@find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}"
|
||||
@app="{{derived_data}}/Build/Products/Debug/bitchat.app"; test -d "$app" || (echo "❌ Built app not found at $app" && exit 1); open "$app"
|
||||
|
||||
# Clean build artifacts and restore original files
|
||||
clean: restore
|
||||
@echo "Cleaning build artifacts..."
|
||||
@rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true
|
||||
@# Only remove the generated project if we have a backup, otherwise use git
|
||||
@if [ -f bitchat.xcodeproj/project.pbxproj.backup ]; then \
|
||||
rm -rf bitchat.xcodeproj; \
|
||||
else \
|
||||
git checkout -- bitchat.xcodeproj/project.pbxproj 2>/dev/null || echo "⚠️ Could not restore project.pbxproj"; \
|
||||
fi
|
||||
@rm -f project-macos.yml 2>/dev/null || true
|
||||
@echo "✅ Cleaned and restored original files"
|
||||
# Backward-compatible alias for the old quick-run recipe.
|
||||
dev-run: run
|
||||
|
||||
# Quick run without cleaning (for development)
|
||||
dev-run: check
|
||||
@echo "Quick development build..."
|
||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
||||
@find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}"
|
||||
test:
|
||||
@swift test
|
||||
|
||||
test-ios: check
|
||||
@xcodebuild -project "{{project}}" -scheme "{{ios_scheme}}" -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' -derivedDataPath "{{derived_data}}" test
|
||||
|
||||
# Artifact-only cleanup. In particular, this recipe never invokes Git and
|
||||
# never writes, moves, restores, or removes source/configuration files.
|
||||
clean:
|
||||
@echo "Cleaning repo-local build artifacts..."
|
||||
@rm -rf -- "{{derived_data}}" ".build"
|
||||
@echo "✅ Cleaned {{derived_data}} and .build; tracked files were untouched"
|
||||
|
||||
# Retain the familiar command, but keep it artifact-only as well.
|
||||
nuke: clean
|
||||
@echo "Cleaning nested package build caches..."
|
||||
@find localPackages -type d -name .build -prune -exec rm -rf -- {} +
|
||||
@rm -rf -- ".cache"
|
||||
@echo "✅ Removed repository build caches; tracked files were untouched"
|
||||
|
||||
# Show app info
|
||||
info:
|
||||
@echo "BitChat - Decentralized Mesh Messaging"
|
||||
@echo "======================================"
|
||||
@echo "• Native macOS SwiftUI app"
|
||||
@echo "• Bluetooth LE mesh networking"
|
||||
@echo "• End-to-end encryption"
|
||||
@echo "• No internet required"
|
||||
@echo "• Works offline with nearby devices"
|
||||
@echo ""
|
||||
@echo "Requirements:"
|
||||
@echo "• macOS 13.0+ (Ventura)"
|
||||
@echo "• Bluetooth LE capable Mac"
|
||||
@echo "• Physical device (no simulator support)"
|
||||
@echo ""
|
||||
@echo "Usage:"
|
||||
@echo "• Set nickname and start chatting"
|
||||
@echo "• Use /join #channel for group chats"
|
||||
@echo "• Use /msg @user for private messages"
|
||||
@echo "• Triple-tap logo for emergency wipe"
|
||||
|
||||
# Force clean everything (nuclear option)
|
||||
nuke:
|
||||
@echo "🧨 Nuclear clean - removing all build artifacts and backups..."
|
||||
@rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true
|
||||
@rm -rf bitchat.xcodeproj 2>/dev/null || true
|
||||
@rm -f bitchat.xcodeproj/project.pbxproj.backup 2>/dev/null || true
|
||||
@rm -f bitchat/Info.plist.backup 2>/dev/null || true
|
||||
@# Restore iOS-specific files if they were moved
|
||||
@if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi
|
||||
@git checkout bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Not a git repo or no changes to restore"
|
||||
@echo "✅ Nuclear clean complete"
|
||||
@echo "BitChat - decentralized mesh messaging"
|
||||
@echo "macOS 13+ and iOS 16+"
|
||||
@echo "Bluetooth mesh behavior requires physical Bluetooth-capable devices"
|
||||
|
||||
@ -17,12 +17,12 @@ bitchat is designed for private, account-free communication. This policy describ
|
||||
|
||||
1. **Identity and cryptographic keys**
|
||||
- Noise, signing, group, prekey, and optional Nostr identity material is generated locally.
|
||||
- Secret keys are stored in the system keychain. Public keys are shared when required for messaging, verification, groups, or Nostr events.
|
||||
- Keys remain until they are rotated, removed by the relevant feature, erased with panic wipe, or removed with the app.
|
||||
- Secret keys are stored in the system keychain as device-only items. Public keys are shared when required for messaging, verification, groups, or Nostr events.
|
||||
- Keys remain until they are rotated, removed by the relevant feature, or erased with panic wipe. Because operating-system keychains can outlive an uninstall, bitchat records a non-secret install marker and deletes surviving app keys before use after a later reinstall.
|
||||
|
||||
2. **Nickname, preferences, and relationships**
|
||||
- Your nickname, settings, favorites, petnames, read-receipt identifiers, and bounded operational metadata are stored locally.
|
||||
- The share extension briefly places content you choose to share in the app-group preferences so the main app can import it.
|
||||
- The share extension can retain one item you choose to share in the app-group preferences for up to 24 hours. The app shows the destination and a preview for review; it does not send the item automatically. The item is cleared when you add it to the composer, cancel, panic-wipe, or it expires.
|
||||
|
||||
3. **Private group state**
|
||||
- Group names, rosters, creator identity, and key epoch are stored as protected files in Application Support.
|
||||
@ -34,13 +34,13 @@ bitchat is designed for private, account-free communication. This policy describ
|
||||
- A panic wipe deletes both stores.
|
||||
|
||||
5. **Recent public mesh messages and notices**
|
||||
- Signed public mesh messages may be kept in a protected local gossip archive for up to 15 minutes so they can cross mesh partitions and survive a short relaunch.
|
||||
- Signed public mesh messages may be kept in a protected local gossip archive for up to 6 hours so they can cross mesh partitions and survive a relaunch.
|
||||
- Public bulletin-board posts and deletion tombstones persist until the post's author-selected expiry, at most seven days. Both stores are bounded and panic-wipeable.
|
||||
- These items are public to the mesh or board where they are posted; they are not confidential messages.
|
||||
|
||||
6. **Media attachments**
|
||||
- Voice notes and images you send or receive can be stored under Application Support so they remain playable while referenced by the app.
|
||||
- Incoming media is subject to a 100 MB quota with oldest-file eviction. Media is deleted by panic wipe or app removal; some outgoing media can otherwise remain on disk.
|
||||
- Incoming media is subject to a 100 MB quota with oldest-file eviction. All stored media, sent and received, is also deleted once it is more than seven days old, and immediately by panic wipe or app removal.
|
||||
|
||||
7. **Optional location-channel state**
|
||||
- Your selected geohash channel, bookmarks, teleport flags, and bookmark display names are stored locally so the UI can restore them.
|
||||
@ -73,7 +73,7 @@ Private group members receive the group's name, roster, key epoch, and encrypted
|
||||
|
||||
Internet-backed features are optional. When enabled or used:
|
||||
|
||||
- Private fallback messages use encrypted NIP-17 gift wraps. Relays can observe event and network metadata but not the message plaintext.
|
||||
- Private fallback messages use BitChat's app-specific encrypted envelopes. This format is not NIP-17, NIP-44, or NIP-59 compatible. Relays can observe the recipient public-key tag, event timing and size, and network metadata, but not the message plaintext or stable sender identity.
|
||||
- Public location-channel messages, notes, notices, and presence include a geohash tag, event kind, timestamp, and a public key. A geohash reveals an approximate area; finer precision reveals a smaller area.
|
||||
- The optional mesh bridge publishes bridge-enabled public mesh messages and presence to a neighborhood rendezvous cell. Those messages are public to participants and relays for that cell. A per-message “nearby only” choice prevents that message from crossing the bridge.
|
||||
- Bridge courier drops contain opaque end-to-end encrypted envelopes and a rotating recipient tag. Relays still observe timing and network metadata.
|
||||
@ -81,6 +81,8 @@ Internet-backed features are optional. When enabled or used:
|
||||
|
||||
Nostr relays are operated by third parties. Their retention, logging, availability, and privacy practices are outside the project's control. Public events and encrypted events may remain on relays according to each relay's policy.
|
||||
|
||||
You can add relays yourself in settings, including `.onion` addresses. Added relays are stored locally, are limited in number, and are erased by panic wipe. Tor routing is on by default; while it is off, every relay you connect to can see your IP address, including relays carrying your private messages.
|
||||
|
||||
## Location and Apple Services
|
||||
|
||||
Location permission is optional and requested as when-in-use access. It is used to compute geohash channels, bridge rendezvous cells, and nearby place labels.
|
||||
@ -103,7 +105,7 @@ Private and public features use different protections:
|
||||
|
||||
- Mesh private sessions use Noise XX with X25519, ChaCha20-Poly1305, and SHA-256.
|
||||
- Private group messages use ChaCha20-Poly1305; group state and relevant mesh packets use Ed25519 signatures.
|
||||
- Nostr events use secp256k1 Schnorr signatures. NIP-44 v2 private payloads use secp256k1 key agreement, HKDF-SHA256, and XChaCha20-Poly1305.
|
||||
- Nostr events use secp256k1 Schnorr signatures. BitChat private envelopes use secp256k1 key agreement, HKDF-SHA256, and XChaCha20-Poly1305. The envelope format is proprietary, only interoperates with BitChat clients, and does not provide forward secrecy against later compromise of the recipient's static Nostr private key.
|
||||
- The persistent private-message outbox uses ChaCha20-Poly1305 with a key held in the keychain. Some other protected local identity state uses AES-GCM.
|
||||
- Public mesh, bridge, geohash, and board content is signed or authenticated as appropriate but is intentionally not confidential.
|
||||
|
||||
@ -114,14 +116,17 @@ No cryptographic system can protect content after a recipient reads, copies, scr
|
||||
- **In-memory chat timelines and active connections:** until the app closes or state is cleared.
|
||||
- **Queued outgoing private messages:** until acknowledged, dropped by bounded policy, or 24 hours, whichever comes first.
|
||||
- **Opaque courier envelopes:** until handed off, evicted by bounded policy, or 24 hours, whichever comes first.
|
||||
- **Recent public mesh gossip:** up to 15 minutes.
|
||||
- **Recent public mesh gossip:** up to 6 hours.
|
||||
- **Public board posts and tombstones:** until expiry, at most seven days.
|
||||
- **Groups, favorites, preferences, identity keys, bookmarks, and media:** until removed by the feature, panic wipe, quota eviction where applicable, or app removal.
|
||||
- **Media:** seven days, or sooner by quota eviction, panic wipe, or app removal.
|
||||
- **Groups, favorites, preferences, identity keys, and bookmarks:** until removed by the feature, panic wipe, or app removal.
|
||||
- **Nostr data:** according to the policies of the relays that receive it.
|
||||
|
||||
## Your Controls
|
||||
|
||||
- **Panic wipe:** Triple-tap the logo to clear local keys, sessions, preferences, groups, queues, carried mail, public archives, board data, and media managed by the app.
|
||||
- **Panic wipe:** Triple-tap the logo to synchronously cancel in-flight media work and clear local keys, sessions, preferences, groups, queues, carried mail, public archives, board data, and media managed by the app.
|
||||
- **Notification previews:** Hidden by default, so lock-screen alerts do not show message text, sender names, or geohashes. Full previews can be turned on in settings.
|
||||
- **Clearing a conversation:** Clearing the mesh timeline also deletes the recent public gossip this device had stored on disk.
|
||||
- **Feature controls:** Location channels, mesh bridge, internet gateway, and related internet behaviors can be disabled in the app. Some already-published relay data cannot be recalled.
|
||||
- **System permissions:** Bluetooth, location, microphone, camera, and photo-library access can be revoked in system settings.
|
||||
- **No account:** The project operates no account record for you to request or export.
|
||||
|
||||
@ -63,7 +63,11 @@ let package = Package(
|
||||
// Only the vector fixture: declaring the whole "Noise"
|
||||
// directory would claim its .swift test files as resources
|
||||
// and silently drop them from compilation.
|
||||
.process("Noise/NoiseTestVectors.json")
|
||||
.process("Noise/NoiseTestVectors.json"),
|
||||
// Frozen envelopes produced by the released iOS (733098bb)
|
||||
// and Android (b7f0b33d) private-DM implementations; prove
|
||||
// receive compatibility independently of the local generator.
|
||||
.process("Nostr/Fixtures")
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
92
README.md
92
README.md
@ -8,6 +8,14 @@ A decentralized peer-to-peer messaging app with dual transport architecture: loc
|
||||
|
||||
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
|
||||
|
||||
📲 [Play Store](https://play.google.com/store/apps/details?id=com.bitchat.droid)
|
||||
|
||||
### Getting a copy you can trust
|
||||
|
||||
Install from the App Store, or build from source you have verified. A compiled build from anywhere else cannot be verified — see [Verifying bitchat](docs/VERIFYING-A-BUILD.md) for how to check source against the per-release hash manifest, and for what to do if that is the only build you can get.
|
||||
|
||||
This matters more than it usually would: this repository has been the target of takedown demands, and when a repository or releases page disappears, mirrors appear that nobody can check.
|
||||
|
||||
## License
|
||||
|
||||
This project is released into the public domain. See the [LICENSE](LICENSE) file for details.
|
||||
@ -18,8 +26,8 @@ This project is released into the public domain. See the [LICENSE](LICENSE) file
|
||||
- **Location-Based Channels**: Geographic chat rooms using geohash coordinates over global Nostr relays
|
||||
- **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)
|
||||
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
|
||||
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers
|
||||
- **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, NIP-17 for Nostr
|
||||
- **Privacy First**: No accounts, no phone numbers, no servers. Note that the mesh does use a persistent per-device identifier derived from your identity key — see [the whitepaper](WHITEPAPER.md) on identity and metadata for what a nearby radio can observe
|
||||
- **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, BitChat private envelopes for Nostr fallback
|
||||
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
|
||||
- **Universal App**: Native support for iOS and macOS
|
||||
- **Emergency Wipe**: Triple-tap to instantly clear all data
|
||||
@ -34,7 +42,7 @@ BitChat uses a **hybrid messaging architecture** with two complementary transpor
|
||||
- **Local Communication**: Direct peer-to-peer within Bluetooth range
|
||||
- **Multi-hop Relay**: Messages route through nearby devices (max 7 hops)
|
||||
- **No Internet Required**: Works completely offline in disaster scenarios
|
||||
- **Noise Protocol Encryption**: End-to-end encryption with forward secrecy
|
||||
- **Noise Protocol Encryption**: End-to-end encryption, with forward secrecy for live sessions (store-and-forward mail is sealed without it — see the whitepaper)
|
||||
- **Binary Protocol**: Compact packet format optimized for Bluetooth LE constraints
|
||||
- **Automatic Discovery**: Peer discovery and connection management
|
||||
- **Adaptive Power**: Battery-optimized duty cycling
|
||||
@ -43,10 +51,16 @@ BitChat uses a **hybrid messaging architecture** with two complementary transpor
|
||||
|
||||
- **Global Reach**: Connect with users worldwide via internet relays
|
||||
- **Location Channels**: Geographic chat rooms using geohash coordinates
|
||||
- **290+ Relay Network**: Distributed across the globe for reliability
|
||||
- **NIP-17 Encryption**: Gift-wrapped private messages for internet privacy
|
||||
- **440+ Relay Network**: Distributed across the globe for reliability
|
||||
- **BitChat Private Envelopes**: App-specific encrypted private messages over Nostr relays
|
||||
- **Ephemeral Keys**: Fresh cryptographic identity per geohash area
|
||||
|
||||
BitChat's private-envelope format is proprietary and is **not** NIP-17,
|
||||
NIP-44, or NIP-59 compatible. It uses Nostr as a relay transport but only
|
||||
interoperates with BitChat clients: private payloads travel inside kind-1059
|
||||
events whose `v2:`-prefixed content is a BitChat-specific XChaCha20-Poly1305
|
||||
construction, not NIP-44 encryption.
|
||||
|
||||
### Channel Types
|
||||
|
||||
#### `mesh #bluetooth`
|
||||
@ -80,7 +94,7 @@ Private messages use **intelligent transport selection**:
|
||||
2. **Nostr Fallback** (when Bluetooth unavailable)
|
||||
|
||||
- Uses recipient's Nostr public key
|
||||
- NIP-17 gift-wrapping for privacy
|
||||
- BitChat's app-specific private-envelope encryption
|
||||
- Routes through global relay network
|
||||
|
||||
3. **Smart Queuing** (when neither available)
|
||||
@ -93,30 +107,62 @@ For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.m
|
||||
|
||||
### Option 1: Using Xcode
|
||||
|
||||
```bash
|
||||
cd bitchat
|
||||
open bitchat.xcodeproj
|
||||
```
|
||||
```bash
|
||||
open bitchat.xcodeproj
|
||||
```
|
||||
|
||||
To run on a device there're a few steps to prepare the code:
|
||||
- Clone the local configs: `cp Configs/Local.xcconfig.example Configs/Local.xcconfig`
|
||||
- Add your Developer Team ID into the newly created `Configs/Local.xcconfig`
|
||||
- Bundle ID would be set to `chat.bitchat.<team_id>` (unless you set to something else)
|
||||
- Entitlements need to be updated manually (TODO: Automate):
|
||||
- Search and replace `group.chat.bitchat` with `group.<your_bundle_id>` (e.g. `group.chat.bitchat.ABC123`)
|
||||
For a signed device build, create your ignored local configuration and replace
|
||||
the example team ID with your Apple Developer Team ID:
|
||||
|
||||
```bash
|
||||
cp Configs/Local.xcconfig.example Configs/Local.xcconfig
|
||||
```
|
||||
|
||||
`Local.xcconfig.example` derives unique app and App Group identifiers from that
|
||||
team ID. The entitlement files already reference `$(APP_GROUP_ID)`, so tracked
|
||||
project or entitlement files do not need to be edited.
|
||||
|
||||
Useful command-line checks from the repository root:
|
||||
|
||||
```bash
|
||||
# macOS Debug build without signing
|
||||
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" \
|
||||
-configuration Debug CODE_SIGNING_ALLOWED=NO build
|
||||
|
||||
# Full SwiftPM test suite
|
||||
swift test
|
||||
|
||||
# iOS simulator tests
|
||||
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (iOS)" \
|
||||
-sdk iphonesimulator \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 17' test
|
||||
```
|
||||
|
||||
If `iPhone 17` is unavailable, choose an installed simulator from:
|
||||
|
||||
```bash
|
||||
xcodebuild -showdestinations -project bitchat.xcodeproj -scheme "bitchat (iOS)"
|
||||
```
|
||||
|
||||
### Option 2: Using `just`
|
||||
|
||||
```bash
|
||||
brew install just
|
||||
```
|
||||
```bash
|
||||
brew install just
|
||||
just check
|
||||
just run
|
||||
```
|
||||
|
||||
Want to try this on macos: `just run` will set it up and run from source.
|
||||
Run `just clean` afterwards to restore things to original state for mobile app building and development.
|
||||
`just build` and `just run` use the current `bitchat (macOS)` scheme and keep
|
||||
Xcode output in the ignored `.DerivedData/` directory. They never patch source,
|
||||
project, configuration, or entitlement files.
|
||||
|
||||
`just clean` removes only `.DerivedData/` and `.build/`. It does not invoke Git
|
||||
or restore tracked files, so uncommitted work is preserved. `just test` runs the
|
||||
SwiftPM suite and `just test-ios` runs the iPhone 17 simulator suite.
|
||||
|
||||
## Localization
|
||||
|
||||
- Base app resources live under `bitchat/Localization/Base.lproj/`. Add new copy to `Localizable.strings` and plural rules to `Localizable.stringsdict`.
|
||||
- Share extension strings are separate in `bitchatShareExtension/Localization/Base.lproj/Localizable.strings`.
|
||||
- App localizations live in `bitchat/Localizable.xcstrings`.
|
||||
- Share extension strings are separate in `bitchatShareExtension/Localization/Localizable.xcstrings`.
|
||||
- Prefer keys that describe intent (`app_info.features.offline.title`) and reuse existing ones where possible.
|
||||
- Run `xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGNING_ALLOWED=NO build` to compile-check any localization updates.
|
||||
|
||||
45
SECURITY.md
Normal file
45
SECURITY.md
Normal file
@ -0,0 +1,45 @@
|
||||
# Security policy
|
||||
|
||||
bitchat is a security-focused messenger, and reports about its security are taken seriously. This page says how to report, what counts as a vulnerability here, and what to expect.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Use GitHub's private vulnerability reporting:** [Report a vulnerability](https://github.com/permissionlesstech/bitchat/security/advisories/new) (Security tab → "Report a vulnerability").
|
||||
|
||||
Please do not open a public issue for anything that could put people at risk before a fix ships. bitchat is used by people in hostile network environments; a public proof-of-concept can be acted on faster than a patch can reach them.
|
||||
|
||||
A useful report says what an attacker can do, against which build (App Store version or commit hash), and how to reproduce it. A failing test or a packet capture is worth more than speculation about impact.
|
||||
|
||||
## What to expect
|
||||
|
||||
This is a volunteer-maintained project. The aim is to acknowledge reports within a week and to move on confirmed vulnerabilities immediately — historically, confirmed protocol and key-handling issues have been fixed within days. You'll be kept in the loop in the advisory thread, and credited in the fix unless you'd rather not be. There is no bug bounty.
|
||||
|
||||
## Supported versions
|
||||
|
||||
Fixes ship to the latest App Store release and `main`. Older releases are not patched; the fix is to update.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope — the properties the app promises:
|
||||
|
||||
- Confidentiality and integrity of private messages and media (Noise sessions over BLE; over Nostr, bitchat's own ephemeral private-envelope format — a proprietary scheme, *not* NIP-17/NIP-44/NIP-59, see `WHITEPAPER.md`)
|
||||
- Identity: key handling, verification, impersonation, session binding
|
||||
- The panic wipe actually destroying what it claims to destroy
|
||||
- Metadata exposure beyond what the documentation already discloses (see `PRIVACY_POLICY.md` and `docs/privacy-assessment.md`)
|
||||
- Downgrade paths: anything that silently moves traffic from an encrypted path to a plaintext one
|
||||
- Tor routing: anything that makes traffic bypass Tor while the Tor preference is on
|
||||
- Supply-chain integrity of the source and its vendored binaries (see `docs/VERIFYING-A-BUILD.md`)
|
||||
|
||||
Out of scope — documented design properties, not vulnerabilities:
|
||||
|
||||
- Public visibility of mesh announces and geohash channels: broadcast content, nicknames, and public keys are public by design
|
||||
- Bluetooth proximity being observable: anyone in radio range can tell a BLE device is present
|
||||
- Mesh flooding/relay behavior inherent to a broadcast mesh (rate limits exist; the topology is what it is)
|
||||
- Behavior of third-party Nostr relays
|
||||
- Denial of service requiring physical proximity, and battery-drain attacks in general
|
||||
|
||||
If you're unsure whether something is in scope, report it privately anyway — a false alarm costs a few minutes; a real issue reported publicly can cost much more.
|
||||
|
||||
## Verifying what you're running
|
||||
|
||||
If your concern is that the app or source you have has been tampered with, that has its own document: `docs/VERIFYING-A-BUILD.md`.
|
||||
@ -18,14 +18,14 @@ bitchat is a decentralized, peer-to-peer messaging application for secure, priva
|
||||
* **Authentication:** peers are identified by cryptographic keys; announcements are signed and verified.
|
||||
* **Resilience:** the network functions in lossy, low-bandwidth, partitioned environments with churning membership.
|
||||
* **Eventual delivery:** a message to an out-of-range peer should still arrive — relayed by the mesh, carried by a moving person, or resting on an internet relay — within a bounded retention window.
|
||||
* **Ephemerality by default:** no plaintext message content is ever written to disk. Everything the store-and-forward stack persists is either sealed ciphertext or already-public broadcast traffic, and all of it dies with the panic wipe.
|
||||
* **Ephemerality by default:** conversation timelines live in memory only. Everything the store-and-forward stack persists is either sealed ciphertext or already-public broadcast traffic, and all of it dies with the panic wipe. Media is the exception: accepted images and voice notes are written to disk unsealed, protected by the platform's data-protection class rather than by app-layer encryption, and bounded by a storage quota.
|
||||
|
||||
## 2. Architecture Overview
|
||||
|
||||
Two transports implement a common `Transport` interface and are coordinated by a `MessageRouter`:
|
||||
|
||||
* **BLE mesh** — every device is simultaneously a GATT central and peripheral, relaying packets in a controlled flood. No infrastructure, pairing, or accounts.
|
||||
* **Nostr** — private messages to mutual favorites travel as NIP-17 gift-wrapped events over public relays (over Tor where enabled), bridging separate meshes through the internet.
|
||||
* **Nostr** — private messages to mutual favorites travel in BitChat's app-specific encrypted envelopes over public relays (over Tor where enabled), bridging separate meshes through the internet.
|
||||
|
||||
The router prefers a live mesh link, falls back to Nostr, and engages the courier system when neither can deliver promptly.
|
||||
|
||||
@ -36,13 +36,17 @@ Each device holds two long-term key pairs in the Keychain:
|
||||
* a **Curve25519 static key** for Noise key agreement — its SHA-256 fingerprint is the peer's stable identity, and
|
||||
* an **Ed25519 signing key** for packet signatures.
|
||||
|
||||
On the mesh, peers appear under short ephemeral IDs derived per session; favoriting pins the full Noise public key so identity survives across sessions. Mutual favorites also exchange Nostr public keys for the internet path. Optional QR verification binds a nickname to a fingerprint in person.
|
||||
On the mesh, peers appear under a short 8-byte peer ID. That ID is **not ephemeral**: it is the first 8 bytes of the SHA-256 fingerprint of the device's Noise static key, so it is stable across sessions, reboots, and reinstalls that preserve the keychain, and it changes only when the identity itself is replaced by a panic wipe. Favoriting pins the full Noise public key so identity survives across sessions. Mutual favorites also exchange Nostr public keys for the internet path. Optional QR verification binds a nickname to a fingerprint in person.
|
||||
|
||||
Signed announcements additionally carry the nickname, the Noise static public key, and the Ed25519 signing public key in cleartext (§4.5), so a passive receiver in radio range can link a device across time and place regardless of the peer ID. Unlinkable presence is not a property this protocol currently provides; see §9.
|
||||
|
||||
## 4. BLE Mesh Layer
|
||||
|
||||
### 4.1 Packet Format
|
||||
|
||||
A compact binary header (version, type, TTL, timestamp, flags) is followed by an 8-byte sender ID, an optional 8-byte recipient ID, the payload, and an optional Ed25519 signature. Version 2 packets may carry an explicit source route. Signatures exclude the TTL byte so relays can decrement it without invalidating them. Packets other than fragments are padded toward uniform sizes.
|
||||
A compact binary header (version, type, TTL, timestamp, flags) is followed by an 8-byte sender ID, an optional 8-byte recipient ID, the payload, and an optional Ed25519 signature. Version 2 packets may carry an explicit source route. Signatures exclude the TTL byte so relays can decrement it without invalidating them.
|
||||
|
||||
Only `noiseEncrypted` and `noiseHandshake` packets are padded, toward 256/512/1024/2048-byte buckets; every other type — public messages, announcements, board posts, group messages, fragments, files, and voice frames — goes out at its natural length. Padding is PKCS#7-style with pad bytes equal to the pad length, and because that length must fit one byte, a frame needing more than 255 bytes to reach its bucket is emitted unpadded. Payload length is therefore observable for most traffic.
|
||||
|
||||
### 4.2 Flood Control
|
||||
|
||||
@ -78,7 +82,9 @@ Courier envelopes are sealed to the recipient's *static* key with the one-way No
|
||||
|
||||
### 5.3 Nostr Path
|
||||
|
||||
Private messages to mutual favorites are wrapped per NIP-17/NIP-59: a rumor (kind 14) sealed (kind 13) and gift-wrapped (kind 1059) under a throwaway ephemeral key, so relays learn neither sender nor content.
|
||||
Private messages to mutual favorites use BitChat's proprietary private-envelope protocol. An unsigned inner message (kind 14) is encrypted and placed in a sender-signed seal (kind 13); that seal is encrypted again inside a public envelope (kind 1059) signed by a one-time key, so relays learn neither the stable sender identity nor the content. Each encrypted content field is `v2:` followed by base64url of a 24-byte nonce, XChaCha20-Poly1305 ciphertext, and its 16-byte tag. Keys come from secp256k1 ECDH and HKDF-SHA256 (the derivation reuses a "nip44-v2" info label but is not the NIP-44 key schedule).
|
||||
|
||||
This format reuses NIP-17/NIP-59 kind numbers but is **not NIP-17, NIP-44, or NIP-59 compatible** and interoperates only with BitChat clients. The outer `p` tag exposes the recipient's Nostr public key to relays; the plaintext and stable sender identity remain inside authenticated ciphertext. Public seal and envelope timestamps are randomized by up to ±15 minutes, while the actual message timestamp is encrypted. The protocol does not provide forward secrecy: compromise of the recipient's static Nostr private key can expose stored envelopes addressed to that key.
|
||||
|
||||
## 6. Store and Forward
|
||||
|
||||
@ -105,7 +111,7 @@ Public broadcast messages are cached (1000 packets) and reconciled between peers
|
||||
|
||||
### 6.4 Nostr Mailboxes
|
||||
|
||||
Gift-wrapped messages rest on Nostr relays; clients re-subscribe with a 24-hour lookback on reconnect, covering the both-devices-offline case for mutual favorites whenever either side touches the internet.
|
||||
BitChat private envelopes rest on Nostr relays; clients re-subscribe with a 24-hour lookback on reconnect, covering the both-devices-offline case for mutual favorites whenever either side touches the internet.
|
||||
|
||||
### 6.5 Delivery Metrics
|
||||
|
||||
@ -122,12 +128,12 @@ Bare local counters (deposits, handovers, sprays, opens, outbox flushes and drop
|
||||
|
||||
## 8. Security Considerations
|
||||
|
||||
* **Relay nodes** cannot read private traffic; they forward padded, opaque ciphertext.
|
||||
* **Relay nodes** cannot read private traffic; they forward opaque ciphertext. Padding applies to Noise frames only (§4.1), so other packet types relay at their natural length.
|
||||
* **Couriers** are quota-bounded mailbags. A malicious courier can drop mail (redundant copies and deposit retry mitigate this) but cannot read it, link it across days, or amplify it — copy budgets are capped and every envelope is validated against size and lifetime policy on deposit.
|
||||
* **Flooding abuse** is bounded by TTL clamps, deduplication, per-depositor quotas, connect-rate limits, and announce-rate limiting.
|
||||
* **Replay** of public broadcasts is bounded by the 6-hour acceptance window plus deduplication; private payloads are protected by Noise nonces.
|
||||
* **Metadata.** BLE proximity is inherently observable; ephemeral IDs and daily-rotating courier tags limit long-term correlation. Nostr traffic can ride Tor.
|
||||
* **No forward secrecy for sealed mail** (§5.2) is the main cryptographic trade-off of the offline path.
|
||||
* **Metadata is the weakest part of this design, and the peer ID does not help.** The 8-byte sender ID in every packet header is derived from a never-rotating key (§3), and announcements publish the static keys and nickname in cleartext, so a passive listener can enumerate participants and follow a device between places. Announcements also carry up to ten direct-neighbor IDs (§4.3), which hands a single sniffer the local adjacency graph. Origin packets leave at the default TTL, so hop distance identifies the originator. Daily-rotating courier tags do limit correlation of carried mail, and Nostr traffic can ride Tor. Addressing the radio-layer exposure is future work (§9).
|
||||
* **No forward secrecy for sealed mail or Nostr private envelopes** (§5.2–5.3) means compromise of a recipient's static key can expose retained ciphertext addressed to that key.
|
||||
|
||||
## 9. Future Work
|
||||
|
||||
@ -135,6 +141,9 @@ Bare local counters (deposits, handovers, sprays, opens, outbox flushes and drop
|
||||
* Couriered media beyond the 16 KiB text cap.
|
||||
* Probabilistic relay and edge-of-network TTL boosting for very dense and very sparse graphs.
|
||||
* Multi-hop courier routing informed by encounter history.
|
||||
* **Rotating on-air identity.** Epoch-rotating peer IDs, with static-key disclosure moved inside the encrypted handshake and mutual favorites recognising each other through a tag derived from their shared secret, so presence stops being linkable across sessions (§3, §8).
|
||||
* **Padding for non-Noise packet types**, and closing the gap where a frame needing more than 255 bytes of padding is emitted unpadded (§4.1).
|
||||
* Making the neighbor list in announcements optional, or restricted to authenticated links (§4.3).
|
||||
|
||||
---
|
||||
|
||||
|
||||
15
bitchat.xcodeproj/project.pbxproj
generated
15
bitchat.xcodeproj/project.pbxproj
generated
@ -70,6 +70,7 @@
|
||||
A6E32D1B2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchatShareExtension" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Services/SharedContentHandoff.swift,
|
||||
Services/TransportConfig.swift,
|
||||
);
|
||||
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
|
||||
@ -93,7 +94,6 @@
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
bitchatShareExtension.entitlements,
|
||||
);
|
||||
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
|
||||
};
|
||||
@ -337,6 +337,7 @@
|
||||
es,
|
||||
ar,
|
||||
de,
|
||||
fa,
|
||||
fr,
|
||||
he,
|
||||
id,
|
||||
@ -377,6 +378,11 @@
|
||||
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */,
|
||||
);
|
||||
};
|
||||
7E9B64F63F93443FB7BA12DF /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
files = (
|
||||
);
|
||||
};
|
||||
C5E027A42ECCDFD700BD6012 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
files = (
|
||||
@ -393,13 +399,6 @@
|
||||
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */,
|
||||
);
|
||||
};
|
||||
7E9B64F63F93443FB7BA12DF /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
|
||||
@ -2,11 +2,6 @@ import BitFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
enum SharedContentKind: String, Sendable, Equatable {
|
||||
case text
|
||||
case url
|
||||
}
|
||||
|
||||
enum RuntimeScenePhase: String, Sendable, Equatable {
|
||||
case active
|
||||
case inactive
|
||||
@ -18,6 +13,8 @@ enum TorLifecycleEvent: String, Sendable, Equatable {
|
||||
case willRestart
|
||||
case didBecomeReady
|
||||
case preferenceChanged
|
||||
/// Bootstrap ran out its deadline without completing.
|
||||
case bootstrapDidStall
|
||||
}
|
||||
|
||||
enum AppEvent: Sendable, Equatable {
|
||||
@ -25,7 +22,7 @@ enum AppEvent: Sendable, Equatable {
|
||||
case startupCompleted
|
||||
case scenePhaseChanged(RuntimeScenePhase)
|
||||
case openedURL(String)
|
||||
case sharedContentAccepted(SharedContentKind)
|
||||
case sharedContentReadyForReview(SharedContentKind)
|
||||
case notificationOpened(peerID: PeerID?)
|
||||
case deepLinkOpened(String)
|
||||
case torLifecycleChanged(TorLifecycleEvent)
|
||||
|
||||
@ -20,13 +20,22 @@ final class AppChromeModel: ObservableObject {
|
||||
@Published var showScreenshotPrivacyWarning = false
|
||||
|
||||
private let chatViewModel: ChatViewModel
|
||||
private let onPanicWipe: () -> Void
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
/// The composer owns capture state above ChatViewModel. ContentView
|
||||
/// installs this hook so both panic entry points synchronously stop it.
|
||||
private var prepareForPanic: (@MainActor () -> Void)?
|
||||
|
||||
/// Bulletin-board coordinator, created on first use of the board sheet.
|
||||
private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService)
|
||||
|
||||
init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) {
|
||||
init(
|
||||
chatViewModel: ChatViewModel,
|
||||
privateInboxModel: PrivateInboxModel,
|
||||
onPanicWipe: @escaping () -> Void = {}
|
||||
) {
|
||||
self.chatViewModel = chatViewModel
|
||||
self.onPanicWipe = onPanicWipe
|
||||
self.nickname = chatViewModel.nickname
|
||||
|
||||
bind(privateInboxModel: privateInboxModel)
|
||||
@ -76,7 +85,8 @@ final class AppChromeModel: ObservableObject {
|
||||
/// neighbor claim but never announced to us) fall back to a short ID.
|
||||
func meshTopologyDisplayModel() -> MeshTopologyDisplayModel {
|
||||
let mesh = chatViewModel.meshService
|
||||
guard let snapshot = mesh.currentMeshTopology() else { return .empty }
|
||||
guard let diagnostics = mesh as? MeshDiagnosing,
|
||||
let snapshot = diagnostics.currentMeshTopology() else { return .empty }
|
||||
let nicknames = mesh.getPeerNicknames()
|
||||
|
||||
let nodes = snapshot.nodes.map { peerID -> MeshTopologyDisplayModel.Node in
|
||||
@ -97,7 +107,13 @@ final class AppChromeModel: ObservableObject {
|
||||
showScreenshotPrivacyWarning = true
|
||||
}
|
||||
|
||||
func setPanicPreparation(_ preparation: (@MainActor () -> Void)?) {
|
||||
prepareForPanic = preparation
|
||||
}
|
||||
|
||||
func panicClearAllData() {
|
||||
prepareForPanic?()
|
||||
onPanicWipe()
|
||||
chatViewModel.panicClearAllData()
|
||||
}
|
||||
|
||||
|
||||
@ -27,6 +27,7 @@ final class AppRuntime: ObservableObject {
|
||||
let peerListModel: PeerListModel
|
||||
let appChromeModel: AppChromeModel
|
||||
let boardAlertsModel: BoardAlertsModel
|
||||
let sharedContentImportModel: SharedContentImportModel
|
||||
|
||||
private let idBridge: NostrIdentityBridge
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
@ -41,7 +42,8 @@ final class AppRuntime: ObservableObject {
|
||||
|
||||
init(
|
||||
keychain: KeychainManagerProtocol = KeychainManager.makeDefault(),
|
||||
idBridge: NostrIdentityBridge = NostrIdentityBridge()
|
||||
idBridge: NostrIdentityBridge = NostrIdentityBridge(),
|
||||
sharedContentStore: SharedContentStore? = nil
|
||||
) {
|
||||
self.idBridge = idBridge
|
||||
let conversations = ConversationStore()
|
||||
@ -84,9 +86,20 @@ final class AppRuntime: ObservableObject {
|
||||
peerIdentityStore: peerIdentityStore,
|
||||
locationPresenceStore: locationPresenceStore
|
||||
)
|
||||
let resolvedSharedContentStore: SharedContentStore?
|
||||
if let sharedContentStore {
|
||||
resolvedSharedContentStore = sharedContentStore
|
||||
} else if let sharedDefaults = UserDefaults(suiteName: BitchatApp.groupID) {
|
||||
resolvedSharedContentStore = SharedContentStore(defaults: sharedDefaults)
|
||||
} else {
|
||||
resolvedSharedContentStore = nil
|
||||
}
|
||||
let sharedContentImportModel = SharedContentImportModel(store: resolvedSharedContentStore)
|
||||
self.sharedContentImportModel = sharedContentImportModel
|
||||
self.appChromeModel = AppChromeModel(
|
||||
chatViewModel: self.chatViewModel,
|
||||
privateInboxModel: self.privateInboxModel
|
||||
privateInboxModel: self.privateInboxModel,
|
||||
onPanicWipe: { sharedContentImportModel.discardAll() }
|
||||
)
|
||||
let chatViewModel = self.chatViewModel
|
||||
self.boardAlertsModel = BoardAlertsModel(
|
||||
@ -106,13 +119,15 @@ final class AppRuntime: ObservableObject {
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
GeoRelayDirectory.shared.prefetchIfNeeded()
|
||||
if chatViewModel.networkActivationAllowed {
|
||||
GeoRelayDirectory.shared.prefetchIfNeeded()
|
||||
}
|
||||
bindRuntimeObservers()
|
||||
NotificationDelegate.shared.runtime = self
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard chatViewModel.networkActivationAllowed else { return }
|
||||
guard !started else {
|
||||
checkForSharedContent()
|
||||
return
|
||||
@ -137,11 +152,26 @@ final class AppRuntime: ObservableObject {
|
||||
NetworkActivationService.shared.start()
|
||||
GeohashPresenceService.shared.start()
|
||||
checkForSharedContent()
|
||||
performMediaMaintenance()
|
||||
|
||||
record(.launched)
|
||||
record(.startupCompleted)
|
||||
}
|
||||
|
||||
/// Drops media that has outlived the retention window, then applies the
|
||||
/// explicit protection class to files that older builds wrote without
|
||||
/// one. Expiry runs first so the migration never touches files the
|
||||
/// sweep is about to delete. Detached because `AppRuntime` is
|
||||
/// main-actor and both passes go file by file through the media tree;
|
||||
/// best-effort, nothing at launch depends on their results.
|
||||
private func performMediaMaintenance() {
|
||||
Task.detached(priority: .utility) {
|
||||
let store = BLEIncomingFileStore()
|
||||
store.expireAgedMedia()
|
||||
store.migrateFileProtectionIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
func handleOpenURL(_ url: URL) {
|
||||
record(.openedURL(url.absoluteString))
|
||||
|
||||
@ -151,12 +181,14 @@ final class AppRuntime: ObservableObject {
|
||||
}
|
||||
|
||||
func handleDidBecomeActiveNotification() {
|
||||
guard chatViewModel.networkActivationAllowed else { return }
|
||||
chatViewModel.handleDidBecomeActive()
|
||||
checkForSharedContent()
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
func handleMacDidBecomeActiveNotification() {
|
||||
guard chatViewModel.networkActivationAllowed else { return }
|
||||
record(.scenePhaseChanged(.active))
|
||||
chatViewModel.handleDidBecomeActive()
|
||||
checkForSharedContent()
|
||||
@ -175,6 +207,7 @@ final class AppRuntime: ObservableObject {
|
||||
didEnterBackground = true
|
||||
|
||||
case .active:
|
||||
guard chatViewModel.networkActivationAllowed else { return }
|
||||
record(.scenePhaseChanged(.active))
|
||||
chatViewModel.meshService.startServices()
|
||||
TorManager.shared.setAppForeground(true)
|
||||
@ -222,6 +255,7 @@ final class AppRuntime: ObservableObject {
|
||||
actionIdentifier: String = UNNotificationDefaultActionIdentifier,
|
||||
userInfo: [AnyHashable: Any]
|
||||
) {
|
||||
guard chatViewModel.networkActivationAllowed else { return }
|
||||
if actionIdentifier == NotificationService.waveActionID {
|
||||
chatViewModel.sendMeshWave()
|
||||
return
|
||||
@ -273,6 +307,8 @@ private extension AppRuntime {
|
||||
NotificationCenter.default.publisher(for: .TorWillRestart)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
guard self?.chatViewModel.networkActivationAllowed == true
|
||||
else { return }
|
||||
self?.record(.torLifecycleChanged(.willRestart))
|
||||
self?.chatViewModel.handleTorWillRestart()
|
||||
}
|
||||
@ -281,6 +317,8 @@ private extension AppRuntime {
|
||||
NotificationCenter.default.publisher(for: .TorDidBecomeReady)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
guard self?.chatViewModel.networkActivationAllowed == true
|
||||
else { return }
|
||||
self?.record(.torLifecycleChanged(.didBecomeReady))
|
||||
self?.chatViewModel.handleTorDidBecomeReady()
|
||||
}
|
||||
@ -289,14 +327,28 @@ private extension AppRuntime {
|
||||
NotificationCenter.default.publisher(for: .TorWillStart)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
guard self?.chatViewModel.networkActivationAllowed == true
|
||||
else { return }
|
||||
self?.record(.torLifecycleChanged(.willStart))
|
||||
self?.chatViewModel.handleTorWillStart()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: .TorBootstrapDidStall)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
guard self?.chatViewModel.networkActivationAllowed == true
|
||||
else { return }
|
||||
self?.record(.torLifecycleChanged(.bootstrapDidStall))
|
||||
self?.chatViewModel.handleTorBootstrapDidStall()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: .TorUserPreferenceChanged)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] notification in
|
||||
guard self?.chatViewModel.networkActivationAllowed == true
|
||||
else { return }
|
||||
self?.record(.torLifecycleChanged(.preferenceChanged))
|
||||
self?.chatViewModel.handleTorPreferenceChanged(notification)
|
||||
}
|
||||
@ -313,44 +365,22 @@ private extension AppRuntime {
|
||||
}
|
||||
|
||||
func checkForSharedContent() {
|
||||
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else { return }
|
||||
let clearSharedContent = {
|
||||
userDefaults.removeObject(forKey: "sharedContent")
|
||||
userDefaults.removeObject(forKey: "sharedContentType")
|
||||
userDefaults.removeObject(forKey: "sharedContentDate")
|
||||
let previousID = sharedContentImportModel.offer?.id
|
||||
guard let payload = sharedContentImportModel.refresh(
|
||||
destination: currentSharedContentDestination
|
||||
) else { return }
|
||||
|
||||
if previousID != payload.id {
|
||||
record(.sharedContentReadyForReview(payload.kind))
|
||||
}
|
||||
}
|
||||
|
||||
guard let sharedContent = userDefaults.string(forKey: "sharedContent"),
|
||||
let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else {
|
||||
// A partial or malformed handoff must not linger in the shared
|
||||
// app-group container indefinitely.
|
||||
clearSharedContent()
|
||||
return
|
||||
}
|
||||
|
||||
guard Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds else {
|
||||
clearSharedContent()
|
||||
return
|
||||
}
|
||||
|
||||
let contentKind = SharedContentKind(rawValue: userDefaults.string(forKey: "sharedContentType") ?? "") ?? .text
|
||||
|
||||
clearSharedContent()
|
||||
|
||||
switch contentKind {
|
||||
case .url:
|
||||
if let data = sharedContent.data(using: .utf8),
|
||||
let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String],
|
||||
let url = urlData["url"] {
|
||||
chatViewModel.sendMessage(url)
|
||||
} else {
|
||||
chatViewModel.sendMessage(sharedContent)
|
||||
}
|
||||
case .text:
|
||||
chatViewModel.sendMessage(sharedContent)
|
||||
}
|
||||
|
||||
record(.sharedContentAccepted(contentKind))
|
||||
var currentSharedContentDestination: SharedContentDestination {
|
||||
SharedContentDestination.resolve(
|
||||
selectedPrivatePeerID: privateConversationModel.selectedPeerID,
|
||||
privateDisplayName: privateConversationModel.selectedHeaderState?.displayName,
|
||||
activeChannel: locationChannelsModel.selectedChannel
|
||||
)
|
||||
}
|
||||
|
||||
func handleNostrRelayConnectionChanged(_ isConnected: Bool) {
|
||||
@ -359,7 +389,9 @@ private extension AppRuntime {
|
||||
let becameConnected = isConnected && !lastNostrRelayConnectedState
|
||||
lastNostrRelayConnectedState = isConnected
|
||||
|
||||
guard started, becameConnected else { return }
|
||||
guard chatViewModel.networkActivationAllowed,
|
||||
started,
|
||||
becameConnected else { return }
|
||||
|
||||
let isInitialConnection = !didHandleInitialNostrConnection
|
||||
didHandleInitialNostrConnection = true
|
||||
|
||||
@ -39,15 +39,17 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
@Published private(set) var messages: [BitchatMessage] = []
|
||||
@Published private(set) var isUnread: Bool = false
|
||||
|
||||
/// Incrementally-maintained message-ID → index map for O(1) dedup and
|
||||
/// delivery-status lookup. Kept in sync on every mutation:
|
||||
/// - tail append: single insert
|
||||
/// - out-of-order insert: suffix reindex from the insertion point
|
||||
/// - trim: full rebuild — `removeFirst(k)` is already O(n), so the
|
||||
/// rebuild does not change the asymptotics, and trim only happens once
|
||||
/// the cap (1337) is reached. Simple and correct beats the
|
||||
/// offset-tracking alternative here.
|
||||
/// Incrementally-maintained message-ID → logical-index map for O(1)
|
||||
/// dedup and delivery-status lookup. Logical indexes are physical array
|
||||
/// indexes plus `indexOffset`; trimming from the head advances the offset
|
||||
/// instead of rewriting every surviving dictionary entry. This matters
|
||||
/// after the 1337-message cap is reached, when every steady-state tail
|
||||
/// append evicts one old row.
|
||||
///
|
||||
/// Out-of-order inserts and middle removals still reindex only the
|
||||
/// affected suffix. Full filtering resets the offset while rebuilding.
|
||||
private var indexByMessageID: [String: Int] = [:]
|
||||
private var indexOffset = 0
|
||||
|
||||
fileprivate init(id: ConversationID, cap: Int) {
|
||||
self.id = id
|
||||
@ -61,7 +63,7 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
}
|
||||
|
||||
func message(withID messageID: String) -> BitchatMessage? {
|
||||
guard let index = indexByMessageID[messageID] else { return nil }
|
||||
guard let index = physicalIndex(forMessageID: messageID) else { return nil }
|
||||
return messages[index]
|
||||
}
|
||||
|
||||
@ -101,7 +103,7 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
reindex(from: index)
|
||||
} else {
|
||||
messages.append(message)
|
||||
indexByMessageID[message.id] = messages.count - 1
|
||||
indexByMessageID[message.id] = indexOffset + messages.count - 1
|
||||
}
|
||||
|
||||
return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded())
|
||||
@ -111,7 +113,7 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
/// timeline position (in-place updates like media progress reuse the
|
||||
/// original timestamp); a new message goes through ordered insertion.
|
||||
fileprivate func upsert(_ message: BitchatMessage) -> UpsertOutcome {
|
||||
if let index = indexByMessageID[message.id] {
|
||||
if let index = physicalIndex(forMessageID: message.id) {
|
||||
messages[index] = message
|
||||
return .updated
|
||||
}
|
||||
@ -125,7 +127,7 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
/// `.read` is never downgraded to `.delivered` or `.sent`.
|
||||
/// Returns `true` when the status was applied.
|
||||
fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||
guard let index = indexByMessageID[messageID] else { return false }
|
||||
guard let index = physicalIndex(forMessageID: messageID) else { return false }
|
||||
let message = messages[index]
|
||||
guard !Self.shouldSkipStatusUpdate(current: message.deliveryStatus, new: status) else { return false }
|
||||
|
||||
@ -142,7 +144,7 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
/// observers still need an @Published emission to re-render.
|
||||
@discardableResult
|
||||
fileprivate func republishMessage(withID messageID: String) -> Bool {
|
||||
guard let index = indexByMessageID[messageID] else { return false }
|
||||
guard let index = physicalIndex(forMessageID: messageID) else { return false }
|
||||
messages[index] = messages[index]
|
||||
return true
|
||||
}
|
||||
@ -157,10 +159,14 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
/// Removes a single message by ID. Returns the removed message, or
|
||||
/// `nil` when no message with that ID exists.
|
||||
fileprivate func remove(messageID: String) -> BitchatMessage? {
|
||||
guard let index = indexByMessageID[messageID] else { return nil }
|
||||
guard let index = physicalIndex(forMessageID: messageID) else { return nil }
|
||||
let removed = messages.remove(at: index)
|
||||
indexByMessageID.removeValue(forKey: messageID)
|
||||
reindex(from: index)
|
||||
if index == 0 {
|
||||
indexOffset += 1
|
||||
} else {
|
||||
reindex(from: index)
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
@ -177,6 +183,7 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
for id in removedIDs {
|
||||
indexByMessageID.removeValue(forKey: id)
|
||||
}
|
||||
indexOffset = 0
|
||||
reindex(from: 0)
|
||||
return removedIDs
|
||||
}
|
||||
@ -184,6 +191,7 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
fileprivate func clearMessages() {
|
||||
messages.removeAll()
|
||||
indexByMessageID.removeAll()
|
||||
indexOffset = 0
|
||||
}
|
||||
|
||||
// MARK: Diagnostics
|
||||
@ -205,9 +213,10 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
let message = messages[position]
|
||||
// Count equality + every message resolving to its own position
|
||||
// proves the index is exactly the inverse map (no stale extras).
|
||||
if let index = indexByMessageID[message.id] {
|
||||
if index != position {
|
||||
violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(index)")
|
||||
if let logicalIndex = indexByMessageID[message.id] {
|
||||
let expectedIndex = indexOffset + position
|
||||
if logicalIndex != expectedIndex {
|
||||
violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(logicalIndex - indexOffset)")
|
||||
}
|
||||
} else {
|
||||
violations.append("\(label): message \(message.id.prefix(8))… at \(position) missing from index")
|
||||
@ -221,8 +230,7 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
|
||||
// MARK: Internals
|
||||
|
||||
static func shouldSkipStatusUpdate(current: DeliveryStatus?, new: DeliveryStatus) -> Bool {
|
||||
guard let current else { return false }
|
||||
static func shouldSkipStatusUpdate(current: DeliveryStatus, new: DeliveryStatus) -> Bool {
|
||||
if current == new { return true }
|
||||
|
||||
// Never downgrade to a weaker delivery state. Ordering of certainty:
|
||||
@ -245,6 +253,10 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
return true
|
||||
case (.sent, .sending):
|
||||
return true
|
||||
case (_, .notSentYet):
|
||||
// .notSentYet is the pre-transport initial state; once a message
|
||||
// has any real status, resetting to it is always a downgrade.
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@ -269,10 +281,17 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
|
||||
private func reindex(from start: Int) {
|
||||
for index in start..<messages.count {
|
||||
indexByMessageID[messages[index].id] = index
|
||||
indexByMessageID[messages[index].id] = indexOffset + index
|
||||
}
|
||||
}
|
||||
|
||||
private func physicalIndex(forMessageID messageID: String) -> Int? {
|
||||
guard let logicalIndex = indexByMessageID[messageID] else { return nil }
|
||||
let index = logicalIndex - indexOffset
|
||||
guard messages.indices.contains(index) else { return nil }
|
||||
return index
|
||||
}
|
||||
|
||||
/// Trims oldest messages over the cap; returns the trimmed message IDs.
|
||||
private func trimIfNeeded() -> [String] {
|
||||
guard messages.count > cap else { return [] }
|
||||
@ -282,7 +301,7 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
indexByMessageID.removeValue(forKey: id)
|
||||
}
|
||||
messages.removeFirst(overflow)
|
||||
reindex(from: 0)
|
||||
indexOffset += overflow
|
||||
return trimmedIDs
|
||||
}
|
||||
}
|
||||
@ -426,6 +445,40 @@ final class ConversationStore: ObservableObject {
|
||||
@discardableResult
|
||||
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||
guard let ids = conversationIDsByMessageID[messageID] else { return false }
|
||||
return applyDeliveryStatus(status, forMessageID: messageID, among: ids)
|
||||
}
|
||||
|
||||
/// Applies an authenticated delivery/read receipt only to the supplied
|
||||
/// direct-conversation aliases. A colliding message ID in another peer's
|
||||
/// conversation (or a public timeline) must not inherit the receipt.
|
||||
///
|
||||
/// Stable and ephemeral aliases can temporarily hold the same message
|
||||
/// instance during handoff. The shared helper republishes every targeted
|
||||
/// alias even when the first mutation already changed that instance.
|
||||
@discardableResult
|
||||
func setDeliveryStatus(
|
||||
_ status: DeliveryStatus,
|
||||
forMessageID messageID: String,
|
||||
inDirectPeerAliases peerIDs: Set<PeerID>
|
||||
) -> Bool {
|
||||
guard !peerIDs.isEmpty,
|
||||
let indexedIDs = conversationIDsByMessageID[messageID] else {
|
||||
return false
|
||||
}
|
||||
let allowedIDs = Set(peerIDs.map { ConversationID.directPeer($0) })
|
||||
return applyDeliveryStatus(
|
||||
status,
|
||||
forMessageID: messageID,
|
||||
among: indexedIDs.intersection(allowedIDs)
|
||||
)
|
||||
}
|
||||
|
||||
private func applyDeliveryStatus(
|
||||
_ status: DeliveryStatus,
|
||||
forMessageID messageID: String,
|
||||
among ids: Set<ConversationID>
|
||||
) -> Bool {
|
||||
guard !ids.isEmpty else { return false }
|
||||
var applied = false
|
||||
var skipped: [ConversationID] = []
|
||||
for id in ids {
|
||||
@ -844,8 +897,8 @@ extension Conversation {
|
||||
/// (positions 0 and 1 swap their index entries). Requires >= 2 messages.
|
||||
func _testCorruptIndexEntries() {
|
||||
guard messages.count >= 2 else { return }
|
||||
indexByMessageID[messages[0].id] = 1
|
||||
indexByMessageID[messages[1].id] = 0
|
||||
indexByMessageID[messages[0].id] = indexOffset + 1
|
||||
indexByMessageID[messages[1].id] = indexOffset
|
||||
}
|
||||
|
||||
/// Drops a message's index entry entirely (count mismatch + missing).
|
||||
@ -859,8 +912,8 @@ extension Conversation {
|
||||
func _testCorruptOrderingPreservingIndex() {
|
||||
guard messages.count >= 2 else { return }
|
||||
messages.swapAt(0, messages.count - 1)
|
||||
indexByMessageID[messages[0].id] = 0
|
||||
indexByMessageID[messages[messages.count - 1].id] = messages.count - 1
|
||||
indexByMessageID[messages[0].id] = indexOffset
|
||||
indexByMessageID[messages[messages.count - 1].id] = indexOffset + messages.count - 1
|
||||
}
|
||||
}
|
||||
|
||||
@ -900,7 +953,7 @@ extension ConversationStore {
|
||||
extension Conversation {
|
||||
fileprivate func _testAppendBypassingTrim(_ message: BitchatMessage) {
|
||||
messages.append(message)
|
||||
indexByMessageID[message.id] = messages.count - 1
|
||||
indexByMessageID[message.id] = indexOffset + messages.count - 1
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@ -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
|
||||
|
||||
@ -7,45 +7,147 @@ final class LocationPresenceStore: ObservableObject {
|
||||
@Published private(set) var geoNicknames: [String: String] = [:]
|
||||
@Published private(set) var teleportedGeo: Set<String> = []
|
||||
|
||||
private let teleportedGeoCapacity: Int
|
||||
private var teleportedGeoOrder: [String] = []
|
||||
private let geoNicknameCapacity: Int
|
||||
private var geoNicknameOrder: [String] = []
|
||||
|
||||
init(
|
||||
teleportedGeoCapacity: Int = TransportConfig.geoTeleportedParticipantsCap,
|
||||
geoNicknameCapacity: Int = TransportConfig.geoNicknameParticipantsCap
|
||||
) {
|
||||
self.teleportedGeoCapacity = max(0, teleportedGeoCapacity)
|
||||
self.geoNicknameCapacity = max(0, geoNicknameCapacity)
|
||||
}
|
||||
|
||||
func setCurrentGeohash(_ geohash: String?) {
|
||||
currentGeohash = geohash?.lowercased()
|
||||
let normalized = geohash?.lowercased()
|
||||
if currentGeohash != normalized {
|
||||
// Presence markers are scoped to the active geohash channel.
|
||||
clearTeleportedGeo()
|
||||
clearGeoNicknames()
|
||||
}
|
||||
currentGeohash = normalized
|
||||
}
|
||||
|
||||
func setNickname(_ nickname: String, for pubkeyHex: String) {
|
||||
geoNicknames[pubkeyHex.lowercased()] = nickname
|
||||
guard geoNicknameCapacity > 0 else {
|
||||
clearGeoNicknames()
|
||||
return
|
||||
}
|
||||
|
||||
let nickname = nickname.normalizedNickname
|
||||
let key = pubkeyHex.lowercased()
|
||||
if geoNicknames[key] != nil {
|
||||
geoNicknames[key] = nickname
|
||||
return
|
||||
}
|
||||
|
||||
while geoNicknameOrder.count >= geoNicknameCapacity, let oldest = geoNicknameOrder.first {
|
||||
geoNicknameOrder.removeFirst()
|
||||
geoNicknames.removeValue(forKey: oldest)
|
||||
}
|
||||
|
||||
geoNicknames[key] = nickname
|
||||
geoNicknameOrder.append(key)
|
||||
}
|
||||
|
||||
func replaceGeoNicknames(_ nicknames: [String: String]) {
|
||||
geoNicknames = Dictionary(
|
||||
uniqueKeysWithValues: nicknames.map { key, value in
|
||||
(key.lowercased(), value)
|
||||
}
|
||||
)
|
||||
guard geoNicknameCapacity > 0 else {
|
||||
clearGeoNicknames()
|
||||
return
|
||||
}
|
||||
|
||||
var seen: Set<String> = []
|
||||
var ordered: [String] = []
|
||||
var normalized: [String: String] = [:]
|
||||
for (key, value) in nicknames {
|
||||
let lower = key.lowercased()
|
||||
guard seen.insert(lower).inserted else { continue }
|
||||
ordered.append(lower)
|
||||
normalized[lower] = value.normalizedNickname
|
||||
}
|
||||
if ordered.count > geoNicknameCapacity {
|
||||
let kept = Array(ordered.suffix(geoNicknameCapacity))
|
||||
ordered = kept
|
||||
normalized = Dictionary(uniqueKeysWithValues: kept.compactMap { key in
|
||||
normalized[key].map { (key, $0) }
|
||||
})
|
||||
}
|
||||
geoNicknameOrder = ordered
|
||||
geoNicknames = normalized
|
||||
}
|
||||
|
||||
func clearGeoNicknames() {
|
||||
geoNicknames.removeAll()
|
||||
geoNicknameOrder.removeAll()
|
||||
}
|
||||
|
||||
func retainGeoNicknames(keeping pubkeys: Set<String>) {
|
||||
let allowed = Set(pubkeys.map { $0.lowercased() })
|
||||
geoNicknameOrder = geoNicknameOrder.filter { allowed.contains($0) }
|
||||
geoNicknames = geoNicknames.filter { allowed.contains($0.key) }
|
||||
}
|
||||
|
||||
func markTeleported(_ pubkeyHex: String) {
|
||||
teleportedGeo.insert(pubkeyHex.lowercased())
|
||||
guard teleportedGeoCapacity > 0 else {
|
||||
clearTeleportedGeo()
|
||||
return
|
||||
}
|
||||
|
||||
let key = pubkeyHex.lowercased()
|
||||
guard !teleportedGeo.contains(key) else { return }
|
||||
|
||||
while teleportedGeoOrder.count >= teleportedGeoCapacity, let oldest = teleportedGeoOrder.first {
|
||||
teleportedGeoOrder.removeFirst()
|
||||
teleportedGeo.remove(oldest)
|
||||
}
|
||||
|
||||
teleportedGeo.insert(key)
|
||||
teleportedGeoOrder.append(key)
|
||||
}
|
||||
|
||||
func clearTeleported(_ pubkeyHex: String) {
|
||||
teleportedGeo.remove(pubkeyHex.lowercased())
|
||||
let key = pubkeyHex.lowercased()
|
||||
teleportedGeo.remove(key)
|
||||
teleportedGeoOrder.removeAll { $0 == key }
|
||||
}
|
||||
|
||||
func replaceTeleportedGeo(_ pubkeys: Set<String>) {
|
||||
teleportedGeo = Set(pubkeys.map { $0.lowercased() })
|
||||
guard teleportedGeoCapacity > 0 else {
|
||||
clearTeleportedGeo()
|
||||
return
|
||||
}
|
||||
|
||||
var seen: Set<String> = []
|
||||
var ordered: [String] = []
|
||||
for key in pubkeys.map({ $0.lowercased() }) where !seen.contains(key) {
|
||||
seen.insert(key)
|
||||
ordered.append(key)
|
||||
}
|
||||
if ordered.count > teleportedGeoCapacity {
|
||||
ordered = Array(ordered.suffix(teleportedGeoCapacity))
|
||||
}
|
||||
teleportedGeoOrder = ordered
|
||||
teleportedGeo = Set(ordered)
|
||||
}
|
||||
|
||||
func retainTeleportedGeo(keeping pubkeys: Set<String>) {
|
||||
let allowed = Set(pubkeys.map { $0.lowercased() })
|
||||
teleportedGeoOrder = teleportedGeoOrder.filter { allowed.contains($0) }
|
||||
teleportedGeo = teleportedGeo.intersection(allowed)
|
||||
}
|
||||
|
||||
func clearTeleportedGeo() {
|
||||
teleportedGeo.removeAll()
|
||||
teleportedGeoOrder.removeAll()
|
||||
}
|
||||
|
||||
func reset() {
|
||||
currentGeohash = nil
|
||||
geoNicknames.removeAll()
|
||||
geoNicknameOrder.removeAll()
|
||||
teleportedGeo.removeAll()
|
||||
teleportedGeoOrder.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
@ -33,15 +33,25 @@ final class NearbyNotesCounter: ObservableObject {
|
||||
private let locationManager: LocationChannelManager
|
||||
private let managerFactory: @MainActor (String) -> LocationNotesManager
|
||||
private let releaseManager: @MainActor (LocationNotesManager?) -> Void
|
||||
private let locationNotesEnabled: @MainActor () -> Bool
|
||||
private let locationNotesSettingsPublisher: AnyPublisher<Void, Never>
|
||||
|
||||
init(
|
||||
locationManager: LocationChannelManager = .shared,
|
||||
managerFactory: @escaping @MainActor (String) -> LocationNotesManager = { LocationNotesPool.shared.acquire($0) },
|
||||
releaseManager: @escaping @MainActor (LocationNotesManager?) -> Void = { LocationNotesPool.shared.release($0) }
|
||||
releaseManager: @escaping @MainActor (LocationNotesManager?) -> Void = { LocationNotesPool.shared.release($0) },
|
||||
locationNotesEnabled: @escaping @MainActor () -> Bool = { LocationNotesSettings.enabled },
|
||||
locationNotesSettings: AnyPublisher<Void, Never>? = nil
|
||||
) {
|
||||
self.locationManager = locationManager
|
||||
self.managerFactory = managerFactory
|
||||
self.releaseManager = releaseManager
|
||||
self.locationNotesEnabled = locationNotesEnabled
|
||||
self.locationNotesSettingsPublisher = locationNotesSettings
|
||||
?? NotificationCenter.default
|
||||
.publisher(for: LocationNotesSettings.didChangeNotification)
|
||||
.map { _ in () }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
/// Whether the empty-timeline "check for notes" hint should render.
|
||||
@ -53,7 +63,7 @@ final class NearbyNotesCounter: ObservableObject {
|
||||
/// passes its own observed permission state so the hint re-renders when
|
||||
/// authorization changes.
|
||||
func offersRevealHint(permissionState: LocationChannelManager.PermissionState) -> Bool {
|
||||
!revealed && LocationNotesSettings.enabled && permissionState == .authorized
|
||||
!revealed && locationNotesEnabled() && permissionState == .authorized
|
||||
}
|
||||
|
||||
/// Marks the one explicit act that lets the counter subscribe. Sticky for
|
||||
@ -83,8 +93,7 @@ final class NearbyNotesCounter: ObservableObject {
|
||||
.sink { [weak self] _ in self?.retarget() }
|
||||
// The app-info kill switch must take effect immediately, not on the
|
||||
// next location change or remount.
|
||||
settingCancellable = NotificationCenter.default
|
||||
.publisher(for: LocationNotesSettings.didChangeNotification)
|
||||
settingCancellable = locationNotesSettingsPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in self?.retarget() }
|
||||
retarget()
|
||||
@ -105,7 +114,7 @@ final class NearbyNotesCounter: ObservableObject {
|
||||
private func retarget() {
|
||||
guard activeHolders > 0,
|
||||
revealed,
|
||||
LocationNotesSettings.enabled,
|
||||
locationNotesEnabled(),
|
||||
locationManager.permissionState == .authorized,
|
||||
let geohash = locationManager.availableChannels
|
||||
.first(where: { $0.level == .building })?.geohash
|
||||
|
||||
100
bitchat/App/PrivacyScreen.swift
Normal file
100
bitchat/App/PrivacyScreen.swift
Normal file
@ -0,0 +1,100 @@
|
||||
//
|
||||
// PrivacyScreen.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
|
||||
/// Covers the window while the app is not frontmost, so the snapshot iOS takes
|
||||
/// for the app switcher shows a placeholder instead of the open conversation.
|
||||
///
|
||||
/// The cover is added on `willResignActive` and removed on `didBecomeActive`.
|
||||
/// Both are deliberately UIKit notifications rather than SwiftUI's `scenePhase`:
|
||||
/// the snapshot is captured shortly after `willResignActive`, and adding an
|
||||
/// opaque subview to the window synchronously in that callback is the only way
|
||||
/// to guarantee it is in the render tree before the capture. A SwiftUI overlay
|
||||
/// driven by state may not have been laid out yet.
|
||||
///
|
||||
/// Panic wipe separately deletes any snapshots already on disk; this keeps new
|
||||
/// ones from containing anything worth deleting.
|
||||
final class PrivacyScreen {
|
||||
static let shared = PrivacyScreen()
|
||||
|
||||
private var cover: UIView?
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Idempotent: repeated calls do not stack observers.
|
||||
///
|
||||
/// `queue: nil` is required, not incidental. Passing an `OperationQueue`
|
||||
/// would enqueue the handler to run in a later runloop turn, which the
|
||||
/// snapshot can beat; with no queue the block runs synchronously on the
|
||||
/// thread that posted the notification — the main thread, for UIApplication
|
||||
/// lifecycle notifications.
|
||||
func install() {
|
||||
guard observers.isEmpty else { return }
|
||||
let center = NotificationCenter.default
|
||||
observers = [
|
||||
center.addObserver(
|
||||
forName: UIApplication.willResignActiveNotification,
|
||||
object: nil,
|
||||
queue: nil
|
||||
) { _ in
|
||||
PrivacyScreen.shared.show()
|
||||
},
|
||||
center.addObserver(
|
||||
forName: UIApplication.didBecomeActiveNotification,
|
||||
object: nil,
|
||||
queue: nil
|
||||
) { _ in
|
||||
PrivacyScreen.shared.hide()
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
private func show() {
|
||||
guard cover == nil, let window = Self.activeWindow() else { return }
|
||||
|
||||
// Opaque rather than a blur: blurred large text can stay partly
|
||||
// legible, and the snapshot is stored on disk.
|
||||
let view = UIView(frame: window.bounds)
|
||||
view.backgroundColor = .systemBackground
|
||||
view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
|
||||
let label = UILabel()
|
||||
label.text = "bitchat"
|
||||
label.font = .monospacedSystemFont(ofSize: 22, weight: .medium)
|
||||
label.textColor = .secondaryLabel
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(label)
|
||||
NSLayoutConstraint.activate([
|
||||
label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
label.centerYAnchor.constraint(equalTo: view.centerYAnchor)
|
||||
])
|
||||
|
||||
window.addSubview(view)
|
||||
cover = view
|
||||
}
|
||||
|
||||
private func hide() {
|
||||
cover?.removeFromSuperview()
|
||||
cover = nil
|
||||
}
|
||||
|
||||
private static func activeWindow() -> UIWindow? {
|
||||
UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.flatMap(\.windows)
|
||||
.first { $0.isKeyWindow } ??
|
||||
UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.flatMap(\.windows)
|
||||
.first
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@ -265,10 +265,10 @@ final class PrivateConversationModel: ObservableObject {
|
||||
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
|
||||
let peer = chatViewModel.getPeer(byID: headerPeerID)
|
||||
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
|
||||
// Geo DMs are always routed over Nostr (NIP-17); their nostr_ keys
|
||||
// never resolve to a reachable mesh peer, so resolveAvailability would
|
||||
// report .offline. Report .nostrAvailable so the header shows the
|
||||
// globe instead of a misleading "offline" tag.
|
||||
// Geo DMs are always routed through BitChat private envelopes over
|
||||
// Nostr; their nostr_ keys never resolve to a reachable mesh peer, so
|
||||
// resolveAvailability would report .offline. Report .nostrAvailable
|
||||
// so the header shows the globe instead of a misleading "offline" tag.
|
||||
let availability = conversationPeerID.isGeoDM
|
||||
? .nostrAvailable
|
||||
: resolveAvailability(for: headerPeerID, peer: peer)
|
||||
|
||||
119
bitchat/App/SharedContentImportModel.swift
Normal file
119
bitchat/App/SharedContentImportModel.swift
Normal file
@ -0,0 +1,119 @@
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
enum SharedContentDestination: Sendable, Equatable {
|
||||
case mesh
|
||||
case geohash(String)
|
||||
case privateConversation(peerID: PeerID, displayName: String)
|
||||
|
||||
static func resolve(
|
||||
selectedPrivatePeerID: PeerID?,
|
||||
privateDisplayName: String?,
|
||||
activeChannel: ChannelID
|
||||
) -> SharedContentDestination {
|
||||
if let selectedPrivatePeerID {
|
||||
let fallback = String(selectedPrivatePeerID.id.prefix(12))
|
||||
return .privateConversation(
|
||||
peerID: selectedPrivatePeerID,
|
||||
displayName: privateDisplayName?.trimmedOrNilIfEmpty ?? fallback
|
||||
)
|
||||
}
|
||||
|
||||
switch activeChannel {
|
||||
case .mesh:
|
||||
return .mesh
|
||||
case .location(let channel):
|
||||
return .geohash(channel.geohash.lowercased())
|
||||
}
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .mesh:
|
||||
return "#mesh"
|
||||
case .geohash(let geohash):
|
||||
return "#\(geohash)"
|
||||
case .privateConversation(_, let displayName):
|
||||
return displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SharedContentOffer: Identifiable, Sendable, Equatable {
|
||||
let payload: SharedContentPayload
|
||||
let destination: SharedContentDestination
|
||||
|
||||
var id: UUID { payload.id }
|
||||
}
|
||||
|
||||
/// Holds a pending extension handoff until the user chooses a destination and
|
||||
/// explicitly adds it to the composer. This type has no send dependency by
|
||||
/// design: confirming an import can never transmit a message.
|
||||
@MainActor
|
||||
final class SharedContentImportModel: ObservableObject {
|
||||
@Published private(set) var offer: SharedContentOffer?
|
||||
|
||||
private let store: SharedContentStore?
|
||||
|
||||
init(store: SharedContentStore?) {
|
||||
self.store = store
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func refresh(
|
||||
destination: SharedContentDestination,
|
||||
now: Date = Date()
|
||||
) -> SharedContentPayload? {
|
||||
guard let payload = store?.pending(now: now) else {
|
||||
offer = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
let nextOffer = SharedContentOffer(payload: payload, destination: destination)
|
||||
if offer != nextOffer {
|
||||
offer = nextOffer
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func updateDestination(_ destination: SharedContentDestination) {
|
||||
guard let offer, offer.destination != destination else { return }
|
||||
self.offer = SharedContentOffer(payload: offer.payload, destination: destination)
|
||||
}
|
||||
|
||||
/// Returns composer text only when the currently displayed destination is
|
||||
/// still current and the reviewed envelope is still the stored envelope.
|
||||
/// A destination change updates the prompt and requires another tap.
|
||||
func confirm(
|
||||
destination: SharedContentDestination,
|
||||
now: Date = Date()
|
||||
) -> String? {
|
||||
guard let offer else { return nil }
|
||||
guard offer.destination == destination else {
|
||||
updateDestination(destination)
|
||||
return nil
|
||||
}
|
||||
guard let payload = store?.consume(id: offer.id, now: now) else {
|
||||
_ = refresh(destination: destination, now: now)
|
||||
return nil
|
||||
}
|
||||
|
||||
self.offer = nil
|
||||
return payload.composerText
|
||||
}
|
||||
|
||||
func cancel(destination: SharedContentDestination, now: Date = Date()) {
|
||||
guard let offer else { return }
|
||||
store?.discard(id: offer.id)
|
||||
self.offer = nil
|
||||
// If a newer share replaced the reviewed envelope, surface it rather
|
||||
// than losing it with the older cancellation.
|
||||
_ = refresh(destination: destination, now: now)
|
||||
}
|
||||
|
||||
func discardAll() {
|
||||
store?.discardAll()
|
||||
offer = nil
|
||||
}
|
||||
}
|
||||
@ -41,6 +41,7 @@ struct BitchatApp: App {
|
||||
.environmentObject(runtime.peerListModel)
|
||||
.environmentObject(runtime.appChromeModel)
|
||||
.environmentObject(runtime.boardAlertsModel)
|
||||
.environmentObject(runtime.sharedContentImportModel)
|
||||
.onAppear {
|
||||
appDelegate.runtime = runtime
|
||||
runtime.start()
|
||||
@ -73,7 +74,10 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
weak var runtime: AppRuntime?
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||
true
|
||||
// Installed before the first resign-active so the app-switcher snapshot
|
||||
// never captures an open conversation.
|
||||
PrivacyScreen.shared.install()
|
||||
return true
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
|
||||
@ -206,7 +206,7 @@ enum ImageUtils {
|
||||
} else {
|
||||
directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true)
|
||||
}
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: BLEIncomingFileStore.mediaProtectionAttributes)
|
||||
return directory.appendingPathComponent(fileName)
|
||||
}
|
||||
|
||||
|
||||
@ -26,6 +26,8 @@ protocol VoiceCaptureSession: AnyObject {
|
||||
/// nothing valid was captured.
|
||||
func finish() async -> URL?
|
||||
func cancel() async
|
||||
/// Stops capture and suppresses every later send before returning.
|
||||
func panicCancelSynchronously()
|
||||
}
|
||||
|
||||
/// The classic record-then-send backend, wrapping the shared `VoiceRecorder`.
|
||||
@ -55,6 +57,10 @@ final class VoiceNoteCaptureSession: VoiceCaptureSession {
|
||||
func cancel() async {
|
||||
await recorder.cancelRecording(owner: owner)
|
||||
}
|
||||
|
||||
func panicCancelSynchronously() {
|
||||
recorder.panicCancelSynchronously(owner: owner)
|
||||
}
|
||||
}
|
||||
|
||||
/// Testable surface of the live capture engine. Production uses
|
||||
@ -216,6 +222,13 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
|
||||
}
|
||||
}
|
||||
|
||||
func panicCancelSynchronously() {
|
||||
// Do not emit a canceled packet: it would itself be pre-panic
|
||||
// conversation data racing the emergency transport reset.
|
||||
completed = true
|
||||
capture.cancel()
|
||||
}
|
||||
|
||||
private func sendControlPacket(_ kind: VoiceBurstPacket.Kind) {
|
||||
guard let packet = VoiceBurstPacket(burstID: burstID, seq: stream.packetizer.nextSeq, kind: kind) else { return }
|
||||
sendPacket(packet.encode())
|
||||
@ -231,7 +244,7 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
|
||||
let directory = base
|
||||
.appendingPathComponent("files", isDirectory: true)
|
||||
.appendingPathComponent("voicenotes/outgoing", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: BLEIncomingFileStore.mediaProtectionAttributes)
|
||||
return directory.appendingPathComponent("voice_\(burstID.hexEncodedString()).m4a")
|
||||
}
|
||||
}
|
||||
|
||||
@ -246,6 +246,21 @@ actor VoiceRecorder {
|
||||
currentURL = nil
|
||||
}
|
||||
|
||||
/// Panic is a synchronous security boundary: the caller must know the
|
||||
/// microphone, audio-session lease, and partial file are gone before it
|
||||
/// rotates identities or deletes the media tree. VoiceRecorder is an
|
||||
/// independent actor and this cleanup path never hops to MainActor, so a
|
||||
/// short semaphore join is safe even when invoked by the UI actor.
|
||||
nonisolated
|
||||
func panicCancelSynchronously(owner: RecordingOwner) {
|
||||
let finished = DispatchSemaphore(value: 0)
|
||||
Task {
|
||||
await cancelRecording(owner: owner)
|
||||
finished.signal()
|
||||
}
|
||||
finished.wait()
|
||||
}
|
||||
|
||||
/// The audio session was interrupted (call, Siri) or reconfigured: stop
|
||||
/// the recorder but keep `recorder`/`currentURL` so the caller's pending
|
||||
/// `stopRecording()` still returns the partial note.
|
||||
@ -285,7 +300,7 @@ actor VoiceRecorder {
|
||||
|
||||
let baseDirectory = try outputDirectory
|
||||
?? applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: nil)
|
||||
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: BLEIncomingFileStore.mediaProtectionAttributes)
|
||||
return baseDirectory.appendingPathComponent(fileName)
|
||||
}
|
||||
|
||||
|
||||
@ -14,18 +14,22 @@
|
||||
///
|
||||
/// ## Overview
|
||||
/// BitChat's identity system separates concerns across three distinct layers:
|
||||
/// 1. **Ephemeral Identity**: Short-lived, rotatable peer IDs for privacy
|
||||
/// 1. **Network Identity**: the 8-byte peer ID seen on air
|
||||
/// 2. **Cryptographic Identity**: Long-term Noise static keys for security
|
||||
/// 3. **Social Identity**: User-assigned names and trust relationships
|
||||
/// 3. **Social Identity**: assigned names and trust relationships
|
||||
///
|
||||
/// This separation allows users to maintain stable cryptographic identities
|
||||
/// while frequently rotating their network identifiers for privacy.
|
||||
/// The layers are separate concerns, but they are not independent: the network
|
||||
/// identity is *derived* from the cryptographic one, so it does not provide
|
||||
/// unlinkability. Rotating peer IDs would be a change to this model, not a
|
||||
/// description of it — see the note below.
|
||||
///
|
||||
/// ## Three-Layer Architecture
|
||||
///
|
||||
/// ### Layer 1: Ephemeral Identity
|
||||
/// - Random 8-byte peer IDs that rotate periodically
|
||||
/// - Provides network-level privacy and prevents tracking
|
||||
/// ### Layer 1: Network Identity
|
||||
/// - 8-byte peer ID = first 8 bytes of the Noise static key fingerprint
|
||||
/// - **Not ephemeral and not rotating.** It is stable across sessions and
|
||||
/// reboots, and changes only when the underlying identity is replaced by a
|
||||
/// panic wipe. A passive observer can use it to track a device.
|
||||
/// - Changes don't affect cryptographic relationships
|
||||
/// - Includes handshake state tracking
|
||||
///
|
||||
@ -33,7 +37,7 @@
|
||||
/// - Based on Noise Protocol static key pairs
|
||||
/// - Fingerprint derived from SHA256 of public key
|
||||
/// - Enables end-to-end encryption and authentication
|
||||
/// - Persists across peer ID rotations
|
||||
/// - The root of the peer ID above, and never rotated on a schedule
|
||||
///
|
||||
/// ### Layer 3: Social Identity
|
||||
/// - User-assigned names (petnames) for contacts
|
||||
@ -44,10 +48,13 @@
|
||||
/// ## Privacy Design
|
||||
/// The model is designed with privacy-first principles:
|
||||
/// - No mandatory persistent storage
|
||||
/// - Optional identity caching with user consent
|
||||
/// - Ephemeral IDs prevent long-term tracking
|
||||
/// - Optional identity caching with explicit consent
|
||||
/// - Social mappings stored locally only
|
||||
///
|
||||
/// It does **not** currently prevent long-term tracking by a passive radio
|
||||
/// observer: the peer ID is stable (Layer 1) and signed announcements carry the
|
||||
/// static keys and nickname in cleartext.
|
||||
///
|
||||
/// ## Trust Model
|
||||
/// Four levels of trust:
|
||||
/// 1. **Unknown**: New or unverified peers
|
||||
@ -56,17 +63,17 @@
|
||||
/// 4. **Verified**: Cryptographic verification completed
|
||||
///
|
||||
/// ## Identity Resolution
|
||||
/// When a peer rotates their ephemeral ID:
|
||||
/// When a peer's ID changes (a panic wipe on their side, or a future rotation):
|
||||
/// 1. Cryptographic handshake reveals their fingerprint
|
||||
/// 2. System looks up social identity by fingerprint
|
||||
/// 3. UI seamlessly maintains user relationships
|
||||
/// 3. UI seamlessly maintains existing relationships
|
||||
/// 4. Historical messages remain properly attributed
|
||||
///
|
||||
/// ## Conflict Resolution
|
||||
/// Handles edge cases like:
|
||||
/// - Multiple peers claiming same nickname
|
||||
/// - Nickname changes and conflicts
|
||||
/// - Identity rotation during active chats
|
||||
/// - Identity replacement during active chats
|
||||
/// - Network partitions and rejoins
|
||||
///
|
||||
/// ## Usage Example
|
||||
@ -85,8 +92,12 @@ import BitFoundation
|
||||
|
||||
// MARK: - Three-Layer Identity Model
|
||||
|
||||
/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy.
|
||||
/// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships.
|
||||
/// Represents the network layer of identity — the peer ID seen on air, plus the
|
||||
/// handshake state tracked against it.
|
||||
///
|
||||
/// Named "ephemeral" for historical reasons; the peer ID is in fact stable,
|
||||
/// being derived from the Noise static key fingerprint. It does not rotate and
|
||||
/// does not prevent tracking.
|
||||
struct EphemeralIdentity {
|
||||
var handshakeState: HandshakeState
|
||||
}
|
||||
@ -99,8 +110,9 @@ enum HandshakeState {
|
||||
}
|
||||
|
||||
/// Represents the cryptographic layer of identity - the stable Noise Protocol static key pair.
|
||||
/// This identity persists across ephemeral ID rotations and enables secure communication.
|
||||
/// The fingerprint serves as the permanent identifier for a peer's cryptographic identity.
|
||||
/// This identity outlives any change to a peer's network ID and enables secure communication.
|
||||
/// The fingerprint serves as the permanent identifier for a peer's cryptographic identity, and
|
||||
/// its first 8 bytes are the peer ID broadcast on the mesh.
|
||||
struct CryptographicIdentity: Codable {
|
||||
let fingerprint: String // SHA256 of public key
|
||||
let publicKey: Data // Noise static public key
|
||||
@ -161,24 +173,24 @@ struct VouchRecord: Codable, Equatable {
|
||||
struct IdentityCache: Codable {
|
||||
// Fingerprint -> Social mapping
|
||||
var socialIdentities: [String: SocialIdentity] = [:]
|
||||
|
||||
|
||||
// Nickname -> [Fingerprints] reverse index
|
||||
// Multiple fingerprints can claim same nickname
|
||||
var nicknameIndex: [String: Set<String>] = [:]
|
||||
|
||||
|
||||
// Verified fingerprints (cryptographic proof)
|
||||
var verifiedFingerprints: Set<String> = []
|
||||
|
||||
|
||||
// Last interaction timestamps (privacy: optional)
|
||||
var lastInteractions: [String: Date] = [:]
|
||||
|
||||
var lastInteractions: [String: Date] = [:]
|
||||
|
||||
// Blocked Nostr pubkeys (lowercased hex) for geohash chats
|
||||
var blockedNostrPubkeys: Set<String> = []
|
||||
|
||||
// Vouching (transitive verification). All three fields are Optional so
|
||||
// caches persisted before this feature decode cleanly — the synthesized
|
||||
// decoder uses decodeIfPresent for optionals, and a missing key must not
|
||||
// trip the "unreadable cache" recovery path that discards everything.
|
||||
// caches persisted before this feature decode cleanly — decodeIfPresent
|
||||
// is used below, and a missing key must not trip the "unreadable cache"
|
||||
// recovery path that discards everything.
|
||||
|
||||
// Vouchee fingerprint -> accepted vouches (capped per vouchee)
|
||||
var vouchesByVouchee: [String: [VouchRecord]]? = nil
|
||||
@ -189,6 +201,50 @@ struct IdentityCache: Codable {
|
||||
// Fingerprint -> when we verified it (orders outgoing vouch batches;
|
||||
// entries verified before this field exists sort as oldest)
|
||||
var verifiedAt: [String: Date]? = nil
|
||||
|
||||
// Stable Noise fingerprints that proved encrypted private-media support
|
||||
// inside an authenticated Noise session. Optional for decoding caches
|
||||
// written before this migration. Entries are monotonic until a panic wipe
|
||||
// so an old/replayed announce cannot silently downgrade a peer.
|
||||
var privateMediaCapableFingerprints: Set<String>? = nil
|
||||
|
||||
// Noise-fingerprint -> Ed25519 announcement key, learned only from the
|
||||
// authenticated peer-state payload. This prevents a self-signed announce
|
||||
// containing a copied public Noise key from replacing a previously bound
|
||||
// public-message signing identity. Optional for old cache compatibility.
|
||||
var authenticatedSigningKeysByFingerprint: [String: Data]? = nil
|
||||
|
||||
// Fingerprint -> Cryptographic identity (noise + pinned signing key).
|
||||
// Persisting the signing-key pin is security-critical: it must survive
|
||||
// app restarts so an attacker cannot replay a known peer's
|
||||
// noiseKey/peerID with their own signing key and be treated as first
|
||||
// contact (TOFU downgrade).
|
||||
var cryptographicIdentities: [String: CryptographicIdentity] = [:]
|
||||
|
||||
// Schema version for future migrations
|
||||
var version: Int = 1
|
||||
|
||||
init() {}
|
||||
|
||||
// Custom decoding so caches written by older builds (missing newer keys
|
||||
// such as `cryptographicIdentities` or the vouching fields) still load
|
||||
// instead of being discarded. Every field uses decodeIfPresent so a
|
||||
// missing key falls back to its default rather than throwing.
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
socialIdentities = try container.decodeIfPresent([String: SocialIdentity].self, forKey: .socialIdentities) ?? [:]
|
||||
nicknameIndex = try container.decodeIfPresent([String: Set<String>].self, forKey: .nicknameIndex) ?? [:]
|
||||
verifiedFingerprints = try container.decodeIfPresent(Set<String>.self, forKey: .verifiedFingerprints) ?? []
|
||||
lastInteractions = try container.decodeIfPresent([String: Date].self, forKey: .lastInteractions) ?? [:]
|
||||
blockedNostrPubkeys = try container.decodeIfPresent(Set<String>.self, forKey: .blockedNostrPubkeys) ?? []
|
||||
vouchesByVouchee = try container.decodeIfPresent([String: [VouchRecord]].self, forKey: .vouchesByVouchee)
|
||||
vouchBatchSentAt = try container.decodeIfPresent([String: Date].self, forKey: .vouchBatchSentAt)
|
||||
verifiedAt = try container.decodeIfPresent([String: Date].self, forKey: .verifiedAt)
|
||||
privateMediaCapableFingerprints = try container.decodeIfPresent(Set<String>.self, forKey: .privateMediaCapableFingerprints)
|
||||
authenticatedSigningKeysByFingerprint = try container.decodeIfPresent([String: Data].self, forKey: .authenticatedSigningKeysByFingerprint)
|
||||
cryptographicIdentities = try container.decodeIfPresent([String: CryptographicIdentity].self, forKey: .cryptographicIdentities) ?? [:]
|
||||
version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
@ -140,6 +140,14 @@ protocol SecureIdentityStateManagerProtocol {
|
||||
func markVouchBatchSent(to fingerprint: String, at date: Date)
|
||||
func signingPublicKey(forFingerprint fingerprint: String) -> Data?
|
||||
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String]
|
||||
|
||||
// MARK: Noise-authenticated announcement identity
|
||||
func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String)
|
||||
func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data?
|
||||
|
||||
// MARK: Private-media downgrade protection
|
||||
func markPrivateMediaCapable(fingerprint: String)
|
||||
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool
|
||||
}
|
||||
|
||||
/// Singleton manager for secure identity state persistence and retrieval.
|
||||
@ -152,18 +160,30 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
// In-memory state
|
||||
private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:]
|
||||
private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
|
||||
// Cryptographic identities (including pinned signing keys) live inside
|
||||
// `cache` so they persist across app restarts; see IdentityCache.
|
||||
private var cache: IdentityCache = IdentityCache()
|
||||
|
||||
// Thread safety
|
||||
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
|
||||
private let queueSpecificKey = DispatchSpecificKey<UInt8>()
|
||||
|
||||
// Pending-save coalescing flag. Reads/writes are serialized on `queue`.
|
||||
// Persistence is done with a fire-and-forget `queue.async(.barrier)` rather
|
||||
// than a retained DispatchSourceTimer: a lingering, never-cancelled timer
|
||||
// keeps the dispatch machinery alive and prevents the unit-test process from
|
||||
// exiting. (The original code used Timer.scheduledTimer on a GCD queue with
|
||||
// no run loop, so saves never actually fired.)
|
||||
//
|
||||
// Persistence is SYNCHRONOUS: every mutating API runs its mutate + encrypt
|
||||
// + keychain write inside `queue.sync(flags: .barrier)`, so when the call
|
||||
// returns the write is already complete and NOTHING is left scheduled on
|
||||
// the queue. This is deliberate — a retained DispatchSourceTimer (the
|
||||
// original design) kept the dispatch machinery alive and prevented the
|
||||
// unit-test process from exiting, and fire-and-forget `queue.async(.barrier)`
|
||||
// (a later design) left a backlog of instrumented barrier saves still
|
||||
// draining when LLVM's `--enable-code-coverage` `atexit` handler dumped
|
||||
// `.profraw`, deadlocking the process at teardown on the constrained CI
|
||||
// runner. Synchronous persistence has zero outstanding dispatch at exit, so
|
||||
// neither failure mode is possible. `pendingSave` is now effectively always
|
||||
// false after any mutation (saveIdentityCache persists inline and clears
|
||||
// it); it remains only as a belt-and-suspenders flag read by `forceSave`
|
||||
// and `deinit`.
|
||||
private var pendingSave = false
|
||||
|
||||
// Encryption key
|
||||
@ -214,6 +234,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
self.encryptionKey = loadedKey
|
||||
self.encryptionKeyIsEphemeral = keyIsEphemeral
|
||||
queue.setSpecific(key: queueSpecificKey, value: 1)
|
||||
|
||||
// Only read the persisted cache when we hold the real key; with an
|
||||
// ephemeral key the decrypt would fail and discard the real cache.
|
||||
@ -223,7 +244,22 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
|
||||
deinit {
|
||||
forceSave()
|
||||
// Do NOT dispatch onto `queue` here. `deinit` can run on any thread
|
||||
// (including one draining `queue`), and the object is being
|
||||
// deallocated: a `queue.sync` risks a re-entrant same-queue wait
|
||||
// (deadlock) and a `queue.async` schedules work that resurrects `self`
|
||||
// and may not drain before process exit.
|
||||
//
|
||||
// A flush here is redundant anyway: every mutating API already
|
||||
// persists inline within its own barrier, so the keychain is already
|
||||
// up to date. As a queue-free best-effort belt-and-suspenders, only
|
||||
// flush if something is still pending. This is a direct read of
|
||||
// in-hand state — safe because a deallocating object has no other
|
||||
// live references, so nothing can be mutating `cache` concurrently.
|
||||
if pendingSave {
|
||||
pendingSave = false
|
||||
persist(snapshot: cache)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Secure Loading/Saving
|
||||
@ -248,21 +284,27 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists the cache. Always invoked on `queue` under a barrier (its callers
|
||||
/// run inside `queue.async(.barrier)`), so it simply marks the cache dirty
|
||||
/// and persists it on the same serialized context — no timer, nothing left
|
||||
/// scheduled to keep the process alive.
|
||||
/// Persists the cache. Always invoked on `queue` under a barrier (its
|
||||
/// callers run inside `queue.sync(flags: .barrier)`), so `cache` is read
|
||||
/// while serialized. The encode + keychain write are done here (already on
|
||||
/// the exclusive barrier context), synchronously, so no separate hop is
|
||||
/// scheduled and nothing is left to keep the process alive.
|
||||
private func saveIdentityCache() {
|
||||
pendingSave = true
|
||||
performSave()
|
||||
// On the barrier context already: snapshot is trivially consistent.
|
||||
persist(snapshot: cache)
|
||||
pendingSave = false
|
||||
}
|
||||
|
||||
/// Writes the cache to the keychain. Must run on `queue` with exclusive
|
||||
/// (barrier) access.
|
||||
private func performSave() {
|
||||
guard pendingSave else { return }
|
||||
pendingSave = false
|
||||
|
||||
/// Encodes, seals, and writes a *snapshot* of the cache to the keychain.
|
||||
///
|
||||
/// Takes the cache by value so callers can capture a consistent snapshot
|
||||
/// under `queue` and then encode without holding it. Reading `cache`
|
||||
/// concurrently with a barrier writer would be a data race on the
|
||||
/// dictionary storage, which — because `JSONEncoder` walks that storage —
|
||||
/// can spin forever (observed as a CI test-suite hang), so the snapshot
|
||||
/// must be taken on `queue`, never off it.
|
||||
private func persist(snapshot: IdentityCache) {
|
||||
// Never persist under an ephemeral key — it would overwrite the real
|
||||
// cache with data the next launch cannot decrypt.
|
||||
guard !encryptionKeyIsEphemeral else {
|
||||
@ -271,7 +313,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
|
||||
do {
|
||||
let data = try JSONEncoder().encode(cache)
|
||||
let data = try JSONEncoder().encode(snapshot)
|
||||
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
|
||||
let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey)
|
||||
if saved {
|
||||
@ -282,14 +324,26 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
// Force immediate save (for app termination / lifecycle events). Mutations
|
||||
// already persist synchronously via saveIdentityCache, so this is normally a
|
||||
// no-op (performSave early-returns when nothing is pending). Runs directly on
|
||||
// the caller's thread — deliberately NOT a `queue.sync(barrier)`, which is
|
||||
// reachable from `deinit` and from async tests on the swift-concurrency
|
||||
// cooperative pool where a blocking barrier-sync can starve/deadlock it.
|
||||
// Force a flush (for app-termination / lifecycle events — NOT from
|
||||
// `deinit`, which persists inline; see the deinit note). Every mutating
|
||||
// API already persists inline inside its own barrier via
|
||||
// `saveIdentityCache`, so by the time this is called the keychain is
|
||||
// already up to date and this is normally a no-op; it exists as a
|
||||
// belt-and-suspenders flush of any `pendingSave` left set.
|
||||
//
|
||||
// Runs synchronously inside a `queue.sync(flags: .barrier)`: the barrier
|
||||
// makes the `cache` read race-free (a plain off-queue read races in-flight
|
||||
// barrier writers — JSONEncoder walking a concurrently-mutated dictionary
|
||||
// can spin forever, which surfaced as a CI hang), and being synchronous it
|
||||
// leaves nothing scheduled to keep the process alive at teardown. Safe
|
||||
// against re-entrant deadlock because this is never invoked from `deinit`
|
||||
// (the only path that can run *on* `queue`).
|
||||
func forceSave() {
|
||||
performSave()
|
||||
queue.sync(flags: .barrier) {
|
||||
guard pendingSave else { return }
|
||||
pendingSave = false
|
||||
persist(snapshot: cache)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Social Identity Management
|
||||
@ -303,15 +357,33 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
// MARK: - Cryptographic Identities
|
||||
|
||||
/// Insert or update a cryptographic identity and optionally persist its signing key and claimed nickname.
|
||||
///
|
||||
/// TOFU signing-key pinning: once a signing key has been persisted for a
|
||||
/// fingerprint, an update carrying a *different* signing key is refused in
|
||||
/// full (including the claimed-nickname update) and security-logged. This
|
||||
/// mirrors `BLEPeerRegistry.upsertVerifiedAnnounce` — without it, an
|
||||
/// attacker replaying a victim's noiseKey/peerID with their own signing
|
||||
/// key could overwrite the victim's persisted identity while the victim is
|
||||
/// offline or after an app restart. The refusal is permanent: there is
|
||||
/// currently no targeted in-app way to reset the pin (`setVerified` does
|
||||
/// not touch it). Recovering from a legitimate signing re-key requires the
|
||||
/// peer to establish a new noise identity (new peerID) or the local user
|
||||
/// to wipe all identity data (`clearAllIdentityData`, e.g. panic wipe).
|
||||
/// - Parameters:
|
||||
/// - fingerprint: SHA-256 hex of the Noise static public key
|
||||
/// - noisePublicKey: Noise static public key data
|
||||
/// - signingPublicKey: Optional Ed25519 signing public key for authenticating public messages
|
||||
/// - claimedNickname: Optional latest claimed nickname to persist into social identity
|
||||
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String? = nil) {
|
||||
queue.async(flags: .barrier) {
|
||||
queue.sync(flags: .barrier) {
|
||||
let now = Date()
|
||||
if var existing = self.cryptographicIdentities[fingerprint] {
|
||||
if var existing = self.cache.cryptographicIdentities[fingerprint] {
|
||||
if let pinnedSigningKey = existing.signingPublicKey,
|
||||
let announcedSigningKey = signingPublicKey,
|
||||
pinnedSigningKey != announcedSigningKey {
|
||||
SecureLogger.warning("🚨 Refusing to replace pinned signing key for \(fingerprint.prefix(8))… (possible impersonation attempt)", category: .security)
|
||||
return
|
||||
}
|
||||
// Update keys if changed
|
||||
if existing.publicKey != noisePublicKey {
|
||||
existing = CryptographicIdentity(
|
||||
@ -320,11 +392,11 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
signingPublicKey: signingPublicKey ?? existing.signingPublicKey,
|
||||
firstSeen: existing.firstSeen
|
||||
)
|
||||
self.cryptographicIdentities[fingerprint] = existing
|
||||
self.cache.cryptographicIdentities[fingerprint] = existing
|
||||
} else {
|
||||
// Update signing key
|
||||
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
|
||||
self.cryptographicIdentities[fingerprint] = existing
|
||||
self.cache.cryptographicIdentities[fingerprint] = existing
|
||||
}
|
||||
// Persist updated state (already assigned in branches above)
|
||||
} else {
|
||||
@ -335,7 +407,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
signingPublicKey: signingPublicKey,
|
||||
firstSeen: now
|
||||
)
|
||||
self.cryptographicIdentities[fingerprint] = entry
|
||||
self.cache.cryptographicIdentities[fingerprint] = entry
|
||||
}
|
||||
|
||||
// Optionally persist claimed nickname into social identity
|
||||
@ -367,12 +439,72 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
queue.sync {
|
||||
// Defensive: ensure hex and correct length
|
||||
guard peerID.isShort else { return [] }
|
||||
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
|
||||
return cache.cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private-media downgrade protection
|
||||
|
||||
func markPrivateMediaCapable(fingerprint: String) {
|
||||
guard !fingerprint.isEmpty else { return }
|
||||
let insertAndPersist = {
|
||||
var pinned = self.cache.privateMediaCapableFingerprints ?? []
|
||||
guard pinned.insert(fingerprint).inserted else { return }
|
||||
self.cache.privateMediaCapableFingerprints = pinned
|
||||
self.saveIdentityCache()
|
||||
}
|
||||
// Downgrade decisions can run immediately after an authenticated
|
||||
// announce. Make the pin visible before returning; merely enqueueing a
|
||||
// barrier leaves a cross-queue window where a replay can look legacy.
|
||||
// The queue-specific fast path prevents self-deadlock if a future
|
||||
// identity-state mutation records the capability from inside `queue`.
|
||||
if DispatchQueue.getSpecific(key: queueSpecificKey) != nil {
|
||||
insertAndPersist()
|
||||
} else {
|
||||
queue.sync(flags: .barrier, execute: insertAndPersist)
|
||||
}
|
||||
}
|
||||
|
||||
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool {
|
||||
guard !fingerprint.isEmpty else { return false }
|
||||
return queue.sync {
|
||||
cache.privateMediaCapableFingerprints?.contains(fingerprint) == true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Noise-authenticated announcement identity
|
||||
|
||||
func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String) {
|
||||
guard signingPublicKey.count == AuthenticatedPeerStatePacket.signingPublicKeyLength,
|
||||
!fingerprint.isEmpty else { return }
|
||||
let bindAndPersist = {
|
||||
var bindings = self.cache.authenticatedSigningKeysByFingerprint ?? [:]
|
||||
let bindingChanged = bindings[fingerprint] != signingPublicKey
|
||||
bindings[fingerprint] = signingPublicKey
|
||||
self.cache.authenticatedSigningKeysByFingerprint = bindings
|
||||
if var cryptoIdentity = self.cache.cryptographicIdentities[fingerprint] {
|
||||
cryptoIdentity.signingPublicKey = signingPublicKey
|
||||
self.cache.cryptographicIdentities[fingerprint] = cryptoIdentity
|
||||
}
|
||||
guard bindingChanged else { return }
|
||||
self.saveIdentityCache()
|
||||
}
|
||||
if DispatchQueue.getSpecific(key: queueSpecificKey) != nil {
|
||||
bindAndPersist()
|
||||
} else {
|
||||
queue.sync(flags: .barrier, execute: bindAndPersist)
|
||||
}
|
||||
}
|
||||
|
||||
func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data? {
|
||||
guard !fingerprint.isEmpty else { return nil }
|
||||
return queue.sync {
|
||||
cache.authenticatedSigningKeysByFingerprint?[fingerprint]
|
||||
}
|
||||
}
|
||||
|
||||
func updateSocialIdentity(_ identity: SocialIdentity) {
|
||||
queue.async(flags: .barrier) {
|
||||
queue.sync(flags: .barrier) {
|
||||
let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname
|
||||
self.cache.socialIdentities[identity.fingerprint] = identity
|
||||
|
||||
@ -408,7 +540,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
|
||||
func setFavorite(_ fingerprint: String, isFavorite: Bool) {
|
||||
queue.async(flags: .barrier) {
|
||||
queue.sync(flags: .barrier) {
|
||||
if var identity = self.cache.socialIdentities[fingerprint] {
|
||||
identity.isFavorite = isFavorite
|
||||
self.cache.socialIdentities[fingerprint] = identity
|
||||
@ -446,7 +578,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
func setBlocked(_ fingerprint: String, isBlocked: Bool) {
|
||||
SecureLogger.info("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: .security)
|
||||
|
||||
queue.async(flags: .barrier) {
|
||||
queue.sync(flags: .barrier) {
|
||||
if var identity = self.cache.socialIdentities[fingerprint] {
|
||||
identity.isBlocked = isBlocked
|
||||
if isBlocked {
|
||||
@ -480,7 +612,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) {
|
||||
let key = pubkeyHexLowercased.lowercased()
|
||||
queue.async(flags: .barrier) {
|
||||
queue.sync(flags: .barrier) {
|
||||
if isBlocked {
|
||||
self.cache.blockedNostrPubkeys.insert(key)
|
||||
} else {
|
||||
@ -503,7 +635,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
|
||||
func updateHandshakeState(peerID: PeerID, state: HandshakeState) {
|
||||
queue.async(flags: .barrier) {
|
||||
queue.sync(flags: .barrier) {
|
||||
self.ephemeralSessions[peerID]?.handshakeState = state
|
||||
|
||||
// If handshake completed, update last interaction
|
||||
@ -519,11 +651,10 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
func clearAllIdentityData() {
|
||||
SecureLogger.warning("Clearing all identity data", category: .security)
|
||||
|
||||
queue.async(flags: .barrier) {
|
||||
queue.sync(flags: .barrier) {
|
||||
self.cache = IdentityCache()
|
||||
self.ephemeralSessions.removeAll()
|
||||
self.cryptographicIdentities.removeAll()
|
||||
|
||||
|
||||
// Delete from keychain
|
||||
let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey)
|
||||
SecureLogger.logKeyOperation(.delete, keyType: "identity cache", success: deleted)
|
||||
@ -531,8 +662,8 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
|
||||
func removeEphemeralSession(peerID: PeerID) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.ephemeralSessions.removeValue(forKey: peerID)
|
||||
queue.sync(flags: .barrier) {
|
||||
_ = self.ephemeralSessions.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
@ -541,7 +672,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
func setVerified(fingerprint: String, verified: Bool) {
|
||||
SecureLogger.info("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: .security)
|
||||
|
||||
queue.async(flags: .barrier) {
|
||||
queue.sync(flags: .barrier) {
|
||||
if verified {
|
||||
self.cache.verifiedFingerprints.insert(fingerprint)
|
||||
var verifiedAt = self.cache.verifiedAt ?? [:]
|
||||
@ -709,7 +840,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
/// The peer's announce-bound Ed25519 signing key, if seen this session.
|
||||
func signingPublicKey(forFingerprint fingerprint: String) -> Data? {
|
||||
queue.sync { cryptographicIdentities[fingerprint]?.signingPublicKey }
|
||||
queue.sync { cache.cryptographicIdentities[fingerprint]?.signingPublicKey }
|
||||
}
|
||||
|
||||
/// Verified fingerprints ordered most recently verified first (entries
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -24,7 +24,7 @@ extension BitchatMessage {
|
||||
do {
|
||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
let filesDir = base.appendingPathComponent("files", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
||||
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: BLEIncomingFileStore.mediaProtectionAttributes)
|
||||
self.filesDir = filesDir
|
||||
} catch {
|
||||
filesDir = nil
|
||||
|
||||
@ -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,14 +6,60 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
enum NoiseSecurityConstants {
|
||||
// Maximum message size to prevent memory exhaustion
|
||||
static let maxMessageSize = 65535 // 64KB as per Noise spec
|
||||
|
||||
/// The extracted transport nonce (4 bytes) and Poly1305 tag (16 bytes)
|
||||
/// added by `NoiseCipherState` around every transport plaintext.
|
||||
static let transportCiphertextOverhead = 20
|
||||
|
||||
/// Private files are an explicit BitChat extension to the ordinary Noise
|
||||
/// message-size ceiling. They remain bounded by the same framed-file cap
|
||||
/// used by the binary and fragment decoders. Only the `.privateFile`
|
||||
/// typed-payload path is allowed to use this larger budget.
|
||||
private static let privateFileOuterPacketOverhead =
|
||||
(BinaryProtocol.v1HeaderSize + 2) // v2 adds two length bytes
|
||||
+ BinaryProtocol.senderIDSize
|
||||
+ BinaryProtocol.recipientIDSize
|
||||
static let maxPrivateFilePlaintextSize = FileTransferLimits.maxFramedFileBytes
|
||||
- privateFileOuterPacketOverhead
|
||||
- transportCiphertextOverhead
|
||||
static let maxPrivateFileCiphertextSize =
|
||||
maxPrivateFilePlaintextSize + transportCiphertextOverhead
|
||||
|
||||
// Maximum handshake message size
|
||||
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
|
||||
|
||||
// Noise XX message 1 contains only the initiator's 32-byte ephemeral key.
|
||||
static let xxInitialMessageSize = 32
|
||||
|
||||
// Bounds an ordinary initiator whose message 1 or 2 is lost.
|
||||
static let ordinaryHandshakeTimeout: TimeInterval = 10
|
||||
|
||||
// Bounds the receive-only rollback quarantine created by an unauthenticated
|
||||
// inbound message 1. A lost message 3 must not strand outbound traffic.
|
||||
static let ordinaryResponderHandshakeTimeout: TimeInterval = 20
|
||||
|
||||
// A released client may immediately retry after both crossed initiators
|
||||
// yielded. Give that unilateral retry a brief head start before the
|
||||
// patched side spends its one bounded recovery.
|
||||
static let handshakeCollisionRecoveryDelay: TimeInterval = 0.2
|
||||
|
||||
// Rate-limited recovery remains actionable without spinning.
|
||||
static let handshakeRateLimitRecoveryDelay: TimeInterval = 60
|
||||
|
||||
// Covers only reordering between a winning message 3 and the losing
|
||||
// crossed message 1.
|
||||
static let recentInitiatorCompletionGracePeriod: TimeInterval = 1
|
||||
|
||||
// After unauthenticated responder rollback, reject another attempt long
|
||||
// enough that paced message 1 traffic cannot keep outbound paused. A
|
||||
// legitimate peer converges through the one manager-owned local retry.
|
||||
static let ordinaryReconnectRollbackCooldown: TimeInterval = 60
|
||||
|
||||
// Session timeout - sessions older than this should be renegotiated
|
||||
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -66,7 +66,10 @@ class NoiseSession {
|
||||
|
||||
// Only initiator writes the first message
|
||||
if role == .initiator {
|
||||
let message = try handshakeState!.writeMessage()
|
||||
guard let handshake = handshakeState else {
|
||||
throw NoiseSessionError.invalidState
|
||||
}
|
||||
let message = try handshake.writeMessage()
|
||||
sentHandshakeMessages.append(message)
|
||||
return message
|
||||
} else {
|
||||
|
||||
@ -11,4 +11,11 @@ enum NoiseSessionError: Error, Equatable {
|
||||
case notEstablished
|
||||
case sessionNotFound
|
||||
case alreadyEstablished
|
||||
case peerIdentityMismatch
|
||||
}
|
||||
|
||||
/// The manager owns the exact attempt's one bounded recovery. Packet handling
|
||||
/// must not launch its historical second, immediate restart for this failure.
|
||||
struct NoiseManagedHandshakeFailure: Error {
|
||||
let underlying: Error
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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,6 +32,23 @@ struct GeoRelayDirectoryDependencies {
|
||||
var retrySleep: (TimeInterval) async -> Void
|
||||
var activeNotificationName: Notification.Name?
|
||||
var autoStart: Bool
|
||||
var validationPolicy: GeoRelayDirectoryValidationPolicy
|
||||
}
|
||||
|
||||
struct GeoRelayDirectoryValidationPolicy: Sendable {
|
||||
let maximumBytes: Int
|
||||
let maximumRows: Int
|
||||
let maximumEntries: Int
|
||||
let minimumRemoteEntries: Int
|
||||
let minimumRetainedFraction: Double
|
||||
|
||||
static let live = GeoRelayDirectoryValidationPolicy(
|
||||
maximumBytes: 512 * 1024,
|
||||
maximumRows: 5_000,
|
||||
maximumEntries: 5_000,
|
||||
minimumRemoteEntries: 50,
|
||||
minimumRetainedFraction: 0.5
|
||||
)
|
||||
}
|
||||
|
||||
private extension GeoRelayDirectoryDependencies {
|
||||
@ -44,21 +61,57 @@ private extension GeoRelayDirectoryDependencies {
|
||||
#else
|
||||
let activeNotificationName: Notification.Name? = nil
|
||||
#endif
|
||||
let validationPolicy = GeoRelayDirectoryValidationPolicy.live
|
||||
|
||||
return Self(
|
||||
userDefaults: .standard,
|
||||
notificationCenter: .default,
|
||||
now: Date.init,
|
||||
remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!,
|
||||
// Runtime refreshes only from bitchat's reviewed copy. Upstream
|
||||
// georelays/main is imported by a validator-backed pull request,
|
||||
// so an upstream mutation cannot immediately retarget clients.
|
||||
remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/bitchat/refs/heads/main/relays/online_relays_gps.csv")!,
|
||||
fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds,
|
||||
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
|
||||
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
|
||||
retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds,
|
||||
awaitTorReady: { await TorManager.shared.awaitReady() },
|
||||
// Only wait for Tor when Tor is switched on. With it off, the fetch
|
||||
// is meant to go direct through the same unproxied session the relay
|
||||
// sockets already use — and `TorManager` has been shut down, so
|
||||
// awaiting readiness would spend the whole bootstrap timeout on
|
||||
// every refresh and freeze the directory on its cached copy.
|
||||
//
|
||||
// Deliberately keyed on the preference rather than live readiness:
|
||||
// if Tor is wanted but not ready, this must keep returning false so
|
||||
// the fetch is skipped instead of silently leaking the IP.
|
||||
awaitTorReady: {
|
||||
guard NetworkActivationService.persistedTorPreference() else { return true }
|
||||
return await TorManager.shared.awaitReady()
|
||||
},
|
||||
makeFetchData: {
|
||||
let session = TorURLSession.shared.session
|
||||
return { request in
|
||||
let (data, _) = try await session.data(for: request)
|
||||
let (bytes, response) = try await session.bytes(for: request)
|
||||
guard let response = response as? HTTPURLResponse,
|
||||
(200...299).contains(response.statusCode),
|
||||
response.url == request.url else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
|
||||
let maximumBytes = validationPolicy.maximumBytes
|
||||
guard response.expectedContentLength <= Int64(maximumBytes) else {
|
||||
throw URLError(.dataLengthExceedsMaximum)
|
||||
}
|
||||
var data = Data()
|
||||
if response.expectedContentLength > 0 {
|
||||
data.reserveCapacity(Int(response.expectedContentLength))
|
||||
}
|
||||
for try await byte in bytes {
|
||||
guard data.count < maximumBytes else {
|
||||
throw URLError(.dataLengthExceedsMaximum)
|
||||
}
|
||||
data.append(byte)
|
||||
}
|
||||
return data
|
||||
}
|
||||
},
|
||||
@ -76,7 +129,11 @@ private extension GeoRelayDirectoryDependencies {
|
||||
)
|
||||
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
return dir.appendingPathComponent("georelays_cache.csv")
|
||||
// v2 ignores caches populated from the old direct-upstream
|
||||
// trust path and subjects every load to strict validation.
|
||||
let legacyCache = dir.appendingPathComponent("georelays_cache.csv")
|
||||
try? FileManager.default.removeItem(at: legacyCache)
|
||||
return dir.appendingPathComponent("georelays_cache_v2.csv")
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
@ -94,7 +151,8 @@ private extension GeoRelayDirectoryDependencies {
|
||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||
},
|
||||
activeNotificationName: activeNotificationName,
|
||||
autoStart: true
|
||||
autoStart: true,
|
||||
validationPolicy: validationPolicy
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -125,7 +183,7 @@ final class GeoRelayDirectory {
|
||||
}
|
||||
|
||||
private enum DetachedFetchOutcome: Sendable {
|
||||
case success(entries: [Entry], csv: String)
|
||||
case success(entries: [Entry], csv: Data)
|
||||
case torNotReady
|
||||
case invalidData
|
||||
case network(String)
|
||||
@ -212,6 +270,8 @@ final class GeoRelayDirectory {
|
||||
)
|
||||
let awaitTorReady = dependencies.awaitTorReady
|
||||
let fetchData = dependencies.makeFetchData()
|
||||
let validationPolicy = dependencies.validationPolicy
|
||||
let baselineEntries = Set(entries)
|
||||
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
@ -219,7 +279,9 @@ final class GeoRelayDirectory {
|
||||
let outcome = await Self.fetchRemoteOutcome(
|
||||
request: request,
|
||||
awaitTorReady: awaitTorReady,
|
||||
fetchData: fetchData
|
||||
fetchData: fetchData,
|
||||
validationPolicy: validationPolicy,
|
||||
baselineEntries: baselineEntries
|
||||
)
|
||||
|
||||
switch outcome {
|
||||
@ -238,7 +300,9 @@ final class GeoRelayDirectory {
|
||||
nonisolated private static func fetchRemoteOutcome(
|
||||
request: URLRequest,
|
||||
awaitTorReady: @escaping @Sendable () async -> Bool,
|
||||
fetchData: @escaping @Sendable (URLRequest) async throws -> Data
|
||||
fetchData: @escaping @Sendable (URLRequest) async throws -> Data,
|
||||
validationPolicy: GeoRelayDirectoryValidationPolicy,
|
||||
baselineEntries: Set<Entry>
|
||||
) async -> DetachedFetchOutcome {
|
||||
await Task.detached(priority: .utility) {
|
||||
let ready = await awaitTorReady()
|
||||
@ -246,16 +310,16 @@ final class GeoRelayDirectory {
|
||||
|
||||
do {
|
||||
let data = try await fetchData(request)
|
||||
guard let text = String(data: data, encoding: .utf8) else {
|
||||
guard let parsed = Self.validatedEntries(
|
||||
from: data,
|
||||
policy: validationPolicy,
|
||||
minimumEntries: validationPolicy.minimumRemoteEntries,
|
||||
baselineEntries: baselineEntries
|
||||
) else {
|
||||
return .invalidData
|
||||
}
|
||||
|
||||
let parsed = Self.parseCSV(text)
|
||||
guard !parsed.isEmpty else {
|
||||
return .invalidData
|
||||
}
|
||||
|
||||
return .success(entries: parsed, csv: text)
|
||||
return .success(entries: parsed, csv: data)
|
||||
} catch {
|
||||
return .network(error.localizedDescription)
|
||||
}
|
||||
@ -269,7 +333,7 @@ final class GeoRelayDirectory {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
|
||||
private func handleFetchSuccess(entries parsed: [Entry], csv: Data) {
|
||||
entries = parsed
|
||||
persistCache(csv)
|
||||
dependencies.userDefaults.set(dependencies.now(), forKey: lastFetchKey)
|
||||
@ -321,9 +385,8 @@ final class GeoRelayDirectory {
|
||||
cleanupState.retryTask = nil
|
||||
}
|
||||
|
||||
private func persistCache(_ text: String) {
|
||||
private func persistCache(_ data: Data) {
|
||||
guard let url = dependencies.cacheURL() else { return }
|
||||
guard let data = text.data(using: .utf8) else { return }
|
||||
do {
|
||||
try dependencies.writeData(data, url)
|
||||
} catch {
|
||||
@ -336,9 +399,12 @@ final class GeoRelayDirectory {
|
||||
// Prefer cached file if present
|
||||
if let cache = dependencies.cacheURL(),
|
||||
let data = dependencies.readData(cache),
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
let arr = Self.parseCSV(text)
|
||||
if !arr.isEmpty { return arr }
|
||||
let entries = Self.validatedEntries(
|
||||
from: data,
|
||||
policy: dependencies.validationPolicy,
|
||||
minimumEntries: 1
|
||||
) {
|
||||
return entries
|
||||
}
|
||||
|
||||
// Try bundled resource(s)
|
||||
@ -346,36 +412,157 @@ final class GeoRelayDirectory {
|
||||
|
||||
for url in bundleCandidates {
|
||||
if let data = dependencies.readData(url),
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
let arr = Self.parseCSV(text)
|
||||
if !arr.isEmpty { return arr }
|
||||
let entries = Self.validatedEntries(
|
||||
from: data,
|
||||
policy: dependencies.validationPolicy,
|
||||
minimumEntries: 1
|
||||
) {
|
||||
return entries
|
||||
}
|
||||
}
|
||||
|
||||
// Try filesystem path (development/test)
|
||||
if let cwd = dependencies.currentDirectoryPath(),
|
||||
let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
return Self.parseCSV(text)
|
||||
let entries = Self.validatedEntries(
|
||||
from: data,
|
||||
policy: dependencies.validationPolicy,
|
||||
minimumEntries: 1
|
||||
) {
|
||||
return entries
|
||||
}
|
||||
|
||||
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
|
||||
return []
|
||||
}
|
||||
|
||||
nonisolated static func parseCSV(_ text: String) -> [Entry] {
|
||||
var result: Set<Entry> = []
|
||||
let lines = text.split(whereSeparator: { $0.isNewline })
|
||||
for (idx, raw) in lines.enumerated() {
|
||||
guard let line = raw.trimmedOrNilIfEmpty else { continue }
|
||||
if idx == 0 && line.lowercased().contains("relay url") { continue }
|
||||
let parts = line.split(separator: ",").map { $0.trimmed }
|
||||
guard parts.count >= 3 else { continue }
|
||||
guard let host = NostrRelayURL.directoryAddress(parts[0]) else { continue }
|
||||
guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue }
|
||||
result.insert(Entry(host: host, lat: lat, lon: lon))
|
||||
/// Parses the fixed three-column format as an all-or-nothing trust unit.
|
||||
/// One malformed or conflicting row rejects the complete dataset rather
|
||||
/// than silently shrinking or partially replacing the current directory.
|
||||
nonisolated static func validatedEntries(
|
||||
from data: Data,
|
||||
policy: GeoRelayDirectoryValidationPolicy,
|
||||
minimumEntries: Int,
|
||||
baselineEntries: Set<Entry>? = nil
|
||||
) -> [Entry]? {
|
||||
guard !data.isEmpty, data.count <= policy.maximumBytes,
|
||||
let text = String(data: data, encoding: .utf8),
|
||||
!text.hasPrefix("\u{feff}") else {
|
||||
return nil
|
||||
}
|
||||
return Array(result)
|
||||
|
||||
let lines = text.split(whereSeparator: { $0.isNewline })
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
guard let header = lines.first,
|
||||
lines.count - 1 <= policy.maximumRows else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let headerParts = header
|
||||
.split(separator: ",", omittingEmptySubsequences: false)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
|
||||
let supportedHeaders = [
|
||||
["relay url", "latitude", "longitude"],
|
||||
["relay url", "lat", "lon"]
|
||||
]
|
||||
guard supportedHeaders.contains(headerParts) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var entriesByHost: [String: Entry] = [:]
|
||||
for line in lines.dropFirst() {
|
||||
let parts = line
|
||||
.split(separator: ",", omittingEmptySubsequences: false)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
guard parts.count == 3,
|
||||
let host = validatedDirectoryAddress(parts[0]),
|
||||
let latitude = Double(parts[1]), latitude.isFinite,
|
||||
(-90.0...90.0).contains(latitude),
|
||||
let longitude = Double(parts[2]), longitude.isFinite,
|
||||
(-180.0...180.0).contains(longitude) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let entry = Entry(host: host, lat: latitude, lon: longitude)
|
||||
if let existing = entriesByHost[host], existing != entry {
|
||||
// One endpoint cannot truthfully occupy two coordinates. Do
|
||||
// not let row ordering choose which location clients trust.
|
||||
return nil
|
||||
}
|
||||
entriesByHost[host] = entry
|
||||
guard entriesByHost.count <= policy.maximumEntries else { return nil }
|
||||
}
|
||||
|
||||
let parsedEntries = Set(entriesByHost.values)
|
||||
guard parsedEntries.count >= minimumEntries else { return nil }
|
||||
|
||||
if let baselineEntries {
|
||||
guard (0...1).contains(policy.minimumRetainedFraction) else { return nil }
|
||||
let requiredOverlap = Int(
|
||||
ceil(Double(baselineEntries.count) * policy.minimumRetainedFraction)
|
||||
)
|
||||
guard parsedEntries.intersection(baselineEntries).count >= requiredOverlap else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return parsedEntries.sorted {
|
||||
($0.host, $0.lat, $0.lon) < ($1.host, $1.lat, $1.lon)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func validatedDirectoryAddress(_ rawValue: String) -> String? {
|
||||
let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !value.isEmpty,
|
||||
value.unicodeScalars.allSatisfy({
|
||||
$0.isASCII && !CharacterSet.controlCharacters.contains($0)
|
||||
}) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let candidate = value.contains("://") ? value : "wss://\(value)"
|
||||
guard let components = URLComponents(string: candidate),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
scheme == "wss" || scheme == "https",
|
||||
components.user == nil,
|
||||
components.password == nil,
|
||||
components.query == nil,
|
||||
components.fragment == nil,
|
||||
components.path.isEmpty || components.path == "/",
|
||||
let rawHost = components.host else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let host = rawHost.lowercased()
|
||||
guard !host.isEmpty, host.count <= 253,
|
||||
host.unicodeScalars.allSatisfy({ $0.isASCII }),
|
||||
!host.hasSuffix("."),
|
||||
host != "localhost",
|
||||
!host.hasSuffix(".localhost"),
|
||||
!host.hasSuffix(".local"),
|
||||
!host.hasSuffix(".internal") else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let labels = host.split(separator: ".", omittingEmptySubsequences: false)
|
||||
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789-")
|
||||
guard labels.count >= 2,
|
||||
!labels.allSatisfy({ $0.allSatisfy(\.isNumber) }),
|
||||
labels.allSatisfy({ label in
|
||||
(1...63).contains(label.count) &&
|
||||
label.first != "-" &&
|
||||
label.last != "-" &&
|
||||
label.unicodeScalars.allSatisfy { allowed.contains($0) }
|
||||
}) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let port = components.port {
|
||||
guard (1...65_535).contains(port) else { return nil }
|
||||
if port != 443 { return "\(host):\(port)" }
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// MARK: - Observers & Timers
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import Foundation
|
||||
import P256K
|
||||
|
||||
/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
|
||||
/// Manages the secp256k1 identity used by BitChat's Nostr relay features,
|
||||
/// including the proprietary private-envelope transport.
|
||||
struct NostrIdentity: Codable {
|
||||
let privateKey: Data
|
||||
let publicKey: Data
|
||||
|
||||
@ -7,16 +7,26 @@ import Security
|
||||
// Note: This file depends on Data extension from BinaryEncodingUtils.swift
|
||||
// Make sure BinaryEncodingUtils.swift is included in the target
|
||||
|
||||
/// NIP-17 Protocol Implementation for Private Direct Messages
|
||||
/// BitChat's private-envelope protocol transported over Nostr relays.
|
||||
///
|
||||
/// This construction is deliberately BitChat-specific and is **not** NIP-17,
|
||||
/// NIP-44, or NIP-59 compatible, even though it historically reuses those
|
||||
/// NIPs' kind numbers (1059/13/14) and a `v2:` content prefix. It uses Nostr
|
||||
/// events and secp256k1 identities, but the XChaCha20-Poly1305 payload layout
|
||||
/// and key derivation are proprietary and interoperate only with BitChat
|
||||
/// clients.
|
||||
struct NostrProtocol {
|
||||
|
||||
|
||||
/// Nostr event kinds
|
||||
enum EventKind: Int {
|
||||
case metadata = 0
|
||||
case textNote = 1
|
||||
case dm = 14 // NIP-17 DM rumor kind
|
||||
case seal = 13 // NIP-17 sealed event
|
||||
case giftWrap = 1059 // NIP-59 gift wrap
|
||||
// BitChat's proprietary private-envelope layers. These reuse the
|
||||
// NIP-17/NIP-59 kind numbers (14/13/1059) for historical reasons, but
|
||||
// the encrypted payloads are BitChat-specific and not NIP-compatible.
|
||||
case dm = 14 // unsigned inner message (inside ciphertext)
|
||||
case seal = 13 // sender-signed seal (inside ciphertext)
|
||||
case giftWrap = 1059 // public outer envelope (one-time key)
|
||||
case ephemeralEvent = 20000
|
||||
case geohashPresence = 20001
|
||||
case deletion = 5 // NIP-09 event deletion request
|
||||
@ -25,29 +35,46 @@ struct NostrProtocol {
|
||||
/// its NIP-40 expiration — the whole point is store-and-forward.
|
||||
case courierDrop = 1401
|
||||
}
|
||||
|
||||
/// Create a NIP-17 private message
|
||||
|
||||
/// Bound work before Base64-decoding either encrypted layer of an inbound
|
||||
/// private envelope, and before parsing each decrypted nested JSON layer.
|
||||
/// Real envelopes are normally a few KiB; 64 KiB leaves ample headroom
|
||||
/// without letting an addressed relay event drive unbounded allocation.
|
||||
static let maximumPrivateEnvelopeCiphertextBytes = 64 * 1024
|
||||
|
||||
/// Create a BitChat private envelope for relay transport (outer kind 1059).
|
||||
static func createPrivateMessage(
|
||||
content: String,
|
||||
recipientPubkey: String,
|
||||
senderIdentity: NostrIdentity
|
||||
) throws -> NostrEvent {
|
||||
|
||||
// Creating private message
|
||||
|
||||
// 1. Create the rumor (unsigned event)
|
||||
try createPrivateMessage(
|
||||
content: content,
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderIdentity: senderIdentity,
|
||||
messageTags: []
|
||||
)
|
||||
}
|
||||
|
||||
private static func createPrivateMessage(
|
||||
content: String,
|
||||
recipientPubkey: String,
|
||||
senderIdentity: NostrIdentity,
|
||||
messageTags: [[String]]
|
||||
) throws -> NostrEvent {
|
||||
// 1. Create the rumor (unsigned inner event)
|
||||
let rumor = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .dm, // NIP-17: DM rumor kind 14
|
||||
tags: [],
|
||||
kind: .dm,
|
||||
tags: messageTags,
|
||||
content: content
|
||||
)
|
||||
|
||||
|
||||
// 2. Seal the rumor (encrypt to recipient) and sign it with the SENDER'S
|
||||
// real identity key. NIP-17 requires the seal be signed by the sender
|
||||
// so the recipient can authenticate who sent the message; signing with
|
||||
// a throwaway key leaves DMs forgeable/impersonatable.
|
||||
// real identity key so the recipient can authenticate who sent the
|
||||
// message; signing with a throwaway key leaves DMs
|
||||
// forgeable/impersonatable.
|
||||
let senderKey = try senderIdentity.schnorrSigningKey()
|
||||
let sealedEvent = try createSeal(
|
||||
rumor: rumor,
|
||||
@ -55,28 +82,39 @@ struct NostrProtocol {
|
||||
senderKey: senderKey
|
||||
)
|
||||
|
||||
// 3. Gift wrap the sealed event with a throwaway ephemeral key (the wrap
|
||||
// 3. Wrap the sealed event with a throwaway ephemeral key (the wrap
|
||||
// layer hides the sender's identity from relays; createGiftWrap mints
|
||||
// its own ephemeral key internally).
|
||||
let giftWrap = try createGiftWrap(
|
||||
seal: sealedEvent,
|
||||
recipientPubkey: recipientPubkey
|
||||
)
|
||||
|
||||
// Created gift wrap
|
||||
|
||||
|
||||
return giftWrap
|
||||
}
|
||||
|
||||
/// Decrypt a received NIP-17 message
|
||||
/// Returns the content, sender pubkey, and the actual message timestamp (not the randomized gift wrap timestamp)
|
||||
|
||||
/// Decrypt a received BitChat private envelope.
|
||||
/// Returns the content, sender pubkey, and the actual message timestamp (not the randomized outer timestamp)
|
||||
static func decryptPrivateMessage(
|
||||
giftWrap: NostrEvent,
|
||||
recipientIdentity: NostrIdentity
|
||||
) throws -> (content: String, senderPubkey: String, timestamp: Int) {
|
||||
|
||||
// Starting decryption
|
||||
|
||||
|
||||
// 0. Validate the untrusted outer envelope before any decryption work.
|
||||
// Every BitChat client (released iOS and current Android) publishes
|
||||
// exactly one outer recipient `p` tag on a validly signed kind-1059
|
||||
// wrap; anything else is malformed or misbound.
|
||||
guard giftWrap.content.utf8.count <= maximumPrivateEnvelopeCiphertextBytes else {
|
||||
SecureLogger.error("❌ Rejecting DM: oversized outer envelope ciphertext", category: .session)
|
||||
throw NostrError.invalidCiphertext
|
||||
}
|
||||
guard giftWrap.kind == EventKind.giftWrap.rawValue,
|
||||
giftWrap.tags == [["p", recipientIdentity.publicKeyHex]],
|
||||
giftWrap.isValidSignature() else {
|
||||
SecureLogger.error("❌ Rejecting DM: malformed or misbound outer envelope", category: .session)
|
||||
throw NostrError.invalidEvent
|
||||
}
|
||||
|
||||
// 1. Unwrap the gift wrap
|
||||
let seal: NostrEvent
|
||||
do {
|
||||
@ -91,10 +129,13 @@ struct NostrProtocol {
|
||||
}
|
||||
|
||||
// 2. Authenticate the seal. The seal MUST be signed by the sender's real
|
||||
// identity key (NIP-17); without this check a DM is forgeable by anyone
|
||||
// who knows the recipient's npub. Verify the seal's own signature.
|
||||
guard seal.isValidSignature() else {
|
||||
SecureLogger.error("❌ Rejecting DM: seal signature is missing or invalid", category: .session)
|
||||
// identity key; without this check a DM is forgeable by anyone who
|
||||
// knows the recipient's npub. Every BitChat sender emits a tagless
|
||||
// kind-13 seal, so bind the decrypted layer to that exact shape.
|
||||
guard seal.kind == EventKind.seal.rawValue,
|
||||
seal.tags.isEmpty,
|
||||
seal.isValidSignature() else {
|
||||
SecureLogger.error("❌ Rejecting DM: seal is malformed or its signature is missing/invalid", category: .session)
|
||||
throw NostrError.invalidEvent
|
||||
}
|
||||
|
||||
@ -111,11 +152,16 @@ struct NostrProtocol {
|
||||
throw error
|
||||
}
|
||||
|
||||
// 4. The sender claimed inside the rumor must match the key that actually
|
||||
// signed the seal, otherwise the sender field is unauthenticated and
|
||||
// spoofable.
|
||||
guard seal.pubkey == rumor.pubkey else {
|
||||
SecureLogger.error("❌ Rejecting DM: rumor pubkey does not match seal signer", category: .session)
|
||||
// 4. The rumor is intentionally unsigned; sender authentication comes
|
||||
// from the seal. The sender claimed inside the rumor must match the
|
||||
// key that actually signed the seal, otherwise the sender field is
|
||||
// unauthenticated and spoofable. Also bind the inner kind and tag
|
||||
// shape to what BitChat clients actually emit.
|
||||
guard rumor.kind == EventKind.dm.rawValue,
|
||||
validInnerMessageTags(rumor.tags, recipientPubkey: recipientIdentity.publicKeyHex),
|
||||
rumor.sig == nil,
|
||||
seal.pubkey == rumor.pubkey else {
|
||||
SecureLogger.error("❌ Rejecting DM: rumor is malformed or does not match seal signer", category: .session)
|
||||
throw NostrError.invalidEvent
|
||||
}
|
||||
|
||||
@ -123,6 +169,17 @@ struct NostrProtocol {
|
||||
return (content: rumor.content, senderPubkey: seal.pubkey, timestamp: rumor.created_at)
|
||||
}
|
||||
|
||||
/// Released iOS envelopes use no inner tags, while current Android
|
||||
/// envelopes place exactly the authenticated recipient's `p` tag on the
|
||||
/// unsigned inner event. Accept only those two historical shapes;
|
||||
/// alternate recipients, duplicate tags, and extra tags are rejected.
|
||||
private static func validInnerMessageTags(
|
||||
_ tags: [[String]],
|
||||
recipientPubkey: String
|
||||
) -> Bool {
|
||||
tags.isEmpty || tags == [["p", recipientPubkey]]
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
static func createPrivateMessageWithInvalidSealSignatureForTesting(
|
||||
content: String,
|
||||
@ -165,6 +222,23 @@ struct NostrProtocol {
|
||||
)
|
||||
return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey)
|
||||
}
|
||||
|
||||
/// Reproduces historical wire shapes (current Android places exactly one
|
||||
/// recipient `p` tag on the unsigned inner event) without making the
|
||||
/// production encoder depend on that quirk.
|
||||
static func createPrivateMessageWithInnerTagsForTesting(
|
||||
content: String,
|
||||
recipientPubkey: String,
|
||||
senderIdentity: NostrIdentity,
|
||||
innerMessageTags: [[String]]
|
||||
) throws -> NostrEvent {
|
||||
try createPrivateMessage(
|
||||
content: content,
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderIdentity: senderIdentity,
|
||||
messageTags: innerMessageTags
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Create a geohash-scoped ephemeral public message (kind 20000)
|
||||
@ -468,14 +542,19 @@ struct NostrProtocol {
|
||||
recipientKey: recipientKey
|
||||
)
|
||||
|
||||
// Check UTF-8 size before allocating Data or invoking the general
|
||||
// JSON parser on attacker-influenced plaintext.
|
||||
guard decrypted.utf8.count <= maximumPrivateEnvelopeCiphertextBytes else {
|
||||
throw NostrError.invalidCiphertext
|
||||
}
|
||||
guard let data = decrypted.data(using: .utf8),
|
||||
let sealDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw NostrError.invalidEvent
|
||||
}
|
||||
|
||||
|
||||
let seal = try NostrEvent(from: sealDict)
|
||||
// Unwrapped seal
|
||||
|
||||
|
||||
return seal
|
||||
}
|
||||
|
||||
@ -490,16 +569,23 @@ struct NostrProtocol {
|
||||
recipientKey: recipientKey
|
||||
)
|
||||
|
||||
guard decrypted.utf8.count <= maximumPrivateEnvelopeCiphertextBytes else {
|
||||
throw NostrError.invalidCiphertext
|
||||
}
|
||||
guard let data = decrypted.data(using: .utf8),
|
||||
let rumorDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw NostrError.invalidEvent
|
||||
}
|
||||
|
||||
|
||||
return try NostrEvent(from: rumorDict)
|
||||
}
|
||||
|
||||
// MARK: - Encryption (NIP-44 v2)
|
||||
|
||||
|
||||
// MARK: - BitChat private-envelope encryption
|
||||
//
|
||||
// Not NIP-44: the `v2:` prefix, base64url(nonce24 || ciphertext || tag)
|
||||
// layout, XChaCha20-Poly1305 cipher, and HKDF parameters are all
|
||||
// BitChat-specific.
|
||||
|
||||
private static func encrypt(
|
||||
plaintext: String,
|
||||
recipientPubkey: String,
|
||||
@ -510,22 +596,25 @@ struct NostrProtocol {
|
||||
throw NostrError.invalidPublicKey
|
||||
}
|
||||
|
||||
// Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned)
|
||||
|
||||
// Derive shared secret
|
||||
let sharedSecret = try deriveSharedSecret(
|
||||
privateKey: senderKey,
|
||||
publicKey: recipientPubkeyData
|
||||
)
|
||||
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info)
|
||||
let key = try deriveNIP44V2Key(from: sharedSecret)
|
||||
|
||||
// Derive the BitChat private-envelope symmetric key (HKDF-SHA256)
|
||||
let key = try derivePrivateEnvelopeKey(from: sharedSecret)
|
||||
|
||||
// 24-byte random nonce for XChaCha20-Poly1305
|
||||
var nonce24 = Data(count: 24)
|
||||
_ = nonce24.withUnsafeMutableBytes { ptr in
|
||||
let randomStatus = nonce24.withUnsafeMutableBytes { ptr in
|
||||
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
|
||||
}
|
||||
|
||||
// Never encrypt with an unrandomized nonce: nonce reuse under the same
|
||||
// key breaks XChaCha20-Poly1305 confidentiality and authenticity.
|
||||
guard randomStatus == errSecSuccess else {
|
||||
throw NostrError.cryptographicFailure
|
||||
}
|
||||
|
||||
let pt = Data(plaintext.utf8)
|
||||
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24)
|
||||
|
||||
@ -542,8 +631,12 @@ struct NostrProtocol {
|
||||
senderPubkey: String,
|
||||
recipientKey: P256K.Schnorr.PrivateKey
|
||||
) throws -> String {
|
||||
// Expect NIP-44 v2 format
|
||||
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
|
||||
// Expect BitChat's historical `v2:` private-envelope framing, and
|
||||
// bound work before Base64 decoding attacker-sized input.
|
||||
guard ciphertext.utf8.count <= maximumPrivateEnvelopeCiphertextBytes,
|
||||
ciphertext.hasPrefix("v2:") else {
|
||||
throw NostrError.invalidCiphertext
|
||||
}
|
||||
let encoded = String(ciphertext.dropFirst(3))
|
||||
guard let data = Base64URLCoding.decode(encoded),
|
||||
data.count > (24 + 16),
|
||||
@ -559,7 +652,7 @@ struct NostrProtocol {
|
||||
// Try decryption with even-Y then odd-Y when sender pubkey is x-only
|
||||
func attemptDecrypt(using pubKeyData: Data) throws -> Data {
|
||||
let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData)
|
||||
let key = try deriveNIP44V2Key(from: ss)
|
||||
let key = try derivePrivateEnvelopeKey(from: ss)
|
||||
return try XChaCha20Poly1305Compat.open(
|
||||
ciphertext: Data(ct),
|
||||
tag: Data(tag),
|
||||
@ -569,18 +662,25 @@ struct NostrProtocol {
|
||||
}
|
||||
|
||||
// If 32 bytes (x-only) try both parities, otherwise single try
|
||||
let plaintext: Data
|
||||
if senderPubkeyData.count == 32 {
|
||||
let even = Data([0x02]) + senderPubkeyData
|
||||
if let pt = try? attemptDecrypt(using: even) {
|
||||
return String(data: pt, encoding: .utf8) ?? ""
|
||||
plaintext = pt
|
||||
} else {
|
||||
let odd = Data([0x03]) + senderPubkeyData
|
||||
plaintext = try attemptDecrypt(using: odd)
|
||||
}
|
||||
let odd = Data([0x03]) + senderPubkeyData
|
||||
let pt = try attemptDecrypt(using: odd)
|
||||
return String(data: pt, encoding: .utf8) ?? ""
|
||||
} else {
|
||||
let pt = try attemptDecrypt(using: senderPubkeyData)
|
||||
return String(data: pt, encoding: .utf8) ?? ""
|
||||
plaintext = try attemptDecrypt(using: senderPubkeyData)
|
||||
}
|
||||
|
||||
// Authenticated plaintext that is not valid UTF-8 is a malformed
|
||||
// envelope, not an empty message.
|
||||
guard let decoded = String(data: plaintext, encoding: .utf8) else {
|
||||
throw NostrError.invalidCiphertext
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
private static func deriveSharedSecret(
|
||||
@ -640,7 +740,8 @@ struct NostrProtocol {
|
||||
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
||||
// ECDH shared secret derived
|
||||
|
||||
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
|
||||
// Return raw ECDH shared secret; HKDF is applied by
|
||||
// derivePrivateEnvelopeKey
|
||||
return sharedSecretData
|
||||
}
|
||||
|
||||
@ -700,6 +801,10 @@ struct NostrEvent: Codable {
|
||||
let content = dict["content"] as? String else {
|
||||
throw NostrError.invalidEvent
|
||||
}
|
||||
|
||||
guard Self.isWithinInboundTagLimits(tags) else {
|
||||
throw NostrError.invalidEvent
|
||||
}
|
||||
|
||||
self.id = dict["id"] as? String ?? ""
|
||||
self.pubkey = pubkey
|
||||
@ -709,6 +814,21 @@ struct NostrEvent: Codable {
|
||||
self.content = content
|
||||
self.sig = dict["sig"] as? String
|
||||
}
|
||||
|
||||
/// Bounds untrusted relay tag arrays so attackers cannot force large
|
||||
/// allocations or expensive joins on the inbound hot path.
|
||||
static func isWithinInboundTagLimits(_ tags: [[String]]) -> Bool {
|
||||
guard tags.count <= TransportConfig.nostrMaxEventTags else { return false }
|
||||
|
||||
for tag in tags {
|
||||
guard tag.count <= TransportConfig.nostrMaxEventTagValues else { return false }
|
||||
guard tag.allSatisfy({ $0.utf8.count <= TransportConfig.nostrMaxEventTagValueBytes }) else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent {
|
||||
let (eventId, eventIdHash) = try calculateEventId()
|
||||
@ -775,12 +895,17 @@ enum NostrError: Error {
|
||||
case invalidPublicKey
|
||||
case invalidEvent
|
||||
case invalidCiphertext
|
||||
case cryptographicFailure
|
||||
}
|
||||
|
||||
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
|
||||
// MARK: - BitChat private-envelope key derivation
|
||||
|
||||
private extension NostrProtocol {
|
||||
static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data {
|
||||
/// The HKDF info string retains the historical "nip44-v2" label for wire
|
||||
/// compatibility with deployed clients, but this is not the NIP-44 key
|
||||
/// schedule: NIP-44 derives a conversation key via HKDF-extract with that
|
||||
/// label as the *salt* and uses ChaCha20 with per-message expanded keys.
|
||||
static func derivePrivateEnvelopeKey(from sharedSecretData: Data) throws -> Data {
|
||||
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
||||
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
||||
salt: Data(),
|
||||
|
||||
@ -48,7 +48,14 @@ private struct URLSessionAdapter: NostrRelaySessionProtocol {
|
||||
let base: URLSession
|
||||
|
||||
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol {
|
||||
URLSessionWebSocketTaskAdapter(base: base.webSocketTask(with: url))
|
||||
let task = base.webSocketTask(with: url)
|
||||
// Byte bound per inbound frame; without it the per-relay buffer cap
|
||||
// (nostrInboundPerRelayBufferCap) bounds FRAMES but not BYTES, and a
|
||||
// hostile relay could pile up cap × 1 MiB (URLSession default) per
|
||||
// connection. See TransportConfig.nostrInboundMaxFrameBytes for the
|
||||
// sizing rationale. Oversized frames fail the receive with an error.
|
||||
task.maximumMessageSize = TransportConfig.nostrInboundMaxFrameBytes
|
||||
return URLSessionWebSocketTaskAdapter(base: task)
|
||||
}
|
||||
}
|
||||
|
||||
@ -69,6 +76,18 @@ struct NostrRelayManagerDependencies {
|
||||
/// Uniform random value in [0, 1) used to jitter reconnect backoff.
|
||||
/// Injectable so tests can pin or sweep the jitter deterministically.
|
||||
var jitterUnit: () -> Double
|
||||
/// Where relay-settings changes are observed. Injectable so a test can use
|
||||
/// its own center instead of racing the process-wide one.
|
||||
var notificationCenter: NotificationCenter = .default
|
||||
/// Relays added by hand, merged with the built-in set. Injectable so tests
|
||||
/// do not have to write to shared preferences.
|
||||
var customRelays: () -> [String] = { NostrRelaySettings.customRelays() }
|
||||
/// Whether a location channel is currently open. Mirrors the third arm of
|
||||
/// `NetworkActivationService`'s gate: teleporting into a geohash needs no
|
||||
/// location permission, and without this the relays would stay filtered out
|
||||
/// for someone who denied location and has no mutual favorites.
|
||||
var isInLocationChannel: () -> Bool = { false }
|
||||
var selectedChannelPublisher: AnyPublisher<ChannelID, Never> = Empty().eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
private extension NostrRelayManagerDependencies {
|
||||
@ -97,7 +116,12 @@ private extension NostrRelayManagerDependencies {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
||||
},
|
||||
now: Date.init,
|
||||
jitterUnit: { Double.random(in: 0..<1) }
|
||||
jitterUnit: { Double.random(in: 0..<1) },
|
||||
isInLocationChannel: {
|
||||
if case .location = LocationChannelManager.shared.selectedChannel { return true }
|
||||
return false
|
||||
},
|
||||
selectedChannelPublisher: LocationChannelManager.shared.$selectedChannel.eraseToAnyPublisher()
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -106,7 +130,10 @@ private extension NostrRelayManagerDependencies {
|
||||
@MainActor
|
||||
final class NostrRelayManager: ObservableObject {
|
||||
static let shared = NostrRelayManager()
|
||||
// Track gift-wraps (kind 1059) we initiated so we can log OK acks at info
|
||||
// Track gift-wraps (kind 1059) we initiated so we can log OK acks at info.
|
||||
// Entries are removed only on OK acks (or panic wipe); relays that never
|
||||
// ack leave entries behind for the process lifetime. Observability-only
|
||||
// state, bounded in practice by outbound DM volume.
|
||||
private(set) static var pendingGiftWrapIDs = Set<String>()
|
||||
static func registerPendingGiftWrap(id: String) {
|
||||
pendingGiftWrapIDs.insert(id)
|
||||
@ -124,16 +151,41 @@ final class NostrRelayManager: ObservableObject {
|
||||
var nextReconnectTime: Date?
|
||||
}
|
||||
|
||||
// Default relays carry NIP-17 gift wraps, so avoid relays known to reject kind 1059.
|
||||
private static let defaultRelays = [
|
||||
// Built-in relays carry private-message envelopes, so avoid relays known to
|
||||
// reject the kinds they use.
|
||||
nonisolated private static let builtInRelays = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.primal.net",
|
||||
"wss://offchain.pub"
|
||||
// For local testing, you can add: "ws://localhost:8080"
|
||||
]
|
||||
private static let defaultRelaySet = Set(defaultRelays.compactMap { NostrRelayURL.normalized($0) })
|
||||
|
||||
/// Exposed so the relay settings UI can reject re-adding a built-in.
|
||||
/// `nonisolated` because it is an immutable constant with no actor state.
|
||||
nonisolated static let builtInRelayURLs = Set(
|
||||
builtInRelays.compactMap { NostrRelayURL.normalized($0) }
|
||||
)
|
||||
|
||||
/// The relays private messages target: the built-in set plus any added by
|
||||
/// hand. Four hardcoded hostnames are four names for a censor to block, so
|
||||
/// the added ones are what keeps this reachable without a new build.
|
||||
///
|
||||
/// Cached rather than computed per access: `allowedRelayList` consults the
|
||||
/// set once per candidate URL, and recomputing would mean a `UserDefaults`
|
||||
/// read and a fresh normalize-and-dedupe pass inside that loop. Refreshed
|
||||
/// from `reloadDefaultRelays()` on construction and whenever the relay
|
||||
/// settings change.
|
||||
private var defaultRelays: [String] = []
|
||||
private var defaultRelaySet: Set<String> = []
|
||||
|
||||
private func reloadDefaultRelays() {
|
||||
var seen = Set<String>()
|
||||
defaultRelays = (Self.builtInRelays + dependencies.customRelays())
|
||||
.compactMap { NostrRelayURL.normalized($0) }
|
||||
.filter { seen.insert($0).inserted }
|
||||
defaultRelaySet = Set(defaultRelays)
|
||||
}
|
||||
|
||||
@Published private(set) var relays: [Relay] = []
|
||||
@Published private(set) var isConnected = false
|
||||
/// Whether a relay that carries private messages is connected. DMs
|
||||
@ -217,7 +269,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
private var messageQueue: [PendingSend] = []
|
||||
private let messageQueueLock = NSLock()
|
||||
/// Non-queued sends whose callers require relay durability. A WebSocket
|
||||
/// write only proves bytes left this process; NIP-20 OK is the relay's
|
||||
/// write only proves bytes left this process; NIP-01 `OK` is the relay's
|
||||
/// accept/reject acknowledgment.
|
||||
private struct ConfirmedSendState {
|
||||
let token: UUID
|
||||
@ -239,38 +291,42 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
// Bump generation to invalidate scheduled reconnects when we reset/disconnect
|
||||
private var connectionGeneration: Int = 0
|
||||
|
||||
init() {
|
||||
self.dependencies = .live()
|
||||
hasMutualFavorites = dependencies.hasMutualFavorites()
|
||||
hasLocationPermission = dependencies.hasLocationPermission()
|
||||
applyDefaultRelayPolicy(force: true)
|
||||
// Deterministic JSON shape for outbound requests
|
||||
self.encoder.outputFormatting = .sortedKeys
|
||||
dependencies.mutualFavoritesPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] favorites in
|
||||
guard let self = self else { return }
|
||||
self.hasMutualFavorites = !favorites.isEmpty
|
||||
self.applyDefaultRelayPolicy()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
dependencies.locationPermissionPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] state in
|
||||
guard let self = self else { return }
|
||||
let authorized = (state == .authorized)
|
||||
if authorized == self.hasLocationPermission { return }
|
||||
self.hasLocationPermission = authorized
|
||||
self.applyDefaultRelayPolicy()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Per-relay off-main inbound pipeline: raw socket frames are parsed and
|
||||
// Schnorr-verified in arrival order OFF the main actor (this is the single
|
||||
// signature verification for the whole inbound path — downstream handlers
|
||||
// receive only verified events), then hop back to the main actor for dedup
|
||||
// recording and handler dispatch.
|
||||
//
|
||||
// Each relay connection owns its OWN AsyncStream + consumer task, so N
|
||||
// relays verify in parallel while every relay's frames stay in arrival
|
||||
// order (a single subscription's events for a relay all arrive on that
|
||||
// relay's socket, so per-relay ordering preserves per-subscription
|
||||
// ordering). A burst of EVENT frames from one busy/malicious relay only
|
||||
// blocks that relay's own verification backlog — DMs, OKs, EOSEs, and
|
||||
// events from every other relay keep flowing on their own pipelines.
|
||||
//
|
||||
// Each stream is bounded (`.bufferingNewest`) so a relay flooding faster
|
||||
// than its verification drains sheds its own oldest frames instead of
|
||||
// growing memory without bound; it can never starve other relays.
|
||||
//
|
||||
// Continuations live in a lock-guarded, `Sendable` router (see
|
||||
// `InboundFrameRouter` at file scope) so the raw socket receive callback
|
||||
// (which is NOT main-actor isolated) can route a frame to the right relay
|
||||
// stream without a per-frame main hop, while the main actor owns pipeline
|
||||
// creation/teardown. The expensive work (Schnorr verify) is what runs
|
||||
// off-main; the yield stays cheap.
|
||||
private let inboundRouter = InboundFrameRouter()
|
||||
|
||||
convenience init() {
|
||||
self.init(dependencies: .live())
|
||||
}
|
||||
|
||||
internal init(dependencies: NostrRelayManagerDependencies) {
|
||||
self.dependencies = dependencies
|
||||
hasMutualFavorites = dependencies.hasMutualFavorites()
|
||||
hasLocationPermission = dependencies.hasLocationPermission()
|
||||
reloadDefaultRelays()
|
||||
applyDefaultRelayPolicy(force: true)
|
||||
// Deterministic JSON shape for outbound requests
|
||||
self.encoder.outputFormatting = .sortedKeys
|
||||
@ -292,8 +348,93 @@ final class NostrRelayManager: ObservableObject {
|
||||
self.applyDefaultRelayPolicy()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
dependencies.selectedChannelPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.applyDefaultRelayPolicy()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
// Adding or removing a relay by hand changes the target set, so
|
||||
// reconcile connections now rather than at the next send.
|
||||
dependencies.notificationCenter
|
||||
.publisher(for: NostrRelaySettings.didChangeNotification)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
guard let self else { return }
|
||||
// Reconcile against the previous set: a removed relay is no
|
||||
// longer in `defaultRelays`, so nothing downstream would ever
|
||||
// close its socket or drop its queued sends.
|
||||
let previous = self.defaultRelaySet
|
||||
self.reloadDefaultRelays()
|
||||
self.dropRelays(previous.subtracting(self.defaultRelaySet))
|
||||
self.applyDefaultRelayPolicy(force: true)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
|
||||
deinit {
|
||||
inboundRouter.finishAll()
|
||||
}
|
||||
|
||||
/// Ensure a serial off-main consumer pipeline exists for a relay. Called on
|
||||
/// the main actor when a socket is (re)armed for receiving. Idempotent.
|
||||
///
|
||||
/// Ordering within the relay is deliberate and security/performance-critical:
|
||||
/// 1. `precheckInboundEvent` (main hop): per-relay stats plus a cheap
|
||||
/// duplicate LOOKUP — duplicate fan-in from multiple relays dominates
|
||||
/// real traffic and must never pay for Schnorr verification.
|
||||
/// 2. `isValidSignature()` runs here, off the main actor — the ONLY
|
||||
/// signature verification on the inbound path (JSON re-serialization +
|
||||
/// SHA-256 + secp256k1 Schnorr per event).
|
||||
/// 3. `deliverVerifiedInboundEvent` (main hop): authoritative
|
||||
/// check-and-RECORD plus handler dispatch. Recording only after
|
||||
/// verification means a forged-signature copy can never poison the
|
||||
/// dedup cache and suppress the genuine event.
|
||||
private func ensureRelayInboundPipeline(for relayUrl: String) {
|
||||
let started = inboundRouter.startPipeline(for: relayUrl) { [weak self] stream in
|
||||
Task.detached(priority: .userInitiated) {
|
||||
for await frame in stream {
|
||||
guard let parsed = ParsedInbound(frame.message) else { continue }
|
||||
guard let self else { return }
|
||||
switch parsed {
|
||||
case .event(let subId, let event):
|
||||
guard await self.precheckInboundEvent(
|
||||
subscriptionID: subId,
|
||||
eventID: event.id,
|
||||
relayUrl: relayUrl
|
||||
) else {
|
||||
continue
|
||||
}
|
||||
guard event.isValidSignature() else {
|
||||
SecureLogger.warning(
|
||||
"⚠️ Dropped invalid Nostr event id=\(event.id.prefix(16))… sub=\(subId) relay=\(relayUrl)",
|
||||
category: .session
|
||||
)
|
||||
continue
|
||||
}
|
||||
await self.deliverVerifiedInboundEvent(subscriptionID: subId, event: event, from: relayUrl)
|
||||
case .eose, .ok, .notice:
|
||||
await self.handleParsedMessage(parsed, from: relayUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if started {
|
||||
SecureLogger.debug("🧵 Started inbound verify pipeline for \(relayUrl)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
/// Tear down a relay's inbound pipeline (socket gone or state wiped). The
|
||||
/// consumer drains any already-buffered frames before finishing, so
|
||||
/// in-flight verified events are still delivered.
|
||||
private func teardownRelayInboundPipeline(for relayUrl: String) {
|
||||
inboundRouter.finishPipeline(for: relayUrl)
|
||||
}
|
||||
|
||||
private func teardownAllRelayInboundPipelines() {
|
||||
inboundRouter.finishAll()
|
||||
}
|
||||
|
||||
/// Connect to all configured relays
|
||||
func connect() {
|
||||
// Global network policy gate
|
||||
@ -308,6 +449,8 @@ final class NostrRelayManager: ObservableObject {
|
||||
task.cancel(with: .goingAway, reason: nil)
|
||||
}
|
||||
connections.removeAll()
|
||||
// Sockets are gone; drop every relay's inbound verify pipeline.
|
||||
teardownAllRelayInboundPipelines()
|
||||
markRelaySocketsClosed(resetState: false)
|
||||
// Sockets are gone, so per-relay subscription state is cleared — but
|
||||
// durable intent (subscriptionRequestState, messageHandlers, parked
|
||||
@ -340,6 +483,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
task.cancel(with: .goingAway, reason: nil)
|
||||
}
|
||||
connections.removeAll()
|
||||
teardownAllRelayInboundPipelines()
|
||||
markRelaySocketsClosed(resetState: true)
|
||||
subscriptions.removeAll()
|
||||
pendingSubscriptions.removeAll()
|
||||
@ -407,13 +551,13 @@ final class NostrRelayManager: ObservableObject {
|
||||
// event locally so it survives a slow bootstrap (queued sends flush
|
||||
// when relays connect), then kick off connection setup, which itself
|
||||
// waits for Tor readiness.
|
||||
let targetRelays = allowedRelayList(from: relayUrls ?? Self.defaultRelays)
|
||||
let targetRelays = allowedRelayList(from: relayUrls ?? defaultRelays)
|
||||
guard !targetRelays.isEmpty else { return }
|
||||
enqueuePendingSend(event, pendingRelays: Set(targetRelays))
|
||||
ensureConnections(to: targetRelays)
|
||||
return
|
||||
}
|
||||
let requestedRelays = relayUrls ?? Self.defaultRelays
|
||||
let requestedRelays = relayUrls ?? defaultRelays
|
||||
let targetRelays = allowedRelayList(from: requestedRelays)
|
||||
guard !targetRelays.isEmpty else { return }
|
||||
ensureConnections(to: targetRelays)
|
||||
@ -433,8 +577,8 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
|
||||
/// Attempts an event only on currently connected target relays and
|
||||
/// reports whether at least one relay explicitly accepted it via NIP-20
|
||||
/// OK. A successful WebSocket write alone is not durable acceptance.
|
||||
/// reports whether at least one relay explicitly accepted it via NIP-01
|
||||
/// `OK`. A successful WebSocket write alone is not durable acceptance.
|
||||
/// Unlike `sendEvent`, this never enters the process-local pending queue;
|
||||
/// callers use it when success unlocks durable state or user-visible
|
||||
/// delivery progress.
|
||||
@ -452,7 +596,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
let requestedRelays = relayUrls ?? Self.defaultRelays
|
||||
let requestedRelays = relayUrls ?? defaultRelays
|
||||
let targetRelays = allowedRelayList(from: requestedRelays)
|
||||
let connectedTargets = targetRelays.compactMap { relayUrl -> (String, NostrRelayConnectionProtocol)? in
|
||||
guard let connection = connectedConnection(for: relayUrl) else { return nil }
|
||||
@ -621,7 +765,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
// SecureLogger.debug("📋 Subscription filter JSON: \(messageString.prefix(200))...", category: .session)
|
||||
|
||||
// Target specific relays if provided; else default. Filter permanently failed relays.
|
||||
let baseUrls = relayUrls ?? Self.defaultRelays
|
||||
let baseUrls = relayUrls ?? defaultRelays
|
||||
let urls = allowedRelayList(from: baseUrls).filter { !isPermanentlyFailed($0) }
|
||||
let requestState = SubscriptionRequestState(messageString: messageString, relayURLs: Set(urls))
|
||||
if subscriptionRequestState[id] == requestState, subscriptionStateExists(id: id, requestState: requestState) {
|
||||
@ -663,49 +807,60 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
|
||||
private func applyDefaultRelayPolicy(force: Bool = false) {
|
||||
let shouldAllow = hasMutualFavorites || hasLocationPermission
|
||||
let shouldAllow = hasMutualFavorites || hasLocationPermission || dependencies.isInLocationChannel()
|
||||
if !force && shouldAllow == allowDefaultRelays { return }
|
||||
allowDefaultRelays = shouldAllow
|
||||
if shouldAllow {
|
||||
var existing = Set(relays.map { $0.url })
|
||||
for url in Self.defaultRelays where !existing.contains(url) {
|
||||
for url in defaultRelays where !existing.contains(url) {
|
||||
relays.append(Relay(url: url))
|
||||
existing.insert(url)
|
||||
}
|
||||
if dependencies.activationAllowed() {
|
||||
ensureConnections(to: Self.defaultRelays)
|
||||
ensureConnections(to: defaultRelays)
|
||||
}
|
||||
} else {
|
||||
for url in Self.defaultRelays {
|
||||
if let connection = connections[url] {
|
||||
connection.cancel(with: .goingAway, reason: nil)
|
||||
}
|
||||
connections.removeValue(forKey: url)
|
||||
subscriptions.removeValue(forKey: url)
|
||||
pendingSubscriptions.removeValue(forKey: url)
|
||||
}
|
||||
messageQueueLock.lock()
|
||||
for index in (0..<messageQueue.count).reversed() {
|
||||
var item = messageQueue[index]
|
||||
item.pendingRelays.subtract(Self.defaultRelaySet)
|
||||
if item.pendingRelays.isEmpty {
|
||||
messageQueue.remove(at: index)
|
||||
} else {
|
||||
messageQueue[index] = item
|
||||
}
|
||||
}
|
||||
messageQueueLock.unlock()
|
||||
relays.removeAll { Self.defaultRelaySet.contains($0.url) }
|
||||
updateConnectionStatus()
|
||||
dropRelays(defaultRelaySet)
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes and forgets a set of relays: connection, inbound pipeline,
|
||||
/// subscriptions, queued sends addressed only to them, and the published row.
|
||||
private func dropRelays(_ urls: Set<String>) {
|
||||
guard !urls.isEmpty else { return }
|
||||
for url in urls {
|
||||
if let connection = connections[url] {
|
||||
connection.cancel(with: .goingAway, reason: nil)
|
||||
}
|
||||
connections.removeValue(forKey: url)
|
||||
teardownRelayInboundPipeline(for: url)
|
||||
subscriptions.removeValue(forKey: url)
|
||||
pendingSubscriptions.removeValue(forKey: url)
|
||||
}
|
||||
messageQueueLock.lock()
|
||||
for index in (0..<messageQueue.count).reversed() {
|
||||
var item = messageQueue[index]
|
||||
item.pendingRelays.subtract(urls)
|
||||
if item.pendingRelays.isEmpty {
|
||||
messageQueue.remove(at: index)
|
||||
} else {
|
||||
messageQueue[index] = item
|
||||
}
|
||||
}
|
||||
messageQueueLock.unlock()
|
||||
// A relay queued while Tor bootstraps would otherwise reconnect when
|
||||
// the queue drains, overriding the explicit removal.
|
||||
pendingTorConnectionURLs.subtract(urls)
|
||||
relays.removeAll { urls.contains($0.url) }
|
||||
updateConnectionStatus()
|
||||
}
|
||||
|
||||
private func allowedRelayList(from urls: [String]) -> [String] {
|
||||
var seen = Set<String>()
|
||||
var result: [String] = []
|
||||
for rawURL in urls {
|
||||
guard let url = NostrRelayURL.normalized(rawURL) else { continue }
|
||||
if !allowDefaultRelays && Self.defaultRelaySet.contains(url) { continue }
|
||||
if !allowDefaultRelays && defaultRelaySet.contains(url) { continue }
|
||||
if seen.insert(url).inserted {
|
||||
result.append(url)
|
||||
}
|
||||
@ -1020,7 +1175,11 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
connections[urlString] = task
|
||||
task.resume()
|
||||
|
||||
|
||||
// Bring up this relay's own serial verify pipeline before arming the
|
||||
// socket, so inbound frames have somewhere to land.
|
||||
ensureRelayInboundPipeline(for: urlString)
|
||||
|
||||
// Start receiving messages
|
||||
receiveMessage(from: task, relayUrl: urlString)
|
||||
|
||||
@ -1095,15 +1254,14 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
switch result {
|
||||
case .success(let message):
|
||||
// Parse off-main to reduce UI jank, then hop back for state updates
|
||||
Task.detached(priority: .utility) {
|
||||
guard let parsed = ParsedInbound(message) else { return }
|
||||
await MainActor.run {
|
||||
guard self.connections[relayUrl] === task else { return }
|
||||
self.handleParsedMessage(parsed, from: relayUrl)
|
||||
}
|
||||
}
|
||||
|
||||
// Hand the raw frame to this relay's serial inbound pipeline:
|
||||
// parsing and signature verification run off-main, in arrival
|
||||
// order, independently of every other relay's pipeline. Routing
|
||||
// through the lock-guarded router keeps this off the main actor
|
||||
// (no per-frame main hop).
|
||||
self.inboundRouter.yield(InboundFrame(message: message), to: relayUrl)
|
||||
|
||||
|
||||
// Continue receiving
|
||||
Task { @MainActor in
|
||||
guard self.connections[relayUrl] === task else { return }
|
||||
@ -1122,35 +1280,55 @@ final class NostrRelayManager: ObservableObject {
|
||||
// Note: declared at file scope below to avoid MainActor isolation inside this class
|
||||
// and keep parsing off the main actor.
|
||||
|
||||
// Handle parsed message on MainActor (state updates and handlers)
|
||||
/// First main-actor hop for an inbound EVENT: per-relay stats plus a cheap
|
||||
/// duplicate LOOKUP (no recording) so duplicate fan-in from multiple
|
||||
/// relays never pays for Schnorr verification. Recording happens only
|
||||
/// after the signature verifies (`deliverVerifiedInboundEvent`), so a
|
||||
/// forged-signature copy can never poison the dedup cache and suppress
|
||||
/// the genuine event.
|
||||
private func precheckInboundEvent(subscriptionID: String, eventID: String, relayUrl: String) -> Bool {
|
||||
if let index = relays.firstIndex(where: { $0.url == relayUrl }) {
|
||||
relays[index].messagesReceived += 1
|
||||
}
|
||||
guard !eventID.isEmpty else { return true }
|
||||
let key = InboundEventKey(subscriptionID: subscriptionID, eventID: eventID)
|
||||
if recentInboundEventKeys.contains(key) {
|
||||
recordDuplicateInboundEventDrop(subscriptionID: subscriptionID)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Second main-actor hop, after off-main signature verification:
|
||||
/// authoritative check-and-record (the serial pipeline means the same
|
||||
/// event is never in flight twice, but the record must stay atomic with
|
||||
/// delivery) and handler dispatch.
|
||||
private func deliverVerifiedInboundEvent(subscriptionID subId: String, event: NostrEvent, from relayUrl: String) {
|
||||
guard shouldDeliverInboundEvent(subscriptionID: subId, eventID: event.id) else {
|
||||
return
|
||||
}
|
||||
if event.kind != 1059 {
|
||||
// Per-event logging floods dev builds in busy geohashes; sample it.
|
||||
inboundEventLogCount += 1
|
||||
if inboundEventLogCount == 1 || inboundEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
|
||||
SecureLogger.debug("📥 Event #\(inboundEventLogCount) kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
|
||||
}
|
||||
}
|
||||
if let handler = self.messageHandlers[subId] {
|
||||
handler(event)
|
||||
} else {
|
||||
SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle parsed non-EVENT messages on MainActor (state updates and handlers)
|
||||
private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) {
|
||||
switch parsed {
|
||||
case .event(let subId, let event):
|
||||
if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) {
|
||||
self.relays[index].messagesReceived += 1
|
||||
}
|
||||
guard event.isValidSignature() else {
|
||||
SecureLogger.warning(
|
||||
"⚠️ Dropped invalid Nostr event id=\(event.id.prefix(16))… sub=\(subId) relay=\(relayUrl)",
|
||||
category: .session
|
||||
)
|
||||
return
|
||||
}
|
||||
guard shouldDeliverInboundEvent(subscriptionID: subId, eventID: event.id) else {
|
||||
return
|
||||
}
|
||||
if event.kind != 1059 {
|
||||
// Per-event logging floods dev builds in busy geohashes; sample it.
|
||||
inboundEventLogCount += 1
|
||||
if inboundEventLogCount == 1 || inboundEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
|
||||
SecureLogger.debug("📥 Event #\(inboundEventLogCount) kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
|
||||
}
|
||||
}
|
||||
if let handler = self.messageHandlers[subId] {
|
||||
handler(event)
|
||||
} else {
|
||||
SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session)
|
||||
}
|
||||
case .event:
|
||||
// Events flow through the serial inbound pipeline (precheck →
|
||||
// off-main signature verification → deliverVerifiedInboundEvent)
|
||||
// and never reach this fallback.
|
||||
assertionFailure("inbound EVENT bypassed the verified pipeline")
|
||||
case .eose(let subId):
|
||||
if var tracker = eoseTrackers[subId] {
|
||||
// An EOSE proves the relay received the REQ even if the local
|
||||
@ -1240,7 +1418,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
isConnected = relays.contains { $0.isConnected }
|
||||
// Relay URLs are normalized before entries are created, so direct
|
||||
// set membership is sound.
|
||||
isDMRelayConnected = relays.contains { $0.isConnected && Self.defaultRelaySet.contains($0.url) }
|
||||
isDMRelayConnected = relays.contains { $0.isConnected && defaultRelaySet.contains($0.url) }
|
||||
}
|
||||
|
||||
/// A relay that drops before sending EOSE must not stall initial-load
|
||||
@ -1285,6 +1463,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
) {
|
||||
if let connection, connections[relayUrl] !== connection { return }
|
||||
connections.removeValue(forKey: relayUrl)
|
||||
teardownRelayInboundPipeline(for: relayUrl)
|
||||
subscriptions.removeValue(forKey: relayUrl)
|
||||
let awaitingConfirmation = confirmedSends.compactMap { eventID, state in
|
||||
state.awaitingRelays.contains(relayUrl) ? eventID : nil
|
||||
@ -1379,8 +1558,9 @@ final class NostrRelayManager: ObservableObject {
|
||||
if let connection = connections[normalizedRelayUrl] {
|
||||
connection.cancel(with: .goingAway, reason: nil)
|
||||
connections.removeValue(forKey: normalizedRelayUrl)
|
||||
teardownRelayInboundPipeline(for: normalizedRelayUrl)
|
||||
}
|
||||
|
||||
|
||||
// Attempt immediate reconnection
|
||||
connectToRelay(normalizedRelayUrl)
|
||||
}
|
||||
@ -1473,6 +1653,77 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
// MARK: - Off-main inbound parsing helpers (file scope, non-isolated)
|
||||
|
||||
/// A single raw socket frame awaiting off-main parse + Schnorr verification.
|
||||
private struct InboundFrame: Sendable {
|
||||
let message: URLSessionWebSocketTask.Message
|
||||
}
|
||||
|
||||
/// Lock-guarded registry of per-relay inbound streams.
|
||||
///
|
||||
/// The raw WebSocket receive callback is not main-actor isolated, so it needs a
|
||||
/// `Sendable` path to route a frame to the correct relay's stream without a
|
||||
/// per-frame hop onto the main actor. Pipeline lifecycle (start/finish) is
|
||||
/// driven from the main actor; frame delivery (`yield`) can come from any
|
||||
/// thread. All access is serialized by a single lock — contention is negligible
|
||||
/// because the guarded critical section is only a dictionary lookup + yield.
|
||||
private final class InboundFrameRouter: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var continuations: [String: AsyncStream<InboundFrame>.Continuation] = [:]
|
||||
private var tasks: [String: Task<Void, Never>] = [:]
|
||||
|
||||
/// Start a relay's stream + consumer if one does not already exist.
|
||||
/// Returns true when a new pipeline was created. The bounded
|
||||
/// `.bufferingNewest` policy makes a single relay shed its OWN oldest
|
||||
/// frames under a flood, never other relays' frames. Buffered memory per
|
||||
/// relay is bounded (not eliminated) at the frame cap times the per-frame
|
||||
/// byte cap (`maximumMessageSize`) — see TransportConfig.
|
||||
func startPipeline(
|
||||
for relayUrl: String,
|
||||
makeConsumer: (AsyncStream<InboundFrame>) -> Task<Void, Never>
|
||||
) -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if continuations[relayUrl] != nil { return false }
|
||||
let (stream, continuation) = AsyncStream<InboundFrame>.makeStream(
|
||||
bufferingPolicy: .bufferingNewest(TransportConfig.nostrInboundPerRelayBufferCap)
|
||||
)
|
||||
continuations[relayUrl] = continuation
|
||||
tasks[relayUrl] = makeConsumer(stream)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Route a frame to a relay's stream. No-op if the relay has no live
|
||||
/// pipeline (socket already torn down) — the frame is simply dropped, which
|
||||
/// is safe for best-effort Nostr inbound.
|
||||
func yield(_ frame: InboundFrame, to relayUrl: String) {
|
||||
lock.lock()
|
||||
let continuation = continuations[relayUrl]
|
||||
lock.unlock()
|
||||
continuation?.yield(frame)
|
||||
}
|
||||
|
||||
/// Finish a relay's stream. The consumer drains any already-buffered frames
|
||||
/// before exiting, so in-flight verified events are still delivered.
|
||||
func finishPipeline(for relayUrl: String) {
|
||||
lock.lock()
|
||||
let continuation = continuations.removeValue(forKey: relayUrl)
|
||||
tasks.removeValue(forKey: relayUrl)
|
||||
lock.unlock()
|
||||
continuation?.finish()
|
||||
}
|
||||
|
||||
func finishAll() {
|
||||
lock.lock()
|
||||
let allContinuations = continuations
|
||||
continuations.removeAll()
|
||||
tasks.removeAll()
|
||||
lock.unlock()
|
||||
for continuation in allContinuations.values {
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum ParsedInbound {
|
||||
case event(subId: String, event: NostrEvent)
|
||||
case ok(eventId: String, success: Bool, reason: String)
|
||||
@ -1480,7 +1731,7 @@ private enum ParsedInbound {
|
||||
case notice(String)
|
||||
|
||||
init?(_ message: URLSessionWebSocketTask.Message) {
|
||||
guard let data = message.data,
|
||||
guard let data = message.dataWithinInboundLimit,
|
||||
let array = try? JSONSerialization.jsonObject(with: data) as? [Any],
|
||||
array.count >= 2,
|
||||
let type = array[0] as? String else {
|
||||
@ -1525,11 +1776,19 @@ private enum ParsedInbound {
|
||||
}
|
||||
|
||||
private extension URLSessionWebSocketTask.Message {
|
||||
var data: Data? {
|
||||
/// Prefer rejecting oversized frames before UTF-8/Data materialization
|
||||
/// where we can (string length), and always before JSON parse.
|
||||
var dataWithinInboundLimit: Data? {
|
||||
let maxBytes = TransportConfig.nostrMaxInboundMessageBytes
|
||||
switch self {
|
||||
case .string(let text): text.data(using: .utf8)
|
||||
case .data(let data): data
|
||||
@unknown default: nil
|
||||
case .string(let text):
|
||||
guard text.utf8.count <= maxBytes else { return nil }
|
||||
return text.data(using: .utf8)
|
||||
case .data(let data):
|
||||
guard data.count <= maxBytes else { return nil }
|
||||
return data
|
||||
@unknown default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
92
bitchat/Nostr/NostrRelaySettings.swift
Normal file
92
bitchat/Nostr/NostrRelaySettings.swift
Normal file
@ -0,0 +1,92 @@
|
||||
//
|
||||
// NostrRelaySettings.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
/// Relays someone has added by hand, alongside the built-in set.
|
||||
///
|
||||
/// The built-in relays are four well-known clearnet hostnames, so a censor
|
||||
/// blocking four names ends internet-delivered private messages for everyone.
|
||||
/// Adding relays — including `.onion` addresses, or a relay run by whoever
|
||||
/// needs it — is the escape hatch that does not require shipping a new build.
|
||||
///
|
||||
/// Stored normalized so comparisons against connection keys and the built-in
|
||||
/// set are exact, and bounded so a long list cannot turn every send into a
|
||||
/// fan-out across dozens of sockets.
|
||||
enum NostrRelaySettings {
|
||||
/// Enough to add a personal relay, an onion address, and a couple of
|
||||
/// regional fallbacks without letting the connection fan-out grow unbounded.
|
||||
static let maxCustomRelays = 8
|
||||
|
||||
private static let storageKey = "nostr.customRelays"
|
||||
|
||||
static let didChangeNotification = Notification.Name("bitchat.nostrRelaySettingsDidChange")
|
||||
|
||||
enum AddFailure: Error, Equatable {
|
||||
case malformed
|
||||
case alreadyPresent
|
||||
case limitReached
|
||||
}
|
||||
|
||||
/// Normalized relay URLs, in the order they were added.
|
||||
static func customRelays(in defaults: UserDefaults = .standard) -> [String] {
|
||||
let stored = defaults.stringArray(forKey: storageKey) ?? []
|
||||
// Re-normalize on read: a value written by an older build, or edited
|
||||
// outside the app, must not reach the connection layer unchecked.
|
||||
var seen = Set<String>()
|
||||
return stored.compactMap { NostrRelayURL.normalized($0) }
|
||||
.filter { seen.insert($0).inserted }
|
||||
}
|
||||
|
||||
/// Adds a relay, returning the normalized URL or why it was rejected.
|
||||
@discardableResult
|
||||
static func add(
|
||||
_ rawValue: String,
|
||||
builtIn: Set<String>,
|
||||
in defaults: UserDefaults = .standard
|
||||
) -> Result<String, AddFailure> {
|
||||
// Bare hostnames are the common way people quote a relay, and wss is
|
||||
// the only sensible assumption for one.
|
||||
guard let normalized = NostrRelayURL.normalized(rawValue, defaultScheme: "wss") else {
|
||||
return .failure(.malformed)
|
||||
}
|
||||
|
||||
var current = customRelays(in: defaults)
|
||||
guard !current.contains(normalized), !builtIn.contains(normalized) else {
|
||||
return .failure(.alreadyPresent)
|
||||
}
|
||||
guard current.count < maxCustomRelays else {
|
||||
return .failure(.limitReached)
|
||||
}
|
||||
|
||||
current.append(normalized)
|
||||
write(current, in: defaults)
|
||||
return .success(normalized)
|
||||
}
|
||||
|
||||
static func remove(_ url: String, in defaults: UserDefaults = .standard) {
|
||||
// Same default scheme as `add`, so a relay entered as a bare hostname
|
||||
// can be removed the way it was typed.
|
||||
guard let normalized = NostrRelayURL.normalized(url, defaultScheme: "wss") else { return }
|
||||
let remaining = customRelays(in: defaults).filter { $0 != normalized }
|
||||
write(remaining, in: defaults)
|
||||
}
|
||||
|
||||
/// Panic-wipe hook: an added relay names somewhere someone chose to route
|
||||
/// through, which is exactly the kind of trace a wipe should not leave.
|
||||
static func reset(in defaults: UserDefaults = .standard) {
|
||||
defaults.removeObject(forKey: storageKey)
|
||||
NotificationCenter.default.post(name: didChangeNotification, object: nil)
|
||||
}
|
||||
|
||||
private static func write(_ relays: [String], in defaults: UserDefaults) {
|
||||
defaults.set(relays, forKey: storageKey)
|
||||
NotificationCenter.default.post(name: didChangeNotification, object: nil)
|
||||
}
|
||||
}
|
||||
@ -39,13 +39,4 @@ enum NostrRelayURL {
|
||||
|
||||
return components.string
|
||||
}
|
||||
|
||||
static func directoryAddress(_ rawValue: String) -> String? {
|
||||
guard var normalized = normalized(rawValue, defaultScheme: "wss") else { return nil }
|
||||
for prefix in ["wss://", "ws://"] where normalized.hasPrefix(prefix) {
|
||||
normalized.removeFirst(prefix.count)
|
||||
break
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
@ -154,3 +154,90 @@ struct BitchatFilePacket {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire-compatible identity for private media exchanged by clients using the
|
||||
/// current iOS entropy-bearing filenames, without extending the deployed file
|
||||
/// TLV. Android clients reject unknown file tags, so eligible senders and
|
||||
/// receivers derive the receipt key from fields already on the wire.
|
||||
///
|
||||
/// Locally-created image and voice-note filenames contain a UUID or live-voice
|
||||
/// burst ID. Including the normalized direction keeps a reused filename
|
||||
/// distinct across chats while allowing short and full Noise-key peer IDs to
|
||||
/// converge. Android and older-iOS timestamp-only names remain ineligible and
|
||||
/// retain their legacy random local IDs (transfer-compatible, no receipts).
|
||||
enum PrivateMediaMessageIdentity {
|
||||
private static let domain = Data("bitchat-private-media-message-v1".utf8)
|
||||
private static let idPrefix = "media-"
|
||||
private static let digestHexLength = 32
|
||||
|
||||
static func isStableID(_ candidate: String) -> Bool {
|
||||
guard candidate.hasPrefix(idPrefix) else { return false }
|
||||
let digest = candidate.dropFirst(idPrefix.count)
|
||||
guard digest.utf8.count == digestHexLength else { return false }
|
||||
return digest.utf8.allSatisfy { byte in
|
||||
(UInt8(ascii: "0")...UInt8(ascii: "9")).contains(byte)
|
||||
|| (UInt8(ascii: "a")...UInt8(ascii: "f")).contains(byte)
|
||||
}
|
||||
}
|
||||
|
||||
static func stableID(
|
||||
senderPeerID: PeerID,
|
||||
recipientPeerID: PeerID,
|
||||
fileName: String?
|
||||
) -> String? {
|
||||
guard let fileName, !fileName.isEmpty else { return nil }
|
||||
let leafName = (fileName as NSString).lastPathComponent
|
||||
guard leafName == fileName else { return nil }
|
||||
|
||||
let path = leafName as NSString
|
||||
let stem = path.deletingPathExtension
|
||||
let fileExtension = path.pathExtension.lowercased()
|
||||
switch true {
|
||||
case stem.hasPrefix("img_"):
|
||||
guard fileExtension == "jpg" || fileExtension == "jpeg" else { return nil }
|
||||
case stem.hasPrefix("voice_"):
|
||||
guard fileExtension == "m4a" else { return nil }
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
let entropyToken = stem.split(separator: "_").last.map(String.init)
|
||||
let hasUUIDEntropy = entropyToken.flatMap(UUID.init(uuidString:)) != nil
|
||||
let voiceBurstID = stem.hasPrefix("voice_")
|
||||
? String(stem.dropFirst("voice_".count))
|
||||
: ""
|
||||
let hasBurstEntropy = voiceBurstID.count == 16
|
||||
&& voiceBurstID.allSatisfy(\.isHexDigit)
|
||||
guard hasUUIDEntropy || hasBurstEntropy else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let fields = [
|
||||
Data(senderPeerID.toShort().bare.utf8),
|
||||
Data(recipientPeerID.toShort().bare.utf8),
|
||||
Data(leafName.utf8)
|
||||
]
|
||||
var input = domain
|
||||
for field in fields {
|
||||
guard let length = UInt32(exactly: field.count) else { return nil }
|
||||
var bigEndianLength = length.bigEndian
|
||||
withUnsafeBytes(of: &bigEndianLength) {
|
||||
input.append(contentsOf: $0)
|
||||
}
|
||||
input.append(field)
|
||||
}
|
||||
|
||||
return "\(idPrefix)\(input.sha256Hex().prefix(digestHexLength))"
|
||||
}
|
||||
|
||||
static func stableID(
|
||||
for packet: BitchatFilePacket,
|
||||
senderPeerID: PeerID,
|
||||
recipientPeerID: PeerID
|
||||
) -> String? {
|
||||
stableID(
|
||||
senderPeerID: senderPeerID,
|
||||
recipientPeerID: recipientPeerID,
|
||||
fileName: packet.fileName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -38,11 +38,15 @@
|
||||
/// 7. **Decoding**: Binary data parsed back to message objects
|
||||
///
|
||||
/// ## Security Considerations
|
||||
/// - Message padding (to 256/512/1024/2048-byte blocks) obscures actual content length
|
||||
/// - Noise frames are padded (to 256/512/1024/2048-byte blocks) to obscure
|
||||
/// content length; other packet types are not padded, so their payload
|
||||
/// length is observable
|
||||
/// - Randomized relay jitter reduces the traffic-analysis signal; there is no
|
||||
/// cover traffic or per-message timing obfuscation
|
||||
/// - Integration with Noise Protocol for E2E encryption
|
||||
/// - No persistent identifiers in protocol headers
|
||||
/// - The 8-byte sender ID in every header IS a persistent identifier: it is
|
||||
/// derived from the long-lived Noise static key and rotates only on a panic
|
||||
/// wipe. Treat headers as linkable across sessions.
|
||||
///
|
||||
/// ## Message Types
|
||||
/// - **Announce/Leave**: Peer presence notifications
|
||||
@ -79,12 +83,35 @@ enum NoisePayloadType: UInt8 {
|
||||
case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update)
|
||||
// Live voice (push-to-talk)
|
||||
case voiceFrame = 0x08 // One live voice-burst packet (see VoiceBurstPacket)
|
||||
// Finalized private media. `0x20` is the value already deployed by the
|
||||
// Android client. The complete BitchatFilePacket is encrypted inside
|
||||
// Noise before the outer noiseEncrypted packet is fragmented.
|
||||
case privateFile = 0x20
|
||||
// Versioned peer state authenticated by the surrounding Noise session.
|
||||
// This is intentionally distinct from the public announce: announce
|
||||
// capabilities are discovery hints, while this payload proves possession
|
||||
// of the advertised Noise static key before downgrade state is pinned.
|
||||
case authenticatedPeerState = 0x21
|
||||
// Verification (QR-based OOB binding)
|
||||
case verifyChallenge = 0x10 // Verification challenge
|
||||
case verifyResponse = 0x11 // Verification response
|
||||
// Transitive verification (web of trust)
|
||||
case vouch = 0x12 // Batch of vouch attestations
|
||||
|
||||
/// #1434 briefly used 0x09 before release. Accept it while prerelease
|
||||
/// builds age out, but never emit it. Decoders canonicalize both values to
|
||||
/// `.privateFile` so the compatibility alias cannot leak into app logic.
|
||||
static let prereleasePrivateFileRawValue: UInt8 = 0x09
|
||||
|
||||
static func decoded(rawValue: UInt8) -> NoisePayloadType? {
|
||||
rawValue == prereleasePrivateFileRawValue ? .privateFile : Self(rawValue: rawValue)
|
||||
}
|
||||
|
||||
static func isPrivateFile(rawValue: UInt8?) -> Bool {
|
||||
guard let rawValue else { return false }
|
||||
return rawValue == privateFile.rawValue || rawValue == prereleasePrivateFileRawValue
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .privateMessage: return "privateMessage"
|
||||
@ -93,6 +120,8 @@ enum NoisePayloadType: UInt8 {
|
||||
case .groupInvite: return "groupInvite"
|
||||
case .groupKeyUpdate: return "groupKeyUpdate"
|
||||
case .voiceFrame: return "voiceFrame"
|
||||
case .privateFile: return "privateFile"
|
||||
case .authenticatedPeerState: return "authenticatedPeerState"
|
||||
case .verifyChallenge: return "verifyChallenge"
|
||||
case .verifyResponse: return "verifyResponse"
|
||||
case .vouch: return "vouch"
|
||||
|
||||
@ -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,11 @@ import BitFoundation
|
||||
extension PeerCapabilities {
|
||||
/// Capabilities this build advertises in its announce packets.
|
||||
/// Each feature adds its bit here when it ships.
|
||||
static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups]
|
||||
static let localSupported: PeerCapabilities = [
|
||||
.vouch,
|
||||
.prekeys,
|
||||
.groups,
|
||||
.privateMedia,
|
||||
.privateMediaReceipts
|
||||
]
|
||||
}
|
||||
|
||||
@ -55,10 +55,10 @@ final class AutocompleteService {
|
||||
|
||||
let fullRange = match.range(at: 0)
|
||||
let captureRange = match.range(at: 1)
|
||||
let prefix = nsText.substring(with: captureRange).lowercased()
|
||||
|
||||
let prefix = nsText.substring(with: captureRange).normalizedNickname.lowercased()
|
||||
|
||||
let suggestions = peers
|
||||
.filter { $0.lowercased().hasPrefix(prefix) }
|
||||
.filter { $0.normalizedNickname.lowercased().hasPrefix(prefix) }
|
||||
.sorted()
|
||||
.prefix(5)
|
||||
.map { "@\($0)" }
|
||||
|
||||
@ -14,8 +14,17 @@ struct BLEAnnounceHandlerEnvironment {
|
||||
let messageTTL: UInt8
|
||||
/// Current time source.
|
||||
let now: () -> Date
|
||||
/// Noise public key already recorded for the peer, if any (registry read).
|
||||
let existingNoisePublicKey: (PeerID) -> Data?
|
||||
/// Noise and signing public keys already recorded for the peer, if any
|
||||
/// (single registry read so both come from one consistent snapshot).
|
||||
let existingPeerKeys: (PeerID) -> (noisePublicKey: Data?, signingPublicKey: Data?)
|
||||
/// Signing key from the persisted cryptographic identity for the peer, if
|
||||
/// any. Registry pins do not survive app restarts or offline-peer
|
||||
/// eviction; this fallback keeps the TOFU signing-key pin effective for
|
||||
/// returning peers.
|
||||
let persistedSigningPublicKey: (PeerID) -> Data?
|
||||
/// Ed25519 key previously bound to this Noise identity by an authenticated
|
||||
/// peer-state payload, if any (persistent identity-state read).
|
||||
let authenticatedSigningPublicKey: (_ noisePublicKey: Data) -> Data?
|
||||
/// Verifies the packet signature against the announced signing key.
|
||||
let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
||||
/// Direct link state for the peer (BLE-queue read).
|
||||
@ -29,13 +38,15 @@ struct BLEAnnounceHandlerEnvironment {
|
||||
/// Runs the registry mutation phase under the collections barrier.
|
||||
let withRegistryBarrier: (() -> Void) -> Void
|
||||
/// Upserts the verified announce into the peer registry.
|
||||
/// Returns `nil` when the registry refuses the announce because it carries
|
||||
/// a signing key different from the one already pinned for this peer.
|
||||
/// Must only be called from inside `withRegistryBarrier`.
|
||||
let upsertVerifiedAnnounce: (
|
||||
_ peerID: PeerID,
|
||||
_ announcement: AnnouncementPacket,
|
||||
_ isConnected: Bool,
|
||||
_ now: Date
|
||||
) -> BLEPeerAnnounceUpdate
|
||||
) -> BLEPeerAnnounceUpdate?
|
||||
/// Debounced reconnect-log decision.
|
||||
/// Must only be called from inside `withRegistryBarrier`.
|
||||
let shouldEmitReconnectLog: (_ peerID: PeerID, _ now: Date) -> Bool
|
||||
@ -115,7 +126,16 @@ final class BLEAnnounceHandler {
|
||||
// Suppress announce logs to reduce noise
|
||||
|
||||
// Precompute signature verification outside barrier to reduce contention
|
||||
let existingNoisePublicKey = env.existingNoisePublicKey(peerID)
|
||||
var existingPeerKeys = env.existingPeerKeys(peerID)
|
||||
if existingPeerKeys.signingPublicKey == nil {
|
||||
// The registry entry (and its signing-key pin) is dropped on app
|
||||
// restart and offline-peer eviction, but the persisted
|
||||
// cryptographic identity survives both. Fall back to it so a
|
||||
// returning peer is not treated as first contact — otherwise an
|
||||
// attacker could replay the peer's noiseKey/peerID with their own
|
||||
// signing key and re-pin the identity (TOFU downgrade).
|
||||
existingPeerKeys.signingPublicKey = env.persistedSigningPublicKey(peerID)
|
||||
}
|
||||
let hasSignature = packet.signature != nil
|
||||
let signatureValid: Bool
|
||||
if hasSignature {
|
||||
@ -129,13 +149,27 @@ final class BLEAnnounceHandler {
|
||||
let trustDecision = BLEAnnounceTrustPolicy.evaluate(
|
||||
hasSignature: hasSignature,
|
||||
signatureValid: signatureValid,
|
||||
existingNoisePublicKey: existingNoisePublicKey,
|
||||
announcedNoisePublicKey: announcement.noisePublicKey
|
||||
existingNoisePublicKey: existingPeerKeys.noisePublicKey,
|
||||
announcedNoisePublicKey: announcement.noisePublicKey,
|
||||
existingSigningPublicKey: existingPeerKeys.signingPublicKey,
|
||||
authenticatedSigningPublicKey: env.authenticatedSigningPublicKey(
|
||||
announcement.noisePublicKey
|
||||
),
|
||||
announcedSigningPublicKey: announcement.signingPublicKey
|
||||
)
|
||||
if case .reject(.keyMismatch) = trustDecision {
|
||||
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security)
|
||||
}
|
||||
let verifiedAnnounce = trustDecision.isVerified
|
||||
if case .reject(.signingKeyMismatch) = trustDecision {
|
||||
SecureLogger.warning("🚨 Announce signing-key mismatch for \(peerID.id.prefix(8))… — refusing to replace pinned signing key (possible impersonation attempt)", category: .security)
|
||||
}
|
||||
if case .reject(.authenticatedSigningKeyMismatch) = trustDecision {
|
||||
SecureLogger.warning(
|
||||
"⚠️ Announce signing-key replacement rejected for Noise-authenticated peer \(peerID.id.prefix(8))…",
|
||||
category: .security
|
||||
)
|
||||
}
|
||||
var verifiedAnnounce = trustDecision.isVerified
|
||||
|
||||
var isNewPeer = false
|
||||
var isReconnectedPeer = false
|
||||
@ -172,12 +206,22 @@ final class BLEAnnounceHandler {
|
||||
return
|
||||
}
|
||||
|
||||
let update = env.upsertVerifiedAnnounce(
|
||||
// The registry re-checks the signing-key pin inside the barrier.
|
||||
// The pre-barrier trust check reads the registry outside the
|
||||
// barrier, so this closes the race where two announces for the
|
||||
// same peer are evaluated concurrently.
|
||||
guard let update = env.upsertVerifiedAnnounce(
|
||||
peerID,
|
||||
announcement,
|
||||
hasPeripheralConnection || hasCentralSubscription || (isDirectAnnounce && !linkBoundToOtherPeer),
|
||||
now
|
||||
)
|
||||
) else {
|
||||
SecureLogger.warning("🚨 Registry refused announce for \(peerID.id.prefix(8))… — signing key differs from pinned key", category: .security)
|
||||
verifiedAnnounce = false
|
||||
isNewPeer = false
|
||||
isReconnectedPeer = false
|
||||
return
|
||||
}
|
||||
isNewPeer = update.isNewPeer
|
||||
isReconnectedPeer = update.wasDisconnected
|
||||
|
||||
|
||||
@ -56,6 +56,8 @@ enum BLEAnnounceTrustRejection: Equatable {
|
||||
case missingSignature
|
||||
case invalidSignature
|
||||
case keyMismatch
|
||||
case signingKeyMismatch
|
||||
case authenticatedSigningKeyMismatch
|
||||
}
|
||||
|
||||
enum BLEAnnounceTrustDecision: Equatable {
|
||||
@ -72,12 +74,34 @@ enum BLEAnnounceTrustPolicy {
|
||||
hasSignature: Bool,
|
||||
signatureValid: Bool,
|
||||
existingNoisePublicKey: Data?,
|
||||
announcedNoisePublicKey: Data
|
||||
announcedNoisePublicKey: Data,
|
||||
existingSigningPublicKey: Data? = nil,
|
||||
authenticatedSigningPublicKey: Data? = nil,
|
||||
announcedSigningPublicKey: Data
|
||||
) -> BLEAnnounceTrustDecision {
|
||||
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
|
||||
return .reject(.keyMismatch)
|
||||
}
|
||||
|
||||
// Strongest binding first: an Ed25519 key bound to this Noise identity
|
||||
// inside an authenticated Noise session can never be replaced by a
|
||||
// merely self-signed announce.
|
||||
if let authenticatedSigningPublicKey,
|
||||
announcedSigningPublicKey != authenticatedSigningPublicKey {
|
||||
return .reject(.authenticatedSigningKeyMismatch)
|
||||
}
|
||||
|
||||
// TOFU signing-key pinning. The packet signature only proves the
|
||||
// announce is self-consistent — it is verified against the Ed25519 key
|
||||
// carried *inside the same announce*. Since peerIDs derive from the
|
||||
// broadcast (public) noise key, an attacker can replay a victim's
|
||||
// peerID+noiseKey with their own signing key and a valid
|
||||
// self-signature. Once we have bound a signing key to this peer,
|
||||
// refuse to silently replace it.
|
||||
if let existingSigningPublicKey, existingSigningPublicKey != announcedSigningPublicKey {
|
||||
return .reject(.signingKeyMismatch)
|
||||
}
|
||||
|
||||
guard hasSignature else {
|
||||
return .reject(.missingSignature)
|
||||
}
|
||||
|
||||
@ -1,6 +1,13 @@
|
||||
import Foundation
|
||||
|
||||
struct BLEAnnounceThrottle {
|
||||
/// Thread-safe announce admission state.
|
||||
///
|
||||
/// Announce requests originate from the Bluetooth delegate queue, the
|
||||
/// concurrent message queue, and the maintenance timer. Keeping the timestamp
|
||||
/// behind a lock makes admission and maintenance snapshots atomic when those
|
||||
/// request sources race.
|
||||
final class BLEAnnounceThrottle: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var lastSent: Date
|
||||
private let normalMinimumInterval: TimeInterval
|
||||
private let forcedMinimumInterval: TimeInterval
|
||||
@ -16,16 +23,27 @@ struct BLEAnnounceThrottle {
|
||||
}
|
||||
|
||||
func elapsed(since now: Date) -> TimeInterval {
|
||||
now.timeIntervalSince(lastSent)
|
||||
lock.withLock { now.timeIntervalSince(lastSent) }
|
||||
}
|
||||
|
||||
mutating func shouldSend(force: Bool, now: Date) -> Bool {
|
||||
let minimumInterval = force ? forcedMinimumInterval : normalMinimumInterval
|
||||
guard elapsed(since: now) >= minimumInterval else {
|
||||
return false
|
||||
}
|
||||
func shouldSend(force: Bool, now: Date) -> Bool {
|
||||
lock.withLock {
|
||||
let minimumInterval = force ? forcedMinimumInterval : normalMinimumInterval
|
||||
guard now.timeIntervalSince(lastSent) >= minimumInterval else {
|
||||
return false
|
||||
}
|
||||
|
||||
lastSent = now
|
||||
return true
|
||||
lastSent = now
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Forgets the last-sent timestamp. A panic rotation calls this so the
|
||||
/// new identity's first announce cannot be swallowed by the old
|
||||
/// identity's throttle debt — otherwise a panic within the forced
|
||||
/// minimum interval of the last announce leaves the rotated identity
|
||||
/// invisible until the next maintenance cycle.
|
||||
func reset() {
|
||||
lock.withLock { lastSent = .distantPast }
|
||||
}
|
||||
}
|
||||
|
||||
39
bitchat/Services/BLE/BLEEngineScheduler.swift
Normal file
39
bitchat/Services/BLE/BLEEngineScheduler.swift
Normal file
@ -0,0 +1,39 @@
|
||||
import Foundation
|
||||
|
||||
/// Schedules deferred engine work: relay jitter, announce delays, protocol
|
||||
/// deadlines (ping, capability proof), notification retry backoff, and
|
||||
/// fragment pacing.
|
||||
///
|
||||
/// This is the transport's only source of engine-side delay. Production
|
||||
/// wraps the engine queue's `asyncAfter`; tests inject a manually advanced
|
||||
/// clock so timer-driven behavior is asserted deterministically instead of
|
||||
/// racing the wall clock — product constants used as deadlines are exactly
|
||||
/// the hidden-elapsed-deadline flake class the test-timing hygiene rules
|
||||
/// exist to contain.
|
||||
protocol BLEEngineScheduling: AnyObject {
|
||||
/// Called once by the transport with its engine queue. Scheduled work
|
||||
/// always executes there: deferred bodies touch engine-confined state.
|
||||
func activate(engineQueue: DispatchQueue)
|
||||
/// Runs `work` on the engine queue after `delay`, honoring
|
||||
/// `DispatchWorkItem` cancellation.
|
||||
func schedule(after delay: TimeInterval, execute work: DispatchWorkItem)
|
||||
}
|
||||
|
||||
extension BLEEngineScheduling {
|
||||
func schedule(after delay: TimeInterval, _ body: @escaping () -> Void) {
|
||||
schedule(after: delay, execute: DispatchWorkItem(block: body))
|
||||
}
|
||||
}
|
||||
|
||||
/// Production scheduler: a thin veneer over the engine queue.
|
||||
final class BLEEngineDispatchScheduler: BLEEngineScheduling {
|
||||
private var queue: DispatchQueue?
|
||||
|
||||
func activate(engineQueue: DispatchQueue) {
|
||||
queue = engineQueue
|
||||
}
|
||||
|
||||
func schedule(after delay: TimeInterval, execute work: DispatchWorkItem) {
|
||||
queue?.asyncAfter(deadline: .now() + delay, execute: work)
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,8 @@ struct BLEFileTransferHandlerEnvironment {
|
||||
let peersSnapshot: () -> [PeerID: BLEPeerInfo]
|
||||
/// Verifies a packet's signature against a candidate signing key (registry path).
|
||||
let verifyPacketSignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
||||
/// Local signing key used to authenticate our own gossip-sync replays.
|
||||
let localSigningPublicKey: () -> Data
|
||||
/// Resolves a display name from a verified packet signature for peers missing from the registry.
|
||||
let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String?
|
||||
/// Tracks the broadcast file packet for gossip sync.
|
||||
@ -30,10 +32,85 @@ struct BLEFileTransferHandlerEnvironment {
|
||||
_ fallbackExtension: String?,
|
||||
_ defaultPrefix: String
|
||||
) -> URL?
|
||||
/// Resolves the durable receiver decision for a stable private-media ID.
|
||||
let privateMediaReceiptState: (
|
||||
_ messageID: String
|
||||
) -> BLEPrivateMediaReceiptState
|
||||
/// Atomically records a stable private-media ID after the payload save.
|
||||
let commitPrivateMediaFile: (_ messageID: String, _ storedURL: URL) -> Bool
|
||||
/// Rolls back a saved payload when its durable receipt commit fails.
|
||||
let removeIncomingFile: (_ storedURL: URL) -> Void
|
||||
/// Releases the allocator's save-to-UI ownership guard after synchronous
|
||||
/// conversation insertion has completed.
|
||||
let finishIncomingFileDelivery: (_ storedURL: URL) -> Void
|
||||
/// Checks the authenticated sender before any private-media disk work.
|
||||
let isPrivateMediaSenderBlocked: (PeerID) -> Bool
|
||||
/// Updates the registry last-seen timestamp for the peer (async barrier write).
|
||||
let updatePeerLastSeen: (PeerID) -> Void
|
||||
/// Delivers `.messageReceived` to the UI as one main-actor hop.
|
||||
let deliverMessage: (BitchatMessage) -> Void
|
||||
/// Acknowledges stable private media only after its synchronous
|
||||
/// conversation delivery has completed.
|
||||
let acknowledgePrivateMedia: (_ messageID: String, _ peerID: PeerID) -> Void
|
||||
/// Delivers `.messageReceived` as one main-actor hop while
|
||||
/// `shouldDeliver` remains true before and after the synchronous sink.
|
||||
/// The completion authorizes the stable-media ACK. Finalization runs after
|
||||
/// every delivery attempt, including rejection, so allocator ownership
|
||||
/// cannot leak indefinitely.
|
||||
let deliverMessage: (
|
||||
_ message: BitchatMessage,
|
||||
_ shouldDeliver: @escaping () -> Bool,
|
||||
_ completion: @escaping () -> Void,
|
||||
_ finalization: @escaping (TransportEventDeliveryOutcome) -> Void
|
||||
) -> Void
|
||||
}
|
||||
|
||||
/// Process-lifetime reservation cache for stable private-media IDs.
|
||||
///
|
||||
/// The first arrival reserves its ID before quota enforcement. Concurrent
|
||||
/// arrivals remain coalesced in memory, while accepted state is resolved from
|
||||
/// the durable ID-to-file ledger so it survives relaunch and becomes retryable
|
||||
/// if quota cleanup removed the file.
|
||||
private final class PrivateMediaArrivalDeduplicator {
|
||||
enum Reservation {
|
||||
case reserved
|
||||
case pending
|
||||
case accepted(URL)
|
||||
case tombstoned
|
||||
case unavailable
|
||||
}
|
||||
|
||||
private let lock = NSLock()
|
||||
private var pending: Set<String> = []
|
||||
|
||||
func reserve(
|
||||
_ messageID: String,
|
||||
receiptState: () -> BLEPrivateMediaReceiptState
|
||||
) -> Reservation {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if pending.contains(messageID) {
|
||||
return .pending
|
||||
}
|
||||
|
||||
switch receiptState() {
|
||||
case .accepted(let existingURL):
|
||||
return .accepted(existingURL)
|
||||
case .tombstoned:
|
||||
return .tombstoned
|
||||
case .unavailable:
|
||||
return .unavailable
|
||||
case .absent:
|
||||
break
|
||||
}
|
||||
|
||||
pending.insert(messageID)
|
||||
return .reserved
|
||||
}
|
||||
|
||||
func finish(_ messageID: String) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
pending.remove(messageID)
|
||||
}
|
||||
}
|
||||
|
||||
/// Orchestrates inbound file transfers: self-echo policy, sender display-name
|
||||
@ -41,61 +118,206 @@ struct BLEFileTransferHandlerEnvironment {
|
||||
/// and UI delivery.
|
||||
final class BLEFileTransferHandler {
|
||||
private let environment: BLEFileTransferHandlerEnvironment
|
||||
private let privateMediaArrivals = PrivateMediaArrivalDeduplicator()
|
||||
|
||||
init(environment: BLEFileTransferHandlerEnvironment) {
|
||||
self.environment = environment
|
||||
}
|
||||
|
||||
/// Returns `false` when the packet fails sender authentication and must
|
||||
/// not be relayed onward. Every other outcome returns `true`: files
|
||||
/// directed to another peer are forwarded untouched, and local-only drops
|
||||
/// (malformed payload, quota, save failure) don't affect multi-hop
|
||||
/// delivery to nodes that may handle them fine.
|
||||
/// Returns `false` when the raw packet fails sender authentication (or is
|
||||
/// a live self-echo) and must not be relayed onward. Authentication runs
|
||||
/// before the routing decision, so a forged directed packet cannot use a
|
||||
/// node that is not its recipient as an unsigned forwarding hop.
|
||||
@discardableResult
|
||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
||||
let env = environment
|
||||
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return true }
|
||||
|
||||
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: env.localPeerID()) else {
|
||||
return true
|
||||
}
|
||||
|
||||
let localPeerID = env.localPeerID()
|
||||
let peersSnapshot = env.peersSnapshot()
|
||||
guard let senderNickname = resolveSenderNickname(
|
||||
|
||||
guard let senderNickname = authenticatedRawSenderNickname(
|
||||
packet: packet,
|
||||
from: peerID,
|
||||
isBroadcast: !deliveryPlan.isPrivateMessage,
|
||||
peers: peersSnapshot,
|
||||
env: env
|
||||
) else {
|
||||
SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security)
|
||||
SecureLogger.warning("🚫 Dropping raw file transfer with missing/invalid signature from \(peerID.id.prefix(8))…", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: localPeerID) {
|
||||
return false
|
||||
}
|
||||
|
||||
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: localPeerID) else {
|
||||
return true
|
||||
}
|
||||
|
||||
if deliveryPlan.shouldTrackForSync {
|
||||
env.trackPacketSeen(packet)
|
||||
}
|
||||
|
||||
_ = storeIncomingPayload(
|
||||
packet.payload,
|
||||
from: peerID,
|
||||
senderNickname: senderNickname,
|
||||
timestamp: Date(timeIntervalSince1970: Double(packet.timestamp) / 1000),
|
||||
isPrivate: deliveryPlan.isPrivateMessage,
|
||||
usesDurableReceipts: false,
|
||||
env: env
|
||||
)
|
||||
// Once authenticated, a local decode/quota/save failure is not proof
|
||||
// that downstream nodes should be denied the valid signed packet.
|
||||
return true
|
||||
}
|
||||
|
||||
/// Accepts a file packet only after it has been authenticated and
|
||||
/// decrypted by the peer's Noise session. The inner packet deliberately
|
||||
/// has no redundant signature: Noise supplies sender authentication and
|
||||
/// confidentiality, while this handler retains the same validation,
|
||||
/// quota, persistence, and UI-delivery behavior as public files.
|
||||
@discardableResult
|
||||
func handlePrivatePayload(_ payload: Data, from peerID: PeerID, timestamp: Date) -> Bool {
|
||||
let env = environment
|
||||
let peers = env.peersSnapshot()
|
||||
let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||
peerID: peerID,
|
||||
localPeerID: env.localPeerID(),
|
||||
localNickname: env.localNickname(),
|
||||
peers: peers,
|
||||
allowConnectedUnverified: true
|
||||
) ?? BLEPeerSenderDisplayName.anonymousNickname(for: peerID)
|
||||
|
||||
return storeIncomingPayload(
|
||||
payload,
|
||||
from: peerID,
|
||||
senderNickname: senderNickname,
|
||||
timestamp: timestamp,
|
||||
isPrivate: true,
|
||||
// Every authenticated Noise private-file keeps the stable ID/ACK
|
||||
// contract introduced with capability bit 8. Bit 9 advertises
|
||||
// sender-side automatic retry support; it must not downgrade
|
||||
// prior iOS clients to random IDs or single-check delivery.
|
||||
usesDurableReceipts: true,
|
||||
env: env
|
||||
)
|
||||
}
|
||||
|
||||
private func storeIncomingPayload(
|
||||
_ payload: Data,
|
||||
from peerID: PeerID,
|
||||
senderNickname: String,
|
||||
timestamp: Date,
|
||||
isPrivate: Bool,
|
||||
usesDurableReceipts: Bool,
|
||||
env: BLEFileTransferHandlerEnvironment
|
||||
) -> Bool {
|
||||
|
||||
let localPeerID = env.localPeerID()
|
||||
let filePacket: BitchatFilePacket
|
||||
let mime: MimeType
|
||||
switch BLEIncomingFileValidator.validate(payload: packet.payload) {
|
||||
switch BLEIncomingFileValidator.validate(payload: payload) {
|
||||
case .success(let acceptance):
|
||||
filePacket = acceptance.filePacket
|
||||
mime = acceptance.mime
|
||||
case .failure(.malformedPayload):
|
||||
SecureLogger.error("❌ Failed to decode file transfer payload", category: .session)
|
||||
return true
|
||||
return false
|
||||
case .failure(.payloadTooLarge(let bytes)):
|
||||
SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security)
|
||||
return true
|
||||
return false
|
||||
case .failure(.unsupportedMime(let mimeType, let bytes)):
|
||||
SecureLogger.warning("🚫 MIME REJECT: '\(mimeType ?? "<empty>")' not supported. Size=\(bytes)b from \(peerID.id.prefix(8))...", category: .security)
|
||||
return true
|
||||
return false
|
||||
case .failure(.magicMismatch(let mime, let bytes, let prefixHex)):
|
||||
SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(bytes)b prefix=[\(prefixHex)] from \(peerID.id.prefix(8))...", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
if isPrivate, env.isPrivateMediaSenderBlocked(peerID) {
|
||||
SecureLogger.debug(
|
||||
"🚫 Dropping private media from blocked peer \(peerID.id.prefix(8))… before disk write",
|
||||
category: .security
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
let messageID = usesDurableReceipts
|
||||
? PrivateMediaMessageIdentity.stableID(
|
||||
for: filePacket,
|
||||
senderPeerID: peerID,
|
||||
recipientPeerID: localPeerID
|
||||
)
|
||||
: nil
|
||||
if let messageID {
|
||||
switch privateMediaArrivals.reserve(
|
||||
messageID,
|
||||
receiptState: { env.privateMediaReceiptState(messageID) }
|
||||
) {
|
||||
case .reserved:
|
||||
break
|
||||
case .pending:
|
||||
// The first arrival has not reached durable storage yet.
|
||||
// Coalesce this retry without ACKing so a failed first save
|
||||
// remains retryable by the sender.
|
||||
SecureLogger.debug(
|
||||
"📁 Coalesced in-flight private media id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))…",
|
||||
category: .session
|
||||
)
|
||||
return true
|
||||
case .accepted(let existingFile):
|
||||
env.updatePeerLastSeen(peerID)
|
||||
let message = incomingMessage(
|
||||
messageID: messageID,
|
||||
senderNickname: senderNickname,
|
||||
timestamp: timestamp,
|
||||
isPrivate: true,
|
||||
peerID: peerID,
|
||||
destination: existingFile,
|
||||
category: storedMediaCategory(
|
||||
for: existingFile,
|
||||
fallback: mime.category
|
||||
),
|
||||
env: env
|
||||
)
|
||||
SecureLogger.debug(
|
||||
"📁 Restored durable private media duplicate id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))… -> \(existingFile.lastPathComponent)",
|
||||
category: .session
|
||||
)
|
||||
deliverStableMessage(
|
||||
message,
|
||||
messageID: messageID,
|
||||
peerID: peerID,
|
||||
expectedURL: existingFile,
|
||||
env: env
|
||||
)
|
||||
return true
|
||||
case .tombstoned:
|
||||
// Explicit deletion is a durable terminal receiver decision.
|
||||
env.updatePeerLastSeen(peerID)
|
||||
env.acknowledgePrivateMedia(messageID, peerID)
|
||||
SecureLogger.debug(
|
||||
"📁 Dropped explicitly deleted private media id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))…",
|
||||
category: .session
|
||||
)
|
||||
return true
|
||||
case .unavailable:
|
||||
// Never turn an unreadable ledger into an empty ledger. A
|
||||
// directory-level failure clears on retry; a quarantined
|
||||
// record keeps exactly this ID fail-closed while every other
|
||||
// payload still flows.
|
||||
SecureLogger.warning(
|
||||
"📁 Withholding private media id=\(messageID.prefix(12))… while durable receipt state is unavailable",
|
||||
category: .session
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
defer {
|
||||
if let messageID {
|
||||
privateMediaArrivals.finish(messageID)
|
||||
}
|
||||
}
|
||||
|
||||
// BCH-01-002: Enforce storage quota before saving
|
||||
env.enforceStorageQuota(filePacket.content.count)
|
||||
|
||||
@ -106,82 +328,175 @@ final class BLEFileTransferHandler {
|
||||
mime.defaultExtension,
|
||||
mime.category.rawValue
|
||||
) else {
|
||||
return true
|
||||
return false
|
||||
}
|
||||
|
||||
if deliveryPlan.isPrivateMessage {
|
||||
if let messageID,
|
||||
!env.commitPrivateMediaFile(messageID, destination) {
|
||||
// A payload without its durable ID mapping cannot safely suppress
|
||||
// a retry after relaunch. Roll it back and withhold UI/ACK.
|
||||
env.removeIncomingFile(destination)
|
||||
return false
|
||||
}
|
||||
|
||||
if isPrivate {
|
||||
env.updatePeerLastSeen(peerID)
|
||||
}
|
||||
|
||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||
let message = BitchatMessage(
|
||||
sender: senderNickname,
|
||||
content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)",
|
||||
timestamp: ts,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: deliveryPlan.isPrivateMessage,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: peerID,
|
||||
// Received messages need an explicit status: BitchatMessage
|
||||
// defaults private messages to .sending, which the media views
|
||||
// render as an in-flight send (empty reveal mask, disabled tap).
|
||||
deliveryStatus: deliveryPlan.isPrivateMessage
|
||||
? .delivered(to: env.localNickname(), at: ts)
|
||||
: nil
|
||||
let message = incomingMessage(
|
||||
messageID: messageID,
|
||||
senderNickname: senderNickname,
|
||||
timestamp: timestamp,
|
||||
isPrivate: isPrivate,
|
||||
peerID: peerID,
|
||||
destination: destination,
|
||||
category: mime.category,
|
||||
env: env
|
||||
)
|
||||
|
||||
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
|
||||
|
||||
env.deliverMessage(message)
|
||||
if let messageID {
|
||||
deliverStableMessage(
|
||||
message,
|
||||
messageID: messageID,
|
||||
peerID: peerID,
|
||||
expectedURL: destination,
|
||||
env: env
|
||||
)
|
||||
} else {
|
||||
env.deliverMessage(
|
||||
message,
|
||||
{ true },
|
||||
{},
|
||||
{ outcome in
|
||||
if outcome == .rejected {
|
||||
// Raw media has no durable receipt that can redeliver
|
||||
// it later. Do not leave a newly saved, UI-unowned file
|
||||
// available for a stale fallback path to misidentify.
|
||||
env.removeIncomingFile(destination)
|
||||
} else {
|
||||
// Plain delegates are invoked without synchronous
|
||||
// insertion confirmation. Preserve the payload for
|
||||
// that supported delivery path.
|
||||
env.finishIncomingFileDelivery(destination)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Resolves the authenticated display name for a file transfer's sender.
|
||||
///
|
||||
/// Directed (private) transfers are addressed to us specifically and keep
|
||||
/// the lenient connected-peer path. Broadcast transfers carry an
|
||||
/// attacker-controllable `senderID` exactly like public messages and public
|
||||
/// voice frames — registry membership alone is NOT proof of identity, so a
|
||||
/// valid packet signature from the claimed sender is required before we
|
||||
/// trust it. Without this, a peer that observed a public voice burst could
|
||||
/// spoof a broadcast `voice_<burstID>.m4a` note under the talker's ID and
|
||||
/// overwrite the signature-verified live bubble with attacker audio.
|
||||
private func resolveSenderNickname(
|
||||
private func deliverStableMessage(
|
||||
_ message: BitchatMessage,
|
||||
messageID: String,
|
||||
peerID: PeerID,
|
||||
expectedURL: URL,
|
||||
env: BLEFileTransferHandlerEnvironment
|
||||
) {
|
||||
env.deliverMessage(
|
||||
message,
|
||||
{
|
||||
guard case .accepted(let resolvedURL) =
|
||||
env.privateMediaReceiptState(messageID) else {
|
||||
return false
|
||||
}
|
||||
return resolvedURL.standardizedFileURL
|
||||
== expectedURL.standardizedFileURL
|
||||
},
|
||||
{
|
||||
env.acknowledgePrivateMedia(messageID, peerID)
|
||||
},
|
||||
{ _ in
|
||||
env.finishIncomingFileDelivery(expectedURL)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func incomingMessage(
|
||||
messageID: String?,
|
||||
senderNickname: String,
|
||||
timestamp: Date,
|
||||
isPrivate: Bool,
|
||||
peerID: PeerID,
|
||||
destination: URL,
|
||||
category: MimeType.Category,
|
||||
env: BLEFileTransferHandlerEnvironment
|
||||
) -> BitchatMessage {
|
||||
BitchatMessage(
|
||||
id: messageID,
|
||||
sender: senderNickname,
|
||||
content: "\(category.messagePrefix)\(destination.lastPathComponent)",
|
||||
timestamp: timestamp,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: isPrivate,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: peerID,
|
||||
// Received messages need an explicit status: BitchatMessage
|
||||
// defaults private messages to .sending, which media views render
|
||||
// as an in-flight send.
|
||||
deliveryStatus: isPrivate
|
||||
? .delivered(to: env.localNickname(), at: timestamp)
|
||||
: nil
|
||||
)
|
||||
}
|
||||
|
||||
/// The durable URL is authoritative during reconstruction. A sender that
|
||||
/// reuses a stable filename with a different MIME type must not change how
|
||||
/// the already-stored payload renders.
|
||||
private func storedMediaCategory(
|
||||
for url: URL,
|
||||
fallback: MimeType.Category
|
||||
) -> MimeType.Category {
|
||||
let mediaDirectory = url
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.lastPathComponent
|
||||
switch mediaDirectory {
|
||||
case MimeType.Category.audio.mediaDir:
|
||||
return .audio
|
||||
case MimeType.Category.image.mediaDir:
|
||||
return .image
|
||||
case MimeType.Category.file.mediaDir:
|
||||
return .file
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
/// Every remaining raw file transfer is signed, regardless of whether it
|
||||
/// is broadcast, addressed to us, or merely passing through. Registry
|
||||
/// signing keys are preferred; persisted identities cover peers that have
|
||||
/// rotated or are not currently present in the registry.
|
||||
private func authenticatedRawSenderNickname(
|
||||
packet: BitchatPacket,
|
||||
from peerID: PeerID,
|
||||
isBroadcast: Bool,
|
||||
peers: [PeerID: BLEPeerInfo],
|
||||
env: BLEFileTransferHandlerEnvironment
|
||||
) -> String? {
|
||||
guard isBroadcast else {
|
||||
return BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||
peerID: peerID,
|
||||
localPeerID: env.localPeerID(),
|
||||
localNickname: env.localNickname(),
|
||||
peers: peers,
|
||||
allowConnectedUnverified: true
|
||||
) ?? env.signedSenderDisplayName(packet, peerID)
|
||||
}
|
||||
guard packet.signature != nil else { return nil }
|
||||
|
||||
// Our own broadcasts replayed back via gossip sync (ttl==0) are
|
||||
// trivially authentic and cannot be verified against the peer registry
|
||||
// or identity cache, so exempt self exactly as `BLEPublicMessageHandler`
|
||||
// does. Verify against the signing key already in the
|
||||
// (synchronously-updated) registry first, then fall back to the
|
||||
// persisted-identity signature lookup for peers not yet cached there.
|
||||
let isSelf = peerID == env.localPeerID()
|
||||
let registrySigningKey = peers[peerID]?.signingPublicKey
|
||||
let verifiedViaRegistry = !isSelf && (registrySigningKey.map { env.verifyPacketSignature(packet, $0) } ?? false)
|
||||
let signedDisplayName = (isSelf || verifiedViaRegistry) ? nil : env.signedSenderDisplayName(packet, peerID)
|
||||
guard isSelf || verifiedViaRegistry || signedDisplayName != nil else { return nil }
|
||||
let localPeerID = env.localPeerID()
|
||||
let candidateKey = peerID == localPeerID
|
||||
? env.localSigningPublicKey()
|
||||
: peers[peerID]?.signingPublicKey
|
||||
let verifiedWithKnownKey = candidateKey.map {
|
||||
env.verifyPacketSignature(packet, $0)
|
||||
} ?? false
|
||||
let signedDisplayName = verifiedWithKnownKey
|
||||
? nil
|
||||
: env.signedSenderDisplayName(packet, peerID)
|
||||
guard verifiedWithKnownKey || signedDisplayName != nil else { return nil }
|
||||
|
||||
return BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||
peerID: peerID,
|
||||
localPeerID: env.localPeerID(),
|
||||
localPeerID: localPeerID,
|
||||
localNickname: env.localNickname(),
|
||||
peers: peers,
|
||||
allowConnectedUnverified: false
|
||||
) ?? signedDisplayName
|
||||
// The packet signature authenticates the announced peer; the old
|
||||
// connected-but-unsigned leniency is not involved.
|
||||
allowConnectedUnverified: true
|
||||
) ?? signedDisplayName ?? BLEPeerSenderDisplayName.anonymousNickname(for: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -2,9 +2,137 @@ import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct BLEIncomingFileStore {
|
||||
private static let quotaBytes: Int64 = 100 * 1024 * 1024
|
||||
struct PanicRecoveryIntent {
|
||||
let fileMarkerEstablished: Bool
|
||||
let externalMarkerEstablished: Bool
|
||||
|
||||
var hasDurableMarker: Bool {
|
||||
fileMarkerEstablished || externalMarkerEstablished
|
||||
}
|
||||
}
|
||||
|
||||
/// Small, dependency-injectable transaction surface used by ChatViewModel.
|
||||
/// Production persists the same intent in two independent locations before
|
||||
/// any application state is erased. Tests can inject an ephemeral operation
|
||||
/// set without touching the developer's Application Support directory.
|
||||
struct PanicRecoveryOperations {
|
||||
let isPending: () throws -> Bool
|
||||
let begin: () -> PanicRecoveryIntent
|
||||
let wipeMedia: (PanicRecoveryIntent) throws -> Void
|
||||
let complete: () throws -> Void
|
||||
|
||||
static func ephemeral(
|
||||
wipeMedia: @escaping () throws -> Void = {}
|
||||
) -> PanicRecoveryOperations {
|
||||
PanicRecoveryOperations(
|
||||
isPending: { false },
|
||||
begin: {
|
||||
PanicRecoveryIntent(
|
||||
fileMarkerEstablished: false,
|
||||
externalMarkerEstablished: false
|
||||
)
|
||||
},
|
||||
wipeMedia: { _ in try wipeMedia() },
|
||||
complete: {}
|
||||
)
|
||||
}
|
||||
|
||||
static func live(
|
||||
fileStore: BLEIncomingFileStore = BLEIncomingFileStore(),
|
||||
defaults: UserDefaults = .standard
|
||||
) -> PanicRecoveryOperations {
|
||||
let defaultsKey = "bitchat.panicResetPending"
|
||||
return PanicRecoveryOperations(
|
||||
isPending: {
|
||||
if defaults.bool(forKey: defaultsKey) {
|
||||
return true
|
||||
}
|
||||
return try fileStore.isPanicRecoveryPending()
|
||||
},
|
||||
begin: {
|
||||
defaults.set(true, forKey: defaultsKey)
|
||||
let externalMarkerEstablished =
|
||||
defaults.synchronize()
|
||||
&& defaults.bool(forKey: defaultsKey)
|
||||
|
||||
let fileMarkerEstablished: Bool
|
||||
do {
|
||||
try fileStore.markPanicRecoveryPending()
|
||||
fileMarkerEstablished = true
|
||||
} catch {
|
||||
fileMarkerEstablished = false
|
||||
SecureLogger.error(
|
||||
"Failed to persist file panic-recovery marker: \(error)",
|
||||
category: .security
|
||||
)
|
||||
}
|
||||
|
||||
return PanicRecoveryIntent(
|
||||
fileMarkerEstablished: fileMarkerEstablished,
|
||||
externalMarkerEstablished: externalMarkerEstablished
|
||||
)
|
||||
},
|
||||
wipeMedia: { intent in
|
||||
try fileStore.panicWipe(
|
||||
hasDurablePendingMarker: intent.hasDurableMarker
|
||||
)
|
||||
},
|
||||
complete: {
|
||||
// Keep the independent defaults latch until the file marker
|
||||
// has definitely cleared. Any failure therefore remains
|
||||
// visible to the next launch.
|
||||
try fileStore.completePanicRecovery()
|
||||
defaults.removeObject(forKey: defaultsKey)
|
||||
guard defaults.synchronize(),
|
||||
!defaults.bool(forKey: defaultsKey) else {
|
||||
throw BLEIncomingFileStore.PanicRecoveryError
|
||||
.externalMarkerCommitFailed
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct BLEIncomingFileStore: @unchecked Sendable {
|
||||
enum PanicRecoveryError: Error {
|
||||
case externalMarkerCommitFailed
|
||||
case markerWriteFailed(Error)
|
||||
case markerWriteAndMediaWipeFailed(
|
||||
markerError: Error,
|
||||
mediaError: Error
|
||||
)
|
||||
}
|
||||
|
||||
struct PrivateMediaDeletionReservation: Sendable {
|
||||
fileprivate let id: UUID
|
||||
}
|
||||
|
||||
private final class PayloadCoordination: @unchecked Sendable {
|
||||
let lock = NSLock()
|
||||
var pendingDeliveryPaths: Set<String> = []
|
||||
var deletionReservations: [UUID: Set<String>] = [:]
|
||||
}
|
||||
|
||||
private static let defaultQuotaBytes: Int64 = 100 * 1024 * 1024
|
||||
/// How long managed media may stay on disk. Bounds by age what the quota
|
||||
/// only bounds by size; see `expireAgedMedia(retention:)`.
|
||||
static let defaultMediaRetention: TimeInterval = 7 * 24 * 60 * 60
|
||||
/// Kept outside `files/` so deleting the media tree cannot erase the
|
||||
/// fail-closed startup decision before the full panic has committed.
|
||||
private static let panicRecoveryPendingMarkerFileName =
|
||||
".panic-recovery-pending"
|
||||
/// Compatibility with a short-lived development build that used the
|
||||
/// media-specific name for the same full-transaction latch.
|
||||
private static let legacyPanicRecoveryPendingMarkerFileName =
|
||||
".panic-media-wipe-pending"
|
||||
private static let mediaSubdirectories = [
|
||||
"voicenotes/incoming",
|
||||
"voicenotes/outgoing",
|
||||
"images/incoming",
|
||||
"images/outgoing",
|
||||
"files/incoming",
|
||||
"files/outgoing"
|
||||
]
|
||||
/// Name prefix of in-flight live voice captures (progressively written by
|
||||
/// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern —
|
||||
/// deleting one mid-stream unlinks the inode under an open `FileHandle`
|
||||
@ -12,23 +140,149 @@ struct BLEIncomingFileStore {
|
||||
/// orphans a previous session left behind.
|
||||
static let liveCapturePrefix = "voice_live_"
|
||||
|
||||
/// Media payloads follow the same at-rest posture as the app's other
|
||||
/// persistence layers (courier, outbox, receipt index): protected until
|
||||
/// first unlock, so the launch-time retention sweep can still run after
|
||||
/// a reboot. Applied to the media directories so recordings that save
|
||||
/// as they go (live captures, `AVAudioRecorder`) inherit it, and stated
|
||||
/// explicitly at the payload write site like every other store.
|
||||
static var mediaProtectionAttributes: [FileAttributeKey: Any]? {
|
||||
#if os(iOS)
|
||||
return [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication]
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Exposed so callers that write progressively into the store's
|
||||
/// directories (live voice captures) share the same file manager.
|
||||
let fileManager: FileManager
|
||||
private let baseDirectory: URL?
|
||||
private let dateProvider: () -> Date
|
||||
private let panicMarkerWriter: (Data, URL) throws -> Void
|
||||
private let quotaBytes: Int64
|
||||
private let privateMediaReceipts: BLEPrivateMediaReceiptStore
|
||||
private let payloadCoordination: PayloadCoordination
|
||||
|
||||
init(fileManager: FileManager = .default, baseDirectory: URL? = nil, dateProvider: @escaping () -> Date = Date.init) {
|
||||
init(
|
||||
fileManager: FileManager = .default,
|
||||
baseDirectory: URL? = nil,
|
||||
dateProvider: @escaping () -> Date = Date.init,
|
||||
panicMarkerWriter: @escaping (Data, URL) throws -> Void = {
|
||||
try $0.write(to: $1, options: .atomic)
|
||||
},
|
||||
quotaBytes: Int64 = Self.defaultQuotaBytes
|
||||
) {
|
||||
self.fileManager = fileManager
|
||||
self.baseDirectory = baseDirectory
|
||||
self.dateProvider = dateProvider
|
||||
self.panicMarkerWriter = panicMarkerWriter
|
||||
self.quotaBytes = max(0, quotaBytes)
|
||||
self.privateMediaReceipts = BLEPrivateMediaReceiptStore(
|
||||
fileManager: fileManager,
|
||||
baseDirectory: baseDirectory,
|
||||
now: dateProvider
|
||||
)
|
||||
self.payloadCoordination = PayloadCoordination()
|
||||
}
|
||||
|
||||
/// Panic-wipe every managed incoming and outgoing media artifact before
|
||||
/// returning. Recreating the directory tree keeps later capture/receive
|
||||
/// paths usable without allowing a detached cleanup task to race them.
|
||||
///
|
||||
/// Marker persistence and deletion are deliberately separate error
|
||||
/// domains: even when both durable marker channels fail, deletion is
|
||||
/// still attempted before this method reports the marker failure.
|
||||
func panicWipe(
|
||||
hasDurablePendingMarker: Bool = false
|
||||
) throws {
|
||||
// The receipt index caches tombstones as well as accepted payloads,
|
||||
// while payload coordination retains save/delete reservations. Always
|
||||
// invalidate both on return, including partial-failure paths, so no
|
||||
// pre-panic receiver decision survives after identity reset.
|
||||
defer {
|
||||
privateMediaReceipts.resetForPanic()
|
||||
payloadCoordination.lock.lock()
|
||||
payloadCoordination.pendingDeliveryPaths.removeAll(
|
||||
keepingCapacity: false
|
||||
)
|
||||
payloadCoordination.deletionReservations.removeAll(
|
||||
keepingCapacity: false
|
||||
)
|
||||
payloadCoordination.lock.unlock()
|
||||
}
|
||||
|
||||
let markerError: Error?
|
||||
do {
|
||||
try markPanicRecoveryPending()
|
||||
markerError = nil
|
||||
} catch {
|
||||
markerError = error
|
||||
SecureLogger.error(
|
||||
"Could not persist file panic-recovery marker; attempting media deletion anyway: \(error)",
|
||||
category: .security
|
||||
)
|
||||
}
|
||||
|
||||
do {
|
||||
let filesDirectory = try rootDirectory()
|
||||
.appendingPathComponent("files", isDirectory: true)
|
||||
if fileManager.fileExists(atPath: filesDirectory.path) {
|
||||
try fileManager.removeItem(at: filesDirectory)
|
||||
}
|
||||
for subdirectory in Self.mediaSubdirectories {
|
||||
try fileManager.createDirectory(
|
||||
at: filesDirectory.appendingPathComponent(
|
||||
subdirectory,
|
||||
isDirectory: true
|
||||
),
|
||||
withIntermediateDirectories: true,
|
||||
attributes: Self.mediaProtectionAttributes
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
if let markerError {
|
||||
throw PanicRecoveryError.markerWriteAndMediaWipeFailed(
|
||||
markerError: markerError,
|
||||
mediaError: error
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
if let markerError, !hasDurablePendingMarker {
|
||||
throw PanicRecoveryError.markerWriteFailed(markerError)
|
||||
}
|
||||
}
|
||||
|
||||
func markPanicRecoveryPending() throws {
|
||||
let markerURL = try panicRecoveryPendingMarkerURL()
|
||||
try fileManager.createDirectory(
|
||||
at: markerURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true,
|
||||
attributes: nil
|
||||
)
|
||||
try panicMarkerWriter(Data([1]), markerURL)
|
||||
}
|
||||
|
||||
func isPanicRecoveryPending() throws -> Bool {
|
||||
try panicRecoveryMarkerURLs().contains {
|
||||
fileManager.fileExists(atPath: $0.path)
|
||||
}
|
||||
}
|
||||
|
||||
func completePanicRecovery() throws {
|
||||
for markerURL in try panicRecoveryMarkerURLs()
|
||||
where fileManager.fileExists(atPath: markerURL.path) {
|
||||
try fileManager.removeItem(at: markerURL)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves (and creates) an incoming-media directory for callers that
|
||||
/// write progressively instead of via `save` (live voice captures).
|
||||
func incomingDirectory(subdirectory: String) throws -> URL {
|
||||
let directory = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
|
||||
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: Self.mediaProtectionAttributes)
|
||||
return directory
|
||||
}
|
||||
|
||||
@ -39,16 +293,41 @@ struct BLEIncomingFileStore {
|
||||
fallbackExtension: String?,
|
||||
defaultPrefix: String
|
||||
) -> URL? {
|
||||
payloadCoordination.lock.lock()
|
||||
defer { payloadCoordination.lock.unlock() }
|
||||
|
||||
do {
|
||||
let base = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
|
||||
try fileManager.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil)
|
||||
try fileManager.createDirectory(at: base, withIntermediateDirectories: true, attributes: Self.mediaProtectionAttributes)
|
||||
let sanitized = sanitizedFileName(
|
||||
preferredName,
|
||||
defaultName: "\(defaultPrefix)_\(Self.timestampString(from: dateProvider()))",
|
||||
fallbackExtension: fallbackExtension
|
||||
)
|
||||
let destination = uniqueFileURL(in: base, fileName: sanitized)
|
||||
try data.write(to: destination, options: .atomic)
|
||||
let reservedPaths = privateMediaReceipts.reservedPayloadPaths()
|
||||
let deletionPaths = payloadCoordination
|
||||
.deletionReservations.values.reduce(into: Set<String>()) {
|
||||
$0.formUnion($1)
|
||||
}
|
||||
let allocationReservations = deletionPaths.union(
|
||||
payloadCoordination.pendingDeliveryPaths
|
||||
)
|
||||
let destination = uniqueFileURL(
|
||||
in: base,
|
||||
fileName: sanitized,
|
||||
reservedPaths: (reservedPaths ?? []).union(
|
||||
allocationReservations
|
||||
),
|
||||
forceRandomizedName: reservedPaths == nil
|
||||
)
|
||||
var options: Data.WritingOptions = [.atomic]
|
||||
#if os(iOS)
|
||||
options.insert(.completeFileProtectionUntilFirstUserAuthentication)
|
||||
#endif
|
||||
try data.write(to: destination, options: options)
|
||||
payloadCoordination.pendingDeliveryPaths.insert(
|
||||
destination.standardizedFileURL.path
|
||||
)
|
||||
return destination
|
||||
} catch {
|
||||
SecureLogger.error("❌ Failed to persist incoming media: \(error)", category: .session)
|
||||
@ -56,12 +335,199 @@ struct BLEIncomingFileStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops THIS instance's in-memory receipt index after a panic wipe.
|
||||
///
|
||||
/// `panicWipe` already resets the receipt store it runs on, but the
|
||||
/// production wipe runs on the `PanicRecoveryOperations.live()` file
|
||||
/// store while receipt lookups are served by `BLEService`'s own
|
||||
/// `incomingFileStore`. The service's panic path must invalidate its own
|
||||
/// cache explicitly or pre-panic decisions survive in memory.
|
||||
func resetPrivateMediaReceiptsForPanic() {
|
||||
privateMediaReceipts.resetForPanic()
|
||||
}
|
||||
|
||||
func privateMediaReceiptState(
|
||||
messageID: String
|
||||
) -> BLEPrivateMediaReceiptState {
|
||||
privateMediaReceipts.state(for: messageID)
|
||||
}
|
||||
|
||||
func commitPrivateMediaFile(
|
||||
messageID: String,
|
||||
storedURL: URL
|
||||
) -> Bool {
|
||||
privateMediaReceipts.commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: storedURL
|
||||
)
|
||||
}
|
||||
|
||||
/// Reserves every receipt/UI path before the asynchronous deletion
|
||||
/// barrier. Allocation and reservation share one lock, so either an
|
||||
/// in-flight raw arrival is observed and deletion fails closed, or the
|
||||
/// arrival is forced onto a different filename.
|
||||
func reservePrivateMediaDeletion(
|
||||
messageIDs: [String],
|
||||
payloadRelativePaths: [String: String]
|
||||
) -> PrivateMediaDeletionReservation? {
|
||||
payloadCoordination.lock.lock()
|
||||
defer { payloadCoordination.lock.unlock() }
|
||||
|
||||
guard let paths = privateMediaReceipts
|
||||
.prospectiveDeletionPayloadPaths(
|
||||
messageIDs: messageIDs,
|
||||
payloadRelativePaths: payloadRelativePaths
|
||||
),
|
||||
paths.isDisjoint(
|
||||
with: payloadCoordination.pendingDeliveryPaths
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let reservation = PrivateMediaDeletionReservation(id: UUID())
|
||||
payloadCoordination.deletionReservations[reservation.id] = paths
|
||||
return reservation
|
||||
}
|
||||
|
||||
func commitPrivateMediaDeletion(
|
||||
reservation: PrivateMediaDeletionReservation,
|
||||
messageIDs: [String],
|
||||
payloadRelativePaths: [String: String],
|
||||
protectedPayloadRelativePaths: Set<String>
|
||||
) -> Bool {
|
||||
payloadCoordination.lock.lock()
|
||||
defer {
|
||||
payloadCoordination.deletionReservations.removeValue(
|
||||
forKey: reservation.id
|
||||
)
|
||||
payloadCoordination.lock.unlock()
|
||||
}
|
||||
guard payloadCoordination.deletionReservations[reservation.id] != nil
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return privateMediaReceipts.recordDeleted(
|
||||
messageIDs: messageIDs,
|
||||
payloadRelativePaths: payloadRelativePaths,
|
||||
protectedPayloadRelativePaths: protectedPayloadRelativePaths
|
||||
)
|
||||
}
|
||||
|
||||
/// Explicit deletion of a LEGACY (non-stable-ID) incoming payload.
|
||||
///
|
||||
/// Legacy media has no durable receipt, so the only safe unlink is one
|
||||
/// that can prove no other owner may hold the basename: the path must
|
||||
/// not be pending delivery, must not belong to an in-flight deletion
|
||||
/// reservation, and must not be owned by a stable receipt or journal
|
||||
/// entry. When any of those hold — or receipt state cannot be read —
|
||||
/// the file stays for bounded quota cleanup (the fail-safe fallback).
|
||||
/// Returns true only when the payload was verifiably unlinked.
|
||||
@discardableResult
|
||||
func removeLegacyIncomingFile(relativePath: String) -> Bool {
|
||||
payloadCoordination.lock.lock()
|
||||
defer { payloadCoordination.lock.unlock() }
|
||||
|
||||
guard let payload = incomingPayloadURL(
|
||||
relativePath: relativePath
|
||||
) else {
|
||||
return false
|
||||
}
|
||||
let standardizedPath = payload.standardizedFileURL.path
|
||||
let reservedByDeletion = payloadCoordination.deletionReservations
|
||||
.values.contains { $0.contains(standardizedPath) }
|
||||
guard !reservedByDeletion,
|
||||
!payloadCoordination.pendingDeliveryPaths.contains(
|
||||
standardizedPath
|
||||
),
|
||||
let receiptOwnedPaths =
|
||||
privateMediaReceipts.reservedPayloadPaths(),
|
||||
!receiptOwnedPaths.contains(standardizedPath) else {
|
||||
return false
|
||||
}
|
||||
guard fileManager.fileExists(atPath: payload.path),
|
||||
(try? payload.resourceValues(
|
||||
forKeys: [.isRegularFileKey]
|
||||
).isRegularFile) == true else {
|
||||
return false
|
||||
}
|
||||
do {
|
||||
try fileManager.removeItem(at: payload)
|
||||
return !fileManager.fileExists(atPath: payload.path)
|
||||
} catch {
|
||||
SecureLogger.warning(
|
||||
"⚠️ Failed to remove explicitly deleted legacy media: \(error)",
|
||||
category: .session
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a `files/`-relative path iff it lands directly inside one of
|
||||
/// the incoming media directories. Anything else is not a deletable
|
||||
/// incoming payload.
|
||||
private func incomingPayloadURL(relativePath: String) -> URL? {
|
||||
guard !relativePath.isEmpty,
|
||||
let base = try? filesDirectory().standardizedFileURL else {
|
||||
return nil
|
||||
}
|
||||
let candidate = base
|
||||
.appendingPathComponent(relativePath, isDirectory: false)
|
||||
.standardizedFileURL
|
||||
let parentPath = candidate.deletingLastPathComponent().path
|
||||
let incomingDirectories = [
|
||||
"voicenotes/incoming",
|
||||
"images/incoming",
|
||||
"files/incoming"
|
||||
]
|
||||
guard incomingDirectories.contains(where: { relativeDirectory in
|
||||
base.appendingPathComponent(
|
||||
relativeDirectory,
|
||||
isDirectory: true
|
||||
).standardizedFileURL.path == parentPath
|
||||
}) else {
|
||||
return nil
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
/// Releases the short window between disk save and synchronous
|
||||
/// conversation insertion. Before this callback, a deletion transaction
|
||||
/// may not infer ownership from a stale bubble that names the same path.
|
||||
func finishIncomingFileDelivery(at storedURL: URL) {
|
||||
payloadCoordination.lock.lock()
|
||||
defer { payloadCoordination.lock.unlock() }
|
||||
payloadCoordination.pendingDeliveryPaths.remove(
|
||||
storedURL.standardizedFileURL.path
|
||||
)
|
||||
}
|
||||
|
||||
/// Best-effort rollback for a payload whose durable receipt commit failed.
|
||||
func removeIncomingFile(at storedURL: URL) {
|
||||
payloadCoordination.lock.lock()
|
||||
defer { payloadCoordination.lock.unlock() }
|
||||
payloadCoordination.pendingDeliveryPaths.remove(
|
||||
storedURL.standardizedFileURL.path
|
||||
)
|
||||
guard isURLInsideFilesDirectory(storedURL) else { return }
|
||||
do {
|
||||
try fileManager.removeItem(at: storedURL)
|
||||
} catch {
|
||||
SecureLogger.warning(
|
||||
"⚠️ Failed to roll back uncommitted incoming media: \(error)",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Frees least-recently-modified incoming files until `reservingBytes`
|
||||
/// fits under the quota. Files named `voice_live_*` (in-flight live
|
||||
/// captures) are never evicted regardless of who triggers enforcement —
|
||||
/// a finalized transfer can arrive at quota while a burst is still
|
||||
/// streaming — but they still count toward usage.
|
||||
func enforceQuota(reservingBytes: Int) {
|
||||
payloadCoordination.lock.lock()
|
||||
defer { payloadCoordination.lock.unlock() }
|
||||
|
||||
do {
|
||||
let base = try filesDirectory()
|
||||
let incomingDirs = [
|
||||
@ -87,14 +553,26 @@ struct BLEIncomingFileStore {
|
||||
}
|
||||
|
||||
let currentUsage = allFiles.reduce(0) { $0 + $1.size }
|
||||
let targetUsage = Self.quotaBytes - Int64(reservingBytes)
|
||||
let targetUsage = quotaBytes - Int64(reservingBytes)
|
||||
guard currentUsage > targetUsage else { return }
|
||||
|
||||
let needToFree = currentUsage - targetUsage
|
||||
let activeDeletionPaths = payloadCoordination
|
||||
.deletionReservations.values.reduce(into: Set<String>()) {
|
||||
$0.formUnion($1)
|
||||
}
|
||||
let protectedPaths = activeDeletionPaths.union(
|
||||
payloadCoordination.pendingDeliveryPaths
|
||||
)
|
||||
var freedSpace: Int64 = 0
|
||||
for file in allFiles.sorted(by: { $0.modified < $1.modified }) {
|
||||
guard freedSpace < needToFree else { break }
|
||||
guard !file.url.lastPathComponent.hasPrefix(Self.liveCapturePrefix) else { continue }
|
||||
guard !protectedPaths.contains(
|
||||
file.url.standardizedFileURL.path
|
||||
) else {
|
||||
continue
|
||||
}
|
||||
do {
|
||||
try fileManager.removeItem(at: file.url)
|
||||
freedSpace += file.size
|
||||
@ -112,16 +590,206 @@ struct BLEIncomingFileStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes managed media older than `retention`, across both incoming and
|
||||
/// outgoing directories, and reports how many files went away.
|
||||
///
|
||||
/// The quota sweep above only bounds *size*, and only for incoming files,
|
||||
/// so a received photo or a sent voice note could sit on disk unbounded in
|
||||
/// time — long outliving the conversation it belonged to, which is what a
|
||||
/// seized device gives up. This bounds media by age instead, on the same
|
||||
/// principle as the courier envelope and gossip archive lifetimes.
|
||||
///
|
||||
/// Honors the same exclusions as quota eviction: in-flight live captures
|
||||
/// and files reserved by an in-progress delivery or deletion are left
|
||||
/// alone regardless of age.
|
||||
@discardableResult
|
||||
func expireAgedMedia(retention: TimeInterval = Self.defaultMediaRetention) -> Int {
|
||||
guard retention > 0 else { return 0 }
|
||||
|
||||
payloadCoordination.lock.lock()
|
||||
defer { payloadCoordination.lock.unlock() }
|
||||
|
||||
let cutoff = dateProvider().addingTimeInterval(-retention)
|
||||
let activeDeletionPaths = payloadCoordination
|
||||
.deletionReservations.values.reduce(into: Set<String>()) {
|
||||
$0.formUnion($1)
|
||||
}
|
||||
let protectedPaths = activeDeletionPaths.union(
|
||||
payloadCoordination.pendingDeliveryPaths
|
||||
)
|
||||
|
||||
var removed = 0
|
||||
do {
|
||||
let base = try filesDirectory()
|
||||
for subdirectory in Self.mediaSubdirectories {
|
||||
let dir = base.appendingPathComponent(subdirectory, isDirectory: true)
|
||||
guard fileManager.fileExists(atPath: dir.path) else { continue }
|
||||
guard let contents = try? fileManager.contentsOfDirectory(
|
||||
at: dir,
|
||||
includingPropertiesForKeys: [.contentModificationDateKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else { continue }
|
||||
|
||||
for fileURL in contents {
|
||||
guard let modified = try? fileURL.resourceValues(
|
||||
forKeys: [.contentModificationDateKey]
|
||||
).contentModificationDate else { continue }
|
||||
guard modified < cutoff else { continue }
|
||||
guard !fileURL.lastPathComponent.hasPrefix(Self.liveCapturePrefix) else { continue }
|
||||
guard !protectedPaths.contains(
|
||||
fileURL.standardizedFileURL.path
|
||||
) else { continue }
|
||||
|
||||
do {
|
||||
try fileManager.removeItem(at: fileURL)
|
||||
removed += 1
|
||||
} catch {
|
||||
SecureLogger.warning(
|
||||
"⚠️ Failed to expire aged media file: \(error)",
|
||||
category: .security
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.warning(
|
||||
"⚠️ Could not expire aged media: \(error)",
|
||||
category: .security
|
||||
)
|
||||
return removed
|
||||
}
|
||||
|
||||
if removed > 0 {
|
||||
SecureLogger.info(
|
||||
"🗑️ Expired \(removed) media file(s) older than the retention window",
|
||||
category: .security
|
||||
)
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
/// Stamps the media directories and any resident payloads with the
|
||||
/// explicit protection class, covering files written by builds that
|
||||
/// relied on the container default. Runs every launch: re-stamping an
|
||||
/// equal class is a metadata no-op, and anything carrying a stronger
|
||||
/// class is left alone, so repetition is cheap and can never downgrade.
|
||||
/// In-flight live captures are skipped for symmetry with the retention
|
||||
/// sweep; they receive the class at creation and need no repair.
|
||||
/// Best-effort like the sweep it runs alongside; a file that cannot be
|
||||
/// stamped is logged, not fatal, and the migration moves on to the next
|
||||
/// item. Returns the number of items stamped so the launch path and
|
||||
/// tests can observe coverage.
|
||||
@discardableResult
|
||||
func migrateFileProtectionIfNeeded() -> Int {
|
||||
#if os(iOS)
|
||||
guard let attributes = Self.mediaProtectionAttributes else { return 0 }
|
||||
var stamped = 0
|
||||
guard let base = try? filesDirectory() else { return 0 }
|
||||
for subdirectory in Self.mediaSubdirectories {
|
||||
let dir = base.appendingPathComponent(subdirectory, isDirectory: true)
|
||||
guard fileManager.fileExists(atPath: dir.path) else { continue }
|
||||
let files = (try? fileManager.contentsOfDirectory(
|
||||
at: dir,
|
||||
includingPropertiesForKeys: [.isRegularFileKey, .isDirectoryKey, .fileProtectionKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
)) ?? []
|
||||
stamped += stampProtectionIfWeaker(dir, requireRegularFile: false, attributes: attributes)
|
||||
for fileURL in files {
|
||||
guard !fileURL.lastPathComponent.hasPrefix(Self.liveCapturePrefix) else { continue }
|
||||
stamped += stampProtectionIfWeaker(fileURL, requireRegularFile: true, attributes: attributes)
|
||||
}
|
||||
}
|
||||
return stamped
|
||||
#else
|
||||
return 0
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// Applies the class to one item, but only when the item currently sits
|
||||
/// at the container default or weaker. The list names the classes that
|
||||
/// are safe to replace; anything else, including classes added in later
|
||||
/// iOS versions, is left alone. Only regular files are stamped when
|
||||
/// `requireRegularFile` is set (and only real directories otherwise),
|
||||
/// matching the caution the legacy-file removal path applies; symlinks
|
||||
/// and other non-regular files are left untouched.
|
||||
private func stampProtectionIfWeaker(
|
||||
_ itemURL: URL,
|
||||
requireRegularFile: Bool,
|
||||
attributes: [FileAttributeKey: Any]
|
||||
) -> Int {
|
||||
let values = try? itemURL.resourceValues(
|
||||
forKeys: [.isRegularFileKey, .isDirectoryKey, .fileProtectionKey]
|
||||
)
|
||||
if requireRegularFile {
|
||||
guard values?.isRegularFile == true else { return 0 }
|
||||
} else {
|
||||
guard values?.isDirectory == true else { return 0 }
|
||||
}
|
||||
if let current = values?.fileProtection,
|
||||
current != .none,
|
||||
current != .completeUntilFirstUserAuthentication {
|
||||
return 0
|
||||
}
|
||||
do {
|
||||
try fileManager.setAttributes(attributes, ofItemAtPath: itemURL.path)
|
||||
return 1
|
||||
} catch let error as CocoaError where error.code == .fileNoSuchFile {
|
||||
// Quota eviction or a deletion commit on another store instance
|
||||
// can delete an item out from under this migration; that is not
|
||||
// a failure.
|
||||
return 0
|
||||
} catch {
|
||||
SecureLogger.warning(
|
||||
"⚠️ Failed to migrate media file protection: \(error)",
|
||||
category: .security
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private func filesDirectory() throws -> URL {
|
||||
let root = try baseDirectory ?? fileManager.url(
|
||||
let filesDir = try rootDirectory().appendingPathComponent("files", isDirectory: true)
|
||||
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: Self.mediaProtectionAttributes)
|
||||
return filesDir
|
||||
}
|
||||
|
||||
private func rootDirectory() throws -> URL {
|
||||
try baseDirectory ?? fileManager.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let filesDir = root.appendingPathComponent("files", isDirectory: true)
|
||||
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
||||
return filesDir
|
||||
}
|
||||
|
||||
private func panicRecoveryPendingMarkerURL() throws -> URL {
|
||||
try rootDirectory().appendingPathComponent(
|
||||
Self.panicRecoveryPendingMarkerFileName,
|
||||
isDirectory: false
|
||||
)
|
||||
}
|
||||
|
||||
private func panicRecoveryMarkerURLs() throws -> [URL] {
|
||||
let root = try rootDirectory()
|
||||
return [
|
||||
root.appendingPathComponent(
|
||||
Self.panicRecoveryPendingMarkerFileName,
|
||||
isDirectory: false
|
||||
),
|
||||
root.appendingPathComponent(
|
||||
Self.legacyPanicRecoveryPendingMarkerFileName,
|
||||
isDirectory: false
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
private func isURLInsideFilesDirectory(_ url: URL) -> Bool {
|
||||
guard let filesDirectory = try? filesDirectory().standardizedFileURL else {
|
||||
return false
|
||||
}
|
||||
return url.standardizedFileURL.path.hasPrefix(filesDirectory.path + "/")
|
||||
}
|
||||
|
||||
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
|
||||
@ -151,11 +819,20 @@ struct BLEIncomingFileStore {
|
||||
return candidate.isEmpty ? defaultName : candidate
|
||||
}
|
||||
|
||||
private func uniqueFileURL(in directory: URL, fileName: String) -> URL {
|
||||
private func uniqueFileURL(
|
||||
in directory: URL,
|
||||
fileName: String,
|
||||
reservedPaths: Set<String>,
|
||||
forceRandomizedName: Bool
|
||||
) -> URL {
|
||||
let directoryPath = directory.standardizedFileURL.path
|
||||
func isInsideDirectory(_ url: URL) -> Bool {
|
||||
url.standardizedFileURL.path.hasPrefix(directoryPath + "/")
|
||||
}
|
||||
func isAvailable(_ url: URL) -> Bool {
|
||||
!reservedPaths.contains(url.standardizedFileURL.path)
|
||||
&& !fileManager.fileExists(atPath: url.path)
|
||||
}
|
||||
|
||||
var candidate = directory.appendingPathComponent(fileName)
|
||||
guard isInsideDirectory(candidate) else {
|
||||
@ -163,19 +840,27 @@ struct BLEIncomingFileStore {
|
||||
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
|
||||
}
|
||||
|
||||
if !fileManager.fileExists(atPath: candidate.path) {
|
||||
let baseName = (fileName as NSString).deletingPathExtension
|
||||
let ext = (fileName as NSString).pathExtension
|
||||
if forceRandomizedName {
|
||||
let suffix = UUID().uuidString
|
||||
let randomizedName = ext.isEmpty
|
||||
? "\(baseName)_\(suffix)"
|
||||
: "\(baseName)_\(suffix).\(ext)"
|
||||
return directory.appendingPathComponent(randomizedName)
|
||||
}
|
||||
|
||||
if isAvailable(candidate) {
|
||||
return candidate
|
||||
}
|
||||
|
||||
let baseName = (fileName as NSString).deletingPathExtension
|
||||
let ext = (fileName as NSString).pathExtension
|
||||
for counter in 1..<100 {
|
||||
let newName = ext.isEmpty ? "\(baseName) (\(counter))" : "\(baseName) (\(counter)).\(ext)"
|
||||
candidate = directory.appendingPathComponent(newName)
|
||||
guard isInsideDirectory(candidate) else {
|
||||
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
|
||||
}
|
||||
if !fileManager.fileExists(atPath: candidate.path) {
|
||||
if isAvailable(candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
@ -124,3 +124,45 @@ struct BLEIngressLinkRegistry {
|
||||
packet.isRSR && packet.ttl == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Lock-backed shared ownership of the ingress-link registry. Ingress is
|
||||
/// recorded on bleQueue the moment a frame decodes (the link identity is
|
||||
/// only known there, and the duplicate-ingress gate must answer before
|
||||
/// the packet is handed to the engine), while relay and routing decisions
|
||||
/// read it from the engine. Every registry mutation is a single
|
||||
/// whole-transition method, so readers never observe a torn state.
|
||||
final class BLEIngressLinkStore: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var registry = BLEIngressLinkRegistry()
|
||||
|
||||
var isEmpty: Bool {
|
||||
lock.withLock { registry.isEmpty }
|
||||
}
|
||||
|
||||
func removeAll() {
|
||||
lock.withLock { registry.removeAll() }
|
||||
}
|
||||
|
||||
func record(for packet: BitchatPacket) -> BLEIngressLinkRecord? {
|
||||
lock.withLock { registry.record(for: packet) }
|
||||
}
|
||||
|
||||
func link(for packet: BitchatPacket) -> BLEIngressLinkID? {
|
||||
lock.withLock { registry.link(for: packet) }
|
||||
}
|
||||
|
||||
func recordIfNew(
|
||||
_ packet: BitchatPacket,
|
||||
link: BLEIngressLinkID,
|
||||
peerID: PeerID,
|
||||
lifetime: TimeInterval
|
||||
) -> Bool {
|
||||
lock.withLock {
|
||||
registry.recordIfNew(packet, link: link, peerID: peerID, lifetime: lifetime)
|
||||
}
|
||||
}
|
||||
|
||||
func prune(before cutoff: Date) {
|
||||
lock.withLock { registry.prune(before: cutoff) }
|
||||
}
|
||||
}
|
||||
|
||||
115
bitchat/Services/BLE/BLELinkAuthState.swift
Normal file
115
bitchat/Services/BLE/BLELinkAuthState.swift
Normal file
@ -0,0 +1,115 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
/// Per-link Noise authentication and rebind-containment state.
|
||||
///
|
||||
/// A peer ID can retain an established Noise session after its physical
|
||||
/// link disappears, and link bindings heal on announces whose directness
|
||||
/// is forgeable (TTL is unsigned). This state pins the stronger facts the
|
||||
/// containment rules need: which exact ingress link a Noise handshake
|
||||
/// completed on, each link's revalidation epoch, and the cooldowns that
|
||||
/// stop a replayed announce from flip-flopping bindings or survivor
|
||||
/// selection.
|
||||
///
|
||||
/// Engine-owned (option-B boundary, docs/BLE-ARCHITECTURE-V3.md),
|
||||
/// alongside the link bindings it qualifies: BLEService debug-traps any
|
||||
/// access off the engine queue.
|
||||
struct BLELinkAuthState {
|
||||
private var authenticatedOwners: [BLEIngressLinkID: PeerID] = [:]
|
||||
private var reconnectPolicy = BLENoiseReconnectPolicy()
|
||||
// Entries older than the cooldown are pruned on each check.
|
||||
private var lastRebindAt: [String: Date] = [:]
|
||||
private var lastRedundantRetirementAt: [PeerID: Date] = [:]
|
||||
|
||||
// MARK: - Authentication ownership
|
||||
|
||||
/// Whether `peerID`'s Noise session was established on this exact link.
|
||||
func isAuthenticated(_ link: BLEIngressLinkID, for peerID: PeerID) -> Bool {
|
||||
authenticatedOwners[link] == peerID
|
||||
}
|
||||
|
||||
func links(ownedBy peerID: PeerID) -> [BLEIngressLinkID] {
|
||||
authenticatedOwners.compactMap { link, owner in
|
||||
owner == peerID ? link : nil
|
||||
}
|
||||
}
|
||||
|
||||
mutating func markAuthenticated(_ link: BLEIngressLinkID, owner peerID: PeerID) {
|
||||
authenticatedOwners[link] = peerID
|
||||
}
|
||||
|
||||
/// Retires a link's proof and closes its revalidation epoch — the pair
|
||||
/// every teardown path (disconnect, unsubscribe, timeout, rebind,
|
||||
/// redundant retirement) must apply together.
|
||||
mutating func retireLink(_ link: BLEIngressLinkID) {
|
||||
authenticatedOwners.removeValue(forKey: link)
|
||||
reconnectPolicy.endLinkEpoch(link)
|
||||
}
|
||||
|
||||
/// Retires every link the departing peer's proofs still own; returns
|
||||
/// the retired links.
|
||||
mutating func retireLinks(ownedBy peerID: PeerID) -> [BLEIngressLinkID] {
|
||||
let departed = links(ownedBy: peerID)
|
||||
for link in departed {
|
||||
retireLink(link)
|
||||
}
|
||||
return departed
|
||||
}
|
||||
|
||||
/// Drops every link proof and revalidation epoch. The containment
|
||||
/// cooldowns deliberately SURVIVE this: panic and emergency resets can
|
||||
/// restart services well inside `bleLinkRebindCooldownSeconds`, and a
|
||||
/// stable CoreBluetooth UUID must not get a fresh rebind/retirement
|
||||
/// allowance just because the session state around it was wiped. The
|
||||
/// maps stay time-pruned on each permit check.
|
||||
mutating func removeAll() {
|
||||
authenticatedOwners.removeAll()
|
||||
reconnectPolicy.removeAll()
|
||||
}
|
||||
|
||||
// MARK: - Session revalidation
|
||||
|
||||
/// Whether a fresh direct link warrants revalidating a cached
|
||||
/// peer-level session with a new XX exchange.
|
||||
mutating func shouldRevalidate(
|
||||
on link: BLEIngressLinkID,
|
||||
for peerID: PeerID,
|
||||
hasEstablishedSession: Bool,
|
||||
hasAuthenticatedPeerLink: Bool,
|
||||
now: Date
|
||||
) -> Bool {
|
||||
reconnectPolicy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: hasEstablishedSession,
|
||||
isNoiseAuthenticatedLink: isAuthenticated(link, for: peerID),
|
||||
hasAuthenticatedPeerLink: hasAuthenticatedPeerLink,
|
||||
now: now
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Rebind containment cooldowns
|
||||
|
||||
/// At most one rotation rebind per link per cooldown window, so two
|
||||
/// identities can't fight over a link in a replay flip-flop. Prunes,
|
||||
/// checks, and records in one transition; true = permitted (recorded).
|
||||
mutating func permitRebind(linkUUID: String, now: Date, cooldown: TimeInterval) -> Bool {
|
||||
lastRebindAt = lastRebindAt.filter {
|
||||
now.timeIntervalSince($0.value) < cooldown
|
||||
}
|
||||
guard lastRebindAt[linkUUID] == nil else { return false }
|
||||
lastRebindAt[linkUUID] = now
|
||||
return true
|
||||
}
|
||||
|
||||
/// At most one redundant-link retirement per peer per cooldown window,
|
||||
/// bounding how often a replayed announce could flip which duplicate
|
||||
/// link survives. True = permitted (recorded).
|
||||
mutating func permitRedundantRetirement(peerID: PeerID, now: Date, cooldown: TimeInterval) -> Bool {
|
||||
lastRedundantRetirementAt = lastRedundantRetirementAt.filter {
|
||||
now.timeIntervalSince($0.value) < cooldown
|
||||
}
|
||||
guard lastRedundantRetirementAt[peerID] == nil else { return false }
|
||||
lastRedundantRetirementAt[peerID] = now
|
||||
return true
|
||||
}
|
||||
}
|
||||
152
bitchat/Services/BLE/BLELinkBindings.swift
Normal file
152
bitchat/Services/BLE/BLELinkBindings.swift
Normal file
@ -0,0 +1,152 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
/// Identity↔link bindings: which peer each physical link currently
|
||||
/// belongs to, in both roles, plus each peer's preferred peripheral link
|
||||
/// for directed sends and fanout collapse.
|
||||
///
|
||||
/// Engine-owned (option-B boundary, docs/BLE-ARCHITECTURE-V3.md),
|
||||
/// alongside `BLELinkAuthState`: *who owns a link* lives on the engine,
|
||||
/// *what links exist* stays on bleQueue in the physical store. BLEService
|
||||
/// debug-traps any access off the engine queue.
|
||||
///
|
||||
/// Lifecycle contract: bindings are only created for live physical links
|
||||
/// (callers check liveness through `readLinkState`) and are retired
|
||||
/// through `peripheralRemoved`/`centralRemoved`/`clear*` on an engine hop
|
||||
/// queued by the physical teardown. A binding can therefore briefly
|
||||
/// outlive its departed link; queries that need liveness join against the
|
||||
/// physical store, and everything converges once the queued retirement
|
||||
/// runs.
|
||||
struct BLELinkBindings {
|
||||
private var peripheralPeers: [String: PeerID] = [:]
|
||||
private var centralPeers: [String: PeerID] = [:]
|
||||
/// The peer's most recently bound peripheral link, kept so duplicate-
|
||||
/// link fanout collapse stays deterministic (see BLEFanoutSelector).
|
||||
private var preferredPeripheral: [PeerID: String] = [:]
|
||||
|
||||
// MARK: - Queries
|
||||
|
||||
func peer(forPeripheralID peripheralID: String) -> PeerID? {
|
||||
peripheralPeers[peripheralID]
|
||||
}
|
||||
|
||||
func peer(forCentralUUID centralUUID: String) -> PeerID? {
|
||||
centralPeers[centralUUID]
|
||||
}
|
||||
|
||||
func boundPeer(for link: BLEIngressLinkID) -> PeerID? {
|
||||
switch link {
|
||||
case .peripheral(let peripheralUUID):
|
||||
return peripheralPeers[peripheralUUID]
|
||||
case .central(let centralUUID):
|
||||
return centralPeers[centralUUID]
|
||||
}
|
||||
}
|
||||
|
||||
/// Every link bound to the peer, both roles. After a state restoration
|
||||
/// the same device can hold several live peripheral links bound to one
|
||||
/// peer (it reappears under a fresh UUID while the restored connection
|
||||
/// lives on), so this scans all bindings rather than the 1:1 preferred
|
||||
/// map.
|
||||
func links(to peerID: PeerID?) -> Set<BLEIngressLinkID> {
|
||||
guard let peerID else { return [] }
|
||||
var links: Set<BLEIngressLinkID> = []
|
||||
for (peripheralUUID, boundPeer) in peripheralPeers where boundPeer == peerID {
|
||||
links.insert(.peripheral(peripheralUUID))
|
||||
}
|
||||
for (centralUUID, boundPeer) in centralPeers where boundPeer == peerID {
|
||||
links.insert(.central(centralUUID))
|
||||
}
|
||||
return links
|
||||
}
|
||||
|
||||
func hasCentral(boundTo peerID: PeerID) -> Bool {
|
||||
centralPeers.values.contains(peerID)
|
||||
}
|
||||
|
||||
func preferredPeripheralUUID(for peerID: PeerID) -> String? {
|
||||
preferredPeripheral[peerID]
|
||||
}
|
||||
|
||||
/// The full preferred-peripheral map, for fanout collapse.
|
||||
var preferredPeripheralBindings: [PeerID: String] {
|
||||
preferredPeripheral
|
||||
}
|
||||
|
||||
/// The full central binding map, for the subscribed-central snapshot.
|
||||
var centralPeersByUUID: [String: PeerID] {
|
||||
centralPeers
|
||||
}
|
||||
|
||||
// MARK: - Binding transitions
|
||||
|
||||
mutating func bindCentral(_ centralUUID: String, to peerID: PeerID) {
|
||||
centralPeers[centralUUID] = peerID
|
||||
}
|
||||
|
||||
mutating func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) {
|
||||
let previousPeerID = peripheralPeers[peripheralUUID]
|
||||
peripheralPeers[peripheralUUID] = peerID
|
||||
// Rebinding (peer-ID rotation): drop the retired ID's reverse
|
||||
// mapping so the old peer no longer claims this link.
|
||||
if let previousPeerID, previousPeerID != peerID,
|
||||
preferredPeripheral[previousPeerID] == peripheralUUID {
|
||||
preferredPeripheral.removeValue(forKey: previousPeerID)
|
||||
}
|
||||
preferredPeripheral[peerID] = peripheralUUID
|
||||
}
|
||||
|
||||
/// Retires a peripheral link's binding. When the removed link was the
|
||||
/// peer's preferred one, the reverse map is repaired onto a surviving
|
||||
/// duplicate chosen by the caller from the peer's remaining bound links
|
||||
/// (the caller knows physical liveness; prefer a writable survivor —
|
||||
/// repairing onto a link mid-service-rediscovery would strand directed
|
||||
/// sends until its characteristic comes back).
|
||||
mutating func peripheralRemoved(
|
||||
_ peripheralUUID: String,
|
||||
chooseSurvivor: (_ remainingBoundUUIDs: [String]) -> String?
|
||||
) -> PeerID? {
|
||||
guard let peerID = peripheralPeers.removeValue(forKey: peripheralUUID) else {
|
||||
return nil
|
||||
}
|
||||
// Only clear (or repair) the reverse map when it points at the
|
||||
// removed link: with duplicate links to one peer, removing a stale
|
||||
// duplicate must not strand the peer's surviving bound link.
|
||||
if preferredPeripheral[peerID] == peripheralUUID {
|
||||
let remaining = peripheralPeers.compactMap { uuid, boundPeer in
|
||||
boundPeer == peerID ? uuid : nil
|
||||
}
|
||||
if let survivorUUID = chooseSurvivor(remaining) {
|
||||
preferredPeripheral[peerID] = survivorUUID
|
||||
} else {
|
||||
preferredPeripheral.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
return peerID
|
||||
}
|
||||
|
||||
mutating func centralRemoved(_ centralUUID: String) -> PeerID? {
|
||||
centralPeers.removeValue(forKey: centralUUID)
|
||||
}
|
||||
|
||||
/// Drops every peripheral binding; returns the peers that held one.
|
||||
mutating func clearPeripherals() -> [PeerID] {
|
||||
let peerIDs = Array(peripheralPeers.values)
|
||||
peripheralPeers.removeAll()
|
||||
preferredPeripheral.removeAll()
|
||||
return peerIDs
|
||||
}
|
||||
|
||||
/// Drops every central binding; returns the peers that held one.
|
||||
mutating func clearCentrals() -> [PeerID] {
|
||||
let peerIDs = Array(centralPeers.values)
|
||||
centralPeers.removeAll()
|
||||
return peerIDs
|
||||
}
|
||||
|
||||
mutating func removeAll() {
|
||||
peripheralPeers.removeAll()
|
||||
centralPeers.removeAll()
|
||||
preferredPeripheral.removeAll()
|
||||
}
|
||||
}
|
||||
40
bitchat/Services/BLE/BLELinkEvent.swift
Normal file
40
bitchat/Services/BLE/BLELinkEvent.swift
Normal file
@ -0,0 +1,40 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
/// The upward half of the link-layer port: everything the bleQueue link
|
||||
/// layer tells the engine, as one enumerable surface with one engine
|
||||
/// entry point (`BLEService.handleLinkEvent`). CoreBluetooth delegates
|
||||
/// shrink to physical bookkeeping plus event emission, and the simulated
|
||||
/// mesh drives the engine through exactly the same seam.
|
||||
///
|
||||
/// Naming follows the physical stores: a *peripheral link* is a
|
||||
/// connection we own as central (keyed by the remote peripheral's UUID);
|
||||
/// a *central link* is a remote central subscribed to our peripheral role
|
||||
/// (keyed by its UUID).
|
||||
enum BLELinkEvent {
|
||||
/// A decoded frame arrived on a link. Attribution — binding lookup,
|
||||
/// spoof rejection, raw-announce binding, ingress recording — is
|
||||
/// engine work. Emission captures the panic lifecycle at the handoff.
|
||||
case frameDecoded(BitchatPacket, link: BLEIngressLinkID, linkDescription: String)
|
||||
|
||||
/// One peripheral link ended (disconnect, connect failure, or radio
|
||||
/// policy teardown). The engine retires the link's identity half —
|
||||
/// proof, epoch, binding with survivor repair — and, when
|
||||
/// `runPeerBookkeeping` is set (real disconnects), marks the peer
|
||||
/// disconnected once its last live link is gone and republishes the
|
||||
/// peer list.
|
||||
case peripheralLinkEnded(peripheralID: String, runPeerBookkeeping: Bool)
|
||||
|
||||
/// A remote central unsubscribed. The engine retires the central
|
||||
/// link's identity half and runs last-link peer bookkeeping.
|
||||
case centralLinkEnded(centralUUID: String)
|
||||
|
||||
/// The central role reset and every peripheral link is gone
|
||||
/// (power-off retires proofs and notifies peers; an authorization
|
||||
/// loss only drops the bindings).
|
||||
case allPeripheralLinksEnded(peripheralIDs: [String], retireProofsAndNotify: Bool)
|
||||
|
||||
/// The peripheral role reset and every central link is gone (same
|
||||
/// power-off / authorization-loss split).
|
||||
case allCentralLinksEnded(centralUUIDs: [String], retireProofsAndNotify: Bool)
|
||||
}
|
||||
@ -5,10 +5,15 @@ import Foundation
|
||||
struct BLEPeripheralLinkState {
|
||||
let peripheral: CBPeripheral
|
||||
var characteristic: CBCharacteristic?
|
||||
var peerID: PeerID?
|
||||
var isConnecting: Bool
|
||||
var isConnected: Bool
|
||||
var lastConnectionAttempt: Date?
|
||||
/// When didConnect last fired for this link. Nil for links restored
|
||||
/// already-connected (their connect predates this process), which is
|
||||
/// exactly the signal redundant-link consolidation needs: a restored
|
||||
/// link lives on an old BLE address the peer no longer advertises,
|
||||
/// so it must never be kept over a freshly connected duplicate.
|
||||
var lastConnectedAt: Date? = nil
|
||||
var assembler: NotificationStreamAssembler
|
||||
}
|
||||
|
||||
@ -26,17 +31,20 @@ struct BLESubscribedCentralSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns all BLE link state (peripheral connections we hold as central, and
|
||||
/// central subscriptions we serve as peripheral). The store has no internal
|
||||
/// locking: every access must happen on the single owning queue (the BLE
|
||||
/// queue). Other queues must go through BLEService's `readLinkState`, which
|
||||
/// hops to that queue. Call `assumeOwnership(of:)` to have debug builds trap
|
||||
/// any access from the wrong queue.
|
||||
// BLEDirectLinkState and the identity↔link binding queries live on
|
||||
// BLELinkBindings; this store owns only physical link state.
|
||||
|
||||
/// Owns the PHYSICAL BLE link state (peripheral connections we hold as
|
||||
/// central, and central subscriptions we serve as peripheral) — CB object
|
||||
/// handles, connect lifecycles, characteristics, and stream assemblers.
|
||||
/// Identity↔link bindings live on `BLELinkBindings`. The store has no
|
||||
/// internal locking: every access must happen on the single owning queue
|
||||
/// (the BLE queue). Other queues must go through BLEService's
|
||||
/// `readLinkState`, which hops to that queue. Call `assumeOwnership(of:)`
|
||||
/// to have debug builds trap any access from the wrong queue.
|
||||
final class BLELinkStateStore {
|
||||
private(set) var peripherals: [String: BLEPeripheralLinkState] = [:]
|
||||
private(set) var peerToPeripheralUUID: [PeerID: String] = [:]
|
||||
private(set) var subscribedCentrals: [CBCentral] = []
|
||||
private(set) var centralToPeerID: [String: PeerID] = [:]
|
||||
|
||||
#if DEBUG
|
||||
private var ownerQueue: DispatchQueue?
|
||||
@ -64,14 +72,6 @@ final class BLELinkStateStore {
|
||||
return Array(peripherals.values)
|
||||
}
|
||||
|
||||
var subscribedCentralSnapshot: BLESubscribedCentralSnapshot {
|
||||
assertOwned()
|
||||
return BLESubscribedCentralSnapshot(
|
||||
centrals: subscribedCentrals,
|
||||
peerIDsByCentralUUID: centralToPeerID
|
||||
)
|
||||
}
|
||||
|
||||
var subscribedCentralCount: Int {
|
||||
assertOwned()
|
||||
return subscribedCentrals.count
|
||||
@ -109,7 +109,6 @@ final class BLELinkStateStore {
|
||||
BLEPeripheralLinkState(
|
||||
peripheral: peripheral,
|
||||
characteristic: nil,
|
||||
peerID: nil,
|
||||
isConnecting: true,
|
||||
isConnected: false,
|
||||
lastConnectionAttempt: date,
|
||||
@ -119,20 +118,21 @@ final class BLELinkStateStore {
|
||||
)
|
||||
}
|
||||
|
||||
func markConnected(_ peripheral: CBPeripheral) {
|
||||
func markConnected(_ peripheral: CBPeripheral, at now: Date = Date()) {
|
||||
let peripheralID = peripheral.identifier.uuidString
|
||||
if updatePeripheral(peripheralID, {
|
||||
$0.isConnecting = false
|
||||
$0.isConnected = true
|
||||
$0.lastConnectedAt = now
|
||||
}) == nil {
|
||||
setPeripheralState(
|
||||
BLEPeripheralLinkState(
|
||||
peripheral: peripheral,
|
||||
characteristic: nil,
|
||||
peerID: nil,
|
||||
isConnecting: false,
|
||||
isConnected: true,
|
||||
lastConnectionAttempt: nil,
|
||||
lastConnectedAt: now,
|
||||
assembler: NotificationStreamAssembler()
|
||||
),
|
||||
for: peripheralID
|
||||
@ -146,130 +146,35 @@ final class BLELinkStateStore {
|
||||
}
|
||||
}
|
||||
|
||||
func directPeripheralState(for peerID: PeerID) -> BLEPeripheralLinkState? {
|
||||
assertOwned()
|
||||
return peerToPeripheralUUID[peerID].flatMap { peripherals[$0] }
|
||||
}
|
||||
|
||||
func directLinkState(for peerID: PeerID) -> BLEDirectLinkState {
|
||||
assertOwned()
|
||||
let peripheralUUID = peerToPeripheralUUID[peerID]
|
||||
let hasPeripheral = peripheralUUID.flatMap { peripherals[$0]?.isConnected } ?? false
|
||||
let hasCentral = centralToPeerID.values.contains(peerID)
|
||||
return BLEDirectLinkState(hasPeripheral: hasPeripheral, hasCentral: hasCentral)
|
||||
}
|
||||
|
||||
func links(to peerID: PeerID?) -> Set<BLEIngressLinkID> {
|
||||
assertOwned()
|
||||
guard let peerID else { return [] }
|
||||
|
||||
var links: Set<BLEIngressLinkID> = []
|
||||
// Scan all states rather than the 1:1 reverse map: after a state
|
||||
// restoration the same device can hold several live peripheral links
|
||||
// bound to one peer (it reappears under a fresh UUID while the
|
||||
// restored connection lives on).
|
||||
for (peripheralUUID, state) in peripherals where state.peerID == peerID {
|
||||
links.insert(.peripheral(peripheralUUID))
|
||||
}
|
||||
for (centralUUID, mappedPeerID) in centralToPeerID where mappedPeerID == peerID {
|
||||
links.insert(.central(centralUUID))
|
||||
}
|
||||
return links
|
||||
}
|
||||
|
||||
/// The peer's most recently bound peripheral link, per peer. Used to keep
|
||||
/// duplicate-link fanout collapse deterministic (see BLEFanoutSelector).
|
||||
var preferredPeripheralBindings: [PeerID: String] {
|
||||
assertOwned()
|
||||
return peerToPeripheralUUID
|
||||
}
|
||||
|
||||
func peerID(forPeripheralID peripheralID: String) -> PeerID? {
|
||||
assertOwned()
|
||||
return peripherals[peripheralID]?.peerID
|
||||
}
|
||||
|
||||
func peerID(forCentralUUID centralUUID: String) -> PeerID? {
|
||||
assertOwned()
|
||||
return centralToPeerID[centralUUID]
|
||||
}
|
||||
|
||||
func addSubscribedCentral(_ central: CBCentral) {
|
||||
assertOwned()
|
||||
guard !subscribedCentrals.contains(central) else { return }
|
||||
subscribedCentrals.append(central)
|
||||
}
|
||||
|
||||
func removeSubscribedCentral(_ central: CBCentral) -> PeerID? {
|
||||
func removeSubscribedCentral(_ central: CBCentral) {
|
||||
assertOwned()
|
||||
let centralUUID = central.identifier.uuidString
|
||||
subscribedCentrals.removeAll { $0.identifier == central.identifier }
|
||||
return centralToPeerID.removeValue(forKey: centralUUID)
|
||||
}
|
||||
|
||||
func bindCentral(_ centralUUID: String, to peerID: PeerID) {
|
||||
func removePeripheral(_ peripheralID: String) {
|
||||
assertOwned()
|
||||
centralToPeerID[centralUUID] = peerID
|
||||
peripherals.removeValue(forKey: peripheralID)
|
||||
}
|
||||
|
||||
func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) {
|
||||
func clearPeripherals() {
|
||||
assertOwned()
|
||||
var previousPeerID: PeerID?
|
||||
let updated = updatePeripheral(peripheralUUID) {
|
||||
previousPeerID = $0.peerID
|
||||
$0.peerID = peerID
|
||||
}
|
||||
guard updated != nil else { return }
|
||||
// Rebinding (peer-ID rotation): drop the retired ID's reverse mapping
|
||||
// so the old peer no longer claims this link.
|
||||
if let previousPeerID, previousPeerID != peerID,
|
||||
peerToPeripheralUUID[previousPeerID] == peripheralUUID {
|
||||
peerToPeripheralUUID.removeValue(forKey: previousPeerID)
|
||||
}
|
||||
peerToPeripheralUUID[peerID] = peripheralUUID
|
||||
}
|
||||
|
||||
func removePeripheral(_ peripheralID: String) -> PeerID? {
|
||||
assertOwned()
|
||||
let peerID = peripherals.removeValue(forKey: peripheralID)?.peerID
|
||||
// Only clear (or repair) the reverse map when it points at the removed
|
||||
// link: with duplicate links to one peer, removing a stale duplicate
|
||||
// must not strand the peer's surviving bound link.
|
||||
if let peerID, peerToPeripheralUUID[peerID] == peripheralID {
|
||||
// Prefer a writable survivor: repairing onto a link that is
|
||||
// mid-service-rediscovery would strand directed sends until the
|
||||
// characteristic comes back.
|
||||
let survivors = peripherals.filter { $0.value.peerID == peerID && $0.value.isConnected }
|
||||
if let survivorUUID = survivors.first(where: { $0.value.characteristic != nil })?.key ?? survivors.first?.key {
|
||||
peerToPeripheralUUID[peerID] = survivorUUID
|
||||
} else {
|
||||
peerToPeripheralUUID.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
return peerID
|
||||
}
|
||||
|
||||
func clearPeripherals() -> [PeerID] {
|
||||
assertOwned()
|
||||
let peerIDs = peripherals.compactMap { $0.value.peerID }
|
||||
peripherals.removeAll()
|
||||
peerToPeripheralUUID.removeAll()
|
||||
return peerIDs
|
||||
}
|
||||
|
||||
func clearCentrals() -> [PeerID] {
|
||||
func clearCentrals() {
|
||||
assertOwned()
|
||||
let peerIDs = Array(centralToPeerID.values)
|
||||
subscribedCentrals.removeAll()
|
||||
centralToPeerID.removeAll()
|
||||
return peerIDs
|
||||
}
|
||||
|
||||
func clearAll() {
|
||||
assertOwned()
|
||||
peripherals.removeAll()
|
||||
peerToPeripheralUUID.removeAll()
|
||||
subscribedCentrals.removeAll()
|
||||
centralToPeerID.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
113
bitchat/Services/BLE/BLELocalIdentityStateStore.swift
Normal file
113
bitchat/Services/BLE/BLELocalIdentityStateStore.swift
Normal file
@ -0,0 +1,113 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct BLELocalIdentitySnapshot: Equatable, Sendable {
|
||||
let peerID: PeerID
|
||||
let peerIDData: Data
|
||||
let nickname: String
|
||||
/// Runtime-toggled capability bits (e.g. the internet-gateway toggle)
|
||||
/// ORed into `PeerCapabilities.localSupported` for every announce.
|
||||
let runtimeCapabilities: PeerCapabilities
|
||||
/// Rendezvous cell advertised while bridging; rides announces only
|
||||
/// while the `.bridge` capability is enabled.
|
||||
let bridgeGeohash: String?
|
||||
|
||||
var advertisedCapabilities: PeerCapabilities {
|
||||
PeerCapabilities.localSupported.union(runtimeCapabilities)
|
||||
}
|
||||
|
||||
var advertisedBridgeGeohash: String? {
|
||||
runtimeCapabilities.contains(.bridge) ? bridgeGeohash : nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 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, identity, and advertised capabilities instead of
|
||||
/// reading 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,
|
||||
runtimeCapabilities: [],
|
||||
bridgeGeohash: nil
|
||||
)
|
||||
}
|
||||
|
||||
func snapshot() -> BLELocalIdentitySnapshot {
|
||||
lock.withLock { state }
|
||||
}
|
||||
|
||||
func setNickname(_ nickname: String) {
|
||||
lock.withLock {
|
||||
state = BLELocalIdentitySnapshot(
|
||||
peerID: state.peerID,
|
||||
peerIDData: state.peerIDData,
|
||||
nickname: nickname,
|
||||
runtimeCapabilities: state.runtimeCapabilities,
|
||||
bridgeGeohash: state.bridgeGeohash
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func replacePeerIdentity(with peerID: PeerID) {
|
||||
lock.withLock {
|
||||
state = BLELocalIdentitySnapshot(
|
||||
peerID: peerID,
|
||||
peerIDData: Data(hexString: peerID.id) ?? Data(),
|
||||
nickname: state.nickname,
|
||||
runtimeCapabilities: state.runtimeCapabilities,
|
||||
bridgeGeohash: state.bridgeGeohash
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Flips a runtime capability bit. Returns whether anything changed.
|
||||
@discardableResult
|
||||
func setCapability(_ capability: PeerCapabilities, enabled: Bool) -> Bool {
|
||||
lock.withLock {
|
||||
var capabilities = state.runtimeCapabilities
|
||||
if enabled {
|
||||
capabilities.insert(capability)
|
||||
} else {
|
||||
capabilities.remove(capability)
|
||||
}
|
||||
guard capabilities != state.runtimeCapabilities else { return false }
|
||||
state = BLELocalIdentitySnapshot(
|
||||
peerID: state.peerID,
|
||||
peerIDData: state.peerIDData,
|
||||
nickname: state.nickname,
|
||||
runtimeCapabilities: capabilities,
|
||||
bridgeGeohash: state.bridgeGeohash
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the bridged rendezvous cell. Returns whether anything changed.
|
||||
@discardableResult
|
||||
func setBridgeGeohash(_ cell: String?) -> Bool {
|
||||
lock.withLock {
|
||||
guard cell != state.bridgeGeohash else { return false }
|
||||
state = BLELocalIdentitySnapshot(
|
||||
peerID: state.peerID,
|
||||
peerIDData: state.peerIDData,
|
||||
nickname: state.nickname,
|
||||
runtimeCapabilities: state.runtimeCapabilities,
|
||||
bridgeGeohash: cell
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
62
bitchat/Services/BLE/BLEMeshPingTracker.swift
Normal file
62
bitchat/Services/BLE/BLEMeshPingTracker.swift
Normal file
@ -0,0 +1,62 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct BLEMeshPingProbe {
|
||||
let peerID: PeerID
|
||||
let sentAt: Date
|
||||
let lifecycleGeneration: UInt64
|
||||
let completion: @MainActor (MeshPingResult?) -> Void
|
||||
let timeout: DispatchWorkItem
|
||||
}
|
||||
|
||||
/// Engine-confined /ping diagnostics state: outstanding probes keyed by
|
||||
/// their unguessable nonce, plus the inbound response budget.
|
||||
///
|
||||
/// The budget is keyed by the ingress link (the directly connected peer
|
||||
/// that delivered the packet), never the packet-claimed sender: pings are
|
||||
/// unsigned, so the claimed sender is attacker-controlled and rotating it
|
||||
/// would reset the budget, turning a directed unencrypted probe into an
|
||||
/// amplification primitive.
|
||||
///
|
||||
/// Pure state — the transport owns packet I/O, timers, and main-actor
|
||||
/// completion delivery around it.
|
||||
struct BLEMeshPingTracker {
|
||||
private var pendingProbes: [Data: BLEMeshPingProbe] = [:]
|
||||
private var responseLimiter = SyncResponseRateLimiter(
|
||||
maxResponses: TransportConfig.meshPingInboundMaxPerLink,
|
||||
window: TransportConfig.meshPingInboundWindowSeconds
|
||||
)
|
||||
|
||||
mutating func register(_ probe: BLEMeshPingProbe, nonce: Data) {
|
||||
pendingProbes[nonce] = probe
|
||||
}
|
||||
|
||||
/// Resolves a pong against its outstanding probe. The echoed nonce plus
|
||||
/// the sender check bind the reply to the probed peer.
|
||||
mutating func resolve(nonce: Data, from peerID: PeerID) -> BLEMeshPingProbe? {
|
||||
guard pendingProbes[nonce]?.peerID == peerID else { return nil }
|
||||
return pendingProbes.removeValue(forKey: nonce)
|
||||
}
|
||||
|
||||
/// Removes a timed-out probe so its completion can fire once with nil.
|
||||
mutating func expire(nonce: Data) -> BLEMeshPingProbe? {
|
||||
pendingProbes.removeValue(forKey: nonce)
|
||||
}
|
||||
|
||||
/// Whether an inbound ping delivered by this link is within budget.
|
||||
mutating func shouldRespond(toLink linkPeerID: PeerID, now: Date) -> Bool {
|
||||
responseLimiter.shouldRespond(to: linkPeerID, now: now)
|
||||
}
|
||||
|
||||
/// Drops all probes and restores a fresh response budget (panic wipe).
|
||||
/// Returns the orphaned timeout work items for the caller to cancel.
|
||||
mutating func reset() -> [DispatchWorkItem] {
|
||||
let timeouts = pendingProbes.values.map(\.timeout)
|
||||
pendingProbes.removeAll()
|
||||
responseLimiter = SyncResponseRateLimiter(
|
||||
maxResponses: TransportConfig.meshPingInboundMaxPerLink,
|
||||
window: TransportConfig.meshPingInboundWindowSeconds
|
||||
)
|
||||
return timeouts
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,18 @@
|
||||
import BitFoundation
|
||||
import BitLogger
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
struct BLENoiseHandshakeHandlingResult {
|
||||
let processed: Bool
|
||||
let didEstablishAuthenticatedSession: Bool
|
||||
}
|
||||
|
||||
struct BLENoiseDecryptionResult {
|
||||
let plaintext: Data
|
||||
let sessionGeneration: UUID
|
||||
}
|
||||
|
||||
/// Narrow environment for `BLENoisePacketHandler`.
|
||||
///
|
||||
/// All queue hops (collections barrier writes, main-actor UI notification)
|
||||
@ -16,10 +27,15 @@ struct BLENoisePacketHandlerEnvironment {
|
||||
let messageTTL: UInt8
|
||||
/// Current time source.
|
||||
let now: () -> Date
|
||||
/// Processes an inbound handshake message, returning an optional response payload (crypto).
|
||||
let processHandshakeMessage: (_ peerID: PeerID, _ message: Data) throws -> Data?
|
||||
/// Processes an inbound handshake message, returning its optional response
|
||||
/// and whether that exact candidate authenticated (crypto).
|
||||
let processHandshakeMessage:
|
||||
(_ peerID: PeerID, _ message: Data) throws
|
||||
-> NoiseHandshakeProcessingResult
|
||||
/// Whether any Noise session (established or pending) exists for the peer (crypto).
|
||||
let hasNoiseSession: (PeerID) -> Bool
|
||||
/// Whether an inbound ordinary XX responder is waiting for message 3.
|
||||
let isAwaitingResponderHandshakeCompletion: (PeerID) -> Bool
|
||||
/// Initiates a fresh Noise handshake with the peer (crypto + send).
|
||||
let initiateHandshake: (PeerID) -> Void
|
||||
/// Broadcasts a packet on the mesh (caller is already on the message queue).
|
||||
@ -27,9 +43,16 @@ struct BLENoisePacketHandlerEnvironment {
|
||||
/// Updates the registry last-seen timestamp for the peer (async barrier write).
|
||||
let updatePeerLastSeen: (PeerID) -> Void
|
||||
/// Decrypts an encrypted payload from the peer (crypto).
|
||||
let decrypt: (_ payload: Data, _ peerID: PeerID) throws -> Data
|
||||
let decrypt: (_ payload: Data, _ peerID: PeerID) throws -> BLENoiseDecryptionResult
|
||||
/// Clears the peer's Noise session after an unrecoverable decrypt failure (crypto).
|
||||
let clearSession: (PeerID) -> Void
|
||||
/// Consumes session-authenticated protocol state inside the transport. It
|
||||
/// must never escape to UI or Nostr payload dispatch.
|
||||
let handleAuthenticatedPeerState: (
|
||||
_ peerID: PeerID,
|
||||
_ payload: Data,
|
||||
_ sessionGeneration: UUID
|
||||
) -> Void
|
||||
/// Delivers `.noisePayloadReceived` to the UI as one main-actor hop.
|
||||
let deliverNoisePayload: (
|
||||
_ peerID: PeerID,
|
||||
@ -43,19 +66,55 @@ struct BLENoisePacketHandlerEnvironment {
|
||||
/// processing (with response), encrypted payload decryption and dispatch,
|
||||
/// and session recovery on decrypt failure.
|
||||
final class BLENoisePacketHandler {
|
||||
private struct DeferredCiphertext {
|
||||
let packet: BitchatPacket
|
||||
let receivedAt: Date
|
||||
}
|
||||
|
||||
/// Early post-handshake packets are normally tiny control messages or
|
||||
/// queued DMs. Keep the recovery surface deliberately small so an
|
||||
/// unauthenticated half-handshake cannot create an unbounded memory queue.
|
||||
private static let maxDeferredPacketsPerPeer = 4
|
||||
private static let maxDeferredPacketsGlobal = 32
|
||||
/// One legacy sender can immediately follow message 3 with the largest
|
||||
/// valid private-file ciphertext and has no application-level retry. Keep
|
||||
/// room for that packet plus a small control-message budget.
|
||||
private static let maxDeferredBytes =
|
||||
NoiseSecurityConstants.maxPrivateFileCiphertextSize + 256 * 1024
|
||||
private static let deferredLifetime =
|
||||
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
|
||||
|
||||
private let environment: BLENoisePacketHandlerEnvironment
|
||||
private let deferredLock = NSLock()
|
||||
private var deferredCiphertexts: [PeerID: [DeferredCiphertext]] = [:]
|
||||
private var deferredCiphertextBytes = 0
|
||||
|
||||
init(environment: BLENoisePacketHandlerEnvironment) {
|
||||
self.environment = environment
|
||||
}
|
||||
|
||||
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
/// Returns true when the handshake message was processed successfully.
|
||||
/// Callers use this to distinguish an authenticated reconnect completion
|
||||
/// from a rejected ordinary responder while rollback state is restored.
|
||||
@discardableResult
|
||||
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
||||
handleHandshakeWithResult(packet, from: peerID).processed
|
||||
}
|
||||
|
||||
func handleHandshakeWithResult(
|
||||
_ packet: BitchatPacket,
|
||||
from peerID: PeerID
|
||||
) -> BLENoiseHandshakeHandlingResult {
|
||||
let env = environment
|
||||
// Use NoiseEncryptionService for handshake processing
|
||||
if PeerID(hexData: packet.recipientID) == env.localPeerID() {
|
||||
// Handshake is for us
|
||||
do {
|
||||
if let response = try env.processHandshakeMessage(peerID, packet.payload) {
|
||||
let result = try env.processHandshakeMessage(
|
||||
peerID,
|
||||
packet.payload
|
||||
)
|
||||
if let response = result.response {
|
||||
// Send response
|
||||
let responsePacket = BitchatPacket(
|
||||
type: MessageType.noiseHandshake.rawValue,
|
||||
@ -70,19 +129,76 @@ final class BLENoisePacketHandler {
|
||||
env.broadcastPacket(responsePacket)
|
||||
}
|
||||
|
||||
// Session establishment will trigger onPeerAuthenticated callback
|
||||
// which will send any pending messages at the right time
|
||||
// The serialized authentication callback installs transport
|
||||
// state before it drains any bounded early ciphertext.
|
||||
return BLENoiseHandshakeHandlingResult(
|
||||
processed: true,
|
||||
didEstablishAuthenticatedSession:
|
||||
result.didEstablishAuthenticatedSession
|
||||
)
|
||||
} catch let managedFailure as NoiseManagedHandshakeFailure {
|
||||
SecureLogger.error(
|
||||
"Failed to process handshake; manager owns recovery: \(managedFailure.underlying)"
|
||||
)
|
||||
return BLENoiseHandshakeHandlingResult(
|
||||
processed: false,
|
||||
didEstablishAuthenticatedSession: false
|
||||
)
|
||||
} catch NoiseSessionError.peerIdentityMismatch {
|
||||
// The responder was already discarded by the session manager.
|
||||
// Do not let a spoofed claimed ID trigger a fresh outbound
|
||||
// handshake or recreate state for the attacker-selected ID.
|
||||
SecureLogger.warning(
|
||||
"Rejected Noise handshake whose static key does not match \(peerID.id.prefix(8))…",
|
||||
category: .security
|
||||
)
|
||||
return BLENoiseHandshakeHandlingResult(
|
||||
processed: false,
|
||||
didEstablishAuthenticatedSession: false
|
||||
)
|
||||
} catch {
|
||||
SecureLogger.error("Failed to process handshake: \(error)")
|
||||
// Try initiating a new handshake
|
||||
if !env.hasNoiseSession(peerID) {
|
||||
env.initiateHandshake(peerID)
|
||||
}
|
||||
return BLENoiseHandshakeHandlingResult(
|
||||
processed: false,
|
||||
didEstablishAuthenticatedSession: false
|
||||
)
|
||||
}
|
||||
}
|
||||
return BLENoiseHandshakeHandlingResult(
|
||||
processed: false,
|
||||
didEstablishAuthenticatedSession: false
|
||||
)
|
||||
}
|
||||
|
||||
func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
handleEncrypted(packet, from: peerID, isDeferredRetry: false)
|
||||
}
|
||||
|
||||
/// Called by the transport's serialized authentication callback after it
|
||||
/// has installed state for the promoted or restored session generation.
|
||||
func handleSessionAuthenticated(_ peerID: PeerID) {
|
||||
drainDeferredCiphertextsIfReady(for: peerID)
|
||||
}
|
||||
|
||||
/// Synchronously discards ciphertext retained for a pre-panic Noise
|
||||
/// generation. The handler survives the service's identity replacement,
|
||||
/// so keeping this queue would replay old bytes after post-panic auth.
|
||||
func resetForPanic() {
|
||||
deferredLock.lock()
|
||||
deferredCiphertexts.removeAll(keepingCapacity: false)
|
||||
deferredCiphertextBytes = 0
|
||||
deferredLock.unlock()
|
||||
}
|
||||
|
||||
private func handleEncrypted(
|
||||
_ packet: BitchatPacket,
|
||||
from peerID: PeerID,
|
||||
isDeferredRetry: Bool
|
||||
) {
|
||||
let env = environment
|
||||
guard let recipientID = PeerID(hexData: packet.recipientID) else {
|
||||
SecureLogger.warning("⚠️ Encrypted message has no recipient ID", category: .session)
|
||||
@ -98,35 +214,231 @@ final class BLENoisePacketHandler {
|
||||
env.updatePeerLastSeen(peerID)
|
||||
|
||||
do {
|
||||
let decrypted = try env.decrypt(packet.payload, peerID)
|
||||
let decryption = try env.decrypt(packet.payload, peerID)
|
||||
let decrypted = decryption.plaintext
|
||||
guard decrypted.count > 0 else { return }
|
||||
|
||||
// First byte indicates the payload type
|
||||
let payloadType = decrypted[0]
|
||||
let payloadData = decrypted.dropFirst()
|
||||
|
||||
guard let noisePayloadType = NoisePayloadType(rawValue: payloadType) else {
|
||||
guard let noisePayloadType = NoisePayloadType.decoded(rawValue: payloadType) else {
|
||||
SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)")
|
||||
return
|
||||
}
|
||||
|
||||
SecureLogger.debug("🔐 Decrypted noise payload type \(noisePayloadType.description) from \(peerID.id.prefix(8))…", category: .session)
|
||||
|
||||
if noisePayloadType == .authenticatedPeerState {
|
||||
env.handleAuthenticatedPeerState(
|
||||
peerID,
|
||||
Data(payloadData),
|
||||
decryption.sessionGeneration
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||
env.deliverNoisePayload(peerID, noisePayloadType, Data(payloadData), ts)
|
||||
} catch NoiseEncryptionError.transportGenerationNotReady {
|
||||
if isDeferredRetry {
|
||||
SecureLogger.warning(
|
||||
"Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… because its authenticated transport generation changed again",
|
||||
category: .session
|
||||
)
|
||||
return
|
||||
}
|
||||
// The manager promoted or restored keys before BLE's serialized
|
||||
// callback installed generation-bound transport state. The
|
||||
// manager rejected this before decrypting, so replay is safe.
|
||||
deferCiphertext(packet, from: peerID)
|
||||
} catch NoiseEncryptionError.sessionNotEstablished {
|
||||
if isDeferredRetry {
|
||||
SecureLogger.warning(
|
||||
"Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… because the authenticated session is unavailable",
|
||||
category: .session
|
||||
)
|
||||
return
|
||||
}
|
||||
// We received an encrypted message before establishing a session with this peer.
|
||||
// Trigger a handshake so future messages can be decrypted.
|
||||
// An initiator may already have sent message 3 followed by this
|
||||
// ciphertext, with BLE delivering the ciphertext first.
|
||||
if env.isAwaitingResponderHandshakeCompletion(peerID) {
|
||||
deferCiphertext(packet, from: peerID)
|
||||
return
|
||||
}
|
||||
// Otherwise trigger a handshake so future messages can decrypt.
|
||||
SecureLogger.debug("🔑 Encrypted message from \(peerID.id.prefix(8))… without session; initiating handshake")
|
||||
if !env.hasNoiseSession(peerID) {
|
||||
env.initiateHandshake(peerID)
|
||||
}
|
||||
} catch {
|
||||
if isDeferredRetry {
|
||||
// An early packet cannot tear down the authenticated session
|
||||
// merely because its single bounded retry still fails.
|
||||
SecureLogger.warning(
|
||||
"Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… after retry failed: \(error)",
|
||||
category: .session
|
||||
)
|
||||
return
|
||||
}
|
||||
// A responder may retain an older transport as receive-only
|
||||
// rollback state while ordinary XX waits for message 3. New-key
|
||||
// ciphertext can fail against those retained receive keys first.
|
||||
if env.isAwaitingResponderHandshakeCompletion(peerID) {
|
||||
if isDeferrableEarlyHandshakeFailure(error) {
|
||||
deferCiphertext(packet, from: peerID)
|
||||
} else {
|
||||
SecureLogger.warning(
|
||||
"Dropping invalid Noise ciphertext from \(peerID.id.prefix(8))… while responder handshake is completing: \(error)",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
if isDropOnlyCiphertextFailure(error) {
|
||||
// The packet is attacker-controlled and did not prove a
|
||||
// transport-state failure. Never let malformed, replayed,
|
||||
// forged, oversized, or rate-limited bytes evict working keys.
|
||||
SecureLogger.warning(
|
||||
"Dropping rejected Noise ciphertext from \(peerID.id.prefix(8))… without clearing its session: \(error)",
|
||||
category: .security
|
||||
)
|
||||
return
|
||||
}
|
||||
// Decryption failed - clear the corrupted session and re-initiate handshake
|
||||
// This handles cases where session state got out of sync (nonce mismatch, etc.)
|
||||
// Only local/session lifecycle failures reach this path.
|
||||
SecureLogger.error("❌ Failed to decrypt message from \(peerID.id.prefix(8))…: \(error) - clearing session and re-initiating handshake")
|
||||
env.clearSession(peerID)
|
||||
env.initiateHandshake(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
private func isDeferrableEarlyHandshakeFailure(_ error: Error) -> Bool {
|
||||
if let noiseError = error as? NoiseError {
|
||||
switch noiseError {
|
||||
case .authenticationFailure, .replayDetected:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
if let cryptoError = error as? CryptoKitError,
|
||||
case .authenticationFailure = cryptoError {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func isDropOnlyCiphertextFailure(_ error: Error) -> Bool {
|
||||
if let securityError = error as? NoiseSecurityError {
|
||||
switch securityError {
|
||||
case .messageTooLarge, .rateLimitExceeded, .invalidPeerID:
|
||||
return true
|
||||
case .sessionExpired, .sessionExhausted:
|
||||
return false
|
||||
}
|
||||
}
|
||||
if let noiseError = error as? NoiseError {
|
||||
switch noiseError {
|
||||
case .invalidCiphertext, .authenticationFailure, .replayDetected:
|
||||
return true
|
||||
case .uninitializedCipher, .handshakeComplete,
|
||||
.handshakeNotComplete, .missingLocalStaticKey,
|
||||
.missingKeys, .invalidMessage, .invalidPublicKey,
|
||||
.nonceExceeded:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return error is CryptoKitError
|
||||
}
|
||||
|
||||
private func deferCiphertext(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
guard NoiseSecurityValidator.validatePrivateFileCiphertextSize(
|
||||
packet.payload
|
||||
) else {
|
||||
SecureLogger.warning(
|
||||
"Dropping oversized early Noise ciphertext from \(peerID.id.prefix(8))…",
|
||||
category: .security
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let now = environment.now()
|
||||
deferredLock.lock()
|
||||
defer { deferredLock.unlock() }
|
||||
purgeExpiredCiphertextsLocked(now: now)
|
||||
|
||||
let peerCount = deferredCiphertexts[peerID]?.count ?? 0
|
||||
let globalCount = deferredCiphertexts.values.reduce(0) {
|
||||
$0 + $1.count
|
||||
}
|
||||
guard peerCount < Self.maxDeferredPacketsPerPeer,
|
||||
globalCount < Self.maxDeferredPacketsGlobal,
|
||||
deferredCiphertextBytes + packet.payload.count
|
||||
<= Self.maxDeferredBytes else {
|
||||
SecureLogger.warning(
|
||||
"Dropping early Noise ciphertext from \(peerID.id.prefix(8))… because the handshake buffer is full",
|
||||
category: .security
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
deferredCiphertexts[peerID, default: []].append(
|
||||
DeferredCiphertext(packet: packet, receivedAt: now)
|
||||
)
|
||||
deferredCiphertextBytes += packet.payload.count
|
||||
SecureLogger.debug(
|
||||
"Deferring early Noise ciphertext from \(peerID.id.prefix(8))… until responder handshake completion",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
|
||||
private func drainDeferredCiphertextsIfReady(for peerID: PeerID) {
|
||||
let env = environment
|
||||
guard !env.isAwaitingResponderHandshakeCompletion(peerID),
|
||||
env.hasNoiseSession(peerID) else {
|
||||
return
|
||||
}
|
||||
|
||||
let now = env.now()
|
||||
deferredLock.lock()
|
||||
purgeExpiredCiphertextsLocked(now: now)
|
||||
let deferred = deferredCiphertexts.removeValue(forKey: peerID) ?? []
|
||||
deferredCiphertextBytes -= deferred.reduce(0) {
|
||||
$0 + $1.packet.payload.count
|
||||
}
|
||||
deferredLock.unlock()
|
||||
|
||||
guard !deferred.isEmpty else { return }
|
||||
SecureLogger.debug(
|
||||
"Retrying \(deferred.count) early Noise ciphertext packet(s) from \(peerID.id.prefix(8))… after handshake completion",
|
||||
category: .session
|
||||
)
|
||||
for item in deferred {
|
||||
handleEncrypted(item.packet, from: peerID, isDeferredRetry: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func purgeExpiredCiphertextsLocked(now: Date) {
|
||||
for peerID in Array(deferredCiphertexts.keys) {
|
||||
guard let items = deferredCiphertexts[peerID] else { continue }
|
||||
let retained = items.filter {
|
||||
now.timeIntervalSince($0.receivedAt) <= Self.deferredLifetime
|
||||
}
|
||||
guard retained.count != items.count else { continue }
|
||||
|
||||
deferredCiphertextBytes -= items.reduce(0) {
|
||||
$0 + $1.packet.payload.count
|
||||
}
|
||||
deferredCiphertextBytes += retained.reduce(0) {
|
||||
$0 + $1.packet.payload.count
|
||||
}
|
||||
if retained.isEmpty {
|
||||
deferredCiphertexts.removeValue(forKey: peerID)
|
||||
} else {
|
||||
deferredCiphertexts[peerID] = retained
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
40
bitchat/Services/BLE/BLENoiseReconnectPolicy.swift
Normal file
40
bitchat/Services/BLE/BLENoiseReconnectPolicy.swift
Normal file
@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
|
||||
/// Bounds ordinary Noise revalidation to one attempt per physical-link epoch.
|
||||
/// A live epoch may retry after the cooldown so a lost handshake cannot leave
|
||||
/// the link permanently unauthenticated.
|
||||
struct BLENoiseReconnectPolicy {
|
||||
static let minimumRetryInterval: TimeInterval = 60
|
||||
|
||||
private var lastAttemptAt: [BLEIngressLinkID: Date] = [:]
|
||||
|
||||
mutating func shouldRevalidate(
|
||||
on link: BLEIngressLinkID,
|
||||
hasEstablishedSession: Bool,
|
||||
isNoiseAuthenticatedLink: Bool,
|
||||
hasAuthenticatedPeerLink: Bool,
|
||||
now: Date
|
||||
) -> Bool {
|
||||
guard hasEstablishedSession,
|
||||
!isNoiseAuthenticatedLink,
|
||||
!hasAuthenticatedPeerLink else {
|
||||
return false
|
||||
}
|
||||
if let previous = lastAttemptAt[link],
|
||||
now.timeIntervalSince(previous) < Self.minimumRetryInterval {
|
||||
return false
|
||||
}
|
||||
lastAttemptAt[link] = now
|
||||
return true
|
||||
}
|
||||
|
||||
/// Link identifiers can be stable across CoreBluetooth reconnects, so a
|
||||
/// disconnect explicitly starts a new epoch and permits one fresh attempt.
|
||||
mutating func endLinkEpoch(_ link: BLEIngressLinkID) {
|
||||
lastAttemptAt.removeValue(forKey: link)
|
||||
}
|
||||
|
||||
mutating func removeAll() {
|
||||
lastAttemptAt.removeAll()
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -260,8 +261,6 @@ struct BLEOutboundFragmentTransferScheduler {
|
||||
continue
|
||||
}
|
||||
|
||||
availableSlots -= 1
|
||||
|
||||
guard activeTransfers.count < maxConcurrentTransfers else {
|
||||
pendingTransfers.insert(request, at: 0)
|
||||
results.append(.queued(request: request, transferId: transferId, position: .front))
|
||||
@ -269,11 +268,17 @@ struct BLEOutboundFragmentTransferScheduler {
|
||||
}
|
||||
|
||||
guard activeTransfers[transferId] == nil else {
|
||||
// Blocked on an already-active copy of this content: leave
|
||||
// the slot budget untouched so a later, unrelated pending
|
||||
// transfer can still start in this same pass instead of
|
||||
// being starved until some other transfer happens to
|
||||
// complete.
|
||||
blockedFront.append(request)
|
||||
results.append(.queued(request: request, transferId: transferId, position: .front))
|
||||
continue
|
||||
}
|
||||
|
||||
availableSlots -= 1
|
||||
activeTransfers[transferId] = ActiveTransferState(
|
||||
totalFragments: 0,
|
||||
sentFragments: 0,
|
||||
|
||||
@ -10,6 +10,9 @@ struct BLEPeerInfo: Equatable {
|
||||
var isVerifiedNickname: Bool
|
||||
var lastSeen: Date
|
||||
var capabilities: PeerCapabilities = []
|
||||
/// Distinguishes an old client that omitted the capabilities TLV from a
|
||||
/// modern client that explicitly advertised a set without a given bit.
|
||||
var capabilitiesWereExplicitlyAdvertised: Bool = false
|
||||
/// Rendezvous cell from the peer's announce when it advertises `.bridge`.
|
||||
var bridgeGeohash: String?
|
||||
}
|
||||
@ -114,6 +117,10 @@ struct BLEPeerRegistry {
|
||||
peers[peerID.toShort()]?.capabilities ?? []
|
||||
}
|
||||
|
||||
func capabilitiesWereExplicitlyAdvertised(for peerID: PeerID) -> Bool {
|
||||
peers[peerID.toShort()]?.capabilitiesWereExplicitlyAdvertised == true
|
||||
}
|
||||
|
||||
/// Peers whose last verified announce advertised the given capability.
|
||||
func peers(advertising capability: PeerCapabilities) -> [PeerID] {
|
||||
peers.values.filter { $0.capabilities.contains(capability) }.map(\.peerID)
|
||||
@ -174,6 +181,22 @@ struct BLEPeerRegistry {
|
||||
peers[peerID] = peer
|
||||
}
|
||||
|
||||
/// Replaces the announcement signing key only after the surrounding Noise
|
||||
/// session proved possession of this peer's static key.
|
||||
mutating func bindAuthenticatedSigningPublicKey(_ key: Data, for peerID: PeerID) {
|
||||
guard var peer = peers[peerID.toShort()] else { return }
|
||||
peer.signingPublicKey = key
|
||||
peers[peer.peerID] = peer
|
||||
}
|
||||
|
||||
/// Applies a verified announce to the registry.
|
||||
///
|
||||
/// TOFU signing-key pinning: once a signing key has been bound to this
|
||||
/// peer entry, an announce carrying a *different* signing key is refused
|
||||
/// (returns `nil`) and the existing record is left untouched. PeerIDs are
|
||||
/// derived from the (public) noise key, so without pinning an attacker
|
||||
/// could replay a victim's noiseKey/peerID with their own signing key and
|
||||
/// silently take over the victim's mesh identity and nickname.
|
||||
mutating func upsertVerifiedAnnounce(
|
||||
peerID: PeerID,
|
||||
nickname: String,
|
||||
@ -181,10 +204,17 @@ struct BLEPeerRegistry {
|
||||
signingPublicKey: Data?,
|
||||
isConnected: Bool,
|
||||
now: Date,
|
||||
capabilities: PeerCapabilities = [],
|
||||
capabilities: PeerCapabilities? = nil,
|
||||
bridgeGeohash: String? = nil
|
||||
) -> BLEPeerAnnounceUpdate {
|
||||
) -> BLEPeerAnnounceUpdate? {
|
||||
let existing = peers[peerID]
|
||||
|
||||
if let pinnedSigningKey = existing?.signingPublicKey,
|
||||
let announcedSigningKey = signingPublicKey,
|
||||
pinnedSigningKey != announcedSigningKey {
|
||||
return nil
|
||||
}
|
||||
|
||||
let update = BLEPeerAnnounceUpdate(
|
||||
isNewPeer: existing == nil,
|
||||
wasDisconnected: existing?.isConnected == false,
|
||||
@ -193,13 +223,15 @@ struct BLEPeerRegistry {
|
||||
|
||||
peers[peerID] = BLEPeerInfo(
|
||||
peerID: existing?.peerID ?? peerID,
|
||||
nickname: nickname,
|
||||
nickname: nickname.normalizedNickname,
|
||||
isConnected: isConnected,
|
||||
noisePublicKey: noisePublicKey,
|
||||
signingPublicKey: signingPublicKey,
|
||||
// Never drop an already-pinned signing key.
|
||||
signingPublicKey: signingPublicKey ?? existing?.signingPublicKey,
|
||||
isVerifiedNickname: true,
|
||||
lastSeen: now,
|
||||
capabilities: capabilities,
|
||||
capabilities: capabilities ?? [],
|
||||
capabilitiesWereExplicitlyAdvertised: capabilities != nil,
|
||||
bridgeGeohash: bridgeGeohash
|
||||
)
|
||||
|
||||
|
||||
84
bitchat/Services/BLE/BLEPeerRegistryStore.swift
Normal file
84
bitchat/Services/BLE/BLEPeerRegistryStore.swift
Normal file
@ -0,0 +1,84 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
/// Lock-backed shared ownership of the peer registry, readable from any
|
||||
/// queue or the main actor without hopping onto a transport queue.
|
||||
///
|
||||
/// Mutations come only from the transport's own serial queues — the
|
||||
/// engine, plus the bleQueue link-drop paths that mark a peer
|
||||
/// disconnected — and the lock serializes them against each other and
|
||||
/// against readers, so the main actor answers questions like
|
||||
/// `isPeerConnected` without blocking behind in-flight transport work.
|
||||
/// Every `BLEPeerRegistry` mutation is a single whole-transition method,
|
||||
/// so a reader between two mutations always observes a valid pre- or
|
||||
/// post-state, never a torn one.
|
||||
///
|
||||
/// Closures passed to `read`/`mutate` run under the (non-recursive) lock
|
||||
/// and must not call back into the store.
|
||||
final class BLEPeerRegistryStore: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var registry = BLEPeerRegistry()
|
||||
|
||||
/// One consistent view across multiple registry reads.
|
||||
func read<T>(_ body: (BLEPeerRegistry) -> T) -> T {
|
||||
lock.withLock { body(registry) }
|
||||
}
|
||||
|
||||
func mutate<T>(_ body: (inout BLEPeerRegistry) -> T) -> T {
|
||||
lock.withLock { body(®istry) }
|
||||
}
|
||||
|
||||
// MARK: - Single-question reads
|
||||
|
||||
var isEmpty: Bool { read { $0.isEmpty } }
|
||||
var peerIDs: [PeerID] { read { $0.peerIDs } }
|
||||
var connectedCount: Int { read { $0.connectedCount } }
|
||||
var connectedPeerIDs: [PeerID] { read { $0.connectedPeerIDs } }
|
||||
var connectedRoutingData: [Data] { read { $0.connectedRoutingData } }
|
||||
var snapshotByID: [PeerID: BLEPeerInfo] { read { $0.snapshotByID } }
|
||||
|
||||
func info(for peerID: PeerID) -> BLEPeerInfo? {
|
||||
read { $0.info(for: peerID) }
|
||||
}
|
||||
|
||||
func isConnected(_ peerID: PeerID) -> Bool {
|
||||
read { $0.isConnected(peerID) }
|
||||
}
|
||||
|
||||
func isReachable(_ peerID: PeerID, now: Date) -> Bool {
|
||||
read { $0.isReachable(peerID, now: now) }
|
||||
}
|
||||
|
||||
func nickname(for peerID: PeerID, connectedOnly: Bool) -> String? {
|
||||
read { $0.nickname(for: peerID, connectedOnly: connectedOnly) }
|
||||
}
|
||||
|
||||
func fingerprint(for peerID: PeerID) -> String? {
|
||||
read { $0.fingerprint(for: peerID) }
|
||||
}
|
||||
|
||||
func capabilities(for peerID: PeerID) -> PeerCapabilities {
|
||||
read { $0.capabilities(for: peerID) }
|
||||
}
|
||||
|
||||
func advertisedBridgeGeohash() -> String? {
|
||||
read { $0.advertisedBridgeGeohash() }
|
||||
}
|
||||
|
||||
func displayNicknames(selfNickname: String) -> [PeerID: String] {
|
||||
read { $0.displayNicknames(selfNickname: selfNickname) }
|
||||
}
|
||||
|
||||
func transportSnapshots(selfNickname: String) -> [TransportPeerSnapshot] {
|
||||
read { $0.transportSnapshots(selfNickname: selfNickname) }
|
||||
}
|
||||
|
||||
/// Peers advertising `capability` that are reachable now, in one
|
||||
/// consistent view.
|
||||
func reachablePeers(advertising capability: PeerCapabilities, now: Date) -> [PeerID] {
|
||||
read { registry in
|
||||
registry.peers(advertising: capability)
|
||||
.filter { registry.isReachable($0, now: now) }
|
||||
}
|
||||
}
|
||||
}
|
||||
1143
bitchat/Services/BLE/BLEPrivateMediaReceiptStore.swift
Normal file
1143
bitchat/Services/BLE/BLEPrivateMediaReceiptStore.swift
Normal file
File diff suppressed because it is too large
Load Diff
363
bitchat/Services/BLE/BLEPrivateMediaSessionStore.swift
Normal file
363
bitchat/Services/BLE/BLEPrivateMediaSessionStore.swift
Normal file
@ -0,0 +1,363 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct BLEAuthenticatedPeerStateObservation {
|
||||
let fingerprint: String
|
||||
let sessionGeneration: UUID
|
||||
let capabilities: PeerCapabilities
|
||||
}
|
||||
|
||||
struct BLEPrivateMediaProofTimeoutMarker {
|
||||
let fingerprint: String
|
||||
let sessionGeneration: UUID?
|
||||
}
|
||||
|
||||
struct BLEPrivateMediaProofWatchdog {
|
||||
let fingerprint: String
|
||||
let sessionGeneration: UUID
|
||||
let timeoutNonce: UUID
|
||||
}
|
||||
|
||||
struct BLEPendingPrivateMediaPolicyResolution {
|
||||
let fingerprint: String
|
||||
var sessionGeneration: UUID?
|
||||
var timeoutNonce: UUID
|
||||
var completions: [UUID: @MainActor (PrivateMediaSendPolicy) -> Void]
|
||||
}
|
||||
|
||||
struct BLEAuthenticatedPeerStateSendProgress {
|
||||
let sessionGeneration: UUID
|
||||
var sentInitial = false
|
||||
var sentEcho = false
|
||||
}
|
||||
|
||||
/// Lock-backed private-media session state: which Noise generation each
|
||||
/// peer's capability proof, peer-state exchange, and policy waiters are
|
||||
/// bound to. A fresh Noise authentication rotates the generation UUID, so
|
||||
/// stale proof timers and proof packets cannot classify a replacement
|
||||
/// session.
|
||||
///
|
||||
/// Lock-backed rather than engine-confined for two reasons: the send
|
||||
/// policy is answered synchronously on the main actor, and several
|
||||
/// transitions run inside noise-manager critical sections that the engine
|
||||
/// is sync-waiting on (where re-entering the engine would self-deadlock,
|
||||
/// but taking a leaf lock is safe). Every method is one whole transition
|
||||
/// under the lock, so no caller can observe a torn intermediate state.
|
||||
final class BLEPrivateMediaSessionStore: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var sessionGenerations: [PeerID: UUID] = [:]
|
||||
private var authenticatedStates: [PeerID: BLEAuthenticatedPeerStateObservation] = [:]
|
||||
private var proofTimeoutMarkers: [PeerID: BLEPrivateMediaProofTimeoutMarker] = [:]
|
||||
private var proofWatchdogs: [PeerID: BLEPrivateMediaProofWatchdog] = [:]
|
||||
private var pendingPolicyResolutions: [PeerID: BLEPendingPrivateMediaPolicyResolution] = [:]
|
||||
private var stateSendProgress: [PeerID: BLEAuthenticatedPeerStateSendProgress] = [:]
|
||||
/// Peers whose parked outbound queues must stay parked until the
|
||||
/// convergence retry re-authenticates: a timeout-restore brings back
|
||||
/// keys the counterpart may have already discarded, so nothing — not
|
||||
/// even the capability-proof watchdog — may drain the queues under
|
||||
/// them. Set on the deferred restore transition, cleared by any
|
||||
/// transition that is allowed to drain.
|
||||
private var outboundConvergenceDeferred: Set<PeerID> = []
|
||||
|
||||
// MARK: Reads
|
||||
|
||||
func currentGeneration(for peerID: PeerID) -> UUID? {
|
||||
lock.withLock { sessionGenerations[peerID] }
|
||||
}
|
||||
|
||||
/// The exact current generation iff it authenticated both encrypted
|
||||
/// private media (bit 8) and durable receipts/retry (bit 9).
|
||||
func receiptSessionGeneration(for peerID: PeerID, currentNoiseGeneration: UUID?) -> UUID? {
|
||||
lock.withLock {
|
||||
guard let generation = sessionGenerations[peerID],
|
||||
generation == currentNoiseGeneration,
|
||||
let authenticated = authenticatedStates[peerID],
|
||||
authenticated.sessionGeneration == generation,
|
||||
authenticated.capabilities.contains(.privateMedia),
|
||||
authenticated.capabilities.contains(.privateMediaReceipts) else {
|
||||
return nil
|
||||
}
|
||||
return generation
|
||||
}
|
||||
}
|
||||
|
||||
/// One consistent view of the state the send-policy calculus needs.
|
||||
func policyInputs(for peerID: PeerID) -> (
|
||||
sessionGeneration: UUID?,
|
||||
authenticatedState: BLEAuthenticatedPeerStateObservation?,
|
||||
timedOut: BLEPrivateMediaProofTimeoutMarker?
|
||||
) {
|
||||
lock.withLock {
|
||||
(
|
||||
sessionGenerations[peerID],
|
||||
authenticatedStates[peerID],
|
||||
proofTimeoutMarkers[peerID]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func hasPendingPolicyResolution(for peerID: PeerID) -> Bool {
|
||||
lock.withLock { pendingPolicyResolutions[peerID] != nil }
|
||||
}
|
||||
|
||||
/// The live proof-timeout identity for a peer (watchdog first, then a
|
||||
/// registered waiter) — what a forced/expired timeout must present.
|
||||
func proofTimeoutTarget(for peerID: PeerID) -> (fingerprint: String, generation: UUID?, nonce: UUID)? {
|
||||
lock.withLock {
|
||||
if let watchdog = proofWatchdogs[peerID] {
|
||||
return (watchdog.fingerprint, watchdog.sessionGeneration, watchdog.timeoutNonce)
|
||||
}
|
||||
if let pending = pendingPolicyResolutions[peerID] {
|
||||
return (pending.fingerprint, pending.sessionGeneration, pending.timeoutNonce)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Generation transitions
|
||||
|
||||
/// Installs a freshly authenticated generation: rotates the proof
|
||||
/// watchdog, resets peer-state send progress, and re-binds any pending
|
||||
/// policy waiters whose fingerprint still matches (mismatched waiters
|
||||
/// are rejected and returned for completion). Returns nil when the
|
||||
/// generation is already current — the same-generation reconciliation
|
||||
/// path, which must not re-arm proof machinery.
|
||||
func beginAuthenticatedGeneration(
|
||||
for peerID: PeerID,
|
||||
fingerprint: String,
|
||||
generation: UUID
|
||||
) -> (watchdogNonce: UUID, rejected: [@MainActor (PrivateMediaSendPolicy) -> Void])? {
|
||||
lock.withLock {
|
||||
guard sessionGenerations[peerID] != generation else { return nil }
|
||||
let watchdogNonce = UUID()
|
||||
sessionGenerations[peerID] = generation
|
||||
authenticatedStates.removeValue(forKey: peerID)
|
||||
proofTimeoutMarkers.removeValue(forKey: peerID)
|
||||
proofWatchdogs[peerID] = BLEPrivateMediaProofWatchdog(
|
||||
fingerprint: fingerprint,
|
||||
sessionGeneration: generation,
|
||||
timeoutNonce: watchdogNonce
|
||||
)
|
||||
stateSendProgress[peerID] =
|
||||
BLEAuthenticatedPeerStateSendProgress(sessionGeneration: generation)
|
||||
|
||||
guard var pending = pendingPolicyResolutions[peerID] else {
|
||||
return (watchdogNonce, [])
|
||||
}
|
||||
guard pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame else {
|
||||
pendingPolicyResolutions.removeValue(forKey: peerID)
|
||||
return (watchdogNonce, Array(pending.completions.values))
|
||||
}
|
||||
pending.sessionGeneration = generation
|
||||
pending.timeoutNonce = watchdogNonce
|
||||
pendingPolicyResolutions[peerID] = pending
|
||||
return (watchdogNonce, [])
|
||||
}
|
||||
}
|
||||
|
||||
/// Records a verified authenticated-peer-state packet for the current
|
||||
/// generation: pins the observation, retires proof timers, and releases
|
||||
/// matching policy waiters. Returns nil when the generation is no longer
|
||||
/// current (the caller's lease raced a replacement).
|
||||
func applyAuthenticatedPeerState(
|
||||
for peerID: PeerID,
|
||||
fingerprint: String,
|
||||
generation: UUID,
|
||||
capabilities: PeerCapabilities
|
||||
) -> [@MainActor (PrivateMediaSendPolicy) -> Void]? {
|
||||
lock.withLock {
|
||||
guard sessionGenerations[peerID] == generation else { return nil }
|
||||
authenticatedStates[peerID] = BLEAuthenticatedPeerStateObservation(
|
||||
fingerprint: fingerprint,
|
||||
sessionGeneration: generation,
|
||||
capabilities: capabilities
|
||||
)
|
||||
proofTimeoutMarkers.removeValue(forKey: peerID)
|
||||
proofWatchdogs.removeValue(forKey: peerID)
|
||||
guard let pending = pendingPolicyResolutions.removeValue(forKey: peerID),
|
||||
pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame,
|
||||
pending.sessionGeneration == generation else {
|
||||
return []
|
||||
}
|
||||
return Array(pending.completions.values)
|
||||
}
|
||||
}
|
||||
|
||||
/// Consumes one peer-state send slot (initial or echo) for the current
|
||||
/// generation. Returns whether the packet should actually go out.
|
||||
func markPeerStateSend(for peerID: PeerID, echo: Bool) -> Bool {
|
||||
lock.withLock {
|
||||
guard let generation = sessionGenerations[peerID],
|
||||
var progress = stateSendProgress[peerID],
|
||||
progress.sessionGeneration == generation else { return false }
|
||||
if echo {
|
||||
guard !progress.sentEcho else { return false }
|
||||
progress.sentEcho = true
|
||||
} else {
|
||||
guard !progress.sentInitial else { return false }
|
||||
progress.sentInitial = true
|
||||
}
|
||||
stateSendProgress[peerID] = progress
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Outbound convergence deferral
|
||||
|
||||
func setOutboundDeferredUntilConvergence(_ peerID: PeerID) {
|
||||
lock.withLock { _ = outboundConvergenceDeferred.insert(peerID) }
|
||||
}
|
||||
|
||||
func clearOutboundDeferredUntilConvergence(_ peerID: PeerID) {
|
||||
lock.withLock { _ = outboundConvergenceDeferred.remove(peerID) }
|
||||
}
|
||||
|
||||
// MARK: Proof timeout
|
||||
|
||||
/// Expires a proof deadline if its nonce/generation/fingerprint still
|
||||
/// identify the live watchdog or waiter set. On expiry the timeout
|
||||
/// marker is pinned and any waiters are returned for completion.
|
||||
/// `deferredOutbound` reports whether the peer's parked queues must
|
||||
/// stay parked (timeout-restore pending its convergence retry).
|
||||
func expireProofDeadline(
|
||||
for peerID: PeerID,
|
||||
fingerprint: String,
|
||||
sessionGeneration: UUID?,
|
||||
nonce: UUID
|
||||
) -> (expired: Bool, deferredOutbound: Bool, completions: [@MainActor (PrivateMediaSendPolicy) -> Void]) {
|
||||
lock.withLock {
|
||||
let pending = pendingPolicyResolutions[peerID]
|
||||
let pendingMatches = pending?.timeoutNonce == nonce
|
||||
&& pending?.sessionGeneration == sessionGeneration
|
||||
&& pending?.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame
|
||||
let watchdog = proofWatchdogs[peerID]
|
||||
let watchdogMatches = sessionGeneration != nil
|
||||
&& watchdog?.timeoutNonce == nonce
|
||||
&& watchdog?.sessionGeneration == sessionGeneration
|
||||
&& watchdog?.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame
|
||||
guard pendingMatches || watchdogMatches else {
|
||||
return (false, false, [])
|
||||
}
|
||||
var completions: [@MainActor (PrivateMediaSendPolicy) -> Void] = []
|
||||
if pendingMatches, let pending {
|
||||
completions = Array(pending.completions.values)
|
||||
}
|
||||
if pendingMatches {
|
||||
pendingPolicyResolutions.removeValue(forKey: peerID)
|
||||
}
|
||||
if watchdogMatches {
|
||||
proofWatchdogs.removeValue(forKey: peerID)
|
||||
}
|
||||
proofTimeoutMarkers[peerID] = BLEPrivateMediaProofTimeoutMarker(
|
||||
fingerprint: fingerprint,
|
||||
sessionGeneration: sessionGeneration
|
||||
)
|
||||
return (true, outboundConvergenceDeferred.contains(peerID), completions)
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers a policy-resolution waiter for a peer still awaiting its
|
||||
/// capability proof. Joins the existing waiter set when fingerprints
|
||||
/// match (bounded), otherwise starts one, reusing the live watchdog's
|
||||
/// deadline identity when it covers the same fingerprint/generation so
|
||||
/// only one timeout is ever in flight. `shouldSchedule` tells the
|
||||
/// caller to arm a fresh deadline.
|
||||
func registerPolicyResolution(
|
||||
for peerID: PeerID,
|
||||
fingerprint: String,
|
||||
requestID: UUID,
|
||||
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||
) -> (registered: Bool, shouldSchedule: Bool, nonce: UUID, generation: UUID?) {
|
||||
lock.withLock {
|
||||
let generation = sessionGenerations[peerID]
|
||||
if var pending = pendingPolicyResolutions[peerID] {
|
||||
guard pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame,
|
||||
pending.completions.count
|
||||
< TransportConfig.privateMediaCapabilityProofWaitersPerPeerCap else {
|
||||
return (false, false, UUID(), generation)
|
||||
}
|
||||
pending.completions[requestID] = completion
|
||||
pendingPolicyResolutions[peerID] = pending
|
||||
return (true, false, pending.timeoutNonce, pending.sessionGeneration)
|
||||
}
|
||||
|
||||
guard pendingPolicyResolutions.count
|
||||
< TransportConfig.privateMediaCapabilityProofPendingPeerCap else {
|
||||
return (false, false, UUID(), generation)
|
||||
}
|
||||
let currentWatchdog = proofWatchdogs[peerID]
|
||||
let reusesWatchdog = currentWatchdog?.fingerprint
|
||||
.caseInsensitiveCompare(fingerprint) == .orderedSame
|
||||
&& currentWatchdog?.sessionGeneration == generation
|
||||
let nonce: UUID
|
||||
if reusesWatchdog, let currentWatchdog {
|
||||
nonce = currentWatchdog.timeoutNonce
|
||||
} else {
|
||||
nonce = UUID()
|
||||
}
|
||||
pendingPolicyResolutions[peerID] =
|
||||
BLEPendingPrivateMediaPolicyResolution(
|
||||
fingerprint: fingerprint,
|
||||
sessionGeneration: generation,
|
||||
timeoutNonce: nonce,
|
||||
completions: [requestID: completion]
|
||||
)
|
||||
return (true, !reusesWatchdog, nonce, generation)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Teardown
|
||||
|
||||
/// A session clear retires every generation-bound record. Waiters are
|
||||
/// kept but rebased onto a nil generation with a fresh deadline nonce,
|
||||
/// returned so the caller re-arms their timeout.
|
||||
func clearSession(for peerID: PeerID) -> (fingerprint: String, nonce: UUID)? {
|
||||
lock.withLock {
|
||||
sessionGenerations.removeValue(forKey: peerID)
|
||||
authenticatedStates.removeValue(forKey: peerID)
|
||||
proofTimeoutMarkers.removeValue(forKey: peerID)
|
||||
proofWatchdogs.removeValue(forKey: peerID)
|
||||
stateSendProgress.removeValue(forKey: peerID)
|
||||
outboundConvergenceDeferred.remove(peerID)
|
||||
guard var pending = pendingPolicyResolutions[peerID] else {
|
||||
return nil
|
||||
}
|
||||
let nonce = UUID()
|
||||
pending.sessionGeneration = nil
|
||||
pending.timeoutNonce = nonce
|
||||
pendingPolicyResolutions[peerID] = pending
|
||||
return (pending.fingerprint, nonce)
|
||||
}
|
||||
}
|
||||
|
||||
/// Panic wipe: these records belong to pre-panic transfer state, and
|
||||
/// invoking their callbacks would let queued UI work recreate or resend
|
||||
/// wiped media — drop everything.
|
||||
func panicReset() {
|
||||
lock.withLock {
|
||||
sessionGenerations.removeAll()
|
||||
authenticatedStates.removeAll()
|
||||
proofTimeoutMarkers.removeAll()
|
||||
proofWatchdogs.removeAll()
|
||||
pendingPolicyResolutions.removeAll()
|
||||
stateSendProgress.removeAll()
|
||||
outboundConvergenceDeferred.removeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension BLEPrivateMediaSessionStore {
|
||||
/// The current generation iff its authenticated peer state proved the
|
||||
/// private-media capability (and, when required, durable receipts).
|
||||
func provenGeneration(for peerID: PeerID, requireReceipts: Bool) -> UUID? {
|
||||
let inputs = policyInputs(for: peerID)
|
||||
guard let generation = inputs.sessionGeneration,
|
||||
let authenticated = inputs.authenticatedState,
|
||||
authenticated.sessionGeneration == generation,
|
||||
authenticated.capabilities.contains(.privateMedia) else { return nil }
|
||||
if requireReceipts {
|
||||
guard authenticated.capabilities.contains(.privateMediaReceipts) else { return nil }
|
||||
}
|
||||
return generation
|
||||
}
|
||||
}
|
||||
411
bitchat/Services/BLE/BLERadioController.swift
Normal file
411
bitchat/Services/BLE/BLERadioController.swift
Normal file
@ -0,0 +1,411 @@
|
||||
import BitLogger
|
||||
import CoreBluetooth
|
||||
import Foundation
|
||||
|
||||
/// The radio's contact points back into the transport. All calls arrive on
|
||||
/// bleQueue.
|
||||
protocol BLERadioControllerDelegate: AnyObject {
|
||||
/// Whether a panic wipe has quiesced the radio.
|
||||
func radioIsPanicSuspended() -> Bool
|
||||
/// iOS app-active snapshot (drives allow-duplicates scanning and
|
||||
/// background connect deferral); always true on macOS.
|
||||
func radioIsAppActive() -> Bool
|
||||
/// A connect attempt died (timeout or foreground stale-reclaim): retire
|
||||
/// the link's transport bookkeeping — write buffers, link-auth proof,
|
||||
/// reconnect epoch, and the link-state entry itself.
|
||||
func radioTearDownPeripheralLink(_ peripheralID: String)
|
||||
}
|
||||
|
||||
/// bleQueue-confined owner of the central-role radio policy: discovery
|
||||
/// admission, the connection budget and queue, connect timeouts,
|
||||
/// wake-on-proximity background connects, scan duty-cycling, RSSI
|
||||
/// adaptation, and the advertising payload.
|
||||
///
|
||||
/// First slice of the link layer (docs/BLE-ARCHITECTURE-V3.md): this type
|
||||
/// makes no peer decisions and owns no bindings or security state — it
|
||||
/// shares the bleQueue-confined link-state store for admission reads and
|
||||
/// asks its delegate to tear down transport bookkeeping when an attempt
|
||||
/// dies.
|
||||
final class BLERadioController {
|
||||
weak var delegate: BLERadioControllerDelegate?
|
||||
/// The transport is every peripheral's CBPeripheralDelegate; connects
|
||||
/// initiated here must point new peripherals at it.
|
||||
weak var peripheralDelegate: CBPeripheralDelegate?
|
||||
/// Attached when the transport creates (or restores) its managers.
|
||||
weak var central: CBCentralManager?
|
||||
|
||||
private let queue: DispatchQueue
|
||||
private let linkStateStore: BLELinkStateStore
|
||||
private let recentTraffic: BLERecentTrafficMonitor
|
||||
|
||||
// Connection budget & scheduling (central role)
|
||||
private var scheduler = BLEConnectionScheduler<CBPeripheral>()
|
||||
// Recently seen peripherals retained for background wake-on-proximity
|
||||
// connects
|
||||
private let recentPeripheralCache = BLERecentPeripheralCache<CBPeripheral>()
|
||||
|
||||
// Adaptive scanning duty-cycle
|
||||
private var scanDutyTimer: DispatchSourceTimer?
|
||||
private var dutyEnabled: Bool = true
|
||||
private var dutyOnDuration: TimeInterval = TransportConfig.bleDutyOnDuration
|
||||
private var dutyOffDuration: TimeInterval = TransportConfig.bleDutyOffDuration
|
||||
private var dutyActive: Bool = false
|
||||
|
||||
init(
|
||||
queue: DispatchQueue,
|
||||
linkStateStore: BLELinkStateStore,
|
||||
recentTraffic: BLERecentTrafficMonitor
|
||||
) {
|
||||
self.queue = queue
|
||||
self.linkStateStore = linkStateStore
|
||||
self.recentTraffic = recentTraffic
|
||||
}
|
||||
|
||||
// MARK: - Advertising
|
||||
|
||||
static func advertisementData() -> [String: Any] {
|
||||
// No Local Name for privacy.
|
||||
[CBAdvertisementDataServiceUUIDsKey: [BLEService.serviceUUID]]
|
||||
}
|
||||
|
||||
// MARK: - Scanning
|
||||
|
||||
func startScanning() {
|
||||
guard delegate?.radioIsPanicSuspended() == false,
|
||||
let central,
|
||||
central.state == .poweredOn,
|
||||
!central.isScanning else { return }
|
||||
|
||||
// Allow duplicates while active for faster discovery: immediate
|
||||
// discovery events instead of coalesced ones.
|
||||
let allowDuplicates = delegate?.radioIsAppActive() ?? true
|
||||
central.scanForPeripherals(
|
||||
withServices: [BLEService.serviceUUID],
|
||||
options: [CBCentralManagerScanOptionAllowDuplicatesKey: allowDuplicates]
|
||||
)
|
||||
}
|
||||
|
||||
func updateScanningDutyCycle(connectedCount: Int) {
|
||||
guard let central, central.state == .poweredOn else { return }
|
||||
// Duty cycle only when the app is active and at least one peer is
|
||||
// connected; force full-time scanning with few neighbors or very
|
||||
// recent traffic.
|
||||
let hasRecentTraffic = recentTraffic.hasTraffic(
|
||||
within: TransportConfig.bleRecentTrafficForceScanSeconds,
|
||||
now: Date()
|
||||
)
|
||||
let scanPlan = BLEScanDutyPolicy.plan(
|
||||
dutyEnabled: dutyEnabled,
|
||||
appIsActive: delegate?.radioIsAppActive() ?? true,
|
||||
connectedCount: connectedCount,
|
||||
hasRecentTraffic: hasRecentTraffic
|
||||
)
|
||||
|
||||
switch scanPlan {
|
||||
case .dutyCycle(let onDuration, let offDuration):
|
||||
let durationsChanged = dutyOnDuration != onDuration || dutyOffDuration != offDuration
|
||||
dutyOnDuration = onDuration
|
||||
dutyOffDuration = offDuration
|
||||
|
||||
if scanDutyTimer == nil {
|
||||
// Start with scanning ON; turn OFF after onDuration.
|
||||
let t = DispatchSource.makeTimerSource(queue: queue)
|
||||
if !central.isScanning { startScanning() }
|
||||
dutyActive = true
|
||||
t.schedule(deadline: .now() + dutyOnDuration, repeating: dutyOnDuration + dutyOffDuration)
|
||||
t.setEventHandler { [weak self] in
|
||||
guard let self, let c = self.central else { return }
|
||||
if self.dutyActive {
|
||||
if c.isScanning { c.stopScan() }
|
||||
self.dutyActive = false
|
||||
self.queue.asyncAfter(deadline: .now() + self.dutyOffDuration) {
|
||||
if self.central?.state == .poweredOn { self.startScanning() }
|
||||
self.dutyActive = true
|
||||
}
|
||||
}
|
||||
}
|
||||
t.resume()
|
||||
scanDutyTimer = t
|
||||
} else if durationsChanged {
|
||||
scanDutyTimer?.schedule(deadline: .now() + dutyOnDuration, repeating: dutyOnDuration + dutyOffDuration)
|
||||
if !central.isScanning { startScanning() }
|
||||
dutyActive = true
|
||||
}
|
||||
case .continuous:
|
||||
// Cancel duty cycle and ensure scanning is ON for discovery.
|
||||
scanDutyTimer?.cancel()
|
||||
scanDutyTimer = nil
|
||||
if !central.isScanning { startScanning() }
|
||||
}
|
||||
}
|
||||
|
||||
func stopDutyCycle() {
|
||||
scanDutyTimer?.cancel()
|
||||
scanDutyTimer = nil
|
||||
}
|
||||
|
||||
func updateRSSIThreshold(connectedCount: Int) {
|
||||
scheduler.updateRSSIThreshold(
|
||||
connectedCount: connectedCount,
|
||||
connectedOrConnectingLinkCount: linkStateStore.connectedOrConnectingPeripheralCount,
|
||||
now: Date()
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Discovery & connection budget
|
||||
|
||||
func handleDiscovery(
|
||||
_ peripheral: CBPeripheral,
|
||||
advertisementData: [String: Any],
|
||||
rssi: NSNumber
|
||||
) {
|
||||
guard delegate?.radioIsPanicSuspended() == false, let central else { return }
|
||||
let peripheralID = peripheral.identifier.uuidString
|
||||
let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? (peripheralID.prefix(6) + "…")
|
||||
let isConnectable = (advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber)?.boolValue ?? true
|
||||
|
||||
let candidate = BLEConnectionCandidate(
|
||||
peripheral: peripheral,
|
||||
peripheralID: peripheralID,
|
||||
rssi: rssi.intValue,
|
||||
name: String(advertisedName),
|
||||
isConnectable: isConnectable,
|
||||
discoveredAt: Date()
|
||||
)
|
||||
if isConnectable {
|
||||
recentPeripheralCache.record(peripheral, peripheralID: peripheralID, at: candidate.discoveredAt)
|
||||
}
|
||||
let existingState = linkStateStore.state(forPeripheralID: peripheralID).map(BLEExistingConnectionState.init)
|
||||
|
||||
switch scheduler.handleDiscovery(
|
||||
candidate,
|
||||
connectedOrConnectingCount: linkStateStore.connectedOrConnectingPeripheralCount,
|
||||
existingState: existingState,
|
||||
peripheralState: peripheral.state.connectionSchedulerState,
|
||||
now: candidate.discoveredAt
|
||||
) {
|
||||
case .ignore, .queued:
|
||||
return
|
||||
case .scheduleRetry(let delay):
|
||||
queue.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
self?.tryConnectFromQueue()
|
||||
}
|
||||
return
|
||||
case .cancelStaleConnection:
|
||||
central.cancelPeripheralConnection(peripheral)
|
||||
return
|
||||
case .connectNow:
|
||||
beginCentralConnection(candidate, using: central, logPrefix: "📱 Connect")
|
||||
}
|
||||
}
|
||||
|
||||
func tryConnectFromQueue() {
|
||||
guard delegate?.radioIsPanicSuspended() == false,
|
||||
let central,
|
||||
central.state == .poweredOn else { return }
|
||||
|
||||
let decision = scheduler.nextCandidate(
|
||||
connectedOrConnectingCount: linkStateStore.connectedOrConnectingPeripheralCount,
|
||||
isAlreadyConnectingOrConnected: { [linkStateStore] peripheralID in
|
||||
let state = linkStateStore.state(forPeripheralID: peripheralID)
|
||||
return state?.isConnected == true || state?.isConnecting == true
|
||||
},
|
||||
now: Date()
|
||||
)
|
||||
|
||||
switch decision {
|
||||
case .none:
|
||||
return
|
||||
case .retryAfter(let delay):
|
||||
queue.asyncAfter(deadline: .now() + delay) { [weak self] in self?.tryConnectFromQueue() }
|
||||
case .connect(let candidate):
|
||||
beginCentralConnection(candidate, using: central, logPrefix: "⏩ Queue connect")
|
||||
}
|
||||
}
|
||||
|
||||
private func beginCentralConnection(
|
||||
_ candidate: BLEConnectionCandidate<CBPeripheral>,
|
||||
using central: CBCentralManager,
|
||||
logPrefix: String
|
||||
) {
|
||||
guard delegate?.radioIsPanicSuspended() == false else { return }
|
||||
let peripheral = candidate.peripheral
|
||||
let peripheralID = candidate.peripheralID
|
||||
linkStateStore.beginConnecting(to: peripheral, at: Date())
|
||||
peripheral.delegate = peripheralDelegate
|
||||
let options: [String: Any] = [
|
||||
CBConnectPeripheralOptionNotifyOnConnectionKey: true,
|
||||
CBConnectPeripheralOptionNotifyOnDisconnectionKey: true,
|
||||
CBConnectPeripheralOptionNotifyOnNotificationKey: true
|
||||
]
|
||||
central.connect(peripheral, options: options)
|
||||
scheduler.recordConnectionAttempt(at: Date())
|
||||
SecureLogger.debug("\(logPrefix): \(candidate.name) [RSSI:\(candidate.rssi)]", category: .session)
|
||||
|
||||
queue.asyncAfter(deadline: .now() + TransportConfig.bleConnectTimeoutSeconds) { [weak self] in
|
||||
guard let self,
|
||||
let state = self.linkStateStore.state(forPeripheralID: peripheralID),
|
||||
state.isConnecting && !state.isConnected else { return }
|
||||
|
||||
guard peripheral.state != .connected else {
|
||||
SecureLogger.debug("⏱️ Timeout fired but peripheral already connected: \(candidate.name)", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
if self.delegate?.radioIsAppActive() == false {
|
||||
// Backgrounded: leave the connect pending. iOS never expires
|
||||
// it — the controller completes it whenever the peer comes
|
||||
// back into range, waking the app (state restoration
|
||||
// relaunches us if we were terminated). Foreground return
|
||||
// cancels stale pendings via cancelStalePendingConnects().
|
||||
SecureLogger.info("🌙 Connect timeout deferred while backgrounded, left pending for wake-on-proximity: \(candidate.name)", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
SecureLogger.debug("⏱️ Timeout: \(candidate.name)", category: .session)
|
||||
central.cancelPeripheralConnection(peripheral)
|
||||
self.delegate?.radioTearDownPeripheralLink(peripheralID)
|
||||
self.scheduler.recordConnectionTimeout(peripheralID: peripheralID, at: Date())
|
||||
self.tryConnectFromQueue()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Scheduler bookkeeping (called from the transport's delegates)
|
||||
|
||||
var candidateCount: Int { scheduler.candidateCount }
|
||||
|
||||
func recordConnectionSuccess(peripheralID: String) {
|
||||
scheduler.recordConnectionSuccess(peripheralID: peripheralID)
|
||||
}
|
||||
|
||||
func recordConnectionFailure(peripheralID: String) {
|
||||
scheduler.recordConnectionFailure(peripheralID: peripheralID)
|
||||
}
|
||||
|
||||
func recordDisconnectError(peripheralID: String, at date: Date) {
|
||||
scheduler.recordDisconnectError(peripheralID: peripheralID, at: date)
|
||||
}
|
||||
|
||||
func recordRecentPeripheral(_ peripheral: CBPeripheral, peripheralID: String, at date: Date) {
|
||||
recentPeripheralCache.record(peripheral, peripheralID: peripheralID, at: date)
|
||||
}
|
||||
|
||||
func pruneConnectionTimeouts(before cutoff: Date) {
|
||||
scheduler.pruneConnectionTimeouts(before: cutoff)
|
||||
}
|
||||
|
||||
/// Panic wipe: drop the candidate queue, backoff state, and RSSI
|
||||
/// adaptation with the identity they served.
|
||||
func reset() {
|
||||
scheduler.reset()
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
// MARK: - Background wake-on-proximity
|
||||
|
||||
/// Backgrounding hands the freed connection budget to iOS as pending
|
||||
/// connects against recently seen peers: the controller completes one
|
||||
/// whenever its peer comes into range, waking (or relaunching) the app.
|
||||
/// A couple of central slots stay reserved for connects driven by live
|
||||
/// background discovery — except on the disconnect re-arm path, which
|
||||
/// may consume the slot the disconnect itself just freed (a dense mesh
|
||||
/// with 4+ remaining links would otherwise compute a zero budget and
|
||||
/// never re-arm the lost peer).
|
||||
func armPendingBackgroundConnects(
|
||||
slotReserve: Int = TransportConfig.bleBackgroundPendingConnectSlotReserve
|
||||
) {
|
||||
queue.async { [weak self] in
|
||||
guard let self,
|
||||
self.delegate?.radioIsPanicSuspended() == false,
|
||||
let central = self.central,
|
||||
central.state == .poweredOn else { return }
|
||||
let budget = TransportConfig.bleMaxCentralLinks
|
||||
- slotReserve
|
||||
- self.linkStateStore.connectedOrConnectingPeripheralCount
|
||||
let now = Date()
|
||||
let targets = self.recentPeripheralCache.reconnectTargets(now: now, limit: budget) { peripheralID in
|
||||
let state = self.linkStateStore.state(forPeripheralID: peripheralID)
|
||||
return state?.isConnected == true || state?.isConnecting == true
|
||||
}
|
||||
guard !targets.isEmpty else { return }
|
||||
for target in targets {
|
||||
// lastConnectionAttempt stays nil: an indefinite pending
|
||||
// connect has no attempt clock, and nil marks it always-stale
|
||||
// so cancelStalePendingConnects() reclaims it on foreground
|
||||
// even after a quick background→foreground bounce.
|
||||
self.linkStateStore.setPeripheralState(
|
||||
BLEPeripheralLinkState(
|
||||
peripheral: target.peripheral,
|
||||
characteristic: nil,
|
||||
isConnecting: true,
|
||||
isConnected: false,
|
||||
lastConnectionAttempt: nil,
|
||||
assembler: NotificationStreamAssembler()
|
||||
),
|
||||
for: target.peripheralID
|
||||
)
|
||||
target.peripheral.delegate = self.peripheralDelegate
|
||||
central.connect(target.peripheral, options: [
|
||||
CBConnectPeripheralOptionNotifyOnConnectionKey: true,
|
||||
CBConnectPeripheralOptionNotifyOnDisconnectionKey: true,
|
||||
CBConnectPeripheralOptionNotifyOnNotificationKey: true
|
||||
])
|
||||
}
|
||||
SecureLogger.info("🌙 Armed \(targets.count) pending background connect(s) for wake-on-proximity", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
/// Foreground restores normal connection management: pending connects
|
||||
/// older than the connect timeout (including ones rebuilt by state
|
||||
/// restoration after a relaunch) are cancelled so live scanning and the
|
||||
/// scheduler take over. Anything still nearby is rediscovered within
|
||||
/// seconds by the allow-duplicates foreground scan.
|
||||
func cancelStalePendingConnects() {
|
||||
queue.async { [weak self] in
|
||||
guard let self, let central = self.central else { return }
|
||||
let now = Date()
|
||||
var cancelled = 0
|
||||
for state in self.linkStateStore.peripheralStates where state.isConnecting && !state.isConnected {
|
||||
let age = state.lastConnectionAttempt.map { now.timeIntervalSince($0) } ?? .infinity
|
||||
guard age > TransportConfig.bleConnectTimeoutSeconds else { continue }
|
||||
let peripheralID = state.peripheral.identifier.uuidString
|
||||
central.cancelPeripheralConnection(state.peripheral)
|
||||
self.delegate?.radioTearDownPeripheralLink(peripheralID)
|
||||
cancelled += 1
|
||||
}
|
||||
if cancelled > 0 {
|
||||
SecureLogger.info("🌅 Cancelled \(cancelled) stale pending connect(s) on foreground", category: .session)
|
||||
self.tryConnectFromQueue()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Connection scheduling helpers
|
||||
|
||||
private extension BLEExistingConnectionState {
|
||||
init(_ state: BLEPeripheralLinkState) {
|
||||
self.init(
|
||||
isConnecting: state.isConnecting,
|
||||
isConnected: state.isConnected,
|
||||
lastConnectionAttempt: state.lastConnectionAttempt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private extension CBPeripheralState {
|
||||
var connectionSchedulerState: BLEPeripheralConnectionState {
|
||||
switch self {
|
||||
case .connected:
|
||||
return .connected
|
||||
case .connecting:
|
||||
return .connecting
|
||||
case .disconnected, .disconnecting:
|
||||
return .disconnected
|
||||
@unknown default:
|
||||
return .disconnected
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -77,6 +77,26 @@ struct BLEReceivePipeline {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lock-backed traffic-level signal: the receive pipeline records packets,
|
||||
/// and the radio layer (maintenance and scan-duty adaptation on bleQueue)
|
||||
/// reads the level without crossing onto a transport queue.
|
||||
final class BLERecentTrafficMonitor: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var tracker = BLERecentTrafficTracker()
|
||||
|
||||
func recordPacket(at now: Date) {
|
||||
lock.withLock { tracker.recordPacket(at: now) }
|
||||
}
|
||||
|
||||
func hasTraffic(within seconds: TimeInterval, now: Date) -> Bool {
|
||||
lock.withLock { tracker.hasTraffic(within: seconds, now: now) }
|
||||
}
|
||||
|
||||
func removeAll() {
|
||||
lock.withLock { tracker.removeAll() }
|
||||
}
|
||||
}
|
||||
|
||||
struct BLERecentTrafficTracker: Equatable {
|
||||
private var packetTimestamps: [Date] = []
|
||||
|
||||
|
||||
@ -21,24 +21,53 @@ enum BLERedundantLinkPolicy {
|
||||
/// A link mid-service-rediscovery (didModifyServices cleared it)
|
||||
/// must never be kept over a writable duplicate.
|
||||
let hasCharacteristic: Bool
|
||||
/// When didConnect last fired for this link in this process. Nil
|
||||
/// for restored links, whose connect predates the relaunch.
|
||||
let lastConnectedAt: Date?
|
||||
|
||||
init(uuid: String, peerID: PeerID?, isConnected: Bool, hasCharacteristic: Bool) {
|
||||
init(
|
||||
uuid: String,
|
||||
peerID: PeerID?,
|
||||
isConnected: Bool,
|
||||
hasCharacteristic: Bool,
|
||||
lastConnectedAt: Date? = nil
|
||||
) {
|
||||
self.uuid = uuid
|
||||
self.peerID = peerID
|
||||
self.isConnected = isConnected
|
||||
self.hasCharacteristic = hasCharacteristic
|
||||
self.lastConnectedAt = lastConnectedAt
|
||||
}
|
||||
}
|
||||
|
||||
/// The link to keep when a peer has several connected bound peripheral
|
||||
/// links, or nil when there is nothing to consolidate. Prefers the
|
||||
/// ingress link of the verified direct announce that triggered the check
|
||||
/// (the strongest liveness proof available), falling back to the peer's
|
||||
/// most recently bound link — but only among writable links while any
|
||||
/// exist: keeping a characteristic-less link and cancelling the writable
|
||||
/// links, or nil when there is nothing to consolidate.
|
||||
///
|
||||
/// Prefers the most recently CONNECTED candidate. Duplicates arise when
|
||||
/// the peer reappears under a fresh BLE address (privacy address
|
||||
/// rotation) while an older connection — typically state-restored —
|
||||
/// lives on: only the newest connection sits on the address the peer
|
||||
/// still advertises. Cancelling that one instead just gets it
|
||||
/// rediscovered and reconnected, a retire↔reconnect oscillation at the
|
||||
/// retirement cooldown (field-observed July 31); the older-address link
|
||||
/// cannot return once cancelled, so consolidation converges immediately.
|
||||
/// Physical connect recency is also a signal an announce replay cannot
|
||||
/// nominate, unlike the previous ingress-link preference — announce
|
||||
/// anchors (ingress, then most recently bound) now only break ties and
|
||||
/// serve links with no connect timestamp at all. Link "health" signals
|
||||
/// like RSSI are deliberately not inputs: they are transient and the
|
||||
/// stale-address link often reads stronger; connect recency is the only
|
||||
/// signal that tracks address currency.
|
||||
///
|
||||
/// The survivor must be writable while any writable candidate exists:
|
||||
/// keeping a characteristic-less link and cancelling the writable
|
||||
/// duplicate would strand outbound traffic on the central link until
|
||||
/// rediscovery finishes. When neither anchor is a viable candidate,
|
||||
/// consolidation waits for a later announce rather than guessing.
|
||||
/// rediscovery finishes. But when the physically NEWEST connection is
|
||||
/// the one that is not writable yet (service discovery still running),
|
||||
/// consolidation defers entirely — selecting an older writable link
|
||||
/// would cancel the freshly advertised connection and recreate the
|
||||
/// oscillation. When no candidate is identifiable, consolidation waits
|
||||
/// for a later announce rather than guessing.
|
||||
static func keptPeripheralUUID(
|
||||
ingressPeripheralUUID: String?,
|
||||
mostRecentlyBoundUUID: String?,
|
||||
@ -51,6 +80,42 @@ enum BLERedundantLinkPolicy {
|
||||
let writable = bound.filter(\.hasCharacteristic)
|
||||
let candidates = writable.isEmpty ? bound : writable
|
||||
|
||||
// The newest connection is still mid-service-discovery while a
|
||||
// writable (typically restored, stale-address) duplicate exists:
|
||||
// defer to a later announce instead of keeping the older link and
|
||||
// cancelling the one connection on the currently advertised address.
|
||||
if !writable.isEmpty,
|
||||
let newestBoundDate = bound.compactMap(\.lastConnectedAt).max(),
|
||||
!writable.contains(where: { $0.lastConnectedAt == newestBoundDate }) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let newestDate = candidates.compactMap(\.lastConnectedAt).max() {
|
||||
let newest = candidates.filter { $0.lastConnectedAt == newestDate }
|
||||
if newest.count == 1 {
|
||||
return newest[0].uuid
|
||||
}
|
||||
return anchoredChoice(
|
||||
among: newest,
|
||||
ingressPeripheralUUID: ingressPeripheralUUID,
|
||||
mostRecentlyBoundUUID: mostRecentlyBoundUUID
|
||||
) ?? newest.map(\.uuid).min()
|
||||
}
|
||||
|
||||
return anchoredChoice(
|
||||
among: candidates,
|
||||
ingressPeripheralUUID: ingressPeripheralUUID,
|
||||
mostRecentlyBoundUUID: mostRecentlyBoundUUID
|
||||
)
|
||||
}
|
||||
|
||||
/// The pre-timestamp anchors: the verified announce's ingress link,
|
||||
/// then the peer's most recently bound link.
|
||||
private static func anchoredChoice(
|
||||
among candidates: [PeripheralLink],
|
||||
ingressPeripheralUUID: String?,
|
||||
mostRecentlyBoundUUID: String?
|
||||
) -> String? {
|
||||
if let ingressPeripheralUUID, candidates.contains(where: { $0.uuid == ingressPeripheralUUID }) {
|
||||
return ingressPeripheralUUID
|
||||
}
|
||||
|
||||
433
bitchat/Services/BLE/BLEService+LinkLayerCentralRole.swift
Normal file
433
bitchat/Services/BLE/BLEService+LinkLayerCentralRole.swift
Normal file
@ -0,0 +1,433 @@
|
||||
//
|
||||
// BLEService+LinkLayerCentralRole.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import BitLogger
|
||||
import CoreBluetooth
|
||||
import Foundation
|
||||
|
||||
// The bleQueue half of the link layer: CoreBluetooth delegate callbacks do
|
||||
// physical bookkeeping (link-state store, buffers, radio policy) and report
|
||||
// everything else to the engine through the link-event port
|
||||
// (BLELinkEvent / emitLinkEvent). See docs/BLE-ARCHITECTURE-V3.md.
|
||||
|
||||
// MARK: - CBCentralManagerDelegate
|
||||
|
||||
extension BLEService: CBCentralManagerDelegate {
|
||||
#if os(iOS)
|
||||
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
|
||||
let restoredPeripherals = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? []
|
||||
guard !isPanicSuspended else {
|
||||
central.stopScan()
|
||||
restoredPeripherals.forEach {
|
||||
central.cancelPeripheralConnection($0)
|
||||
}
|
||||
return
|
||||
}
|
||||
let restoredServices = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? []
|
||||
let restoredOptions = (dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]) ?? [:]
|
||||
let allowDuplicates = restoredOptions[CBCentralManagerScanOptionAllowDuplicatesKey] as? Bool
|
||||
|
||||
SecureLogger.info(
|
||||
"♻️ Central restore: peripherals=\(restoredPeripherals.count) services=\(restoredServices.count) allowDuplicates=\(String(describing: allowDuplicates))",
|
||||
category: .session
|
||||
)
|
||||
|
||||
for peripheral in restoredPeripherals {
|
||||
let identifier = peripheral.identifier.uuidString
|
||||
peripheral.delegate = self
|
||||
let existing = linkStateStore.state(forPeripheralID: identifier)
|
||||
let assembler = existing?.assembler ?? NotificationStreamAssembler()
|
||||
let characteristic = existing?.characteristic
|
||||
let wasConnecting = existing?.isConnecting ?? false
|
||||
let wasConnected = existing?.isConnected ?? false
|
||||
|
||||
let restoredState = BLEPeripheralLinkState(
|
||||
peripheral: peripheral,
|
||||
characteristic: characteristic,
|
||||
isConnecting: wasConnecting || peripheral.state == .connecting,
|
||||
isConnected: wasConnected || peripheral.state == .connected,
|
||||
lastConnectionAttempt: existing?.lastConnectionAttempt,
|
||||
assembler: assembler
|
||||
)
|
||||
linkStateStore.setPeripheralState(restoredState, for: identifier)
|
||||
|
||||
// Restored peripherals are the freshest wake-on-proximity
|
||||
// candidates we have after a relaunch — without this the cache
|
||||
// starts empty and backgrounding right after a restore arms
|
||||
// nothing. Service rediscovery for restored-connected links waits
|
||||
// for poweredOn: CoreBluetooth drops commands issued during
|
||||
// restoration (API MISUSE warnings).
|
||||
radio.recordRecentPeripheral(peripheral, peripheralID: identifier, at: Date())
|
||||
}
|
||||
|
||||
// Via the sampler (not a direct capture): it refreshes the cached
|
||||
// background budget on main first, so the restore log shows the real
|
||||
// wake window instead of the init sentinel.
|
||||
logBluetoothStatus("central-restore")
|
||||
|
||||
if central.state == .poweredOn {
|
||||
radio.startScanning()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||
emitTransportEvent(.bluetoothStateUpdated(central.state))
|
||||
|
||||
switch central.state {
|
||||
case .poweredOn:
|
||||
guard !isPanicSuspended else {
|
||||
central.stopScan()
|
||||
return
|
||||
}
|
||||
// Links restored as connected have no characteristic in the new
|
||||
// process; without rediscovery they sit connected-but-unusable
|
||||
// until the peer disconnects. Runs here (not willRestoreState)
|
||||
// because commands issued before poweredOn are dropped.
|
||||
for state in linkStateStore.peripheralStates where state.isConnected
|
||||
&& state.characteristic == nil
|
||||
&& state.peripheral.state == .connected {
|
||||
SecureLogger.info("♻️ Rediscovering services on restored link: \(state.peripheral.identifier.uuidString.prefix(8))…", category: .session)
|
||||
state.peripheral.discoverServices([BLEService.serviceUUID])
|
||||
}
|
||||
|
||||
// Start scanning - use allow duplicates for faster discovery when active
|
||||
radio.startScanning()
|
||||
|
||||
case .poweredOff:
|
||||
// CoreBluetooth has already transitioned out of poweredOn. Do
|
||||
// not issue stop/cancel commands now; they are rejected as API
|
||||
// misuse. Retire our link state locally instead.
|
||||
SecureLogger.info("📴 Bluetooth powered off - cleaning up central state", category: .session)
|
||||
let peripheralIDs = linkStateStore.peripheralStates.map { $0.peripheral.identifier.uuidString }
|
||||
for peripheralID in peripheralIDs {
|
||||
pendingPeripheralWrites.discardAll(for: peripheralID)
|
||||
}
|
||||
linkStateStore.clearPeripherals()
|
||||
emitLinkEvent(.allPeripheralLinksEnded(peripheralIDs: peripheralIDs, retireProofsAndNotify: true))
|
||||
|
||||
case .unauthorized:
|
||||
// User denied Bluetooth permission
|
||||
SecureLogger.warning("🚫 Bluetooth unauthorized - user denied permission", category: .session)
|
||||
linkStateStore.clearPeripherals()
|
||||
emitLinkEvent(.allPeripheralLinksEnded(peripheralIDs: [], retireProofsAndNotify: false))
|
||||
|
||||
case .unsupported:
|
||||
// Device doesn't support BLE
|
||||
SecureLogger.error("❌ Bluetooth LE not supported on this device", category: .session)
|
||||
|
||||
case .resetting:
|
||||
// Bluetooth stack is resetting - will get another state update when done
|
||||
SecureLogger.info("🔄 Bluetooth stack resetting...", category: .session)
|
||||
|
||||
case .unknown:
|
||||
// Initial state before we know the actual state
|
||||
SecureLogger.debug("❓ Bluetooth state unknown (initializing)", category: .session)
|
||||
|
||||
@unknown default:
|
||||
SecureLogger.warning("⚠️ Unknown Bluetooth state: \(central.state.rawValue)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
|
||||
radio.handleDiscovery(peripheral, advertisementData: advertisementData, rssi: RSSI)
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||||
guard !isPanicSuspended else {
|
||||
central.cancelPeripheralConnection(peripheral)
|
||||
return
|
||||
}
|
||||
let peripheralID = peripheral.identifier.uuidString
|
||||
|
||||
#if os(iOS)
|
||||
// A connect completing while backgrounded is the wake-on-proximity
|
||||
// path doing its job — worth an info line for field verification.
|
||||
if !isAppActive {
|
||||
SecureLogger.info("🌙 Background wake: connected to \(peripheral.name ?? peripheralID) while backgrounded", category: .session)
|
||||
}
|
||||
#endif
|
||||
|
||||
// Update state to connected
|
||||
linkStateStore.markConnected(peripheral)
|
||||
|
||||
// Reset backoff state on success
|
||||
radio.recordConnectionSuccess(peripheralID: peripheralID)
|
||||
|
||||
SecureLogger.debug("✅ Connected: \(peripheral.name ?? "Unknown") [\(peripheralID)]", category: .session)
|
||||
|
||||
// Discover services
|
||||
peripheral.discoverServices([BLEService.serviceUUID])
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
|
||||
let peripheralID = peripheral.identifier.uuidString
|
||||
|
||||
SecureLogger.debug("📱 Disconnect: \(peripheralID)\(error != nil ? " (\(error!.localizedDescription))" : "")", category: .session)
|
||||
|
||||
// If disconnect carried an error (often timeout), apply short backoff to avoid thrash
|
||||
if error != nil {
|
||||
radio.recordDisconnectError(peripheralID: peripheralID, at: Date())
|
||||
}
|
||||
|
||||
// Retain the handle: a dropped link is the best wake-on-proximity
|
||||
// candidate if the app backgrounds before the peer returns.
|
||||
radio.recordRecentPeripheral(peripheral, peripheralID: peripheralID, at: Date())
|
||||
|
||||
#if os(iOS)
|
||||
// Link lost while backgrounded (peer walked away): re-arm a pending
|
||||
// connect during this wake window so the peer's return wakes us again.
|
||||
// Delayed past the disconnect-settle window to avoid reconnect thrash
|
||||
// at range edge.
|
||||
if !isAppActive {
|
||||
bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleDisconnectDiscoveryIgnoreSeconds) { [weak self] in
|
||||
guard let self, !self.isAppActive else { return }
|
||||
// Reserve 0: use the slot this disconnect freed even in a
|
||||
// dense mesh, so the lost peer can wake us when it returns.
|
||||
self.radio.armPendingBackgroundConnects(slotReserve: 0)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Physical teardown now; identity retirement and peer-disconnect
|
||||
// bookkeeping ride the link-event port. The scan restart and
|
||||
// connect-slot refill below stay on bleQueue — they respond to
|
||||
// the physical drop regardless of remaining logical links.
|
||||
discardPeripheralLinkPhysical(peripheralID)
|
||||
emitLinkEvent(.peripheralLinkEnded(peripheralID: peripheralID, runPeerBookkeeping: true))
|
||||
|
||||
// Restart scanning with allow duplicates for faster rediscovery
|
||||
if centralManager?.state == .poweredOn {
|
||||
// Stop and restart scanning to ensure we get fresh discovery events
|
||||
centralManager?.stopScan()
|
||||
bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleRestartScanDelaySeconds) { [weak self] in
|
||||
self?.radio.startScanning()
|
||||
}
|
||||
}
|
||||
// Attempt to fill freed slot from queue
|
||||
bleQueue.async { [weak self] in self?.radio.tryConnectFromQueue() }
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
|
||||
let peripheralID = peripheral.identifier.uuidString
|
||||
|
||||
// Clean up the references: physical now, identity via the port.
|
||||
discardPeripheralLinkPhysical(peripheralID)
|
||||
emitLinkEvent(.peripheralLinkEnded(peripheralID: peripheralID, runPeerBookkeeping: false))
|
||||
|
||||
SecureLogger.error("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: .session)
|
||||
radio.recordConnectionFailure(peripheralID: peripheralID)
|
||||
// Try next candidate
|
||||
bleQueue.async { [weak self] in self?.radio.tryConnectFromQueue() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CBPeripheralDelegate
|
||||
|
||||
extension BLEService: CBPeripheralDelegate {
|
||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
||||
guard !isPanicSuspended else { return }
|
||||
if let error = error {
|
||||
SecureLogger.error("❌ Error discovering services for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session)
|
||||
// Retry service discovery after a delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
guard peripheral.state == .connected else { return }
|
||||
peripheral.discoverServices([BLEService.serviceUUID])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let services = peripheral.services else {
|
||||
SecureLogger.warning("⚠️ No services discovered for \(peripheral.name ?? "Unknown")", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
guard let service = services.first(where: { $0.uuid == BLEService.serviceUUID }) else {
|
||||
// Not a BitChat peer - disconnect
|
||||
centralManager?.cancelPeripheralConnection(peripheral)
|
||||
return
|
||||
}
|
||||
|
||||
// Discovering BLE characteristics
|
||||
peripheral.discoverCharacteristics([BLEService.characteristicUUID], for: service)
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
||||
guard !isPanicSuspended else { return }
|
||||
if let error = error {
|
||||
SecureLogger.error("❌ Error discovering characteristics for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
guard let characteristic = service.characteristics?.first(where: { $0.uuid == BLEService.characteristicUUID }) else {
|
||||
SecureLogger.warning("⚠️ No matching characteristic found for \(peripheral.name ?? "Unknown")", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
// Found characteristic
|
||||
|
||||
// Log characteristic properties for debugging
|
||||
var properties: [String] = []
|
||||
if characteristic.properties.contains(.read) { properties.append("read") }
|
||||
if characteristic.properties.contains(.write) { properties.append("write") }
|
||||
if characteristic.properties.contains(.writeWithoutResponse) { properties.append("writeWithoutResponse") }
|
||||
if characteristic.properties.contains(.notify) { properties.append("notify") }
|
||||
if characteristic.properties.contains(.indicate) { properties.append("indicate") }
|
||||
// Characteristic properties: \(properties.joined(separator: ", "))
|
||||
|
||||
// Verify characteristic supports reliable writes
|
||||
if !characteristic.properties.contains(.write) {
|
||||
SecureLogger.warning("⚠️ Characteristic doesn't support reliable writes (withResponse)!", category: .session)
|
||||
}
|
||||
|
||||
// Store characteristic in our consolidated structure
|
||||
let peripheralID = peripheral.identifier.uuidString
|
||||
linkStateStore.updateCharacteristic(characteristic, forPeripheralID: peripheralID)
|
||||
|
||||
// Subscribe for notifications
|
||||
if characteristic.properties.contains(.notify) {
|
||||
peripheral.setNotifyValue(true, for: characteristic)
|
||||
SecureLogger.debug("🔔 Subscribed to notifications from \(peripheral.name ?? "Unknown")", category: .session)
|
||||
|
||||
// Send announce after subscription is confirmed (force send for new connection)
|
||||
engineScheduler.schedule(after: TransportConfig.blePostSubscribeAnnounceDelaySeconds) { [weak self] in
|
||||
self?.sendAnnounce(forceSend: true)
|
||||
// Try flushing any spooled directed packets now that we have a link
|
||||
self?.flushDirectedSpool()
|
||||
}
|
||||
} else {
|
||||
SecureLogger.warning("⚠️ Characteristic does not support notifications", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
|
||||
guard !isPanicSuspended else { return }
|
||||
if let error = error {
|
||||
SecureLogger.error("❌ Error receiving notification: \(error.localizedDescription)", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = characteristic.value, !data.isEmpty else {
|
||||
SecureLogger.warning("⚠️ No data in notification", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
bufferNotificationChunk(data, from: peripheral)
|
||||
}
|
||||
|
||||
private func bufferNotificationChunk(_ chunk: Data, from peripheral: CBPeripheral) {
|
||||
let peripheralUUID = peripheral.identifier.uuidString
|
||||
|
||||
var state = linkStateStore.state(forPeripheralID: peripheralUUID) ?? BLEPeripheralLinkState(
|
||||
peripheral: peripheral,
|
||||
characteristic: nil,
|
||||
isConnecting: false,
|
||||
isConnected: peripheral.state == .connected,
|
||||
lastConnectionAttempt: nil,
|
||||
assembler: NotificationStreamAssembler()
|
||||
)
|
||||
|
||||
var assembler = state.assembler
|
||||
let result = assembler.append(chunk)
|
||||
state.assembler = assembler
|
||||
linkStateStore.setPeripheralState(state, for: peripheralUUID)
|
||||
|
||||
for byte in result.droppedPrefixes {
|
||||
SecureLogger.warning("⚠️ Dropping byte from BLE stream (unexpected prefix \(String(format: "%02x", byte)))", category: .session)
|
||||
}
|
||||
|
||||
if result.reset {
|
||||
SecureLogger.error("❌ Invalid BLE frame length; reset notification stream", category: .session)
|
||||
}
|
||||
|
||||
// Attribution — spoof rejection, announce binding, ingress
|
||||
// recording — is engine work now (the engine owns the bindings).
|
||||
// Frames hop up in decode order; the engine's serial slot ordering
|
||||
// gives the same same-batch spoof protection the old bleQueue-side
|
||||
// batch-local binding enforced: an announce that binds this link is
|
||||
// attributed before every frame that rode behind it.
|
||||
for frame in result.frames {
|
||||
guard let packet = BinaryProtocol.decode(frame) else {
|
||||
let prefix = frame.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
|
||||
SecureLogger.error("❌ Failed to decode assembled notification frame (len=\(frame.count), prefix=\(prefix))", category: .session)
|
||||
continue
|
||||
}
|
||||
emitLinkEvent(.frameDecoded(
|
||||
packet,
|
||||
link: .peripheral(peripheralUUID),
|
||||
linkDescription: "Peripheral \(peripheralUUID.prefix(8))…"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
|
||||
if let error = error {
|
||||
SecureLogger.error("❌ Write failed to \(peripheral.name ?? peripheral.identifier.uuidString): \(error.localizedDescription)", category: .session)
|
||||
// Don't retry - just log the error
|
||||
} else {
|
||||
SecureLogger.debug("✅ Write confirmed to \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
|
||||
guard !isPanicSuspended else { return }
|
||||
// Resume queued writes for this peripheral - called when canSendWriteWithoutResponse becomes true again
|
||||
if logRateLimiter.shouldLog(key: "peripheral-ready:\(peripheral.identifier.uuidString)") {
|
||||
SecureLogger.debug("📤 Peripheral \(peripheral.name ?? peripheral.identifier.uuidString.prefix(8).description) ready for more writes", category: .session)
|
||||
}
|
||||
drainPendingWrites(for: peripheral)
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) {
|
||||
guard !isPanicSuspended else { return }
|
||||
SecureLogger.warning("⚠️ Services modified for \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session)
|
||||
|
||||
let shouldRediscover = BLEService.shouldRediscoverBitChatService(
|
||||
invalidatedServiceUUIDs: invalidatedServices.map(\.uuid),
|
||||
cachedServiceUUIDs: peripheral.services?.map(\.uuid)
|
||||
)
|
||||
|
||||
guard shouldRediscover else { return }
|
||||
|
||||
let peripheralID = peripheral.identifier.uuidString
|
||||
linkStateStore.updatePeripheral(peripheralID) {
|
||||
$0.characteristic = nil
|
||||
$0.assembler = NotificationStreamAssembler()
|
||||
}
|
||||
|
||||
SecureLogger.debug("🔄 BitChat service changed for \(peripheral.name ?? peripheral.identifier.uuidString), rediscovering", category: .session)
|
||||
peripheral.discoverServices([BLEService.serviceUUID])
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
|
||||
guard !isPanicSuspended else { return }
|
||||
if let error = error {
|
||||
SecureLogger.error("❌ Error updating notification state: \(error.localizedDescription)", category: .session)
|
||||
} else {
|
||||
SecureLogger.debug("🔔 Notification state updated for \(peripheral.name ?? peripheral.identifier.uuidString): \(characteristic.isNotifying ? "ON" : "OFF")", category: .session)
|
||||
|
||||
// If notifications are now on, send an announce to ensure this peer knows about us
|
||||
if characteristic.isNotifying {
|
||||
// Sending announce after subscription
|
||||
self.sendAnnounce(forceSend: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension BLEService {
|
||||
static func shouldRediscoverBitChatService(
|
||||
invalidatedServiceUUIDs: [CBUUID],
|
||||
cachedServiceUUIDs: [CBUUID]?
|
||||
) -> Bool {
|
||||
invalidatedServiceUUIDs.contains(serviceUUID) || cachedServiceUUIDs?.contains(serviceUUID) != true
|
||||
}
|
||||
}
|
||||
320
bitchat/Services/BLE/BLEService+LinkLayerPeripheralRole.swift
Normal file
320
bitchat/Services/BLE/BLEService+LinkLayerPeripheralRole.swift
Normal file
@ -0,0 +1,320 @@
|
||||
//
|
||||
// BLEService+LinkLayerPeripheralRole.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import BitLogger
|
||||
import CoreBluetooth
|
||||
import Foundation
|
||||
|
||||
// The bleQueue half of the link layer: CoreBluetooth delegate callbacks do
|
||||
// physical bookkeeping (link-state store, buffers, radio policy) and report
|
||||
// everything else to the engine through the link-event port
|
||||
// (BLELinkEvent / emitLinkEvent). See docs/BLE-ARCHITECTURE-V3.md.
|
||||
|
||||
// MARK: - CBPeripheralManagerDelegate
|
||||
|
||||
extension BLEService: CBPeripheralManagerDelegate {
|
||||
func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
|
||||
SecureLogger.debug("📡 Peripheral manager state: \(peripheral.state.rawValue)", category: .session)
|
||||
|
||||
switch peripheral.state {
|
||||
case .poweredOn:
|
||||
guard !isPanicSuspended else {
|
||||
peripheral.stopAdvertising()
|
||||
peripheral.removeAllServices()
|
||||
characteristic = nil
|
||||
return
|
||||
}
|
||||
// Remove all services first to ensure clean state
|
||||
peripheral.removeAllServices()
|
||||
|
||||
// Create characteristic
|
||||
characteristic = CBMutableCharacteristic(
|
||||
type: BLEService.characteristicUUID,
|
||||
properties: [.notify, .write, .writeWithoutResponse, .read],
|
||||
value: nil,
|
||||
permissions: [.readable, .writeable]
|
||||
)
|
||||
|
||||
// Create service
|
||||
let service = CBMutableService(type: BLEService.serviceUUID, primary: true)
|
||||
service.characteristics = [characteristic!]
|
||||
|
||||
// Add service (advertising will start in didAdd delegate)
|
||||
SecureLogger.debug("🔧 Adding BLE service...", category: .session)
|
||||
peripheral.add(service)
|
||||
|
||||
case .poweredOff:
|
||||
// Bluetooth was turned off - clean up peripheral state
|
||||
SecureLogger.info("📴 Bluetooth powered off - cleaning up peripheral state", category: .session)
|
||||
// Clear subscribed centrals (they are now invalid)
|
||||
let centralIDs = linkStateStore.subscribedCentrals.map { $0.identifier.uuidString }
|
||||
pendingNotifications.removeAll()
|
||||
pendingWriteBuffers.removeAll()
|
||||
linkStateStore.clearCentrals()
|
||||
subscriptionAnnounceLimiter.removeAll()
|
||||
characteristic = nil
|
||||
emitLinkEvent(.allCentralLinksEnded(centralUUIDs: centralIDs, retireProofsAndNotify: true))
|
||||
|
||||
case .unauthorized:
|
||||
// User denied Bluetooth permission
|
||||
SecureLogger.warning("🚫 Bluetooth unauthorized for peripheral role", category: .session)
|
||||
linkStateStore.clearCentrals()
|
||||
subscriptionAnnounceLimiter.removeAll()
|
||||
characteristic = nil
|
||||
emitLinkEvent(.allCentralLinksEnded(centralUUIDs: [], retireProofsAndNotify: false))
|
||||
|
||||
case .unsupported:
|
||||
// Device doesn't support BLE peripheral role
|
||||
SecureLogger.error("❌ Bluetooth LE peripheral role not supported", category: .session)
|
||||
|
||||
case .resetting:
|
||||
// Bluetooth stack is resetting
|
||||
SecureLogger.info("🔄 Bluetooth peripheral stack resetting...", category: .session)
|
||||
|
||||
case .unknown:
|
||||
SecureLogger.debug("❓ Peripheral Bluetooth state unknown (initializing)", category: .session)
|
||||
|
||||
@unknown default:
|
||||
SecureLogger.warning("⚠️ Unknown peripheral Bluetooth state: \(peripheral.state.rawValue)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String: Any]) {
|
||||
guard !isPanicSuspended else {
|
||||
peripheral.stopAdvertising()
|
||||
peripheral.removeAllServices()
|
||||
characteristic = nil
|
||||
return
|
||||
}
|
||||
let restoredServices = (dict[CBPeripheralManagerRestoredStateServicesKey] as? [CBMutableService]) ?? []
|
||||
let restoredAdvertisement = (dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any]) ?? [:]
|
||||
|
||||
SecureLogger.info(
|
||||
"♻️ Peripheral restore: services=\(restoredServices.count) advertisingDataKeys=\(Array(restoredAdvertisement.keys))",
|
||||
category: .session
|
||||
)
|
||||
|
||||
// Attempt to recover characteristic from restored services
|
||||
if characteristic == nil {
|
||||
if let service = restoredServices.first(where: { $0.uuid == BLEService.serviceUUID }),
|
||||
let restoredCharacteristic = service.characteristics?.first(where: { $0.uuid == BLEService.characteristicUUID }) as? CBMutableCharacteristic {
|
||||
characteristic = restoredCharacteristic
|
||||
}
|
||||
}
|
||||
|
||||
// Via the sampler for a fresh background budget (see central-restore).
|
||||
logBluetoothStatus("peripheral-restore")
|
||||
|
||||
if peripheral.state == .poweredOn && !peripheral.isAdvertising {
|
||||
peripheral.startAdvertising(BLERadioController.advertisementData())
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) {
|
||||
guard !isPanicSuspended else {
|
||||
peripheral.stopAdvertising()
|
||||
return
|
||||
}
|
||||
if let error = error {
|
||||
SecureLogger.error("❌ Failed to add service: \(error.localizedDescription)", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
SecureLogger.debug("✅ Service added successfully, starting advertising", category: .session)
|
||||
|
||||
// Start advertising after service is confirmed added
|
||||
let adData = BLERadioController.advertisementData()
|
||||
peripheral.startAdvertising(adData)
|
||||
|
||||
SecureLogger.debug("📡 Started advertising (LocalName: \((adData[CBAdvertisementDataLocalNameKey] as? String) != nil ? "on" : "off"), ID: \(myPeerID.id.prefix(8))…)", category: .session)
|
||||
}
|
||||
|
||||
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) {
|
||||
guard !isPanicSuspended else { return }
|
||||
let centralUUID = central.identifier.uuidString
|
||||
SecureLogger.debug("📥 Central subscribed: \(centralUUID.prefix(8))…", category: .session)
|
||||
linkStateStore.addSubscribedCentral(central)
|
||||
|
||||
// BCH-01-004: Rate-limit subscription-triggered announces to prevent enumeration attacks
|
||||
let now = Date()
|
||||
switch subscriptionAnnounceLimiter.decision(for: centralUUID, now: now) {
|
||||
case .allowed:
|
||||
break
|
||||
case let .rateLimited(backoffSeconds, attemptCount, suppressAnnounce):
|
||||
SecureLogger.warning("🛡️ BCH-01-004: Rate-limited announce for central \(centralUUID.prefix(8))... (backoff: \(Int(backoffSeconds))s, attempts: \(attemptCount))", category: .security)
|
||||
if suppressAnnounce {
|
||||
SecureLogger.warning("🚨 BCH-01-004: Possible enumeration attack from central \(centralUUID.prefix(8))... - suppressing announce", category: .security)
|
||||
return
|
||||
}
|
||||
|
||||
// Still flush directed packets for legitimate mesh operation
|
||||
engineScheduler.schedule(after: TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in
|
||||
self?.flushDirectedSpool()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Send announce to the newly subscribed central after a small delay
|
||||
engineScheduler.schedule(after: TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in
|
||||
self?.sendAnnounce(forceSend: true)
|
||||
// Flush any spooled directed packets now that we have a central subscribed
|
||||
self?.flushDirectedSpool()
|
||||
}
|
||||
}
|
||||
|
||||
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) {
|
||||
let centralID = central.identifier.uuidString
|
||||
SecureLogger.debug("📤 Central unsubscribed: \(centralID.prefix(8))…", category: .session)
|
||||
// bleQueue: physical retirement now.
|
||||
pendingNotifications.removeTarget { $0.identifier.uuidString == centralID }
|
||||
linkStateStore.removeSubscribedCentral(central)
|
||||
|
||||
// Ensure we're still advertising for other devices to find us
|
||||
if !isPanicSuspended, peripheral.isAdvertising == false {
|
||||
SecureLogger.debug("📡 Restarting advertising after central unsubscribed", category: .session)
|
||||
peripheral.startAdvertising(BLERadioController.advertisementData())
|
||||
}
|
||||
|
||||
// Identity retirement and peer-disconnect bookkeeping ride the
|
||||
// link-event port.
|
||||
emitLinkEvent(.centralLinkEnded(centralUUID: centralID))
|
||||
}
|
||||
|
||||
func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) {
|
||||
guard !isPanicSuspended else { return }
|
||||
drainPendingNotifications(logPrefix: "✅ Sent")
|
||||
}
|
||||
|
||||
func logBackpressureSampled(_ message: @autoclosure () -> String) {
|
||||
notificationBackpressureLogCount += 1
|
||||
if notificationBackpressureLogCount == 1 ||
|
||||
notificationBackpressureLogCount.isMultiple(of: TransportConfig.bleBackpressureLogInterval) {
|
||||
SecureLogger.debug("\(message()) [backpressure event #\(notificationBackpressureLogCount)]", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
func drainPendingNotifications(logPrefix: String) {
|
||||
bleQueue.async { [weak self] in
|
||||
guard let self = self,
|
||||
let characteristic = self.characteristic,
|
||||
!self.pendingNotifications.isEmpty else { return }
|
||||
|
||||
let pending = self.pendingNotifications.takeAll()
|
||||
let sentCount = self.sendPendingNotifications(pending, characteristic: characteristic)
|
||||
|
||||
if sentCount > 0 {
|
||||
self.logBackpressureSampled("\(logPrefix) \(sentCount) pending notifications from retry queue (\(self.pendingNotifications.count) still pending)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sendPendingNotifications(_ pending: [BLEPendingNotification<CBCentral>], characteristic: CBMutableCharacteristic) -> Int {
|
||||
var sentCount = 0
|
||||
|
||||
for (index, notification) in pending.enumerated() {
|
||||
let success = peripheralManager?.updateValue(
|
||||
notification.data,
|
||||
for: characteristic,
|
||||
onSubscribedCentrals: notification.targets
|
||||
) ?? false
|
||||
|
||||
guard success else {
|
||||
let remaining = Array(pending.dropFirst(index))
|
||||
pendingNotifications.prepend(remaining)
|
||||
logBackpressureSampled("⚠️ Notification queue still full after \(sentCount) sent, re-queuing \(remaining.count) items")
|
||||
break
|
||||
}
|
||||
|
||||
sentCount += 1
|
||||
}
|
||||
|
||||
return sentCount
|
||||
}
|
||||
|
||||
func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) {
|
||||
// Suppress logs for single write requests to reduce noise
|
||||
if requests.count > 1 {
|
||||
SecureLogger.debug("📥 Received \(requests.count) write requests from central", category: .session)
|
||||
}
|
||||
|
||||
// IMPORTANT: Respond immediately to prevent timeouts!
|
||||
// We must respond within a few milliseconds or the central will timeout
|
||||
for request in requests {
|
||||
peripheral.respond(to: request, withResult: .success)
|
||||
}
|
||||
guard !isPanicSuspended else { return }
|
||||
|
||||
// Process writes. For long writes, CoreBluetooth may deliver multiple CBATTRequest values with offsets.
|
||||
// Combine per-central request values by offset before decoding.
|
||||
// Process directly on our message queue to match transport context
|
||||
let grouped = Dictionary(grouping: requests, by: { $0.central.identifier.uuidString })
|
||||
for (centralUUID, group) in grouped {
|
||||
// Sort by offset ascending
|
||||
let sorted = group.sorted { $0.offset < $1.offset }
|
||||
let hasMultiple = sorted.count > 1 || (sorted.first?.offset ?? 0) > 0
|
||||
let chunks = sorted.compactMap { request -> BLEInboundWriteChunk? in
|
||||
guard let data = request.value, !data.isEmpty else { return nil }
|
||||
return BLEInboundWriteChunk(offset: request.offset, data: data)
|
||||
}
|
||||
|
||||
let result = pendingWriteBuffers.append(
|
||||
chunks: chunks,
|
||||
for: centralUUID,
|
||||
capBytes: TransportConfig.blePendingWriteBufferCapBytes
|
||||
)
|
||||
|
||||
switch result {
|
||||
case let .decoded(packet, metadata):
|
||||
logAccumulatedCentralWrite(metadata, centralUUID: centralUUID)
|
||||
processDecodedCentralWrite(packet, centralUUID: centralUUID, central: sorted[0].central)
|
||||
|
||||
case let .waiting(metadata):
|
||||
logAccumulatedCentralWrite(metadata, centralUUID: centralUUID)
|
||||
logFailedSingleWriteIfNeeded(hasMultiple: hasMultiple, sortedRequests: sorted)
|
||||
|
||||
case let .oversized(metadata):
|
||||
logAccumulatedCentralWrite(metadata, centralUUID: centralUUID)
|
||||
SecureLogger.warning("⚠️ Dropping oversized pending write buffer (\(metadata.accumulatedBytes) bytes) for central \(centralUUID.prefix(8))…", category: .session)
|
||||
logFailedSingleWriteIfNeeded(hasMultiple: hasMultiple, sortedRequests: sorted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func logAccumulatedCentralWrite(_ metadata: BLEInboundWriteAppendMetadata, centralUUID: String) {
|
||||
guard let packetType = metadata.packetType,
|
||||
packetType != MessageType.announce.rawValue else { return }
|
||||
|
||||
SecureLogger.debug(
|
||||
"📥 Accumulated write from central \(centralUUID.prefix(8))…: size=\(metadata.accumulatedBytes) (+\(metadata.appendedBytes)) bytes (type=\(packetType)), offsets=\(metadata.offsets)",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
|
||||
private func logFailedSingleWriteIfNeeded(hasMultiple: Bool, sortedRequests: [CBATTRequest]) {
|
||||
guard !hasMultiple, let raw = sortedRequests.first?.value else { return }
|
||||
|
||||
let prefix = raw.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
|
||||
SecureLogger.error("❌ Failed to decode packet from central (len=\(raw.count), prefix=\(prefix))", category: .session)
|
||||
}
|
||||
|
||||
private func processDecodedCentralWrite(_ packet: BitchatPacket, centralUUID: String, central: CBCentral) {
|
||||
// bleQueue: physical bookkeeping only. A writer is a live central
|
||||
// whether or not it subscribed; track it so directed replies and
|
||||
// the fanout planner can reach it.
|
||||
linkStateStore.addSubscribedCentral(central)
|
||||
// Attribution is engine work (the engine owns the bindings).
|
||||
emitLinkEvent(.frameDecoded(
|
||||
packet,
|
||||
link: .central(centralUUID),
|
||||
linkDescription: "Central \(centralUUID.prefix(8))…"
|
||||
))
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -19,6 +19,8 @@ final class BoardManager: ObservableObject {
|
||||
@Published private(set) var posts: [BoardPostPacket] = []
|
||||
|
||||
private let transport: Transport
|
||||
/// Board broadcast rides the mesh only; absent on other transports.
|
||||
private var boardTransport: MeshBoardBroadcasting? { transport as? MeshBoardBroadcasting }
|
||||
/// Publishes a bridged kind-1 note (expiring with the board post via
|
||||
/// NIP-40) and returns its Nostr event id, or nil when bridging failed or
|
||||
/// was skipped.
|
||||
@ -122,7 +124,7 @@ final class BoardManager: ObservableObject {
|
||||
flags: flags,
|
||||
signature: signature
|
||||
)
|
||||
transport.sendBoardPayload(BoardWire.post(post).encode())
|
||||
boardTransport?.sendBoardPayload(BoardWire.post(post).encode())
|
||||
|
||||
// Nostr bridge: geohash posts also go out as kind-1 location notes so
|
||||
// online users see them. Remember the event id for merged deletes.
|
||||
@ -148,7 +150,7 @@ final class BoardManager: ObservableObject {
|
||||
deletedAt: deletedAt,
|
||||
signature: signature
|
||||
)
|
||||
transport.sendBoardPayload(BoardWire.tombstone(tombstone).encode())
|
||||
boardTransport?.sendBoardPayload(BoardWire.tombstone(tombstone).encode())
|
||||
|
||||
// Merged delete: also retract the bridged Nostr copy when we still
|
||||
// know its event id.
|
||||
|
||||
@ -90,6 +90,9 @@ protocol CommandContextProvider: AnyObject {
|
||||
final class CommandProcessor {
|
||||
weak var contextProvider: CommandContextProvider?
|
||||
weak var meshService: Transport?
|
||||
/// Mesh-only command surfaces, absent when the transport lacks them.
|
||||
private var meshDiagnostics: MeshDiagnosing? { meshService as? MeshDiagnosing }
|
||||
private var meshArchive: MeshPublicArchiving? { meshService as? MeshPublicArchiving }
|
||||
private let identityManager: SecureIdentityStateManagerProtocol
|
||||
|
||||
init(contextProvider: CommandContextProvider? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
|
||||
@ -371,7 +374,7 @@ final class CommandProcessor {
|
||||
}
|
||||
// Scrub their carried public messages now, while the peerID is
|
||||
// resolvable, so they can't resurface as archived echoes.
|
||||
meshService?.purgeArchivedPublicMessages(from: peerID)
|
||||
meshArchive?.purgeArchivedPublicMessages(from: peerID)
|
||||
return .success(message: "blocked \(nickname). you will no longer receive messages from them")
|
||||
}
|
||||
// Mesh lookup failed; try geohash (Nostr) participant by display name
|
||||
@ -474,7 +477,7 @@ final class CommandProcessor {
|
||||
// meshPingTimeoutSeconds later, and reading the selected chat at
|
||||
// callback time would misroute the result after a chat switch.
|
||||
let destination = contextProvider?.currentCommandDestination() ?? .meshTimeline
|
||||
meshService?.sendMeshPing(to: target.peerID) { [weak currentProvider] result in
|
||||
meshDiagnostics?.sendMeshPing(to: target.peerID) { [weak currentProvider] result in
|
||||
let provider = currentProvider
|
||||
guard let result else {
|
||||
provider?.addCommandOutput("no reply from \(nickname)", to: destination)
|
||||
@ -496,7 +499,7 @@ final class CommandProcessor {
|
||||
}
|
||||
|
||||
guard let mesh = meshService,
|
||||
let intermediates = mesh.computeMeshPath(to: target.peerID) else {
|
||||
let intermediates = meshDiagnostics?.computeMeshPath(to: target.peerID) else {
|
||||
return .success(message: "no known path to \(target.nickname)")
|
||||
}
|
||||
// Graph-derived from gossiped neighbor claims, not route-recorded —
|
||||
|
||||
@ -322,7 +322,7 @@ final class CourierStore {
|
||||
|
||||
/// Envelopes eligible to park on relays as bridge courier drops. Merely
|
||||
/// offering one does not start its cooldown: the caller commits that only
|
||||
/// after a relay explicitly accepts the event via NIP-20 OK.
|
||||
/// after a relay explicitly accepts the event via NIP-01 `OK`.
|
||||
func envelopesForBridgePublish(cooldown: TimeInterval) -> [CourierEnvelope] {
|
||||
let date = now()
|
||||
return queue.sync {
|
||||
|
||||
@ -110,6 +110,10 @@ final class MessageOutboxStore {
|
||||
/// Delivery/read acknowledgments received before a deferred cold-load
|
||||
/// reveals the durable queue. Applied to every merge before persistence.
|
||||
private var pendingRemovalMessageIDs = Set<String>()
|
||||
/// Peer-scoped acknowledgments received before a deferred cold-load
|
||||
/// reveals the durable queue. Unlike the legacy global tombstones above,
|
||||
/// these must not remove a colliding message ID queued for another peer.
|
||||
private var pendingScopedRemovalMessageIDs: [PeerID: Set<String>] = [:]
|
||||
private var recoveryHandler: (@MainActor (Snapshot) -> Void)?
|
||||
/// Recovery loaded durable state that MessageRouter has not merged yet.
|
||||
/// While true, router saves must union with `cachedSnapshot` instead of
|
||||
@ -195,7 +199,7 @@ final class MessageOutboxStore {
|
||||
? (pendingSnapshot ?? [:])
|
||||
: Self.merge(durable, pendingSnapshot ?? [:]))
|
||||
diskState = .loaded
|
||||
if pendingSnapshot != nil || !pendingRemovalMessageIDs.isEmpty {
|
||||
if pendingSnapshot != nil || hasPendingRemovalsLocked {
|
||||
if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
|
||||
pendingSnapshot = nil
|
||||
pendingSnapshotIsAuthoritative = false
|
||||
@ -212,7 +216,7 @@ final class MessageOutboxStore {
|
||||
case .missing:
|
||||
cachedSnapshot = applyingPendingRemovalsLocked(pendingSnapshot ?? [:])
|
||||
diskState = .loaded
|
||||
if pendingSnapshot != nil || !pendingRemovalMessageIDs.isEmpty {
|
||||
if pendingSnapshot != nil || hasPendingRemovalsLocked {
|
||||
if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
|
||||
pendingSnapshot = nil
|
||||
pendingSnapshotIsAuthoritative = false
|
||||
@ -235,7 +239,7 @@ final class MessageOutboxStore {
|
||||
diskState = .loaded
|
||||
cachedSnapshot = applyingPendingRemovalsLocked(pendingSnapshot ?? [:])
|
||||
SecureLogger.error("Failed to decode encrypted outbox: \(error)", category: .session)
|
||||
if pendingSnapshot != nil || !pendingRemovalMessageIDs.isEmpty {
|
||||
if pendingSnapshot != nil || hasPendingRemovalsLocked {
|
||||
if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
|
||||
pendingSnapshot = nil
|
||||
pendingSnapshotIsAuthoritative = false
|
||||
@ -442,6 +446,25 @@ final class MessageOutboxStore {
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// Records an ack for only the supplied peer aliases. This preserves
|
||||
/// another recipient's queued entry when message IDs happen to collide,
|
||||
/// including while the durable snapshot is hidden by protected data.
|
||||
func recordRemoval(messageID: String, for peerIDs: Set<PeerID>) {
|
||||
guard !peerIDs.isEmpty else { return }
|
||||
|
||||
lock.lock()
|
||||
for peerID in peerIDs {
|
||||
pendingScopedRemovalMessageIDs[peerID, default: []].insert(messageID)
|
||||
}
|
||||
cachedSnapshot = Self.removing(pendingScopedRemovalMessageIDs, from: cachedSnapshot)
|
||||
unseenRecoveredSnapshot = Self.removing(pendingScopedRemovalMessageIDs, from: unseenRecoveredSnapshot)
|
||||
recoveryRouterSnapshot = Self.removing(pendingScopedRemovalMessageIDs, from: recoveryRouterSnapshot)
|
||||
if let pendingSnapshot {
|
||||
self.pendingSnapshot = Self.removing(pendingScopedRemovalMessageIDs, from: pendingSnapshot)
|
||||
}
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// Retries a deferred protected-data load. The returned snapshot includes
|
||||
/// both durable messages and any messages queued during the locked wake.
|
||||
@discardableResult
|
||||
@ -478,7 +501,7 @@ final class MessageOutboxStore {
|
||||
: (pendingSnapshotIsAuthoritative ? known : Self.merge(durable, known)))
|
||||
cachedSnapshot = merged
|
||||
diskState = .loaded
|
||||
if (pendingSnapshot == nil && pendingRemovalMessageIDs.isEmpty) ||
|
||||
if (pendingSnapshot == nil && !hasPendingRemovalsLocked) ||
|
||||
persistSnapshotAndClearRemovalsLocked(merged) {
|
||||
pendingSnapshot = nil
|
||||
pendingSnapshotIsAuthoritative = false
|
||||
@ -507,7 +530,7 @@ final class MessageOutboxStore {
|
||||
: known)
|
||||
cachedSnapshot = merged
|
||||
diskState = .loaded
|
||||
if (pendingSnapshot == nil && pendingRemovalMessageIDs.isEmpty) ||
|
||||
if (pendingSnapshot == nil && !hasPendingRemovalsLocked) ||
|
||||
persistSnapshotAndClearRemovalsLocked(merged) {
|
||||
pendingSnapshot = nil
|
||||
pendingSnapshotIsAuthoritative = false
|
||||
@ -539,7 +562,7 @@ final class MessageOutboxStore {
|
||||
: known)
|
||||
cachedSnapshot = merged
|
||||
diskState = .loaded
|
||||
if (pendingSnapshot == nil && pendingRemovalMessageIDs.isEmpty) ||
|
||||
if (pendingSnapshot == nil && !hasPendingRemovalsLocked) ||
|
||||
persistSnapshotAndClearRemovalsLocked(merged) {
|
||||
pendingSnapshot = nil
|
||||
pendingSnapshotIsAuthoritative = false
|
||||
@ -578,6 +601,7 @@ final class MessageOutboxStore {
|
||||
pendingSnapshot = nil
|
||||
pendingSnapshotIsAuthoritative = false
|
||||
pendingRemovalMessageIDs.removeAll()
|
||||
pendingScopedRemovalMessageIDs.removeAll()
|
||||
recoveryDeliveryPending = false
|
||||
unseenRecoveryPendingPersistence = false
|
||||
unseenRecoveredSnapshot = [:]
|
||||
@ -742,12 +766,21 @@ final class MessageOutboxStore {
|
||||
private func persistSnapshotAndClearRemovalsLocked(_ snapshot: Snapshot) -> Bool {
|
||||
guard persistSnapshotLocked(snapshot) else { return false }
|
||||
pendingRemovalMessageIDs.removeAll()
|
||||
pendingScopedRemovalMessageIDs.removeAll()
|
||||
return true
|
||||
}
|
||||
|
||||
/// Must be called with `lock` held.
|
||||
private func applyingPendingRemovalsLocked(_ snapshot: Snapshot) -> Snapshot {
|
||||
Self.removing(pendingRemovalMessageIDs, from: snapshot)
|
||||
Self.removing(
|
||||
pendingScopedRemovalMessageIDs,
|
||||
from: Self.removing(pendingRemovalMessageIDs, from: snapshot)
|
||||
)
|
||||
}
|
||||
|
||||
/// Must be read with `lock` held.
|
||||
private var hasPendingRemovalsLocked: Bool {
|
||||
!pendingRemovalMessageIDs.isEmpty || !pendingScopedRemovalMessageIDs.isEmpty
|
||||
}
|
||||
|
||||
private static func removing(_ messageIDs: Set<String>, from snapshot: Snapshot) -> Snapshot {
|
||||
@ -760,9 +793,28 @@ final class MessageOutboxStore {
|
||||
return filtered
|
||||
}
|
||||
|
||||
private static func removing(
|
||||
_ messageIDsByPeer: [PeerID: Set<String>],
|
||||
from snapshot: Snapshot
|
||||
) -> Snapshot {
|
||||
guard !messageIDsByPeer.isEmpty else { return snapshot }
|
||||
var filtered = snapshot
|
||||
for (peerID, messageIDs) in messageIDsByPeer {
|
||||
guard !messageIDs.isEmpty, let queue = filtered[peerID] else { continue }
|
||||
let remaining = queue.filter { !messageIDs.contains($0.messageID) }
|
||||
filtered[peerID] = remaining.isEmpty ? nil : remaining
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
private static func excludingKnownMessages(from durable: Snapshot, known: Snapshot) -> Snapshot {
|
||||
let knownIDs = Set(known.values.flatMap { $0.map(\.messageID) })
|
||||
return removing(knownIDs, from: durable)
|
||||
var unseen: Snapshot = [:]
|
||||
for (peerID, durableQueue) in durable {
|
||||
let knownIDs = Set(known[peerID]?.map(\.messageID) ?? [])
|
||||
let remaining = durableQueue.filter { !knownIDs.contains($0.messageID) }
|
||||
if !remaining.isEmpty { unseen[peerID] = remaining }
|
||||
}
|
||||
return unseen
|
||||
}
|
||||
|
||||
private static func merge(_ durable: Snapshot, _ pending: Snapshot) -> Snapshot {
|
||||
|
||||
@ -67,7 +67,7 @@ final class BridgeCourierService: ObservableObject {
|
||||
var relaysConnected: (@MainActor () -> Bool)?
|
||||
/// Publishes a signed drop event directly to connected default (DM)
|
||||
/// relays. Completion is true only after at least one relay explicitly
|
||||
/// accepts the event via NIP-20 OK; this must never mean "queued in RAM"
|
||||
/// accepts the event via NIP-01 `OK`; this must never mean "queued in RAM"
|
||||
/// or merely "written to a socket".
|
||||
var publishEvent: (@MainActor (NostrEvent, @escaping @MainActor (Bool) -> Void) -> Void)?
|
||||
/// (Re)opens the drop subscription for the given hex tags.
|
||||
@ -106,13 +106,14 @@ final class BridgeCourierService: ObservableObject {
|
||||
dedupKey: String?,
|
||||
operationID: UUID?
|
||||
)] = []
|
||||
/// Message IDs already published as drops (sender-side dedup) and drop
|
||||
/// event IDs already handled (multi-relay dedup). Both persist across
|
||||
/// relaunches: relays hold drops for the full 24h NIP-40 window and the
|
||||
/// persisted outbox keeps re-depositing, so in-memory-only dedup meant
|
||||
/// every relaunch republished the same message as a fresh drop and every
|
||||
/// gateway relaunch re-delivered the whole backlog (field-verified
|
||||
/// amplification storm). Entries age out with the 24h drop window.
|
||||
/// Opaque recipient/message keys already published as drops (sender-side
|
||||
/// dedup) and drop event IDs already handled (multi-relay dedup). Both
|
||||
/// persist across relaunches: relays hold drops for the full 24h NIP-40
|
||||
/// window and the persisted outbox keeps re-depositing, so in-memory-only
|
||||
/// dedup meant every relaunch republished the same message as a fresh drop
|
||||
/// and every gateway relaunch re-delivered the whole backlog
|
||||
/// (field-verified amplification storm). Entries age out with the 24h drop
|
||||
/// window.
|
||||
private var publishedDropKeys: ExpiringIDSet
|
||||
private var seenDropEventIDs: ExpiringIDSet
|
||||
private var subscriptionOpen = false
|
||||
@ -126,10 +127,10 @@ final class BridgeCourierService: ObservableObject {
|
||||
}
|
||||
/// Sender operations queued locally or awaiting relay confirmation.
|
||||
/// The per-attempt ID prevents a stale pre-wipe callback from completing
|
||||
/// a newer attempt for the same message.
|
||||
/// a newer attempt for the same recipient/message pair.
|
||||
private var activeDropOperations: [String: ActiveDropOperation] = [:]
|
||||
/// Held-envelope publishes have no sender message ID, but still need an
|
||||
/// in-flight identity: repeated refreshes inside the NIP-20 wait window
|
||||
/// in-flight identity: repeated refreshes inside the relay-OK wait window
|
||||
/// must not mint duplicate relay events for the same opaque envelope.
|
||||
private var heldDropOperations: [Data: UUID] = [:]
|
||||
/// Deterministically invalid envelopes are suppressed for this process,
|
||||
@ -214,11 +215,46 @@ final class BridgeCourierService: ObservableObject {
|
||||
|
||||
// MARK: - Sender role
|
||||
|
||||
/// Stable, opaque sender-side dedup key. Recipient scope prevents one
|
||||
/// conversation's colliding message ID from suppressing another's drop,
|
||||
/// while hashing keeps recipient keys out of the persisted snapshot.
|
||||
private static func senderDropKey(
|
||||
messageID: String,
|
||||
recipientNoiseKey: Data
|
||||
) -> String {
|
||||
var material = Data("bitchat-bridge-drop-dedup-v2".utf8)
|
||||
appendLengthPrefixed(Data(messageID.utf8), to: &material)
|
||||
appendLengthPrefixed(recipientNoiseKey, to: &material)
|
||||
return "v2:\(material.sha256Hex())"
|
||||
}
|
||||
|
||||
private static func appendLengthPrefixed(_ value: Data, to output: inout Data) {
|
||||
let length = UInt32(value.count)
|
||||
output.append(UInt8((length >> 24) & 0xFF))
|
||||
output.append(UInt8((length >> 16) & 0xFF))
|
||||
output.append(UInt8((length >> 8) & 0xFF))
|
||||
output.append(UInt8(length & 0xFF))
|
||||
output.append(value)
|
||||
}
|
||||
|
||||
/// Previous releases persisted raw message IDs without recipient scope.
|
||||
/// They remain conservative wildcards for their original 24-hour
|
||||
/// lifetime: assigning one to a recipient would be guesswork and could
|
||||
/// republish the original drop. New acceptances persist only v2 keys.
|
||||
private func wasPublished(
|
||||
legacyMessageID: String,
|
||||
dedupKey: String,
|
||||
now date: Date
|
||||
) -> Bool {
|
||||
publishedDropKeys.contains(dedupKey, now: date)
|
||||
|| publishedDropKeys.contains(legacyMessageID, now: date)
|
||||
}
|
||||
|
||||
/// Parallel-deposit a sealed copy of an outbound private message as a
|
||||
/// relay drop. Called by the message router alongside physical courier
|
||||
/// deposits; idempotent per message ID. Completion becomes true only
|
||||
/// after a real relay acceptance arrives, which is when the router may
|
||||
/// show "carried".
|
||||
/// deposits; idempotent per recipient/message pair. Completion becomes
|
||||
/// true only after a real relay acceptance arrives, which is when the
|
||||
/// router may show "carried".
|
||||
func depositDrop(
|
||||
content: String,
|
||||
messageID: String,
|
||||
@ -229,9 +265,18 @@ final class BridgeCourierService: ObservableObject {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
guard !publishedDropKeys.contains(messageID, now: now()),
|
||||
activeDropOperations[messageID] == nil,
|
||||
!rejectedDropKeys.contains(messageID, now: now()) else {
|
||||
let date = now()
|
||||
let dedupKey = Self.senderDropKey(
|
||||
messageID: messageID,
|
||||
recipientNoiseKey: recipientNoiseKey
|
||||
)
|
||||
guard !wasPublished(
|
||||
legacyMessageID: messageID,
|
||||
dedupKey: dedupKey,
|
||||
now: date
|
||||
),
|
||||
activeDropOperations[dedupKey] == nil,
|
||||
!rejectedDropKeys.contains(dedupKey, now: date) else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
@ -244,13 +289,13 @@ final class BridgeCourierService: ObservableObject {
|
||||
// of the sealing); suppress it in-memory so the retry sweep does not
|
||||
// churn, but never persist it as a published drop.
|
||||
guard let encoded = envelope.encode(), encoded.count <= Limits.maxDropEnvelopeBytes else {
|
||||
rejectedDropKeys.insert(messageID, now: now())
|
||||
rejectedDropKeys.insert(dedupKey, now: date)
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
let operationID = UUID()
|
||||
activeDropOperations[messageID] = ActiveDropOperation(id: operationID, completion: completion)
|
||||
publishDrop(envelope, messageID: messageID, operationID: operationID)
|
||||
activeDropOperations[dedupKey] = ActiveDropOperation(id: operationID, completion: completion)
|
||||
publishDrop(envelope, dedupKey: dedupKey, operationID: operationID)
|
||||
}
|
||||
|
||||
/// Publishes held envelopes (mail we carry for others) as drops,
|
||||
@ -272,13 +317,14 @@ final class BridgeCourierService: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Publishes a drop, or queues it when relays are down. `messageID` is the
|
||||
/// sender-side dedup key (nil for held/relayed envelopes we don't track);
|
||||
/// it rides the pending queue so an evicted or failed drop can release its
|
||||
/// in-flight slot. Completion reports actual NIP-20 relay acceptance.
|
||||
/// Publishes a drop, or queues it when relays are down. `dedupKey` is the
|
||||
/// opaque sender-side recipient/message key (nil for held/relayed
|
||||
/// envelopes we don't track); it rides the pending queue so an evicted or
|
||||
/// failed drop can release its in-flight slot. Completion reports actual
|
||||
/// NIP-01 relay acceptance.
|
||||
private func publishDrop(
|
||||
_ envelope: CourierEnvelope,
|
||||
messageID: String? = nil,
|
||||
dedupKey: String? = nil,
|
||||
operationID: UUID? = nil,
|
||||
untrackedCompletion: (@MainActor (Bool) -> Void)? = nil
|
||||
) {
|
||||
@ -286,7 +332,7 @@ final class BridgeCourierService: ObservableObject {
|
||||
encoded.count <= Limits.maxDropEnvelopeBytes,
|
||||
!envelope.isExpired else {
|
||||
finishPublish(
|
||||
messageID: messageID,
|
||||
dedupKey: dedupKey,
|
||||
operationID: operationID,
|
||||
succeeded: false,
|
||||
untrackedCompletion: untrackedCompletion
|
||||
@ -297,15 +343,15 @@ final class BridgeCourierService: ObservableObject {
|
||||
// Held mail remains in CourierStore and has no sender operation to
|
||||
// recover after an in-memory queue loss. Leave its cooldown unset
|
||||
// and let the next connected refresh offer it again.
|
||||
guard messageID != nil else {
|
||||
guard dedupKey != nil else {
|
||||
untrackedCompletion?(false)
|
||||
return
|
||||
}
|
||||
pendingDrops.append((envelope, messageID, operationID))
|
||||
pendingDrops.append((envelope, dedupKey, operationID))
|
||||
while pendingDrops.count > Limits.maxPendingDrops {
|
||||
let evicted = pendingDrops.removeFirst()
|
||||
finishPublish(
|
||||
messageID: evicted.dedupKey,
|
||||
dedupKey: evicted.dedupKey,
|
||||
operationID: evicted.operationID,
|
||||
succeeded: false
|
||||
)
|
||||
@ -321,7 +367,7 @@ final class BridgeCourierService: ObservableObject {
|
||||
) else {
|
||||
SecureLogger.error("📦🌉 Failed to compose courier drop", category: .encryption)
|
||||
finishPublish(
|
||||
messageID: messageID,
|
||||
dedupKey: dedupKey,
|
||||
operationID: operationID,
|
||||
succeeded: false,
|
||||
untrackedCompletion: untrackedCompletion
|
||||
@ -331,7 +377,7 @@ final class BridgeCourierService: ObservableObject {
|
||||
guard let publishEvent else {
|
||||
SecureLogger.error("📦🌉 Courier drop publisher is not configured", category: .session)
|
||||
finishPublish(
|
||||
messageID: messageID,
|
||||
dedupKey: dedupKey,
|
||||
operationID: operationID,
|
||||
succeeded: false,
|
||||
untrackedCompletion: untrackedCompletion
|
||||
@ -341,7 +387,7 @@ final class BridgeCourierService: ObservableObject {
|
||||
publishEvent(event) { [weak self] succeeded in
|
||||
guard let self else { return }
|
||||
guard self.finishPublish(
|
||||
messageID: messageID,
|
||||
dedupKey: dedupKey,
|
||||
operationID: operationID,
|
||||
succeeded: succeeded,
|
||||
untrackedCompletion: untrackedCompletion
|
||||
@ -356,23 +402,23 @@ final class BridgeCourierService: ObservableObject {
|
||||
|
||||
@discardableResult
|
||||
private func finishPublish(
|
||||
messageID: String?,
|
||||
dedupKey: String?,
|
||||
operationID: UUID?,
|
||||
succeeded: Bool,
|
||||
untrackedCompletion: (@MainActor (Bool) -> Void)? = nil
|
||||
) -> Bool {
|
||||
guard let messageID else {
|
||||
guard let dedupKey else {
|
||||
untrackedCompletion?(succeeded)
|
||||
return true
|
||||
}
|
||||
// Missing/mismatched means this callback was duplicated, invalidated
|
||||
// by panic wipe, or belongs to an older attempt for the same key.
|
||||
guard let operationID,
|
||||
let operation = activeDropOperations[messageID],
|
||||
let operation = activeDropOperations[dedupKey],
|
||||
operation.id == operationID else { return false }
|
||||
activeDropOperations.removeValue(forKey: messageID)
|
||||
activeDropOperations.removeValue(forKey: dedupKey)
|
||||
if succeeded {
|
||||
publishedDropKeys.insert(messageID, now: now())
|
||||
publishedDropKeys.insert(dedupKey, now: now())
|
||||
persistDedup()
|
||||
}
|
||||
operation.completion(succeeded)
|
||||
@ -387,7 +433,7 @@ final class BridgeCourierService: ObservableObject {
|
||||
for item in queued {
|
||||
publishDrop(
|
||||
item.envelope,
|
||||
messageID: item.dedupKey,
|
||||
dedupKey: item.dedupKey,
|
||||
operationID: item.operationID
|
||||
)
|
||||
}
|
||||
|
||||
@ -64,11 +64,11 @@ struct ExpiringIDSet {
|
||||
/// fresh drop (fresh throwaway seal, undeduplicatable downstream) and every
|
||||
/// gateway relaunch re-delivered the whole backlog. Field-verified: ~20
|
||||
/// copies of one DM delivered in 40ms fed the storm behind a permanent
|
||||
/// device freeze. Persisting both sides caps this at one drop per message
|
||||
/// ID per 24h regardless of relaunch count.
|
||||
/// device freeze. Persisting both sides caps this at one drop per
|
||||
/// recipient/message pair per 24h regardless of relaunch count.
|
||||
///
|
||||
/// Contents are opaque IDs (message UUIDs, relay event IDs) — no plaintext,
|
||||
/// no peer identities — so until-first-unlock protection matches
|
||||
/// Contents are opaque hashes and relay event IDs — no plaintext or peer
|
||||
/// identities — so until-first-unlock protection matches
|
||||
/// `NostrProcessedEventStore`, and the file must load during a
|
||||
/// locked-background restoration relaunch. Wiped on panic with the rest of
|
||||
/// the courier state.
|
||||
|
||||
@ -45,6 +45,9 @@ final class GeohashPresenceService: ObservableObject {
|
||||
|
||||
private var subscriptions = Set<AnyCancellable>()
|
||||
private var heartbeatTimer: GeohashPresenceTimerProtocol?
|
||||
private var pendingBroadcastTasks: [UUID: Task<Void, Never>] = [:]
|
||||
private var heartbeatGeneration: UInt64 = 0
|
||||
private var started = false
|
||||
private let availableChannelsProvider: () -> [GeohashChannel]
|
||||
private let locationChanges: AnyPublisher<[GeohashChannel], Never>
|
||||
private let torReadyPublisher: AnyPublisher<Void, Never>
|
||||
@ -147,10 +150,25 @@ final class GeohashPresenceService: ObservableObject {
|
||||
|
||||
/// Start the service (safe to call multiple times)
|
||||
func start() {
|
||||
guard !started else { return }
|
||||
started = true
|
||||
heartbeatGeneration &+= 1
|
||||
SecureLogger.info("Presence: service starting...", category: .session)
|
||||
scheduleNextHeartbeat()
|
||||
}
|
||||
|
||||
/// Stops the timer and every decorrelation task synchronously at the panic
|
||||
/// boundary. Generation checks also protect against custom sleepers that
|
||||
/// ignore task cancellation and return later.
|
||||
func stopForPanic() {
|
||||
started = false
|
||||
heartbeatGeneration &+= 1
|
||||
heartbeatTimer?.invalidate()
|
||||
heartbeatTimer = nil
|
||||
pendingBroadcastTasks.values.forEach { $0.cancel() }
|
||||
pendingBroadcastTasks.removeAll(keepingCapacity: false)
|
||||
}
|
||||
|
||||
private func setupObservers() {
|
||||
// Monitor location channel changes
|
||||
locationChanges
|
||||
@ -169,20 +187,26 @@ final class GeohashPresenceService: ObservableObject {
|
||||
}
|
||||
|
||||
func handleLocationChange() {
|
||||
guard started else { return }
|
||||
// When location changes, we trigger an immediate (but slightly delayed) heartbeat
|
||||
// to announce presence in the new zone, then reset the loop.
|
||||
SecureLogger.debug("Presence: location changed, scheduling update", category: .session)
|
||||
heartbeatTimer?.invalidate()
|
||||
|
||||
// Small delay to allow location state to settle
|
||||
let generation = heartbeatGeneration
|
||||
heartbeatTimer = scheduleTimer(5.0) { [weak self] in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.performHeartbeat()
|
||||
guard let self,
|
||||
self.started,
|
||||
self.heartbeatGeneration == generation else { return }
|
||||
self.performHeartbeat()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleConnectivityChange() {
|
||||
guard started else { return }
|
||||
SecureLogger.debug("Presence: connectivity restored, triggering heartbeat", category: .session)
|
||||
// If we were waiting for network, do it now
|
||||
if heartbeatTimer == nil || !heartbeatTimer!.isValid {
|
||||
@ -191,18 +215,29 @@ final class GeohashPresenceService: ObservableObject {
|
||||
}
|
||||
|
||||
func scheduleNextHeartbeat() {
|
||||
guard started else { return }
|
||||
heartbeatTimer?.invalidate()
|
||||
let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval)
|
||||
let generation = heartbeatGeneration
|
||||
heartbeatTimer = scheduleTimer(interval) { [weak self] in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.performHeartbeat()
|
||||
guard let self,
|
||||
self.started,
|
||||
self.heartbeatGeneration == generation else { return }
|
||||
self.performHeartbeat()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func performHeartbeat() {
|
||||
guard started else { return }
|
||||
let generation = heartbeatGeneration
|
||||
// Always schedule next loop first ensures continuity even if this one fails/skips
|
||||
defer { scheduleNextHeartbeat() }
|
||||
defer {
|
||||
if started, heartbeatGeneration == generation {
|
||||
scheduleNextHeartbeat()
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Check preconditions
|
||||
guard torIsReady() else {
|
||||
@ -228,14 +263,27 @@ final class GeohashPresenceService: ObservableObject {
|
||||
}
|
||||
|
||||
// Launch independent task for each channel's delay
|
||||
Task { @MainActor in
|
||||
let taskID = UUID()
|
||||
let sleeper = self.sleeper
|
||||
let delay = TimeInterval.random(
|
||||
in: burstMinDelay...burstMaxDelay
|
||||
)
|
||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
||||
let task = Task { @MainActor [weak self] in
|
||||
// Random delay for decorrelation
|
||||
let delay = TimeInterval.random(in: self.burstMinDelay...self.burstMaxDelay)
|
||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
||||
await self.sleeper(nanoseconds)
|
||||
|
||||
await sleeper(nanoseconds)
|
||||
|
||||
guard let self else { return }
|
||||
guard !Task.isCancelled,
|
||||
self.started,
|
||||
self.heartbeatGeneration == generation else {
|
||||
self.pendingBroadcastTasks.removeValue(forKey: taskID)
|
||||
return
|
||||
}
|
||||
self.pendingBroadcastTasks.removeValue(forKey: taskID)
|
||||
self.broadcastPresence(for: channel.geohash)
|
||||
}
|
||||
pendingBroadcastTasks[taskID] = task
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -11,6 +11,54 @@ import BitFoundation
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
enum KeychainInstallLifecycleAction: Equatable {
|
||||
case markerPresent
|
||||
case bootstrapMarker
|
||||
case clearStaleKeys
|
||||
case retryLater
|
||||
}
|
||||
|
||||
/// Process-local fail-closed gate for an unresolved install lifecycle.
|
||||
///
|
||||
/// A blocked caller may perform one synchronous reconciliation attempt.
|
||||
/// Concurrent callers fail closed instead of reading while that cleanup is
|
||||
/// in flight. Once reconciliation succeeds, access remains open.
|
||||
final class KeychainInstallAccessGate: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var blocked = false
|
||||
private var reconciliationInProgress = false
|
||||
|
||||
func block() {
|
||||
lock.lock()
|
||||
blocked = true
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func allowsAccess(reconcile: () -> Bool) -> Bool {
|
||||
lock.lock()
|
||||
if !blocked {
|
||||
lock.unlock()
|
||||
return true
|
||||
}
|
||||
guard !reconciliationInProgress else {
|
||||
lock.unlock()
|
||||
return false
|
||||
}
|
||||
reconciliationInProgress = true
|
||||
lock.unlock()
|
||||
|
||||
let completed = reconcile()
|
||||
|
||||
lock.lock()
|
||||
if completed {
|
||||
blocked = false
|
||||
}
|
||||
reconciliationInProgress = false
|
||||
lock.unlock()
|
||||
return completed
|
||||
}
|
||||
}
|
||||
|
||||
final class KeychainManager: KeychainManagerProtocol {
|
||||
/// Default keychain for components that construct their own rather than
|
||||
/// having one injected. Under test this is an in-memory keychain: the
|
||||
@ -41,53 +89,281 @@ final class KeychainManager: KeychainManagerProtocol {
|
||||
// Use consistent service name for all keychain items
|
||||
private let service = BitchatApp.bundleID
|
||||
private let appGroup = "group.\(BitchatApp.bundleID)"
|
||||
|
||||
#if os(iOS)
|
||||
private let installAccessGate = KeychainInstallAccessGate()
|
||||
#endif
|
||||
/// Every generic-password service owned by this app, including names used
|
||||
/// by older releases. Keep custom services here so one-time security
|
||||
/// migrations and panic deletion cannot silently miss them.
|
||||
private static let additionalApplicationOwnedServices = [
|
||||
"chat.bitchat.nostr",
|
||||
"chat.bitchat.favorites",
|
||||
"chat.bitchat.outbox",
|
||||
"com.bitchat.passwords",
|
||||
"com.bitchat.deviceidentity",
|
||||
"com.bitchat.noise.identity",
|
||||
"chat.bitchat.passwords",
|
||||
"bitchat.keychain",
|
||||
"bitchat",
|
||||
"com.bitchat"
|
||||
]
|
||||
// AfterFirstUnlock, not WhenUnlocked: the mesh keeps running with the
|
||||
// device locked (identity-cache saves failed with -25308 throughout
|
||||
// locked-phone testing), and a wake-on-proximity relaunch via BLE state
|
||||
// restoration must be able to read the noise keys before the user
|
||||
// unlocks. Backup/sync semantics are unchanged (not ThisDeviceOnly).
|
||||
private static let itemAccessibility = kSecAttrAccessibleAfterFirstUnlock
|
||||
// unlocks. ThisDeviceOnly prevents private identities and group keys from
|
||||
// migrating through device backups onto a second device.
|
||||
private static let itemAccessibility = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
|
||||
init() {
|
||||
#if os(iOS)
|
||||
migrateAccessibilityIfNeeded()
|
||||
if reconcileInstallLifecycle() {
|
||||
migrateAccessibilityIfNeeded()
|
||||
} else {
|
||||
installAccessGate.block()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
static func installLifecycleAction(
|
||||
containerKnowsMarker: Bool,
|
||||
cleanupPending: Bool = false,
|
||||
markerRead: KeychainReadResult
|
||||
) -> KeychainInstallLifecycleAction {
|
||||
// Once a reinstall cleanup has started, its container-local latch
|
||||
// must win even if the keychain marker was deleted before a later
|
||||
// keychain operation failed. Otherwise the next launch could mistake
|
||||
// a partial cleanup for a fresh bootstrap and preserve stale secrets.
|
||||
if cleanupPending {
|
||||
return .clearStaleKeys
|
||||
}
|
||||
|
||||
switch markerRead {
|
||||
case .success:
|
||||
return containerKnowsMarker ? .markerPresent : .clearStaleKeys
|
||||
case .itemNotFound:
|
||||
return .bootstrapMarker
|
||||
case .accessDenied, .deviceLocked, .authenticationFailed, .otherError:
|
||||
return .retryLater
|
||||
}
|
||||
}
|
||||
|
||||
static func applicationOwnedKeychainServices(primaryService: String) -> [String] {
|
||||
var seen = Set<String>()
|
||||
return ([primaryService] + additionalApplicationOwnedServices).filter {
|
||||
seen.insert($0).inserted
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs every service update even after one failure. Successful updates
|
||||
/// are idempotent, while returning false keeps the one-time flag unset so
|
||||
/// a later unlocked launch retries the incomplete migration.
|
||||
static func migrateAccessibilityForApplicationOwnedServices(
|
||||
primaryService: String,
|
||||
updateService: (String) -> OSStatus
|
||||
) -> Bool {
|
||||
var completed = true
|
||||
for serviceName in applicationOwnedKeychainServices(
|
||||
primaryService: primaryService
|
||||
) {
|
||||
let status = updateService(serviceName)
|
||||
if status != errSecSuccess && status != errSecItemNotFound {
|
||||
completed = false
|
||||
}
|
||||
}
|
||||
return completed
|
||||
}
|
||||
|
||||
/// Deletes every declared service even after one failure. An empty scope
|
||||
/// is already clean, while any other status leaves the cleanup
|
||||
/// incomplete so its durable retry marker remains set.
|
||||
static func deleteApplicationOwnedKeychainServices(
|
||||
primaryService: String,
|
||||
deleteService: (String) -> OSStatus
|
||||
) -> Bool {
|
||||
var completed = true
|
||||
for serviceName in applicationOwnedKeychainServices(
|
||||
primaryService: primaryService
|
||||
) {
|
||||
let status = deleteService(serviceName)
|
||||
if status != errSecSuccess && status != errSecItemNotFound {
|
||||
completed = false
|
||||
}
|
||||
}
|
||||
return completed
|
||||
}
|
||||
|
||||
/// The app currently has an application-group entitlement, not a
|
||||
/// keychain-access-group entitlement. Keep the historical group cleanup
|
||||
/// probe as best effort without making its expected -34018 response block
|
||||
/// panic recovery forever.
|
||||
static func completedApplicationGroupDelete(status: OSStatus) -> Bool {
|
||||
status == errSecSuccess
|
||||
|| status == errSecItemNotFound
|
||||
|| status == -34018
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
|
||||
private static let installMarkerAccount = "install_lifecycle_marker"
|
||||
private static let installMarkerDefaultsKey = "keychain.installLifecycleMarker.present"
|
||||
private static let installCleanupPendingDefaultsKey =
|
||||
"keychain.installLifecycleCleanup.pending"
|
||||
|
||||
/// Keychain items can survive app removal while the app container and its
|
||||
/// UserDefaults do not. The first version carrying this marker bootstraps
|
||||
/// without deleting existing users' identities. On a later reinstall, a
|
||||
/// surviving keychain marker plus a missing defaults marker proves the app
|
||||
/// container was replaced, so stale secrets are removed before use.
|
||||
@discardableResult
|
||||
private func reconcileInstallLifecycle() -> Bool {
|
||||
let defaults = UserDefaults.standard
|
||||
let containerKnowsMarker = defaults.bool(forKey: Self.installMarkerDefaultsKey)
|
||||
let cleanupPending = defaults.bool(
|
||||
forKey: Self.installCleanupPendingDefaultsKey
|
||||
)
|
||||
|
||||
let markerRead = retrieveDataWithResult(forKey: Self.installMarkerAccount)
|
||||
switch Self.installLifecycleAction(
|
||||
containerKnowsMarker: containerKnowsMarker,
|
||||
cleanupPending: cleanupPending,
|
||||
markerRead: markerRead
|
||||
) {
|
||||
case .markerPresent:
|
||||
defaults.set(true, forKey: Self.installMarkerDefaultsKey)
|
||||
return true
|
||||
|
||||
case .bootstrapMarker:
|
||||
if case .success = saveDataWithResult(Data([1]), forKey: Self.installMarkerAccount) {
|
||||
defaults.set(true, forKey: Self.installMarkerDefaultsKey)
|
||||
}
|
||||
// A missing marker is the intentional bootstrap path for both a
|
||||
// fresh install and the first marker-carrying upgrade. Preserve
|
||||
// existing users' identities even if marker creation must retry
|
||||
// on a later construction.
|
||||
return true
|
||||
|
||||
case .clearStaleKeys:
|
||||
// Establish a container-local retry latch before deleting the
|
||||
// surviving keychain marker. If the process exits or any keychain
|
||||
// operation fails, the next launch retries even when that marker
|
||||
// can no longer be read.
|
||||
defaults.set(true, forKey: Self.installCleanupPendingDefaultsKey)
|
||||
guard defaults.synchronize(),
|
||||
defaults.bool(forKey: Self.installCleanupPendingDefaultsKey)
|
||||
else {
|
||||
SecureLogger.error(
|
||||
"Could not persist reinstall keychain-cleanup intent",
|
||||
category: .security
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
guard deleteAllKeychainData() else {
|
||||
SecureLogger.error(
|
||||
"Reinstall keychain cleanup incomplete; retry remains pending",
|
||||
category: .security
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
defaults.set(true, forKey: Self.installMarkerDefaultsKey)
|
||||
defaults.removeObject(
|
||||
forKey: Self.installCleanupPendingDefaultsKey
|
||||
)
|
||||
guard defaults.synchronize(),
|
||||
defaults.bool(forKey: Self.installMarkerDefaultsKey),
|
||||
!defaults.bool(
|
||||
forKey: Self.installCleanupPendingDefaultsKey
|
||||
)
|
||||
else {
|
||||
// Preserve the fail-closed state in memory and make one more
|
||||
// best-effort persistence attempt before startup continues.
|
||||
defaults.set(
|
||||
true,
|
||||
forKey: Self.installCleanupPendingDefaultsKey
|
||||
)
|
||||
_ = defaults.synchronize()
|
||||
SecureLogger.error(
|
||||
"Could not commit reinstall keychain-cleanup state; retry remains pending",
|
||||
category: .security
|
||||
)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
|
||||
case .retryLater:
|
||||
// Do not guess that a temporarily unreadable marker is absent.
|
||||
// An established container may keep using ordinary protected-data
|
||||
// semantics: reads fail while locked and recover after unlock. A
|
||||
// container that has not committed the marker must stay blocked
|
||||
// until the marker becomes readable and this state machine can
|
||||
// distinguish bootstrap from reinstall.
|
||||
return containerKnowsMarker
|
||||
}
|
||||
}
|
||||
|
||||
/// One-time upgrade of items created under WhenUnlocked. New saves get
|
||||
/// the right class on their own (saves are delete-then-add), but the
|
||||
/// long-lived identity keys are written once and would otherwise stay
|
||||
/// unreadable while the device is locked.
|
||||
private func migrateAccessibilityIfNeeded() {
|
||||
let flag = "keychain.accessibility.afterFirstUnlock.migrated"
|
||||
let flag = "keychain.accessibility.afterFirstUnlockThisDeviceOnly.migrated"
|
||||
guard !UserDefaults.standard.bool(forKey: flag) else { return }
|
||||
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service
|
||||
]
|
||||
let update: [String: Any] = [
|
||||
kSecAttrAccessible as String: Self.itemAccessibility
|
||||
]
|
||||
let status = SecItemUpdate(query as CFDictionary, update as CFDictionary)
|
||||
switch status {
|
||||
case errSecSuccess, errSecItemNotFound:
|
||||
// Nothing to migrate on a fresh install; both are terminal.
|
||||
let completed = Self.migrateAccessibilityForApplicationOwnedServices(
|
||||
primaryService: service
|
||||
) { serviceName in
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: serviceName
|
||||
]
|
||||
return SecItemUpdate(
|
||||
query as CFDictionary,
|
||||
update as CFDictionary
|
||||
)
|
||||
}
|
||||
if completed {
|
||||
// Missing services on a fresh install are terminal, but the flag is
|
||||
// set only after every application-owned service was considered.
|
||||
UserDefaults.standard.set(true, forKey: flag)
|
||||
SecureLogger.info("Keychain accessibility migrated to AfterFirstUnlock (status \(status))", category: .keychain)
|
||||
default:
|
||||
SecureLogger.info(
|
||||
"Keychain accessibility migrated to AfterFirstUnlockThisDeviceOnly",
|
||||
category: .keychain
|
||||
)
|
||||
} else {
|
||||
// Likely errSecInteractionNotAllowed (relaunched while locked) —
|
||||
// leave the flag unset so the next launch retries.
|
||||
SecureLogger.warning("Keychain accessibility migration deferred (status \(status))", category: .keychain)
|
||||
SecureLogger.warning(
|
||||
"Keychain accessibility migration deferred for at least one application-owned service",
|
||||
category: .keychain
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private func installAccessAllowed() -> Bool {
|
||||
#if os(iOS)
|
||||
return installAccessGate.allowsAccess { [self] in
|
||||
guard reconcileInstallLifecycle() else { return false }
|
||||
migrateAccessibilityIfNeeded()
|
||||
return true
|
||||
}
|
||||
#else
|
||||
return true
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Identity Keys
|
||||
|
||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
||||
guard installAccessAllowed() else {
|
||||
SecureLogger.logKeyOperation(.save, keyType: key, success: false)
|
||||
return false
|
||||
}
|
||||
let fullKey = "identity_\(key)"
|
||||
let result = saveData(keyData, forKey: fullKey)
|
||||
SecureLogger.logKeyOperation(.save, keyType: key, success: result)
|
||||
@ -95,11 +371,16 @@ final class KeychainManager: KeychainManagerProtocol {
|
||||
}
|
||||
|
||||
func getIdentityKey(forKey key: String) -> Data? {
|
||||
guard installAccessAllowed() else { return nil }
|
||||
let fullKey = "identity_\(key)"
|
||||
return retrieveData(forKey: fullKey)
|
||||
}
|
||||
|
||||
func deleteIdentityKey(forKey key: String) -> Bool {
|
||||
guard installAccessAllowed() else {
|
||||
SecureLogger.logKeyOperation(.delete, keyType: key, success: false)
|
||||
return false
|
||||
}
|
||||
let result = delete(forKey: "identity_\(key)")
|
||||
SecureLogger.logKeyOperation(.delete, keyType: key, success: result)
|
||||
return result
|
||||
@ -110,12 +391,14 @@ final class KeychainManager: KeychainManagerProtocol {
|
||||
/// Get identity key with detailed result for proper error handling
|
||||
/// Distinguishes between missing keys (expected) and critical failures
|
||||
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
|
||||
guard installAccessAllowed() else { return .accessDenied }
|
||||
let fullKey = "identity_\(key)"
|
||||
return retrieveDataWithResult(forKey: fullKey)
|
||||
}
|
||||
|
||||
/// Save identity key with detailed result and retry logic for transient errors
|
||||
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
|
||||
guard installAccessAllowed() else { return .accessDenied }
|
||||
let fullKey = "identity_\(key)"
|
||||
return saveDataWithResult(keyData, forKey: fullKey)
|
||||
}
|
||||
@ -385,114 +668,165 @@ final class KeychainManager: KeychainManagerProtocol {
|
||||
// Delete ALL keychain data for panic mode
|
||||
func deleteAllKeychainData() -> Bool {
|
||||
SecureLogger.warning("Panic mode - deleting all keychain data", category: .security)
|
||||
|
||||
var totalDeleted = 0
|
||||
|
||||
// Search without service restriction to catch all items
|
||||
|
||||
let ownedServices = Set(
|
||||
Self.applicationOwnedKeychainServices(
|
||||
primaryService: service
|
||||
)
|
||||
)
|
||||
var enumerationCompleted = true
|
||||
let searchQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecMatchLimit as String: kSecMatchLimitAll,
|
||||
kSecReturnAttributes as String: true
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let searchStatus = SecItemCopyMatching(searchQuery as CFDictionary, &result)
|
||||
|
||||
if searchStatus == errSecSuccess, let items = result as? [[String: Any]] {
|
||||
let searchStatus = SecItemCopyMatching(
|
||||
searchQuery as CFDictionary,
|
||||
&result
|
||||
)
|
||||
switch searchStatus {
|
||||
case errSecSuccess:
|
||||
guard let items = result as? [[String: Any]] else {
|
||||
enumerationCompleted = false
|
||||
SecureLogger.error(
|
||||
"Unable to decode application-owned keychain inventory",
|
||||
category: .security
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
// Preserve the access-group sweep for custom services that are
|
||||
// not yet in the declared legacy-service list.
|
||||
for item in items {
|
||||
var shouldDelete = false
|
||||
let account = item[kSecAttrAccount as String] as? String ?? ""
|
||||
let service = item[kSecAttrService as String] as? String ?? ""
|
||||
let accessGroup = item[kSecAttrAccessGroup as String] as? String
|
||||
|
||||
// More precise deletion criteria:
|
||||
// 1. Check for our specific app group
|
||||
// 2. OR check for our exact service name
|
||||
// 3. OR check for known legacy service names
|
||||
if accessGroup == appGroup {
|
||||
shouldDelete = true
|
||||
} else if service == self.service {
|
||||
shouldDelete = true
|
||||
} else if [
|
||||
"com.bitchat.passwords",
|
||||
"com.bitchat.deviceidentity",
|
||||
"com.bitchat.noise.identity",
|
||||
"chat.bitchat.passwords",
|
||||
"bitchat.keychain",
|
||||
"bitchat",
|
||||
"com.bitchat"
|
||||
].contains(service) {
|
||||
shouldDelete = true
|
||||
let account =
|
||||
item[kSecAttrAccount as String] as? String ?? ""
|
||||
let itemService =
|
||||
item[kSecAttrService as String] as? String ?? ""
|
||||
let accessGroup =
|
||||
item[kSecAttrAccessGroup as String] as? String
|
||||
guard accessGroup == appGroup
|
||||
|| ownedServices.contains(itemService)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
|
||||
if shouldDelete {
|
||||
// Build delete query with all available attributes for precise deletion
|
||||
var deleteQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword
|
||||
]
|
||||
|
||||
if !account.isEmpty {
|
||||
deleteQuery[kSecAttrAccount as String] = account
|
||||
}
|
||||
if !service.isEmpty {
|
||||
deleteQuery[kSecAttrService as String] = service
|
||||
}
|
||||
|
||||
// Add access group if present
|
||||
if let accessGroup = item[kSecAttrAccessGroup as String] as? String,
|
||||
!accessGroup.isEmpty && accessGroup != "test" {
|
||||
deleteQuery[kSecAttrAccessGroup as String] = accessGroup
|
||||
}
|
||||
|
||||
let deleteStatus = SecItemDelete(deleteQuery as CFDictionary)
|
||||
if deleteStatus == errSecSuccess {
|
||||
totalDeleted += 1
|
||||
SecureLogger.info("Deleted keychain item: \(account) from \(service)", category: .keychain)
|
||||
}
|
||||
|
||||
var deleteQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword
|
||||
]
|
||||
if !account.isEmpty {
|
||||
deleteQuery[kSecAttrAccount as String] = account
|
||||
}
|
||||
if !itemService.isEmpty {
|
||||
deleteQuery[kSecAttrService as String] = itemService
|
||||
}
|
||||
if let accessGroup,
|
||||
!accessGroup.isEmpty,
|
||||
accessGroup != "test" {
|
||||
deleteQuery[kSecAttrAccessGroup as String] = accessGroup
|
||||
}
|
||||
|
||||
let status = SecItemDelete(deleteQuery as CFDictionary)
|
||||
if status != errSecSuccess && status != errSecItemNotFound {
|
||||
enumerationCompleted = false
|
||||
SecureLogger.error(
|
||||
NSError(domain: "Keychain", code: Int(status)),
|
||||
context: "Unable to delete enumerated application-owned keychain item",
|
||||
category: .keychain
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
case errSecItemNotFound:
|
||||
break
|
||||
|
||||
default:
|
||||
enumerationCompleted = false
|
||||
SecureLogger.error(
|
||||
NSError(domain: "Keychain", code: Int(searchStatus)),
|
||||
context: "Unable to enumerate application-owned keychain items",
|
||||
category: .keychain
|
||||
)
|
||||
}
|
||||
|
||||
// Also try to delete by known service names and app group
|
||||
// This catches any items that might have been missed above
|
||||
let knownServices = [
|
||||
self.service, // Current service name
|
||||
"com.bitchat.passwords",
|
||||
"com.bitchat.deviceidentity",
|
||||
"com.bitchat.noise.identity",
|
||||
"chat.bitchat.passwords",
|
||||
"chat.bitchat.nostr",
|
||||
"bitchat.keychain",
|
||||
"bitchat",
|
||||
"com.bitchat"
|
||||
]
|
||||
|
||||
for serviceName in knownServices {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: serviceName
|
||||
]
|
||||
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
if status == errSecSuccess {
|
||||
totalDeleted += 1
|
||||
|
||||
// Bulk deletion by every application-owned service is authoritative
|
||||
// and idempotent. It also verifies that every known service scope is
|
||||
// empty even when the inventory pass found no items.
|
||||
let servicesCompleted =
|
||||
Self.deleteApplicationOwnedKeychainServices(
|
||||
primaryService: service
|
||||
) { serviceName in
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: serviceName
|
||||
]
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
if status != errSecSuccess && status != errSecItemNotFound {
|
||||
SecureLogger.error(
|
||||
NSError(domain: "Keychain", code: Int(status)),
|
||||
context: "Unable to delete application-owned keychain service \(serviceName)",
|
||||
category: .keychain
|
||||
)
|
||||
}
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
// Also delete by app group to ensure complete cleanup
|
||||
|
||||
// Historical builds attempted this application-group identifier as a
|
||||
// keychain access group. It is not currently entitled, so -34018
|
||||
// means the scope is inapplicable rather than partially deleted.
|
||||
let groupQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccessGroup as String: appGroup
|
||||
]
|
||||
|
||||
let groupStatus = SecItemDelete(groupQuery as CFDictionary)
|
||||
if groupStatus == errSecSuccess {
|
||||
totalDeleted += 1
|
||||
let groupCompleted = Self.completedApplicationGroupDelete(
|
||||
status: groupStatus
|
||||
)
|
||||
if !groupCompleted {
|
||||
SecureLogger.error(
|
||||
NSError(domain: "Keychain", code: Int(groupStatus)),
|
||||
context: "Unable to delete historical application-group keychain items",
|
||||
category: .keychain
|
||||
)
|
||||
}
|
||||
|
||||
SecureLogger.warning("Panic mode cleanup completed. Total items deleted: \(totalDeleted)", category: .keychain)
|
||||
|
||||
return totalDeleted > 0
|
||||
|
||||
var markerCompleted = true
|
||||
#if os(iOS)
|
||||
// The non-secret marker is intentionally recreated after a panic so a
|
||||
// later uninstall/reinstall can still be distinguished from an in-place
|
||||
// upgrade. Do not commit the container-side marker here: reinstall
|
||||
// reconciliation may still need to retry an incomplete cleanup.
|
||||
if case .success = saveDataWithResult(
|
||||
Data([1]),
|
||||
forKey: Self.installMarkerAccount
|
||||
) {
|
||||
markerCompleted = true
|
||||
} else {
|
||||
markerCompleted = false
|
||||
SecureLogger.error(
|
||||
"Unable to restore install-lifecycle keychain marker",
|
||||
category: .security
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
let completed =
|
||||
enumerationCompleted
|
||||
&& servicesCompleted
|
||||
&& groupCompleted
|
||||
&& markerCompleted
|
||||
if completed {
|
||||
SecureLogger.warning(
|
||||
"Panic mode keychain cleanup completed",
|
||||
category: .keychain
|
||||
)
|
||||
} else {
|
||||
SecureLogger.error(
|
||||
"Panic mode keychain cleanup incomplete",
|
||||
category: .security
|
||||
)
|
||||
}
|
||||
return completed
|
||||
}
|
||||
|
||||
// MARK: - Security Utilities
|
||||
@ -518,6 +852,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
||||
// MARK: - Debug
|
||||
|
||||
func verifyIdentityKeyExists() -> Bool {
|
||||
guard installAccessAllowed() else { return false }
|
||||
let key = "identity_noiseStaticKey"
|
||||
return retrieveData(forKey: key) != nil
|
||||
}
|
||||
@ -526,18 +861,40 @@ final class KeychainManager: KeychainManagerProtocol {
|
||||
|
||||
/// Save data with a custom service name
|
||||
func save(key: String, data: Data, service customService: String, accessible: CFString?) {
|
||||
var query: [String: Any] = [
|
||||
guard installAccessAllowed() else { return }
|
||||
let primaryKeyQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: customService,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecValueData as String: data
|
||||
kSecAttrAccount as String: key
|
||||
]
|
||||
if let accessible = accessible {
|
||||
query[kSecAttrAccessible as String] = accessible
|
||||
}
|
||||
var addQuery = primaryKeyQuery
|
||||
addQuery.merge([
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessible as String: accessible ?? Self.itemAccessibility,
|
||||
kSecAttrSynchronizable as String: false
|
||||
]) { _, new in new }
|
||||
|
||||
SecItemDelete(query as CFDictionary)
|
||||
SecItemAdd(query as CFDictionary, nil)
|
||||
// Delete by the item's primary key only. Value/accessibility fields
|
||||
// are add attributes, not valid selectors for replacing an existing
|
||||
// item; including them can leave the old item in place and make the
|
||||
// subsequent add fail as a duplicate.
|
||||
let deleteStatus = SecItemDelete(primaryKeyQuery as CFDictionary)
|
||||
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
|
||||
SecureLogger.error(
|
||||
NSError(domain: "Keychain", code: Int(deleteStatus)),
|
||||
context: "Unable to replace custom-service keychain item",
|
||||
category: .keychain
|
||||
)
|
||||
return
|
||||
}
|
||||
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
|
||||
if addStatus != errSecSuccess {
|
||||
SecureLogger.error(
|
||||
NSError(domain: "Keychain", code: Int(addStatus)),
|
||||
context: "Unable to save custom-service keychain item",
|
||||
category: .keychain
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Load data from a custom service
|
||||
@ -551,6 +908,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
||||
/// Load custom-service data without collapsing `itemNotFound` and
|
||||
/// protected-data/keychain failures into the same nil result.
|
||||
func loadWithResult(key: String, service customService: String) -> KeychainReadResult {
|
||||
guard installAccessAllowed() else { return .accessDenied }
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: customService,
|
||||
@ -565,6 +923,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
||||
|
||||
/// Delete data from a custom service
|
||||
func delete(key: String, service customService: String) {
|
||||
guard installAccessAllowed() else { return }
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: customService,
|
||||
@ -576,6 +935,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
||||
|
||||
/// Delete every item stored under a custom service
|
||||
func deleteAll(service customService: String) {
|
||||
guard installAccessAllowed() else { return }
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: customService,
|
||||
|
||||
@ -10,9 +10,12 @@ import Foundation
|
||||
|
||||
/// Watermark for "heard here earlier" echoes: clearing the mesh timeline
|
||||
/// (triple-tap or /clear) records the moment, and the next launch only
|
||||
/// re-seeds archived messages heard after it. The archive itself is left
|
||||
/// alone — the device keeps carrying those messages for peers; the user
|
||||
/// just doesn't want to see them again.
|
||||
/// re-seeds archived messages heard after it.
|
||||
///
|
||||
/// Clearing also erases the archive itself, so cleared history is gone from
|
||||
/// disk rather than merely hidden from the timeline. The watermark still
|
||||
/// matters afterwards: it keeps messages this device hears again from peers,
|
||||
/// which predate the clear, from reappearing as echoes.
|
||||
enum MeshEchoSettings {
|
||||
private static let clearedThroughKey = "meshEchoes.clearedThrough"
|
||||
|
||||
|
||||
152
bitchat/Services/MeshTransportCapabilities.swift
Normal file
152
bitchat/Services/MeshTransportCapabilities.swift
Normal file
@ -0,0 +1,152 @@
|
||||
import BitFoundation
|
||||
import CoreBluetooth
|
||||
import Foundation
|
||||
|
||||
/// Optional transport capabilities, discovered with `as?` instead of casting
|
||||
/// to a concrete transport class. `Transport` stays the contract every
|
||||
/// transport genuinely implements; a capability protocol here is the
|
||||
/// contract for one mesh-only feature surface, so app wiring depends on the
|
||||
/// feature it needs rather than on `BLEService` itself.
|
||||
|
||||
/// Radio-state reporting for transports backed by a local radio.
|
||||
protocol BluetoothStateReporting: AnyObject {
|
||||
func getCurrentBluetoothState() -> CBManagerState
|
||||
}
|
||||
|
||||
/// Panic-mode lifecycle for transports that own durable identity state.
|
||||
/// A transport implementing this owns its own restart sequencing:
|
||||
/// `completePanicReset` decides whether services come back, so generic
|
||||
/// `startServices()` calls after a panic belong only to transports that
|
||||
/// don't implement it.
|
||||
protocol PanicResettingTransport: AnyObject {
|
||||
/// Quiesces the radio and drains in-flight work ahead of a panic wipe.
|
||||
func suspendForPanicReset()
|
||||
/// Finishes a panic wipe, optionally restarting services.
|
||||
func completePanicReset(restartServices: Bool)
|
||||
/// Rotates the transport identity as part of a panic reset.
|
||||
func resetIdentityForPanic(currentNickname: String, restartServices: Bool)
|
||||
}
|
||||
|
||||
/// File and private-media transfer over a mesh transport, including the
|
||||
/// capability-proof policy that gates encrypted private media.
|
||||
protocol MeshFileTransferring: AnyObject {
|
||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
|
||||
func sendFilePrivate(
|
||||
_ packet: BitchatFilePacket,
|
||||
to peerID: PeerID,
|
||||
transferId: String,
|
||||
allowLegacyFallback: Bool
|
||||
)
|
||||
/// Automatic whole-file retry is admitted only while this exact Noise
|
||||
/// generation authenticates bit 9. It must never queue across a session
|
||||
/// replacement or enter the signed raw legacy path.
|
||||
func sendFilePrivateReceiptRetry(
|
||||
_ packet: BitchatFilePacket,
|
||||
to peerID: PeerID,
|
||||
transferId: String
|
||||
)
|
||||
func cancelTransfer(_ transferId: String)
|
||||
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
|
||||
/// The exact current Noise generation that authenticated both encrypted
|
||||
/// private media (bit 8) and durable receipts/retry (bit 9).
|
||||
func authenticatedPrivateMediaReceiptSessionGeneration(to peerID: PeerID) -> UUID?
|
||||
func resolvePrivateMediaSendPolicy(
|
||||
to peerID: PeerID,
|
||||
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||
)
|
||||
}
|
||||
|
||||
/// Live voice / push-to-talk: one encoded `VoiceBurstPacket`,
|
||||
/// fire-and-forget inside the Noise session (private) or as a signed
|
||||
/// ephemeral broadcast (public). Frames are only useful now — the
|
||||
/// transport drops them (never queues) without an established session.
|
||||
protocol MeshVoiceStreaming: AnyObject {
|
||||
func sendVoiceFrame(_ burstContent: Data, to peerID: PeerID)
|
||||
func sendVoiceFrameBroadcast(_ burstContent: Data)
|
||||
}
|
||||
|
||||
/// Courier store-and-forward: seal a message to the recipient's static
|
||||
/// key and hand it to connected couriers for physical delivery while the
|
||||
/// recipient is offline. Returns false when the transport cannot courier.
|
||||
protocol MeshCourierTransporting: AnyObject {
|
||||
@discardableResult
|
||||
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool
|
||||
}
|
||||
|
||||
/// Private groups: creator-signed state travels 1:1 over Noise sessions;
|
||||
/// group messages flood like public broadcasts.
|
||||
protocol MeshGroupMessaging: AnyObject {
|
||||
func sendGroupInvite(_ statePayload: Data, to peerID: PeerID)
|
||||
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID)
|
||||
func broadcastGroupMessage(_ envelope: Data)
|
||||
}
|
||||
|
||||
/// Bulletin board: broadcast a pre-signed board payload (post or
|
||||
/// tombstone) so it spreads over relay and gossip sync.
|
||||
protocol MeshBoardBroadcasting: AnyObject {
|
||||
func sendBoardPayload(_ payload: Data)
|
||||
}
|
||||
|
||||
/// Mesh diagnostics (/ping, /trace, topology map).
|
||||
protocol MeshDiagnosing: AnyObject {
|
||||
/// Sends a directed ping probe; the completion fires exactly once on
|
||||
/// the main actor with the measured result, or nil on timeout.
|
||||
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void)
|
||||
/// Estimated intermediate hops toward `peerID` from gossiped topology
|
||||
/// ([] = direct link, nil = no known path).
|
||||
func computeMeshPath(to peerID: PeerID) -> [PeerID]?
|
||||
/// Current mesh graph for the topology map.
|
||||
func currentMeshTopology() -> MeshTopologySnapshot?
|
||||
}
|
||||
|
||||
/// QR verification and transitive vouching over the Noise session.
|
||||
protocol MeshVerifying: AnyObject {
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||
/// Sends an encoded vouch-attestation batch inside the Noise session.
|
||||
func sendVouchAttestations(_ payload: Data, to peerID: PeerID)
|
||||
}
|
||||
|
||||
/// Store-and-forward archive: the public messages this device is carrying
|
||||
/// for gossip sync, decoded for display as "heard here earlier" echoes.
|
||||
protocol MeshPublicArchiving: AnyObject {
|
||||
func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void)
|
||||
/// Drops any carried public messages from a (newly blocked) sender so
|
||||
/// they can't resurface as archived echoes on a later launch.
|
||||
func purgeArchivedPublicMessages(from peerID: PeerID)
|
||||
/// Erases the whole carried public-message archive, on disk included.
|
||||
func purgeAllArchivedPublicMessages()
|
||||
}
|
||||
|
||||
/// Internet-gateway and geohash-bridge wiring surface (BLE mesh today).
|
||||
/// Everything the gateway/bridge/courier services need from the mesh
|
||||
/// transport, so their bootstrap wiring never touches the concrete class.
|
||||
protocol MeshBridgingTransport: AnyObject {
|
||||
// Runtime-advertised capability bits
|
||||
func setLocalCapability(_ capability: PeerCapabilities, enabled: Bool)
|
||||
func setLocalBridgeGeohash(_ cell: String?)
|
||||
func advertisedBridgeGeohash() -> String?
|
||||
|
||||
// Peers currently advertising bridging roles
|
||||
func reachableGatewayPeers() -> [PeerID]
|
||||
func reachableBridgePeers() -> [PeerID]
|
||||
|
||||
// Gateway carrier packets (mesh <-> Nostr uplink/downlink)
|
||||
@discardableResult
|
||||
func sendNostrCarrier(_ payload: Data, to gatewayPeer: PeerID) -> Bool
|
||||
func broadcastNostrCarrier(_ payload: Data)
|
||||
/// Sink for received carrier packets (set once by app wiring; called on
|
||||
/// the main actor after transport-level checks).
|
||||
var onNostrCarrierPacket: (@MainActor (_ payload: Data, _ from: PeerID, _ directedToUs: Bool) -> Void)? { get set }
|
||||
|
||||
// Bridge courier drops (sealed envelopes carried across the bridge)
|
||||
func sealBridgeCourierEnvelope(_ content: String, messageID: String, recipientNoiseKey: Data) -> CourierEnvelope?
|
||||
@discardableResult
|
||||
func openBridgedCourierEnvelope(_ envelope: CourierEnvelope) -> Bool
|
||||
@discardableResult
|
||||
func deliverBridgedEnvelope(_ envelope: CourierEnvelope, to peerID: PeerID) -> Bool
|
||||
func myNoiseStaticPublicKey() -> Data
|
||||
func verifiedPeersWithNoiseKeys() -> [(peerID: PeerID, noiseKey: Data)]
|
||||
/// Fired (off-main) when a signature-verified announce is processed.
|
||||
var onVerifiedPeerAnnounce: ((_ peerID: PeerID) -> Void)? { get set }
|
||||
}
|
||||
@ -104,12 +104,10 @@ final class LRUDeduplicationCache<Value> {
|
||||
enum ContentNormalizer {
|
||||
|
||||
/// Regex to simplify HTTP URLs by stripping query strings and fragments
|
||||
private static let simplifyHTTPURL: NSRegularExpression = {
|
||||
try! NSRegularExpression(
|
||||
pattern: "https?://[^\\s?#]+(?:[?#][^\\s]*)?",
|
||||
options: [.caseInsensitive]
|
||||
)
|
||||
}()
|
||||
private static let simplifyHTTPURL = SafeRegex.compile(
|
||||
"https?://[^\\s?#]+(?:[?#][^\\s]*)?",
|
||||
options: [.caseInsensitive]
|
||||
)
|
||||
|
||||
/// Normalizes content for deduplication comparison.
|
||||
/// - Parameters:
|
||||
@ -172,8 +170,8 @@ final class MessageDeduplicationService {
|
||||
/// Cache for Nostr ACK deduplication (messageId:ackType:senderPubkey format)
|
||||
private let nostrAckCache: LRUDeduplicationCache<Bool>
|
||||
|
||||
/// Optional cross-launch persistence for the Nostr event cache. NIP-59
|
||||
/// randomizes gift-wrap timestamps, so DM subscriptions look back 24h and
|
||||
/// Optional cross-launch persistence for the Nostr event cache. BitChat
|
||||
/// randomizes private-envelope timestamps, so DM subscriptions look back 24h and
|
||||
/// relays redeliver the same events on every launch; without this record
|
||||
/// each relaunch reprocesses old PMs and acks. Nil (tests, macOS callers
|
||||
/// that don't opt in) keeps the cache purely in-memory.
|
||||
@ -314,7 +312,7 @@ final class MessageDeduplicationService {
|
||||
// MARK: - Clear
|
||||
|
||||
/// Clears all caches. This is the wipe/panic path: the persisted
|
||||
/// gift-wrap record goes with everything else.
|
||||
/// private-envelope record goes with everything else.
|
||||
func clearAll() {
|
||||
contentCache.clear()
|
||||
nostrEventCache.clear()
|
||||
@ -325,7 +323,7 @@ final class MessageDeduplicationService {
|
||||
|
||||
/// Clears only the in-memory Nostr caches (events and ACKs). Runs on
|
||||
/// every geohash channel switch, so the disk record deliberately
|
||||
/// survives — wiping it here would forfeit cross-launch gift-wrap dedup
|
||||
/// survives — wiping it here would forfeit cross-launch private-envelope dedup
|
||||
/// each time the user changes channels (flagged by Codex on #1398).
|
||||
func clearNostrCaches() {
|
||||
nostrEventCache.clear()
|
||||
|
||||
@ -39,37 +39,23 @@ final class MessageFormattingEngine {
|
||||
|
||||
/// Precompiled regex patterns for message content parsing
|
||||
enum Patterns {
|
||||
static let hashtag: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "#([a-zA-Z0-9_]+)", options: [])
|
||||
}()
|
||||
static let hashtag = SafeRegex.compile("#([a-zA-Z0-9_]+)")
|
||||
|
||||
static let mention: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)", options: [])
|
||||
}()
|
||||
static let mention = SafeRegex.compile("@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)")
|
||||
|
||||
static let cashu: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
|
||||
}()
|
||||
static let cashu = SafeRegex.compile("\\bcashu[AB][A-Za-z0-9._-]{40,}\\b")
|
||||
|
||||
static let bolt11: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: [])
|
||||
}()
|
||||
static let bolt11 = SafeRegex.compile("(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b")
|
||||
|
||||
static let lnurl: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: [])
|
||||
}()
|
||||
static let lnurl = SafeRegex.compile("(?i)\\blnurl1[a-z0-9]{20,}\\b")
|
||||
|
||||
static let lightningScheme: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: [])
|
||||
}()
|
||||
static let lightningScheme = SafeRegex.compile("(?i)\\blightning:[^\\s]+")
|
||||
|
||||
static let linkDetector: NSDataDetector? = {
|
||||
try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||
}()
|
||||
|
||||
static let quickCashuPresence: NSRegularExpression = {
|
||||
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
|
||||
}()
|
||||
static let quickCashuPresence = SafeRegex.compile("\\bcashu[AB][A-Za-z0-9._-]{40,}\\b")
|
||||
}
|
||||
|
||||
// MARK: - Match Types
|
||||
@ -124,11 +110,12 @@ final class MessageFormattingEngine {
|
||||
)
|
||||
|
||||
// Format content
|
||||
let myNickname = context.nickname.normalizedNickname
|
||||
let contentResult = formatContent(
|
||||
message.content,
|
||||
baseColor: baseColor,
|
||||
isSelf: isSelf,
|
||||
isMentioned: message.mentions?.contains(context.nickname) ?? false
|
||||
isMentioned: message.mentions?.contains { $0.normalizedNickname == myNickname } ?? false
|
||||
)
|
||||
result.append(contentResult)
|
||||
|
||||
@ -251,9 +238,9 @@ final class MessageFormattingEngine {
|
||||
isSelf: Bool,
|
||||
isMentioned: Bool
|
||||
) -> AttributedString {
|
||||
// For very long content without special tokens, use plain formatting
|
||||
let containsCashu = containsCashuToken(content)
|
||||
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashu {
|
||||
// For very long content, use plain formatting to avoid expensive
|
||||
// regex/detector work. Cashu presence must not disable this guard.
|
||||
if content.isOversizedForRichFormatting() {
|
||||
return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf)
|
||||
}
|
||||
|
||||
|
||||
@ -34,6 +34,15 @@ struct CourierDirectory {
|
||||
final class MessageRouter {
|
||||
typealias QueuedMessage = MessageOutboxStore.QueuedMessage
|
||||
|
||||
private struct PeerMessageKey: Hashable {
|
||||
// periphery:ignore - read only via the synthesized Hashable
|
||||
// conformance (dictionary-key identity), which the indexer
|
||||
// cannot attribute; see retain_codable_properties in .periphery.yml
|
||||
// for the same class of false positive.
|
||||
let peerID: PeerID
|
||||
let messageID: String
|
||||
}
|
||||
|
||||
private let transports: [Transport]
|
||||
private let now: () -> Date
|
||||
private let courierDirectory: CourierDirectory
|
||||
@ -104,15 +113,25 @@ final class MessageRouter {
|
||||
}
|
||||
|
||||
private var bridgeSweepTask: Task<Void, Never>?
|
||||
private var bridgeDepositsInFlight = Set<String>()
|
||||
private var bridgeDepositsInFlight = Set<PeerMessageKey>()
|
||||
|
||||
private var outbox: [PeerID: [QueuedMessage]] = [:]
|
||||
/// Peer/message pairs whose latest router-owned transmission used an
|
||||
/// already-established secure session and still await an ack. Peer scope
|
||||
/// is required because message IDs are not globally unique across direct
|
||||
/// conversations. This deliberately excludes messages handed to BLE while
|
||||
/// a handshake is pending: BLE owns those sends and drains its queue after
|
||||
/// authentication, so retrying them here would duplicate every normal
|
||||
/// first-handshake DM.
|
||||
private var secureTransmissions = Set<PeerMessageKey>()
|
||||
|
||||
// Outbox limits to prevent unbounded memory growth
|
||||
private static let maxMessagesPerPeer = 100
|
||||
private static let messageTTLSeconds: TimeInterval = 24 * 60 * 60 // 24 hours
|
||||
// Bound resends of messages sent on a weak reachability signal that never
|
||||
// get a delivery ack (e.g. peer on an old client that doesn't ack).
|
||||
// Bound actual sends that never receive an ack, whether they used weak
|
||||
// reachability or an apparently secure session that keeps being replaced.
|
||||
// Connected pre-handshake sends are transport-owned and do not burn this
|
||||
// cap because BLE queues/drains them itself.
|
||||
private static let maxSendAttempts = 8
|
||||
// Redundant couriers improve delivery odds; receivers dedup by message ID.
|
||||
private static let maxCouriersPerMessage = 3
|
||||
@ -171,15 +190,28 @@ final class MessageRouter {
|
||||
// MARK: - Message Sending
|
||||
|
||||
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||
let message = QueuedMessage(
|
||||
content: content,
|
||||
nickname: recipientNickname,
|
||||
messageID: messageID,
|
||||
timestamp: now(),
|
||||
sendAttempts: 1
|
||||
)
|
||||
|
||||
if let transport = connectedTransport(for: peerID), transport.canDeliverSecurely(to: peerID) {
|
||||
// A live link that can complete an encrypted delivery is a
|
||||
// strong delivery signal; trust it outright.
|
||||
// Even an established Noise session can be stale after the peer
|
||||
// restarts or replaces its app. Persist before handing the packet
|
||||
// to the transport so a fast ack cannot race ahead of retention,
|
||||
// then keep the copy until a delivery/read ack clears it. A
|
||||
// replacement handshake will retry this same message ID, which
|
||||
// receivers deduplicate.
|
||||
enqueue(message, for: peerID)
|
||||
secureTransmissions.insert(PeerMessageKey(peerID: peerID, messageID: messageID))
|
||||
SecureLogger.debug("Routing PM via \(type(of: transport)) (connected) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
return
|
||||
}
|
||||
|
||||
let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: now(), sendAttempts: 1)
|
||||
if let transport = connectedTransport(for: peerID) {
|
||||
// "Connected" without an established secure session is forgeable:
|
||||
// link bindings heal on signature-verified "direct" announces, but
|
||||
@ -197,8 +229,9 @@ final class MessageRouter {
|
||||
// deposit is cleared on ack. Don't "optimize" the courier call
|
||||
// away.
|
||||
SecureLogger.debug("Routing PM via \(type(of: transport)) (connected, no secure session) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
enqueue(message, for: peerID)
|
||||
secureTransmissions.remove(PeerMessageKey(peerID: peerID, messageID: messageID))
|
||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
attemptCourierDeposit(messageID: messageID, for: peerID)
|
||||
return
|
||||
}
|
||||
@ -209,8 +242,8 @@ final class MessageRouter {
|
||||
// Send now, but retain a copy until a delivery/read ack clears it;
|
||||
// receivers dedup resends by message ID.
|
||||
SecureLogger.debug("Routing PM via \(type(of: transport)) (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
enqueue(message, for: peerID)
|
||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
// "Reachable" without prompt delivery means the send only joined
|
||||
// a queue (Nostr with relays down): also hand a sealed copy to
|
||||
// any connected couriers rather than waiting for internet that
|
||||
@ -248,6 +281,7 @@ final class MessageRouter {
|
||||
guard remainingSlots > 0 else { return }
|
||||
|
||||
for transport in transports {
|
||||
guard let courierTransport = transport as? MeshCourierTransporting else { continue }
|
||||
let couriers = eligibleCouriers(
|
||||
on: transport,
|
||||
recipientKey: recipientKey,
|
||||
@ -255,7 +289,7 @@ final class MessageRouter {
|
||||
limit: remainingSlots
|
||||
)
|
||||
guard !couriers.isEmpty else { continue }
|
||||
if transport.sendCourierMessage(entry.content, messageID: messageID, recipientNoiseKey: recipientKey, via: couriers.map(\.peerID)) {
|
||||
if courierTransport.sendCourierMessage(entry.content, messageID: messageID, recipientNoiseKey: recipientKey, via: couriers.map(\.peerID)) {
|
||||
SecureLogger.debug("📦 PM \(messageID.prefix(8))… handed to \(couriers.count) courier(s) for \(peerID.id.prefix(8))…", category: .session)
|
||||
recordCourierDeposit(messageID: messageID, for: peerID, courierKeys: couriers.map(\.noiseKey))
|
||||
onMessageCarried?(messageID, peerID)
|
||||
@ -271,6 +305,7 @@ final class MessageRouter {
|
||||
/// `maxCouriersPerMessage` distinct couriers or expires.
|
||||
func courierBecameAvailable(_ peerID: PeerID) {
|
||||
for transport in transports {
|
||||
guard let courierTransport = transport as? MeshCourierTransporting else { continue }
|
||||
guard transport.isPeerConnected(peerID),
|
||||
let snapshot = transport.currentPeerSnapshots().first(where: { $0.peerID == peerID && $0.isConnected }),
|
||||
let courierKey = snapshot.noisePublicKey,
|
||||
@ -286,7 +321,7 @@ final class MessageRouter {
|
||||
guard message.depositedCourierKeys.count < Self.maxCouriersPerMessage,
|
||||
!message.depositedCourierKeys.contains(courierKey),
|
||||
currentDate.timeIntervalSince(message.timestamp) <= Self.messageTTLSeconds else { continue }
|
||||
if transport.sendCourierMessage(message.content, messageID: message.messageID, recipientNoiseKey: recipientKey, via: [peerID]) {
|
||||
if courierTransport.sendCourierMessage(message.content, messageID: message.messageID, recipientNoiseKey: recipientKey, via: [peerID]) {
|
||||
SecureLogger.debug("📦 Deposit retry: PM \(message.messageID.prefix(8))… handed to \(peerID.id.prefix(8))… for \(recipient.id.prefix(8))…", category: .session)
|
||||
recordCourierDeposit(messageID: message.messageID, for: recipient, courierKeys: [courierKey])
|
||||
onMessageCarried?(message.messageID, recipient)
|
||||
@ -333,11 +368,12 @@ final class MessageRouter {
|
||||
for peerID: PeerID,
|
||||
recipientKey: Data
|
||||
) {
|
||||
let inFlightKey = PeerMessageKey(peerID: peerID, messageID: message.messageID)
|
||||
guard let bridgeCourierDeposit,
|
||||
bridgeDepositsInFlight.insert(message.messageID).inserted else { return }
|
||||
bridgeDepositsInFlight.insert(inFlightKey).inserted else { return }
|
||||
bridgeCourierDeposit(message.content, message.messageID, recipientKey) { [weak self] succeeded in
|
||||
guard let self else { return }
|
||||
self.bridgeDepositsInFlight.remove(message.messageID)
|
||||
self.bridgeDepositsInFlight.remove(inFlightKey)
|
||||
// A direct delivery may have cleared the outbox while the relay
|
||||
// relay confirmation was in flight; do not regress its UI state.
|
||||
guard succeeded, self.queuedMessage(message.messageID, for: peerID) != nil else { return }
|
||||
@ -356,8 +392,23 @@ final class MessageRouter {
|
||||
|
||||
// MARK: - Outbox Management
|
||||
|
||||
/// A delivery or read ack confirms receipt; stop retaining the message.
|
||||
/// A locally trusted delivery transition confirms receipt; stop retaining
|
||||
/// every copy of the message. Authenticated remote receipts must use the
|
||||
/// peer-bound overload below instead.
|
||||
func markDelivered(_ messageID: String) {
|
||||
clearRetainedMessage(messageID)
|
||||
}
|
||||
|
||||
/// Stops retaining a message only for the authenticated conversation
|
||||
/// aliases that produced the accepted receipt. A peer that learns another
|
||||
/// conversation's message ID cannot use it to clear that conversation's
|
||||
/// retry state.
|
||||
func markDelivered(_ messageID: String, from peerIDs: Set<PeerID>) {
|
||||
guard !peerIDs.isEmpty else { return }
|
||||
_ = markDelivered(messageID, for: Array(peerIDs))
|
||||
}
|
||||
|
||||
private func clearRetainedMessage(_ messageID: String) {
|
||||
var cleared = false
|
||||
for (peerID, queue) in outbox {
|
||||
let filtered = queue.filter { $0.messageID != messageID }
|
||||
@ -365,6 +416,10 @@ final class MessageRouter {
|
||||
outbox[peerID] = filtered.isEmpty ? nil : filtered
|
||||
cleared = true
|
||||
}
|
||||
let matchingSecureTransmissions = secureTransmissions.filter {
|
||||
$0.messageID == messageID
|
||||
}
|
||||
secureTransmissions.subtract(matchingSecureTransmissions)
|
||||
// The durable snapshot may still be hidden by protected data. Record
|
||||
// the ack even when this cold-load view cannot find the message, then
|
||||
// persist the current view so the store retains a removal tombstone.
|
||||
@ -375,6 +430,35 @@ final class MessageRouter {
|
||||
persistOutbox()
|
||||
}
|
||||
|
||||
/// A delivery or read ack authenticated to one account confirms receipt
|
||||
/// only for that account's transport aliases. A colliding message ID
|
||||
/// queued for another peer must remain retained.
|
||||
@discardableResult
|
||||
func markDelivered(_ messageID: String, for peerAliases: [PeerID]) -> Bool {
|
||||
let peerIDs = Set(peerAliases)
|
||||
guard !peerIDs.isEmpty else { return false }
|
||||
|
||||
var cleared = false
|
||||
for peerID in peerIDs {
|
||||
guard let queue = outbox[peerID] else { continue }
|
||||
let filtered = queue.filter { $0.messageID != messageID }
|
||||
guard filtered.count != queue.count else { continue }
|
||||
outbox[peerID] = filtered.isEmpty ? nil : filtered
|
||||
cleared = true
|
||||
}
|
||||
for peerID in peerIDs {
|
||||
secureTransmissions.remove(PeerMessageKey(peerID: peerID, messageID: messageID))
|
||||
}
|
||||
// Preserve the scoped ack even when protected data hides the durable
|
||||
// queue during a cold launch.
|
||||
outboxStore?.recordRemoval(messageID: messageID, for: peerIDs)
|
||||
if cleared {
|
||||
metrics?.record(.outboxDelivered)
|
||||
}
|
||||
persistOutbox()
|
||||
return cleared
|
||||
}
|
||||
|
||||
private func enqueue(_ message: QueuedMessage, for peerID: PeerID) {
|
||||
var message = message
|
||||
var queue = outbox[peerID] ?? []
|
||||
@ -398,7 +482,34 @@ final class MessageRouter {
|
||||
persistOutbox()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func removeQueuedMessage(_ messageID: String, for peerID: PeerID) -> Bool {
|
||||
guard let queue = outbox[peerID],
|
||||
let index = queue.firstIndex(where: { $0.messageID == messageID }) else {
|
||||
return false
|
||||
}
|
||||
var updated = queue
|
||||
updated.remove(at: index)
|
||||
outbox[peerID] = updated.isEmpty ? nil : updated
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func incrementSendAttemptsIfQueued(_ messageID: String, for peerID: PeerID) -> Bool {
|
||||
guard var queue = outbox[peerID],
|
||||
let index = queue.firstIndex(where: { $0.messageID == messageID }) else {
|
||||
// A synchronous delivery/read ack may have cleared the retained
|
||||
// copy while `sendPrivateMessage` was on the stack. Never
|
||||
// resurrect it from the flush snapshot.
|
||||
return false
|
||||
}
|
||||
queue[index].sendAttempts += 1
|
||||
outbox[peerID] = queue
|
||||
return true
|
||||
}
|
||||
|
||||
private func dropMessage(_ messageID: String, for peerID: PeerID) {
|
||||
secureTransmissions.remove(PeerMessageKey(peerID: peerID, messageID: messageID))
|
||||
metrics?.record(.outboxDropped)
|
||||
onMessageDropped?(messageID, peerID)
|
||||
}
|
||||
@ -442,6 +553,7 @@ final class MessageRouter {
|
||||
/// Panic wipe: forget queued mail on disk and in memory.
|
||||
func wipeOutbox() {
|
||||
outbox.removeAll()
|
||||
secureTransmissions.removeAll()
|
||||
outboxStore?.wipe()
|
||||
}
|
||||
|
||||
@ -469,26 +581,160 @@ final class MessageRouter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Retries only messages that the router previously transmitted through
|
||||
/// an already-established secure session and that still await an ack.
|
||||
///
|
||||
/// A peer restart can leave that local session looking usable until the
|
||||
/// replacement handshake arrives; the first ciphertext is then
|
||||
/// undecryptable remotely. Normal pre-handshake sends are intentionally
|
||||
/// absent from `secureTransmissions` because BLE already queues
|
||||
/// and drains them when authentication completes.
|
||||
func retrySecurePrivateMessagesAfterAuthentication(for peerIDAliases: [PeerID]) {
|
||||
typealias Candidate = (
|
||||
peerID: PeerID,
|
||||
message: QueuedMessage,
|
||||
aliasOrder: Int,
|
||||
queueOrder: Int
|
||||
)
|
||||
|
||||
var visitedPeerIDs = Set<PeerID>()
|
||||
var retriedMessageIDs = Set<String>()
|
||||
var outboxChanged = false
|
||||
let currentDate = now()
|
||||
var candidates: [Candidate] = []
|
||||
|
||||
for (aliasOrder, peerID) in peerIDAliases.enumerated() {
|
||||
guard visitedPeerIDs.insert(peerID).inserted else { continue }
|
||||
guard let queued = outbox[peerID], !queued.isEmpty,
|
||||
let transport = connectedTransport(for: peerID),
|
||||
transport.canDeliverSecurely(to: peerID) else {
|
||||
continue
|
||||
}
|
||||
|
||||
for (queueOrder, message) in queued.enumerated() {
|
||||
let key = PeerMessageKey(peerID: peerID, messageID: message.messageID)
|
||||
guard secureTransmissions.contains(key) else { continue }
|
||||
candidates.append((
|
||||
peerID: peerID,
|
||||
message: message,
|
||||
aliasOrder: aliasOrder,
|
||||
queueOrder: queueOrder
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Conversation migration can leave retained messages split across the
|
||||
// ephemeral and stable outbox keys. Merge both queues into one
|
||||
// chronological stream so callback alias order cannot send newer mail
|
||||
// ahead of older mail.
|
||||
candidates.sort { lhs, rhs in
|
||||
if lhs.message.timestamp != rhs.message.timestamp {
|
||||
return lhs.message.timestamp < rhs.message.timestamp
|
||||
}
|
||||
if lhs.aliasOrder != rhs.aliasOrder {
|
||||
return lhs.aliasOrder < rhs.aliasOrder
|
||||
}
|
||||
if lhs.queueOrder != rhs.queueOrder {
|
||||
return lhs.queueOrder < rhs.queueOrder
|
||||
}
|
||||
return lhs.message.messageID < rhs.message.messageID
|
||||
}
|
||||
|
||||
for candidate in candidates {
|
||||
let peerID = candidate.peerID
|
||||
let message = candidate.message
|
||||
let key = PeerMessageKey(peerID: peerID, messageID: message.messageID)
|
||||
guard retriedMessageIDs.insert(message.messageID).inserted,
|
||||
secureTransmissions.contains(key),
|
||||
queuedMessage(message.messageID, for: peerID) != nil,
|
||||
let transport = connectedTransport(for: peerID),
|
||||
transport.canDeliverSecurely(to: peerID) else {
|
||||
continue
|
||||
}
|
||||
|
||||
if currentDate.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds {
|
||||
if removeQueuedMessage(message.messageID, for: peerID) {
|
||||
dropMessage(message.messageID, for: peerID)
|
||||
outboxChanged = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
guard message.sendAttempts < Self.maxSendAttempts else {
|
||||
SecureLogger.warning(
|
||||
"📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) secure attempts",
|
||||
category: .session
|
||||
)
|
||||
if removeQueuedMessage(message.messageID, for: peerID) {
|
||||
dropMessage(message.messageID, for: peerID)
|
||||
outboxChanged = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
SecureLogger.debug(
|
||||
"Auth retry -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…",
|
||||
category: .session
|
||||
)
|
||||
transport.sendPrivateMessage(
|
||||
message.content,
|
||||
to: peerID,
|
||||
recipientNickname: message.nickname,
|
||||
messageID: message.messageID
|
||||
)
|
||||
metrics?.record(.outboxResent)
|
||||
outboxChanged = incrementSendAttemptsIfQueued(message.messageID, for: peerID) || outboxChanged
|
||||
}
|
||||
|
||||
if outboxChanged {
|
||||
persistOutbox()
|
||||
}
|
||||
}
|
||||
|
||||
func flushOutbox(for peerID: PeerID) {
|
||||
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
||||
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
|
||||
|
||||
let now = now()
|
||||
var remaining: [QueuedMessage] = []
|
||||
var outboxChanged = false
|
||||
|
||||
for message in queued {
|
||||
// A synchronous ack from an earlier send in this flush may have
|
||||
// removed an entry from the live outbox. The snapshot is only an
|
||||
// iteration order; never use it to recreate removed messages.
|
||||
guard queuedMessage(message.messageID, for: peerID) != nil else { continue }
|
||||
|
||||
// Skip expired messages (TTL exceeded)
|
||||
if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds {
|
||||
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… (age: \(Int(now.timeIntervalSince(message.timestamp)))s)", category: .session)
|
||||
dropMessage(message.messageID, for: peerID)
|
||||
if removeQueuedMessage(message.messageID, for: peerID) {
|
||||
dropMessage(message.messageID, for: peerID)
|
||||
outboxChanged = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if let transport = connectedTransport(for: peerID), transport.canDeliverSecurely(to: peerID) {
|
||||
// Live link with a secure session: send and stop retaining.
|
||||
// A secure session is meaningful enough to retry, but not
|
||||
// proof that this particular ciphertext reached the peer: the
|
||||
// remote app may have restarted while our old session still
|
||||
// looked established. Retain until an ack, while bounding
|
||||
// actual secure transmissions for peers that never ack.
|
||||
guard message.sendAttempts < Self.maxSendAttempts else {
|
||||
SecureLogger.warning("📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session)
|
||||
if removeQueuedMessage(message.messageID, for: peerID) {
|
||||
dropMessage(message.messageID, for: peerID)
|
||||
outboxChanged = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
SecureLogger.debug("Outbox -> \(type(of: transport)) (connected) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
||||
secureTransmissions.insert(
|
||||
PeerMessageKey(peerID: peerID, messageID: message.messageID)
|
||||
)
|
||||
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
||||
metrics?.record(.outboxResent)
|
||||
outboxChanged = incrementSendAttemptsIfQueued(message.messageID, for: peerID) || outboxChanged
|
||||
} else if let transport = connectedTransport(for: peerID) {
|
||||
// "Connected" without a secure session — possibly a stolen
|
||||
// binding from a replayed announce: send (a genuine link
|
||||
@ -501,9 +747,11 @@ final class MessageRouter {
|
||||
// preserve. Retention stays bounded by the 24h outbox TTL
|
||||
// and the per-peer FIFO cap.
|
||||
SecureLogger.debug("Outbox -> \(type(of: transport)) (connected, no secure session) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
||||
secureTransmissions.remove(
|
||||
PeerMessageKey(peerID: peerID, messageID: message.messageID)
|
||||
)
|
||||
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
||||
metrics?.record(.outboxResent)
|
||||
remaining.append(message)
|
||||
} else if let transport = reachableTransport(for: peerID) {
|
||||
// Reachability without a connection is a freshness heuristic,
|
||||
// so the send can silently go nowhere: send but keep retaining
|
||||
@ -511,26 +759,22 @@ final class MessageRouter {
|
||||
// that never ack.
|
||||
guard message.sendAttempts < Self.maxSendAttempts else {
|
||||
SecureLogger.warning("📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session)
|
||||
dropMessage(message.messageID, for: peerID)
|
||||
if removeQueuedMessage(message.messageID, for: peerID) {
|
||||
dropMessage(message.messageID, for: peerID)
|
||||
outboxChanged = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
SecureLogger.debug("Outbox -> \(type(of: transport)) (reachable) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
||||
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
||||
metrics?.record(.outboxResent)
|
||||
var retained = message
|
||||
retained.sendAttempts += 1
|
||||
remaining.append(retained)
|
||||
} else {
|
||||
remaining.append(message)
|
||||
outboxChanged = incrementSendAttemptsIfQueued(message.messageID, for: peerID) || outboxChanged
|
||||
}
|
||||
}
|
||||
|
||||
if remaining.isEmpty {
|
||||
outbox.removeValue(forKey: peerID)
|
||||
} else {
|
||||
outbox[peerID] = remaining
|
||||
if outboxChanged {
|
||||
persistOutbox()
|
||||
}
|
||||
persistOutbox()
|
||||
}
|
||||
|
||||
func flushAllOutbox() {
|
||||
|
||||
@ -42,13 +42,18 @@ final class NetworkActivationService: ObservableObject {
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var started = false
|
||||
private let torPreferenceKey = "networkActivationService.userTorEnabled"
|
||||
/// Storage key for the Tor preference. Exposed as a `nonisolated` constant
|
||||
/// so off-main callers can read the preference without hopping to the main
|
||||
/// actor; see `persistedTorPreference(in:)`.
|
||||
nonisolated static let torPreferenceKey = "networkActivationService.userTorEnabled"
|
||||
private var torAutoStartDesired: Bool = false
|
||||
private let storage: UserDefaults
|
||||
private let locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>
|
||||
private let mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>
|
||||
private let selectedChannelPublisher: AnyPublisher<ChannelID, Never>
|
||||
private let permissionProvider: () -> LocationChannelManager.PermissionState
|
||||
private let mutualFavoritesProvider: () -> Set<Data>
|
||||
private let locationChannelSelectedProvider: () -> Bool
|
||||
private let reachabilityMonitor: NetworkReachabilityMonitoring
|
||||
private let torController: NetworkActivationTorControlling
|
||||
// Resolved lazily: NostrRelayManager.init() reads NetworkActivationService.shared
|
||||
@ -63,8 +68,13 @@ final class NetworkActivationService: ObservableObject {
|
||||
storage = .standard
|
||||
locationPermissionPublisher = LocationChannelManager.shared.$permissionState.eraseToAnyPublisher()
|
||||
mutualFavoritesPublisher = FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher()
|
||||
selectedChannelPublisher = LocationChannelManager.shared.$selectedChannel.eraseToAnyPublisher()
|
||||
permissionProvider = { LocationChannelManager.shared.permissionState }
|
||||
mutualFavoritesProvider = { FavoritesPersistenceService.shared.mutualFavorites }
|
||||
locationChannelSelectedProvider = {
|
||||
if case .location = LocationChannelManager.shared.selectedChannel { return true }
|
||||
return false
|
||||
}
|
||||
reachabilityMonitor = NWPathReachabilityMonitor()
|
||||
torController = TorManager.shared
|
||||
relayControllerProvider = { NostrRelayManager.shared }
|
||||
@ -78,6 +88,8 @@ final class NetworkActivationService: ObservableObject {
|
||||
mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>,
|
||||
permissionProvider: @escaping () -> LocationChannelManager.PermissionState,
|
||||
mutualFavoritesProvider: @escaping () -> Set<Data>,
|
||||
selectedChannelPublisher: AnyPublisher<ChannelID, Never> = Empty().eraseToAnyPublisher(),
|
||||
locationChannelSelectedProvider: @escaping () -> Bool = { false },
|
||||
reachabilityMonitor: NetworkReachabilityMonitoring,
|
||||
torController: NetworkActivationTorControlling,
|
||||
relayController: NetworkActivationRelayControlling,
|
||||
@ -89,6 +101,8 @@ final class NetworkActivationService: ObservableObject {
|
||||
self.mutualFavoritesPublisher = mutualFavoritesPublisher
|
||||
self.permissionProvider = permissionProvider
|
||||
self.mutualFavoritesProvider = mutualFavoritesProvider
|
||||
self.selectedChannelPublisher = selectedChannelPublisher
|
||||
self.locationChannelSelectedProvider = locationChannelSelectedProvider
|
||||
self.reachabilityMonitor = reachabilityMonitor
|
||||
self.torController = torController
|
||||
self.relayControllerProvider = { relayController }
|
||||
@ -96,11 +110,25 @@ final class NetworkActivationService: ObservableObject {
|
||||
self.notificationCenter = notificationCenter
|
||||
}
|
||||
|
||||
/// Whether Tor routing is switched on, read without main-actor isolation.
|
||||
///
|
||||
/// This is the *preference*, not live Tor readiness. Background work that
|
||||
/// must decide whether waiting for Tor is even meaningful needs the
|
||||
/// preference: when someone has deliberately turned Tor off, requests are
|
||||
/// intended to go direct, so waiting on a client that has been shut down
|
||||
/// would only burn the bootstrap timeout. When the preference is on, callers
|
||||
/// must still wait for readiness rather than falling back to clearnet.
|
||||
nonisolated static func persistedTorPreference(
|
||||
in defaults: UserDefaults = .standard
|
||||
) -> Bool {
|
||||
defaults.object(forKey: torPreferenceKey) as? Bool ?? true
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard !started else { return }
|
||||
started = true
|
||||
|
||||
if let stored = storage.object(forKey: torPreferenceKey) as? Bool {
|
||||
if let stored = storage.object(forKey: Self.torPreferenceKey) as? Bool {
|
||||
userTorEnabled = stored
|
||||
} else {
|
||||
userTorEnabled = true
|
||||
@ -138,6 +166,16 @@ final class NetworkActivationService: ObservableObject {
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// React to entering or leaving a location channel, which can flip the
|
||||
// gate on its own for someone with no location permission and no
|
||||
// mutual favorites.
|
||||
selectedChannelPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.reevaluate()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// React to network reachability changes (debounced, unsatisfied-only).
|
||||
reachabilityMonitor.reachabilityPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
@ -154,10 +192,23 @@ final class NetworkActivationService: ObservableObject {
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
/// Stops all internet-facing work at the synchronous panic boundary.
|
||||
/// `start()` may be called again only after the full wipe commits.
|
||||
func stopForPanic() {
|
||||
cancellables.removeAll()
|
||||
started = false
|
||||
reachabilityMonitor.stop()
|
||||
activationAllowed = false
|
||||
torAutoStartDesired = false
|
||||
relayController.disconnect()
|
||||
torController.setAutoStartAllowed(false)
|
||||
applyTorState(torDesired: false)
|
||||
}
|
||||
|
||||
func setUserTorEnabled(_ enabled: Bool) {
|
||||
guard enabled != userTorEnabled else { return }
|
||||
userTorEnabled = enabled
|
||||
storage.set(enabled, forKey: torPreferenceKey)
|
||||
storage.set(enabled, forKey: Self.torPreferenceKey)
|
||||
notificationCenter.post(
|
||||
name: .TorUserPreferenceChanged,
|
||||
object: nil,
|
||||
@ -167,6 +218,7 @@ final class NetworkActivationService: ObservableObject {
|
||||
}
|
||||
|
||||
private func reevaluate() {
|
||||
guard started else { return }
|
||||
let allowed = effectiveAllowed()
|
||||
let torDesired = allowed && userTorEnabled
|
||||
let statusChanged = allowed != activationAllowed
|
||||
@ -197,7 +249,14 @@ final class NetworkActivationService: ObservableObject {
|
||||
private func basePolicyAllowed() -> Bool {
|
||||
let permOK = permissionProvider() == .authorized
|
||||
let hasMutual = !mutualFavoritesProvider().isEmpty
|
||||
return permOK || hasMutual
|
||||
// Being in a location channel counts too. Teleporting into a geohash
|
||||
// needs no location permission, so someone who denied location and has
|
||||
// no mutual favorites could sit in a channel that never connects: the
|
||||
// gate suppressed Tor and the relays, and nothing said why. The channel
|
||||
// is itself an internet feature in active use, which is exactly what
|
||||
// this gate is meant to detect.
|
||||
let inLocationChannel = locationChannelSelectedProvider()
|
||||
return permOK || hasMutual || inLocationChannel
|
||||
}
|
||||
|
||||
/// Effective gate: base policy AND a usable network path. When there is
|
||||
|
||||
@ -27,6 +27,8 @@ protocol NetworkReachabilityMonitoring: AnyObject {
|
||||
var reachabilityPublisher: AnyPublisher<Bool, Never> { get }
|
||||
/// Begin monitoring. Idempotent.
|
||||
func start()
|
||||
/// Stop monitoring and discard pending debounce work. Idempotent.
|
||||
func stop()
|
||||
}
|
||||
|
||||
/// Pure debounce/decision logic for reachability, split out so it can be
|
||||
@ -88,18 +90,6 @@ struct ReachabilityDebounce {
|
||||
}
|
||||
}
|
||||
|
||||
/// Always-reachable stub. Used as the default in tests and as the fallback on
|
||||
/// platforms without the Network framework, so reachability never suppresses
|
||||
/// startup by itself.
|
||||
@MainActor
|
||||
final class AlwaysReachableMonitor: NetworkReachabilityMonitoring {
|
||||
var isReachable: Bool { true }
|
||||
var reachabilityPublisher: AnyPublisher<Bool, Never> {
|
||||
Empty(completeImmediately: false).eraseToAnyPublisher()
|
||||
}
|
||||
func start() {}
|
||||
}
|
||||
|
||||
/// `NWPathMonitor`-backed reachability. All state lives on the main actor; the
|
||||
/// background path callback hops here before touching the debounce.
|
||||
@MainActor
|
||||
@ -146,6 +136,18 @@ final class NWPathReachabilityMonitor: NetworkReachabilityMonitoring {
|
||||
#endif
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard started else { return }
|
||||
started = false
|
||||
flushWorkItem?.cancel()
|
||||
flushWorkItem = nil
|
||||
#if canImport(Network)
|
||||
monitor?.pathUpdateHandler = nil
|
||||
monitor?.cancel()
|
||||
monitor = nil
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Feed an observation into the debounce and publish committed changes.
|
||||
/// Exposed internally so higher layers/tests could drive it if needed.
|
||||
func ingest(reachable: Bool) {
|
||||
|
||||
@ -97,7 +97,7 @@ enum EncryptionStatus: Equatable {
|
||||
case noiseHandshaking // Currently establishing
|
||||
case noiseSecured // Established but not verified
|
||||
case noiseVerified // Established and verified
|
||||
|
||||
|
||||
var icon: String? { // Made optional to hide icon when no handshake
|
||||
switch self {
|
||||
case .none:
|
||||
@ -165,7 +165,6 @@ final class NoiseEncryptionService {
|
||||
// Peer fingerprints (SHA256 hash of static public key)
|
||||
private var peerFingerprints: [PeerID: String] = [:]
|
||||
private var fingerprintToPeerID: [String: PeerID] = [:]
|
||||
|
||||
// Thread safety
|
||||
private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent)
|
||||
|
||||
@ -183,12 +182,27 @@ final class NoiseEncryptionService {
|
||||
|
||||
// Callbacks
|
||||
private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication
|
||||
private var onPeerAuthenticatedWithGenerationHandlers: [((PeerID, String, UUID) -> Void)] = []
|
||||
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
|
||||
/// Automatic rekey prepared XX message 1. The transport must claim the
|
||||
/// exact attempt at its actual BLE handoff; a crossed inbound initiation
|
||||
/// can invalidate the token before that point.
|
||||
var onRekeyHandshakeReady:
|
||||
((_ peerID: PeerID, _ initiation: NoiseHandshakeInitiation) -> Void)?
|
||||
var onHandshakeRecoveryRequired:
|
||||
((_ request: NoiseHandshakeRecoveryRequest) -> Void)?
|
||||
/// An unauthenticated reconnect attempt failed or timed out and the
|
||||
/// receive-only rollback session became the active transport again.
|
||||
/// Transport queues may only be drained for this exact restored
|
||||
/// generation when the reason is terminal; a restore that owns a pending
|
||||
/// convergence retry must keep them parked until the retry concludes.
|
||||
var onSessionRestoredWithGeneration:
|
||||
((_ peerID: PeerID, _ generation: UUID, _ reason: NoiseSessionRestoreReason) -> Void)?
|
||||
|
||||
// Add a handler for peer authentication
|
||||
func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) {
|
||||
serviceQueue.async(flags: .barrier) { [weak self] in
|
||||
self?.onPeerAuthenticatedHandlers.append(handler)
|
||||
serviceQueue.sync(flags: .barrier) {
|
||||
onPeerAuthenticatedHandlers.append(handler)
|
||||
}
|
||||
}
|
||||
|
||||
@ -201,8 +215,30 @@ final class NoiseEncryptionService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generation-aware authentication notifications are used by protocols
|
||||
/// whose state must be bound to one exact Noise transport session.
|
||||
var onPeerAuthenticatedWithGeneration: ((PeerID, String, UUID) -> Void)? {
|
||||
get { nil }
|
||||
set {
|
||||
guard let handler = newValue else { return }
|
||||
serviceQueue.sync(flags: .barrier) {
|
||||
onPeerAuthenticatedWithGenerationHandlers.append(handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init(keychain: KeychainManagerProtocol) {
|
||||
init(
|
||||
keychain: KeychainManagerProtocol,
|
||||
ordinaryHandshakeTimeout: TimeInterval =
|
||||
NoiseSecurityConstants.ordinaryHandshakeTimeout,
|
||||
ordinaryResponderHandshakeTimeout: TimeInterval =
|
||||
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout,
|
||||
recentInitiatorCompletionGracePeriod: TimeInterval =
|
||||
NoiseSecurityConstants.recentInitiatorCompletionGracePeriod,
|
||||
ordinaryReconnectRollbackCooldown: TimeInterval =
|
||||
NoiseSecurityConstants.ordinaryReconnectRollbackCooldown
|
||||
) {
|
||||
self.keychain = keychain
|
||||
self.localPrekeys = LocalPrekeyStore(keychain: keychain)
|
||||
|
||||
@ -292,11 +328,31 @@ final class NoiseEncryptionService {
|
||||
self.signingPublicKey = signingKey.publicKey
|
||||
|
||||
// Initialize session manager
|
||||
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain)
|
||||
self.sessionManager = NoiseSessionManager(
|
||||
localStaticKey: staticIdentityKey,
|
||||
keychain: keychain,
|
||||
ordinaryHandshakeTimeout: ordinaryHandshakeTimeout,
|
||||
ordinaryResponderHandshakeTimeout:
|
||||
ordinaryResponderHandshakeTimeout,
|
||||
recentInitiatorCompletionGracePeriod:
|
||||
recentInitiatorCompletionGracePeriod,
|
||||
ordinaryReconnectRollbackCooldown:
|
||||
ordinaryReconnectRollbackCooldown
|
||||
)
|
||||
|
||||
// Set up session callbacks
|
||||
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in
|
||||
self?.handleSessionEstablished(peerID: peerID, remoteStaticKey: remoteStaticKey)
|
||||
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey, generation in
|
||||
self?.handleSessionEstablished(
|
||||
peerID: peerID,
|
||||
remoteStaticKey: remoteStaticKey,
|
||||
sessionGeneration: generation
|
||||
)
|
||||
}
|
||||
sessionManager.onSessionRestored = { [weak self] peerID, generation, reason in
|
||||
self?.onSessionRestoredWithGeneration?(peerID, generation, reason)
|
||||
}
|
||||
sessionManager.onHandshakeRecoveryRequired = { [weak self] request in
|
||||
self?.onHandshakeRecoveryRequired?(request)
|
||||
}
|
||||
|
||||
// Start session maintenance timer
|
||||
@ -607,7 +663,7 @@ final class NoiseEncryptionService {
|
||||
guard let packetData = packet.toBinaryDataForSigning() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
// Sign with the noise private key (converted to Ed25519 for signing)
|
||||
guard let signature = signData(packetData) else {
|
||||
return nil
|
||||
@ -661,9 +717,105 @@ final class NoiseEncryptionService {
|
||||
let handshakeData = try sessionManager.initiateHandshake(with: peerID)
|
||||
return handshakeData
|
||||
}
|
||||
|
||||
/// Atomically admits and prepares one initial ordinary handshake. Returns
|
||||
/// nil when another discovery callback already created a session.
|
||||
func initiateHandshakeIfNeeded(
|
||||
with peerID: PeerID,
|
||||
retryOnTimeout: Bool = false
|
||||
) throws -> NoiseHandshakeInitiation? {
|
||||
guard peerID.isValid else {
|
||||
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
|
||||
throw NoiseSecurityError.invalidPeerID
|
||||
}
|
||||
|
||||
guard let initiation = try sessionManager.initiateHandshakeIfAbsent(
|
||||
with: peerID,
|
||||
notifyOnTimeout: retryOnTimeout,
|
||||
authorize: { [rateLimiter] in
|
||||
guard rateLimiter.allowHandshake(from: peerID) else {
|
||||
SecureLogger.warning(
|
||||
.authenticationFailed(peerID: "Rate limited: \(peerID)")
|
||||
)
|
||||
throw NoiseSecurityError.rateLimitExceeded
|
||||
}
|
||||
}
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
SecureLogger.info(.handshakeStarted(peerID: peerID.id))
|
||||
return initiation
|
||||
}
|
||||
|
||||
/// Atomically prepares an ordinary reconnect for a peer whose cached
|
||||
/// transport belongs to an earlier physical link. Failed authorization or
|
||||
/// handshake setup preserves the established session.
|
||||
func initiateReconnectHandshake(
|
||||
with peerID: PeerID,
|
||||
retryOnTimeout: Bool = false
|
||||
) throws -> NoiseHandshakeInitiation {
|
||||
guard peerID.isValid else {
|
||||
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
|
||||
throw NoiseSecurityError.invalidPeerID
|
||||
}
|
||||
|
||||
return try sessionManager.initiateReconnectHandshake(
|
||||
with: peerID,
|
||||
notifyOnTimeout: retryOnTimeout,
|
||||
authorize: { [rateLimiter] in
|
||||
guard rateLimiter.allowHandshake(from: peerID) else {
|
||||
SecureLogger.warning(
|
||||
.authenticationFailed(peerID: "Rate limited: \(peerID)")
|
||||
)
|
||||
throw NoiseSecurityError.rateLimitExceeded
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func prepareHandshakeRecovery(
|
||||
_ request: NoiseHandshakeRecoveryRequest
|
||||
) throws -> NoiseHandshakeRecoveryPreparation? {
|
||||
try sessionManager.prepareHandshakeRecovery(
|
||||
request,
|
||||
authorizeAttempt: { [rateLimiter] in
|
||||
guard rateLimiter.allowHandshake(from: request.peerID) else {
|
||||
SecureLogger.warning(
|
||||
.authenticationFailed(
|
||||
peerID: "Rate limited: \(request.peerID)"
|
||||
)
|
||||
)
|
||||
throw NoiseSecurityError.rateLimitExceeded
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func cancelHandshakeRecovery(_ request: NoiseHandshakeRecoveryRequest) {
|
||||
sessionManager.cancelHandshakeRecovery(request)
|
||||
}
|
||||
|
||||
func claimHandshakeInitiation(
|
||||
_ initiation: NoiseHandshakeInitiation,
|
||||
for peerID: PeerID
|
||||
) -> Data? {
|
||||
sessionManager.claimHandshakeInitiation(initiation, for: peerID)
|
||||
}
|
||||
|
||||
/// Process an incoming handshake message
|
||||
func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? {
|
||||
try processHandshakeMessageWithResult(
|
||||
from: peerID,
|
||||
message: message
|
||||
).response
|
||||
}
|
||||
|
||||
/// Process an incoming handshake message and report whether the exact
|
||||
/// session that consumed it completed authenticated establishment.
|
||||
func processHandshakeMessageWithResult(
|
||||
from peerID: PeerID,
|
||||
message: Data
|
||||
) throws -> NoiseHandshakeProcessingResult {
|
||||
|
||||
// Validate peer ID
|
||||
guard peerID.isValid else {
|
||||
@ -685,11 +837,14 @@ final class NoiseEncryptionService {
|
||||
|
||||
// For handshakes, we process the raw data directly without NoiseMessage wrapper
|
||||
// The Noise protocol handles its own message format
|
||||
let responsePayload = try sessionManager.handleIncomingHandshake(from: peerID, message: message)
|
||||
let result = try sessionManager.handleIncomingHandshakeWithResult(
|
||||
from: peerID,
|
||||
message: message
|
||||
)
|
||||
|
||||
|
||||
// Return raw response without wrapper
|
||||
return responsePayload
|
||||
return result
|
||||
}
|
||||
|
||||
/// Check if we have an established session with a peer
|
||||
@ -701,6 +856,13 @@ final class NoiseEncryptionService {
|
||||
func hasSession(with peerID: PeerID) -> Bool {
|
||||
return sessionManager.getSession(for: peerID) != nil
|
||||
}
|
||||
|
||||
/// True while an inbound ordinary XX responder is waiting for message 3.
|
||||
/// A small amount of immediately-following ciphertext may arrive first
|
||||
/// over BLE and must be retried only after responder promotion.
|
||||
func isAwaitingResponderHandshakeCompletion(with peerID: PeerID) -> Bool {
|
||||
sessionManager.isAwaitingResponderHandshakeCompletion(for: peerID)
|
||||
}
|
||||
|
||||
// MARK: - Encryption/Decryption
|
||||
|
||||
@ -725,25 +887,87 @@ final class NoiseEncryptionService {
|
||||
|
||||
return try sessionManager.encrypt(data, for: peerID)
|
||||
}
|
||||
|
||||
/// Decrypt data from a specific peer
|
||||
func decrypt(_ data: Data, from peerID: PeerID) throws -> Data {
|
||||
// Validate message size
|
||||
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
||||
|
||||
/// Encrypts a finalized private-media packet. Ordinary Noise application
|
||||
/// messages retain the 64 KiB ceiling; this purpose-specific path permits
|
||||
/// the bounded `BitchatFilePacket` envelope and refuses every other typed
|
||||
/// payload so the larger allocation budget cannot become a generic bypass.
|
||||
func encryptPrivateFilePayload(
|
||||
_ data: Data,
|
||||
for peerID: PeerID,
|
||||
sessionGeneration: UUID? = nil
|
||||
) throws -> Data {
|
||||
guard NoisePayloadType.isPrivateFile(rawValue: data.first),
|
||||
NoiseSecurityValidator.validatePrivateFileMessageSize(data) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
|
||||
// Check rate limit
|
||||
|
||||
guard rateLimiter.allowMessage(from: peerID) else {
|
||||
throw NoiseSecurityError.rateLimitExceeded
|
||||
}
|
||||
|
||||
// Check if we have an established session
|
||||
|
||||
guard hasEstablishedSession(with: peerID) else {
|
||||
onHandshakeRequired?(peerID)
|
||||
throw NoiseEncryptionError.handshakeRequired
|
||||
}
|
||||
|
||||
// `maxPrivateFilePlaintextSize` already subtracts the cipher's fixed
|
||||
// nonce/tag overhead, so the result is bounded without a second copy.
|
||||
if let sessionGeneration {
|
||||
return try sessionManager.encrypt(
|
||||
data,
|
||||
for: peerID,
|
||||
expectedSessionGeneration: sessionGeneration
|
||||
)
|
||||
}
|
||||
return try sessionManager.encrypt(data, for: peerID)
|
||||
}
|
||||
|
||||
/// Decrypt data from a specific peer
|
||||
func decrypt(_ data: Data, from peerID: PeerID) throws -> Data {
|
||||
try decryptWithSessionGeneration(data, from: peerID).plaintext
|
||||
}
|
||||
|
||||
func decryptWithSessionGeneration(
|
||||
_ data: Data,
|
||||
from peerID: PeerID,
|
||||
establishedGenerationIsReady: (UUID) -> Bool = { _ in true }
|
||||
) throws -> (plaintext: Data, sessionGeneration: UUID) {
|
||||
// Standard transport ciphertext has 20 bytes of nonce/tag overhead.
|
||||
// A larger ciphertext is admitted only up to the framed-file ceiling;
|
||||
// after authenticated decryption it must prove it is `.privateFile`.
|
||||
let isStandardCiphertext = NoiseSecurityValidator.validateCiphertextSize(data)
|
||||
let isAdmittedCiphertext = isStandardCiphertext
|
||||
|| NoiseSecurityValidator.validatePrivateFileCiphertextSize(data)
|
||||
|
||||
// A quarantined transport is deliberately unavailable for outbound
|
||||
// state, but remains receive-only until the responder proves identity
|
||||
// or the bounded rollback restores it.
|
||||
guard sessionManager.hasReceiveSession(for: peerID) else {
|
||||
throw NoiseEncryptionError.sessionNotEstablished
|
||||
}
|
||||
|
||||
return try sessionManager.decrypt(data, from: peerID)
|
||||
let result = try sessionManager.decryptWithSessionGeneration(
|
||||
data,
|
||||
from: peerID,
|
||||
establishedGenerationIsReady:
|
||||
establishedGenerationIsReady,
|
||||
authorizeDecrypt: { [rateLimiter] in
|
||||
guard isAdmittedCiphertext else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
guard rateLimiter.allowMessage(from: peerID) else {
|
||||
throw NoiseSecurityError.rateLimitExceeded
|
||||
}
|
||||
}
|
||||
)
|
||||
if !isStandardCiphertext {
|
||||
guard NoisePayloadType.isPrivateFile(rawValue: result.plaintext.first),
|
||||
NoiseSecurityValidator.validatePrivateFileMessageSize(result.plaintext) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Peer Management
|
||||
@ -755,6 +979,25 @@ final class NoiseEncryptionService {
|
||||
}
|
||||
}
|
||||
|
||||
func sessionGeneration(for peerID: PeerID) -> UUID? {
|
||||
sessionManager.sessionGeneration(for: peerID)
|
||||
}
|
||||
|
||||
/// Runs `body` while holding a read lease on the exact session generation.
|
||||
/// Session insertion, replacement, and removal use the same manager
|
||||
/// barrier, so they cannot interleave with an authenticated-state commit.
|
||||
func withCurrentSessionGeneration<Result>(
|
||||
for peerID: PeerID,
|
||||
expected: UUID,
|
||||
_ body: () -> Result
|
||||
) -> Result? {
|
||||
sessionManager.withCurrentSessionGeneration(
|
||||
for: peerID,
|
||||
expected: expected,
|
||||
body
|
||||
)
|
||||
}
|
||||
|
||||
func clearEphemeralStateForPanic() {
|
||||
sessionManager.removeAllSessions()
|
||||
serviceQueue.sync(flags: .barrier) {
|
||||
@ -777,24 +1020,36 @@ final class NoiseEncryptionService {
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func handleSessionEstablished(peerID: PeerID, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
|
||||
private func handleSessionEstablished(
|
||||
peerID: PeerID,
|
||||
remoteStaticKey: Curve25519.KeyAgreement.PublicKey,
|
||||
sessionGeneration: UUID
|
||||
) {
|
||||
// Calculate fingerprint
|
||||
let fingerprint = remoteStaticKey.rawRepresentation.sha256Fingerprint()
|
||||
|
||||
// Store fingerprint mapping
|
||||
serviceQueue.sync(flags: .barrier) {
|
||||
// Registering handlers is synchronous, and this barrier snapshots them
|
||||
// with the fingerprint update. Invoke the snapshot outside the queue:
|
||||
// parallel Swift Testing workers must not block behind queued callback
|
||||
// registration or allow a handler to re-enter serviceQueue.
|
||||
let handlers: (
|
||||
generationAware: [(PeerID, String, UUID) -> Void],
|
||||
legacy: [(PeerID, String) -> Void]
|
||||
) = serviceQueue.sync(flags: .barrier) {
|
||||
peerFingerprints[peerID] = fingerprint
|
||||
fingerprintToPeerID[fingerprint] = peerID
|
||||
return (onPeerAuthenticatedWithGenerationHandlers, onPeerAuthenticatedHandlers)
|
||||
}
|
||||
|
||||
// Log security event
|
||||
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||
|
||||
// Notify all handlers about authentication
|
||||
serviceQueue.async { [weak self] in
|
||||
self?.onPeerAuthenticatedHandlers.forEach { handler in
|
||||
handler(peerID, fingerprint)
|
||||
}
|
||||
// Notify all handlers about authentication.
|
||||
handlers.generationAware.forEach { handler in
|
||||
handler(peerID, fingerprint, sessionGeneration)
|
||||
}
|
||||
handlers.legacy.forEach { handler in
|
||||
handler(peerID, fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
@ -815,19 +1070,30 @@ 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)
|
||||
}
|
||||
|
||||
func _test_fireSuppressedInitiationRecovery(for peerID: PeerID) {
|
||||
sessionManager._test_fireSuppressedInitiationRecovery(for: peerID)
|
||||
}
|
||||
#endif
|
||||
|
||||
deinit {
|
||||
stopRekeyTimer()
|
||||
@ -915,6 +1181,9 @@ struct NoiseMessage: Codable {
|
||||
enum NoiseEncryptionError: Error {
|
||||
case handshakeRequired
|
||||
case sessionNotEstablished
|
||||
/// Manager keys are established or restored, but BLE has not installed
|
||||
/// generation-bound transport state. No receive nonce was consumed.
|
||||
case transportGenerationNotReady
|
||||
/// Envelope references a prekey ID we don't hold (never ours, already
|
||||
/// deleted after its grace window, or wiped in a panic).
|
||||
case unknownPrekey
|
||||
|
||||
@ -9,8 +9,9 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
/// Disk persistence for processed gift-wrap event IDs. NIP-59 randomizes
|
||||
/// gift-wrap timestamps, so DM subscriptions must look back generously (24h)
|
||||
/// Disk persistence for processed private-envelope event IDs. BitChat
|
||||
/// randomizes envelope timestamps, so DM subscriptions must look back
|
||||
/// generously (24h)
|
||||
/// and relays redeliver the same events on every launch — without a
|
||||
/// cross-launch record, each relaunch reprocesses old PMs and acks
|
||||
/// (re-sent DELIVERED bursts, "delivered ack for unknown mid" noise).
|
||||
|
||||
44
bitchat/Services/NotificationPrivacySettings.swift
Normal file
44
bitchat/Services/NotificationPrivacySettings.swift
Normal file
@ -0,0 +1,44 @@
|
||||
//
|
||||
// NotificationPrivacySettings.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Controls how much a delivered notification says while the device is locked.
|
||||
///
|
||||
/// Notification content is rendered by the system on the lock screen, so it is
|
||||
/// readable by anyone holding the phone without unlocking it. With previews
|
||||
/// hidden, alerts still say that something arrived and stay tappable, but the
|
||||
/// message body, the sender's nickname, and the geohash are withheld until the
|
||||
/// app is opened.
|
||||
///
|
||||
/// Defaults to hidden: a locked phone lying on a table or taken at a protest
|
||||
/// should not narrate conversations, and someone who wants previews can say so.
|
||||
enum NotificationPrivacySettings {
|
||||
private static let hidePreviewsKey = "notifications.hideMessagePreviews"
|
||||
|
||||
static var hideMessagePreviews: Bool {
|
||||
get { hideMessagePreviews(in: .standard) }
|
||||
set { setHideMessagePreviews(newValue, in: .standard) }
|
||||
}
|
||||
|
||||
/// Store-injecting forms, so tests can assert the default and both settings
|
||||
/// without touching the shared preferences other tests read.
|
||||
static func hideMessagePreviews(in defaults: UserDefaults) -> Bool {
|
||||
defaults.object(forKey: hidePreviewsKey) as? Bool ?? true
|
||||
}
|
||||
|
||||
static func setHideMessagePreviews(_ hide: Bool, in defaults: UserDefaults) {
|
||||
defaults.set(hide, forKey: hidePreviewsKey)
|
||||
}
|
||||
|
||||
/// Panic-wipe hook. Removing the key restores the hidden default, so a
|
||||
/// wiped device cannot come back louder than a fresh install.
|
||||
static func reset(in defaults: UserDefaults = .standard) {
|
||||
defaults.removeObject(forKey: hidePreviewsKey)
|
||||
}
|
||||
}
|
||||
@ -95,6 +95,35 @@ final class NotificationService {
|
||||
static let nearbyCategoryID = "chat.bitchat.category.nearby"
|
||||
static let waveActionID = "chat.bitchat.action.wave"
|
||||
|
||||
/// Copy used when `NotificationPrivacySettings.hideMessagePreviews` is on.
|
||||
/// These say that something arrived without naming who sent it, quoting it,
|
||||
/// or disclosing which geohash it came from.
|
||||
private enum Redacted {
|
||||
static var directMessageTitle: String {
|
||||
String(localized: "notification.redacted.dm.title", defaultValue: "🔒 new dm", comment: "Lock-screen notification title for a received direct message when message previews are hidden; deliberately names neither the sender nor the content")
|
||||
}
|
||||
static var mentionTitle: String {
|
||||
String(localized: "notification.redacted.mention.title", defaultValue: "🫵 you were mentioned", comment: "Lock-screen notification title telling someone they were mentioned when message previews are hidden; deliberately omits who mentioned them")
|
||||
}
|
||||
static var geohashActivityTitle: String {
|
||||
String(localized: "notification.redacted.geohash.title", defaultValue: "📍 new activity nearby", comment: "Lock-screen notification title for activity in a location channel when message previews are hidden; deliberately omits the geohash")
|
||||
}
|
||||
static var body: String {
|
||||
String(localized: "notification.redacted.body", defaultValue: "open bitchat to read", comment: "Lock-screen notification body shown in place of the message text when message previews are hidden")
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether delivered alerts must withhold sender, content, and geohash.
|
||||
///
|
||||
/// Injected rather than read from the preference directly so tests state
|
||||
/// which behavior they are asserting instead of inheriting whatever the
|
||||
/// shared preference happens to hold when they run.
|
||||
private let hidePreviewsProvider: () -> Bool
|
||||
|
||||
private var hidePreviews: Bool {
|
||||
hidePreviewsProvider()
|
||||
}
|
||||
|
||||
private let isRunningTestsProvider: () -> Bool
|
||||
private let authorizer: NotificationAuthorizing
|
||||
private let requestDeliverer: NotificationRequestDelivering
|
||||
@ -106,6 +135,7 @@ final class NotificationService {
|
||||
}
|
||||
|
||||
private init() {
|
||||
self.hidePreviewsProvider = { NotificationPrivacySettings.hideMessagePreviews }
|
||||
self.isRunningTestsProvider = {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
return NSClassFromString("XCTestCase") != nil ||
|
||||
@ -130,12 +160,14 @@ final class NotificationService {
|
||||
isRunningTestsProvider: @escaping () -> Bool,
|
||||
authorizer: NotificationAuthorizing,
|
||||
requestDeliverer: NotificationRequestDelivering,
|
||||
categoryRegistrar: NotificationCategoryRegistering = NoopNotificationCategoryRegistrar()
|
||||
categoryRegistrar: NotificationCategoryRegistering = NoopNotificationCategoryRegistrar(),
|
||||
hidePreviewsProvider: @escaping () -> Bool = { NotificationPrivacySettings.hideMessagePreviews }
|
||||
) {
|
||||
self.isRunningTestsProvider = isRunningTestsProvider
|
||||
self.authorizer = authorizer
|
||||
self.requestDeliverer = requestDeliverer
|
||||
self.categoryRegistrar = categoryRegistrar
|
||||
self.hidePreviewsProvider = hidePreviewsProvider
|
||||
}
|
||||
|
||||
func requestAuthorization() {
|
||||
@ -197,29 +229,34 @@ final class NotificationService {
|
||||
}
|
||||
|
||||
func sendMentionNotification(from sender: String, message: String) {
|
||||
let title = "🫵 you were mentioned by \(sender)"
|
||||
let body = message
|
||||
let title = hidePreviews ? Redacted.mentionTitle : "🫵 you were mentioned by \(sender)"
|
||||
let body = hidePreviews ? Redacted.body : message
|
||||
let identifier = "mention-\(UUID().uuidString)"
|
||||
|
||||
|
||||
sendLocalNotification(title: title, body: body, identifier: identifier)
|
||||
}
|
||||
|
||||
|
||||
func sendPrivateMessageNotification(from sender: String, message: String, peerID: PeerID) {
|
||||
let title = "🔒 DM from \(sender)"
|
||||
let body = message
|
||||
let title = hidePreviews ? Redacted.directMessageTitle : "🔒 DM from \(sender)"
|
||||
let body = hidePreviews ? Redacted.body : message
|
||||
let identifier = "private-\(UUID().uuidString)"
|
||||
// Routing payload, not display copy: `userInfo` never reaches the lock
|
||||
// screen, and the conversation to open still has to be identifiable.
|
||||
let userInfo = ["peerID": peerID.id, "senderName": sender]
|
||||
|
||||
|
||||
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
|
||||
}
|
||||
|
||||
|
||||
// Geohash public chat notification with deep link to a specific geohash
|
||||
func sendGeohashActivityNotification(geohash: String, titlePrefix: String = "#", bodyPreview: String) {
|
||||
let title = "\(titlePrefix)\(geohash)"
|
||||
// The geohash itself is location data, so hiding previews withholds it
|
||||
// from the alert while leaving the deep link intact for the tap.
|
||||
let title = hidePreviews ? Redacted.geohashActivityTitle : "\(titlePrefix)\(geohash)"
|
||||
let body = hidePreviews ? Redacted.body : bodyPreview
|
||||
let identifier = "geo-activity-\(geohash)-\(Date().timeIntervalSince1970)"
|
||||
let deeplink = "bitchat://geohash/\(geohash)"
|
||||
let userInfo: [String: Any] = ["deeplink": deeplink]
|
||||
sendLocalNotification(title: title, body: bodyPreview, identifier: identifier, userInfo: userInfo)
|
||||
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
|
||||
}
|
||||
|
||||
func sendNetworkAvailableNotification(peerCount: Int) {
|
||||
|
||||
@ -203,14 +203,12 @@ final class PrivateChatManager: ObservableObject {
|
||||
func syncReadReceiptsForSentMessages(peerID: PeerID, nickname: String, externalReceipts: inout Set<String>) {
|
||||
for message in messages(for: peerID) {
|
||||
if message.sender == nickname {
|
||||
if let status = message.deliveryStatus {
|
||||
switch status {
|
||||
case .read, .delivered:
|
||||
externalReceipts.insert(message.id)
|
||||
sentReadReceipts.insert(message.id)
|
||||
case .failed, .partiallyDelivered, .sending, .sent, .carried:
|
||||
break
|
||||
}
|
||||
switch message.deliveryStatus {
|
||||
case .read, .delivered:
|
||||
externalReceipts.insert(message.id)
|
||||
sentReadReceipts.insert(message.id)
|
||||
case .notSentYet, .failed, .partiallyDelivered, .sending, .sent, .carried:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
212
bitchat/Services/SharedContentHandoff.swift
Normal file
212
bitchat/Services/SharedContentHandoff.swift
Normal file
@ -0,0 +1,212 @@
|
||||
import Foundation
|
||||
|
||||
enum SharedContentKind: String, Codable, Sendable, Equatable {
|
||||
case text
|
||||
case url
|
||||
}
|
||||
|
||||
/// The single, bounded payload handed from the share extension to the app.
|
||||
///
|
||||
/// The app-group store intentionally contains at most one envelope. A newer
|
||||
/// share replaces an older one, which prevents unbounded shared-container
|
||||
/// growth while still surviving suspension and a later app launch.
|
||||
struct SharedContentPayload: Codable, Sendable, Equatable, Identifiable {
|
||||
static let currentVersion = 1
|
||||
static let maxContentBytes = 16_000
|
||||
static let maxTitleBytes = 512
|
||||
static let maxEnvelopeBytes = 24_000
|
||||
static let retentionSeconds: TimeInterval = 24 * 60 * 60
|
||||
static let allowedFutureSkewSeconds: TimeInterval = 5 * 60
|
||||
|
||||
let version: Int
|
||||
let id: UUID
|
||||
let kind: SharedContentKind
|
||||
let content: String
|
||||
let title: String?
|
||||
let createdAt: Date
|
||||
|
||||
init(
|
||||
version: Int = Self.currentVersion,
|
||||
id: UUID = UUID(),
|
||||
kind: SharedContentKind,
|
||||
content: String,
|
||||
title: String? = nil,
|
||||
createdAt: Date = Date()
|
||||
) {
|
||||
self.version = version
|
||||
self.id = id
|
||||
self.kind = kind
|
||||
self.content = content
|
||||
self.title = title
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
static func text(_ content: String, createdAt: Date = Date()) -> SharedContentPayload {
|
||||
SharedContentPayload(kind: .text, content: content, createdAt: createdAt)
|
||||
}
|
||||
|
||||
var composerText: String { content }
|
||||
|
||||
var preview: String {
|
||||
let normalized = content
|
||||
.replacingOccurrences(of: "\r\n", with: "\n")
|
||||
.replacingOccurrences(of: "\r", with: "\n")
|
||||
guard normalized.count > 240 else { return normalized }
|
||||
return String(normalized.prefix(240)) + "…"
|
||||
}
|
||||
|
||||
func validate(now: Date = Date()) throws {
|
||||
guard version == Self.currentVersion else {
|
||||
throw SharedContentHandoffError.unsupportedVersion
|
||||
}
|
||||
|
||||
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
throw SharedContentHandoffError.emptyContent
|
||||
}
|
||||
guard content.utf8.count <= Self.maxContentBytes else {
|
||||
throw SharedContentHandoffError.contentTooLarge
|
||||
}
|
||||
if let title {
|
||||
guard title.utf8.count <= Self.maxTitleBytes else {
|
||||
throw SharedContentHandoffError.titleTooLarge
|
||||
}
|
||||
guard !Self.containsDisallowedControl(in: title, allowsTextLayout: false) else {
|
||||
throw SharedContentHandoffError.invalidCharacters
|
||||
}
|
||||
}
|
||||
|
||||
let age = now.timeIntervalSince(createdAt)
|
||||
guard age >= -Self.allowedFutureSkewSeconds,
|
||||
age <= Self.retentionSeconds else {
|
||||
throw SharedContentHandoffError.expired
|
||||
}
|
||||
|
||||
switch kind {
|
||||
case .text:
|
||||
guard !Self.containsDisallowedControl(in: content, allowsTextLayout: true) else {
|
||||
throw SharedContentHandoffError.invalidCharacters
|
||||
}
|
||||
case .url:
|
||||
guard !Self.containsDisallowedControl(in: content, allowsTextLayout: false),
|
||||
let components = URLComponents(string: content),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
scheme == "http" || scheme == "https",
|
||||
components.host?.isEmpty == false else {
|
||||
throw SharedContentHandoffError.unsupportedURL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func containsDisallowedControl(
|
||||
in value: String,
|
||||
allowsTextLayout: Bool
|
||||
) -> Bool {
|
||||
value.unicodeScalars.contains { scalar in
|
||||
guard CharacterSet.controlCharacters.contains(scalar) else { return false }
|
||||
if allowsTextLayout, scalar == "\n" || scalar == "\r" || scalar == "\t" {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SharedContentHandoffError: Error, Equatable {
|
||||
case unsupportedVersion
|
||||
case emptyContent
|
||||
case contentTooLarge
|
||||
case titleTooLarge
|
||||
case invalidCharacters
|
||||
case expired
|
||||
case unsupportedURL
|
||||
case envelopeTooLarge
|
||||
case encodingFailed
|
||||
}
|
||||
|
||||
/// Durable, single-item app-group storage used by both the extension and app.
|
||||
final class SharedContentStore {
|
||||
static let storageKey = "sharedContentEnvelopeV1"
|
||||
|
||||
private static let legacyKeys = [
|
||||
"sharedContent",
|
||||
"sharedContentType",
|
||||
"sharedContentDate"
|
||||
]
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private let encoder: JSONEncoder
|
||||
private let decoder: JSONDecoder
|
||||
|
||||
init(defaults: UserDefaults) {
|
||||
self.defaults = defaults
|
||||
self.encoder = JSONEncoder()
|
||||
self.decoder = JSONDecoder()
|
||||
}
|
||||
|
||||
/// Replaces any older pending share with a validated, bounded envelope.
|
||||
func stage(_ payload: SharedContentPayload, now: Date = Date()) throws {
|
||||
try payload.validate(now: now)
|
||||
guard let encoded = try? encoder.encode(payload) else {
|
||||
throw SharedContentHandoffError.encodingFailed
|
||||
}
|
||||
guard encoded.count <= SharedContentPayload.maxEnvelopeBytes else {
|
||||
throw SharedContentHandoffError.envelopeTooLarge
|
||||
}
|
||||
|
||||
defaults.set(encoded, forKey: Self.storageKey)
|
||||
clearLegacyKeys()
|
||||
}
|
||||
|
||||
/// Reads the pending share without consuming it. Invalid and expired data
|
||||
/// is removed immediately so malformed app-group state cannot linger.
|
||||
func pending(now: Date = Date()) -> SharedContentPayload? {
|
||||
clearLegacyKeys()
|
||||
|
||||
guard let encoded = defaults.data(forKey: Self.storageKey) else { return nil }
|
||||
guard encoded.count <= SharedContentPayload.maxEnvelopeBytes,
|
||||
let payload = try? decoder.decode(SharedContentPayload.self, from: encoded) else {
|
||||
defaults.removeObject(forKey: Self.storageKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
try payload.validate(now: now)
|
||||
return payload
|
||||
} catch {
|
||||
defaults.removeObject(forKey: Self.storageKey)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Consumes only the envelope the user actually reviewed. If a newer share
|
||||
/// already replaced it, the newer content remains pending.
|
||||
func consume(id: UUID, now: Date = Date()) -> SharedContentPayload? {
|
||||
guard let payload = pending(now: now), payload.id == id else { return nil }
|
||||
defaults.removeObject(forKey: Self.storageKey)
|
||||
return payload
|
||||
}
|
||||
|
||||
/// Explicit cancellation has the same identity guard as consumption so it
|
||||
/// can never discard a newer share that arrived while a prompt was open.
|
||||
func discard(id: UUID) {
|
||||
guard let encoded = defaults.data(forKey: Self.storageKey),
|
||||
encoded.count <= SharedContentPayload.maxEnvelopeBytes,
|
||||
let payload = try? decoder.decode(SharedContentPayload.self, from: encoded),
|
||||
payload.id == id else {
|
||||
return
|
||||
}
|
||||
defaults.removeObject(forKey: Self.storageKey)
|
||||
}
|
||||
|
||||
func discardAll() {
|
||||
defaults.removeObject(forKey: Self.storageKey)
|
||||
clearLegacyKeys()
|
||||
}
|
||||
|
||||
private func clearLegacyKeys() {
|
||||
for key in Self.legacyKeys {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user