mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 07:45:20 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8e330777a | ||
|
|
91aba8b597 | ||
|
|
d9a6dbfca8 | ||
|
|
68f8f03ad8 | ||
|
|
48026991b2 | ||
|
|
ec795520ee | ||
|
|
40238c5e43 | ||
|
|
a31cd80027 | ||
|
|
974510ad9e | ||
|
|
5aee7f0f98 | ||
|
|
6054248765 | ||
|
|
5f7df63238 | ||
|
|
b081c98dba | ||
|
|
76d3b0f1ed | ||
|
|
aa3021c9ca | ||
|
|
ca18843bb0 | ||
|
|
593fd7d737 |
@@ -1,228 +1,42 @@
|
|||||||
name: Propose GeoRelay Data Update
|
name: Fetch GeoRelays Data
|
||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "0 6 * * 0"
|
- cron: '0 6 * * 0'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
# Default to read-only. The publishing job receives only the scopes required
|
|
||||||
# to push its branch and publish either a PR or a tracking issue.
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
concurrency:
|
|
||||||
group: georelay-data-update
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
env:
|
|
||||||
SOURCE_REPOSITORY: https://github.com/permissionlesstech/georelays.git
|
|
||||||
UPDATE_BRANCH: automation/georelay-data
|
|
||||||
TRACKING_ISSUE_TITLE: GeoRelay update awaiting pull request
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
propose-relay-data:
|
update-relay-data:
|
||||||
name: Validate and propose relay data
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
issues: write
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout reviewed base
|
- name: Checkout repository
|
||||||
# Pinned actions/checkout v5 so a mutable action tag cannot change the
|
uses: actions/checkout@v4
|
||||||
# code that receives this job's write-capable token.
|
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
|
|
||||||
with:
|
with:
|
||||||
ref: main
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
# Do not expose the write token to fetch/validation subprocesses.
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Test GeoRelay validator
|
- name: Fetch GeoRelays
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
||||||
python3 -m unittest discover -s scripts/tests -p "test_*.py" -v
|
mv nostr_relays.csv ./relays/online_relays_gps.csv
|
||||||
|
|
||||||
- name: Fetch candidate over pinned HTTPS policy
|
- name: Check for changes
|
||||||
id: upstream
|
id: git-check
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT
|
||||||
source_commit=$(git ls-remote --refs "$SOURCE_REPOSITORY" refs/heads/main | awk 'NR == 1 { print $1 }')
|
|
||||||
if [[ ! "$source_commit" =~ ^[0-9a-f]{40}$ ]]; then
|
- name: Commit and push changes
|
||||||
echo "::error::Could not resolve an immutable upstream commit"
|
if: steps.git-check.outputs.changes == 'true'
|
||||||
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: |
|
run: |
|
||||||
set -euo pipefail
|
git config --local user.email "action@github.com"
|
||||||
python3 scripts/validate_georelays.py --input "$RUNNER_TEMP/georelays-candidate.csv" --baseline relays/online_relays_gps.csv --output relays/online_relays_gps.csv --github-output "$GITHUB_OUTPUT"
|
git config --local user.name "GitHub Action"
|
||||||
|
git add relays/online_relays_gps.csv
|
||||||
- name: Check for a reviewed-file change
|
git commit -m "Automated update of relay data - $(date -u)"
|
||||||
id: changes
|
git push
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if git diff --quiet -- relays/online_relays_gps.csv; then
|
|
||||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Upstream GeoRelay data already matches main." >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
else
|
|
||||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
|
||||||
git diff --stat -- relays/online_relays_gps.csv
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Push automation branch and publish review request
|
|
||||||
if: steps.changes.outputs.changed == 'true'
|
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
SOURCE_COMMIT: ${{ steps.upstream.outputs.source_commit }}
|
|
||||||
SOURCE_URL: ${{ steps.upstream.outputs.source_url }}
|
|
||||||
DATA_ROWS: ${{ steps.validation.outputs.data_rows }}
|
|
||||||
UNIQUE_RELAYS: ${{ steps.validation.outputs.unique_relays }}
|
|
||||||
DATA_SHA256: ${{ steps.validation.outputs.sha256 }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Scope credential exposure to this final publishing step.
|
|
||||||
gh auth setup-git
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
||||||
|
|
||||||
git switch -C "$UPDATE_BRANCH"
|
|
||||||
git add -- relays/online_relays_gps.csv
|
|
||||||
git diff --cached --quiet && {
|
|
||||||
echo "::error::Expected a staged GeoRelay data change"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
git commit -m "Update reviewed georelay directory" -m "Upstream-commit: $SOURCE_COMMIT"
|
|
||||||
|
|
||||||
remote_ref="refs/remotes/origin/$UPDATE_BRANCH"
|
|
||||||
if git fetch --no-tags origin "+refs/heads/$UPDATE_BRANCH:$remote_ref" 2>/dev/null; then
|
|
||||||
remote_sha=$(git rev-parse "$remote_ref")
|
|
||||||
git push --force-with-lease="refs/heads/$UPDATE_BRANCH:$remote_sha" origin "HEAD:refs/heads/$UPDATE_BRANCH"
|
|
||||||
else
|
|
||||||
git push origin "HEAD:refs/heads/$UPDATE_BRANCH"
|
|
||||||
fi
|
|
||||||
|
|
||||||
body_file="$RUNNER_TEMP/georelay-pr-body.md"
|
|
||||||
{
|
|
||||||
echo "## Automated GeoRelay data proposal"
|
|
||||||
echo
|
|
||||||
echo "- Source: $SOURCE_URL"
|
|
||||||
echo "- Upstream commit: $SOURCE_COMMIT"
|
|
||||||
echo "- Data rows: $DATA_ROWS"
|
|
||||||
echo "- Unique normalized relays: $UNIQUE_RELAYS"
|
|
||||||
echo "- SHA-256: $DATA_SHA256"
|
|
||||||
echo
|
|
||||||
echo "The candidate passed strict UTF-8, schema, size, row-count, secure-host, coordinate, duplicate-conflict, and baseline-delta validation."
|
|
||||||
echo
|
|
||||||
echo "This PR is intentionally not auto-merged. Review the relay additions/removals before merging."
|
|
||||||
} > "$body_file"
|
|
||||||
|
|
||||||
existing_pr=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --base main --head "$UPDATE_BRANCH" --json number --jq '.[0].number // empty')
|
|
||||||
pr_error="$RUNNER_TEMP/georelay-pr-error.txt"
|
|
||||||
pr_url=""
|
|
||||||
if [[ -n "$existing_pr" ]]; then
|
|
||||||
if gh pr edit "$existing_pr" --repo "$GITHUB_REPOSITORY" --title "Update reviewed GeoRelay directory" --body-file "$body_file" 2> "$pr_error"; then
|
|
||||||
pr_url=$(gh pr view "$existing_pr" --repo "$GITHUB_REPOSITORY" --json url --jq .url)
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
if created_pr_url=$(gh pr create --repo "$GITHUB_REPOSITORY" --base main --head "$UPDATE_BRANCH" --title "Update reviewed GeoRelay directory" --body-file "$body_file" 2> "$pr_error"); then
|
|
||||||
pr_url="$created_pr_url"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
tracking_issue_numbers=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "\"$TRACKING_ISSUE_TITLE\" in:title" --limit 100 --json number,title --jq ".[] | select(.title == \"$TRACKING_ISSUE_TITLE\") | .number")
|
|
||||||
tracking_issues=()
|
|
||||||
if [[ -n "$tracking_issue_numbers" ]]; then
|
|
||||||
mapfile -t tracking_issues <<< "$tracking_issue_numbers"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "$pr_url" ]]; then
|
|
||||||
for issue_number in "${tracking_issues[@]}"; do
|
|
||||||
gh issue close "$issue_number" --repo "$GITHUB_REPOSITORY" --comment "A pull request is now available at $pr_url; closing this fallback tracking issue."
|
|
||||||
done
|
|
||||||
echo "Published GeoRelay review PR: $pr_url" >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "::warning::GITHUB_TOKEN could not create or update the GeoRelay pull request; publishing the issues-write fallback."
|
|
||||||
if [[ -s "$pr_error" ]]; then
|
|
||||||
cat "$pr_error" >&2
|
|
||||||
fi
|
|
||||||
|
|
||||||
compare_url="https://github.com/${GITHUB_REPOSITORY}/compare/main...${UPDATE_BRANCH}?expand=1"
|
|
||||||
issue_body_file="$RUNNER_TEMP/georelay-tracking-issue-body.md"
|
|
||||||
{
|
|
||||||
echo "## Validated GeoRelay update awaiting review"
|
|
||||||
echo
|
|
||||||
echo "The automation branch was updated, but this workflow token could not create or update the pull request. Use the compare link below to create it manually."
|
|
||||||
echo
|
|
||||||
echo "- Compare and create PR: $compare_url"
|
|
||||||
echo "- Automation branch: $UPDATE_BRANCH"
|
|
||||||
echo "- Source: $SOURCE_URL"
|
|
||||||
echo "- Upstream commit: $SOURCE_COMMIT"
|
|
||||||
echo "- Data rows: $DATA_ROWS"
|
|
||||||
echo "- Unique normalized relays: $UNIQUE_RELAYS"
|
|
||||||
echo "- SHA-256: $DATA_SHA256"
|
|
||||||
echo
|
|
||||||
echo "The snapshot passed the repository's strict validator before the branch was pushed."
|
|
||||||
} > "$issue_body_file"
|
|
||||||
|
|
||||||
if (( ${#tracking_issues[@]} > 0 )); then
|
|
||||||
primary_issue="${tracking_issues[0]}"
|
|
||||||
gh issue edit "$primary_issue" --repo "$GITHUB_REPOSITORY" --title "$TRACKING_ISSUE_TITLE" --body-file "$issue_body_file"
|
|
||||||
issue_url=$(gh issue view "$primary_issue" --repo "$GITHUB_REPOSITORY" --json url --jq .url)
|
|
||||||
for duplicate_issue in "${tracking_issues[@]:1}"; do
|
|
||||||
gh issue close "$duplicate_issue" --repo "$GITHUB_REPOSITORY" --comment "Closing duplicate GeoRelay automation tracking issue; #$primary_issue is canonical."
|
|
||||||
done
|
|
||||||
else
|
|
||||||
issue_url=$(gh issue create --repo "$GITHUB_REPOSITORY" --title "$TRACKING_ISSUE_TITLE" --body-file "$issue_body_file")
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Do not claim success until the fallback issue was confirmed.
|
|
||||||
[[ -n "$issue_url" ]]
|
|
||||||
echo "Published GeoRelay tracking issue fallback: $issue_url" >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
|
|
||||||
- name: Clean obsolete automation review state
|
|
||||||
if: steps.changes.outputs.changed == 'false'
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
gh auth setup-git
|
|
||||||
|
|
||||||
existing_pr=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --base main --head "$UPDATE_BRANCH" --json number --jq '.[0].number // empty')
|
|
||||||
if [[ -n "$existing_pr" ]]; then
|
|
||||||
gh pr close "$existing_pr" --repo "$GITHUB_REPOSITORY" --comment "Upstream now matches the reviewed file on main; closing this obsolete automation proposal."
|
|
||||||
echo "Closed obsolete PR #$existing_pr." >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
fi
|
|
||||||
|
|
||||||
tracking_issue_numbers=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "\"$TRACKING_ISSUE_TITLE\" in:title" --limit 100 --json number,title --jq ".[] | select(.title == \"$TRACKING_ISSUE_TITLE\") | .number")
|
|
||||||
if [[ -n "$tracking_issue_numbers" ]]; then
|
|
||||||
while IFS= read -r issue_number; do
|
|
||||||
gh issue close "$issue_number" --repo "$GITHUB_REPOSITORY" --comment "Upstream now matches the reviewed file on main; closing this obsolete automation tracker."
|
|
||||||
echo "Closed obsolete tracking issue #$issue_number." >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
done <<< "$tracking_issue_numbers"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if git ls-remote --exit-code --heads origin "refs/heads/$UPDATE_BRANCH" > /dev/null; then
|
|
||||||
git push origin --delete "$UPDATE_BRANCH"
|
|
||||||
echo "Deleted obsolete automation branch $UPDATE_BRANCH." >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
else
|
|
||||||
ls_remote_status=$?
|
|
||||||
if (( ls_remote_status != 2 )); then
|
|
||||||
echo "::error::Could not inspect the obsolete automation branch"
|
|
||||||
exit "$ls_remote_status"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
+3
-3
@@ -17,8 +17,8 @@ bitchat is designed for private, account-free communication. This policy describ
|
|||||||
|
|
||||||
1. **Identity and cryptographic keys**
|
1. **Identity and cryptographic keys**
|
||||||
- Noise, signing, group, prekey, and optional Nostr identity material is generated locally.
|
- 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.
|
- 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, erased with panic wipe, or removed with the app.
|
- 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**
|
2. **Nickname, preferences, and relationships**
|
||||||
- Your nickname, settings, favorites, petnames, read-receipt identifiers, and bounded operational metadata are stored locally.
|
- Your nickname, settings, favorites, petnames, read-receipt identifiers, and bounded operational metadata are stored locally.
|
||||||
@@ -121,7 +121,7 @@ No cryptographic system can protect content after a recipient reads, copies, scr
|
|||||||
|
|
||||||
## Your Controls
|
## 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.
|
||||||
- **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.
|
- **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.
|
- **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.
|
- **No account:** The project operates no account record for you to request or export.
|
||||||
|
|||||||
Generated
+1
@@ -337,6 +337,7 @@
|
|||||||
es,
|
es,
|
||||||
ar,
|
ar,
|
||||||
de,
|
de,
|
||||||
|
fa,
|
||||||
fr,
|
fr,
|
||||||
he,
|
he,
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ final class AppChromeModel: ObservableObject {
|
|||||||
|
|
||||||
private let chatViewModel: ChatViewModel
|
private let chatViewModel: ChatViewModel
|
||||||
private var cancellables = Set<AnyCancellable>()
|
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.
|
/// Bulletin-board coordinator, created on first use of the board sheet.
|
||||||
private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService)
|
private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService)
|
||||||
@@ -97,7 +100,12 @@ final class AppChromeModel: ObservableObject {
|
|||||||
showScreenshotPrivacyWarning = true
|
showScreenshotPrivacyWarning = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setPanicPreparation(_ preparation: (@MainActor () -> Void)?) {
|
||||||
|
prepareForPanic = preparation
|
||||||
|
}
|
||||||
|
|
||||||
func panicClearAllData() {
|
func panicClearAllData() {
|
||||||
|
prepareForPanic?()
|
||||||
chatViewModel.panicClearAllData()
|
chatViewModel.panicClearAllData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -107,12 +107,15 @@ final class AppRuntime: ObservableObject {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
GeoRelayDirectory.shared.prefetchIfNeeded()
|
if chatViewModel.networkActivationAllowed {
|
||||||
|
GeoRelayDirectory.shared.prefetchIfNeeded()
|
||||||
|
}
|
||||||
bindRuntimeObservers()
|
bindRuntimeObservers()
|
||||||
NotificationDelegate.shared.runtime = self
|
NotificationDelegate.shared.runtime = self
|
||||||
}
|
}
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
|
guard chatViewModel.networkActivationAllowed else { return }
|
||||||
guard !started else {
|
guard !started else {
|
||||||
checkForSharedContent()
|
checkForSharedContent()
|
||||||
return
|
return
|
||||||
@@ -151,12 +154,14 @@ final class AppRuntime: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleDidBecomeActiveNotification() {
|
func handleDidBecomeActiveNotification() {
|
||||||
|
guard chatViewModel.networkActivationAllowed else { return }
|
||||||
chatViewModel.handleDidBecomeActive()
|
chatViewModel.handleDidBecomeActive()
|
||||||
checkForSharedContent()
|
checkForSharedContent()
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
func handleMacDidBecomeActiveNotification() {
|
func handleMacDidBecomeActiveNotification() {
|
||||||
|
guard chatViewModel.networkActivationAllowed else { return }
|
||||||
record(.scenePhaseChanged(.active))
|
record(.scenePhaseChanged(.active))
|
||||||
chatViewModel.handleDidBecomeActive()
|
chatViewModel.handleDidBecomeActive()
|
||||||
checkForSharedContent()
|
checkForSharedContent()
|
||||||
@@ -175,6 +180,7 @@ final class AppRuntime: ObservableObject {
|
|||||||
didEnterBackground = true
|
didEnterBackground = true
|
||||||
|
|
||||||
case .active:
|
case .active:
|
||||||
|
guard chatViewModel.networkActivationAllowed else { return }
|
||||||
record(.scenePhaseChanged(.active))
|
record(.scenePhaseChanged(.active))
|
||||||
chatViewModel.meshService.startServices()
|
chatViewModel.meshService.startServices()
|
||||||
TorManager.shared.setAppForeground(true)
|
TorManager.shared.setAppForeground(true)
|
||||||
@@ -222,6 +228,7 @@ final class AppRuntime: ObservableObject {
|
|||||||
actionIdentifier: String = UNNotificationDefaultActionIdentifier,
|
actionIdentifier: String = UNNotificationDefaultActionIdentifier,
|
||||||
userInfo: [AnyHashable: Any]
|
userInfo: [AnyHashable: Any]
|
||||||
) {
|
) {
|
||||||
|
guard chatViewModel.networkActivationAllowed else { return }
|
||||||
if actionIdentifier == NotificationService.waveActionID {
|
if actionIdentifier == NotificationService.waveActionID {
|
||||||
chatViewModel.sendMeshWave()
|
chatViewModel.sendMeshWave()
|
||||||
return
|
return
|
||||||
@@ -273,6 +280,8 @@ private extension AppRuntime {
|
|||||||
NotificationCenter.default.publisher(for: .TorWillRestart)
|
NotificationCenter.default.publisher(for: .TorWillRestart)
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
|
guard self?.chatViewModel.networkActivationAllowed == true
|
||||||
|
else { return }
|
||||||
self?.record(.torLifecycleChanged(.willRestart))
|
self?.record(.torLifecycleChanged(.willRestart))
|
||||||
self?.chatViewModel.handleTorWillRestart()
|
self?.chatViewModel.handleTorWillRestart()
|
||||||
}
|
}
|
||||||
@@ -281,6 +290,8 @@ private extension AppRuntime {
|
|||||||
NotificationCenter.default.publisher(for: .TorDidBecomeReady)
|
NotificationCenter.default.publisher(for: .TorDidBecomeReady)
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
|
guard self?.chatViewModel.networkActivationAllowed == true
|
||||||
|
else { return }
|
||||||
self?.record(.torLifecycleChanged(.didBecomeReady))
|
self?.record(.torLifecycleChanged(.didBecomeReady))
|
||||||
self?.chatViewModel.handleTorDidBecomeReady()
|
self?.chatViewModel.handleTorDidBecomeReady()
|
||||||
}
|
}
|
||||||
@@ -289,6 +300,8 @@ private extension AppRuntime {
|
|||||||
NotificationCenter.default.publisher(for: .TorWillStart)
|
NotificationCenter.default.publisher(for: .TorWillStart)
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
|
guard self?.chatViewModel.networkActivationAllowed == true
|
||||||
|
else { return }
|
||||||
self?.record(.torLifecycleChanged(.willStart))
|
self?.record(.torLifecycleChanged(.willStart))
|
||||||
self?.chatViewModel.handleTorWillStart()
|
self?.chatViewModel.handleTorWillStart()
|
||||||
}
|
}
|
||||||
@@ -297,6 +310,8 @@ private extension AppRuntime {
|
|||||||
NotificationCenter.default.publisher(for: .TorUserPreferenceChanged)
|
NotificationCenter.default.publisher(for: .TorUserPreferenceChanged)
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] notification in
|
.sink { [weak self] notification in
|
||||||
|
guard self?.chatViewModel.networkActivationAllowed == true
|
||||||
|
else { return }
|
||||||
self?.record(.torLifecycleChanged(.preferenceChanged))
|
self?.record(.torLifecycleChanged(.preferenceChanged))
|
||||||
self?.chatViewModel.handleTorPreferenceChanged(notification)
|
self?.chatViewModel.handleTorPreferenceChanged(notification)
|
||||||
}
|
}
|
||||||
@@ -313,6 +328,7 @@ private extension AppRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func checkForSharedContent() {
|
func checkForSharedContent() {
|
||||||
|
guard chatViewModel.networkActivationAllowed else { return }
|
||||||
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else { return }
|
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else { return }
|
||||||
let clearSharedContent = {
|
let clearSharedContent = {
|
||||||
userDefaults.removeObject(forKey: "sharedContent")
|
userDefaults.removeObject(forKey: "sharedContent")
|
||||||
@@ -359,7 +375,9 @@ private extension AppRuntime {
|
|||||||
let becameConnected = isConnected && !lastNostrRelayConnectedState
|
let becameConnected = isConnected && !lastNostrRelayConnectedState
|
||||||
lastNostrRelayConnectedState = isConnected
|
lastNostrRelayConnectedState = isConnected
|
||||||
|
|
||||||
guard started, becameConnected else { return }
|
guard chatViewModel.networkActivationAllowed,
|
||||||
|
started,
|
||||||
|
becameConnected else { return }
|
||||||
|
|
||||||
let isInitialConnection = !didHandleInitialNostrConnection
|
let isInitialConnection = !didHandleInitialNostrConnection
|
||||||
didHandleInitialNostrConnection = true
|
didHandleInitialNostrConnection = true
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ final class ConversationUIModel: ObservableObject {
|
|||||||
@Published private(set) var currentNickname: String
|
@Published private(set) var currentNickname: String
|
||||||
@Published private(set) var isBatchingPublic = false
|
@Published private(set) var isBatchingPublic = false
|
||||||
@Published private(set) var canSendMediaInCurrentContext = true
|
@Published private(set) var canSendMediaInCurrentContext = true
|
||||||
|
@Published private(set) var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest?
|
||||||
/// Who is talking live in the public mesh channel right now (floor
|
/// Who is talking live in the public mesh channel right now (floor
|
||||||
/// courtesy: the composer mic tints "busy" while someone holds the floor).
|
/// courtesy: the composer mic tints "busy" while someone holds the floor).
|
||||||
@Published private(set) var activeLiveVoiceTalker: String?
|
@Published private(set) var activeLiveVoiceTalker: String?
|
||||||
@@ -153,6 +154,13 @@ final class ConversationUIModel: ObservableObject {
|
|||||||
chatViewModel.sendVoiceNote(at: url)
|
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
|
/// Capture backend for the mic gesture: live PTT when the current DM
|
||||||
/// peer can hear it now, classic voice note otherwise.
|
/// peer can hear it now, classic voice note otherwise.
|
||||||
func makeVoiceCaptureSession() -> VoiceCaptureSession {
|
func makeVoiceCaptureSession() -> VoiceCaptureSession {
|
||||||
@@ -193,6 +201,10 @@ final class ConversationUIModel: ObservableObject {
|
|||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.assign(to: &$activeLiveVoiceTalker)
|
.assign(to: &$activeLiveVoiceTalker)
|
||||||
|
|
||||||
|
chatViewModel.$legacyPrivateMediaConsentRequest
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$legacyPrivateMediaConsentRequest)
|
||||||
|
|
||||||
conversations.$activeChannel
|
conversations.$activeChannel
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] channel in
|
.sink { [weak self] channel in
|
||||||
|
|||||||
@@ -7,45 +7,146 @@ final class LocationPresenceStore: ObservableObject {
|
|||||||
@Published private(set) var geoNicknames: [String: String] = [:]
|
@Published private(set) var geoNicknames: [String: String] = [:]
|
||||||
@Published private(set) var teleportedGeo: Set<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?) {
|
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) {
|
func setNickname(_ nickname: String, for pubkeyHex: String) {
|
||||||
geoNicknames[pubkeyHex.lowercased()] = nickname
|
guard geoNicknameCapacity > 0 else {
|
||||||
|
clearGeoNicknames()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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]) {
|
func replaceGeoNicknames(_ nicknames: [String: String]) {
|
||||||
geoNicknames = Dictionary(
|
guard geoNicknameCapacity > 0 else {
|
||||||
uniqueKeysWithValues: nicknames.map { key, value in
|
clearGeoNicknames()
|
||||||
(key.lowercased(), value)
|
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
|
||||||
|
}
|
||||||
|
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() {
|
func clearGeoNicknames() {
|
||||||
geoNicknames.removeAll()
|
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) {
|
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) {
|
func clearTeleported(_ pubkeyHex: String) {
|
||||||
teleportedGeo.remove(pubkeyHex.lowercased())
|
let key = pubkeyHex.lowercased()
|
||||||
|
teleportedGeo.remove(key)
|
||||||
|
teleportedGeoOrder.removeAll { $0 == key }
|
||||||
}
|
}
|
||||||
|
|
||||||
func replaceTeleportedGeo(_ pubkeys: Set<String>) {
|
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() {
|
func clearTeleportedGeo() {
|
||||||
teleportedGeo.removeAll()
|
teleportedGeo.removeAll()
|
||||||
|
teleportedGeoOrder.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
func reset() {
|
func reset() {
|
||||||
currentGeohash = nil
|
currentGeohash = nil
|
||||||
geoNicknames.removeAll()
|
geoNicknames.removeAll()
|
||||||
|
geoNicknameOrder.removeAll()
|
||||||
teleportedGeo.removeAll()
|
teleportedGeo.removeAll()
|
||||||
|
teleportedGeoOrder.removeAll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ protocol VoiceCaptureSession: AnyObject {
|
|||||||
/// nothing valid was captured.
|
/// nothing valid was captured.
|
||||||
func finish() async -> URL?
|
func finish() async -> URL?
|
||||||
func cancel() async
|
func cancel() async
|
||||||
|
/// Stops capture and suppresses every later send before returning.
|
||||||
|
func panicCancelSynchronously()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The classic record-then-send backend, wrapping the shared `VoiceRecorder`.
|
/// The classic record-then-send backend, wrapping the shared `VoiceRecorder`.
|
||||||
@@ -55,6 +57,10 @@ final class VoiceNoteCaptureSession: VoiceCaptureSession {
|
|||||||
func cancel() async {
|
func cancel() async {
|
||||||
await recorder.cancelRecording(owner: owner)
|
await recorder.cancelRecording(owner: owner)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func panicCancelSynchronously() {
|
||||||
|
recorder.panicCancelSynchronously(owner: owner)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Testable surface of the live capture engine. Production uses
|
/// 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) {
|
private func sendControlPacket(_ kind: VoiceBurstPacket.Kind) {
|
||||||
guard let packet = VoiceBurstPacket(burstID: burstID, seq: stream.packetizer.nextSeq, kind: kind) else { return }
|
guard let packet = VoiceBurstPacket(burstID: burstID, seq: stream.packetizer.nextSeq, kind: kind) else { return }
|
||||||
sendPacket(packet.encode())
|
sendPacket(packet.encode())
|
||||||
|
|||||||
@@ -246,6 +246,21 @@ actor VoiceRecorder {
|
|||||||
currentURL = nil
|
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 audio session was interrupted (call, Siri) or reconfigured: stop
|
||||||
/// the recorder but keep `recorder`/`currentURL` so the caller's pending
|
/// the recorder but keep `recorder`/`currentURL` so the caller's pending
|
||||||
/// `stopRecording()` still returns the partial note.
|
/// `stopRecording()` still returns the partial note.
|
||||||
|
|||||||
@@ -189,6 +189,18 @@ struct IdentityCache: Codable {
|
|||||||
// Fingerprint -> when we verified it (orders outgoing vouch batches;
|
// Fingerprint -> when we verified it (orders outgoing vouch batches;
|
||||||
// entries verified before this field exists sort as oldest)
|
// entries verified before this field exists sort as oldest)
|
||||||
var verifiedAt: [String: Date]? = nil
|
var verifiedAt: [String: Date]? = nil
|
||||||
|
|
||||||
|
// Stable Noise fingerprints that proved encrypted private-media support
|
||||||
|
// inside an authenticated Noise session. Optional for decoding caches
|
||||||
|
// written before this migration. Entries are monotonic until a panic wipe
|
||||||
|
// so an old/replayed announce cannot silently downgrade a peer.
|
||||||
|
var privateMediaCapableFingerprints: Set<String>? = nil
|
||||||
|
|
||||||
|
// Noise-fingerprint -> Ed25519 announcement key, learned only from the
|
||||||
|
// authenticated peer-state payload. This prevents a self-signed announce
|
||||||
|
// containing a copied public Noise key from replacing a previously bound
|
||||||
|
// public-message signing identity. Optional for old cache compatibility.
|
||||||
|
var authenticatedSigningKeysByFingerprint: [String: Data]? = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -140,6 +140,14 @@ protocol SecureIdentityStateManagerProtocol {
|
|||||||
func markVouchBatchSent(to fingerprint: String, at date: Date)
|
func markVouchBatchSent(to fingerprint: String, at date: Date)
|
||||||
func signingPublicKey(forFingerprint fingerprint: String) -> Data?
|
func signingPublicKey(forFingerprint fingerprint: String) -> Data?
|
||||||
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String]
|
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.
|
/// Singleton manager for secure identity state persistence and retrieval.
|
||||||
@@ -157,6 +165,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
|
|
||||||
// Thread safety
|
// Thread safety
|
||||||
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
|
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`.
|
// Pending-save coalescing flag. Reads/writes are serialized on `queue`.
|
||||||
// Persistence is done with a fire-and-forget `queue.async(.barrier)` rather
|
// Persistence is done with a fire-and-forget `queue.async(.barrier)` rather
|
||||||
@@ -214,6 +223,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
|
|
||||||
self.encryptionKey = loadedKey
|
self.encryptionKey = loadedKey
|
||||||
self.encryptionKeyIsEphemeral = keyIsEphemeral
|
self.encryptionKeyIsEphemeral = keyIsEphemeral
|
||||||
|
queue.setSpecific(key: queueSpecificKey, value: 1)
|
||||||
|
|
||||||
// Only read the persisted cache when we hold the real key; with an
|
// Only read the persisted cache when we hold the real key; with an
|
||||||
// ephemeral key the decrypt would fail and discard the real cache.
|
// ephemeral key the decrypt would fail and discard the real cache.
|
||||||
@@ -370,6 +380,66 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
|
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Private-media downgrade protection
|
||||||
|
|
||||||
|
func markPrivateMediaCapable(fingerprint: String) {
|
||||||
|
guard !fingerprint.isEmpty else { return }
|
||||||
|
let insertAndPersist = {
|
||||||
|
var pinned = self.cache.privateMediaCapableFingerprints ?? []
|
||||||
|
guard pinned.insert(fingerprint).inserted else { return }
|
||||||
|
self.cache.privateMediaCapableFingerprints = pinned
|
||||||
|
self.saveIdentityCache()
|
||||||
|
}
|
||||||
|
// Downgrade decisions can run immediately after an authenticated
|
||||||
|
// announce. Make the pin visible before returning; merely enqueueing a
|
||||||
|
// barrier leaves a cross-queue window where a replay can look legacy.
|
||||||
|
// The queue-specific fast path prevents self-deadlock if a future
|
||||||
|
// identity-state mutation records the capability from inside `queue`.
|
||||||
|
if DispatchQueue.getSpecific(key: queueSpecificKey) != nil {
|
||||||
|
insertAndPersist()
|
||||||
|
} else {
|
||||||
|
queue.sync(flags: .barrier, execute: insertAndPersist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool {
|
||||||
|
guard !fingerprint.isEmpty else { return false }
|
||||||
|
return queue.sync {
|
||||||
|
cache.privateMediaCapableFingerprints?.contains(fingerprint) == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Noise-authenticated announcement identity
|
||||||
|
|
||||||
|
func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String) {
|
||||||
|
guard signingPublicKey.count == AuthenticatedPeerStatePacket.signingPublicKeyLength,
|
||||||
|
!fingerprint.isEmpty else { return }
|
||||||
|
let bindAndPersist = {
|
||||||
|
var bindings = self.cache.authenticatedSigningKeysByFingerprint ?? [:]
|
||||||
|
let bindingChanged = bindings[fingerprint] != signingPublicKey
|
||||||
|
bindings[fingerprint] = signingPublicKey
|
||||||
|
self.cache.authenticatedSigningKeysByFingerprint = bindings
|
||||||
|
if var cryptoIdentity = self.cryptographicIdentities[fingerprint] {
|
||||||
|
cryptoIdentity.signingPublicKey = signingPublicKey
|
||||||
|
self.cryptographicIdentities[fingerprint] = cryptoIdentity
|
||||||
|
}
|
||||||
|
guard bindingChanged else { return }
|
||||||
|
self.saveIdentityCache()
|
||||||
|
}
|
||||||
|
if DispatchQueue.getSpecific(key: queueSpecificKey) != nil {
|
||||||
|
bindAndPersist()
|
||||||
|
} else {
|
||||||
|
queue.sync(flags: .barrier, execute: bindAndPersist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data? {
|
||||||
|
guard !fingerprint.isEmpty else { return nil }
|
||||||
|
return queue.sync {
|
||||||
|
cache.authenticatedSigningKeysByFingerprint?[fingerprint]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func updateSocialIdentity(_ identity: SocialIdentity) {
|
func updateSocialIdentity(_ identity: SocialIdentity) {
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
|
|||||||
+3099
-1
File diff suppressed because it is too large
Load Diff
@@ -30,7 +30,7 @@ struct NoisePayload {
|
|||||||
|
|
||||||
// Safely get the first byte
|
// Safely get the first byte
|
||||||
let firstByte = data[data.startIndex]
|
let firstByte = data[data.startIndex]
|
||||||
guard let type = NoisePayloadType(rawValue: firstByte) else {
|
guard let type = NoisePayloadType.decoded(rawValue: firstByte) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,36 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum NoiseSecurityConstants {
|
enum NoiseSecurityConstants {
|
||||||
// Maximum message size to prevent memory exhaustion
|
// Maximum message size to prevent memory exhaustion
|
||||||
static let maxMessageSize = 65535 // 64KB as per Noise spec
|
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
|
// Maximum handshake message size
|
||||||
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
|
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
|
||||||
|
|
||||||
// Session timeout - sessions older than this should be renegotiated
|
// Session timeout - sessions older than this should be renegotiated
|
||||||
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
||||||
|
|||||||
@@ -14,6 +14,19 @@ struct NoiseSecurityValidator {
|
|||||||
static func validateMessageSize(_ data: Data) -> Bool {
|
static func validateMessageSize(_ data: Data) -> Bool {
|
||||||
return data.count <= NoiseSecurityConstants.maxMessageSize
|
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
|
/// Validate handshake message size
|
||||||
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
||||||
|
|||||||
@@ -66,7 +66,10 @@ class NoiseSession {
|
|||||||
|
|
||||||
// Only initiator writes the first message
|
// Only initiator writes the first message
|
||||||
if role == .initiator {
|
if role == .initiator {
|
||||||
let message = try handshakeState!.writeMessage()
|
guard let handshake = handshakeState else {
|
||||||
|
throw NoiseSessionError.invalidState
|
||||||
|
}
|
||||||
|
let message = try handshake.writeMessage()
|
||||||
sentHandshakeMessages.append(message)
|
sentHandshakeMessages.append(message)
|
||||||
return message
|
return message
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -11,4 +11,5 @@ enum NoiseSessionError: Error, Equatable {
|
|||||||
case notEstablished
|
case notEstablished
|
||||||
case sessionNotFound
|
case sessionNotFound
|
||||||
case alreadyEstablished
|
case alreadyEstablished
|
||||||
|
case peerIdentityMismatch
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,13 +11,27 @@ import CryptoKit
|
|||||||
import Foundation
|
import Foundation
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
|
|
||||||
|
struct NoiseHandshakeProcessingResult {
|
||||||
|
let response: Data?
|
||||||
|
let didEstablishAuthenticatedSession: Bool
|
||||||
|
}
|
||||||
|
|
||||||
final class NoiseSessionManager {
|
final class NoiseSessionManager {
|
||||||
private var sessions: [PeerID: NoiseSession] = [:]
|
private var sessions: [PeerID: NoiseSession] = [:]
|
||||||
|
/// Opaque identity for each exact entry in `sessions`. The generation is
|
||||||
|
/// created and removed under the same barrier as the session itself, so a
|
||||||
|
/// caller can never authenticate data with one session and lease another.
|
||||||
|
private var sessionGenerations: [PeerID: UUID] = [:]
|
||||||
|
/// A responder rehandshake must not evict a working transport session
|
||||||
|
/// before the candidate proves that its authenticated static key belongs
|
||||||
|
/// to the claimed wire ID. Candidates therefore live outside `sessions`
|
||||||
|
/// until the XX handshake completes and the binding is validated.
|
||||||
|
private var responderCandidates: [PeerID: NoiseSession] = [:]
|
||||||
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
|
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
|
||||||
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)?
|
var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey, UUID) -> Void)?
|
||||||
var onSessionFailed: ((PeerID, Error) -> Void)?
|
var onSessionFailed: ((PeerID, Error) -> Void)?
|
||||||
|
|
||||||
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
||||||
@@ -54,6 +68,10 @@ final class NoiseSessionManager {
|
|||||||
if let session = sessions.removeValue(forKey: peerID) {
|
if let session = sessions.removeValue(forKey: peerID) {
|
||||||
session.reset() // Clear sensitive data before removing
|
session.reset() // Clear sensitive data before removing
|
||||||
}
|
}
|
||||||
|
sessionGenerations.removeValue(forKey: peerID)
|
||||||
|
if let candidate = responderCandidates.removeValue(forKey: peerID) {
|
||||||
|
candidate.reset()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +80,12 @@ final class NoiseSessionManager {
|
|||||||
for (_, session) in sessions {
|
for (_, session) in sessions {
|
||||||
session.reset()
|
session.reset()
|
||||||
}
|
}
|
||||||
|
for (_, candidate) in responderCandidates {
|
||||||
|
candidate.reset()
|
||||||
|
}
|
||||||
sessions.removeAll()
|
sessions.removeAll()
|
||||||
|
sessionGenerations.removeAll()
|
||||||
|
responderCandidates.removeAll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,11 +102,14 @@ final class NoiseSessionManager {
|
|||||||
// Remove any existing non-established session
|
// Remove any existing non-established session
|
||||||
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
|
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
sessionGenerations.removeValue(forKey: peerID)
|
||||||
|
existingSession.reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new initiator session
|
// Create new initiator session
|
||||||
let session = sessionFactory(peerID, .initiator)
|
let session = sessionFactory(peerID, .initiator)
|
||||||
sessions[peerID] = session
|
sessions[peerID] = session
|
||||||
|
sessionGenerations[peerID] = UUID()
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let handshakeData = try session.startHandshake()
|
let handshakeData = try session.startHandshake()
|
||||||
@@ -91,6 +117,8 @@ final class NoiseSessionManager {
|
|||||||
} catch {
|
} catch {
|
||||||
// Clean up failed session
|
// Clean up failed session
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
sessionGenerations.removeValue(forKey: peerID)
|
||||||
|
session.reset()
|
||||||
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
|
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
@@ -98,61 +126,128 @@ final class NoiseSessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
|
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
|
||||||
// Process everything within the synchronized block to prevent race conditions
|
try handleIncomingHandshakeWithResult(
|
||||||
return try managerQueue.sync(flags: .barrier) {
|
from: peerID,
|
||||||
var shouldCreateNew = false
|
message: message
|
||||||
var existingSession: NoiseSession? = nil
|
).response
|
||||||
|
}
|
||||||
if let existing = sessions[peerID] {
|
|
||||||
// If we have an established session, the peer must have cleared their session
|
/// Processes one exact handshake candidate and reports whether that
|
||||||
// for a good reason (e.g., decryption failure, restart, etc.)
|
/// candidate completed authenticated establishment. The peer's retained
|
||||||
// We should accept the new handshake to re-establish encryption
|
/// session may already be established while a replacement is only on
|
||||||
if existing.isEstablished() {
|
/// message one, so callers must not infer candidate completion from the
|
||||||
SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session)
|
/// peer-level session table.
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
func handleIncomingHandshakeWithResult(
|
||||||
shouldCreateNew = true
|
from peerID: PeerID,
|
||||||
|
message: Data
|
||||||
|
) throws -> NoiseHandshakeProcessingResult {
|
||||||
|
// Process everything within the synchronized block to prevent race conditions.
|
||||||
|
// Return establishment metadata and publish the callback only after the
|
||||||
|
// manager barrier is released, avoiding both a deadlock and a window in
|
||||||
|
// which `processHandshakeMessage` returns before authentication state.
|
||||||
|
let result: (
|
||||||
|
response: Data?,
|
||||||
|
establishedSession: (
|
||||||
|
remoteKey: Curve25519.KeyAgreement.PublicKey,
|
||||||
|
generation: UUID
|
||||||
|
)?
|
||||||
|
) = try managerQueue.sync(flags: .barrier) {
|
||||||
|
let session: NoiseSession
|
||||||
|
let isReplacementCandidate: Bool
|
||||||
|
|
||||||
|
if let candidate = responderCandidates[peerID] {
|
||||||
|
// A fresh XX message 1 supersedes an incomplete candidate,
|
||||||
|
// but never the established session it is trying to replace.
|
||||||
|
if message.count == NoiseSecurityConstants.xxInitialMessageSize {
|
||||||
|
candidate.reset()
|
||||||
|
let replacement = sessionFactory(peerID, .responder)
|
||||||
|
responderCandidates[peerID] = replacement
|
||||||
|
session = replacement
|
||||||
} else {
|
} else {
|
||||||
// If we're in the middle of a handshake and receive a new initiation,
|
session = candidate
|
||||||
// reset and start fresh (the other side may have restarted)
|
}
|
||||||
if existing.getState() == .handshaking && message.count == 32 {
|
isReplacementCandidate = true
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
} else if let existing = sessions[peerID] {
|
||||||
shouldCreateNew = true
|
if existing.isEstablished() {
|
||||||
} else {
|
SecureLogger.info(
|
||||||
existingSession = existing
|
"Validating replacement handshake from \(peerID) while preserving the established session",
|
||||||
}
|
category: .session
|
||||||
|
)
|
||||||
|
let candidate = sessionFactory(peerID, .responder)
|
||||||
|
responderCandidates[peerID] = candidate
|
||||||
|
session = candidate
|
||||||
|
isReplacementCandidate = true
|
||||||
|
} else if existing.getState() == .handshaking,
|
||||||
|
message.count == NoiseSecurityConstants.xxInitialMessageSize {
|
||||||
|
// No established transport state exists to preserve. A
|
||||||
|
// fresh initiation replaces the incomplete handshake.
|
||||||
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
sessionGenerations.removeValue(forKey: peerID)
|
||||||
|
existing.reset()
|
||||||
|
let replacement = sessionFactory(peerID, .responder)
|
||||||
|
sessions[peerID] = replacement
|
||||||
|
sessionGenerations[peerID] = UUID()
|
||||||
|
session = replacement
|
||||||
|
isReplacementCandidate = false
|
||||||
|
} else {
|
||||||
|
session = existing
|
||||||
|
isReplacementCandidate = false
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
shouldCreateNew = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get or create session
|
|
||||||
let session: NoiseSession
|
|
||||||
if shouldCreateNew {
|
|
||||||
let newSession = sessionFactory(peerID, .responder)
|
let newSession = sessionFactory(peerID, .responder)
|
||||||
sessions[peerID] = newSession
|
sessions[peerID] = newSession
|
||||||
|
sessionGenerations[peerID] = UUID()
|
||||||
session = newSession
|
session = newSession
|
||||||
} else {
|
isReplacementCandidate = false
|
||||||
session = existingSession!
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process the handshake message within the synchronized block
|
// Process the handshake message within the synchronized block
|
||||||
do {
|
do {
|
||||||
let response = try session.processHandshakeMessage(message)
|
let response = try session.processHandshakeMessage(message)
|
||||||
|
|
||||||
// Check if session is established after processing
|
// Check the exact session that processed this message. A
|
||||||
|
// preserved peer-level session can remain established while a
|
||||||
|
// replacement candidate is still unauthenticated.
|
||||||
|
var establishedSession: (
|
||||||
|
remoteKey: Curve25519.KeyAgreement.PublicKey,
|
||||||
|
generation: UUID
|
||||||
|
)?
|
||||||
if session.isEstablished() {
|
if session.isEstablished() {
|
||||||
if let remoteKey = session.getRemoteStaticPublicKey() {
|
guard let remoteKey = session.getRemoteStaticPublicKey(),
|
||||||
// Schedule callback outside the synchronized block to prevent deadlock
|
authenticatedRemoteKey(remoteKey, matches: peerID) else {
|
||||||
DispatchQueue.global().async { [weak self] in
|
throw NoiseSessionError.peerIdentityMismatch
|
||||||
self?.onSessionEstablished?(peerID, remoteKey)
|
}
|
||||||
|
|
||||||
|
if isReplacementCandidate {
|
||||||
|
_ = responderCandidates.removeValue(forKey: peerID)
|
||||||
|
let previous = sessions.updateValue(session, forKey: peerID)
|
||||||
|
sessionGenerations[peerID] = UUID()
|
||||||
|
if let previous, previous !== session {
|
||||||
|
previous.reset()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
guard let generation = sessionGenerations[peerID] else {
|
||||||
|
throw NoiseEncryptionError.sessionNotEstablished
|
||||||
|
}
|
||||||
|
establishedSession = (remoteKey, generation)
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
return (response, establishedSession)
|
||||||
} catch {
|
} catch {
|
||||||
// Reset the session on handshake failure so next attempt can start fresh
|
// A failed candidate is discarded without touching the
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
// established session. Ordinary failed handshakes retain the
|
||||||
|
// historical cleanup behavior.
|
||||||
|
if isReplacementCandidate {
|
||||||
|
if let storedCandidate = responderCandidates[peerID],
|
||||||
|
storedCandidate === session {
|
||||||
|
_ = responderCandidates.removeValue(forKey: peerID)
|
||||||
|
}
|
||||||
|
} else if let storedSession = sessions[peerID],
|
||||||
|
storedSession === session {
|
||||||
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
sessionGenerations.removeValue(forKey: peerID)
|
||||||
|
}
|
||||||
|
session.reset()
|
||||||
|
|
||||||
// Schedule callback outside the synchronized block to prevent deadlock
|
// Schedule callback outside the synchronized block to prevent deadlock
|
||||||
DispatchQueue.global().async { [weak self] in
|
DispatchQueue.global().async { [weak self] in
|
||||||
@@ -163,24 +258,105 @@ final class NoiseSessionManager {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let established = result.establishedSession {
|
||||||
|
onSessionEstablished?(peerID, established.remoteKey, established.generation)
|
||||||
|
}
|
||||||
|
return NoiseHandshakeProcessingResult(
|
||||||
|
response: result.response,
|
||||||
|
didEstablishAuthenticatedSession: result.establishedSession != nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mesh handshakes normally use a 16-hex wire ID. Full Noise-key IDs are
|
||||||
|
/// also accepted by internal callers when they exactly match the static
|
||||||
|
/// key. Non-wire identifiers remain available to protocol test harnesses;
|
||||||
|
/// BLE packet ingress always supplies a short hexadecimal ID.
|
||||||
|
private func authenticatedRemoteKey(
|
||||||
|
_ remoteKey: Curve25519.KeyAgreement.PublicKey,
|
||||||
|
matches claimedPeerID: PeerID
|
||||||
|
) -> Bool {
|
||||||
|
let rawKey = remoteKey.rawRepresentation
|
||||||
|
if claimedPeerID.isShort {
|
||||||
|
return PeerID(publicKey: rawKey) == claimedPeerID
|
||||||
|
}
|
||||||
|
if let claimedNoiseKey = claimedPeerID.noiseKey {
|
||||||
|
return claimedNoiseKey == rawKey
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Encryption/Decryption
|
// MARK: - Encryption/Decryption
|
||||||
|
|
||||||
func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
|
func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
|
||||||
guard let session = getSession(for: peerID) else {
|
try managerQueue.sync {
|
||||||
throw NoiseSessionError.sessionNotFound
|
guard let session = sessions[peerID] else {
|
||||||
|
throw NoiseSessionError.sessionNotFound
|
||||||
|
}
|
||||||
|
return try session.encrypt(plaintext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypts only if `expected` still names the current established entry.
|
||||||
|
/// A rekey between capability proof and media encryption therefore fails
|
||||||
|
/// closed instead of sending on an unproven replacement session.
|
||||||
|
func encrypt(
|
||||||
|
_ plaintext: Data,
|
||||||
|
for peerID: PeerID,
|
||||||
|
expectedSessionGeneration expected: UUID
|
||||||
|
) throws -> Data {
|
||||||
|
try managerQueue.sync {
|
||||||
|
guard let session = sessions[peerID],
|
||||||
|
session.isEstablished(),
|
||||||
|
sessionGenerations[peerID] == expected else {
|
||||||
|
throw NoiseEncryptionError.sessionNotEstablished
|
||||||
|
}
|
||||||
|
return try session.encrypt(plaintext)
|
||||||
}
|
}
|
||||||
|
|
||||||
return try session.encrypt(plaintext)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data {
|
func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data {
|
||||||
guard let session = getSession(for: peerID) else {
|
try decryptWithSessionGeneration(ciphertext, from: peerID).plaintext
|
||||||
throw NoiseSessionError.sessionNotFound
|
}
|
||||||
|
|
||||||
|
func sessionGeneration(for peerID: PeerID) -> UUID? {
|
||||||
|
managerQueue.sync {
|
||||||
|
guard sessions[peerID]?.isEstablished() == true else { return nil }
|
||||||
|
return sessionGenerations[peerID]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypts while holding the manager's read lease. Session promotion and
|
||||||
|
/// removal require its barrier, so the returned generation always names
|
||||||
|
/// the exact session object that authenticated these bytes.
|
||||||
|
func decryptWithSessionGeneration(
|
||||||
|
_ ciphertext: Data,
|
||||||
|
from peerID: PeerID
|
||||||
|
) throws -> (plaintext: Data, sessionGeneration: UUID) {
|
||||||
|
try managerQueue.sync {
|
||||||
|
guard let session = sessions[peerID] else {
|
||||||
|
throw NoiseSessionError.sessionNotFound
|
||||||
|
}
|
||||||
|
guard session.isEstablished(),
|
||||||
|
let generation = sessionGenerations[peerID] else {
|
||||||
|
throw NoiseEncryptionError.sessionNotEstablished
|
||||||
|
}
|
||||||
|
return (try session.decrypt(ciphertext), generation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs a state commit under a read lease for the exact established
|
||||||
|
/// session. Rekey, replacement, and removal all need the same barrier.
|
||||||
|
func withCurrentSessionGeneration<Result>(
|
||||||
|
for peerID: PeerID,
|
||||||
|
expected: UUID,
|
||||||
|
_ body: () -> Result
|
||||||
|
) -> Result? {
|
||||||
|
managerQueue.sync {
|
||||||
|
guard sessions[peerID]?.isEstablished() == true,
|
||||||
|
sessionGenerations[peerID] == expected else { return nil }
|
||||||
|
return body()
|
||||||
}
|
}
|
||||||
|
|
||||||
return try session.decrypt(ciphertext)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Key Management
|
// MARK: - Key Management
|
||||||
@@ -207,11 +383,11 @@ final class NoiseSessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func initiateRekey(for peerID: PeerID) throws {
|
func initiateRekey(for peerID: PeerID) throws -> Data {
|
||||||
// Remove old session
|
// Remove old session
|
||||||
removeSession(for: peerID)
|
removeSession(for: peerID)
|
||||||
|
|
||||||
// Initiate new handshake
|
// Initiate new handshake
|
||||||
_ = try initiateHandshake(with: peerID)
|
return try initiateHandshake(with: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,8 +24,12 @@ final class SecureNoiseSession: NoiseSession {
|
|||||||
throw NoiseSecurityError.sessionExhausted
|
throw NoiseSecurityError.sessionExhausted
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate message size
|
// Ordinary Noise messages keep the protocol ceiling. Finalized media
|
||||||
guard NoiseSecurityValidator.validateMessageSize(plaintext) else {
|
// 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
|
throw NoiseSecurityError.messageTooLarge
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,8 +46,11 @@ final class SecureNoiseSession: NoiseSession {
|
|||||||
throw NoiseSecurityError.sessionExpired
|
throw NoiseSecurityError.sessionExpired
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate message size
|
// The payload type is encrypted, so a large candidate can only be
|
||||||
guard NoiseSecurityValidator.validateMessageSize(ciphertext) else {
|
// 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
|
throw NoiseSecurityError.messageTooLarge
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,23 +32,6 @@ struct GeoRelayDirectoryDependencies {
|
|||||||
var retrySleep: (TimeInterval) async -> Void
|
var retrySleep: (TimeInterval) async -> Void
|
||||||
var activeNotificationName: Notification.Name?
|
var activeNotificationName: Notification.Name?
|
||||||
var autoStart: Bool
|
var autoStart: Bool
|
||||||
var validationPolicy: GeoRelayDirectoryValidationPolicy
|
|
||||||
}
|
|
||||||
|
|
||||||
struct GeoRelayDirectoryValidationPolicy: Sendable {
|
|
||||||
let maximumBytes: Int
|
|
||||||
let maximumRows: Int
|
|
||||||
let maximumEntries: Int
|
|
||||||
let minimumRemoteEntries: Int
|
|
||||||
let minimumRetainedFraction: Double
|
|
||||||
|
|
||||||
static let live = GeoRelayDirectoryValidationPolicy(
|
|
||||||
maximumBytes: 512 * 1024,
|
|
||||||
maximumRows: 5_000,
|
|
||||||
maximumEntries: 5_000,
|
|
||||||
minimumRemoteEntries: 50,
|
|
||||||
minimumRetainedFraction: 0.5
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension GeoRelayDirectoryDependencies {
|
private extension GeoRelayDirectoryDependencies {
|
||||||
@@ -61,16 +44,12 @@ private extension GeoRelayDirectoryDependencies {
|
|||||||
#else
|
#else
|
||||||
let activeNotificationName: Notification.Name? = nil
|
let activeNotificationName: Notification.Name? = nil
|
||||||
#endif
|
#endif
|
||||||
let validationPolicy = GeoRelayDirectoryValidationPolicy.live
|
|
||||||
|
|
||||||
return Self(
|
return Self(
|
||||||
userDefaults: .standard,
|
userDefaults: .standard,
|
||||||
notificationCenter: .default,
|
notificationCenter: .default,
|
||||||
now: Date.init,
|
now: Date.init,
|
||||||
// Runtime refreshes only from bitchat's reviewed copy. Upstream
|
remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!,
|
||||||
// georelays/main is imported by a validator-backed pull request,
|
|
||||||
// so an upstream mutation cannot immediately retarget clients.
|
|
||||||
remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/bitchat/refs/heads/main/relays/online_relays_gps.csv")!,
|
|
||||||
fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds,
|
fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds,
|
||||||
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
|
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
|
||||||
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
|
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
|
||||||
@@ -79,27 +58,7 @@ private extension GeoRelayDirectoryDependencies {
|
|||||||
makeFetchData: {
|
makeFetchData: {
|
||||||
let session = TorURLSession.shared.session
|
let session = TorURLSession.shared.session
|
||||||
return { request in
|
return { request in
|
||||||
let (bytes, response) = try await session.bytes(for: request)
|
let (data, _) = try await session.data(for: request)
|
||||||
guard let response = response as? HTTPURLResponse,
|
|
||||||
(200...299).contains(response.statusCode),
|
|
||||||
response.url == request.url else {
|
|
||||||
throw URLError(.badServerResponse)
|
|
||||||
}
|
|
||||||
|
|
||||||
let maximumBytes = validationPolicy.maximumBytes
|
|
||||||
guard response.expectedContentLength <= Int64(maximumBytes) else {
|
|
||||||
throw URLError(.dataLengthExceedsMaximum)
|
|
||||||
}
|
|
||||||
var data = Data()
|
|
||||||
if response.expectedContentLength > 0 {
|
|
||||||
data.reserveCapacity(Int(response.expectedContentLength))
|
|
||||||
}
|
|
||||||
for try await byte in bytes {
|
|
||||||
guard data.count < maximumBytes else {
|
|
||||||
throw URLError(.dataLengthExceedsMaximum)
|
|
||||||
}
|
|
||||||
data.append(byte)
|
|
||||||
}
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -117,11 +76,7 @@ private extension GeoRelayDirectoryDependencies {
|
|||||||
)
|
)
|
||||||
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
||||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
// v2 ignores caches populated from the old direct-upstream
|
return dir.appendingPathComponent("georelays_cache.csv")
|
||||||
// trust path and subjects every load to strict validation.
|
|
||||||
let legacyCache = dir.appendingPathComponent("georelays_cache.csv")
|
|
||||||
try? FileManager.default.removeItem(at: legacyCache)
|
|
||||||
return dir.appendingPathComponent("georelays_cache_v2.csv")
|
|
||||||
} catch {
|
} catch {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -139,8 +94,7 @@ private extension GeoRelayDirectoryDependencies {
|
|||||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||||
},
|
},
|
||||||
activeNotificationName: activeNotificationName,
|
activeNotificationName: activeNotificationName,
|
||||||
autoStart: true,
|
autoStart: true
|
||||||
validationPolicy: validationPolicy
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,7 +125,7 @@ final class GeoRelayDirectory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private enum DetachedFetchOutcome: Sendable {
|
private enum DetachedFetchOutcome: Sendable {
|
||||||
case success(entries: [Entry], csv: Data)
|
case success(entries: [Entry], csv: String)
|
||||||
case torNotReady
|
case torNotReady
|
||||||
case invalidData
|
case invalidData
|
||||||
case network(String)
|
case network(String)
|
||||||
@@ -258,8 +212,6 @@ final class GeoRelayDirectory {
|
|||||||
)
|
)
|
||||||
let awaitTorReady = dependencies.awaitTorReady
|
let awaitTorReady = dependencies.awaitTorReady
|
||||||
let fetchData = dependencies.makeFetchData()
|
let fetchData = dependencies.makeFetchData()
|
||||||
let validationPolicy = dependencies.validationPolicy
|
|
||||||
let baselineEntries = Set(entries)
|
|
||||||
|
|
||||||
Task { [weak self] in
|
Task { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
@@ -267,9 +219,7 @@ final class GeoRelayDirectory {
|
|||||||
let outcome = await Self.fetchRemoteOutcome(
|
let outcome = await Self.fetchRemoteOutcome(
|
||||||
request: request,
|
request: request,
|
||||||
awaitTorReady: awaitTorReady,
|
awaitTorReady: awaitTorReady,
|
||||||
fetchData: fetchData,
|
fetchData: fetchData
|
||||||
validationPolicy: validationPolicy,
|
|
||||||
baselineEntries: baselineEntries
|
|
||||||
)
|
)
|
||||||
|
|
||||||
switch outcome {
|
switch outcome {
|
||||||
@@ -288,9 +238,7 @@ final class GeoRelayDirectory {
|
|||||||
nonisolated private static func fetchRemoteOutcome(
|
nonisolated private static func fetchRemoteOutcome(
|
||||||
request: URLRequest,
|
request: URLRequest,
|
||||||
awaitTorReady: @escaping @Sendable () async -> Bool,
|
awaitTorReady: @escaping @Sendable () async -> Bool,
|
||||||
fetchData: @escaping @Sendable (URLRequest) async throws -> Data,
|
fetchData: @escaping @Sendable (URLRequest) async throws -> Data
|
||||||
validationPolicy: GeoRelayDirectoryValidationPolicy,
|
|
||||||
baselineEntries: Set<Entry>
|
|
||||||
) async -> DetachedFetchOutcome {
|
) async -> DetachedFetchOutcome {
|
||||||
await Task.detached(priority: .utility) {
|
await Task.detached(priority: .utility) {
|
||||||
let ready = await awaitTorReady()
|
let ready = await awaitTorReady()
|
||||||
@@ -298,16 +246,16 @@ final class GeoRelayDirectory {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
let data = try await fetchData(request)
|
let data = try await fetchData(request)
|
||||||
guard let parsed = Self.validatedEntries(
|
guard let text = String(data: data, encoding: .utf8) else {
|
||||||
from: data,
|
|
||||||
policy: validationPolicy,
|
|
||||||
minimumEntries: validationPolicy.minimumRemoteEntries,
|
|
||||||
baselineEntries: baselineEntries
|
|
||||||
) else {
|
|
||||||
return .invalidData
|
return .invalidData
|
||||||
}
|
}
|
||||||
|
|
||||||
return .success(entries: parsed, csv: data)
|
let parsed = Self.parseCSV(text)
|
||||||
|
guard !parsed.isEmpty else {
|
||||||
|
return .invalidData
|
||||||
|
}
|
||||||
|
|
||||||
|
return .success(entries: parsed, csv: text)
|
||||||
} catch {
|
} catch {
|
||||||
return .network(error.localizedDescription)
|
return .network(error.localizedDescription)
|
||||||
}
|
}
|
||||||
@@ -321,7 +269,7 @@ final class GeoRelayDirectory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func handleFetchSuccess(entries parsed: [Entry], csv: Data) {
|
private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
|
||||||
entries = parsed
|
entries = parsed
|
||||||
persistCache(csv)
|
persistCache(csv)
|
||||||
dependencies.userDefaults.set(dependencies.now(), forKey: lastFetchKey)
|
dependencies.userDefaults.set(dependencies.now(), forKey: lastFetchKey)
|
||||||
@@ -373,8 +321,9 @@ final class GeoRelayDirectory {
|
|||||||
cleanupState.retryTask = nil
|
cleanupState.retryTask = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func persistCache(_ data: Data) {
|
private func persistCache(_ text: String) {
|
||||||
guard let url = dependencies.cacheURL() else { return }
|
guard let url = dependencies.cacheURL() else { return }
|
||||||
|
guard let data = text.data(using: .utf8) else { return }
|
||||||
do {
|
do {
|
||||||
try dependencies.writeData(data, url)
|
try dependencies.writeData(data, url)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -387,12 +336,9 @@ final class GeoRelayDirectory {
|
|||||||
// Prefer cached file if present
|
// Prefer cached file if present
|
||||||
if let cache = dependencies.cacheURL(),
|
if let cache = dependencies.cacheURL(),
|
||||||
let data = dependencies.readData(cache),
|
let data = dependencies.readData(cache),
|
||||||
let entries = Self.validatedEntries(
|
let text = String(data: data, encoding: .utf8) {
|
||||||
from: data,
|
let arr = Self.parseCSV(text)
|
||||||
policy: dependencies.validationPolicy,
|
if !arr.isEmpty { return arr }
|
||||||
minimumEntries: 1
|
|
||||||
) {
|
|
||||||
return entries
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try bundled resource(s)
|
// Try bundled resource(s)
|
||||||
@@ -400,157 +346,36 @@ final class GeoRelayDirectory {
|
|||||||
|
|
||||||
for url in bundleCandidates {
|
for url in bundleCandidates {
|
||||||
if let data = dependencies.readData(url),
|
if let data = dependencies.readData(url),
|
||||||
let entries = Self.validatedEntries(
|
let text = String(data: data, encoding: .utf8) {
|
||||||
from: data,
|
let arr = Self.parseCSV(text)
|
||||||
policy: dependencies.validationPolicy,
|
if !arr.isEmpty { return arr }
|
||||||
minimumEntries: 1
|
|
||||||
) {
|
|
||||||
return entries
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try filesystem path (development/test)
|
// Try filesystem path (development/test)
|
||||||
if let cwd = dependencies.currentDirectoryPath(),
|
if let cwd = dependencies.currentDirectoryPath(),
|
||||||
let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
||||||
let entries = Self.validatedEntries(
|
let text = String(data: data, encoding: .utf8) {
|
||||||
from: data,
|
return Self.parseCSV(text)
|
||||||
policy: dependencies.validationPolicy,
|
|
||||||
minimumEntries: 1
|
|
||||||
) {
|
|
||||||
return entries
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
|
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parses the fixed three-column format as an all-or-nothing trust unit.
|
nonisolated static func parseCSV(_ text: String) -> [Entry] {
|
||||||
/// One malformed or conflicting row rejects the complete dataset rather
|
var result: Set<Entry> = []
|
||||||
/// than silently shrinking or partially replacing the current directory.
|
|
||||||
nonisolated static func validatedEntries(
|
|
||||||
from data: Data,
|
|
||||||
policy: GeoRelayDirectoryValidationPolicy,
|
|
||||||
minimumEntries: Int,
|
|
||||||
baselineEntries: Set<Entry>? = nil
|
|
||||||
) -> [Entry]? {
|
|
||||||
guard !data.isEmpty, data.count <= policy.maximumBytes,
|
|
||||||
let text = String(data: data, encoding: .utf8),
|
|
||||||
!text.hasPrefix("\u{feff}") else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let lines = text.split(whereSeparator: { $0.isNewline })
|
let lines = text.split(whereSeparator: { $0.isNewline })
|
||||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
for (idx, raw) in lines.enumerated() {
|
||||||
.filter { !$0.isEmpty }
|
guard let line = raw.trimmedOrNilIfEmpty else { continue }
|
||||||
guard let header = lines.first,
|
if idx == 0 && line.lowercased().contains("relay url") { continue }
|
||||||
lines.count - 1 <= policy.maximumRows else {
|
let parts = line.split(separator: ",").map { $0.trimmed }
|
||||||
return nil
|
guard parts.count >= 3 else { continue }
|
||||||
|
guard let host = NostrRelayURL.directoryAddress(parts[0]) else { continue }
|
||||||
|
guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue }
|
||||||
|
result.insert(Entry(host: host, lat: lat, lon: lon))
|
||||||
}
|
}
|
||||||
|
return Array(result)
|
||||||
let headerParts = header
|
|
||||||
.split(separator: ",", omittingEmptySubsequences: false)
|
|
||||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
|
|
||||||
let supportedHeaders = [
|
|
||||||
["relay url", "latitude", "longitude"],
|
|
||||||
["relay url", "lat", "lon"]
|
|
||||||
]
|
|
||||||
guard supportedHeaders.contains(headerParts) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var entriesByHost: [String: Entry] = [:]
|
|
||||||
for line in lines.dropFirst() {
|
|
||||||
let parts = line
|
|
||||||
.split(separator: ",", omittingEmptySubsequences: false)
|
|
||||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
||||||
guard parts.count == 3,
|
|
||||||
let host = validatedDirectoryAddress(parts[0]),
|
|
||||||
let latitude = Double(parts[1]), latitude.isFinite,
|
|
||||||
(-90.0...90.0).contains(latitude),
|
|
||||||
let longitude = Double(parts[2]), longitude.isFinite,
|
|
||||||
(-180.0...180.0).contains(longitude) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let entry = Entry(host: host, lat: latitude, lon: longitude)
|
|
||||||
if let existing = entriesByHost[host], existing != entry {
|
|
||||||
// One endpoint cannot truthfully occupy two coordinates. Do
|
|
||||||
// not let row ordering choose which location clients trust.
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
entriesByHost[host] = entry
|
|
||||||
guard entriesByHost.count <= policy.maximumEntries else { return nil }
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsedEntries = Set(entriesByHost.values)
|
|
||||||
guard parsedEntries.count >= minimumEntries else { return nil }
|
|
||||||
|
|
||||||
if let baselineEntries {
|
|
||||||
guard (0...1).contains(policy.minimumRetainedFraction) else { return nil }
|
|
||||||
let requiredOverlap = Int(
|
|
||||||
ceil(Double(baselineEntries.count) * policy.minimumRetainedFraction)
|
|
||||||
)
|
|
||||||
guard parsedEntries.intersection(baselineEntries).count >= requiredOverlap else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsedEntries.sorted {
|
|
||||||
($0.host, $0.lat, $0.lon) < ($1.host, $1.lat, $1.lon)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nonisolated private static func validatedDirectoryAddress(_ rawValue: String) -> String? {
|
|
||||||
let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !value.isEmpty,
|
|
||||||
value.unicodeScalars.allSatisfy({
|
|
||||||
$0.isASCII && !CharacterSet.controlCharacters.contains($0)
|
|
||||||
}) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let candidate = value.contains("://") ? value : "wss://\(value)"
|
|
||||||
guard let components = URLComponents(string: candidate),
|
|
||||||
let scheme = components.scheme?.lowercased(),
|
|
||||||
scheme == "wss" || scheme == "https",
|
|
||||||
components.user == nil,
|
|
||||||
components.password == nil,
|
|
||||||
components.query == nil,
|
|
||||||
components.fragment == nil,
|
|
||||||
components.path.isEmpty || components.path == "/",
|
|
||||||
let rawHost = components.host else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let host = rawHost.lowercased()
|
|
||||||
guard !host.isEmpty, host.count <= 253,
|
|
||||||
host.unicodeScalars.allSatisfy({ $0.isASCII }),
|
|
||||||
!host.hasSuffix("."),
|
|
||||||
host != "localhost",
|
|
||||||
!host.hasSuffix(".localhost"),
|
|
||||||
!host.hasSuffix(".local"),
|
|
||||||
!host.hasSuffix(".internal") else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let labels = host.split(separator: ".", omittingEmptySubsequences: false)
|
|
||||||
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789-")
|
|
||||||
guard labels.count >= 2,
|
|
||||||
!labels.allSatisfy({ $0.allSatisfy(\.isNumber) }),
|
|
||||||
labels.allSatisfy({ label in
|
|
||||||
(1...63).contains(label.count) &&
|
|
||||||
label.first != "-" &&
|
|
||||||
label.last != "-" &&
|
|
||||||
label.unicodeScalars.allSatisfy { allowed.contains($0) }
|
|
||||||
}) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if let port = components.port {
|
|
||||||
guard (1...65_535).contains(port) else { return nil }
|
|
||||||
if port != 443 { return "\(host):\(port)" }
|
|
||||||
}
|
|
||||||
return host
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Observers & Timers
|
// MARK: - Observers & Timers
|
||||||
|
|||||||
@@ -700,6 +700,10 @@ struct NostrEvent: Codable {
|
|||||||
let content = dict["content"] as? String else {
|
let content = dict["content"] as? String else {
|
||||||
throw NostrError.invalidEvent
|
throw NostrError.invalidEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard Self.isWithinInboundTagLimits(tags) else {
|
||||||
|
throw NostrError.invalidEvent
|
||||||
|
}
|
||||||
|
|
||||||
self.id = dict["id"] as? String ?? ""
|
self.id = dict["id"] as? String ?? ""
|
||||||
self.pubkey = pubkey
|
self.pubkey = pubkey
|
||||||
@@ -709,6 +713,21 @@ struct NostrEvent: Codable {
|
|||||||
self.content = content
|
self.content = content
|
||||||
self.sig = dict["sig"] as? String
|
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 {
|
func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent {
|
||||||
let (eventId, eventIdHash) = try calculateEventId()
|
let (eventId, eventIdHash) = try calculateEventId()
|
||||||
|
|||||||
@@ -1480,7 +1480,7 @@ private enum ParsedInbound {
|
|||||||
case notice(String)
|
case notice(String)
|
||||||
|
|
||||||
init?(_ message: URLSessionWebSocketTask.Message) {
|
init?(_ message: URLSessionWebSocketTask.Message) {
|
||||||
guard let data = message.data,
|
guard let data = message.dataWithinInboundLimit,
|
||||||
let array = try? JSONSerialization.jsonObject(with: data) as? [Any],
|
let array = try? JSONSerialization.jsonObject(with: data) as? [Any],
|
||||||
array.count >= 2,
|
array.count >= 2,
|
||||||
let type = array[0] as? String else {
|
let type = array[0] as? String else {
|
||||||
@@ -1525,11 +1525,19 @@ private enum ParsedInbound {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extension URLSessionWebSocketTask.Message {
|
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 {
|
switch self {
|
||||||
case .string(let text): text.data(using: .utf8)
|
case .string(let text):
|
||||||
case .data(let data): data
|
guard text.utf8.count <= maxBytes else { return nil }
|
||||||
@unknown default: nil
|
return text.data(using: .utf8)
|
||||||
|
case .data(let data):
|
||||||
|
guard data.count <= maxBytes else { return nil }
|
||||||
|
return data
|
||||||
|
@unknown default:
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,4 +39,13 @@ enum NostrRelayURL {
|
|||||||
|
|
||||||
return components.string
|
return components.string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func directoryAddress(_ rawValue: String) -> String? {
|
||||||
|
guard var normalized = normalized(rawValue, defaultScheme: "wss") else { return nil }
|
||||||
|
for prefix in ["wss://", "ws://"] where normalized.hasPrefix(prefix) {
|
||||||
|
normalized.removeFirst(prefix.count)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,12 +79,35 @@ enum NoisePayloadType: UInt8 {
|
|||||||
case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update)
|
case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update)
|
||||||
// Live voice (push-to-talk)
|
// Live voice (push-to-talk)
|
||||||
case voiceFrame = 0x08 // One live voice-burst packet (see VoiceBurstPacket)
|
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)
|
// Verification (QR-based OOB binding)
|
||||||
case verifyChallenge = 0x10 // Verification challenge
|
case verifyChallenge = 0x10 // Verification challenge
|
||||||
case verifyResponse = 0x11 // Verification response
|
case verifyResponse = 0x11 // Verification response
|
||||||
// Transitive verification (web of trust)
|
// Transitive verification (web of trust)
|
||||||
case vouch = 0x12 // Batch of vouch attestations
|
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 {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .privateMessage: return "privateMessage"
|
case .privateMessage: return "privateMessage"
|
||||||
@@ -93,6 +116,8 @@ enum NoisePayloadType: UInt8 {
|
|||||||
case .groupInvite: return "groupInvite"
|
case .groupInvite: return "groupInvite"
|
||||||
case .groupKeyUpdate: return "groupKeyUpdate"
|
case .groupKeyUpdate: return "groupKeyUpdate"
|
||||||
case .voiceFrame: return "voiceFrame"
|
case .voiceFrame: return "voiceFrame"
|
||||||
|
case .privateFile: return "privateFile"
|
||||||
|
case .authenticatedPeerState: return "authenticatedPeerState"
|
||||||
case .verifyChallenge: return "verifyChallenge"
|
case .verifyChallenge: return "verifyChallenge"
|
||||||
case .verifyResponse: return "verifyResponse"
|
case .verifyResponse: return "verifyResponse"
|
||||||
case .vouch: return "vouch"
|
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 {
|
struct PrivateMessagePacket {
|
||||||
let messageID: String
|
let messageID: String
|
||||||
let content: String
|
let content: String
|
||||||
|
|||||||
@@ -3,5 +3,5 @@ import BitFoundation
|
|||||||
extension PeerCapabilities {
|
extension PeerCapabilities {
|
||||||
/// Capabilities this build advertises in its announce packets.
|
/// Capabilities this build advertises in its announce packets.
|
||||||
/// Each feature adds its bit here when it ships.
|
/// Each feature adds its bit here when it ships.
|
||||||
static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups]
|
static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups, .privateMedia]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ struct BLEAnnounceHandlerEnvironment {
|
|||||||
let now: () -> Date
|
let now: () -> Date
|
||||||
/// Noise public key already recorded for the peer, if any (registry read).
|
/// Noise public key already recorded for the peer, if any (registry read).
|
||||||
let existingNoisePublicKey: (PeerID) -> Data?
|
let existingNoisePublicKey: (PeerID) -> Data?
|
||||||
|
/// Ed25519 key previously bound to this Noise identity by an authenticated
|
||||||
|
/// peer-state payload, if any (persistent identity-state read).
|
||||||
|
let authenticatedSigningPublicKey: (_ noisePublicKey: Data) -> Data?
|
||||||
/// Verifies the packet signature against the announced signing key.
|
/// Verifies the packet signature against the announced signing key.
|
||||||
let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
||||||
/// Direct link state for the peer (BLE-queue read).
|
/// Direct link state for the peer (BLE-queue read).
|
||||||
@@ -130,11 +133,21 @@ final class BLEAnnounceHandler {
|
|||||||
hasSignature: hasSignature,
|
hasSignature: hasSignature,
|
||||||
signatureValid: signatureValid,
|
signatureValid: signatureValid,
|
||||||
existingNoisePublicKey: existingNoisePublicKey,
|
existingNoisePublicKey: existingNoisePublicKey,
|
||||||
announcedNoisePublicKey: announcement.noisePublicKey
|
announcedNoisePublicKey: announcement.noisePublicKey,
|
||||||
|
authenticatedSigningPublicKey: env.authenticatedSigningPublicKey(
|
||||||
|
announcement.noisePublicKey
|
||||||
|
),
|
||||||
|
announcedSigningPublicKey: announcement.signingPublicKey
|
||||||
)
|
)
|
||||||
if case .reject(.keyMismatch) = trustDecision {
|
if case .reject(.keyMismatch) = trustDecision {
|
||||||
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security)
|
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security)
|
||||||
}
|
}
|
||||||
|
if case .reject(.authenticatedSigningKeyMismatch) = trustDecision {
|
||||||
|
SecureLogger.warning(
|
||||||
|
"⚠️ Announce signing-key replacement rejected for Noise-authenticated peer \(peerID.id.prefix(8))…",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
}
|
||||||
let verifiedAnnounce = trustDecision.isVerified
|
let verifiedAnnounce = trustDecision.isVerified
|
||||||
|
|
||||||
var isNewPeer = false
|
var isNewPeer = false
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ enum BLEAnnounceTrustRejection: Equatable {
|
|||||||
case missingSignature
|
case missingSignature
|
||||||
case invalidSignature
|
case invalidSignature
|
||||||
case keyMismatch
|
case keyMismatch
|
||||||
|
case authenticatedSigningKeyMismatch
|
||||||
}
|
}
|
||||||
|
|
||||||
enum BLEAnnounceTrustDecision: Equatable {
|
enum BLEAnnounceTrustDecision: Equatable {
|
||||||
@@ -72,12 +73,19 @@ enum BLEAnnounceTrustPolicy {
|
|||||||
hasSignature: Bool,
|
hasSignature: Bool,
|
||||||
signatureValid: Bool,
|
signatureValid: Bool,
|
||||||
existingNoisePublicKey: Data?,
|
existingNoisePublicKey: Data?,
|
||||||
announcedNoisePublicKey: Data
|
announcedNoisePublicKey: Data,
|
||||||
|
authenticatedSigningPublicKey: Data? = nil,
|
||||||
|
announcedSigningPublicKey: Data? = nil
|
||||||
) -> BLEAnnounceTrustDecision {
|
) -> BLEAnnounceTrustDecision {
|
||||||
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
|
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
|
||||||
return .reject(.keyMismatch)
|
return .reject(.keyMismatch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let authenticatedSigningPublicKey,
|
||||||
|
announcedSigningPublicKey != authenticatedSigningPublicKey {
|
||||||
|
return .reject(.authenticatedSigningKeyMismatch)
|
||||||
|
}
|
||||||
|
|
||||||
guard hasSignature else {
|
guard hasSignature else {
|
||||||
return .reject(.missingSignature)
|
return .reject(.missingSignature)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ struct BLEFileTransferHandlerEnvironment {
|
|||||||
let peersSnapshot: () -> [PeerID: BLEPeerInfo]
|
let peersSnapshot: () -> [PeerID: BLEPeerInfo]
|
||||||
/// Verifies a packet's signature against a candidate signing key (registry path).
|
/// Verifies a packet's signature against a candidate signing key (registry path).
|
||||||
let verifyPacketSignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
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.
|
/// Resolves a display name from a verified packet signature for peers missing from the registry.
|
||||||
let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String?
|
let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String?
|
||||||
/// Tracks the broadcast file packet for gossip sync.
|
/// Tracks the broadcast file packet for gossip sync.
|
||||||
@@ -46,54 +48,105 @@ final class BLEFileTransferHandler {
|
|||||||
self.environment = environment
|
self.environment = environment
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `false` when the packet fails sender authentication and must
|
/// Returns `false` when the raw packet fails sender authentication (or is
|
||||||
/// not be relayed onward. Every other outcome returns `true`: files
|
/// a live self-echo) and must not be relayed onward. Authentication runs
|
||||||
/// directed to another peer are forwarded untouched, and local-only drops
|
/// before the routing decision, so a forged directed packet cannot use a
|
||||||
/// (malformed payload, quota, save failure) don't affect multi-hop
|
/// node that is not its recipient as an unsigned forwarding hop.
|
||||||
/// delivery to nodes that may handle them fine.
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
func handle(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
||||||
let env = environment
|
let env = environment
|
||||||
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return true }
|
let localPeerID = env.localPeerID()
|
||||||
|
|
||||||
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: env.localPeerID()) else {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
let peersSnapshot = env.peersSnapshot()
|
let peersSnapshot = env.peersSnapshot()
|
||||||
guard let senderNickname = resolveSenderNickname(
|
|
||||||
|
guard let senderNickname = authenticatedRawSenderNickname(
|
||||||
packet: packet,
|
packet: packet,
|
||||||
from: peerID,
|
from: peerID,
|
||||||
isBroadcast: !deliveryPlan.isPrivateMessage,
|
|
||||||
peers: peersSnapshot,
|
peers: peersSnapshot,
|
||||||
env: env
|
env: env
|
||||||
) else {
|
) 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
|
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 {
|
if deliveryPlan.shouldTrackForSync {
|
||||||
env.trackPacketSeen(packet)
|
env.trackPacketSeen(packet)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_ = storeIncomingPayload(
|
||||||
|
packet.payload,
|
||||||
|
from: peerID,
|
||||||
|
senderNickname: senderNickname,
|
||||||
|
timestamp: Date(timeIntervalSince1970: Double(packet.timestamp) / 1000),
|
||||||
|
isPrivate: deliveryPlan.isPrivateMessage,
|
||||||
|
env: env
|
||||||
|
)
|
||||||
|
// Once authenticated, a local decode/quota/save failure is not proof
|
||||||
|
// that downstream nodes should be denied the valid signed packet.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accepts a file packet only after it has been authenticated and
|
||||||
|
/// decrypted by the peer's Noise session. The inner packet deliberately
|
||||||
|
/// has no redundant signature: Noise supplies sender authentication and
|
||||||
|
/// confidentiality, while this handler retains the same validation,
|
||||||
|
/// quota, persistence, and UI-delivery behavior as public files.
|
||||||
|
@discardableResult
|
||||||
|
func handlePrivatePayload(_ payload: Data, from peerID: PeerID, timestamp: Date) -> Bool {
|
||||||
|
let env = environment
|
||||||
|
let peers = env.peersSnapshot()
|
||||||
|
let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||||
|
peerID: peerID,
|
||||||
|
localPeerID: env.localPeerID(),
|
||||||
|
localNickname: env.localNickname(),
|
||||||
|
peers: peers,
|
||||||
|
allowConnectedUnverified: true
|
||||||
|
) ?? BLEPeerSenderDisplayName.anonymousNickname(for: peerID)
|
||||||
|
|
||||||
|
return storeIncomingPayload(
|
||||||
|
payload,
|
||||||
|
from: peerID,
|
||||||
|
senderNickname: senderNickname,
|
||||||
|
timestamp: timestamp,
|
||||||
|
isPrivate: true,
|
||||||
|
env: env
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func storeIncomingPayload(
|
||||||
|
_ payload: Data,
|
||||||
|
from peerID: PeerID,
|
||||||
|
senderNickname: String,
|
||||||
|
timestamp: Date,
|
||||||
|
isPrivate: Bool,
|
||||||
|
env: BLEFileTransferHandlerEnvironment
|
||||||
|
) -> Bool {
|
||||||
|
|
||||||
let filePacket: BitchatFilePacket
|
let filePacket: BitchatFilePacket
|
||||||
let mime: MimeType
|
let mime: MimeType
|
||||||
switch BLEIncomingFileValidator.validate(payload: packet.payload) {
|
switch BLEIncomingFileValidator.validate(payload: payload) {
|
||||||
case .success(let acceptance):
|
case .success(let acceptance):
|
||||||
filePacket = acceptance.filePacket
|
filePacket = acceptance.filePacket
|
||||||
mime = acceptance.mime
|
mime = acceptance.mime
|
||||||
case .failure(.malformedPayload):
|
case .failure(.malformedPayload):
|
||||||
SecureLogger.error("❌ Failed to decode file transfer payload", category: .session)
|
SecureLogger.error("❌ Failed to decode file transfer payload", category: .session)
|
||||||
return true
|
return false
|
||||||
case .failure(.payloadTooLarge(let bytes)):
|
case .failure(.payloadTooLarge(let bytes)):
|
||||||
SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security)
|
SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security)
|
||||||
return true
|
return false
|
||||||
case .failure(.unsupportedMime(let mimeType, let bytes)):
|
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)
|
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)):
|
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)
|
SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(bytes)b prefix=[\(prefixHex)] from \(peerID.id.prefix(8))...", category: .security)
|
||||||
return true
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// BCH-01-002: Enforce storage quota before saving
|
// BCH-01-002: Enforce storage quota before saving
|
||||||
@@ -106,28 +159,27 @@ final class BLEFileTransferHandler {
|
|||||||
mime.defaultExtension,
|
mime.defaultExtension,
|
||||||
mime.category.rawValue
|
mime.category.rawValue
|
||||||
) else {
|
) else {
|
||||||
return true
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if deliveryPlan.isPrivateMessage {
|
if isPrivate {
|
||||||
env.updatePeerLastSeen(peerID)
|
env.updatePeerLastSeen(peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
|
||||||
let message = BitchatMessage(
|
let message = BitchatMessage(
|
||||||
sender: senderNickname,
|
sender: senderNickname,
|
||||||
content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)",
|
content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)",
|
||||||
timestamp: ts,
|
timestamp: timestamp,
|
||||||
isRelay: false,
|
isRelay: false,
|
||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
isPrivate: deliveryPlan.isPrivateMessage,
|
isPrivate: isPrivate,
|
||||||
recipientNickname: nil,
|
recipientNickname: nil,
|
||||||
senderPeerID: peerID,
|
senderPeerID: peerID,
|
||||||
// Received messages need an explicit status: BitchatMessage
|
// Received messages need an explicit status: BitchatMessage
|
||||||
// defaults private messages to .sending, which the media views
|
// defaults private messages to .sending, which the media views
|
||||||
// render as an in-flight send (empty reveal mask, disabled tap).
|
// render as an in-flight send (empty reveal mask, disabled tap).
|
||||||
deliveryStatus: deliveryPlan.isPrivateMessage
|
deliveryStatus: isPrivate
|
||||||
? .delivered(to: env.localNickname(), at: ts)
|
? .delivered(to: env.localNickname(), at: timestamp)
|
||||||
: nil
|
: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -137,51 +189,38 @@ final class BLEFileTransferHandler {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves the authenticated display name for a file transfer's sender.
|
/// Every remaining raw file transfer is signed, regardless of whether it
|
||||||
///
|
/// is broadcast, addressed to us, or merely passing through. Registry
|
||||||
/// Directed (private) transfers are addressed to us specifically and keep
|
/// signing keys are preferred; persisted identities cover peers that have
|
||||||
/// the lenient connected-peer path. Broadcast transfers carry an
|
/// rotated or are not currently present in the registry.
|
||||||
/// attacker-controllable `senderID` exactly like public messages and public
|
private func authenticatedRawSenderNickname(
|
||||||
/// 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(
|
|
||||||
packet: BitchatPacket,
|
packet: BitchatPacket,
|
||||||
from peerID: PeerID,
|
from peerID: PeerID,
|
||||||
isBroadcast: Bool,
|
|
||||||
peers: [PeerID: BLEPeerInfo],
|
peers: [PeerID: BLEPeerInfo],
|
||||||
env: BLEFileTransferHandlerEnvironment
|
env: BLEFileTransferHandlerEnvironment
|
||||||
) -> String? {
|
) -> String? {
|
||||||
guard isBroadcast else {
|
guard packet.signature != nil else { return nil }
|
||||||
return BLEPeerSenderDisplayName.resolveKnownPeer(
|
|
||||||
peerID: peerID,
|
|
||||||
localPeerID: env.localPeerID(),
|
|
||||||
localNickname: env.localNickname(),
|
|
||||||
peers: peers,
|
|
||||||
allowConnectedUnverified: true
|
|
||||||
) ?? env.signedSenderDisplayName(packet, peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Our own broadcasts replayed back via gossip sync (ttl==0) are
|
let localPeerID = env.localPeerID()
|
||||||
// trivially authentic and cannot be verified against the peer registry
|
let candidateKey = peerID == localPeerID
|
||||||
// or identity cache, so exempt self exactly as `BLEPublicMessageHandler`
|
? env.localSigningPublicKey()
|
||||||
// does. Verify against the signing key already in the
|
: peers[peerID]?.signingPublicKey
|
||||||
// (synchronously-updated) registry first, then fall back to the
|
let verifiedWithKnownKey = candidateKey.map {
|
||||||
// persisted-identity signature lookup for peers not yet cached there.
|
env.verifyPacketSignature(packet, $0)
|
||||||
let isSelf = peerID == env.localPeerID()
|
} ?? false
|
||||||
let registrySigningKey = peers[peerID]?.signingPublicKey
|
let signedDisplayName = verifiedWithKnownKey
|
||||||
let verifiedViaRegistry = !isSelf && (registrySigningKey.map { env.verifyPacketSignature(packet, $0) } ?? false)
|
? nil
|
||||||
let signedDisplayName = (isSelf || verifiedViaRegistry) ? nil : env.signedSenderDisplayName(packet, peerID)
|
: env.signedSenderDisplayName(packet, peerID)
|
||||||
guard isSelf || verifiedViaRegistry || signedDisplayName != nil else { return nil }
|
guard verifiedWithKnownKey || signedDisplayName != nil else { return nil }
|
||||||
|
|
||||||
return BLEPeerSenderDisplayName.resolveKnownPeer(
|
return BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
localPeerID: env.localPeerID(),
|
localPeerID: localPeerID,
|
||||||
localNickname: env.localNickname(),
|
localNickname: env.localNickname(),
|
||||||
peers: peers,
|
peers: peers,
|
||||||
allowConnectedUnverified: false
|
// The packet signature authenticates the announced peer; the old
|
||||||
) ?? signedDisplayName
|
// 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 {
|
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.
|
// 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
|
return FileTransferLimits.maxFramedFileBytes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,124 @@ import BitLogger
|
|||||||
import BitFoundation
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
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 {
|
struct BLEIncomingFileStore {
|
||||||
|
enum PanicRecoveryError: Error {
|
||||||
|
case externalMarkerCommitFailed
|
||||||
|
case markerWriteFailed(Error)
|
||||||
|
case markerWriteAndMediaWipeFailed(
|
||||||
|
markerError: Error,
|
||||||
|
mediaError: Error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private static let quotaBytes: Int64 = 100 * 1024 * 1024
|
private static let quotaBytes: Int64 = 100 * 1024 * 1024
|
||||||
|
/// 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
|
/// Name prefix of in-flight live voice captures (progressively written by
|
||||||
/// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern —
|
/// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern —
|
||||||
@@ -17,11 +133,96 @@ struct BLEIncomingFileStore {
|
|||||||
let fileManager: FileManager
|
let fileManager: FileManager
|
||||||
private let baseDirectory: URL?
|
private let baseDirectory: URL?
|
||||||
private let dateProvider: () -> Date
|
private let dateProvider: () -> Date
|
||||||
|
private let panicMarkerWriter: (Data, URL) throws -> Void
|
||||||
|
|
||||||
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)
|
||||||
|
}
|
||||||
|
) {
|
||||||
self.fileManager = fileManager
|
self.fileManager = fileManager
|
||||||
self.baseDirectory = baseDirectory
|
self.baseDirectory = baseDirectory
|
||||||
self.dateProvider = dateProvider
|
self.dateProvider = dateProvider
|
||||||
|
self.panicMarkerWriter = panicMarkerWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
let markerError: Error?
|
||||||
|
do {
|
||||||
|
try markPanicRecoveryPending()
|
||||||
|
markerError = nil
|
||||||
|
} catch {
|
||||||
|
markerError = error
|
||||||
|
SecureLogger.error(
|
||||||
|
"Could not persist file panic-recovery marker; attempting media deletion anyway: \(error)",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let filesDirectory = try rootDirectory()
|
||||||
|
.appendingPathComponent("files", isDirectory: true)
|
||||||
|
if fileManager.fileExists(atPath: filesDirectory.path) {
|
||||||
|
try fileManager.removeItem(at: filesDirectory)
|
||||||
|
}
|
||||||
|
for subdirectory in Self.mediaSubdirectories {
|
||||||
|
try fileManager.createDirectory(
|
||||||
|
at: filesDirectory.appendingPathComponent(
|
||||||
|
subdirectory,
|
||||||
|
isDirectory: true
|
||||||
|
),
|
||||||
|
withIntermediateDirectories: true,
|
||||||
|
attributes: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if let markerError {
|
||||||
|
throw PanicRecoveryError.markerWriteAndMediaWipeFailed(
|
||||||
|
markerError: markerError,
|
||||||
|
mediaError: error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
if let markerError, !hasDurablePendingMarker {
|
||||||
|
throw PanicRecoveryError.markerWriteFailed(markerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func markPanicRecoveryPending() throws {
|
||||||
|
let markerURL = try panicRecoveryPendingMarkerURL()
|
||||||
|
try fileManager.createDirectory(
|
||||||
|
at: markerURL.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true,
|
||||||
|
attributes: nil
|
||||||
|
)
|
||||||
|
try panicMarkerWriter(Data([1]), markerURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPanicRecoveryPending() throws -> Bool {
|
||||||
|
try panicRecoveryMarkerURLs().contains {
|
||||||
|
fileManager.fileExists(atPath: $0.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func completePanicRecovery() throws {
|
||||||
|
for markerURL in try panicRecoveryMarkerURLs()
|
||||||
|
where fileManager.fileExists(atPath: markerURL.path) {
|
||||||
|
try fileManager.removeItem(at: markerURL)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves (and creates) an incoming-media directory for callers that
|
/// Resolves (and creates) an incoming-media directory for callers that
|
||||||
@@ -113,15 +314,39 @@ struct BLEIncomingFileStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func filesDirectory() throws -> URL {
|
private func filesDirectory() throws -> URL {
|
||||||
let root = try baseDirectory ?? fileManager.url(
|
let filesDir = try rootDirectory().appendingPathComponent("files", isDirectory: true)
|
||||||
|
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
||||||
|
return filesDir
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rootDirectory() throws -> URL {
|
||||||
|
try baseDirectory ?? fileManager.url(
|
||||||
for: .applicationSupportDirectory,
|
for: .applicationSupportDirectory,
|
||||||
in: .userDomainMask,
|
in: .userDomainMask,
|
||||||
appropriateFor: nil,
|
appropriateFor: nil,
|
||||||
create: true
|
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 sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
|
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
|
||||||
|
|||||||
@@ -2,6 +2,16 @@ import BitFoundation
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
struct BLENoiseHandshakeHandlingResult {
|
||||||
|
let processed: Bool
|
||||||
|
let didEstablishAuthenticatedSession: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLENoiseDecryptionResult {
|
||||||
|
let plaintext: Data
|
||||||
|
let sessionGeneration: UUID
|
||||||
|
}
|
||||||
|
|
||||||
/// Narrow environment for `BLENoisePacketHandler`.
|
/// Narrow environment for `BLENoisePacketHandler`.
|
||||||
///
|
///
|
||||||
/// All queue hops (collections barrier writes, main-actor UI notification)
|
/// All queue hops (collections barrier writes, main-actor UI notification)
|
||||||
@@ -16,8 +26,11 @@ struct BLENoisePacketHandlerEnvironment {
|
|||||||
let messageTTL: UInt8
|
let messageTTL: UInt8
|
||||||
/// Current time source.
|
/// Current time source.
|
||||||
let now: () -> Date
|
let now: () -> Date
|
||||||
/// Processes an inbound handshake message, returning an optional response payload (crypto).
|
/// Processes an inbound handshake message, returning its optional response
|
||||||
let processHandshakeMessage: (_ peerID: PeerID, _ message: Data) throws -> Data?
|
/// 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).
|
/// Whether any Noise session (established or pending) exists for the peer (crypto).
|
||||||
let hasNoiseSession: (PeerID) -> Bool
|
let hasNoiseSession: (PeerID) -> Bool
|
||||||
/// Initiates a fresh Noise handshake with the peer (crypto + send).
|
/// Initiates a fresh Noise handshake with the peer (crypto + send).
|
||||||
@@ -27,9 +40,16 @@ struct BLENoisePacketHandlerEnvironment {
|
|||||||
/// Updates the registry last-seen timestamp for the peer (async barrier write).
|
/// Updates the registry last-seen timestamp for the peer (async barrier write).
|
||||||
let updatePeerLastSeen: (PeerID) -> Void
|
let updatePeerLastSeen: (PeerID) -> Void
|
||||||
/// Decrypts an encrypted payload from the peer (crypto).
|
/// 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).
|
/// Clears the peer's Noise session after an unrecoverable decrypt failure (crypto).
|
||||||
let clearSession: (PeerID) -> Void
|
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.
|
/// Delivers `.noisePayloadReceived` to the UI as one main-actor hop.
|
||||||
let deliverNoisePayload: (
|
let deliverNoisePayload: (
|
||||||
_ peerID: PeerID,
|
_ peerID: PeerID,
|
||||||
@@ -49,13 +69,28 @@ final class BLENoisePacketHandler {
|
|||||||
self.environment = environment
|
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 replacement completion
|
||||||
|
/// from a rejected candidate while an older session remains established.
|
||||||
|
@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
|
let env = environment
|
||||||
// Use NoiseEncryptionService for handshake processing
|
// Use NoiseEncryptionService for handshake processing
|
||||||
if PeerID(hexData: packet.recipientID) == env.localPeerID() {
|
if PeerID(hexData: packet.recipientID) == env.localPeerID() {
|
||||||
// Handshake is for us
|
// Handshake is for us
|
||||||
do {
|
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
|
// Send response
|
||||||
let responsePacket = BitchatPacket(
|
let responsePacket = BitchatPacket(
|
||||||
type: MessageType.noiseHandshake.rawValue,
|
type: MessageType.noiseHandshake.rawValue,
|
||||||
@@ -72,14 +107,39 @@ final class BLENoisePacketHandler {
|
|||||||
|
|
||||||
// Session establishment will trigger onPeerAuthenticated callback
|
// Session establishment will trigger onPeerAuthenticated callback
|
||||||
// which will send any pending messages at the right time
|
// which will send any pending messages at the right time
|
||||||
|
return BLENoiseHandshakeHandlingResult(
|
||||||
|
processed: true,
|
||||||
|
didEstablishAuthenticatedSession:
|
||||||
|
result.didEstablishAuthenticatedSession
|
||||||
|
)
|
||||||
|
} catch NoiseSessionError.peerIdentityMismatch {
|
||||||
|
// The candidate 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 {
|
} catch {
|
||||||
SecureLogger.error("Failed to process handshake: \(error)")
|
SecureLogger.error("Failed to process handshake: \(error)")
|
||||||
// Try initiating a new handshake
|
// Try initiating a new handshake
|
||||||
if !env.hasNoiseSession(peerID) {
|
if !env.hasNoiseSession(peerID) {
|
||||||
env.initiateHandshake(peerID)
|
env.initiateHandshake(peerID)
|
||||||
}
|
}
|
||||||
|
return BLENoiseHandshakeHandlingResult(
|
||||||
|
processed: false,
|
||||||
|
didEstablishAuthenticatedSession: false
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return BLENoiseHandshakeHandlingResult(
|
||||||
|
processed: false,
|
||||||
|
didEstablishAuthenticatedSession: false
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
|
func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
@@ -98,20 +158,30 @@ final class BLENoisePacketHandler {
|
|||||||
env.updatePeerLastSeen(peerID)
|
env.updatePeerLastSeen(peerID)
|
||||||
|
|
||||||
do {
|
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 }
|
guard decrypted.count > 0 else { return }
|
||||||
|
|
||||||
// First byte indicates the payload type
|
// First byte indicates the payload type
|
||||||
let payloadType = decrypted[0]
|
let payloadType = decrypted[0]
|
||||||
let payloadData = decrypted.dropFirst()
|
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)")
|
SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
SecureLogger.debug("🔐 Decrypted noise payload type \(noisePayloadType.description) from \(peerID.id.prefix(8))…", category: .session)
|
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)
|
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||||
env.deliverNoisePayload(peerID, noisePayloadType, Data(payloadData), ts)
|
env.deliverNoisePayload(peerID, noisePayloadType, Data(payloadData), ts)
|
||||||
} catch NoiseEncryptionError.sessionNotEstablished {
|
} catch NoiseEncryptionError.sessionNotEstablished {
|
||||||
|
|||||||
@@ -17,6 +17,16 @@ enum BLENoisePayloadFactory {
|
|||||||
typedPayload(.delivered, payload: Data(messageID.utf8))
|
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 {
|
static func typedPayload(_ type: NoisePayloadType, payload: Data) -> Data {
|
||||||
var typed = Data([type.rawValue])
|
var typed = Data([type.rawValue])
|
||||||
typed.append(payload)
|
typed.append(payload)
|
||||||
|
|||||||
@@ -6,9 +6,16 @@ struct BLEPendingPrivateMessage: Equatable {
|
|||||||
let messageID: String
|
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 {
|
struct BLENoiseSessionQueues {
|
||||||
private var privateMessagesByPeerID: [PeerID: [BLEPendingPrivateMessage]] = [:]
|
private var privateMessagesByPeerID: [PeerID: [BLEPendingPrivateMessage]] = [:]
|
||||||
private var typedPayloadsByPeerID: [PeerID: [Data]] = [:]
|
private var typedPayloadsByPeerID: [PeerID: [BLEPendingTypedPayload]] = [:]
|
||||||
|
|
||||||
var isEmpty: Bool {
|
var isEmpty: Bool {
|
||||||
privateMessagesByPeerID.isEmpty && typedPayloadsByPeerID.isEmpty
|
privateMessagesByPeerID.isEmpty && typedPayloadsByPeerID.isEmpty
|
||||||
@@ -34,13 +41,35 @@ struct BLENoiseSessionQueues {
|
|||||||
privateMessagesByPeerID[peerID, default: []].insert(contentsOf: messages, at: 0)
|
privateMessagesByPeerID[peerID, default: []].insert(contentsOf: messages, at: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func appendTypedPayload(_ payload: Data, for peerID: PeerID) {
|
mutating func appendTypedPayload(_ payload: Data, transferId: String? = nil, for peerID: PeerID) {
|
||||||
typedPayloadsByPeerID[peerID, default: []].append(payload)
|
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] ?? []
|
let payloads = typedPayloadsByPeerID[peerID] ?? []
|
||||||
typedPayloadsByPeerID.removeValue(forKey: peerID)
|
typedPayloadsByPeerID.removeValue(forKey: peerID)
|
||||||
return payloads
|
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 {
|
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 minimumChunkSize = 64
|
||||||
private static let fragmentIDLength = 8
|
private static let fragmentIDLength = 8
|
||||||
|
|
||||||
@@ -71,6 +74,10 @@ enum BLEOutboundFragmentPlanner {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func isPrivateMediaV1Compatible(_ plan: BLEOutboundFragmentPlan) -> Bool {
|
||||||
|
plan.totalFragments <= privateMediaV1MaxFragments
|
||||||
|
}
|
||||||
|
|
||||||
private static func sizingPolicy(
|
private static func sizingPolicy(
|
||||||
for packet: BitchatPacket,
|
for packet: BitchatPacket,
|
||||||
requestedMaxChunk: Int?,
|
requestedMaxChunk: Int?,
|
||||||
|
|||||||
@@ -29,8 +29,9 @@ struct BLEOutboundFragmentTransferRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var resolvedTransferId: String? {
|
var resolvedTransferId: String? {
|
||||||
|
if let transferId { return transferId }
|
||||||
guard packet.type == MessageType.fileTransfer.rawValue else { return nil }
|
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
|
/// Content identity independent of the caller-chosen transfer ID: the
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ struct BLEPeerInfo: Equatable {
|
|||||||
var isVerifiedNickname: Bool
|
var isVerifiedNickname: Bool
|
||||||
var lastSeen: Date
|
var lastSeen: Date
|
||||||
var capabilities: PeerCapabilities = []
|
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`.
|
/// Rendezvous cell from the peer's announce when it advertises `.bridge`.
|
||||||
var bridgeGeohash: String?
|
var bridgeGeohash: String?
|
||||||
}
|
}
|
||||||
@@ -114,6 +117,10 @@ struct BLEPeerRegistry {
|
|||||||
peers[peerID.toShort()]?.capabilities ?? []
|
peers[peerID.toShort()]?.capabilities ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func capabilitiesWereExplicitlyAdvertised(for peerID: PeerID) -> Bool {
|
||||||
|
peers[peerID.toShort()]?.capabilitiesWereExplicitlyAdvertised == true
|
||||||
|
}
|
||||||
|
|
||||||
/// Peers whose last verified announce advertised the given capability.
|
/// Peers whose last verified announce advertised the given capability.
|
||||||
func peers(advertising capability: PeerCapabilities) -> [PeerID] {
|
func peers(advertising capability: PeerCapabilities) -> [PeerID] {
|
||||||
peers.values.filter { $0.capabilities.contains(capability) }.map(\.peerID)
|
peers.values.filter { $0.capabilities.contains(capability) }.map(\.peerID)
|
||||||
@@ -174,6 +181,14 @@ struct BLEPeerRegistry {
|
|||||||
peers[peerID] = peer
|
peers[peerID] = peer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replaces the announcement signing key only after the surrounding Noise
|
||||||
|
/// session proved possession of this peer's static key.
|
||||||
|
mutating func bindAuthenticatedSigningPublicKey(_ key: Data, for peerID: PeerID) {
|
||||||
|
guard var peer = peers[peerID.toShort()] else { return }
|
||||||
|
peer.signingPublicKey = key
|
||||||
|
peers[peer.peerID] = peer
|
||||||
|
}
|
||||||
|
|
||||||
mutating func upsertVerifiedAnnounce(
|
mutating func upsertVerifiedAnnounce(
|
||||||
peerID: PeerID,
|
peerID: PeerID,
|
||||||
nickname: String,
|
nickname: String,
|
||||||
@@ -181,7 +196,7 @@ struct BLEPeerRegistry {
|
|||||||
signingPublicKey: Data?,
|
signingPublicKey: Data?,
|
||||||
isConnected: Bool,
|
isConnected: Bool,
|
||||||
now: Date,
|
now: Date,
|
||||||
capabilities: PeerCapabilities = [],
|
capabilities: PeerCapabilities? = nil,
|
||||||
bridgeGeohash: String? = nil
|
bridgeGeohash: String? = nil
|
||||||
) -> BLEPeerAnnounceUpdate {
|
) -> BLEPeerAnnounceUpdate {
|
||||||
let existing = peers[peerID]
|
let existing = peers[peerID]
|
||||||
@@ -199,7 +214,8 @@ struct BLEPeerRegistry {
|
|||||||
signingPublicKey: signingPublicKey,
|
signingPublicKey: signingPublicKey,
|
||||||
isVerifiedNickname: true,
|
isVerifiedNickname: true,
|
||||||
lastSeen: now,
|
lastSeen: now,
|
||||||
capabilities: capabilities,
|
capabilities: capabilities ?? [],
|
||||||
|
capabilitiesWereExplicitlyAdvertised: capabilities != nil,
|
||||||
bridgeGeohash: bridgeGeohash
|
bridgeGeohash: bridgeGeohash
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+1806
-125
File diff suppressed because it is too large
Load Diff
@@ -45,6 +45,9 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
|
|
||||||
private var subscriptions = Set<AnyCancellable>()
|
private var subscriptions = Set<AnyCancellable>()
|
||||||
private var heartbeatTimer: GeohashPresenceTimerProtocol?
|
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 availableChannelsProvider: () -> [GeohashChannel]
|
||||||
private let locationChanges: AnyPublisher<[GeohashChannel], Never>
|
private let locationChanges: AnyPublisher<[GeohashChannel], Never>
|
||||||
private let torReadyPublisher: AnyPublisher<Void, Never>
|
private let torReadyPublisher: AnyPublisher<Void, Never>
|
||||||
@@ -147,10 +150,25 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
|
|
||||||
/// Start the service (safe to call multiple times)
|
/// Start the service (safe to call multiple times)
|
||||||
func start() {
|
func start() {
|
||||||
|
guard !started else { return }
|
||||||
|
started = true
|
||||||
|
heartbeatGeneration &+= 1
|
||||||
SecureLogger.info("Presence: service starting...", category: .session)
|
SecureLogger.info("Presence: service starting...", category: .session)
|
||||||
scheduleNextHeartbeat()
|
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() {
|
private func setupObservers() {
|
||||||
// Monitor location channel changes
|
// Monitor location channel changes
|
||||||
locationChanges
|
locationChanges
|
||||||
@@ -169,20 +187,26 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleLocationChange() {
|
func handleLocationChange() {
|
||||||
|
guard started else { return }
|
||||||
// When location changes, we trigger an immediate (but slightly delayed) heartbeat
|
// When location changes, we trigger an immediate (but slightly delayed) heartbeat
|
||||||
// to announce presence in the new zone, then reset the loop.
|
// to announce presence in the new zone, then reset the loop.
|
||||||
SecureLogger.debug("Presence: location changed, scheduling update", category: .session)
|
SecureLogger.debug("Presence: location changed, scheduling update", category: .session)
|
||||||
heartbeatTimer?.invalidate()
|
heartbeatTimer?.invalidate()
|
||||||
|
|
||||||
// Small delay to allow location state to settle
|
// Small delay to allow location state to settle
|
||||||
|
let generation = heartbeatGeneration
|
||||||
heartbeatTimer = scheduleTimer(5.0) { [weak self] in
|
heartbeatTimer = scheduleTimer(5.0) { [weak self] in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
self?.performHeartbeat()
|
guard let self,
|
||||||
|
self.started,
|
||||||
|
self.heartbeatGeneration == generation else { return }
|
||||||
|
self.performHeartbeat()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleConnectivityChange() {
|
func handleConnectivityChange() {
|
||||||
|
guard started else { return }
|
||||||
SecureLogger.debug("Presence: connectivity restored, triggering heartbeat", category: .session)
|
SecureLogger.debug("Presence: connectivity restored, triggering heartbeat", category: .session)
|
||||||
// If we were waiting for network, do it now
|
// If we were waiting for network, do it now
|
||||||
if heartbeatTimer == nil || !heartbeatTimer!.isValid {
|
if heartbeatTimer == nil || !heartbeatTimer!.isValid {
|
||||||
@@ -191,18 +215,29 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func scheduleNextHeartbeat() {
|
func scheduleNextHeartbeat() {
|
||||||
|
guard started else { return }
|
||||||
heartbeatTimer?.invalidate()
|
heartbeatTimer?.invalidate()
|
||||||
let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval)
|
let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval)
|
||||||
|
let generation = heartbeatGeneration
|
||||||
heartbeatTimer = scheduleTimer(interval) { [weak self] in
|
heartbeatTimer = scheduleTimer(interval) { [weak self] in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
self?.performHeartbeat()
|
guard let self,
|
||||||
|
self.started,
|
||||||
|
self.heartbeatGeneration == generation else { return }
|
||||||
|
self.performHeartbeat()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func performHeartbeat() {
|
func performHeartbeat() {
|
||||||
|
guard started else { return }
|
||||||
|
let generation = heartbeatGeneration
|
||||||
// Always schedule next loop first ensures continuity even if this one fails/skips
|
// Always schedule next loop first ensures continuity even if this one fails/skips
|
||||||
defer { scheduleNextHeartbeat() }
|
defer {
|
||||||
|
if started, heartbeatGeneration == generation {
|
||||||
|
scheduleNextHeartbeat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Check preconditions
|
// 1. Check preconditions
|
||||||
guard torIsReady() else {
|
guard torIsReady() else {
|
||||||
@@ -228,14 +263,27 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Launch independent task for each channel's delay
|
// 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
|
// Random delay for decorrelation
|
||||||
let delay = TimeInterval.random(in: self.burstMinDelay...self.burstMaxDelay)
|
await sleeper(nanoseconds)
|
||||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
|
||||||
await self.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)
|
self.broadcastPresence(for: channel.geohash)
|
||||||
}
|
}
|
||||||
|
pendingBroadcastTasks[taskID] = task
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,54 @@ import BitFoundation
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Security
|
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 {
|
final class KeychainManager: KeychainManagerProtocol {
|
||||||
/// Default keychain for components that construct their own rather than
|
/// Default keychain for components that construct their own rather than
|
||||||
/// having one injected. Under test this is an in-memory keychain: the
|
/// 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
|
// Use consistent service name for all keychain items
|
||||||
private let service = BitchatApp.bundleID
|
private let service = BitchatApp.bundleID
|
||||||
private let appGroup = "group.\(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
|
// AfterFirstUnlock, not WhenUnlocked: the mesh keeps running with the
|
||||||
// device locked (identity-cache saves failed with -25308 throughout
|
// device locked (identity-cache saves failed with -25308 throughout
|
||||||
// locked-phone testing), and a wake-on-proximity relaunch via BLE state
|
// locked-phone testing), and a wake-on-proximity relaunch via BLE state
|
||||||
// restoration must be able to read the noise keys before the user
|
// restoration must be able to read the noise keys before the user
|
||||||
// unlocks. Backup/sync semantics are unchanged (not ThisDeviceOnly).
|
// unlocks. ThisDeviceOnly prevents private identities and group keys from
|
||||||
private static let itemAccessibility = kSecAttrAccessibleAfterFirstUnlock
|
// migrating through device backups onto a second device.
|
||||||
|
private static let itemAccessibility = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
migrateAccessibilityIfNeeded()
|
if reconcileInstallLifecycle() {
|
||||||
|
migrateAccessibilityIfNeeded()
|
||||||
|
} else {
|
||||||
|
installAccessGate.block()
|
||||||
|
}
|
||||||
#endif
|
#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)
|
#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
|
/// One-time upgrade of items created under WhenUnlocked. New saves get
|
||||||
/// the right class on their own (saves are delete-then-add), but the
|
/// the right class on their own (saves are delete-then-add), but the
|
||||||
/// long-lived identity keys are written once and would otherwise stay
|
/// long-lived identity keys are written once and would otherwise stay
|
||||||
/// unreadable while the device is locked.
|
/// unreadable while the device is locked.
|
||||||
private func migrateAccessibilityIfNeeded() {
|
private func migrateAccessibilityIfNeeded() {
|
||||||
let flag = "keychain.accessibility.afterFirstUnlock.migrated"
|
let flag = "keychain.accessibility.afterFirstUnlockThisDeviceOnly.migrated"
|
||||||
guard !UserDefaults.standard.bool(forKey: flag) else { return }
|
guard !UserDefaults.standard.bool(forKey: flag) else { return }
|
||||||
|
|
||||||
let query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: service
|
|
||||||
]
|
|
||||||
let update: [String: Any] = [
|
let update: [String: Any] = [
|
||||||
kSecAttrAccessible as String: Self.itemAccessibility
|
kSecAttrAccessible as String: Self.itemAccessibility
|
||||||
]
|
]
|
||||||
let status = SecItemUpdate(query as CFDictionary, update as CFDictionary)
|
let completed = Self.migrateAccessibilityForApplicationOwnedServices(
|
||||||
switch status {
|
primaryService: service
|
||||||
case errSecSuccess, errSecItemNotFound:
|
) { serviceName in
|
||||||
// Nothing to migrate on a fresh install; both are terminal.
|
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)
|
UserDefaults.standard.set(true, forKey: flag)
|
||||||
SecureLogger.info("Keychain accessibility migrated to AfterFirstUnlock (status \(status))", category: .keychain)
|
SecureLogger.info(
|
||||||
default:
|
"Keychain accessibility migrated to AfterFirstUnlockThisDeviceOnly",
|
||||||
|
category: .keychain
|
||||||
|
)
|
||||||
|
} else {
|
||||||
// Likely errSecInteractionNotAllowed (relaunched while locked) —
|
// Likely errSecInteractionNotAllowed (relaunched while locked) —
|
||||||
// leave the flag unset so the next launch retries.
|
// 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
|
#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
|
// MARK: - Identity Keys
|
||||||
|
|
||||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
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 fullKey = "identity_\(key)"
|
||||||
let result = saveData(keyData, forKey: fullKey)
|
let result = saveData(keyData, forKey: fullKey)
|
||||||
SecureLogger.logKeyOperation(.save, keyType: key, success: result)
|
SecureLogger.logKeyOperation(.save, keyType: key, success: result)
|
||||||
@@ -95,11 +371,16 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getIdentityKey(forKey key: String) -> Data? {
|
func getIdentityKey(forKey key: String) -> Data? {
|
||||||
|
guard installAccessAllowed() else { return nil }
|
||||||
let fullKey = "identity_\(key)"
|
let fullKey = "identity_\(key)"
|
||||||
return retrieveData(forKey: fullKey)
|
return retrieveData(forKey: fullKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteIdentityKey(forKey key: String) -> Bool {
|
func deleteIdentityKey(forKey key: String) -> Bool {
|
||||||
|
guard installAccessAllowed() else {
|
||||||
|
SecureLogger.logKeyOperation(.delete, keyType: key, success: false)
|
||||||
|
return false
|
||||||
|
}
|
||||||
let result = delete(forKey: "identity_\(key)")
|
let result = delete(forKey: "identity_\(key)")
|
||||||
SecureLogger.logKeyOperation(.delete, keyType: key, success: result)
|
SecureLogger.logKeyOperation(.delete, keyType: key, success: result)
|
||||||
return result
|
return result
|
||||||
@@ -110,12 +391,14 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
/// Get identity key with detailed result for proper error handling
|
/// Get identity key with detailed result for proper error handling
|
||||||
/// Distinguishes between missing keys (expected) and critical failures
|
/// Distinguishes between missing keys (expected) and critical failures
|
||||||
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
|
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
|
||||||
|
guard installAccessAllowed() else { return .accessDenied }
|
||||||
let fullKey = "identity_\(key)"
|
let fullKey = "identity_\(key)"
|
||||||
return retrieveDataWithResult(forKey: fullKey)
|
return retrieveDataWithResult(forKey: fullKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save identity key with detailed result and retry logic for transient errors
|
/// Save identity key with detailed result and retry logic for transient errors
|
||||||
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
|
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
|
||||||
|
guard installAccessAllowed() else { return .accessDenied }
|
||||||
let fullKey = "identity_\(key)"
|
let fullKey = "identity_\(key)"
|
||||||
return saveDataWithResult(keyData, forKey: fullKey)
|
return saveDataWithResult(keyData, forKey: fullKey)
|
||||||
}
|
}
|
||||||
@@ -385,114 +668,165 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
// Delete ALL keychain data for panic mode
|
// Delete ALL keychain data for panic mode
|
||||||
func deleteAllKeychainData() -> Bool {
|
func deleteAllKeychainData() -> Bool {
|
||||||
SecureLogger.warning("Panic mode - deleting all keychain data", category: .security)
|
SecureLogger.warning("Panic mode - deleting all keychain data", category: .security)
|
||||||
|
|
||||||
var totalDeleted = 0
|
let ownedServices = Set(
|
||||||
|
Self.applicationOwnedKeychainServices(
|
||||||
// Search without service restriction to catch all items
|
primaryService: service
|
||||||
|
)
|
||||||
|
)
|
||||||
|
var enumerationCompleted = true
|
||||||
let searchQuery: [String: Any] = [
|
let searchQuery: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecMatchLimit as String: kSecMatchLimitAll,
|
kSecMatchLimit as String: kSecMatchLimitAll,
|
||||||
kSecReturnAttributes as String: true
|
kSecReturnAttributes as String: true
|
||||||
]
|
]
|
||||||
|
|
||||||
var result: AnyObject?
|
var result: AnyObject?
|
||||||
let searchStatus = SecItemCopyMatching(searchQuery as CFDictionary, &result)
|
let searchStatus = SecItemCopyMatching(
|
||||||
|
searchQuery as CFDictionary,
|
||||||
if searchStatus == errSecSuccess, let items = result as? [[String: Any]] {
|
&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 {
|
for item in items {
|
||||||
var shouldDelete = false
|
let account =
|
||||||
let account = item[kSecAttrAccount as String] as? String ?? ""
|
item[kSecAttrAccount as String] as? String ?? ""
|
||||||
let service = item[kSecAttrService as String] as? String ?? ""
|
let itemService =
|
||||||
let accessGroup = item[kSecAttrAccessGroup as String] as? String
|
item[kSecAttrService as String] as? String ?? ""
|
||||||
|
let accessGroup =
|
||||||
// More precise deletion criteria:
|
item[kSecAttrAccessGroup as String] as? String
|
||||||
// 1. Check for our specific app group
|
guard accessGroup == appGroup
|
||||||
// 2. OR check for our exact service name
|
|| ownedServices.contains(itemService)
|
||||||
// 3. OR check for known legacy service names
|
else {
|
||||||
if accessGroup == appGroup {
|
continue
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if shouldDelete {
|
var deleteQuery: [String: Any] = [
|
||||||
// Build delete query with all available attributes for precise deletion
|
kSecClass as String: kSecClassGenericPassword
|
||||||
var deleteQuery: [String: Any] = [
|
]
|
||||||
kSecClass as String: kSecClassGenericPassword
|
if !account.isEmpty {
|
||||||
]
|
deleteQuery[kSecAttrAccount as String] = account
|
||||||
|
}
|
||||||
if !account.isEmpty {
|
if !itemService.isEmpty {
|
||||||
deleteQuery[kSecAttrAccount as String] = account
|
deleteQuery[kSecAttrService as String] = itemService
|
||||||
}
|
}
|
||||||
if !service.isEmpty {
|
if let accessGroup,
|
||||||
deleteQuery[kSecAttrService as String] = service
|
!accessGroup.isEmpty,
|
||||||
}
|
accessGroup != "test" {
|
||||||
|
deleteQuery[kSecAttrAccessGroup as String] = accessGroup
|
||||||
// Add access group if present
|
}
|
||||||
if let accessGroup = item[kSecAttrAccessGroup as String] as? String,
|
|
||||||
!accessGroup.isEmpty && accessGroup != "test" {
|
let status = SecItemDelete(deleteQuery as CFDictionary)
|
||||||
deleteQuery[kSecAttrAccessGroup as String] = accessGroup
|
if status != errSecSuccess && status != errSecItemNotFound {
|
||||||
}
|
enumerationCompleted = false
|
||||||
|
SecureLogger.error(
|
||||||
let deleteStatus = SecItemDelete(deleteQuery as CFDictionary)
|
NSError(domain: "Keychain", code: Int(status)),
|
||||||
if deleteStatus == errSecSuccess {
|
context: "Unable to delete enumerated application-owned keychain item",
|
||||||
totalDeleted += 1
|
category: .keychain
|
||||||
SecureLogger.info("Deleted keychain item: \(account) from \(service)", 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
|
// Bulk deletion by every application-owned service is authoritative
|
||||||
// This catches any items that might have been missed above
|
// and idempotent. It also verifies that every known service scope is
|
||||||
let knownServices = [
|
// empty even when the inventory pass found no items.
|
||||||
self.service, // Current service name
|
let servicesCompleted =
|
||||||
"com.bitchat.passwords",
|
Self.deleteApplicationOwnedKeychainServices(
|
||||||
"com.bitchat.deviceidentity",
|
primaryService: service
|
||||||
"com.bitchat.noise.identity",
|
) { serviceName in
|
||||||
"chat.bitchat.passwords",
|
let query: [String: Any] = [
|
||||||
"chat.bitchat.nostr",
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
"bitchat.keychain",
|
kSecAttrService as String: serviceName
|
||||||
"bitchat",
|
]
|
||||||
"com.bitchat"
|
let status = SecItemDelete(query as CFDictionary)
|
||||||
]
|
if status != errSecSuccess && status != errSecItemNotFound {
|
||||||
|
SecureLogger.error(
|
||||||
for serviceName in knownServices {
|
NSError(domain: "Keychain", code: Int(status)),
|
||||||
let query: [String: Any] = [
|
context: "Unable to delete application-owned keychain service \(serviceName)",
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
category: .keychain
|
||||||
kSecAttrService as String: serviceName
|
)
|
||||||
]
|
}
|
||||||
|
return status
|
||||||
let status = SecItemDelete(query as CFDictionary)
|
|
||||||
if status == errSecSuccess {
|
|
||||||
totalDeleted += 1
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// Historical builds attempted this application-group identifier as a
|
||||||
// Also delete by app group to ensure complete cleanup
|
// keychain access group. It is not currently entitled, so -34018
|
||||||
|
// means the scope is inapplicable rather than partially deleted.
|
||||||
let groupQuery: [String: Any] = [
|
let groupQuery: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrAccessGroup as String: appGroup
|
kSecAttrAccessGroup as String: appGroup
|
||||||
]
|
]
|
||||||
|
|
||||||
let groupStatus = SecItemDelete(groupQuery as CFDictionary)
|
let groupStatus = SecItemDelete(groupQuery as CFDictionary)
|
||||||
if groupStatus == errSecSuccess {
|
let groupCompleted = Self.completedApplicationGroupDelete(
|
||||||
totalDeleted += 1
|
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)
|
var markerCompleted = true
|
||||||
|
#if os(iOS)
|
||||||
return totalDeleted > 0
|
// 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
|
// MARK: - Security Utilities
|
||||||
@@ -518,6 +852,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
// MARK: - Debug
|
// MARK: - Debug
|
||||||
|
|
||||||
func verifyIdentityKeyExists() -> Bool {
|
func verifyIdentityKeyExists() -> Bool {
|
||||||
|
guard installAccessAllowed() else { return false }
|
||||||
let key = "identity_noiseStaticKey"
|
let key = "identity_noiseStaticKey"
|
||||||
return retrieveData(forKey: key) != nil
|
return retrieveData(forKey: key) != nil
|
||||||
}
|
}
|
||||||
@@ -526,18 +861,40 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
|
|
||||||
/// Save data with a custom service name
|
/// Save data with a custom service name
|
||||||
func save(key: String, data: Data, service customService: String, accessible: CFString?) {
|
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,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrService as String: customService,
|
kSecAttrService as String: customService,
|
||||||
kSecAttrAccount as String: key,
|
kSecAttrAccount as String: key
|
||||||
kSecValueData as String: data
|
|
||||||
]
|
]
|
||||||
if let accessible = accessible {
|
var addQuery = primaryKeyQuery
|
||||||
query[kSecAttrAccessible as String] = accessible
|
addQuery.merge([
|
||||||
}
|
kSecValueData as String: data,
|
||||||
|
kSecAttrAccessible as String: accessible ?? Self.itemAccessibility,
|
||||||
|
kSecAttrSynchronizable as String: false
|
||||||
|
]) { _, new in new }
|
||||||
|
|
||||||
SecItemDelete(query as CFDictionary)
|
// Delete by the item's primary key only. Value/accessibility fields
|
||||||
SecItemAdd(query as CFDictionary, nil)
|
// 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
|
/// Load data from a custom service
|
||||||
@@ -551,6 +908,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
/// Load custom-service data without collapsing `itemNotFound` and
|
/// Load custom-service data without collapsing `itemNotFound` and
|
||||||
/// protected-data/keychain failures into the same nil result.
|
/// protected-data/keychain failures into the same nil result.
|
||||||
func loadWithResult(key: String, service customService: String) -> KeychainReadResult {
|
func loadWithResult(key: String, service customService: String) -> KeychainReadResult {
|
||||||
|
guard installAccessAllowed() else { return .accessDenied }
|
||||||
let query: [String: Any] = [
|
let query: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrService as String: customService,
|
kSecAttrService as String: customService,
|
||||||
@@ -565,6 +923,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
|
|
||||||
/// Delete data from a custom service
|
/// Delete data from a custom service
|
||||||
func delete(key: String, service customService: String) {
|
func delete(key: String, service customService: String) {
|
||||||
|
guard installAccessAllowed() else { return }
|
||||||
let query: [String: Any] = [
|
let query: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrService as String: customService,
|
kSecAttrService as String: customService,
|
||||||
@@ -576,6 +935,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
|
|
||||||
/// Delete every item stored under a custom service
|
/// Delete every item stored under a custom service
|
||||||
func deleteAll(service customService: String) {
|
func deleteAll(service customService: String) {
|
||||||
|
guard installAccessAllowed() else { return }
|
||||||
let query: [String: Any] = [
|
let query: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrService as String: customService,
|
kSecAttrService as String: customService,
|
||||||
|
|||||||
@@ -251,9 +251,9 @@ final class MessageFormattingEngine {
|
|||||||
isSelf: Bool,
|
isSelf: Bool,
|
||||||
isMentioned: Bool
|
isMentioned: Bool
|
||||||
) -> AttributedString {
|
) -> AttributedString {
|
||||||
// For very long content without special tokens, use plain formatting
|
// For very long content, use plain formatting to avoid expensive
|
||||||
let containsCashu = containsCashuToken(content)
|
// regex/detector work. Cashu presence must not disable this guard.
|
||||||
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashu {
|
if content.isOversizedForRichFormatting() {
|
||||||
return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf)
|
return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -154,6 +154,19 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
.store(in: &cancellables)
|
.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) {
|
func setUserTorEnabled(_ enabled: Bool) {
|
||||||
guard enabled != userTorEnabled else { return }
|
guard enabled != userTorEnabled else { return }
|
||||||
userTorEnabled = enabled
|
userTorEnabled = enabled
|
||||||
@@ -167,6 +180,7 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func reevaluate() {
|
private func reevaluate() {
|
||||||
|
guard started else { return }
|
||||||
let allowed = effectiveAllowed()
|
let allowed = effectiveAllowed()
|
||||||
let torDesired = allowed && userTorEnabled
|
let torDesired = allowed && userTorEnabled
|
||||||
let statusChanged = allowed != activationAllowed
|
let statusChanged = allowed != activationAllowed
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ protocol NetworkReachabilityMonitoring: AnyObject {
|
|||||||
var reachabilityPublisher: AnyPublisher<Bool, Never> { get }
|
var reachabilityPublisher: AnyPublisher<Bool, Never> { get }
|
||||||
/// Begin monitoring. Idempotent.
|
/// Begin monitoring. Idempotent.
|
||||||
func start()
|
func start()
|
||||||
|
/// Stop monitoring and discard pending debounce work. Idempotent.
|
||||||
|
func stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pure debounce/decision logic for reachability, split out so it can be
|
/// 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
|
/// `NWPathMonitor`-backed reachability. All state lives on the main actor; the
|
||||||
/// background path callback hops here before touching the debounce.
|
/// background path callback hops here before touching the debounce.
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -146,6 +136,18 @@ final class NWPathReachabilityMonitor: NetworkReachabilityMonitoring {
|
|||||||
#endif
|
#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.
|
/// Feed an observation into the debounce and publish committed changes.
|
||||||
/// Exposed internally so higher layers/tests could drive it if needed.
|
/// Exposed internally so higher layers/tests could drive it if needed.
|
||||||
func ingest(reachable: Bool) {
|
func ingest(reachable: Bool) {
|
||||||
|
|||||||
@@ -165,7 +165,6 @@ final class NoiseEncryptionService {
|
|||||||
// Peer fingerprints (SHA256 hash of static public key)
|
// Peer fingerprints (SHA256 hash of static public key)
|
||||||
private var peerFingerprints: [PeerID: String] = [:]
|
private var peerFingerprints: [PeerID: String] = [:]
|
||||||
private var fingerprintToPeerID: [String: PeerID] = [:]
|
private var fingerprintToPeerID: [String: PeerID] = [:]
|
||||||
|
|
||||||
// Thread safety
|
// Thread safety
|
||||||
private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent)
|
private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent)
|
||||||
|
|
||||||
@@ -183,12 +182,18 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication
|
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
|
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
|
||||||
|
/// Automatic rekey removed the old session and produced XX message 1.
|
||||||
|
/// The transport must clear session-scoped state and put these exact bytes
|
||||||
|
/// on the wire; merely reporting "handshake required" strands the partial
|
||||||
|
/// initiator session because a second initiate call sees it already exists.
|
||||||
|
var onRekeyHandshakeReady: ((_ peerID: PeerID, _ message: Data) -> Void)?
|
||||||
|
|
||||||
// Add a handler for peer authentication
|
// Add a handler for peer authentication
|
||||||
func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) {
|
func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) {
|
||||||
serviceQueue.async(flags: .barrier) { [weak self] in
|
serviceQueue.sync(flags: .barrier) {
|
||||||
self?.onPeerAuthenticatedHandlers.append(handler)
|
onPeerAuthenticatedHandlers.append(handler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,6 +206,18 @@ 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) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
@@ -295,8 +312,12 @@ final class NoiseEncryptionService {
|
|||||||
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain)
|
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain)
|
||||||
|
|
||||||
// Set up session callbacks
|
// Set up session callbacks
|
||||||
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in
|
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey, generation in
|
||||||
self?.handleSessionEstablished(peerID: peerID, remoteStaticKey: remoteStaticKey)
|
self?.handleSessionEstablished(
|
||||||
|
peerID: peerID,
|
||||||
|
remoteStaticKey: remoteStaticKey,
|
||||||
|
sessionGeneration: generation
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start session maintenance timer
|
// Start session maintenance timer
|
||||||
@@ -664,6 +685,18 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
/// Process an incoming handshake message
|
/// Process an incoming handshake message
|
||||||
func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? {
|
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
|
// Validate peer ID
|
||||||
guard peerID.isValid else {
|
guard peerID.isValid else {
|
||||||
@@ -685,11 +718,14 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
// For handshakes, we process the raw data directly without NoiseMessage wrapper
|
// For handshakes, we process the raw data directly without NoiseMessage wrapper
|
||||||
// The Noise protocol handles its own message format
|
// 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 raw response without wrapper
|
||||||
return responsePayload
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if we have an established session with a peer
|
/// Check if we have an established session with a peer
|
||||||
@@ -725,11 +761,56 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
return try sessionManager.encrypt(data, for: peerID)
|
return try sessionManager.encrypt(data, for: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Encrypts a finalized private-media packet. Ordinary Noise application
|
||||||
|
/// messages retain the 64 KiB ceiling; this purpose-specific path permits
|
||||||
|
/// the bounded `BitchatFilePacket` envelope and refuses every other typed
|
||||||
|
/// payload so the larger allocation budget cannot become a generic bypass.
|
||||||
|
func encryptPrivateFilePayload(
|
||||||
|
_ data: Data,
|
||||||
|
for peerID: PeerID,
|
||||||
|
sessionGeneration: UUID? = nil
|
||||||
|
) throws -> Data {
|
||||||
|
guard NoisePayloadType.isPrivateFile(rawValue: data.first),
|
||||||
|
NoiseSecurityValidator.validatePrivateFileMessageSize(data) else {
|
||||||
|
throw NoiseSecurityError.messageTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
guard rateLimiter.allowMessage(from: peerID) else {
|
||||||
|
throw NoiseSecurityError.rateLimitExceeded
|
||||||
|
}
|
||||||
|
|
||||||
|
guard hasEstablishedSession(with: peerID) else {
|
||||||
|
onHandshakeRequired?(peerID)
|
||||||
|
throw NoiseEncryptionError.handshakeRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
// `maxPrivateFilePlaintextSize` already subtracts the cipher's fixed
|
||||||
|
// nonce/tag overhead, so the result is bounded without a second copy.
|
||||||
|
if let sessionGeneration {
|
||||||
|
return try sessionManager.encrypt(
|
||||||
|
data,
|
||||||
|
for: peerID,
|
||||||
|
expectedSessionGeneration: sessionGeneration
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return try sessionManager.encrypt(data, for: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
/// Decrypt data from a specific peer
|
/// Decrypt data from a specific peer
|
||||||
func decrypt(_ data: Data, from peerID: PeerID) throws -> Data {
|
func decrypt(_ data: Data, from peerID: PeerID) throws -> Data {
|
||||||
// Validate message size
|
try decryptWithSessionGeneration(data, from: peerID).plaintext
|
||||||
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
}
|
||||||
|
|
||||||
|
func decryptWithSessionGeneration(
|
||||||
|
_ data: Data,
|
||||||
|
from peerID: PeerID
|
||||||
|
) throws -> (plaintext: Data, sessionGeneration: UUID) {
|
||||||
|
// Standard transport ciphertext has 20 bytes of nonce/tag overhead.
|
||||||
|
// A larger candidate is admitted only up to the framed-file ceiling;
|
||||||
|
// after authenticated decryption it must prove it is `.privateFile`.
|
||||||
|
let isStandardCiphertext = NoiseSecurityValidator.validateCiphertextSize(data)
|
||||||
|
guard isStandardCiphertext || NoiseSecurityValidator.validatePrivateFileCiphertextSize(data) else {
|
||||||
throw NoiseSecurityError.messageTooLarge
|
throw NoiseSecurityError.messageTooLarge
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -743,7 +824,14 @@ final class NoiseEncryptionService {
|
|||||||
throw NoiseEncryptionError.sessionNotEstablished
|
throw NoiseEncryptionError.sessionNotEstablished
|
||||||
}
|
}
|
||||||
|
|
||||||
return try sessionManager.decrypt(data, from: peerID)
|
let result = try sessionManager.decryptWithSessionGeneration(data, from: peerID)
|
||||||
|
if !isStandardCiphertext {
|
||||||
|
guard NoisePayloadType.isPrivateFile(rawValue: result.plaintext.first),
|
||||||
|
NoiseSecurityValidator.validatePrivateFileMessageSize(result.plaintext) else {
|
||||||
|
throw NoiseSecurityError.messageTooLarge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Peer Management
|
// MARK: - Peer Management
|
||||||
@@ -755,6 +843,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() {
|
func clearEphemeralStateForPanic() {
|
||||||
sessionManager.removeAllSessions()
|
sessionManager.removeAllSessions()
|
||||||
serviceQueue.sync(flags: .barrier) {
|
serviceQueue.sync(flags: .barrier) {
|
||||||
@@ -777,24 +884,36 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
// MARK: - Private Helpers
|
// 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
|
// Calculate fingerprint
|
||||||
let fingerprint = remoteStaticKey.rawRepresentation.sha256Fingerprint()
|
let fingerprint = remoteStaticKey.rawRepresentation.sha256Fingerprint()
|
||||||
|
|
||||||
// Store fingerprint mapping
|
// Registering handlers is synchronous, and this barrier snapshots them
|
||||||
serviceQueue.sync(flags: .barrier) {
|
// 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
|
peerFingerprints[peerID] = fingerprint
|
||||||
fingerprintToPeerID[fingerprint] = peerID
|
fingerprintToPeerID[fingerprint] = peerID
|
||||||
|
return (onPeerAuthenticatedWithGenerationHandlers, onPeerAuthenticatedHandlers)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log security event
|
// Log security event
|
||||||
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||||
|
|
||||||
// Notify all handlers about authentication
|
// Notify all handlers about authentication.
|
||||||
serviceQueue.async { [weak self] in
|
handlers.generationAware.forEach { handler in
|
||||||
self?.onPeerAuthenticatedHandlers.forEach { handler in
|
handler(peerID, fingerprint, sessionGeneration)
|
||||||
handler(peerID, fingerprint)
|
}
|
||||||
}
|
handlers.legacy.forEach { handler in
|
||||||
|
handler(peerID, fingerprint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -815,19 +934,26 @@ final class NoiseEncryptionService {
|
|||||||
let sessionsNeedingRekey = sessionManager.getSessionsNeedingRekey()
|
let sessionsNeedingRekey = sessionManager.getSessionsNeedingRekey()
|
||||||
|
|
||||||
for (peerID, needsRekey) in sessionsNeedingRekey where needsRekey {
|
for (peerID, needsRekey) in sessionsNeedingRekey where needsRekey {
|
||||||
|
|
||||||
// Attempt to rekey the session
|
|
||||||
do {
|
do {
|
||||||
try sessionManager.initiateRekey(for: peerID)
|
try initiateAutomaticRekey(for: peerID)
|
||||||
SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security)
|
|
||||||
|
|
||||||
// Signal that handshake is needed
|
|
||||||
onHandshakeRequired?(peerID)
|
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error(error, context: "Failed to initiate rekey for peer: \(peerID)", category: .session)
|
SecureLogger.error(error, context: "Failed to initiate rekey for peer: \(peerID)", category: .session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func initiateAutomaticRekey(for peerID: PeerID) throws {
|
||||||
|
let handshakeMessage = try sessionManager.initiateRekey(for: peerID)
|
||||||
|
SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security)
|
||||||
|
onRekeyHandshakeReady?(peerID, handshakeMessage)
|
||||||
|
onHandshakeRequired?(peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
func _test_initiateAutomaticRekey(for peerID: PeerID) throws {
|
||||||
|
try initiateAutomaticRekey(for: peerID)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
stopRekeyTimer()
|
stopRekeyTimer()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ final class TransferProgressManager {
|
|||||||
case updated(id: String, sentFragments: Int, totalFragments: Int)
|
case updated(id: String, sentFragments: Int, totalFragments: Int)
|
||||||
case completed(id: String, totalFragments: Int)
|
case completed(id: String, totalFragments: Int)
|
||||||
case cancelled(id: String, sentFragments: Int, totalFragments: Int)
|
case cancelled(id: String, sentFragments: Int, totalFragments: Int)
|
||||||
|
case rejected(id: String, reason: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
private let subject = PassthroughSubject<Event, Never>()
|
private let subject = PassthroughSubject<Event, Never>()
|
||||||
@@ -49,6 +50,17 @@ final class TransferProgressManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fails a preflight check while keeping the outgoing placeholder visible
|
||||||
|
/// with an actionable reason instead of treating policy/size rejection as
|
||||||
|
/// a user cancellation.
|
||||||
|
func rejectBeforeStart(id: String, reason: String) {
|
||||||
|
queue.async(flags: .barrier) { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.states.removeValue(forKey: id)
|
||||||
|
self.subject.send(.rejected(id: id, reason: reason))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func snapshot(id: String) -> (sent: Int, total: Int)? {
|
func snapshot(id: String) -> (sent: Int, total: Int)? {
|
||||||
var result: (sent: Int, total: Int)?
|
var result: (sent: Int, total: Int)?
|
||||||
queue.sync {
|
queue.sync {
|
||||||
|
|||||||
@@ -83,6 +83,20 @@ enum TransportEvent: @unchecked Sendable {
|
|||||||
case bluetoothStateUpdated(CBManagerState)
|
case bluetoothStateUpdated(CBManagerState)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Downgrade-safe decision for a private-media recipient. Callers ask before
|
||||||
|
/// prompting, and BLEService checks again when it consumes any one-shot
|
||||||
|
/// legacy consent.
|
||||||
|
enum PrivateMediaSendPolicy: Equatable {
|
||||||
|
case encrypted
|
||||||
|
/// A public announce hinted at encrypted media (or a prior authenticated
|
||||||
|
/// pin exists), but this exact Noise session has not yet supplied its
|
||||||
|
/// authenticated peer-state proof. Callers wait boundedly; they must not
|
||||||
|
/// pre-queue encrypted bytes or silently select the legacy path.
|
||||||
|
case awaitingCapabilityProof
|
||||||
|
case legacyRequiresConsent
|
||||||
|
case blockedDowngrade
|
||||||
|
}
|
||||||
|
|
||||||
protocol TransportEventDelegate: AnyObject {
|
protocol TransportEventDelegate: AnyObject {
|
||||||
@MainActor func didReceiveTransportEvent(_ event: TransportEvent)
|
@MainActor func didReceiveTransportEvent(_ event: TransportEvent)
|
||||||
}
|
}
|
||||||
@@ -163,6 +177,12 @@ protocol Transport: AnyObject {
|
|||||||
func sendDeliveryAck(for messageID: String, to peerID: PeerID)
|
func sendDeliveryAck(for messageID: String, to peerID: PeerID)
|
||||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
|
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
|
||||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
|
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
|
||||||
|
func sendFilePrivate(
|
||||||
|
_ packet: BitchatFilePacket,
|
||||||
|
to peerID: PeerID,
|
||||||
|
transferId: String,
|
||||||
|
allowLegacyFallback: Bool
|
||||||
|
)
|
||||||
func cancelTransfer(_ transferId: String)
|
func cancelTransfer(_ transferId: String)
|
||||||
|
|
||||||
// Live voice / push-to-talk (mesh transports only): one encoded
|
// Live voice / push-to-talk (mesh transports only): one encoded
|
||||||
@@ -208,6 +228,11 @@ protocol Transport: AnyObject {
|
|||||||
/// Capabilities the peer advertised in its last verified announce;
|
/// Capabilities the peer advertised in its last verified announce;
|
||||||
/// empty for peers that predate the capabilities TLV.
|
/// empty for peers that predate the capabilities TLV.
|
||||||
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities
|
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities
|
||||||
|
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
|
||||||
|
func resolvePrivateMediaSendPolicy(
|
||||||
|
to peerID: PeerID,
|
||||||
|
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||||
|
)
|
||||||
/// Sends an encoded vouch-attestation batch inside the Noise session.
|
/// Sends an encoded vouch-attestation batch inside the Noise session.
|
||||||
func sendVouchAttestations(_ payload: Data, to peerID: PeerID)
|
func sendVouchAttestations(_ payload: Data, to peerID: PeerID)
|
||||||
/// Appends a peer-authenticated observer. Unlike
|
/// Appends a peer-authenticated observer. Unlike
|
||||||
@@ -278,6 +303,16 @@ extension Transport {
|
|||||||
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {}
|
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {}
|
||||||
func broadcastGroupMessage(_ envelope: Data) {}
|
func broadcastGroupMessage(_ envelope: Data) {}
|
||||||
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
|
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
|
||||||
|
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { .blockedDowngrade }
|
||||||
|
func resolvePrivateMediaSendPolicy(
|
||||||
|
to peerID: PeerID,
|
||||||
|
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||||
|
) {
|
||||||
|
let policy = privateMediaSendPolicy(to: peerID)
|
||||||
|
Task { @MainActor in
|
||||||
|
completion(policy == .awaitingCapabilityProof ? .blockedDowngrade : policy)
|
||||||
|
}
|
||||||
|
}
|
||||||
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {}
|
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {}
|
||||||
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {}
|
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {}
|
||||||
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
|
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
|
||||||
@@ -294,6 +329,15 @@ extension Transport {
|
|||||||
func currentMeshTopology() -> MeshTopologySnapshot? { nil }
|
func currentMeshTopology() -> MeshTopologySnapshot? { nil }
|
||||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
||||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
||||||
|
func sendFilePrivate(
|
||||||
|
_ packet: BitchatFilePacket,
|
||||||
|
to peerID: PeerID,
|
||||||
|
transferId: String,
|
||||||
|
allowLegacyFallback: Bool
|
||||||
|
) {
|
||||||
|
guard !allowLegacyFallback else { return }
|
||||||
|
sendFilePrivate(packet, to: peerID, transferId: transferId)
|
||||||
|
}
|
||||||
func cancelTransfer(_ transferId: String) {}
|
func cancelTransfer(_ transferId: String) {}
|
||||||
|
|
||||||
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
|
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ enum TransportConfig {
|
|||||||
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
|
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
|
||||||
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
|
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
|
||||||
static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends
|
static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends
|
||||||
|
// Bounded wait for the session-authenticated capability proof used by
|
||||||
|
// private-media migration. Expiry never auto-sends clear bytes; it only
|
||||||
|
// resolves to the existing one-shot consent or downgrade-blocked path.
|
||||||
|
static let privateMediaCapabilityProofTimeoutSeconds: TimeInterval = 5
|
||||||
|
static let privateMediaCapabilityProofPendingPeerCap: Int = 64
|
||||||
|
static let privateMediaCapabilityProofWaitersPerPeerCap: Int = 16
|
||||||
static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
|
static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
|
||||||
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
|
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
|
||||||
// Fragment relay TTL in sparse graphs; matches messageTTLDefault so media
|
// Fragment relay TTL in sparse graphs; matches messageTTLDefault so media
|
||||||
@@ -46,6 +52,7 @@ enum TransportConfig {
|
|||||||
static let privateChatCap: Int = 1337
|
static let privateChatCap: Int = 1337
|
||||||
static let meshTimelineCap: Int = 1337
|
static let meshTimelineCap: Int = 1337
|
||||||
static let geoTimelineCap: Int = 1337
|
static let geoTimelineCap: Int = 1337
|
||||||
|
static let geoNicknameParticipantsCap: Int = 1337
|
||||||
static let contentLRUCap: Int = 2000
|
static let contentLRUCap: Int = 2000
|
||||||
static let geoSamplingEventLRUCap: Int = 2000
|
static let geoSamplingEventLRUCap: Int = 2000
|
||||||
|
|
||||||
@@ -81,6 +88,11 @@ enum TransportConfig {
|
|||||||
static let nostrDuplicateEventLogInterval: Int = 50
|
static let nostrDuplicateEventLogInterval: Int = 50
|
||||||
// Sample interval for per-event debug logs on the inbound hot path.
|
// Sample interval for per-event debug logs on the inbound hot path.
|
||||||
static let nostrInboundEventLogInterval: Int = 100
|
static let nostrInboundEventLogInterval: Int = 100
|
||||||
|
// Reject oversized/untrusted relay frames before JSON parse / store.
|
||||||
|
static let nostrMaxInboundMessageBytes: Int = 256 * 1024
|
||||||
|
static let nostrMaxEventTags: Int = 64
|
||||||
|
static let nostrMaxEventTagValues: Int = 16
|
||||||
|
static let nostrMaxEventTagValueBytes: Int = 1024
|
||||||
|
|
||||||
// Conversation store diagnostics (field observability)
|
// Conversation store diagnostics (field observability)
|
||||||
// Sample interval for the periodic store-audit "OK" heartbeat line
|
// Sample interval for the periodic store-audit "OK" heartbeat line
|
||||||
@@ -98,11 +110,16 @@ enum TransportConfig {
|
|||||||
static let uiSenderRateBucketRefillPerSec: Double = 1.0
|
static let uiSenderRateBucketRefillPerSec: Double = 1.0
|
||||||
static let uiContentRateBucketCapacity: Double = 3
|
static let uiContentRateBucketCapacity: Double = 3
|
||||||
static let uiContentRateBucketRefillPerSec: Double = 0.5
|
static let uiContentRateBucketRefillPerSec: Double = 0.5
|
||||||
|
// Bound attacker-keyed bucket maps (sender IDs / content digests).
|
||||||
|
static let uiSenderRateBucketMaxEntries: Int = 2000
|
||||||
|
static let uiContentRateBucketMaxEntries: Int = 2000
|
||||||
|
static let uiRateBucketIdleTTL: TimeInterval = 10 * 60
|
||||||
|
// Cap teleported-participant markers so remote events cannot grow the set.
|
||||||
|
static let geoTeleportedParticipantsCap: Int = 1337
|
||||||
|
|
||||||
// UI sleeps/delays
|
// UI sleeps/delays
|
||||||
static let uiStartupInitialDelaySeconds: TimeInterval = 1.0
|
static let uiStartupInitialDelaySeconds: TimeInterval = 1.0
|
||||||
static let uiStartupPhaseDurationSeconds: TimeInterval = 2.0
|
static let uiStartupPhaseDurationSeconds: TimeInterval = 2.0
|
||||||
static let uiAsyncShortSleepNs: UInt64 = 100_000_000
|
|
||||||
static let uiReadReceiptRetryShortSeconds: TimeInterval = 0.1
|
static let uiReadReceiptRetryShortSeconds: TimeInterval = 0.1
|
||||||
static let uiReadReceiptRetryLongSeconds: TimeInterval = 0.5
|
static let uiReadReceiptRetryLongSeconds: TimeInterval = 0.5
|
||||||
static let uiBatchDispatchStaggerSeconds: TimeInterval = 0.15
|
static let uiBatchDispatchStaggerSeconds: TimeInterval = 0.15
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// In-app override for the UI language, on top of the system per-app
|
||||||
|
/// language. Apple resolves localization from the AppleLanguages default at
|
||||||
|
/// process start, so a new choice takes effect on the next launch — callers
|
||||||
|
/// surface a "restart to apply" note after changing it.
|
||||||
|
enum AppLanguageSettings {
|
||||||
|
/// "" means no override: follow the device (or per-app system) language.
|
||||||
|
static let overrideKey = "app.languageOverride"
|
||||||
|
private static let appleLanguagesKey = "AppleLanguages"
|
||||||
|
|
||||||
|
/// Language codes the app ships translations for, straight from the
|
||||||
|
/// built bundle so this never drifts from the string catalog.
|
||||||
|
static var availableLanguages: [String] {
|
||||||
|
Bundle.main.localizations
|
||||||
|
.filter { $0 != "Base" }
|
||||||
|
.sorted { endonym(for: $0).localizedCaseInsensitiveCompare(endonym(for: $1)) == .orderedAscending }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The language's name in that language ("فارسی", "한국어") so every user
|
||||||
|
/// can find their own entry regardless of the current UI language.
|
||||||
|
static func endonym(for code: String) -> String {
|
||||||
|
let locale = Locale(identifier: code)
|
||||||
|
let name = locale.localizedString(forIdentifier: code) ?? code
|
||||||
|
return name.lowercased(with: locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persists the override (nil clears it). AppleLanguages drives the
|
||||||
|
/// actual localization lookup on next launch.
|
||||||
|
static func setOverride(_ code: String?) {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
if let code, !code.isEmpty {
|
||||||
|
defaults.set(code, forKey: overrideKey)
|
||||||
|
defaults.set([code], forKey: appleLanguagesKey)
|
||||||
|
} else {
|
||||||
|
defaults.removeObject(forKey: overrideKey)
|
||||||
|
defaults.removeObject(forKey: appleLanguagesKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -226,6 +226,21 @@ final class ChatLiveVoiceCoordinator {
|
|||||||
assemblies.values.contains { $0.messageID == message.id }
|
assemblies.values.contains { $0.messageID == message.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stop every live file handle/player before the panic media directory is
|
||||||
|
/// removed. This prevents an in-flight assembly from continuing to write
|
||||||
|
/// through an unlinked file after the wipe returns.
|
||||||
|
func resetForPanic() {
|
||||||
|
for assembly in Array(assemblies.values) {
|
||||||
|
cancelAssembly(assembly)
|
||||||
|
}
|
||||||
|
for player in drainingPlayers.values {
|
||||||
|
player.stop()
|
||||||
|
}
|
||||||
|
drainingPlayers.removeAll(keepingCapacity: false)
|
||||||
|
finishedBursts.removeAll(keepingCapacity: false)
|
||||||
|
updatePublicTalkerIndicator()
|
||||||
|
}
|
||||||
|
|
||||||
/// Called for every inbound private message: when it is the finalized
|
/// Called for every inbound private message: when it is the finalized
|
||||||
/// voice note of a burst we assembled (matched by burst ID in the file
|
/// voice note of a burst we assembled (matched by burst ID in the file
|
||||||
/// name), swap it into the existing live bubble and report `true` so the
|
/// name), swap it into the existing live bubble and report `true` so the
|
||||||
|
|||||||
@@ -6,6 +6,19 @@ import Foundation
|
|||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
struct LegacyPrivateMediaConsentRequest: Identifiable, Equatable {
|
||||||
|
let id: UUID
|
||||||
|
let peerID: PeerID
|
||||||
|
let peerName: String
|
||||||
|
let transferId: String
|
||||||
|
let messageID: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PendingLegacyPrivateMediaConsent {
|
||||||
|
let request: LegacyPrivateMediaConsentRequest
|
||||||
|
let completion: @MainActor (Bool) -> Void
|
||||||
|
}
|
||||||
|
|
||||||
/// The narrow surface `ChatMediaTransferCoordinator` needs from its owner.
|
/// The narrow surface `ChatMediaTransferCoordinator` needs from its owner.
|
||||||
///
|
///
|
||||||
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
|
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
|
||||||
@@ -43,7 +56,24 @@ protocol ChatMediaTransferContext: AnyObject {
|
|||||||
func recordContentKey(_ key: String, timestamp: Date)
|
func recordContentKey(_ key: String, timestamp: Date)
|
||||||
|
|
||||||
// MARK: Mesh file transfer
|
// MARK: Mesh file transfer
|
||||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
|
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
|
||||||
|
func resolvePrivateMediaSendPolicy(
|
||||||
|
to peerID: PeerID,
|
||||||
|
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||||
|
)
|
||||||
|
func requestLegacyPrivateMediaConsent(
|
||||||
|
for peerID: PeerID,
|
||||||
|
transferId: String,
|
||||||
|
messageID: String,
|
||||||
|
completion: @escaping @MainActor (Bool) -> Void
|
||||||
|
)
|
||||||
|
func cancelLegacyPrivateMediaConsent(transferId: String, messageID: String)
|
||||||
|
func sendFilePrivate(
|
||||||
|
_ packet: BitchatFilePacket,
|
||||||
|
to peerID: PeerID,
|
||||||
|
transferId: String,
|
||||||
|
allowLegacyFallback: Bool
|
||||||
|
)
|
||||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
|
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
|
||||||
func cancelTransfer(_ transferId: String)
|
func cancelTransfer(_ transferId: String)
|
||||||
}
|
}
|
||||||
@@ -59,8 +89,50 @@ extension ChatViewModel: ChatMediaTransferContext {
|
|||||||
// other contexts or satisfied by existing `ChatViewModel` members. The
|
// other contexts or satisfied by existing `ChatViewModel` members. The
|
||||||
// members below flatten mesh service accesses.
|
// members below flatten mesh service accesses.
|
||||||
|
|
||||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
|
||||||
meshService.sendFilePrivate(packet, to: peerID, transferId: transferId)
|
meshService.privateMediaSendPolicy(to: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolvePrivateMediaSendPolicy(
|
||||||
|
to peerID: PeerID,
|
||||||
|
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||||
|
) {
|
||||||
|
meshService.resolvePrivateMediaSendPolicy(to: peerID, completion: completion)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestLegacyPrivateMediaConsent(
|
||||||
|
for peerID: PeerID,
|
||||||
|
transferId: String,
|
||||||
|
messageID: String,
|
||||||
|
completion: @escaping @MainActor (Bool) -> Void
|
||||||
|
) {
|
||||||
|
enqueueLegacyPrivateMediaConsent(
|
||||||
|
for: peerID,
|
||||||
|
transferId: transferId,
|
||||||
|
messageID: messageID,
|
||||||
|
completion: completion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelLegacyPrivateMediaConsent(transferId: String, messageID: String) {
|
||||||
|
invalidateLegacyPrivateMediaConsent(
|
||||||
|
transferId: transferId,
|
||||||
|
messageID: messageID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendFilePrivate(
|
||||||
|
_ packet: BitchatFilePacket,
|
||||||
|
to peerID: PeerID,
|
||||||
|
transferId: String,
|
||||||
|
allowLegacyFallback: Bool
|
||||||
|
) {
|
||||||
|
meshService.sendFilePrivate(
|
||||||
|
packet,
|
||||||
|
to: peerID,
|
||||||
|
transferId: transferId,
|
||||||
|
allowLegacyFallback: allowLegacyFallback
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
|
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
|
||||||
@@ -72,15 +144,103 @@ extension ChatViewModel: ChatMediaTransferContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Synchronous boundary between detached image writers and panic deletion.
|
||||||
|
///
|
||||||
|
/// Invalidation closes admission before waiting for writers that already
|
||||||
|
/// entered. Those writers never need the main actor while inside the boundary,
|
||||||
|
/// so a synchronous panic transaction can safely join them and then delete
|
||||||
|
/// every output before reporting completion.
|
||||||
|
private final class ImagePreparationBarrier: @unchecked Sendable {
|
||||||
|
private let condition = NSCondition()
|
||||||
|
private var generation: UInt64 = 0
|
||||||
|
private var activeOperations = 0
|
||||||
|
|
||||||
|
var currentGeneration: UInt64 {
|
||||||
|
condition.lock()
|
||||||
|
defer { condition.unlock() }
|
||||||
|
return generation
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCurrent(_ candidate: UInt64) -> Bool {
|
||||||
|
condition.lock()
|
||||||
|
defer { condition.unlock() }
|
||||||
|
return generation == candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
func performIfCurrent<T>(
|
||||||
|
generation candidate: UInt64,
|
||||||
|
operation: () throws -> T
|
||||||
|
) rethrows -> T? {
|
||||||
|
condition.lock()
|
||||||
|
guard generation == candidate else {
|
||||||
|
condition.unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
activeOperations += 1
|
||||||
|
condition.unlock()
|
||||||
|
|
||||||
|
defer {
|
||||||
|
condition.lock()
|
||||||
|
activeOperations -= 1
|
||||||
|
if activeOperations == 0 {
|
||||||
|
condition.broadcast()
|
||||||
|
}
|
||||||
|
condition.unlock()
|
||||||
|
}
|
||||||
|
return try operation()
|
||||||
|
}
|
||||||
|
|
||||||
|
func invalidateAndWait() {
|
||||||
|
condition.lock()
|
||||||
|
generation &+= 1
|
||||||
|
while activeOperations > 0 {
|
||||||
|
condition.wait()
|
||||||
|
}
|
||||||
|
condition.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs synchronous media encoding and file I/O on a dispatch worker.
|
||||||
|
///
|
||||||
|
/// These operations can legitimately block. Keeping them off Swift's
|
||||||
|
/// cooperative executor preserves forward progress when several transfers or
|
||||||
|
/// test doubles wait at the same time.
|
||||||
|
private func runBlockingMediaPreparation<T>(
|
||||||
|
_ operation: @escaping @Sendable () throws -> T
|
||||||
|
) async throws -> T {
|
||||||
|
try await withCheckedThrowingContinuation { continuation in
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
do {
|
||||||
|
continuation.resume(returning: try operation())
|
||||||
|
} catch {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class ChatMediaTransferCoordinator {
|
final class ChatMediaTransferCoordinator {
|
||||||
private unowned let context: any ChatMediaTransferContext
|
private unowned let context: any ChatMediaTransferContext
|
||||||
|
private let prepareImagePacket: @Sendable (URL) throws -> ChatPreparedImage
|
||||||
|
private let imagePreparationBarrier = ImagePreparationBarrier()
|
||||||
|
private let prepareVoiceNotePacket: @Sendable (URL) throws -> BitchatFilePacket
|
||||||
|
|
||||||
private(set) var transferIdToMessageIDs: [String: [String]] = [:]
|
private(set) var transferIdToMessageIDs: [String: [String]] = [:]
|
||||||
private(set) var messageIDToTransferId: [String: String] = [:]
|
private(set) var messageIDToTransferId: [String: String] = [:]
|
||||||
|
|
||||||
init(context: any ChatMediaTransferContext) {
|
init(
|
||||||
|
context: any ChatMediaTransferContext,
|
||||||
|
prepareImagePacket: @escaping @Sendable (URL) throws -> ChatPreparedImage = {
|
||||||
|
try ChatMediaPreparation.prepareImagePacket(from: $0)
|
||||||
|
},
|
||||||
|
prepareVoiceNotePacket: @escaping @Sendable (URL) throws -> BitchatFilePacket = {
|
||||||
|
try ChatMediaPreparation.prepareVoiceNotePacket(at: $0)
|
||||||
|
}
|
||||||
|
) {
|
||||||
self.context = context
|
self.context = context
|
||||||
|
self.prepareImagePacket = prepareImagePacket
|
||||||
|
self.prepareVoiceNotePacket = prepareVoiceNotePacket
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendVoiceNote(at url: URL) {
|
func sendVoiceNote(at url: URL) {
|
||||||
@@ -98,16 +258,33 @@ final class ChatMediaTransferCoordinator {
|
|||||||
)
|
)
|
||||||
let messageID = message.id
|
let messageID = message.id
|
||||||
let transferId = makeTransferID(messageID: messageID)
|
let transferId = makeTransferID(messageID: messageID)
|
||||||
|
// Own the transfer before detached preparation begins. Cancel/delete
|
||||||
|
// must be able to invalidate this exact invocation even while file I/O
|
||||||
|
// is still running off the main actor.
|
||||||
|
registerTransfer(transferId: transferId, messageID: messageID)
|
||||||
|
let prepareVoiceNotePacket = self.prepareVoiceNotePacket
|
||||||
|
let barrier = imagePreparationBarrier
|
||||||
|
let generation = barrier.currentGeneration
|
||||||
|
|
||||||
Task.detached(priority: .userInitiated) { [weak self] in
|
Task.detached(priority: .userInitiated) { [weak self, barrier] in
|
||||||
do {
|
do {
|
||||||
let packet = try ChatMediaPreparation.prepareVoiceNotePacket(at: url)
|
let packet = try await runBlockingMediaPreparation {
|
||||||
|
try prepareVoiceNotePacket(url)
|
||||||
|
}
|
||||||
|
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self, barrier] in
|
||||||
guard let self else { return }
|
guard let self,
|
||||||
self.registerTransfer(transferId: transferId, messageID: messageID)
|
barrier.isCurrent(generation),
|
||||||
|
self.isRegisteredTransfer(transferId, messageID: messageID) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
if let peerID = targetPeer {
|
if let peerID = targetPeer {
|
||||||
self.context.sendFilePrivate(packet, to: peerID, transferId: transferId)
|
self.beginPrivateMediaSend(
|
||||||
|
packet,
|
||||||
|
to: peerID,
|
||||||
|
transferId: transferId,
|
||||||
|
messageID: messageID
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
self.context.sendFileBroadcast(packet, transferId: transferId)
|
self.context.sendFileBroadcast(packet, transferId: transferId)
|
||||||
}
|
}
|
||||||
@@ -115,14 +292,22 @@ final class ChatMediaTransferCoordinator {
|
|||||||
} catch ChatMediaPreparationError.voiceNoteTooLarge(let size) {
|
} catch ChatMediaPreparationError.voiceNoteTooLarge(let size) {
|
||||||
SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session)
|
SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session)
|
||||||
try? FileManager.default.removeItem(at: url)
|
try? FileManager.default.removeItem(at: url)
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self, barrier] in
|
||||||
guard let self else { return }
|
guard let self,
|
||||||
|
barrier.isCurrent(generation),
|
||||||
|
self.isRegisteredTransfer(transferId, messageID: messageID) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", comment: "Failure reason shown when a voice note exceeds the size limit"))
|
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", comment: "Failure reason shown when a voice note exceeds the size limit"))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("Voice note send failed: \(error)", category: .session)
|
SecureLogger.error("Voice note send failed: \(error)", category: .session)
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self, barrier] in
|
||||||
guard let self else { return }
|
guard let self,
|
||||||
|
barrier.isCurrent(generation),
|
||||||
|
self.isRegisteredTransfer(transferId, messageID: messageID) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", comment: "Failure reason shown when a voice note could not be sent"))
|
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", comment: "Failure reason shown when a voice note could not be sent"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,11 +317,27 @@ final class ChatMediaTransferCoordinator {
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
func processThenSendImage(_ image: UIImage?) {
|
func processThenSendImage(_ image: UIImage?) {
|
||||||
guard let image else { return }
|
guard let image else { return }
|
||||||
Task.detached { [weak self] in
|
let generation = imagePreparationBarrier.currentGeneration
|
||||||
|
let barrier = imagePreparationBarrier
|
||||||
|
Task.detached(priority: .userInitiated) { [weak self, barrier] in
|
||||||
do {
|
do {
|
||||||
let processedURL = try ImageUtils.processImage(image)
|
let processedURL = try await runBlockingMediaPreparation {
|
||||||
await MainActor.run { [weak self] in
|
try barrier.performIfCurrent(
|
||||||
guard let self else { return }
|
generation: generation,
|
||||||
|
operation: {
|
||||||
|
try ImageUtils.processImage(image)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
guard let processedURL else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await MainActor.run { [weak self, barrier] in
|
||||||
|
guard let self,
|
||||||
|
barrier.isCurrent(generation) else {
|
||||||
|
try? FileManager.default.removeItem(at: processedURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
self.sendImage(from: processedURL)
|
self.sendImage(from: processedURL)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -147,11 +348,27 @@ final class ChatMediaTransferCoordinator {
|
|||||||
#elseif os(macOS)
|
#elseif os(macOS)
|
||||||
func processThenSendImage(from url: URL?) {
|
func processThenSendImage(from url: URL?) {
|
||||||
guard let url else { return }
|
guard let url else { return }
|
||||||
Task.detached { [weak self] in
|
let generation = imagePreparationBarrier.currentGeneration
|
||||||
|
let barrier = imagePreparationBarrier
|
||||||
|
Task.detached(priority: .userInitiated) { [weak self, barrier] in
|
||||||
do {
|
do {
|
||||||
let processedURL = try ImageUtils.processImage(at: url)
|
let processedURL = try await runBlockingMediaPreparation {
|
||||||
await MainActor.run { [weak self] in
|
try barrier.performIfCurrent(
|
||||||
guard let self else { return }
|
generation: generation,
|
||||||
|
operation: {
|
||||||
|
try ImageUtils.processImage(at: url)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
guard let processedURL else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await MainActor.run { [weak self, barrier] in
|
||||||
|
guard let self,
|
||||||
|
barrier.isCurrent(generation) else {
|
||||||
|
try? FileManager.default.removeItem(at: processedURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
self.sendImage(from: processedURL)
|
self.sendImage(from: processedURL)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -170,6 +387,7 @@ final class ChatMediaTransferCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let targetPeer = context.selectedPrivateChatPeer
|
let targetPeer = context.selectedPrivateChatPeer
|
||||||
|
let generation = imagePreparationBarrier.currentGeneration
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try ImageUtils.validateImageSource(at: sourceURL)
|
try ImageUtils.validateImageSource(at: sourceURL)
|
||||||
@@ -179,12 +397,28 @@ final class ChatMediaTransferCoordinator {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Task.detached(priority: .userInitiated) { [weak self] in
|
let prepareImagePacket = self.prepareImagePacket
|
||||||
|
let barrier = imagePreparationBarrier
|
||||||
|
Task.detached(priority: .userInitiated) { [weak self, barrier] in
|
||||||
do {
|
do {
|
||||||
let prepared = try ChatMediaPreparation.prepareImagePacket(from: sourceURL)
|
let prepared = try await runBlockingMediaPreparation {
|
||||||
|
try barrier.performIfCurrent(
|
||||||
|
generation: generation,
|
||||||
|
operation: {
|
||||||
|
try prepareImagePacket(sourceURL)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
guard let prepared else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self, barrier] in
|
||||||
guard let self else { return }
|
guard let self,
|
||||||
|
barrier.isCurrent(generation) else {
|
||||||
|
try? FileManager.default.removeItem(at: prepared.outputURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
let message = self.enqueueMediaMessage(
|
let message = self.enqueueMediaMessage(
|
||||||
content: "\(MimeType.Category.image.messagePrefix)\(prepared.outputURL.lastPathComponent)",
|
content: "\(MimeType.Category.image.messagePrefix)\(prepared.outputURL.lastPathComponent)",
|
||||||
targetPeer: targetPeer
|
targetPeer: targetPeer
|
||||||
@@ -193,21 +427,32 @@ final class ChatMediaTransferCoordinator {
|
|||||||
let transferId = self.makeTransferID(messageID: messageID)
|
let transferId = self.makeTransferID(messageID: messageID)
|
||||||
self.registerTransfer(transferId: transferId, messageID: messageID)
|
self.registerTransfer(transferId: transferId, messageID: messageID)
|
||||||
if let peerID = targetPeer {
|
if let peerID = targetPeer {
|
||||||
self.context.sendFilePrivate(prepared.packet, to: peerID, transferId: transferId)
|
self.beginPrivateMediaSend(
|
||||||
|
prepared.packet,
|
||||||
|
to: peerID,
|
||||||
|
transferId: transferId,
|
||||||
|
messageID: messageID
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
self.context.sendFileBroadcast(prepared.packet, transferId: transferId)
|
self.context.sendFileBroadcast(prepared.packet, transferId: transferId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch ChatMediaPreparationError.imageTooLarge(let size) {
|
} catch ChatMediaPreparationError.imageTooLarge(let size) {
|
||||||
SecureLogger.warning("Processed image exceeds size limit (\(size) bytes)", category: .session)
|
SecureLogger.warning("Processed image exceeds size limit (\(size) bytes)", category: .session)
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self, barrier] in
|
||||||
guard let self else { return }
|
guard let self,
|
||||||
|
barrier.isCurrent(generation) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
self.context.addSystemMessage("Image is too large to send.")
|
self.context.addSystemMessage("Image is too large to send.")
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
|
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self, barrier] in
|
||||||
guard let self else { return }
|
guard let self,
|
||||||
|
barrier.isCurrent(generation) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
self.context.addSystemMessage("Failed to prepare image for sending.")
|
self.context.addSystemMessage("Failed to prepare image for sending.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,17 +498,127 @@ final class ChatMediaTransferCoordinator {
|
|||||||
return message
|
return message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func beginPrivateMediaSend(
|
||||||
|
_ packet: BitchatFilePacket,
|
||||||
|
to peerID: PeerID,
|
||||||
|
transferId: String,
|
||||||
|
messageID: String
|
||||||
|
) {
|
||||||
|
continuePrivateMediaSend(
|
||||||
|
packet,
|
||||||
|
to: peerID,
|
||||||
|
transferId: transferId,
|
||||||
|
messageID: messageID,
|
||||||
|
policy: context.privateMediaSendPolicy(to: peerID)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func continuePrivateMediaSend(
|
||||||
|
_ packet: BitchatFilePacket,
|
||||||
|
to peerID: PeerID,
|
||||||
|
transferId: String,
|
||||||
|
messageID: String,
|
||||||
|
policy: PrivateMediaSendPolicy
|
||||||
|
) {
|
||||||
|
switch policy {
|
||||||
|
case .encrypted:
|
||||||
|
context.sendFilePrivate(
|
||||||
|
packet,
|
||||||
|
to: peerID,
|
||||||
|
transferId: transferId,
|
||||||
|
allowLegacyFallback: false
|
||||||
|
)
|
||||||
|
|
||||||
|
case .awaitingCapabilityProof:
|
||||||
|
context.resolvePrivateMediaSendPolicy(to: peerID) { [weak self] resolvedPolicy in
|
||||||
|
guard let self,
|
||||||
|
self.isRegisteredTransfer(transferId, messageID: messageID) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard resolvedPolicy != .awaitingCapabilityProof else {
|
||||||
|
self.handleMediaSendFailure(
|
||||||
|
messageID: messageID,
|
||||||
|
reason: String(
|
||||||
|
localized: "content.delivery.reason.private_media_capability_unresolved",
|
||||||
|
defaultValue: "Could not confirm encrypted media support",
|
||||||
|
comment: "Failure reason when private-media capability negotiation did not resolve"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.continuePrivateMediaSend(
|
||||||
|
packet,
|
||||||
|
to: peerID,
|
||||||
|
transferId: transferId,
|
||||||
|
messageID: messageID,
|
||||||
|
policy: resolvedPolicy
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .legacyRequiresConsent:
|
||||||
|
context.requestLegacyPrivateMediaConsent(
|
||||||
|
for: peerID,
|
||||||
|
transferId: transferId,
|
||||||
|
messageID: messageID
|
||||||
|
) { [weak self] approved in
|
||||||
|
guard let self else { return }
|
||||||
|
// Consent belongs to this exact placeholder/transfer. A late
|
||||||
|
// dialog callback after cancel/delete must never resurrect it.
|
||||||
|
guard self.messageIDToTransferId[messageID] == transferId,
|
||||||
|
self.transferIdToMessageIDs[transferId]?.contains(messageID) == true else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard approved else {
|
||||||
|
self.handleMediaSendFailure(
|
||||||
|
messageID: messageID,
|
||||||
|
reason: String(
|
||||||
|
localized: "content.delivery.reason.legacy_media_declined",
|
||||||
|
defaultValue: "Not sent without end-to-end encryption",
|
||||||
|
comment: "Failure reason after declining the warning for a legacy clear private-media send"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.context.sendFilePrivate(
|
||||||
|
packet,
|
||||||
|
to: peerID,
|
||||||
|
transferId: transferId,
|
||||||
|
allowLegacyFallback: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .blockedDowngrade:
|
||||||
|
handleMediaSendFailure(
|
||||||
|
messageID: messageID,
|
||||||
|
reason: String(
|
||||||
|
localized: "content.delivery.reason.private_media_downgrade_blocked",
|
||||||
|
defaultValue: "Encrypted media required; ask this contact to upgrade",
|
||||||
|
comment: "Failure reason when a peer that previously supported encrypted media appears to downgrade"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func registerTransfer(transferId: String, messageID: String) {
|
func registerTransfer(transferId: String, messageID: String) {
|
||||||
transferIdToMessageIDs[transferId, default: []].append(messageID)
|
transferIdToMessageIDs[transferId, default: []].append(messageID)
|
||||||
messageIDToTransferId[messageID] = transferId
|
messageIDToTransferId[messageID] = transferId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func isRegisteredTransfer(_ transferId: String, messageID: String) -> Bool {
|
||||||
|
messageIDToTransferId[messageID] == transferId
|
||||||
|
&& transferIdToMessageIDs[transferId]?.contains(messageID) == true
|
||||||
|
}
|
||||||
|
|
||||||
func makeTransferID(messageID: String) -> String {
|
func makeTransferID(messageID: String) -> String {
|
||||||
"\(messageID)-\(UUID().uuidString)"
|
"\(messageID)-\(UUID().uuidString)"
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearTransferMapping(for messageID: String) {
|
func clearTransferMapping(for messageID: String) {
|
||||||
guard let transferId = messageIDToTransferId.removeValue(forKey: messageID) else { return }
|
guard let transferId = messageIDToTransferId.removeValue(forKey: messageID) else { return }
|
||||||
|
context.cancelLegacyPrivateMediaConsent(
|
||||||
|
transferId: transferId,
|
||||||
|
messageID: messageID
|
||||||
|
)
|
||||||
guard var queue = transferIdToMessageIDs[transferId] else { return }
|
guard var queue = transferIdToMessageIDs[transferId] else { return }
|
||||||
|
|
||||||
if !queue.isEmpty {
|
if !queue.isEmpty {
|
||||||
@@ -298,6 +653,9 @@ final class ChatMediaTransferCoordinator {
|
|||||||
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
|
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
|
||||||
clearTransferMapping(for: messageID)
|
clearTransferMapping(for: messageID)
|
||||||
context.removeMessage(withID: messageID, cleanupFile: true)
|
context.removeMessage(withID: messageID, cleanupFile: true)
|
||||||
|
case .rejected(let id, let reason):
|
||||||
|
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
|
||||||
|
handleMediaSendFailure(messageID: messageID, reason: reason)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,9 +696,30 @@ final class ChatMediaTransferCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func deleteMediaMessage(messageID: String) {
|
func deleteMediaMessage(messageID: String) {
|
||||||
|
// Delete is also a send cancellation. In particular, an approved
|
||||||
|
// legacy-clear send may still be waiting on BLEService.messageQueue;
|
||||||
|
// removing only the UI mapping would let that deferred work transmit.
|
||||||
|
if let transferId = messageIDToTransferId[messageID],
|
||||||
|
transferIdToMessageIDs[transferId]?.first == messageID {
|
||||||
|
context.cancelTransfer(transferId)
|
||||||
|
}
|
||||||
clearTransferMapping(for: messageID)
|
clearTransferMapping(for: messageID)
|
||||||
context.removeMessage(withID: messageID, cleanupFile: true)
|
context.removeMessage(withID: messageID, cleanupFile: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Invalidates detached preparation work and cancels every transfer that
|
||||||
|
/// reached the transport. Closing image-preparation admission and joining
|
||||||
|
/// active synchronous writers ensures the following panic media deletion
|
||||||
|
/// is the last filesystem mutation before the transaction can complete.
|
||||||
|
func resetForPanic() {
|
||||||
|
imagePreparationBarrier.invalidateAndWait()
|
||||||
|
let transferIDs = Set(transferIdToMessageIDs.keys)
|
||||||
|
transferIdToMessageIDs.removeAll(keepingCapacity: false)
|
||||||
|
messageIDToTransferId.removeAll(keepingCapacity: false)
|
||||||
|
for transferID in transferIDs {
|
||||||
|
context.cancelTransfer(transferID)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension ChatMediaTransferCoordinator {
|
private extension ChatMediaTransferCoordinator {
|
||||||
|
|||||||
@@ -71,12 +71,8 @@ final class ChatMessageFormatter {
|
|||||||
let content = message.content
|
let content = message.content
|
||||||
let nsContent = content as NSString
|
let nsContent = content as NSString
|
||||||
let nsLen = nsContent.length
|
let nsLen = nsContent.length
|
||||||
let containsCashuEarly: Bool = {
|
|
||||||
let regex = Patterns.quickCashuPresence
|
|
||||||
return regex.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) > 0
|
|
||||||
}()
|
|
||||||
|
|
||||||
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly {
|
if content.isOversizedForRichFormatting() {
|
||||||
var plainStyle = AttributeContainer()
|
var plainStyle = AttributeContainer()
|
||||||
plainStyle.foregroundColor = baseColor
|
plainStyle.foregroundColor = baseColor
|
||||||
plainStyle.font = isSelf
|
plainStyle.font = isSelf
|
||||||
|
|||||||
@@ -407,6 +407,13 @@ private extension ChatTransportEventCoordinator {
|
|||||||
|
|
||||||
case .voiceFrame:
|
case .voiceFrame:
|
||||||
context.handleVoiceFramePayload(from: peerID, payload: payload, timestamp: timestamp)
|
context.handleVoiceFramePayload(from: peerID, payload: payload, timestamp: timestamp)
|
||||||
|
|
||||||
|
case .privateFile, .authenticatedPeerState:
|
||||||
|
// BLEService validates and persists decrypted private files before
|
||||||
|
// emitting a normal `.messageReceived` event, and consumes peer
|
||||||
|
// state inside the transport. Neither payload crosses this
|
||||||
|
// UI-facing typed-payload fallback.
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,26 @@ import UIKit
|
|||||||
#endif
|
#endif
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
struct PanicNetworkLifecycle {
|
||||||
|
let stop: @MainActor () -> Void
|
||||||
|
let restart: @MainActor () -> Void
|
||||||
|
|
||||||
|
static let noop = PanicNetworkLifecycle(stop: {}, restart: {})
|
||||||
|
|
||||||
|
static var live: PanicNetworkLifecycle {
|
||||||
|
PanicNetworkLifecycle(
|
||||||
|
stop: {
|
||||||
|
GeohashPresenceService.shared.stopForPanic()
|
||||||
|
NetworkActivationService.shared.stopForPanic()
|
||||||
|
},
|
||||||
|
restart: {
|
||||||
|
NetworkActivationService.shared.start()
|
||||||
|
GeohashPresenceService.shared.start()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Manages the application state and business logic for BitChat.
|
/// Manages the application state and business logic for BitChat.
|
||||||
/// Acts as the primary coordinator between UI components and backend services,
|
/// Acts as the primary coordinator between UI components and backend services,
|
||||||
/// implementing the BitchatDelegate protocol to handle network events.
|
/// implementing the BitchatDelegate protocol to handle network events.
|
||||||
@@ -142,6 +162,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
@Published var currentColorScheme: ColorScheme = .light
|
@Published var currentColorScheme: ColorScheme = .light
|
||||||
@Published var currentTheme: AppTheme = .matrix
|
@Published var currentTheme: AppTheme = .matrix
|
||||||
@Published var isConnected = false
|
@Published var isConnected = false
|
||||||
|
@Published private(set) var panicRecoveryBlocked = false
|
||||||
|
var networkActivationAllowed: Bool { !panicRecoveryBlocked }
|
||||||
@Published var nickname: String = "" {
|
@Published var nickname: String = "" {
|
||||||
didSet {
|
didSet {
|
||||||
// Trim whitespace whenever nickname is set; whitespace-only becomes ""
|
// Trim whitespace whenever nickname is set; whitespace-only becomes ""
|
||||||
@@ -151,7 +173,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Update mesh service nickname if it's initialized
|
// Update mesh service nickname if it's initialized
|
||||||
if !meshService.myPeerID.isEmpty {
|
if !isPanicResetting, !meshService.myPeerID.isEmpty {
|
||||||
meshService.setNickname(nickname)
|
meshService.setNickname(nickname)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,7 +199,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(context: self)
|
lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(context: self)
|
||||||
lazy var nostrCoordinator = ChatNostrCoordinator(context: self)
|
lazy var nostrCoordinator = ChatNostrCoordinator(context: self)
|
||||||
lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self)
|
lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self)
|
||||||
lazy var liveVoiceCoordinator = ChatLiveVoiceCoordinator(context: self)
|
lazy var liveVoiceCoordinator = ChatLiveVoiceCoordinator(
|
||||||
|
context: self,
|
||||||
|
sweepsOnInit: !TestEnvironment.isRunningTests
|
||||||
|
)
|
||||||
lazy var verificationCoordinator = ChatVerificationCoordinator(context: self)
|
lazy var verificationCoordinator = ChatVerificationCoordinator(context: self)
|
||||||
lazy var groupCoordinator = ChatGroupCoordinator(context: self)
|
lazy var groupCoordinator = ChatGroupCoordinator(context: self)
|
||||||
lazy var vouchCoordinator = ChatVouchCoordinator(context: self)
|
lazy var vouchCoordinator = ChatVouchCoordinator(context: self)
|
||||||
@@ -292,6 +317,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
var nostrRelayManager: NostrRelayManager?
|
var nostrRelayManager: NostrRelayManager?
|
||||||
private let userDefaults = UserDefaults.standard
|
private let userDefaults = UserDefaults.standard
|
||||||
let keychain: KeychainManagerProtocol
|
let keychain: KeychainManagerProtocol
|
||||||
|
private let panicRecoveryOperations: PanicRecoveryOperations
|
||||||
|
private let panicNetworkLifecycle: PanicNetworkLifecycle
|
||||||
|
private var isPanicResetting = false
|
||||||
/// Private group membership: keys in the keychain, metadata on disk.
|
/// Private group membership: keys in the keychain, metadata on disk.
|
||||||
let groupStore: GroupStore
|
let groupStore: GroupStore
|
||||||
private let nicknameKey = "bitchat.nickname"
|
private let nicknameKey = "bitchat.nickname"
|
||||||
@@ -347,6 +375,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
@Published var showBluetoothAlert = false
|
@Published var showBluetoothAlert = false
|
||||||
@Published var bluetoothAlertMessage = ""
|
@Published var bluetoothAlertMessage = ""
|
||||||
@Published var bluetoothState: CBManagerState = .unknown
|
@Published var bluetoothState: CBManagerState = .unknown
|
||||||
|
@Published private(set) var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest?
|
||||||
|
private var pendingLegacyPrivateMediaConsents: [PendingLegacyPrivateMediaConsent] = []
|
||||||
|
|
||||||
private func performDeliveryUpdate(_ update: @escaping @MainActor (ChatDeliveryCoordinator) -> Void) {
|
private func performDeliveryUpdate(_ update: @escaping @MainActor (ChatDeliveryCoordinator) -> Void) {
|
||||||
if Thread.isMainThread {
|
if Thread.isMainThread {
|
||||||
@@ -769,7 +799,34 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
locationPresenceStore: LocationPresenceStore? = nil,
|
locationPresenceStore: LocationPresenceStore? = nil,
|
||||||
locationManager: LocationChannelManager = .shared
|
locationManager: LocationChannelManager = .shared
|
||||||
) {
|
) {
|
||||||
let meshService = BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager)
|
let livePanicRecoveryOperations = PanicRecoveryOperations.live()
|
||||||
|
let startSuspendedForRecovery: Bool
|
||||||
|
do {
|
||||||
|
startSuspendedForRecovery =
|
||||||
|
try livePanicRecoveryOperations.isPending()
|
||||||
|
} catch {
|
||||||
|
startSuspendedForRecovery = true
|
||||||
|
}
|
||||||
|
// Preserve the preflight decision used to defer CoreBluetooth. A
|
||||||
|
// transiently successful second read must not skip recovery and leave
|
||||||
|
// the service permanently suspended without running the wipe.
|
||||||
|
let panicRecoveryOperations = PanicRecoveryOperations(
|
||||||
|
isPending: {
|
||||||
|
if startSuspendedForRecovery {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return try livePanicRecoveryOperations.isPending()
|
||||||
|
},
|
||||||
|
begin: livePanicRecoveryOperations.begin,
|
||||||
|
wipeMedia: livePanicRecoveryOperations.wipeMedia,
|
||||||
|
complete: livePanicRecoveryOperations.complete
|
||||||
|
)
|
||||||
|
let meshService = BLEService(
|
||||||
|
keychain: keychain,
|
||||||
|
idBridge: idBridge,
|
||||||
|
identityManager: identityManager,
|
||||||
|
startSuspendedForPanicRecovery: startSuspendedForRecovery
|
||||||
|
)
|
||||||
meshService.sfMetrics = .shared
|
meshService.sfMetrics = .shared
|
||||||
self.init(
|
self.init(
|
||||||
keychain: keychain,
|
keychain: keychain,
|
||||||
@@ -781,7 +838,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(),
|
locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(),
|
||||||
locationManager: locationManager,
|
locationManager: locationManager,
|
||||||
outboxStore: MessageOutboxStore(keychain: keychain),
|
outboxStore: MessageOutboxStore(keychain: keychain),
|
||||||
sfMetrics: .shared
|
sfMetrics: .shared,
|
||||||
|
panicRecoveryOperations: panicRecoveryOperations,
|
||||||
|
panicNetworkLifecycle: .live
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -799,7 +858,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
locationManager: LocationChannelManager = .shared,
|
locationManager: LocationChannelManager = .shared,
|
||||||
readReceiptsDefaults: UserDefaults? = nil,
|
readReceiptsDefaults: UserDefaults? = nil,
|
||||||
outboxStore: MessageOutboxStore? = nil,
|
outboxStore: MessageOutboxStore? = nil,
|
||||||
sfMetrics: StoreAndForwardMetrics? = nil
|
sfMetrics: StoreAndForwardMetrics? = nil,
|
||||||
|
panicMediaWipe: (() throws -> Void)? = nil,
|
||||||
|
panicRecoveryOperations: PanicRecoveryOperations? = nil,
|
||||||
|
panicNetworkLifecycle: PanicNetworkLifecycle = .noop
|
||||||
) {
|
) {
|
||||||
let conversations = conversations ?? ConversationStore()
|
let conversations = conversations ?? ConversationStore()
|
||||||
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
|
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
|
||||||
@@ -814,6 +876,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
|
self.panicRecoveryOperations = panicRecoveryOperations
|
||||||
|
?? .ephemeral(wipeMedia: panicMediaWipe ?? {})
|
||||||
|
self.panicNetworkLifecycle = panicNetworkLifecycle
|
||||||
self.groupStore = GroupStore(keychain: keychain)
|
self.groupStore = GroupStore(keychain: keychain)
|
||||||
self.idBridge = idBridge
|
self.idBridge = idBridge
|
||||||
self.identityManager = identityManager
|
self.identityManager = identityManager
|
||||||
@@ -849,7 +914,31 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
ChatViewModelBootstrapper(viewModel: self).configure()
|
let recoveryRequired: Bool
|
||||||
|
do {
|
||||||
|
recoveryRequired = try self.panicRecoveryOperations.isPending()
|
||||||
|
} catch {
|
||||||
|
// Failure to read the latch cannot fail open. Re-run the complete
|
||||||
|
// transaction; a persistent storage failure leaves services
|
||||||
|
// blocked below.
|
||||||
|
recoveryRequired = true
|
||||||
|
SecureLogger.error(
|
||||||
|
"Could not read panic-recovery state; retrying the full wipe before startup: \(error)",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if recoveryRequired {
|
||||||
|
SecureLogger.warning(
|
||||||
|
"Pending panic recovery detected; wiping before runtime services start",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
_ = panicClearAllData(restartServices: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if networkActivationAllowed {
|
||||||
|
ChatViewModelBootstrapper(viewModel: self).configure()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Deinitialization
|
// MARK: - Deinitialization
|
||||||
@@ -1153,8 +1242,37 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
|
|
||||||
// PANIC: Emergency data clearing for activist safety
|
// PANIC: Emergency data clearing for activist safety
|
||||||
@MainActor
|
@MainActor
|
||||||
func panicClearAllData() {
|
@discardableResult
|
||||||
// Messages are processed immediately - nothing to flush
|
func panicClearAllData(restartServices: Bool = true) -> Bool {
|
||||||
|
panicRecoveryBlocked = true
|
||||||
|
isPanicResetting = true
|
||||||
|
defer { isPanicResetting = false }
|
||||||
|
|
||||||
|
// Stop internet and location-presence work before clearing identity or
|
||||||
|
// state. These services cancel their subscriptions and delayed tasks,
|
||||||
|
// so old callbacks cannot reconnect during the transaction.
|
||||||
|
panicNetworkLifecycle.stop()
|
||||||
|
|
||||||
|
// Establish both independent durable intents before erasing anything.
|
||||||
|
// `wipeMedia` will still attempt deletion if neither write succeeds.
|
||||||
|
let recoveryIntent = panicRecoveryOperations.begin()
|
||||||
|
|
||||||
|
// Quiesce the mesh before clearing stores. Identity replacement below
|
||||||
|
// deliberately stays stopped until media deletion and marker commit.
|
||||||
|
if let bleService = meshService as? BLEService {
|
||||||
|
bleService.suspendForPanicReset()
|
||||||
|
} else {
|
||||||
|
meshService.emergencyDisconnectAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate detached media preparation and close live capture file
|
||||||
|
// handles before clearing state or removing the media directory.
|
||||||
|
mediaTransferCoordinator.resetForPanic()
|
||||||
|
liveVoiceCoordinator.resetForPanic()
|
||||||
|
|
||||||
|
// Deny and release any clear-media confirmations before identities,
|
||||||
|
// message state, and local files are wiped.
|
||||||
|
cancelAllLegacyPrivateMediaConsents()
|
||||||
|
|
||||||
// Clear all messages (public timelines and private chats live in the
|
// Clear all messages (public timelines and private chats live in the
|
||||||
// single-writer ConversationStore; the derived `messages` view and
|
// single-writer ConversationStore; the derived `messages` view and
|
||||||
@@ -1163,7 +1281,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
pendingGeohashSystemMessages.removeAll()
|
pendingGeohashSystemMessages.removeAll()
|
||||||
|
|
||||||
// Delete all keychain data (including Noise and Nostr keys)
|
// Delete all keychain data (including Noise and Nostr keys)
|
||||||
_ = keychain.deleteAllKeychainData()
|
let keychainWipeCompleted = keychain.deleteAllKeychainData()
|
||||||
|
if !keychainWipeCompleted {
|
||||||
|
SecureLogger.error(
|
||||||
|
"Panic keychain cleanup incomplete; recovery remains pending",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Clear UserDefaults identity data
|
// Clear UserDefaults identity data
|
||||||
userDefaults.removeObject(forKey: "bitchat.noiseIdentityKey")
|
userDefaults.removeObject(forKey: "bitchat.noiseIdentityKey")
|
||||||
@@ -1176,13 +1300,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
|
|
||||||
// Reset nickname to anonymous
|
// Reset nickname to anonymous
|
||||||
nickname = "anon\(Int.random(in: 1000...9999))"
|
nickname = "anon\(Int.random(in: 1000...9999))"
|
||||||
saveNickname()
|
userDefaults.set(nickname, forKey: nicknameKey)
|
||||||
|
|
||||||
// Clear favorites and peer mappings
|
// Clear favorites and peer mappings
|
||||||
// Clear through SecureIdentityStateManager instead of directly
|
// Clear through SecureIdentityStateManager instead of directly
|
||||||
identityManager.clearAllIdentityData()
|
identityManager.clearAllIdentityData()
|
||||||
peerIdentityStore.clearAll()
|
peerIdentityStore.clearAll()
|
||||||
locationPresenceStore.reset()
|
locationPresenceStore.reset()
|
||||||
|
publicRateLimiter.reset()
|
||||||
|
|
||||||
// Clear persistent favorites from keychain
|
// Clear persistent favorites from keychain
|
||||||
FavoritesPersistenceService.shared.clearAllFavorites()
|
FavoritesPersistenceService.shared.clearAllFavorites()
|
||||||
@@ -1247,78 +1372,77 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
// Clear Nostr identity associations
|
// Clear Nostr identity associations
|
||||||
idBridge.clearAllAssociations()
|
idBridge.clearAllAssociations()
|
||||||
|
|
||||||
// Disconnect from all peers and clear persistent identity
|
// Replace the BLE identity while keeping the radio stopped. It may
|
||||||
// This will force creation of a new identity (new fingerprint) on next launch
|
// reopen only after the durable panic transaction commits.
|
||||||
meshService.emergencyDisconnectAll()
|
|
||||||
if let bleService = meshService as? BLEService {
|
if let bleService = meshService as? BLEService {
|
||||||
bleService.resetIdentityForPanic(currentNickname: nickname)
|
bleService.resetIdentityForPanic(
|
||||||
|
currentNickname: nickname,
|
||||||
|
restartServices: false
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
meshService.setNickname(nickname)
|
||||||
}
|
}
|
||||||
|
|
||||||
// No need to force UserDefaults synchronization
|
// The wipe must finish before this security action returns. A detached
|
||||||
|
// task could otherwise lose a race with a new capture or app exit and
|
||||||
|
// leave pre-panic media behind.
|
||||||
|
let panicCompleted: Bool
|
||||||
|
do {
|
||||||
|
try panicRecoveryOperations.wipeMedia(recoveryIntent)
|
||||||
|
if keychainWipeCompleted {
|
||||||
|
try panicRecoveryOperations.complete()
|
||||||
|
panicCompleted = true
|
||||||
|
SecureLogger.info(
|
||||||
|
"🗑️ Deleted all media files during panic clear",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Do not clear either durable recovery marker. Startup must
|
||||||
|
// retry the entire transaction before any transport restarts.
|
||||||
|
panicCompleted = false
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
panicCompleted = false
|
||||||
|
SecureLogger.error(
|
||||||
|
"Panic transaction did not commit; services remain stopped: \(error)",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
}
|
||||||
|
panicRecoveryBlocked = !panicCompleted
|
||||||
|
|
||||||
// Reinitialize Nostr with new identity
|
// BCH-01-013: Clear iOS app switcher snapshots. Keep tests away from
|
||||||
// This will generate new Nostr keys derived from new Noise keys.
|
// the host user's real cache tree just as the default media wipe does.
|
||||||
// Skipped under tests: connecting the shared relay singleton starts
|
#if os(iOS)
|
||||||
// real network/reconnect work that never completes and would keep the
|
|
||||||
// test process alive (the singleton, unlike a discardable instance, is
|
|
||||||
// never deallocated to cancel it).
|
|
||||||
if !TestEnvironment.isRunningTests {
|
if !TestEnvironment.isRunningTests {
|
||||||
Task { @MainActor in
|
Self.clearAppSwitcherSnapshots()
|
||||||
// Small delay to ensure cleanup completes
|
}
|
||||||
try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds
|
#endif
|
||||||
|
|
||||||
// Reinitialize Nostr relay manager with new identity. Reuse the
|
guard panicCompleted else { return false }
|
||||||
// shared singleton — every other component (NostrTransport, geohash
|
|
||||||
// subscriptions, AppRuntime observers) is bound to `.shared`, so
|
if let bleService = meshService as? BLEService {
|
||||||
// creating a fresh instance here would split relay state and leave
|
// Startup recovery reopens admission but leaves actual service
|
||||||
// sends running against a disconnected manager.
|
// start to the bootstrapper immediately after this method.
|
||||||
|
bleService.completePanicReset(
|
||||||
|
restartServices: restartServices
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if restartServices {
|
||||||
|
// All persistent state and media are gone. Bring each service back
|
||||||
|
// only now, under the new identity.
|
||||||
|
if !(meshService is BLEService) {
|
||||||
|
meshService.startServices()
|
||||||
|
}
|
||||||
|
|
||||||
|
if !TestEnvironment.isRunningTests {
|
||||||
nostrRelayManager = NostrRelayManager.shared
|
nostrRelayManager = NostrRelayManager.shared
|
||||||
setupNostrMessageHandling()
|
setupNostrMessageHandling()
|
||||||
nostrRelayManager?.connect()
|
|
||||||
}
|
}
|
||||||
|
panicNetworkLifecycle.restart()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete ALL media files (incoming and outgoing) in background
|
return true
|
||||||
Task.detached(priority: .utility) {
|
|
||||||
// Skipped under tests: the test process shares the user's real
|
|
||||||
// ~/Library/Application Support/files tree, and this detached
|
|
||||||
// utility-priority wipe fires at a nondeterministic time —
|
|
||||||
// deleting media that concurrently running tests (e.g. the
|
|
||||||
// sendImage flow) just wrote there, and the developer's real
|
|
||||||
// app data with it.
|
|
||||||
guard !TestEnvironment.isRunningTests else { return }
|
|
||||||
do {
|
|
||||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
|
||||||
let filesDir = base.appendingPathComponent("files", isDirectory: true)
|
|
||||||
|
|
||||||
// Delete the entire files directory and recreate it
|
|
||||||
if FileManager.default.fileExists(atPath: filesDir.path) {
|
|
||||||
try FileManager.default.removeItem(at: filesDir)
|
|
||||||
SecureLogger.info("🗑️ Deleted all media files during panic clear", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recreate empty directory structure
|
|
||||||
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
|
||||||
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("voicenotes/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
|
|
||||||
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("voicenotes/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
|
|
||||||
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("images/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
|
|
||||||
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("images/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
|
|
||||||
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("files/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
|
|
||||||
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("files/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("Failed to clear media files during panic: \(error)", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
// BCH-01-013: Clear iOS app switcher snapshots
|
|
||||||
// These are stored in Library/Caches/Snapshots/<bundle_id>/
|
|
||||||
#if os(iOS)
|
|
||||||
Self.clearAppSwitcherSnapshots()
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
// Force immediate UI update for panic mode
|
|
||||||
// UI updates immediately - no flushing needed
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// BCH-01-013: Clear iOS app switcher snapshots during panic mode
|
/// BCH-01-013: Clear iOS app switcher snapshots during panic mode
|
||||||
@@ -1804,4 +1928,91 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
publicConversationCoordinator.sendHapticFeedback(for: message)
|
publicConversationCoordinator.sendHapticFeedback(for: message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
extension ChatViewModel {
|
||||||
|
func enqueueLegacyPrivateMediaConsent(
|
||||||
|
for peerID: PeerID,
|
||||||
|
transferId: String,
|
||||||
|
messageID: String,
|
||||||
|
completion: @escaping @MainActor (Bool) -> Void
|
||||||
|
) {
|
||||||
|
let request = LegacyPrivateMediaConsentRequest(
|
||||||
|
id: UUID(),
|
||||||
|
peerID: peerID,
|
||||||
|
peerName: nicknameForPeer(peerID),
|
||||||
|
transferId: transferId,
|
||||||
|
messageID: messageID
|
||||||
|
)
|
||||||
|
pendingLegacyPrivateMediaConsents.append(PendingLegacyPrivateMediaConsent(
|
||||||
|
request: request,
|
||||||
|
completion: completion
|
||||||
|
))
|
||||||
|
if legacyPrivateMediaConsentRequest == nil {
|
||||||
|
legacyPrivateMediaConsentRequest = request
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveLegacyPrivateMediaConsent(requestID: UUID, approved: Bool) {
|
||||||
|
// SwiftUI may report both the selected button and the presentation
|
||||||
|
// binding's dismissal. Resolve only the exact request that was shown;
|
||||||
|
// a duplicate callback for it must not consume the next queued send.
|
||||||
|
guard legacyPrivateMediaConsentRequest?.id == requestID,
|
||||||
|
pendingLegacyPrivateMediaConsents.first?.request.id == requestID else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let resolved = pendingLegacyPrivateMediaConsents.removeFirst()
|
||||||
|
// Drive the boolean presentation state through false before showing
|
||||||
|
// the next queued per-send warning. Otherwise SwiftUI sees true→true,
|
||||||
|
// closes the first dialog, and never presents the second.
|
||||||
|
legacyPrivateMediaConsentRequest = nil
|
||||||
|
resolved.completion(approved)
|
||||||
|
presentNextLegacyPrivateMediaConsentDeferred()
|
||||||
|
}
|
||||||
|
|
||||||
|
func invalidateLegacyPrivateMediaConsent(transferId: String, messageID: String) {
|
||||||
|
let invalidatedIDs = Set(
|
||||||
|
pendingLegacyPrivateMediaConsents.compactMap { pending -> UUID? in
|
||||||
|
let request = pending.request
|
||||||
|
return request.transferId == transferId && request.messageID == messageID
|
||||||
|
? request.id
|
||||||
|
: nil
|
||||||
|
}
|
||||||
|
)
|
||||||
|
guard !invalidatedIDs.isEmpty else { return }
|
||||||
|
|
||||||
|
pendingLegacyPrivateMediaConsents.removeAll {
|
||||||
|
invalidatedIDs.contains($0.request.id)
|
||||||
|
}
|
||||||
|
if let currentID = legacyPrivateMediaConsentRequest?.id,
|
||||||
|
invalidatedIDs.contains(currentID) {
|
||||||
|
legacyPrivateMediaConsentRequest = nil
|
||||||
|
presentNextLegacyPrivateMediaConsentDeferred()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelAllLegacyPrivateMediaConsents() {
|
||||||
|
let pending = pendingLegacyPrivateMediaConsents
|
||||||
|
pendingLegacyPrivateMediaConsents.removeAll()
|
||||||
|
legacyPrivateMediaConsentRequest = nil
|
||||||
|
for item in pending {
|
||||||
|
item.completion(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentNextLegacyPrivateMediaConsentDeferred() {
|
||||||
|
guard legacyPrivateMediaConsentRequest == nil,
|
||||||
|
let nextRequestID = pendingLegacyPrivateMediaConsents.first?.request.id else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self,
|
||||||
|
self.legacyPrivateMediaConsentRequest == nil,
|
||||||
|
self.pendingLegacyPrivateMediaConsents.first?.request.id == nextRequestID else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.legacyPrivateMediaConsentRequest = self.pendingLegacyPrivateMediaConsents[0].request
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// End of ChatViewModel class
|
// End of ChatViewModel class
|
||||||
|
|||||||
@@ -156,6 +156,17 @@ private extension ChatViewModelBootstrapper {
|
|||||||
viewModel?.objectWillChange.send()
|
viewModel?.objectWillChange.send()
|
||||||
}
|
}
|
||||||
.store(in: &viewModel.cancellables)
|
.store(in: &viewModel.cancellables)
|
||||||
|
|
||||||
|
viewModel.participantTracker.$visiblePeople
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak viewModel] people in
|
||||||
|
Task { @MainActor [weak viewModel] in
|
||||||
|
let visible = Set(people.map { $0.id })
|
||||||
|
viewModel?.locationPresenceStore.retainTeleportedGeo(keeping: visible)
|
||||||
|
viewModel?.locationPresenceStore.retainGeoNicknames(keeping: visible)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &viewModel.cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadPersistedViewState() {
|
func loadPersistedViewState() {
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ struct MessageRateLimiter {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isIdle(since now: Date, idleTTL: TimeInterval) -> Bool {
|
||||||
|
now.timeIntervalSince(lastRefill) >= idleTTL
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var senderBuckets: [String: TokenBucket] = [:]
|
private var senderBuckets: [String: TokenBucket] = [:]
|
||||||
@@ -35,17 +39,26 @@ struct MessageRateLimiter {
|
|||||||
private let senderRefill: Double
|
private let senderRefill: Double
|
||||||
private let contentCapacity: Double
|
private let contentCapacity: Double
|
||||||
private let contentRefill: Double
|
private let contentRefill: Double
|
||||||
|
private let maxSenderBuckets: Int
|
||||||
|
private let maxContentBuckets: Int
|
||||||
|
private let bucketIdleTTL: TimeInterval
|
||||||
|
|
||||||
init(
|
init(
|
||||||
senderCapacity: Double,
|
senderCapacity: Double,
|
||||||
senderRefillPerSec: Double,
|
senderRefillPerSec: Double,
|
||||||
contentCapacity: Double,
|
contentCapacity: Double,
|
||||||
contentRefillPerSec: Double
|
contentRefillPerSec: Double,
|
||||||
|
maxSenderBuckets: Int = TransportConfig.uiSenderRateBucketMaxEntries,
|
||||||
|
maxContentBuckets: Int = TransportConfig.uiContentRateBucketMaxEntries,
|
||||||
|
bucketIdleTTL: TimeInterval = TransportConfig.uiRateBucketIdleTTL
|
||||||
) {
|
) {
|
||||||
self.senderCapacity = senderCapacity
|
self.senderCapacity = senderCapacity
|
||||||
self.senderRefill = senderRefillPerSec
|
self.senderRefill = senderRefillPerSec
|
||||||
self.contentCapacity = contentCapacity
|
self.contentCapacity = contentCapacity
|
||||||
self.contentRefill = contentRefillPerSec
|
self.contentRefill = contentRefillPerSec
|
||||||
|
self.maxSenderBuckets = max(1, maxSenderBuckets)
|
||||||
|
self.maxContentBuckets = max(1, maxContentBuckets)
|
||||||
|
self.bucketIdleTTL = bucketIdleTTL
|
||||||
}
|
}
|
||||||
|
|
||||||
/// - Parameter powBits: validated NIP-13 difficulty of the event
|
/// - Parameter powBits: validated NIP-13 difficulty of the event
|
||||||
@@ -58,25 +71,83 @@ struct MessageRateLimiter {
|
|||||||
if powBits >= NostrPoW.rateLimitBypassBits {
|
if powBits >= NostrPoW.rateLimitBypassBits {
|
||||||
senderAllowed = true
|
senderAllowed = true
|
||||||
} else {
|
} else {
|
||||||
var senderBucket = senderBuckets[senderKey] ?? TokenBucket(
|
var senderBucket = Self.bucket(
|
||||||
|
for: senderKey,
|
||||||
|
in: &senderBuckets,
|
||||||
capacity: senderCapacity,
|
capacity: senderCapacity,
|
||||||
tokens: senderCapacity,
|
|
||||||
refillPerSec: senderRefill,
|
refillPerSec: senderRefill,
|
||||||
lastRefill: now
|
maxBuckets: maxSenderBuckets,
|
||||||
|
idleTTL: bucketIdleTTL,
|
||||||
|
now: now
|
||||||
)
|
)
|
||||||
senderAllowed = senderBucket.allow(now: now)
|
senderAllowed = senderBucket.allow(now: now)
|
||||||
senderBuckets[senderKey] = senderBucket
|
senderBuckets[senderKey] = senderBucket
|
||||||
}
|
}
|
||||||
|
|
||||||
var contentBucket = contentBuckets[contentKey] ?? TokenBucket(
|
// Rejected senders must not mint attacker-keyed content entries.
|
||||||
|
guard senderAllowed else { return false }
|
||||||
|
|
||||||
|
var contentBucket = Self.bucket(
|
||||||
|
for: contentKey,
|
||||||
|
in: &contentBuckets,
|
||||||
capacity: contentCapacity,
|
capacity: contentCapacity,
|
||||||
tokens: contentCapacity,
|
|
||||||
refillPerSec: contentRefill,
|
refillPerSec: contentRefill,
|
||||||
lastRefill: now
|
maxBuckets: maxContentBuckets,
|
||||||
|
idleTTL: bucketIdleTTL,
|
||||||
|
now: now
|
||||||
)
|
)
|
||||||
let contentAllowed = contentBucket.allow(now: now)
|
let contentAllowed = contentBucket.allow(now: now)
|
||||||
contentBuckets[contentKey] = contentBucket
|
contentBuckets[contentKey] = contentBucket
|
||||||
|
|
||||||
return senderAllowed && contentAllowed
|
return contentAllowed
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func reset() {
|
||||||
|
senderBuckets.removeAll()
|
||||||
|
contentBuckets.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
var bucketCountsForTesting: (sender: Int, content: Int) {
|
||||||
|
(senderBuckets.count, contentBuckets.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static so we can take `inout` on a stored dictionary without overlapping
|
||||||
|
// exclusive access through a mutating method on `self`.
|
||||||
|
private static func bucket(
|
||||||
|
for key: String,
|
||||||
|
in buckets: inout [String: TokenBucket],
|
||||||
|
capacity: Double,
|
||||||
|
refillPerSec: Double,
|
||||||
|
maxBuckets: Int,
|
||||||
|
idleTTL: TimeInterval,
|
||||||
|
now: Date
|
||||||
|
) -> TokenBucket {
|
||||||
|
if let existing = buckets[key] {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
evictIfNeeded(from: &buckets, maxBuckets: maxBuckets, idleTTL: idleTTL, now: now)
|
||||||
|
return TokenBucket(
|
||||||
|
capacity: capacity,
|
||||||
|
tokens: capacity,
|
||||||
|
refillPerSec: refillPerSec,
|
||||||
|
lastRefill: now
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func evictIfNeeded(
|
||||||
|
from buckets: inout [String: TokenBucket],
|
||||||
|
maxBuckets: Int,
|
||||||
|
idleTTL: TimeInterval,
|
||||||
|
now: Date
|
||||||
|
) {
|
||||||
|
guard buckets.count >= maxBuckets else { return }
|
||||||
|
|
||||||
|
buckets = buckets.filter { !$0.value.isIdle(since: now, idleTTL: idleTTL) }
|
||||||
|
guard buckets.count >= maxBuckets else { return }
|
||||||
|
|
||||||
|
if let oldestKey = buckets.min(by: { $0.value.lastRefill < $1.value.lastRefill })?.key {
|
||||||
|
buckets.removeValue(forKey: oldestKey)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,7 +196,10 @@ final class NostrInboundPipeline {
|
|||||||
// Sampled: fires for every geo event and floods dev logs in busy geohashes.
|
// Sampled: fires for every geo event and floods dev logs in busy geohashes.
|
||||||
geoEventLogCount += 1
|
geoEventLogCount += 1
|
||||||
if geoEventLogCount == 1 || geoEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
|
if geoEventLogCount == 1 || geoEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
|
||||||
SecureLogger.debug("GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… pow=\(powBits) tags=\(event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ","))", category: .session)
|
SecureLogger.debug(
|
||||||
|
"GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… pow=\(powBits) tagCount=\(event.tags.count)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
|
if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
|
||||||
@@ -307,7 +310,7 @@ final class NostrInboundPipeline {
|
|||||||
// claiming to be group traffic over Nostr is ignored.
|
// claiming to be group traffic over Nostr is ignored.
|
||||||
// Live voice is mesh-only: latency and relay cost make it
|
// Live voice is mesh-only: latency and relay cost make it
|
||||||
// meaningless over Nostr.
|
// meaningless over Nostr.
|
||||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame:
|
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -363,7 +366,7 @@ final class NostrInboundPipeline {
|
|||||||
// claiming to be group traffic over Nostr is ignored.
|
// claiming to be group traffic over Nostr is ignored.
|
||||||
// Live voice is mesh-only: latency and relay cost make it
|
// Live voice is mesh-only: latency and relay cost make it
|
||||||
// meaningless over Nostr.
|
// meaningless over Nostr.
|
||||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame:
|
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -446,7 +449,7 @@ final class NostrInboundPipeline {
|
|||||||
// in v1; group traffic over Nostr is ignored.
|
// in v1; group traffic over Nostr is ignored.
|
||||||
// Live voice is mesh-only: latency and relay cost make it
|
// Live voice is mesh-only: latency and relay cost make it
|
||||||
// meaningless over Nostr.
|
// meaningless over Nostr.
|
||||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame:
|
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,8 +188,25 @@ final class VoiceRecordingViewModel: ObservableObject {
|
|||||||
|
|
||||||
Task {
|
Task {
|
||||||
let finalDuration = Date().timeIntervalSince(startDate)
|
let finalDuration = Date().timeIntervalSince(startDate)
|
||||||
if let url = await session.finish(),
|
if let url = await session.finish() {
|
||||||
isValidRecording(at: url, duration: finalDuration) {
|
// Panic and a newer hold both invalidate this completion.
|
||||||
|
// Never route an old recording using a post-panic target.
|
||||||
|
guard generation == holdGeneration else {
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard isValidRecording(
|
||||||
|
at: url,
|
||||||
|
duration: finalDuration
|
||||||
|
) else {
|
||||||
|
guard state == .idle else { return }
|
||||||
|
state = .error(
|
||||||
|
message: finalDuration < VoiceRecorder.minRecordingDuration
|
||||||
|
? "Recording is too short."
|
||||||
|
: "Recording failed to save."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
completion(url)
|
completion(url)
|
||||||
} else {
|
} else {
|
||||||
guard generation == holdGeneration, state == .idle else { return }
|
guard generation == holdGeneration, state == .idle else { return }
|
||||||
@@ -206,6 +223,17 @@ final class VoiceRecordingViewModel: ObservableObject {
|
|||||||
finish(completion: nil)
|
finish(completion: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Invalidates in-flight permission/start/finalize callbacks and tears
|
||||||
|
/// down an active microphone before the panic transaction continues.
|
||||||
|
func panicWipe() {
|
||||||
|
holdGeneration &+= 1
|
||||||
|
let session = activeSession
|
||||||
|
activeSession = nil
|
||||||
|
state = .idle
|
||||||
|
isLiveStreaming = false
|
||||||
|
session?.panicCancelSynchronously()
|
||||||
|
}
|
||||||
|
|
||||||
private func isValidRecording(at url: URL, duration: TimeInterval) -> Bool {
|
private func isValidRecording(at url: URL, duration: TimeInterval) -> Bool {
|
||||||
if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
|
if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
|
||||||
let fileSize = attributes[.size] as? NSNumber,
|
let fileSize = attributes[.size] as? NSNumber,
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ struct AppInfoView: View {
|
|||||||
/// introduction), and afterwards the sheet reopens wherever it was left.
|
/// introduction), and afterwards the sheet reopens wherever it was left.
|
||||||
@AppStorage("appInfo.selectedPane") private var selectedPane: Pane = .info
|
@AppStorage("appInfo.selectedPane") private var selectedPane: Pane = .info
|
||||||
@State private var showPanicConfirmation = false
|
@State private var showPanicConfirmation = false
|
||||||
|
@AppStorage(AppLanguageSettings.overrideKey) private var languageOverride = ""
|
||||||
|
/// The override changed this session; localization resolves at process
|
||||||
|
/// start, so surface the restart hint.
|
||||||
|
@State private var showLanguageRestartNote = false
|
||||||
|
|
||||||
private enum Pane: String {
|
private enum Pane: String {
|
||||||
case settings
|
case settings
|
||||||
@@ -55,6 +59,11 @@ struct AppInfoView: View {
|
|||||||
|
|
||||||
static let connectivityTitle = String(localized: "app_info.settings.connectivity.title", defaultValue: "CONNECTIVITY", comment: "Section header (uppercase) for the connectivity toggles: mesh bridge, internet gateway, tor routing")
|
static let connectivityTitle = String(localized: "app_info.settings.connectivity.title", defaultValue: "CONNECTIVITY", comment: "Section header (uppercase) for the connectivity toggles: mesh bridge, internet gateway, tor routing")
|
||||||
|
|
||||||
|
static let languageTitle = String(localized: "app_info.settings.language.title", defaultValue: "LANGUAGE", comment: "Section header (uppercase) for the app language picker in settings")
|
||||||
|
static let languagePickerLabel = String(localized: "app_info.settings.language.picker_label", defaultValue: "app language", comment: "Label of the app language picker row in settings")
|
||||||
|
static let languageSystem = String(localized: "app_info.settings.language.system", defaultValue: "system default", comment: "Menu option that clears the in-app language override so the app follows the device language")
|
||||||
|
static let languageRestartNote = String(localized: "app_info.settings.language.restart_note", defaultValue: "restart bitchat to apply the new language", comment: "Caption shown after the user picks a different app language; the change takes effect on next launch")
|
||||||
|
|
||||||
static let bridgeTitle = String(localized: "app_info.settings.bridge.title", defaultValue: "mesh bridge", comment: "Title of the mesh bridge toggle in settings")
|
static let bridgeTitle = String(localized: "app_info.settings.bridge.title", defaultValue: "mesh bridge", comment: "Title of the mesh bridge toggle in settings")
|
||||||
static let bridgeSubtitle = String(localized: "app_info.settings.bridge.subtitle", defaultValue: "joins nearby mesh islands over the internet: what you say in the mesh channel also reaches people in your area beyond radio range, and their messages appear here marked with the network glyph. while you have internet, your device also carries bridge and location-channel traffic for phones around you that have none.", comment: "Subtitle explaining what the mesh bridge toggle does")
|
static let bridgeSubtitle = String(localized: "app_info.settings.bridge.subtitle", defaultValue: "joins nearby mesh islands over the internet: what you say in the mesh channel also reaches people in your area beyond radio range, and their messages appear here marked with the network glyph. while you have internet, your device also carries bridge and location-channel traffic for phones around you that have none.", comment: "Subtitle explaining what the mesh bridge toggle does")
|
||||||
static func bridgeCell(_ cell: String) -> String {
|
static func bridgeCell(_ cell: String) -> String {
|
||||||
@@ -313,6 +322,52 @@ struct AppInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Language — an in-app override so the UI language can differ
|
||||||
|
// from the device language (takes effect on next launch).
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
SectionHeader(verbatim: Strings.Settings.languageTitle)
|
||||||
|
|
||||||
|
settingsCard {
|
||||||
|
Menu {
|
||||||
|
Button {
|
||||||
|
selectLanguage(nil)
|
||||||
|
} label: {
|
||||||
|
menuItemLabel(Strings.Settings.languageSystem, isSelected: languageOverride.isEmpty)
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
ForEach(AppLanguageSettings.availableLanguages, id: \.self) { code in
|
||||||
|
Button {
|
||||||
|
selectLanguage(code)
|
||||||
|
} label: {
|
||||||
|
menuItemLabel(AppLanguageSettings.endonym(for: code), isSelected: languageOverride == code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(Strings.Settings.languagePickerLabel)
|
||||||
|
.bitchatFont(size: 12, weight: .semibold)
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
Spacer()
|
||||||
|
Text(languageOverride.isEmpty ? Strings.Settings.languageSystem : AppLanguageSettings.endonym(for: languageOverride))
|
||||||
|
.bitchatFont(size: 12)
|
||||||
|
.foregroundColor(palette.accent)
|
||||||
|
Image(systemName: "chevron.up.chevron.down")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(secondaryTextColor)
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
if showLanguageRestartNote {
|
||||||
|
Text(Strings.Settings.languageRestartNote)
|
||||||
|
.bitchatFont(size: 11)
|
||||||
|
.foregroundColor(secondaryTextColor)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Voice — same card + IRC pill as every other toggle setting.
|
// Voice — same card + IRC pill as every other toggle setting.
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
SectionHeader(Strings.Voice.title)
|
SectionHeader(Strings.Voice.title)
|
||||||
@@ -458,6 +513,24 @@ struct AppInfoView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func selectLanguage(_ code: String?) {
|
||||||
|
let previous = languageOverride
|
||||||
|
AppLanguageSettings.setOverride(code)
|
||||||
|
languageOverride = code ?? ""
|
||||||
|
if languageOverride != previous {
|
||||||
|
showLanguageRestartNote = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func menuItemLabel(_ title: String, isSelected: Bool) -> some View {
|
||||||
|
HStack {
|
||||||
|
Text(title)
|
||||||
|
if isSelected {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var bridgeToggleBinding: Binding<Bool> {
|
private var bridgeToggleBinding: Binding<Bool> {
|
||||||
Binding(
|
Binding(
|
||||||
get: { bridgeService.isEnabled },
|
get: { bridgeService.isEnabled },
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ struct TextMessageView: View {
|
|||||||
// first text line; a fixed top padding left the lock's solid body
|
// first text line; a fixed top padding left the lock's solid body
|
||||||
// hanging below the line's visual center.
|
// hanging below the line's visual center.
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 0) {
|
HStack(alignment: .firstTextBaseline, spacing: 0) {
|
||||||
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty
|
let isLong = message.content.isLongForDisplay()
|
||||||
let isExpanded = expandedMessageIDs.contains(message.id)
|
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||||
if message.isPrivate {
|
if message.isPrivate {
|
||||||
Image(systemName: "lock.fill")
|
Image(systemName: "lock.fill")
|
||||||
@@ -103,7 +103,7 @@ struct TextMessageView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Expand/Collapse for very long messages
|
// Expand/Collapse for very long messages
|
||||||
if (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty {
|
if message.content.isLongForDisplay() {
|
||||||
let isExpanded = expandedMessageIDs.contains(message.id)
|
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||||
let labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more")
|
let labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more")
|
||||||
Button(labelKey) {
|
Button(labelKey) {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ struct ContentPeopleSheetView: View {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
let legacyConsentRequest = conversationUIModel.legacyPrivateMediaConsentRequest
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Group {
|
Group {
|
||||||
if privateConversationModel.selectedPeerID != nil {
|
if privateConversationModel.selectedPeerID != nil {
|
||||||
@@ -97,6 +98,63 @@ struct ContentPeopleSheetView: View {
|
|||||||
}
|
}
|
||||||
.themedSheetBackground()
|
.themedSheetBackground()
|
||||||
.foregroundColor(palette.primary)
|
.foregroundColor(palette.primary)
|
||||||
|
.confirmationDialog(
|
||||||
|
String(
|
||||||
|
localized: "content.private_media.legacy_warning.title",
|
||||||
|
defaultValue: "Send without end-to-end encryption?",
|
||||||
|
comment: "Title warning before sending private media to an older client in a clear signed envelope"
|
||||||
|
),
|
||||||
|
isPresented: Binding(
|
||||||
|
get: { legacyConsentRequest != nil },
|
||||||
|
set: { isPresented in
|
||||||
|
if !isPresented, let requestID = legacyConsentRequest?.id {
|
||||||
|
conversationUIModel.resolveLegacyPrivateMediaConsent(
|
||||||
|
requestID: requestID,
|
||||||
|
approved: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
String(
|
||||||
|
localized: "content.private_media.legacy_warning.send",
|
||||||
|
defaultValue: "send visible file",
|
||||||
|
comment: "Destructive confirmation action for one legacy clear private-media send"
|
||||||
|
),
|
||||||
|
role: .destructive
|
||||||
|
) {
|
||||||
|
if let requestID = legacyConsentRequest?.id {
|
||||||
|
conversationUIModel.resolveLegacyPrivateMediaConsent(
|
||||||
|
requestID: requestID,
|
||||||
|
approved: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("common.cancel", role: .cancel) {
|
||||||
|
if let requestID = legacyConsentRequest?.id {
|
||||||
|
conversationUIModel.resolveLegacyPrivateMediaConsent(
|
||||||
|
requestID: requestID,
|
||||||
|
approved: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
if let request = legacyConsentRequest {
|
||||||
|
Text(
|
||||||
|
String(
|
||||||
|
format: String(
|
||||||
|
localized: "content.private_media.legacy_warning.message",
|
||||||
|
defaultValue: "%@'s client does not advertise encrypted private media. This file will be signed but not end-to-end encrypted, so mesh relays can see it. Send this file anyway?",
|
||||||
|
comment: "Warning explaining the confidentiality loss for one legacy private-media send; parameter is the peer name"
|
||||||
|
),
|
||||||
|
locale: .current,
|
||||||
|
request.peerName
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.frame(minWidth: 420, minHeight: 520)
|
.frame(minWidth: 420, minHeight: 520)
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ struct ContentView: View {
|
|||||||
voiceRecordingVM.sessionProvider = { [weak conversationUIModel] in
|
voiceRecordingVM.sessionProvider = { [weak conversationUIModel] in
|
||||||
conversationUIModel?.makeVoiceCaptureSession() ?? VoiceNoteCaptureSession()
|
conversationUIModel?.makeVoiceCaptureSession() ?? VoiceNoteCaptureSession()
|
||||||
}
|
}
|
||||||
|
appChromeModel.setPanicPreparation { [weak voiceRecordingVM] in
|
||||||
|
voiceRecordingVM?.panicWipe()
|
||||||
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
isNicknameFieldFocused = false
|
isNicknameFieldFocused = false
|
||||||
@@ -229,6 +232,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
autocompleteDebounceTimer?.invalidate()
|
autocompleteDebounceTimer?.invalidate()
|
||||||
|
appChromeModel.setPanicPreparation(nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,26 @@ extension String {
|
|||||||
return current >= threshold
|
return current >= threshold
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True when the message should collapse behind Show more in the UI.
|
||||||
|
/// Length alone decides this — embedding a Cashu-looking token must not
|
||||||
|
/// disable the guard (remote DoS via unbounded layout).
|
||||||
|
func isLongForDisplay(
|
||||||
|
lengthThreshold: Int = TransportConfig.uiLongMessageLengthThreshold,
|
||||||
|
tokenThreshold: Int = TransportConfig.uiVeryLongTokenThreshold
|
||||||
|
) -> Bool {
|
||||||
|
count > lengthThreshold || hasVeryLongToken(threshold: tokenThreshold)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when rich formatting (regex / link detectors) should be skipped.
|
||||||
|
/// Cashu presence used to exempt oversized content from the plain path;
|
||||||
|
/// that let untrusted input force expensive formatting work.
|
||||||
|
func isOversizedForRichFormatting(
|
||||||
|
lengthThreshold: Int = 4000,
|
||||||
|
tokenThreshold: Int = 1024
|
||||||
|
) -> Bool {
|
||||||
|
count > lengthThreshold || hasVeryLongToken(threshold: tokenThreshold)
|
||||||
|
}
|
||||||
|
|
||||||
// Extract up to `max` distinct Cashu tokens (cashuA/cashuB), as the bare
|
// Extract up to `max` distinct Cashu tokens (cashuA/cashuB), as the bare
|
||||||
// bearer strings. Allow dot '.' and shorter lengths. The `cashu:` URI
|
// bearer strings. Allow dot '.' and shorter lengths. The `cashu:` URI
|
||||||
// form matches too — the token embedded after the scheme is the match.
|
// form matches too — the token embedded after the scheme is the match.
|
||||||
|
|||||||
@@ -14,11 +14,25 @@ final class PreviewKeychainManager: KeychainManagerProtocol {
|
|||||||
// every default-constructed component under test, which access it from
|
// every default-constructed component under test, which access it from
|
||||||
// arbitrary threads.
|
// arbitrary threads.
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
|
private let installAccessGate: KeychainInstallAccessGate
|
||||||
|
private let reconcileInstallAccess: () -> Bool
|
||||||
private var storage: [String: Data] = [:]
|
private var storage: [String: Data] = [:]
|
||||||
private var serviceStorage: [String: [String: Data]] = [:]
|
private var serviceStorage: [String: [String: Data]] = [:]
|
||||||
init() {}
|
|
||||||
|
init(
|
||||||
|
installAccessGate: KeychainInstallAccessGate = KeychainInstallAccessGate(),
|
||||||
|
reconcileInstallAccess: @escaping () -> Bool = { true }
|
||||||
|
) {
|
||||||
|
self.installAccessGate = installAccessGate
|
||||||
|
self.reconcileInstallAccess = reconcileInstallAccess
|
||||||
|
}
|
||||||
|
|
||||||
|
private func installAccessAllowed() -> Bool {
|
||||||
|
installAccessGate.allowsAccess(reconcile: reconcileInstallAccess)
|
||||||
|
}
|
||||||
|
|
||||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
||||||
|
guard installAccessAllowed() else { return false }
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
storage[key] = keyData
|
storage[key] = keyData
|
||||||
@@ -26,12 +40,14 @@ final class PreviewKeychainManager: KeychainManagerProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getIdentityKey(forKey key: String) -> Data? {
|
func getIdentityKey(forKey key: String) -> Data? {
|
||||||
|
guard installAccessAllowed() else { return nil }
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
return storage[key]
|
return storage[key]
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteIdentityKey(forKey key: String) -> Bool {
|
func deleteIdentityKey(forKey key: String) -> Bool {
|
||||||
|
guard installAccessAllowed() else { return false }
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
storage.removeValue(forKey: key)
|
storage.removeValue(forKey: key)
|
||||||
@@ -51,6 +67,7 @@ final class PreviewKeychainManager: KeychainManagerProtocol {
|
|||||||
func secureClear(_ string: inout String) {}
|
func secureClear(_ string: inout String) {}
|
||||||
|
|
||||||
func verifyIdentityKeyExists() -> Bool {
|
func verifyIdentityKeyExists() -> Bool {
|
||||||
|
guard installAccessAllowed() else { return false }
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
return storage["identity_noiseStaticKey"] != nil
|
return storage["identity_noiseStaticKey"] != nil
|
||||||
@@ -58,6 +75,7 @@ final class PreviewKeychainManager: KeychainManagerProtocol {
|
|||||||
|
|
||||||
// BCH-01-009: New methods with proper error classification
|
// BCH-01-009: New methods with proper error classification
|
||||||
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
|
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
|
||||||
|
guard installAccessAllowed() else { return .accessDenied }
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
if let data = storage[key] {
|
if let data = storage[key] {
|
||||||
@@ -67,6 +85,7 @@ final class PreviewKeychainManager: KeychainManagerProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
|
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
|
||||||
|
guard installAccessAllowed() else { return .accessDenied }
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
storage[key] = keyData
|
storage[key] = keyData
|
||||||
@@ -76,24 +95,38 @@ final class PreviewKeychainManager: KeychainManagerProtocol {
|
|||||||
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
|
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
|
||||||
|
|
||||||
func save(key: String, data: Data, service: String, accessible: CFString?) {
|
func save(key: String, data: Data, service: String, accessible: CFString?) {
|
||||||
|
guard installAccessAllowed() else { return }
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
serviceStorage[service, default: [:]][key] = data
|
serviceStorage[service, default: [:]][key] = data
|
||||||
}
|
}
|
||||||
|
|
||||||
func load(key: String, service: String) -> Data? {
|
func load(key: String, service: String) -> Data? {
|
||||||
|
guard case .success(let data) = loadWithResult(key: key, service: service) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadWithResult(key: String, service: String) -> KeychainReadResult {
|
||||||
|
guard installAccessAllowed() else { return .accessDenied }
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
return serviceStorage[service]?[key]
|
guard let data = serviceStorage[service]?[key] else {
|
||||||
|
return .itemNotFound
|
||||||
|
}
|
||||||
|
return .success(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func delete(key: String, service: String) {
|
func delete(key: String, service: String) {
|
||||||
|
guard installAccessAllowed() else { return }
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
serviceStorage[service]?.removeValue(forKey: key)
|
serviceStorage[service]?.removeValue(forKey: key)
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteAll(service: String) {
|
func deleteAll(service: String) {
|
||||||
|
guard installAccessAllowed() else { return }
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
serviceStorage.removeValue(forKey: service)
|
serviceStorage.removeValue(forKey: service)
|
||||||
|
|||||||
@@ -38,6 +38,12 @@
|
|||||||
"comment" : "Fallback title when saving a shared link"
|
"comment" : "Fallback title when saving a shared link"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "پیوند اشتراکگذاریشده"
|
||||||
|
}
|
||||||
|
},
|
||||||
"fil" : {
|
"fil" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "needs_review",
|
"state" : "needs_review",
|
||||||
@@ -233,6 +239,12 @@
|
|||||||
"comment" : "Shown when the share payload cannot be encoded"
|
"comment" : "Shown when the share payload cannot be encoded"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "کدگذاری پیوند ناموفق بود"
|
||||||
|
}
|
||||||
|
},
|
||||||
"fil" : {
|
"fil" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "needs_review",
|
"state" : "needs_review",
|
||||||
@@ -428,6 +440,12 @@
|
|||||||
"comment" : "Shown when provided content cannot be shared"
|
"comment" : "Shown when provided content cannot be shared"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "محتوای قابل اشتراکگذاری وجود ندارد"
|
||||||
|
}
|
||||||
|
},
|
||||||
"fil" : {
|
"fil" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "needs_review",
|
"state" : "needs_review",
|
||||||
@@ -623,6 +641,12 @@
|
|||||||
"comment" : "Shown when the share extension receives no content"
|
"comment" : "Shown when the share extension receives no content"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "چیزی برای اشتراکگذاری نیست"
|
||||||
|
}
|
||||||
|
},
|
||||||
"fil" : {
|
"fil" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "needs_review",
|
"state" : "needs_review",
|
||||||
@@ -818,6 +842,12 @@
|
|||||||
"comment" : "Confirmation after successfully sharing a link"
|
"comment" : "Confirmation after successfully sharing a link"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "✓ پیوند در bitchat به اشتراک گذاشته شد"
|
||||||
|
}
|
||||||
|
},
|
||||||
"fil" : {
|
"fil" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "needs_review",
|
"state" : "needs_review",
|
||||||
@@ -1013,6 +1043,12 @@
|
|||||||
"comment" : "Confirmation after successfully sharing text"
|
"comment" : "Confirmation after successfully sharing text"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "✓ متن در bitchat به اشتراک گذاشته شد"
|
||||||
|
}
|
||||||
|
},
|
||||||
"fil" : {
|
"fil" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "needs_review",
|
"state" : "needs_review",
|
||||||
|
|||||||
@@ -147,6 +147,44 @@ struct AppArchitectureTests {
|
|||||||
#expect(store.teleportedGeo.isEmpty)
|
#expect(store.teleportedGeo.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("LocationPresenceStore bounds and prunes teleported geohash participants")
|
||||||
|
@MainActor
|
||||||
|
func locationPresenceStoreBoundsTeleportedParticipants() {
|
||||||
|
let store = LocationPresenceStore(teleportedGeoCapacity: 2)
|
||||||
|
|
||||||
|
store.setCurrentGeohash("u4pruy")
|
||||||
|
store.markTeleported("AAAAAA")
|
||||||
|
store.markTeleported("BBBBBB")
|
||||||
|
store.markTeleported("CCCCCC")
|
||||||
|
|
||||||
|
#expect(store.teleportedGeo == Set(["bbbbbb", "cccccc"]))
|
||||||
|
|
||||||
|
store.retainTeleportedGeo(keeping: Set(["CCCCCC"]))
|
||||||
|
#expect(store.teleportedGeo == Set(["cccccc"]))
|
||||||
|
|
||||||
|
store.setCurrentGeohash("u4pruz")
|
||||||
|
#expect(store.teleportedGeo.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("LocationPresenceStore bounds geohash nicknames and clears on channel switch")
|
||||||
|
@MainActor
|
||||||
|
func locationPresenceStoreBoundsGeoNicknames() {
|
||||||
|
let store = LocationPresenceStore(geoNicknameCapacity: 2)
|
||||||
|
|
||||||
|
store.setCurrentGeohash("u4pruy")
|
||||||
|
store.setNickname("alice", for: "AAAAAA")
|
||||||
|
store.setNickname("bob", for: "BBBBBB")
|
||||||
|
store.setNickname("carol", for: "CCCCCC")
|
||||||
|
|
||||||
|
#expect(store.geoNicknames == ["bbbbbb": "bob", "cccccc": "carol"])
|
||||||
|
|
||||||
|
store.retainGeoNicknames(keeping: Set(["CCCCCC"]))
|
||||||
|
#expect(store.geoNicknames == ["cccccc": "carol"])
|
||||||
|
|
||||||
|
store.setCurrentGeohash("u4pruz")
|
||||||
|
#expect(store.geoNicknames.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
@Test("PeerHandle equality and hashing use the canonical identity only")
|
@Test("PeerHandle equality and hashing use the canonical identity only")
|
||||||
func peerHandleEqualityUsesCanonicalIdentity() {
|
func peerHandleEqualityUsesCanonicalIdentity() {
|
||||||
let first = PeerHandle(id: "noise:abc123", routingPeerID: PeerID(str: "peer-a"))
|
let first = PeerHandle(id: "noise:abc123", routingPeerID: PeerID(str: "peer-a"))
|
||||||
|
|||||||
@@ -99,6 +99,95 @@ struct BLEServiceCoreTests {
|
|||||||
#expect(ble.currentPeerSnapshots().isEmpty)
|
#expect(ble.currentPeerSnapshots().isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func unsignedAndBadSignatureLeaveDoNotEvictOrRelayClaimedPeer() async throws {
|
||||||
|
let ble = makeService()
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
|
let outbound = OutboundPacketTap()
|
||||||
|
ble._test_onOutboundPacket = outbound.record
|
||||||
|
|
||||||
|
let unsigned = makeLeavePacket(sender: alicePeerID, marker: "unsigned")
|
||||||
|
ble._test_handlePacket(
|
||||||
|
unsigned,
|
||||||
|
fromPeerID: alicePeerID,
|
||||||
|
signingPublicKey: alice.getSigningPublicKeyData()
|
||||||
|
)
|
||||||
|
|
||||||
|
let unsignedRelayed = await TestHelpers.waitUntil(
|
||||||
|
{ outbound.count(ofType: .leave) > 0 },
|
||||||
|
timeout: TestConstants.shortTimeout
|
||||||
|
)
|
||||||
|
#expect(!unsignedRelayed)
|
||||||
|
#expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID })
|
||||||
|
|
||||||
|
let badSignature = try #require(
|
||||||
|
mallory.signPacket(makeLeavePacket(sender: alicePeerID, marker: "bad-signature"))
|
||||||
|
)
|
||||||
|
ble._test_handlePacket(
|
||||||
|
badSignature,
|
||||||
|
fromPeerID: alicePeerID,
|
||||||
|
signingPublicKey: alice.getSigningPublicKeyData()
|
||||||
|
)
|
||||||
|
|
||||||
|
let badSignatureRelayed = await TestHelpers.waitUntil(
|
||||||
|
{ outbound.count(ofType: .leave) > 0 },
|
||||||
|
timeout: TestConstants.shortTimeout
|
||||||
|
)
|
||||||
|
#expect(!badSignatureRelayed)
|
||||||
|
#expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func validSignedLeaveEvictsSessionAndRelays() async throws {
|
||||||
|
let ble = makeService()
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
|
|
||||||
|
// Establish a real session so the leave regression also verifies that
|
||||||
|
// stale secure-delivery state is retired, not just the peer-list row.
|
||||||
|
let message1 = try ble._test_noiseInitiateHandshake(with: alicePeerID)
|
||||||
|
let message2 = try #require(
|
||||||
|
try alice.processHandshakeMessage(from: ble.myPeerID, message: message1)
|
||||||
|
)
|
||||||
|
let message3 = try #require(
|
||||||
|
try ble._test_noiseProcessHandshakeMessage(from: alicePeerID, message: message2)
|
||||||
|
)
|
||||||
|
_ = try alice.processHandshakeMessage(from: ble.myPeerID, message: message3)
|
||||||
|
#expect(ble.canDeliverSecurely(to: alicePeerID))
|
||||||
|
let centralUUID = "central-valid-leave"
|
||||||
|
ble._test_bindCentral(centralUUID, to: alicePeerID)
|
||||||
|
ble._test_markNoiseAuthenticatedCentral(centralUUID, to: alicePeerID)
|
||||||
|
#expect(ble._test_isNoiseAuthenticatedCentral(centralUUID, for: alicePeerID))
|
||||||
|
|
||||||
|
let outbound = OutboundPacketTap()
|
||||||
|
ble._test_onOutboundPacket = outbound.record
|
||||||
|
let signedLeave = try #require(
|
||||||
|
alice.signPacket(makeLeavePacket(sender: alicePeerID, marker: "valid"))
|
||||||
|
)
|
||||||
|
ble._test_handlePacket(
|
||||||
|
signedLeave,
|
||||||
|
fromPeerID: alicePeerID,
|
||||||
|
signingPublicKey: alice.getSigningPublicKeyData()
|
||||||
|
)
|
||||||
|
|
||||||
|
let evicted = await TestHelpers.waitUntil(
|
||||||
|
{
|
||||||
|
!ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID }
|
||||||
|
&& !ble.canDeliverSecurely(to: alicePeerID)
|
||||||
|
&& !ble._test_isNoiseAuthenticatedCentral(centralUUID, for: alicePeerID)
|
||||||
|
},
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
)
|
||||||
|
#expect(evicted)
|
||||||
|
let relayed = await TestHelpers.waitUntil(
|
||||||
|
{ outbound.count(ofType: .leave) == 1 },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
)
|
||||||
|
#expect(relayed)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func ingressAllowsRelayedSenderOnBoundLink() async throws {
|
func ingressAllowsRelayedSenderOnBoundLink() async throws {
|
||||||
let ble = makeService()
|
let ble = makeService()
|
||||||
@@ -440,6 +529,94 @@ struct BLEServiceCoreTests {
|
|||||||
#expect(outbound.count(ofType: .courierEnvelope) == 0)
|
#expect(outbound.count(ofType: .courierEnvelope) == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func replacementXXMessageOneWithPayloadCannotAuthenticateIngressLink() async throws {
|
||||||
|
let ble = makeService()
|
||||||
|
let victim = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let victimPeerID = PeerID(publicKey: victim.getStaticPublicKeyData())
|
||||||
|
|
||||||
|
// Preserve a working victim session while an unauthenticated
|
||||||
|
// replacement candidate arrives on a newly bound physical link.
|
||||||
|
let message1 = try ble._test_noiseInitiateHandshake(with: victimPeerID)
|
||||||
|
let message2 = try #require(
|
||||||
|
try victim.processHandshakeMessage(from: ble.myPeerID, message: message1)
|
||||||
|
)
|
||||||
|
let message3 = try #require(
|
||||||
|
try ble._test_noiseProcessHandshakeMessage(
|
||||||
|
from: victimPeerID,
|
||||||
|
message: message2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_ = try victim.processHandshakeMessage(
|
||||||
|
from: ble.myPeerID,
|
||||||
|
message: message3
|
||||||
|
)
|
||||||
|
#expect(ble.canDeliverSecurely(to: victimPeerID))
|
||||||
|
|
||||||
|
let centralUUID = "central-replacement-xx-message-one"
|
||||||
|
ble._test_bindCentral(centralUUID, to: victimPeerID)
|
||||||
|
#expect(
|
||||||
|
!ble._test_isNoiseAuthenticatedCentral(
|
||||||
|
centralUUID,
|
||||||
|
for: victimPeerID
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// XX message one may legally carry a payload, so its length is not a
|
||||||
|
// reliable signal that the replacement handshake completed.
|
||||||
|
let unauthenticatedInitiator = NoiseHandshakeState(
|
||||||
|
role: .initiator,
|
||||||
|
pattern: .XX,
|
||||||
|
keychain: MockKeychain()
|
||||||
|
)
|
||||||
|
let replacementMessage1 = try unauthenticatedInitiator.writeMessage(
|
||||||
|
payload: Data([0xA5])
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
replacementMessage1.count
|
||||||
|
> NoiseSecurityConstants.xxInitialMessageSize
|
||||||
|
)
|
||||||
|
|
||||||
|
let packet = BitchatPacket(
|
||||||
|
type: MessageType.noiseHandshake.rawValue,
|
||||||
|
senderID: Data(hexString: victimPeerID.id) ?? Data(),
|
||||||
|
recipientID: Data(hexString: ble.myPeerID.id),
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
payload: replacementMessage1,
|
||||||
|
signature: nil,
|
||||||
|
ttl: TransportConfig.messageTTLDefault
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
ble._test_recordIngressIfNew(
|
||||||
|
packet: packet,
|
||||||
|
linkID: centralUUID
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
let outbound = OutboundPacketTap()
|
||||||
|
ble._test_onOutboundPacket = outbound.record
|
||||||
|
ble._test_handlePacket(
|
||||||
|
packet,
|
||||||
|
fromPeerID: victimPeerID,
|
||||||
|
preseedPeer: false
|
||||||
|
)
|
||||||
|
|
||||||
|
// Waiting for the responder's message two proves the candidate was
|
||||||
|
// processed before checking its exact authentication result.
|
||||||
|
let candidateProcessed = await TestHelpers.waitUntil(
|
||||||
|
{ outbound.count(ofType: .noiseHandshake) == 1 },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
)
|
||||||
|
#expect(candidateProcessed)
|
||||||
|
#expect(
|
||||||
|
!ble._test_isNoiseAuthenticatedCentral(
|
||||||
|
centralUUID,
|
||||||
|
for: victimPeerID
|
||||||
|
)
|
||||||
|
)
|
||||||
|
#expect(ble.canDeliverSecurely(to: victimPeerID))
|
||||||
|
}
|
||||||
|
|
||||||
/// A legitimate rotation announce necessarily arrives on a link still
|
/// A legitimate rotation announce necessarily arrives on a link still
|
||||||
/// bound to the OLD ID, so its registry upsert stores the new peer
|
/// bound to the OLD ID, so its registry upsert stores the new peer
|
||||||
/// disconnected. The successful rebind must promote it: a healed
|
/// disconnected. The successful rebind must promote it: a healed
|
||||||
@@ -572,6 +749,108 @@ struct BLEServiceCoreTests {
|
|||||||
#expect(ble.myPeerID == PeerID(str: newFingerprint.prefix(16)))
|
#expect(ble.myPeerID == PeerID(str: newFingerprint.prefix(16)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func panicSuspension_dropsLateOutboundWorkUntilCommit() async {
|
||||||
|
let ble = makeService()
|
||||||
|
let outbound = OutboundPacketTap()
|
||||||
|
ble._test_onOutboundPacket = outbound.record
|
||||||
|
let packet = makePublicPacket(
|
||||||
|
content: "late callback",
|
||||||
|
sender: ble.myPeerID,
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000)
|
||||||
|
)
|
||||||
|
|
||||||
|
ble.suspendForPanicReset()
|
||||||
|
ble.sendPacket(packet)
|
||||||
|
#expect(outbound.count(ofType: .message) == 0)
|
||||||
|
|
||||||
|
ble.completePanicReset(restartServices: false)
|
||||||
|
ble.sendPacket(packet)
|
||||||
|
#expect(outbound.count(ofType: .message) == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func panicSuspension_invalidatesQueuedMainActorIngress() async {
|
||||||
|
let ble = makeService()
|
||||||
|
let delegate = TransportEventCaptureDelegate()
|
||||||
|
ble.eventDelegate = delegate
|
||||||
|
let message = BitchatMessage(
|
||||||
|
id: "pre-panic-ingress",
|
||||||
|
sender: "Peer",
|
||||||
|
content: "must not survive panic",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: "Me",
|
||||||
|
senderPeerID: PeerID(str: "1122334455667788")
|
||||||
|
)
|
||||||
|
|
||||||
|
// The test already owns MainActor, so this task cannot run until the
|
||||||
|
// synchronous panic boundary below has invalidated its generation.
|
||||||
|
ble._test_emitTransportEvent(.messageReceived(message))
|
||||||
|
ble.suspendForPanicReset()
|
||||||
|
await Task.yield()
|
||||||
|
#expect(delegate.messageIDs.isEmpty)
|
||||||
|
|
||||||
|
ble.completePanicReset(restartServices: false)
|
||||||
|
ble._test_emitTransportEvent(.messageReceived(message))
|
||||||
|
await Task.yield()
|
||||||
|
#expect(delegate.messageIDs == [message.id])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func panicSuspension_rejectsPausedBLEReceiveBeforeMessageQueueHandoff() async {
|
||||||
|
let ble = makeService()
|
||||||
|
let gate = ReceivePacketHandoffGate()
|
||||||
|
ble._test_beforeReceivePacketHandoff = gate.pause
|
||||||
|
ble._test_onReceivePacketHandoff = gate.recordHandoff
|
||||||
|
defer {
|
||||||
|
gate.release()
|
||||||
|
ble._test_beforeReceivePacketHandoff = nil
|
||||||
|
ble._test_onReceivePacketHandoff = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let sender = PeerID(str: "1122334455667788")
|
||||||
|
let packet = makePublicPacket(
|
||||||
|
content: "must not cross panic",
|
||||||
|
sender: sender,
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000)
|
||||||
|
)
|
||||||
|
ble._test_handlePacketFromBLEQueue(packet, fromPeerID: sender)
|
||||||
|
#expect(await TestHelpers.waitUntil(
|
||||||
|
{ gate.hasPaused },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
))
|
||||||
|
|
||||||
|
// Panic closes the lifecycle before waiting for the paused bleQueue
|
||||||
|
// callback. Releasing it afterward lets the callback enqueue its
|
||||||
|
// messageQueue handoff, where the captured generation must be rejected
|
||||||
|
// before packet processing starts.
|
||||||
|
let panicIngressObserver = PanicIngressObserver(service: ble)
|
||||||
|
let didObservePanicClosure = await withCheckedContinuation { continuation in
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
let didObserveClosure = panicIngressObserver.waitUntilClosed(
|
||||||
|
timeout: TestConstants.defaultTimeout
|
||||||
|
)
|
||||||
|
gate.release()
|
||||||
|
continuation.resume(returning: didObserveClosure)
|
||||||
|
}
|
||||||
|
ble.suspendForPanicReset()
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(didObservePanicClosure)
|
||||||
|
#expect(gate.handoffCount == 0)
|
||||||
|
|
||||||
|
// A packet captured under the reopened lifecycle still crosses the
|
||||||
|
// same handoff, proving the test did not merely disable the hook.
|
||||||
|
ble.completePanicReset(restartServices: false)
|
||||||
|
ble._test_handlePacketFromBLEQueue(packet, fromPeerID: sender)
|
||||||
|
#expect(await TestHelpers.waitUntil(
|
||||||
|
{ gate.handoffCount == 1 },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func modifiedServices_rediscoverWhenBitChatServiceIsInvalidated() async throws {
|
func modifiedServices_rediscoverWhenBitChatServiceIsInvalidated() async throws {
|
||||||
let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB")
|
let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB")
|
||||||
@@ -666,6 +945,68 @@ private final class OutboundPacketTap {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final class ReceivePacketHandoffGate: @unchecked Sendable {
|
||||||
|
private let condition = NSCondition()
|
||||||
|
private var paused = false
|
||||||
|
private var released = false
|
||||||
|
private var recordedHandoffCount = 0
|
||||||
|
|
||||||
|
var hasPaused: Bool {
|
||||||
|
condition.lock()
|
||||||
|
defer { condition.unlock() }
|
||||||
|
return paused
|
||||||
|
}
|
||||||
|
|
||||||
|
var handoffCount: Int {
|
||||||
|
condition.lock()
|
||||||
|
defer { condition.unlock() }
|
||||||
|
return recordedHandoffCount
|
||||||
|
}
|
||||||
|
|
||||||
|
func pause() {
|
||||||
|
condition.lock()
|
||||||
|
paused = true
|
||||||
|
condition.broadcast()
|
||||||
|
while !released {
|
||||||
|
condition.wait()
|
||||||
|
}
|
||||||
|
condition.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func release() {
|
||||||
|
condition.lock()
|
||||||
|
released = true
|
||||||
|
condition.broadcast()
|
||||||
|
condition.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordHandoff() {
|
||||||
|
condition.lock()
|
||||||
|
recordedHandoffCount += 1
|
||||||
|
condition.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lets a dedicated dispatch worker observe the lock-protected panic gate
|
||||||
|
/// without treating the full BLE service as generally Sendable.
|
||||||
|
private final class PanicIngressObserver: @unchecked Sendable {
|
||||||
|
private let service: BLEService
|
||||||
|
|
||||||
|
init(service: BLEService) {
|
||||||
|
self.service = service
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitUntilClosed(timeout: TimeInterval) -> Bool {
|
||||||
|
let deadline = DispatchTime.now().uptimeNanoseconds
|
||||||
|
+ UInt64(timeout * 1_000_000_000)
|
||||||
|
while service._test_isPanicIngressOpen,
|
||||||
|
DispatchTime.now().uptimeNanoseconds < deadline {
|
||||||
|
Thread.sleep(forTimeInterval: 0.001)
|
||||||
|
}
|
||||||
|
return !service._test_isPanicIngressOpen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func makeService() -> BLEService {
|
private func makeService() -> BLEService {
|
||||||
let keychain = MockKeychain()
|
let keychain = MockKeychain()
|
||||||
let identityManager = MockIdentityManager(keychain)
|
let identityManager = MockIdentityManager(keychain)
|
||||||
@@ -690,6 +1031,18 @@ private func makePublicPacket(content: String, sender: PeerID, timestamp: UInt64
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func makeLeavePacket(sender: PeerID, marker: String) -> BitchatPacket {
|
||||||
|
BitchatPacket(
|
||||||
|
type: MessageType.leave.rawValue,
|
||||||
|
senderID: Data(hexString: sender.id) ?? Data(),
|
||||||
|
recipientID: nil,
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
payload: Data(marker.utf8),
|
||||||
|
signature: nil,
|
||||||
|
ttl: TransportConfig.messageTTLDefault
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private final class PublicCaptureDelegate: BitchatDelegate {
|
private final class PublicCaptureDelegate: BitchatDelegate {
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private(set) var publicMessages: [BitchatMessage] = []
|
private(set) var publicMessages: [BitchatMessage] = []
|
||||||
@@ -724,3 +1077,13 @@ private final class PublicCaptureDelegate: BitchatDelegate {
|
|||||||
return publicMessages
|
return publicMessages
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class TransportEventCaptureDelegate: TransportEventDelegate {
|
||||||
|
private(set) var messageIDs: [String] = []
|
||||||
|
|
||||||
|
func didReceiveTransportEvent(_ event: TransportEvent) {
|
||||||
|
guard case .messageReceived(let message) = event else { return }
|
||||||
|
messageIDs.append(message.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,15 +7,19 @@
|
|||||||
// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` /
|
// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` /
|
||||||
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
|
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
|
||||||
//
|
//
|
||||||
// Scope note: the async media-preparation pipelines (`ImageUtils`,
|
// Real file/codec work remains covered by `ChatMediaPreparationTests`. These
|
||||||
// `ChatMediaPreparation`) run real file/codec work and remain covered by
|
// tests inject a paused voice-note preparer to exercise cancellation ownership
|
||||||
// `ChatMediaPreparationTests`; here we cover message enqueueing, transfer
|
// across the detached-preparation/MainActor boundary deterministically.
|
||||||
// bookkeeping, and the blocked-context guards.
|
|
||||||
//
|
//
|
||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
import Foundation
|
import Foundation
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#else
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
// MARK: - Mock Context
|
// MARK: - Mock Context
|
||||||
@@ -86,11 +90,72 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
|||||||
|
|
||||||
// Mesh file transfer
|
// Mesh file transfer
|
||||||
private(set) var privateFileSends: [(peerID: PeerID, transferId: String)] = []
|
private(set) var privateFileSends: [(peerID: PeerID, transferId: String)] = []
|
||||||
|
private(set) var privateFileLegacyAllowances: [Bool] = []
|
||||||
private(set) var broadcastFileSends: [String] = []
|
private(set) var broadcastFileSends: [String] = []
|
||||||
private(set) var cancelledTransfers: [String] = []
|
private(set) var cancelledTransfers: [String] = []
|
||||||
|
var privateMediaPolicy: PrivateMediaSendPolicy = .encrypted
|
||||||
|
var resolvedPrivateMediaPolicy: PrivateMediaSendPolicy?
|
||||||
|
private(set) var legacyConsentRequests: [(
|
||||||
|
id: UUID,
|
||||||
|
peerID: PeerID,
|
||||||
|
transferId: String,
|
||||||
|
messageID: String
|
||||||
|
)] = []
|
||||||
|
private(set) var invalidatedLegacyConsents: [(transferId: String, messageID: String)] = []
|
||||||
|
private var pendingLegacyConsentIDs: [UUID] = []
|
||||||
|
private var legacyConsentCompletions: [UUID: @MainActor (Bool) -> Void] = [:]
|
||||||
|
|
||||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
|
||||||
|
privateMediaPolicy
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolvePrivateMediaSendPolicy(
|
||||||
|
to peerID: PeerID,
|
||||||
|
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||||
|
) {
|
||||||
|
completion(resolvedPrivateMediaPolicy ?? privateMediaPolicy)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestLegacyPrivateMediaConsent(
|
||||||
|
for peerID: PeerID,
|
||||||
|
transferId: String,
|
||||||
|
messageID: String,
|
||||||
|
completion: @escaping @MainActor (Bool) -> Void
|
||||||
|
) {
|
||||||
|
let id = UUID()
|
||||||
|
legacyConsentRequests.append((id, peerID, transferId, messageID))
|
||||||
|
pendingLegacyConsentIDs.append(id)
|
||||||
|
legacyConsentCompletions[id] = completion
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelLegacyPrivateMediaConsent(transferId: String, messageID: String) {
|
||||||
|
invalidatedLegacyConsents.append((transferId, messageID))
|
||||||
|
let matchingIDs = Set(legacyConsentRequests.compactMap { request in
|
||||||
|
request.transferId == transferId && request.messageID == messageID
|
||||||
|
? request.id
|
||||||
|
: nil
|
||||||
|
})
|
||||||
|
pendingLegacyConsentIDs.removeAll { matchingIDs.contains($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveNextLegacyConsent(_ approved: Bool) {
|
||||||
|
guard !pendingLegacyConsentIDs.isEmpty else { return }
|
||||||
|
let id = pendingLegacyConsentIDs.removeFirst()
|
||||||
|
legacyConsentCompletions[id]?(approved)
|
||||||
|
}
|
||||||
|
|
||||||
|
func invokeLegacyConsentEvenIfInvalidated(id: UUID, approved: Bool) {
|
||||||
|
legacyConsentCompletions[id]?(approved)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendFilePrivate(
|
||||||
|
_ packet: BitchatFilePacket,
|
||||||
|
to peerID: PeerID,
|
||||||
|
transferId: String,
|
||||||
|
allowLegacyFallback: Bool
|
||||||
|
) {
|
||||||
privateFileSends.append((peerID, transferId))
|
privateFileSends.append((peerID, transferId))
|
||||||
|
privateFileLegacyAllowances.append(allowLegacyFallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
|
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
|
||||||
@@ -102,6 +167,56 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final class PausedVoiceNotePreparer: @unchecked Sendable {
|
||||||
|
private let condition = NSCondition()
|
||||||
|
private var started = false
|
||||||
|
private var released = false
|
||||||
|
private var finished = false
|
||||||
|
private let packet: BitchatFilePacket
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let content = Data("voice".utf8)
|
||||||
|
packet = BitchatFilePacket(
|
||||||
|
fileName: "paused.m4a",
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "audio/mp4",
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepare(_: URL) throws -> BitchatFilePacket {
|
||||||
|
condition.lock()
|
||||||
|
started = true
|
||||||
|
condition.broadcast()
|
||||||
|
while !released {
|
||||||
|
condition.wait()
|
||||||
|
}
|
||||||
|
finished = true
|
||||||
|
condition.broadcast()
|
||||||
|
condition.unlock()
|
||||||
|
return packet
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasStarted: Bool {
|
||||||
|
condition.lock()
|
||||||
|
defer { condition.unlock() }
|
||||||
|
return started
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasFinished: Bool {
|
||||||
|
condition.lock()
|
||||||
|
defer { condition.unlock() }
|
||||||
|
return finished
|
||||||
|
}
|
||||||
|
|
||||||
|
func release() {
|
||||||
|
condition.lock()
|
||||||
|
released = true
|
||||||
|
condition.broadcast()
|
||||||
|
condition.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Coordinator Tests Against Mock Context
|
// MARK: - Coordinator Tests Against Mock Context
|
||||||
|
|
||||||
/// Exercises `ChatMediaTransferCoordinator` against
|
/// Exercises `ChatMediaTransferCoordinator` against
|
||||||
@@ -166,6 +281,14 @@ struct ChatMediaTransferCoordinatorContextTests {
|
|||||||
#expect(context.removedMessages.count == 1)
|
#expect(context.removedMessages.count == 1)
|
||||||
#expect(context.removedMessages.first?.messageID == "m2")
|
#expect(context.removedMessages.first?.messageID == "m2")
|
||||||
#expect(context.removedMessages.first?.cleanupFile == true)
|
#expect(context.removedMessages.first?.cleanupFile == true)
|
||||||
|
|
||||||
|
// A pre-start rejection keeps the placeholder visible and failed,
|
||||||
|
// including queued post-handshake encryption failures.
|
||||||
|
coordinator.registerTransfer(transferId: "t3", messageID: "m3")
|
||||||
|
coordinator.handleTransferEvent(.rejected(id: "t3", reason: "encryption failed"))
|
||||||
|
#expect(context.deliveryStatusUpdates.last?.messageID == "m3")
|
||||||
|
#expect(context.deliveryStatusUpdates.last?.status == .failed(reason: "encryption failed"))
|
||||||
|
#expect(coordinator.messageIDToTransferId["m3"] == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
@@ -188,6 +311,128 @@ struct ChatMediaTransferCoordinatorContextTests {
|
|||||||
#expect(coordinator.messageIDToTransferId.isEmpty)
|
#expect(coordinator.messageIDToTransferId.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func resetForPanic_cancelsEveryTransportTransferAndClearsMappings() {
|
||||||
|
let context = MockChatMediaTransferContext()
|
||||||
|
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||||
|
coordinator.registerTransfer(transferId: "t1", messageID: "m1")
|
||||||
|
coordinator.registerTransfer(transferId: "t1", messageID: "m2")
|
||||||
|
coordinator.registerTransfer(transferId: "t2", messageID: "m3")
|
||||||
|
|
||||||
|
coordinator.resetForPanic()
|
||||||
|
|
||||||
|
#expect(Set(context.cancelledTransfers) == Set(["t1", "t2"]))
|
||||||
|
#expect(coordinator.transferIdToMessageIDs.isEmpty)
|
||||||
|
#expect(coordinator.messageIDToTransferId.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func resetForPanic_waitsForActiveImageWriterBeforeReturning() async throws {
|
||||||
|
let context = MockChatMediaTransferContext()
|
||||||
|
let sourceURL = try makeCoordinatorTestImageURL()
|
||||||
|
let outputURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("panic-prepared-\(UUID().uuidString).jpg")
|
||||||
|
let preparer = PausedImagePreparer(outputURL: outputURL)
|
||||||
|
let coordinator = ChatMediaTransferCoordinator(
|
||||||
|
context: context,
|
||||||
|
prepareImagePacket: { sourceURL in
|
||||||
|
try preparer.prepare(sourceURL)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
defer {
|
||||||
|
preparer.release()
|
||||||
|
try? FileManager.default.removeItem(at: sourceURL)
|
||||||
|
try? FileManager.default.removeItem(at: outputURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
coordinator.sendImage(from: sourceURL)
|
||||||
|
#expect(await TestHelpers.waitUntil(
|
||||||
|
{ preparer.hasStarted },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
))
|
||||||
|
|
||||||
|
DispatchQueue.global(qos: .userInitiated).asyncAfter(
|
||||||
|
deadline: .now() + .milliseconds(100)
|
||||||
|
) {
|
||||||
|
preparer.release()
|
||||||
|
}
|
||||||
|
coordinator.resetForPanic()
|
||||||
|
|
||||||
|
// The synchronous reset boundary cannot return while a pre-panic
|
||||||
|
// writer can still create output. The real panic path deletes media
|
||||||
|
// immediately after this method returns.
|
||||||
|
#expect(preparer.hasFinished)
|
||||||
|
|
||||||
|
try? FileManager.default.removeItem(at: outputURL)
|
||||||
|
#expect(await TestHelpers.waitUntil(
|
||||||
|
{ !FileManager.default.fileExists(atPath: outputURL.path) },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
))
|
||||||
|
await Task.yield()
|
||||||
|
#expect(context.privateFileSends.isEmpty)
|
||||||
|
#expect(context.broadcastFileSends.isEmpty)
|
||||||
|
#expect(context.systemMessages.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func imagePreparation_doesNotRetainCoordinatorOrDeallocatedContext() async throws {
|
||||||
|
let sourceURL = try makeCoordinatorTestImageURL()
|
||||||
|
let outputURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("released-context-\(UUID().uuidString).jpg")
|
||||||
|
let preparer = PausedImagePreparer(outputURL: outputURL)
|
||||||
|
var context: MockChatMediaTransferContext? = MockChatMediaTransferContext()
|
||||||
|
var coordinator: ChatMediaTransferCoordinator? = ChatMediaTransferCoordinator(
|
||||||
|
context: context!,
|
||||||
|
prepareImagePacket: { sourceURL in
|
||||||
|
try preparer.prepare(sourceURL)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
weak var weakContext: MockChatMediaTransferContext?
|
||||||
|
weak var weakCoordinator: ChatMediaTransferCoordinator?
|
||||||
|
weakContext = context
|
||||||
|
weakCoordinator = coordinator
|
||||||
|
defer {
|
||||||
|
preparer.release()
|
||||||
|
try? FileManager.default.removeItem(at: sourceURL)
|
||||||
|
try? FileManager.default.removeItem(at: outputURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
coordinator?.sendImage(from: sourceURL)
|
||||||
|
#expect(await TestHelpers.waitUntil(
|
||||||
|
{ preparer.hasStarted },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
))
|
||||||
|
|
||||||
|
coordinator = nil
|
||||||
|
context = nil
|
||||||
|
#expect(weakCoordinator == nil)
|
||||||
|
#expect(weakContext == nil)
|
||||||
|
|
||||||
|
preparer.release()
|
||||||
|
#expect(await TestHelpers.waitUntil(
|
||||||
|
{ preparer.hasFinished },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
))
|
||||||
|
#expect(await TestHelpers.waitUntil(
|
||||||
|
{ !FileManager.default.fileExists(atPath: outputURL.path) },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func deleteMediaMessage_cancelsApprovedTransferBeforeRemovingMapping() {
|
||||||
|
let context = MockChatMediaTransferContext()
|
||||||
|
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||||
|
coordinator.registerTransfer(transferId: "approved-delete", messageID: "message-delete")
|
||||||
|
|
||||||
|
coordinator.deleteMediaMessage(messageID: "message-delete")
|
||||||
|
|
||||||
|
#expect(context.cancelledTransfers == ["approved-delete"])
|
||||||
|
#expect(coordinator.messageIDToTransferId["message-delete"] == nil)
|
||||||
|
#expect(context.removedMessages.map(\.messageID) == ["message-delete"])
|
||||||
|
#expect(context.removedMessages.first?.cleanupFile == true)
|
||||||
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func sendVoiceNote_blockedContextRemovesFileAndExplains() async throws {
|
func sendVoiceNote_blockedContextRemovesFileAndExplains() async throws {
|
||||||
let context = MockChatMediaTransferContext()
|
let context = MockChatMediaTransferContext()
|
||||||
@@ -206,4 +451,337 @@ struct ChatMediaTransferCoordinatorContextTests {
|
|||||||
#expect(context.appendedPublicMessages.isEmpty)
|
#expect(context.appendedPublicMessages.isEmpty)
|
||||||
#expect(coordinator.transferIdToMessageIDs.isEmpty)
|
#expect(coordinator.transferIdToMessageIDs.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func cancelVoiceNoteDuringDetachedPreparationCannotSendOrRestoreMapping() async throws {
|
||||||
|
let context = MockChatMediaTransferContext()
|
||||||
|
let peerID = PeerID(str: "5566778899aabbcc")
|
||||||
|
context.selectedPrivateChatPeer = peerID
|
||||||
|
let preparer = PausedVoiceNotePreparer()
|
||||||
|
let coordinator = ChatMediaTransferCoordinator(
|
||||||
|
context: context,
|
||||||
|
prepareVoiceNotePacket: { url in try preparer.prepare(url) }
|
||||||
|
)
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("paused-private-\(UUID().uuidString).m4a")
|
||||||
|
try Data("voice".utf8).write(to: url)
|
||||||
|
defer {
|
||||||
|
preparer.release()
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
coordinator.sendVoiceNote(at: url)
|
||||||
|
#expect(await TestHelpers.waitUntil({ preparer.hasStarted }, timeout: TestConstants.longTimeout))
|
||||||
|
let messageID = try #require(context.privateChats[peerID]?.first?.id)
|
||||||
|
let transferId = try #require(coordinator.messageIDToTransferId[messageID])
|
||||||
|
|
||||||
|
coordinator.cancelMediaSend(messageID: messageID)
|
||||||
|
preparer.release()
|
||||||
|
#expect(await TestHelpers.waitUntil({ preparer.hasFinished }, timeout: TestConstants.longTimeout))
|
||||||
|
for _ in 0..<10 { await Task.yield() }
|
||||||
|
|
||||||
|
#expect(context.cancelledTransfers == [transferId])
|
||||||
|
#expect(context.privateFileSends.isEmpty)
|
||||||
|
#expect(context.broadcastFileSends.isEmpty)
|
||||||
|
#expect(coordinator.messageIDToTransferId[messageID] == nil)
|
||||||
|
#expect(coordinator.transferIdToMessageIDs[transferId] == nil)
|
||||||
|
#expect(context.removedMessages.map(\.messageID) == [messageID])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func deletePublicVoiceNoteDuringDetachedPreparationCannotBroadcastOrRestoreMapping() async throws {
|
||||||
|
let context = MockChatMediaTransferContext()
|
||||||
|
let preparer = PausedVoiceNotePreparer()
|
||||||
|
let coordinator = ChatMediaTransferCoordinator(
|
||||||
|
context: context,
|
||||||
|
prepareVoiceNotePacket: { url in try preparer.prepare(url) }
|
||||||
|
)
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("paused-public-\(UUID().uuidString).m4a")
|
||||||
|
try Data("voice".utf8).write(to: url)
|
||||||
|
defer {
|
||||||
|
preparer.release()
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
coordinator.sendVoiceNote(at: url)
|
||||||
|
#expect(await TestHelpers.waitUntil({ preparer.hasStarted }, timeout: TestConstants.longTimeout))
|
||||||
|
let messageID = try #require(context.appendedPublicMessages.first?.message.id)
|
||||||
|
let transferId = try #require(coordinator.messageIDToTransferId[messageID])
|
||||||
|
|
||||||
|
coordinator.deleteMediaMessage(messageID: messageID)
|
||||||
|
preparer.release()
|
||||||
|
#expect(await TestHelpers.waitUntil({ preparer.hasFinished }, timeout: TestConstants.longTimeout))
|
||||||
|
for _ in 0..<10 { await Task.yield() }
|
||||||
|
|
||||||
|
#expect(context.cancelledTransfers == [transferId])
|
||||||
|
#expect(context.broadcastFileSends.isEmpty)
|
||||||
|
#expect(context.privateFileSends.isEmpty)
|
||||||
|
#expect(coordinator.messageIDToTransferId[messageID] == nil)
|
||||||
|
#expect(coordinator.transferIdToMessageIDs[transferId] == nil)
|
||||||
|
#expect(context.removedMessages.map(\.messageID) == [messageID])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func voicePreparationFailureMarksPlaceholderFailedAndClearsEarlyMapping() async throws {
|
||||||
|
let context = MockChatMediaTransferContext()
|
||||||
|
let peerID = PeerID(str: "66778899aabbccdd")
|
||||||
|
context.selectedPrivateChatPeer = peerID
|
||||||
|
let coordinator = ChatMediaTransferCoordinator(
|
||||||
|
context: context,
|
||||||
|
prepareVoiceNotePacket: { _ in
|
||||||
|
throw ChatMediaPreparationError.voiceNoteTooLarge(bytes: 999_999)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("failing-private-\(UUID().uuidString).m4a")
|
||||||
|
try Data("voice".utf8).write(to: url)
|
||||||
|
defer { try? FileManager.default.removeItem(at: url) }
|
||||||
|
|
||||||
|
coordinator.sendVoiceNote(at: url)
|
||||||
|
#expect(await TestHelpers.waitUntil(
|
||||||
|
{
|
||||||
|
context.deliveryStatusUpdates.contains { update in
|
||||||
|
if case .failed = update.status { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
))
|
||||||
|
let messageID = try #require(context.privateChats[peerID]?.first?.id)
|
||||||
|
|
||||||
|
#expect(coordinator.messageIDToTransferId[messageID] == nil)
|
||||||
|
#expect(coordinator.transferIdToMessageIDs.isEmpty)
|
||||||
|
#expect(context.privateFileSends.isEmpty)
|
||||||
|
#expect(context.broadcastFileSends.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func legacyPrivateVoiceNoteWaitsForPerSendConsent() async throws {
|
||||||
|
let context = MockChatMediaTransferContext()
|
||||||
|
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||||
|
let peerID = PeerID(str: "1122334455667788")
|
||||||
|
context.selectedPrivateChatPeer = peerID
|
||||||
|
context.privateMediaPolicy = .legacyRequiresConsent
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("legacy-consent-\(UUID().uuidString).m4a")
|
||||||
|
try (Data([0x00, 0x00, 0x00, 0x18]) + Data("ftypM4A voice".utf8)).write(to: url)
|
||||||
|
defer { try? FileManager.default.removeItem(at: url) }
|
||||||
|
|
||||||
|
coordinator.sendVoiceNote(at: url)
|
||||||
|
|
||||||
|
let prompted = await TestHelpers.waitUntil(
|
||||||
|
{ context.legacyConsentRequests.count == 1 },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
)
|
||||||
|
#expect(prompted)
|
||||||
|
#expect(context.legacyConsentRequests.map { $0.peerID } == [peerID])
|
||||||
|
#expect(context.privateFileSends.isEmpty)
|
||||||
|
|
||||||
|
context.resolveNextLegacyConsent(true)
|
||||||
|
|
||||||
|
#expect(context.privateFileSends.count == 1)
|
||||||
|
#expect(context.privateFileLegacyAllowances == [true])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func capabilityProofTimeoutTransitionsToConsentWithoutAutomaticRawSend() async throws {
|
||||||
|
let context = MockChatMediaTransferContext()
|
||||||
|
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||||
|
let peerID = PeerID(str: "1020304050607080")
|
||||||
|
context.selectedPrivateChatPeer = peerID
|
||||||
|
context.privateMediaPolicy = .awaitingCapabilityProof
|
||||||
|
context.resolvedPrivateMediaPolicy = .legacyRequiresConsent
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("proof-timeout-consent-\(UUID().uuidString).m4a")
|
||||||
|
try Data("voice".utf8).write(to: url)
|
||||||
|
defer { try? FileManager.default.removeItem(at: url) }
|
||||||
|
|
||||||
|
coordinator.sendVoiceNote(at: url)
|
||||||
|
|
||||||
|
let prompted = await TestHelpers.waitUntil(
|
||||||
|
{ context.legacyConsentRequests.count == 1 },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
)
|
||||||
|
#expect(prompted)
|
||||||
|
#expect(context.privateFileSends.isEmpty)
|
||||||
|
context.resolveNextLegacyConsent(false)
|
||||||
|
#expect(context.privateFileSends.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func legacyConsentApprovalAfterCancelCannotSend() async throws {
|
||||||
|
let context = MockChatMediaTransferContext()
|
||||||
|
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||||
|
let peerID = PeerID(str: "2233445566778899")
|
||||||
|
context.selectedPrivateChatPeer = peerID
|
||||||
|
context.privateMediaPolicy = .legacyRequiresConsent
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("legacy-cancel-\(UUID().uuidString).m4a")
|
||||||
|
try Data("voice".utf8).write(to: url)
|
||||||
|
defer { try? FileManager.default.removeItem(at: url) }
|
||||||
|
|
||||||
|
coordinator.sendVoiceNote(at: url)
|
||||||
|
let prompted = await TestHelpers.waitUntil(
|
||||||
|
{ context.legacyConsentRequests.count == 1 },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
)
|
||||||
|
#expect(prompted)
|
||||||
|
let request = try #require(context.legacyConsentRequests.first)
|
||||||
|
|
||||||
|
coordinator.cancelMediaSend(messageID: request.messageID)
|
||||||
|
#expect(context.invalidatedLegacyConsents.contains {
|
||||||
|
$0.transferId == request.transferId && $0.messageID == request.messageID
|
||||||
|
})
|
||||||
|
|
||||||
|
// Model a stale framework callback that escaped active invalidation.
|
||||||
|
// The coordinator's transfer/message binding check is the final gate.
|
||||||
|
context.invokeLegacyConsentEvenIfInvalidated(id: request.id, approved: true)
|
||||||
|
#expect(context.privateFileSends.isEmpty)
|
||||||
|
#expect(coordinator.messageIDToTransferId[request.messageID] == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func legacyConsentApprovalAfterDeleteCannotSend() async throws {
|
||||||
|
let context = MockChatMediaTransferContext()
|
||||||
|
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||||
|
let peerID = PeerID(str: "33445566778899aa")
|
||||||
|
context.selectedPrivateChatPeer = peerID
|
||||||
|
context.privateMediaPolicy = .legacyRequiresConsent
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("legacy-delete-\(UUID().uuidString).m4a")
|
||||||
|
try Data("voice".utf8).write(to: url)
|
||||||
|
defer { try? FileManager.default.removeItem(at: url) }
|
||||||
|
|
||||||
|
coordinator.sendVoiceNote(at: url)
|
||||||
|
let prompted = await TestHelpers.waitUntil(
|
||||||
|
{ context.legacyConsentRequests.count == 1 },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
)
|
||||||
|
#expect(prompted)
|
||||||
|
let request = try #require(context.legacyConsentRequests.first)
|
||||||
|
|
||||||
|
coordinator.deleteMediaMessage(messageID: request.messageID)
|
||||||
|
context.invokeLegacyConsentEvenIfInvalidated(id: request.id, approved: true)
|
||||||
|
|
||||||
|
#expect(context.invalidatedLegacyConsents.contains {
|
||||||
|
$0.transferId == request.transferId && $0.messageID == request.messageID
|
||||||
|
})
|
||||||
|
#expect(context.privateFileSends.isEmpty)
|
||||||
|
#expect(coordinator.messageIDToTransferId[request.messageID] == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func pinnedPrivateMediaDowngradeNeverPromptsOrSends() async throws {
|
||||||
|
let context = MockChatMediaTransferContext()
|
||||||
|
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||||
|
let peerID = PeerID(str: "1122334455667788")
|
||||||
|
context.selectedPrivateChatPeer = peerID
|
||||||
|
context.privateMediaPolicy = .blockedDowngrade
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("blocked-downgrade-\(UUID().uuidString).m4a")
|
||||||
|
try Data("voice".utf8).write(to: url)
|
||||||
|
defer { try? FileManager.default.removeItem(at: url) }
|
||||||
|
|
||||||
|
coordinator.sendVoiceNote(at: url)
|
||||||
|
|
||||||
|
let failed = await TestHelpers.waitUntil(
|
||||||
|
{ context.deliveryStatusUpdates.contains { update in
|
||||||
|
if case .failed = update.status { return true }
|
||||||
|
return false
|
||||||
|
} },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
)
|
||||||
|
#expect(failed)
|
||||||
|
#expect(context.legacyConsentRequests.isEmpty)
|
||||||
|
#expect(context.privateFileSends.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class PausedImagePreparer: @unchecked Sendable {
|
||||||
|
private let condition = NSCondition()
|
||||||
|
private let outputURL: URL
|
||||||
|
private var started = false
|
||||||
|
private var released = false
|
||||||
|
private var finished = false
|
||||||
|
|
||||||
|
init(outputURL: URL) {
|
||||||
|
self.outputURL = outputURL
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasStarted: Bool {
|
||||||
|
condition.lock()
|
||||||
|
defer { condition.unlock() }
|
||||||
|
return started
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasFinished: Bool {
|
||||||
|
condition.lock()
|
||||||
|
defer { condition.unlock() }
|
||||||
|
return finished
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepare(_ _: URL) throws -> ChatPreparedImage {
|
||||||
|
condition.lock()
|
||||||
|
started = true
|
||||||
|
condition.broadcast()
|
||||||
|
while !released {
|
||||||
|
condition.wait()
|
||||||
|
}
|
||||||
|
condition.unlock()
|
||||||
|
|
||||||
|
let data = Data("prepared image".utf8)
|
||||||
|
try data.write(to: outputURL, options: .atomic)
|
||||||
|
let packet = BitchatFilePacket(
|
||||||
|
fileName: outputURL.lastPathComponent,
|
||||||
|
fileSize: UInt64(data.count),
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: data
|
||||||
|
)
|
||||||
|
|
||||||
|
condition.lock()
|
||||||
|
finished = true
|
||||||
|
condition.broadcast()
|
||||||
|
condition.unlock()
|
||||||
|
return ChatPreparedImage(outputURL: outputURL, packet: packet)
|
||||||
|
}
|
||||||
|
|
||||||
|
func release() {
|
||||||
|
condition.lock()
|
||||||
|
released = true
|
||||||
|
condition.broadcast()
|
||||||
|
condition.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeCoordinatorTestImageURL() throws -> URL {
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("coordinator-image-\(UUID().uuidString).png")
|
||||||
|
#if os(iOS)
|
||||||
|
let image = UIGraphicsImageRenderer(size: CGSize(width: 16, height: 16))
|
||||||
|
.image { context in
|
||||||
|
UIColor.systemBlue.setFill()
|
||||||
|
context.fill(CGRect(x: 0, y: 0, width: 16, height: 16))
|
||||||
|
}
|
||||||
|
guard let data = image.pngData() else {
|
||||||
|
throw CoordinatorImageTestError.encodingFailed
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
let image = NSImage(size: NSSize(width: 16, height: 16))
|
||||||
|
image.lockFocus()
|
||||||
|
NSColor.systemBlue.setFill()
|
||||||
|
NSRect(x: 0, y: 0, width: 16, height: 16).fill()
|
||||||
|
image.unlockFocus()
|
||||||
|
guard let tiff = image.tiffRepresentation,
|
||||||
|
let bitmap = NSBitmapImageRep(data: tiff),
|
||||||
|
let data = bitmap.representation(using: .png, properties: [:]) else {
|
||||||
|
throw CoordinatorImageTestError.encodingFailed
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
try data.write(to: url, options: .atomic)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CoordinatorImageTestError: Error {
|
||||||
|
case encodingFailed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1048,6 +1048,89 @@ struct ChatViewModelMediaTransferTests {
|
|||||||
#expect(viewModel.transferIdToMessageIDs.count == 1)
|
#expect(viewModel.transferIdToMessageIDs.count == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func legacyPrivateMediaConsentRequestsArePerSendAndQueued() async throws {
|
||||||
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
|
let firstPeer = PeerID(str: "1111111111111111")
|
||||||
|
let secondPeer = PeerID(str: "2222222222222222")
|
||||||
|
var decisions: [Bool] = []
|
||||||
|
|
||||||
|
viewModel.enqueueLegacyPrivateMediaConsent(
|
||||||
|
for: firstPeer,
|
||||||
|
transferId: "transfer-1",
|
||||||
|
messageID: "message-1"
|
||||||
|
) { decisions.append($0) }
|
||||||
|
viewModel.enqueueLegacyPrivateMediaConsent(
|
||||||
|
for: secondPeer,
|
||||||
|
transferId: "transfer-2",
|
||||||
|
messageID: "message-2"
|
||||||
|
) { decisions.append($0) }
|
||||||
|
|
||||||
|
#expect(viewModel.legacyPrivateMediaConsentRequest?.peerID == firstPeer)
|
||||||
|
let firstRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id)
|
||||||
|
viewModel.resolveLegacyPrivateMediaConsent(requestID: firstRequestID, approved: true)
|
||||||
|
let showedSecond = await TestHelpers.waitUntil(
|
||||||
|
{ viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
)
|
||||||
|
#expect(showedSecond)
|
||||||
|
let secondRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id)
|
||||||
|
|
||||||
|
// A button action and the dialog binding may both resolve the first
|
||||||
|
// ID. The stale second callback must not consume the queued request.
|
||||||
|
viewModel.resolveLegacyPrivateMediaConsent(requestID: firstRequestID, approved: false)
|
||||||
|
#expect(decisions == [true])
|
||||||
|
#expect(viewModel.legacyPrivateMediaConsentRequest?.id == secondRequestID)
|
||||||
|
|
||||||
|
viewModel.resolveLegacyPrivateMediaConsent(requestID: secondRequestID, approved: false)
|
||||||
|
|
||||||
|
#expect(decisions == [true, false])
|
||||||
|
#expect(viewModel.legacyPrivateMediaConsentRequest == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func invalidatingPresentedLegacyConsentAdvancesQueueAndStaleResolutionNoops() async throws {
|
||||||
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
|
let firstPeer = PeerID(str: "3333333333333333")
|
||||||
|
let secondPeer = PeerID(str: "4444444444444444")
|
||||||
|
var decisions: [String] = []
|
||||||
|
|
||||||
|
viewModel.enqueueLegacyPrivateMediaConsent(
|
||||||
|
for: firstPeer,
|
||||||
|
transferId: "transfer-cancelled",
|
||||||
|
messageID: "message-cancelled"
|
||||||
|
) { decisions.append("first:\($0)") }
|
||||||
|
viewModel.enqueueLegacyPrivateMediaConsent(
|
||||||
|
for: secondPeer,
|
||||||
|
transferId: "transfer-kept",
|
||||||
|
messageID: "message-kept"
|
||||||
|
) { decisions.append("second:\($0)") }
|
||||||
|
|
||||||
|
let cancelledRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id)
|
||||||
|
viewModel.invalidateLegacyPrivateMediaConsent(
|
||||||
|
transferId: "transfer-cancelled",
|
||||||
|
messageID: "message-cancelled"
|
||||||
|
)
|
||||||
|
let advanced = await TestHelpers.waitUntil(
|
||||||
|
{ viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
)
|
||||||
|
#expect(advanced)
|
||||||
|
#expect(decisions.isEmpty, "Invalidation drops the request rather than resolving its send")
|
||||||
|
|
||||||
|
viewModel.resolveLegacyPrivateMediaConsent(
|
||||||
|
requestID: cancelledRequestID,
|
||||||
|
approved: true
|
||||||
|
)
|
||||||
|
#expect(viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer)
|
||||||
|
#expect(decisions.isEmpty)
|
||||||
|
|
||||||
|
let keptRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id)
|
||||||
|
viewModel.resolveLegacyPrivateMediaConsent(requestID: keptRequestID, approved: true)
|
||||||
|
#expect(decisions == ["second:true"])
|
||||||
|
#expect(viewModel.legacyPrivateMediaConsentRequest == nil)
|
||||||
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func sendVoiceNote_oversizedFileFailsAndDeletesTempFile() async throws {
|
func sendVoiceNote_oversizedFileFailsAndDeletesTempFile() async throws {
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
|||||||
@@ -15,8 +15,13 @@ import BitFoundation
|
|||||||
|
|
||||||
/// Creates a ChatViewModel with mock dependencies for testing
|
/// Creates a ChatViewModel with mock dependencies for testing
|
||||||
@MainActor
|
@MainActor
|
||||||
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
|
private func makeTestableViewModel(
|
||||||
let keychain = MockKeychain()
|
keychain injectedKeychain: MockKeychain? = nil,
|
||||||
|
panicMediaWipe: (() throws -> Void)? = nil,
|
||||||
|
panicRecoveryOperations: PanicRecoveryOperations? = nil,
|
||||||
|
panicNetworkLifecycle: PanicNetworkLifecycle = .noop
|
||||||
|
) -> (viewModel: ChatViewModel, transport: MockTransport) {
|
||||||
|
let keychain = injectedKeychain ?? MockKeychain()
|
||||||
let keychainHelper = MockKeychainHelper()
|
let keychainHelper = MockKeychainHelper()
|
||||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
||||||
let identityManager = MockIdentityManager(keychain)
|
let identityManager = MockIdentityManager(keychain)
|
||||||
@@ -26,7 +31,10 @@ private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: Mo
|
|||||||
keychain: keychain,
|
keychain: keychain,
|
||||||
idBridge: idBridge,
|
idBridge: idBridge,
|
||||||
identityManager: identityManager,
|
identityManager: identityManager,
|
||||||
transport: transport
|
transport: transport,
|
||||||
|
panicMediaWipe: panicMediaWipe,
|
||||||
|
panicRecoveryOperations: panicRecoveryOperations,
|
||||||
|
panicNetworkLifecycle: panicNetworkLifecycle
|
||||||
)
|
)
|
||||||
|
|
||||||
return (viewModel, transport)
|
return (viewModel, transport)
|
||||||
@@ -646,6 +654,25 @@ struct ChatViewModelFormattingTests {
|
|||||||
#expect(String(formatted.characters) == "<@Alice#a1b2> hello #mesh [\(message.formattedTimestamp)]")
|
#expect(String(formatted.characters) == "<@Alice#a1b2> hello #mesh [\(message.formattedTimestamp)]")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func formatMessageAsText_longCashuFallsBackToPlain() async {
|
||||||
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
|
let cashu = "cashuA" + String(repeating: "a", count: 40)
|
||||||
|
let longContent = "hi @bob " + cashu + " " + String(repeating: "x", count: 4_100)
|
||||||
|
let message = BitchatMessage(
|
||||||
|
id: "fmt-long-cashu",
|
||||||
|
sender: "Alice#a1b2",
|
||||||
|
content: longContent,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_700_010_123),
|
||||||
|
isRelay: false,
|
||||||
|
senderPeerID: PeerID(str: "00000000000000b3")
|
||||||
|
)
|
||||||
|
|
||||||
|
let formatted = viewModel.formatMessageAsText(message, colorScheme: .light)
|
||||||
|
|
||||||
|
#expect(String(formatted.characters) == "<@Alice#a1b2> \(longContent) [\(message.formattedTimestamp)]")
|
||||||
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func formatMessageHeader_formatsSenderHeader() async {
|
func formatMessageHeader_formatsSenderHeader() async {
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
@@ -1097,6 +1124,159 @@ struct ChatViewModelBluetoothTests {
|
|||||||
|
|
||||||
struct ChatViewModelPanicTests {
|
struct ChatViewModelPanicTests {
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func panicClearAllData_finishesMediaWipeBeforeReturning() {
|
||||||
|
var wipeFinished = false
|
||||||
|
let (viewModel, _) = makeTestableViewModel(panicMediaWipe: {
|
||||||
|
wipeFinished = true
|
||||||
|
})
|
||||||
|
|
||||||
|
viewModel.panicClearAllData()
|
||||||
|
|
||||||
|
#expect(wipeFinished)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func panicClearAllData_stopsNetworkBeforeWipeAndRestartsAfterCommit() {
|
||||||
|
var events: [String] = []
|
||||||
|
let lifecycle = PanicNetworkLifecycle(
|
||||||
|
stop: { events.append("stop") },
|
||||||
|
restart: { events.append("restart") }
|
||||||
|
)
|
||||||
|
let (viewModel, _) = makeTestableViewModel(
|
||||||
|
panicMediaWipe: { events.append("wipe") },
|
||||||
|
panicNetworkLifecycle: lifecycle
|
||||||
|
)
|
||||||
|
|
||||||
|
let completed = viewModel.panicClearAllData()
|
||||||
|
|
||||||
|
#expect(completed)
|
||||||
|
#expect(events == ["stop", "wipe", "restart"])
|
||||||
|
#expect(viewModel.networkActivationAllowed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func panicKeychainFailureKeepsRecoveryPendingAndServicesStopped() {
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
keychain.simulatedDeleteAllResult = false
|
||||||
|
var events: [String] = []
|
||||||
|
let operations = PanicRecoveryOperations(
|
||||||
|
isPending: { false },
|
||||||
|
begin: {
|
||||||
|
events.append("begin")
|
||||||
|
return PanicRecoveryIntent(
|
||||||
|
fileMarkerEstablished: true,
|
||||||
|
externalMarkerEstablished: false
|
||||||
|
)
|
||||||
|
},
|
||||||
|
wipeMedia: { _ in events.append("wipe") },
|
||||||
|
complete: { events.append("complete") }
|
||||||
|
)
|
||||||
|
let lifecycle = PanicNetworkLifecycle(
|
||||||
|
stop: { events.append("stop") },
|
||||||
|
restart: { events.append("restart") }
|
||||||
|
)
|
||||||
|
let (viewModel, transport) = makeTestableViewModel(
|
||||||
|
keychain: keychain,
|
||||||
|
panicRecoveryOperations: operations,
|
||||||
|
panicNetworkLifecycle: lifecycle
|
||||||
|
)
|
||||||
|
let startsBeforePanic = transport.startServicesCallCount
|
||||||
|
|
||||||
|
let completed = viewModel.panicClearAllData()
|
||||||
|
|
||||||
|
#expect(!completed)
|
||||||
|
#expect(events == ["stop", "begin", "wipe"])
|
||||||
|
#expect(keychain.deleteAllCallCount == 1)
|
||||||
|
#expect(transport.startServicesCallCount == startsBeforePanic)
|
||||||
|
#expect(!viewModel.networkActivationAllowed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func pendingPanicRecoveryCompletesBeforeTransportBootstrap() {
|
||||||
|
var events: [String] = []
|
||||||
|
let operations = PanicRecoveryOperations(
|
||||||
|
isPending: {
|
||||||
|
events.append("read")
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
begin: {
|
||||||
|
events.append("begin")
|
||||||
|
return PanicRecoveryIntent(
|
||||||
|
fileMarkerEstablished: true,
|
||||||
|
externalMarkerEstablished: false
|
||||||
|
)
|
||||||
|
},
|
||||||
|
wipeMedia: { _ in events.append("wipe") },
|
||||||
|
complete: { events.append("complete") }
|
||||||
|
)
|
||||||
|
|
||||||
|
let (viewModel, transport) = makeTestableViewModel(
|
||||||
|
panicRecoveryOperations: operations
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(events == ["read", "begin", "wipe", "complete"])
|
||||||
|
#expect(transport.emergencyDisconnectCallCount == 1)
|
||||||
|
#expect(transport.startServicesCallCount == 1)
|
||||||
|
#expect(viewModel.networkActivationAllowed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func failedStartupRecoveryLeavesTransportAndNetworkBlocked() {
|
||||||
|
enum WipeFailure: Error { case failed }
|
||||||
|
var completedMarker = false
|
||||||
|
let operations = PanicRecoveryOperations(
|
||||||
|
isPending: { true },
|
||||||
|
begin: {
|
||||||
|
PanicRecoveryIntent(
|
||||||
|
fileMarkerEstablished: true,
|
||||||
|
externalMarkerEstablished: false
|
||||||
|
)
|
||||||
|
},
|
||||||
|
wipeMedia: { _ in throw WipeFailure.failed },
|
||||||
|
complete: { completedMarker = true }
|
||||||
|
)
|
||||||
|
|
||||||
|
let (viewModel, transport) = makeTestableViewModel(
|
||||||
|
panicRecoveryOperations: operations
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(!completedMarker)
|
||||||
|
#expect(transport.emergencyDisconnectCallCount == 1)
|
||||||
|
#expect(transport.startServicesCallCount == 0)
|
||||||
|
#expect(!viewModel.networkActivationAllowed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func failedStartupKeychainRecoveryLeavesIntentAndTransportBlocked() {
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
keychain.simulatedDeleteAllResult = false
|
||||||
|
var events: [String] = []
|
||||||
|
let operations = PanicRecoveryOperations(
|
||||||
|
isPending: { true },
|
||||||
|
begin: {
|
||||||
|
events.append("begin")
|
||||||
|
return PanicRecoveryIntent(
|
||||||
|
fileMarkerEstablished: true,
|
||||||
|
externalMarkerEstablished: true
|
||||||
|
)
|
||||||
|
},
|
||||||
|
wipeMedia: { _ in events.append("wipe") },
|
||||||
|
complete: { events.append("complete") }
|
||||||
|
)
|
||||||
|
|
||||||
|
let (viewModel, transport) = makeTestableViewModel(
|
||||||
|
keychain: keychain,
|
||||||
|
panicRecoveryOperations: operations
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(events == ["begin", "wipe"])
|
||||||
|
#expect(keychain.deleteAllCallCount == 1)
|
||||||
|
#expect(transport.emergencyDisconnectCallCount == 1)
|
||||||
|
#expect(transport.startServicesCallCount == 0)
|
||||||
|
#expect(!viewModel.networkActivationAllowed)
|
||||||
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func panicClearAllData_delegatesToTransport() async {
|
func panicClearAllData_delegatesToTransport() async {
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -323,6 +323,32 @@ struct MessageFormattingEngineTests {
|
|||||||
// Exactly at threshold DOES trigger (uses >= comparison)
|
// Exactly at threshold DOES trigger (uses >= comparison)
|
||||||
#expect(content.hasVeryLongToken(threshold: 50))
|
#expect(content.hasVeryLongToken(threshold: 50))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func isLongForDisplay_doesNotIgnoreCashuLinks() {
|
||||||
|
let cashu = "cashuA" + String(repeating: "a", count: 40)
|
||||||
|
let content = String(repeating: "a", count: TransportConfig.uiLongMessageLengthThreshold + 1) + " " + cashu
|
||||||
|
|
||||||
|
#expect(content.extractCashuLinks().count == 1)
|
||||||
|
#expect(content.isLongForDisplay())
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func formatMessage_longCashuMessageFallsBackToPlainContentPath() {
|
||||||
|
let context = MockMessageFormattingContext(nickname: "carol")
|
||||||
|
let cashu = "cashuA" + String(repeating: "a", count: 40)
|
||||||
|
let longContent = "hi @bob " + cashu + " " + String(repeating: "x", count: 4_100)
|
||||||
|
let message = BitchatMessage(
|
||||||
|
id: "long-cashu",
|
||||||
|
sender: "alice",
|
||||||
|
content: longContent,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_700_000_999),
|
||||||
|
isRelay: false
|
||||||
|
)
|
||||||
|
|
||||||
|
let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light)
|
||||||
|
|
||||||
|
#expect(String(formatted.characters) == "<@alice> \(longContent) [\(message.formattedTimestamp)]")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -116,4 +116,87 @@ struct MessageRateLimiterTests {
|
|||||||
#expect(plain)
|
#expect(plain)
|
||||||
#expect(!plainExhausted)
|
#expect(!plainExhausted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("Content buckets do not grow when sender is rate limited")
|
||||||
|
func contentBucketsDoNotGrowAfterSenderLimit() {
|
||||||
|
var limiter = MessageRateLimiter(
|
||||||
|
senderCapacity: 1,
|
||||||
|
senderRefillPerSec: 0,
|
||||||
|
contentCapacity: 1,
|
||||||
|
contentRefillPerSec: 0,
|
||||||
|
maxSenderBuckets: 10,
|
||||||
|
maxContentBuckets: 10,
|
||||||
|
bucketIdleTTL: 60
|
||||||
|
)
|
||||||
|
let now = Date()
|
||||||
|
|
||||||
|
let first = limiter.allow(senderKey: "sender", contentKey: "content-0", now: now)
|
||||||
|
var rejected = true
|
||||||
|
for index in 1...100 {
|
||||||
|
if limiter.allow(senderKey: "sender", contentKey: "content-\(index)", now: now) {
|
||||||
|
rejected = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(first)
|
||||||
|
#expect(rejected)
|
||||||
|
#expect(limiter.bucketCountsForTesting.sender == 1)
|
||||||
|
#expect(limiter.bucketCountsForTesting.content == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Bucket maps evict entries at configured caps")
|
||||||
|
func bucketMapsEvictAtConfiguredCaps() {
|
||||||
|
let maxEntries = 3
|
||||||
|
var limiter = MessageRateLimiter(
|
||||||
|
senderCapacity: 1,
|
||||||
|
senderRefillPerSec: 0,
|
||||||
|
contentCapacity: 1,
|
||||||
|
contentRefillPerSec: 0,
|
||||||
|
maxSenderBuckets: maxEntries,
|
||||||
|
maxContentBuckets: maxEntries,
|
||||||
|
bucketIdleTTL: 60
|
||||||
|
)
|
||||||
|
let now = Date()
|
||||||
|
|
||||||
|
for index in 0..<25 {
|
||||||
|
_ = limiter.allow(
|
||||||
|
senderKey: "sender-\(index)",
|
||||||
|
contentKey: "content-\(index)",
|
||||||
|
now: now.addingTimeInterval(TimeInterval(index))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(limiter.bucketCountsForTesting.sender == maxEntries)
|
||||||
|
#expect(limiter.bucketCountsForTesting.content == maxEntries)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("PoW bypass still creates content buckets under the cap")
|
||||||
|
func powBypassCreatesBoundedContentBuckets() {
|
||||||
|
let maxEntries = 3
|
||||||
|
var limiter = MessageRateLimiter(
|
||||||
|
senderCapacity: 1,
|
||||||
|
senderRefillPerSec: 0,
|
||||||
|
contentCapacity: 100,
|
||||||
|
contentRefillPerSec: 0,
|
||||||
|
maxSenderBuckets: maxEntries,
|
||||||
|
maxContentBuckets: maxEntries,
|
||||||
|
bucketIdleTTL: 60
|
||||||
|
)
|
||||||
|
let now = Date()
|
||||||
|
|
||||||
|
var allAllowed = true
|
||||||
|
for index in 0..<10 {
|
||||||
|
let allowed = limiter.allow(
|
||||||
|
senderKey: "sender",
|
||||||
|
contentKey: "content-\(index)",
|
||||||
|
powBits: NostrPoW.rateLimitBypassBits,
|
||||||
|
now: now.addingTimeInterval(TimeInterval(index))
|
||||||
|
)
|
||||||
|
if !allowed { allAllowed = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(allAllowed)
|
||||||
|
#expect(limiter.bucketCountsForTesting.sender == 0)
|
||||||
|
#expect(limiter.bucketCountsForTesting.content == maxEntries)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
|||||||
private var blockedFingerprints: Set<String> = []
|
private var blockedFingerprints: Set<String> = []
|
||||||
private var blockedNostrPubkeys: Set<String> = []
|
private var blockedNostrPubkeys: Set<String> = []
|
||||||
private var socialIdentities: [String: SocialIdentity] = [:]
|
private var socialIdentities: [String: SocialIdentity] = [:]
|
||||||
|
private var privateMediaCapableFingerprints: Set<String> = []
|
||||||
|
private var authenticatedSigningKeys: [String: Data] = [:]
|
||||||
|
|
||||||
init(_: KeychainManagerProtocol) {}
|
init(_: KeychainManagerProtocol) {}
|
||||||
|
|
||||||
@@ -87,7 +89,10 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
|||||||
|
|
||||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState) {}
|
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState) {}
|
||||||
|
|
||||||
func clearAllIdentityData() {}
|
func clearAllIdentityData() {
|
||||||
|
privateMediaCapableFingerprints.removeAll()
|
||||||
|
authenticatedSigningKeys.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
func removeEphemeralSession(peerID: PeerID) {}
|
func removeEphemeralSession(peerID: PeerID) {}
|
||||||
|
|
||||||
@@ -101,6 +106,22 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
|||||||
Set()
|
Set()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func markPrivateMediaCapable(fingerprint: String) {
|
||||||
|
privateMediaCapableFingerprints.insert(fingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool {
|
||||||
|
privateMediaCapableFingerprints.contains(fingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String) {
|
||||||
|
authenticatedSigningKeys[fingerprint] = signingPublicKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data? {
|
||||||
|
authenticatedSigningKeys[fingerprint]
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Vouching (transitive verification)
|
// MARK: Vouching (transitive verification)
|
||||||
|
|
||||||
private var vouchesByVouchee: [String: [VouchRecord]] = [:]
|
private var vouchesByVouchee: [String: [VouchRecord]] = [:]
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ final class MockKeychain: KeychainManagerProtocol {
|
|||||||
var simulatedReadError: KeychainReadResult?
|
var simulatedReadError: KeychainReadResult?
|
||||||
var simulatedSaveError: KeychainSaveResult?
|
var simulatedSaveError: KeychainSaveResult?
|
||||||
var simulatedGenericReadError: KeychainReadResult?
|
var simulatedGenericReadError: KeychainReadResult?
|
||||||
|
var simulatedDeleteAllResult = true
|
||||||
|
private(set) var deleteAllCallCount = 0
|
||||||
|
|
||||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
||||||
storage[key] = keyData
|
storage[key] = keyData
|
||||||
@@ -34,6 +36,8 @@ final class MockKeychain: KeychainManagerProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func deleteAllKeychainData() -> Bool {
|
func deleteAllKeychainData() -> Bool {
|
||||||
|
deleteAllCallCount += 1
|
||||||
|
guard simulatedDeleteAllResult else { return false }
|
||||||
storage.removeAll()
|
storage.removeAll()
|
||||||
serviceStorage.removeAll()
|
serviceStorage.removeAll()
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ final class MockTransport: Transport {
|
|||||||
private(set) var sentFavoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = []
|
private(set) var sentFavoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = []
|
||||||
private(set) var sentBroadcastFiles: [(packet: BitchatFilePacket, transferID: String)] = []
|
private(set) var sentBroadcastFiles: [(packet: BitchatFilePacket, transferID: String)] = []
|
||||||
private(set) var sentPrivateFiles: [(packet: BitchatFilePacket, peerID: PeerID, transferID: String)] = []
|
private(set) var sentPrivateFiles: [(packet: BitchatFilePacket, peerID: PeerID, transferID: String)] = []
|
||||||
|
private(set) var sentPrivateFileLegacyAllowances: [Bool] = []
|
||||||
private(set) var cancelledTransfers: [String] = []
|
private(set) var cancelledTransfers: [String] = []
|
||||||
private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||||
private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||||
@@ -58,6 +59,7 @@ final class MockTransport: Transport {
|
|||||||
var peerNicknames: [PeerID: String] = [:]
|
var peerNicknames: [PeerID: String] = [:]
|
||||||
var peerFingerprints: [PeerID: String] = [:]
|
var peerFingerprints: [PeerID: String] = [:]
|
||||||
var peerNoiseStates: [PeerID: LazyHandshakeState] = [:]
|
var peerNoiseStates: [PeerID: LazyHandshakeState] = [:]
|
||||||
|
var privateMediaPolicies: [PeerID: PrivateMediaSendPolicy] = [:]
|
||||||
private let mockKeychain = MockKeychain()
|
private let mockKeychain = MockKeychain()
|
||||||
|
|
||||||
// MARK: - Transport Protocol Implementation
|
// MARK: - Transport Protocol Implementation
|
||||||
@@ -186,6 +188,29 @@ final class MockTransport: Transport {
|
|||||||
|
|
||||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
||||||
sentPrivateFiles.append((packet, peerID, transferId))
|
sentPrivateFiles.append((packet, peerID, transferId))
|
||||||
|
sentPrivateFileLegacyAllowances.append(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendFilePrivate(
|
||||||
|
_ packet: BitchatFilePacket,
|
||||||
|
to peerID: PeerID,
|
||||||
|
transferId: String,
|
||||||
|
allowLegacyFallback: Bool
|
||||||
|
) {
|
||||||
|
sentPrivateFiles.append((packet, peerID, transferId))
|
||||||
|
sentPrivateFileLegacyAllowances.append(allowLegacyFallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
|
||||||
|
privateMediaPolicies[peerID] ?? .encrypted
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolvePrivateMediaSendPolicy(
|
||||||
|
to peerID: PeerID,
|
||||||
|
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||||
|
) {
|
||||||
|
let policy = privateMediaPolicies[peerID] ?? .encrypted
|
||||||
|
Task { @MainActor in completion(policy) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func cancelTransfer(_ transferId: String) {
|
func cancelTransfer(_ transferId: String) {
|
||||||
|
|||||||
@@ -12,8 +12,15 @@ struct NoiseCoverageTests {
|
|||||||
private let bobStaticKey = Curve25519.KeyAgreement.PrivateKey()
|
private let bobStaticKey = Curve25519.KeyAgreement.PrivateKey()
|
||||||
private let charlieStaticKey = Curve25519.KeyAgreement.PrivateKey()
|
private let charlieStaticKey = Curve25519.KeyAgreement.PrivateKey()
|
||||||
|
|
||||||
private let alicePeerID = PeerID(str: "0011223344556677")
|
// Manager test dictionaries are keyed by the remote peer. Keep the
|
||||||
private let bobPeerID = PeerID(str: "8899aabbccddeeff")
|
// historical names, but derive each wire ID from the static key that the
|
||||||
|
// corresponding manager authenticates during the handshake.
|
||||||
|
private var alicePeerID: PeerID {
|
||||||
|
PeerID(publicKey: bobStaticKey.publicKey.rawRepresentation)
|
||||||
|
}
|
||||||
|
private var bobPeerID: PeerID {
|
||||||
|
PeerID(publicKey: aliceStaticKey.publicKey.rawRepresentation)
|
||||||
|
}
|
||||||
private let charliePeerID = PeerID(str: "fedcba9876543210")
|
private let charliePeerID = PeerID(str: "fedcba9876543210")
|
||||||
|
|
||||||
@Test("Protocol metadata and handshake patterns expose expected values")
|
@Test("Protocol metadata and handshake patterns expose expected values")
|
||||||
@@ -535,8 +542,12 @@ struct NoiseCoverageTests {
|
|||||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain)
|
let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain)
|
||||||
let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
|
let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
|
||||||
|
|
||||||
aliceManager.onSessionEstablished = establishedRecorder.recordEstablished(peerID:remoteKey:)
|
aliceManager.onSessionEstablished = establishedRecorder.recordEstablished(
|
||||||
bobManager.onSessionEstablished = establishedRecorder.recordEstablished(peerID:remoteKey:)
|
peerID:remoteKey:sessionGeneration:
|
||||||
|
)
|
||||||
|
bobManager.onSessionEstablished = establishedRecorder.recordEstablished(
|
||||||
|
peerID:remoteKey:sessionGeneration:
|
||||||
|
)
|
||||||
|
|
||||||
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||||
|
|
||||||
@@ -643,13 +654,121 @@ struct NoiseCoverageTests {
|
|||||||
try aliceManager.initiateHandshake(with: alicePeerID)
|
try aliceManager.initiateHandshake(with: alicePeerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
try aliceManager.initiateRekey(for: alicePeerID)
|
let rekeyHandshake = try aliceManager.initiateRekey(for: alicePeerID)
|
||||||
|
#expect(!rekeyHandshake.isEmpty)
|
||||||
let rekeyedSession = try #require(aliceManager.getSession(for: alicePeerID))
|
let rekeyedSession = try #require(aliceManager.getSession(for: alicePeerID))
|
||||||
|
|
||||||
#expect(rekeyedSession !== establishedSession)
|
#expect(rekeyedSession !== establishedSession)
|
||||||
#expect(rekeyedSession.getState() == .handshaking)
|
#expect(rekeyedSession.getState() == .handshaking)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("A stale decrypt generation cannot commit across session promotion")
|
||||||
|
func staleDecryptGenerationCannotCommitAcrossPromotion() throws {
|
||||||
|
let aliceManager = NoiseSessionManager(
|
||||||
|
localStaticKey: aliceStaticKey,
|
||||||
|
keychain: keychain,
|
||||||
|
sessionFactory: { peerID, role in
|
||||||
|
BlockingDecryptNoiseSession(
|
||||||
|
peerID: peerID,
|
||||||
|
role: role,
|
||||||
|
keychain: self.keychain,
|
||||||
|
localStaticKey: self.aliceStaticKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
|
||||||
|
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||||
|
|
||||||
|
let oldSession = try #require(
|
||||||
|
aliceManager.getSession(for: alicePeerID) as? BlockingDecryptNoiseSession
|
||||||
|
)
|
||||||
|
let oldGeneration = try #require(aliceManager.sessionGeneration(for: alicePeerID))
|
||||||
|
|
||||||
|
// Prepare a fully authenticated responder candidate without promoting
|
||||||
|
// it yet. Its final XX message is the exact operation that replaces
|
||||||
|
// the old `sessions[peerID]` entry.
|
||||||
|
let replacementInitiator = NoiseSession(
|
||||||
|
peerID: bobPeerID,
|
||||||
|
role: .initiator,
|
||||||
|
keychain: keychain,
|
||||||
|
localStaticKey: bobStaticKey
|
||||||
|
)
|
||||||
|
let message1 = try replacementInitiator.startHandshake()
|
||||||
|
let message2 = try #require(
|
||||||
|
try aliceManager.handleIncomingHandshake(from: alicePeerID, message: message1)
|
||||||
|
)
|
||||||
|
let message3 = try #require(try replacementInitiator.processHandshakeMessage(message2))
|
||||||
|
|
||||||
|
let ciphertext = try bobManager.encrypt(Data("old session".utf8), for: bobPeerID)
|
||||||
|
oldSession.pauseNextDecrypt()
|
||||||
|
|
||||||
|
let decryptResult = ConcurrentTestResult<(plaintext: Data, sessionGeneration: UUID)>()
|
||||||
|
var promotionResultForCleanup: ConcurrentTestResult<Data?>?
|
||||||
|
defer {
|
||||||
|
// A failed startup requirement must not strand a late thread in
|
||||||
|
// the blocking test double after the test has returned.
|
||||||
|
oldSession.resumeDecrypt()
|
||||||
|
_ = decryptResult.wait(timeout: 5)
|
||||||
|
if let promotionResultForCleanup {
|
||||||
|
_ = promotionResultForCleanup.wait(timeout: 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let decryptThread = Thread {
|
||||||
|
decryptResult.capture {
|
||||||
|
try aliceManager.decryptWithSessionGeneration(ciphertext, from: self.alicePeerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
decryptThread.name = "NoiseCoverageTests.staleDecrypt.decrypt"
|
||||||
|
decryptThread.qualityOfService = .userInitiated
|
||||||
|
decryptThread.start()
|
||||||
|
try #require(oldSession.waitForDecryptStart(timeout: 5))
|
||||||
|
|
||||||
|
let promotionStarted = DispatchSemaphore(value: 0)
|
||||||
|
let promotionResult = ConcurrentTestResult<Data?>()
|
||||||
|
promotionResultForCleanup = promotionResult
|
||||||
|
let promotionThread = Thread {
|
||||||
|
promotionStarted.signal()
|
||||||
|
promotionResult.capture {
|
||||||
|
try aliceManager.handleIncomingHandshake(from: self.alicePeerID, message: message3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
promotionThread.name = "NoiseCoverageTests.staleDecrypt.promote"
|
||||||
|
promotionThread.qualityOfService = .userInitiated
|
||||||
|
promotionThread.start()
|
||||||
|
try #require(promotionStarted.wait(timeout: .now() + 5) == .success)
|
||||||
|
#expect(
|
||||||
|
promotionResult.wait(timeout: 0.05) == nil,
|
||||||
|
"Promotion must wait for the exact decrypting-session lease"
|
||||||
|
)
|
||||||
|
|
||||||
|
oldSession.resumeDecrypt()
|
||||||
|
let decrypted = try #require(decryptResult.wait(timeout: 5)).get()
|
||||||
|
_ = try #require(promotionResult.wait(timeout: 5)).get()
|
||||||
|
|
||||||
|
#expect(decrypted.plaintext == Data("old session".utf8))
|
||||||
|
#expect(decrypted.sessionGeneration == oldGeneration)
|
||||||
|
#expect(aliceManager.sessionGeneration(for: alicePeerID) != oldGeneration)
|
||||||
|
#expect(throws: NoiseEncryptionError.sessionNotEstablished) {
|
||||||
|
try aliceManager.encrypt(
|
||||||
|
Data("stale send".utf8),
|
||||||
|
for: alicePeerID,
|
||||||
|
expectedSessionGeneration: oldGeneration
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var staleCommitRan = false
|
||||||
|
let staleCommit = aliceManager.withCurrentSessionGeneration(
|
||||||
|
for: alicePeerID,
|
||||||
|
expected: decrypted.sessionGeneration
|
||||||
|
) {
|
||||||
|
staleCommitRan = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
#expect(staleCommit == nil)
|
||||||
|
#expect(!staleCommitRan)
|
||||||
|
}
|
||||||
|
|
||||||
@Test("Secure noise sessions enforce limits and renegotiation thresholds")
|
@Test("Secure noise sessions enforce limits and renegotiation thresholds")
|
||||||
func secureNoiseSessionsEnforceLimitsAndThresholds() throws {
|
func secureNoiseSessionsEnforceLimitsAndThresholds() throws {
|
||||||
let initiator = SecureNoiseSession(
|
let initiator = SecureNoiseSession(
|
||||||
@@ -844,7 +963,11 @@ private final class SessionCallbackRecorder: @unchecked Sendable {
|
|||||||
return establishedEntries.map(\.0)
|
return establishedEntries.map(\.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func recordEstablished(peerID: PeerID, remoteKey: Curve25519.KeyAgreement.PublicKey) {
|
func recordEstablished(
|
||||||
|
peerID: PeerID,
|
||||||
|
remoteKey: Curve25519.KeyAgreement.PublicKey,
|
||||||
|
sessionGeneration _: UUID
|
||||||
|
) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
establishedEntries.append((peerID, remoteKey.rawRepresentation))
|
establishedEntries.append((peerID, remoteKey.rawRepresentation))
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
@@ -866,3 +989,62 @@ private final class FailingNoiseSession: NoiseSession {
|
|||||||
throw Error.synthetic
|
throw Error.synthetic
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final class BlockingDecryptNoiseSession: NoiseSession, @unchecked Sendable {
|
||||||
|
private let controlLock = NSLock()
|
||||||
|
private var shouldPauseNextDecrypt = false
|
||||||
|
private let decryptStarted = DispatchSemaphore(value: 0)
|
||||||
|
private let resumeDecryptSemaphore = DispatchSemaphore(value: 0)
|
||||||
|
|
||||||
|
func pauseNextDecrypt() {
|
||||||
|
controlLock.lock()
|
||||||
|
shouldPauseNextDecrypt = true
|
||||||
|
controlLock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForDecryptStart(timeout: TimeInterval) -> Bool {
|
||||||
|
decryptStarted.wait(timeout: .now() + timeout) == .success
|
||||||
|
}
|
||||||
|
|
||||||
|
func resumeDecrypt() {
|
||||||
|
resumeDecryptSemaphore.signal()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func decrypt(_ ciphertext: Data) throws -> Data {
|
||||||
|
controlLock.lock()
|
||||||
|
let shouldPause = shouldPauseNextDecrypt
|
||||||
|
shouldPauseNextDecrypt = false
|
||||||
|
controlLock.unlock()
|
||||||
|
|
||||||
|
if shouldPause {
|
||||||
|
decryptStarted.signal()
|
||||||
|
resumeDecryptSemaphore.wait()
|
||||||
|
}
|
||||||
|
return try super.decrypt(ciphertext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class ConcurrentTestResult<Value>: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private let completed = DispatchGroup()
|
||||||
|
private var storedResult: Result<Value, Error>?
|
||||||
|
|
||||||
|
init() {
|
||||||
|
completed.enter()
|
||||||
|
}
|
||||||
|
|
||||||
|
func capture(_ operation: () throws -> Value) {
|
||||||
|
let result = Result(catching: operation)
|
||||||
|
lock.lock()
|
||||||
|
storedResult = result
|
||||||
|
lock.unlock()
|
||||||
|
completed.leave()
|
||||||
|
}
|
||||||
|
|
||||||
|
func wait(timeout: TimeInterval) -> Result<Value, Error>? {
|
||||||
|
guard completed.wait(timeout: .now() + timeout) == .success else { return nil }
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return storedResult
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,25 +5,19 @@ import XCTest
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class GeoRelayDirectoryTests: XCTestCase {
|
final class GeoRelayDirectoryTests: XCTestCase {
|
||||||
private func parse(_ csv: String) -> [GeoRelayDirectory.Entry] {
|
func test_parseCSV_normalizesRelaySchemesAndDeduplicatesEntries() {
|
||||||
GeoRelayDirectory.validatedEntries(
|
|
||||||
from: Data(csv.utf8),
|
|
||||||
policy: .live,
|
|
||||||
minimumEntries: 1
|
|
||||||
) ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
func test_parseCSV_normalizesSecureRelaySchemesAndDeduplicatesEntries() {
|
|
||||||
let csv = """
|
let csv = """
|
||||||
relay url,lat,lon
|
relay url,lat,lon
|
||||||
wss://one.example/,10,20
|
wss://one.example/,10,20
|
||||||
https://one.example,10,20
|
https://one.example,10,20
|
||||||
wss://one.example:443/,10,20
|
wss://one.example:443/,10,20
|
||||||
two.example,11,21
|
http://two.example/,11,21
|
||||||
wss://two.example:443,11,21
|
wss://two.example:443,11,21
|
||||||
|
invalid row
|
||||||
|
ws://three.example,not-a-lat,22
|
||||||
"""
|
"""
|
||||||
|
|
||||||
let parsed = Set(parse(csv))
|
let parsed = Set(GeoRelayDirectory.parseCSV(csv))
|
||||||
|
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
parsed,
|
parsed,
|
||||||
@@ -34,136 +28,6 @@ final class GeoRelayDirectoryTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_parseCSV_rejectsWholeDatasetWhenAnyRowOrHeaderIsUnsafe() {
|
|
||||||
let invalidCSVs = [
|
|
||||||
"relay,lat,lon\nrelay.example,1,2\n",
|
|
||||||
"relay url,lat,lon\nrelay.example,1\n",
|
|
||||||
"relay url,lat,lon\nhttp://relay.example,1,2\n",
|
|
||||||
"relay url,lat,lon\nwss://user@relay.example,1,2\n",
|
|
||||||
"relay url,lat,lon\nwss://relay.example/path,1,2\n",
|
|
||||||
"relay url,lat,lon\nwss://relay.example?,1,2\n",
|
|
||||||
"relay url,lat,lon\nwss://relay.example#,1,2\n",
|
|
||||||
"relay url,lat,lon\nrelay.example:0,1,2\n",
|
|
||||||
"relay url,lat,lon\nrelay.example:99999,1,2\n",
|
|
||||||
"relay url,lat,lon\nlocalhost,1,2\n",
|
|
||||||
"relay url,lat,lon\nr\u{00e9}lay.example,1,2\n",
|
|
||||||
"relay url,lat,lon\nrelay\u{202e}.example,1,2\n",
|
|
||||||
"relay url,lat,lon\nrelay.example,NaN,2\n",
|
|
||||||
"relay url,lat,lon\nrelay.example,1_0,2\n",
|
|
||||||
"relay url,lat,lon\nrelay.example,\u{0661}\u{0660},2\n",
|
|
||||||
"relay url,lat,lon\nrelay.example,\u{ff11}\u{ff10},2\n",
|
|
||||||
"relay url,lat,lon\nrelay.example,91,2\n",
|
|
||||||
"relay url,lat,lon\nrelay.example,1,181\n",
|
|
||||||
"relay url,lat,lon\nrelay.example,1,2\nrelay.example,3,4\n"
|
|
||||||
]
|
|
||||||
|
|
||||||
for csv in invalidCSVs {
|
|
||||||
XCTAssertTrue(parse(csv).isEmpty, csv)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func test_validatedEntries_enforcesByteRowEntryAndRetentionLimits() {
|
|
||||||
let restrictive = GeoRelayDirectoryValidationPolicy(
|
|
||||||
maximumBytes: 100,
|
|
||||||
maximumRows: 2,
|
|
||||||
maximumEntries: 2,
|
|
||||||
minimumRemoteEntries: 1,
|
|
||||||
minimumRetainedFraction: 0.5
|
|
||||||
)
|
|
||||||
let one = Data("relay url,lat,lon\none.example,1,2\n".utf8)
|
|
||||||
let three = Data("relay url,lat,lon\none.example,1,2\ntwo.example,3,4\nthree.example,5,6\n".utf8)
|
|
||||||
|
|
||||||
XCTAssertNil(GeoRelayDirectory.validatedEntries(
|
|
||||||
from: one,
|
|
||||||
policy: restrictive,
|
|
||||||
minimumEntries: 2
|
|
||||||
))
|
|
||||||
XCTAssertNil(GeoRelayDirectory.validatedEntries(
|
|
||||||
from: Data(repeating: 0x41, count: 101),
|
|
||||||
policy: restrictive,
|
|
||||||
minimumEntries: 1
|
|
||||||
))
|
|
||||||
XCTAssertNil(GeoRelayDirectory.validatedEntries(
|
|
||||||
from: three,
|
|
||||||
policy: restrictive,
|
|
||||||
minimumEntries: 1
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func test_validatedEntries_requiresExactBaselineEntryOverlap() throws {
|
|
||||||
let policy = GeoRelayDirectoryValidationPolicy(
|
|
||||||
maximumBytes: 1_000,
|
|
||||||
maximumRows: 10,
|
|
||||||
maximumEntries: 10,
|
|
||||||
minimumRemoteEntries: 1,
|
|
||||||
minimumRetainedFraction: 0.5
|
|
||||||
)
|
|
||||||
let baseline = Set(try XCTUnwrap(GeoRelayDirectory.validatedEntries(
|
|
||||||
from: Data("""
|
|
||||||
relay url,lat,lon
|
|
||||||
one.example,1,1
|
|
||||||
two.example,2,2
|
|
||||||
three.example,3,3
|
|
||||||
""".utf8),
|
|
||||||
policy: policy,
|
|
||||||
minimumEntries: 1
|
|
||||||
)))
|
|
||||||
let disjoint = Data("""
|
|
||||||
relay url,lat,lon
|
|
||||||
four.example,1,1
|
|
||||||
five.example,2,2
|
|
||||||
six.example,3,3
|
|
||||||
""".utf8)
|
|
||||||
let rewrittenCoordinates = Data("""
|
|
||||||
relay url,lat,lon
|
|
||||||
one.example,11,11
|
|
||||||
two.example,12,12
|
|
||||||
three.example,13,13
|
|
||||||
""".utf8)
|
|
||||||
let halfRetained = Data("""
|
|
||||||
relay url,lat,lon
|
|
||||||
wss://one.example:443/,1,1
|
|
||||||
https://two.example/,2,2
|
|
||||||
replacement.example,4,4
|
|
||||||
""".utf8)
|
|
||||||
|
|
||||||
XCTAssertNil(GeoRelayDirectory.validatedEntries(
|
|
||||||
from: disjoint,
|
|
||||||
policy: policy,
|
|
||||||
minimumEntries: 1,
|
|
||||||
baselineEntries: baseline
|
|
||||||
))
|
|
||||||
XCTAssertNil(GeoRelayDirectory.validatedEntries(
|
|
||||||
from: rewrittenCoordinates,
|
|
||||||
policy: policy,
|
|
||||||
minimumEntries: 1,
|
|
||||||
baselineEntries: baseline
|
|
||||||
))
|
|
||||||
XCTAssertNotNil(GeoRelayDirectory.validatedEntries(
|
|
||||||
from: halfRetained,
|
|
||||||
policy: policy,
|
|
||||||
minimumEntries: 1,
|
|
||||||
baselineEntries: baseline
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func test_bundledReviewedCSV_passesStrictProductionValidation() throws {
|
|
||||||
let repositoryRoot = URL(fileURLWithPath: #filePath)
|
|
||||||
.deletingLastPathComponent()
|
|
||||||
.deletingLastPathComponent()
|
|
||||||
.deletingLastPathComponent()
|
|
||||||
let data = try Data(
|
|
||||||
contentsOf: repositoryRoot.appendingPathComponent("relays/online_relays_gps.csv")
|
|
||||||
)
|
|
||||||
|
|
||||||
let entries = try XCTUnwrap(GeoRelayDirectory.validatedEntries(
|
|
||||||
from: data,
|
|
||||||
policy: .live,
|
|
||||||
minimumEntries: GeoRelayDirectoryValidationPolicy.live.minimumRemoteEntries
|
|
||||||
))
|
|
||||||
XCTAssertGreaterThan(entries.count, 250)
|
|
||||||
}
|
|
||||||
|
|
||||||
func test_closestRelays_sortsByDistanceForLatLonAndGeohash() {
|
func test_closestRelays_sortsByDistanceForLatLonAndGeohash() {
|
||||||
let harness = makeHarness(
|
let harness = makeHarness(
|
||||||
cacheCSV: """
|
cacheCSV: """
|
||||||
@@ -379,53 +243,6 @@ final class GeoRelayDirectoryTests: XCTestCase {
|
|||||||
XCTAssertFalse(directory.debugHasRetryTask)
|
XCTAssertFalse(directory.debugHasRetryTask)
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_prefetchIfNeeded_rejectsSharpValidLookingTruncationBeforeCaching() async {
|
|
||||||
let cached = """
|
|
||||||
relay url,lat,lon
|
|
||||||
old-one.example,1,1
|
|
||||||
old-two.example,2,2
|
|
||||||
old-three.example,3,3
|
|
||||||
"""
|
|
||||||
let truncated = """
|
|
||||||
relay url,lat,lon
|
|
||||||
attacker.example,9,9
|
|
||||||
"""
|
|
||||||
let recovered = """
|
|
||||||
relay url,lat,lon
|
|
||||||
old-one.example,1,1
|
|
||||||
old-two.example,2,2
|
|
||||||
new-three.example,6,6
|
|
||||||
"""
|
|
||||||
let harness = makeHarness(
|
|
||||||
cacheCSV: cached,
|
|
||||||
fetchResults: [
|
|
||||||
.success(Data(truncated.utf8)),
|
|
||||||
.success(Data(recovered.utf8))
|
|
||||||
],
|
|
||||||
validationPolicy: GeoRelayDirectoryValidationPolicy(
|
|
||||||
maximumBytes: 64 * 1024,
|
|
||||||
maximumRows: 1_000,
|
|
||||||
maximumEntries: 1_000,
|
|
||||||
minimumRemoteEntries: 1,
|
|
||||||
minimumRetainedFraction: 0.5
|
|
||||||
)
|
|
||||||
)
|
|
||||||
let directory = GeoRelayDirectory(dependencies: harness.dependencies)
|
|
||||||
|
|
||||||
directory.prefetchIfNeeded()
|
|
||||||
|
|
||||||
let refreshed = await waitUntil {
|
|
||||||
directory.entries.contains(where: { $0.host == "new-three.example" })
|
|
||||||
}
|
|
||||||
XCTAssertTrue(refreshed)
|
|
||||||
XCTAssertFalse(directory.entries.contains(where: { $0.host == "attacker.example" }))
|
|
||||||
let requestCount = await harness.fetcher.recordedRequestCount()
|
|
||||||
let retryDelays = await harness.retryRecorder.recordedDelays()
|
|
||||||
XCTAssertEqual(requestCount, 2)
|
|
||||||
XCTAssertEqual(retryDelays, [5])
|
|
||||||
XCTAssertEqual(harness.fileStore.dataByURL[harness.cacheURL], Data(recovered.utf8))
|
|
||||||
}
|
|
||||||
|
|
||||||
func test_observers_triggerPrefetchesForTorReadyAndAppActivation() async {
|
func test_observers_triggerPrefetchesForTorReadyAndAppActivation() async {
|
||||||
let activeNotification = Notification.Name("GeoRelayDirectoryTests.didBecomeActive")
|
let activeNotification = Notification.Name("GeoRelayDirectoryTests.didBecomeActive")
|
||||||
let harness = makeHarness(
|
let harness = makeHarness(
|
||||||
@@ -472,14 +289,7 @@ final class GeoRelayDirectoryTests: XCTestCase {
|
|||||||
fetchFactoryObserver: (@MainActor @Sendable () -> Void)? = nil,
|
fetchFactoryObserver: (@MainActor @Sendable () -> Void)? = nil,
|
||||||
fetchObserver: (@Sendable () async -> Void)? = nil,
|
fetchObserver: (@Sendable () async -> Void)? = nil,
|
||||||
autoStart: Bool = false,
|
autoStart: Bool = false,
|
||||||
activeNotificationName: Notification.Name? = nil,
|
activeNotificationName: Notification.Name? = nil
|
||||||
validationPolicy: GeoRelayDirectoryValidationPolicy = GeoRelayDirectoryValidationPolicy(
|
|
||||||
maximumBytes: 64 * 1024,
|
|
||||||
maximumRows: 1_000,
|
|
||||||
maximumEntries: 1_000,
|
|
||||||
minimumRemoteEntries: 1,
|
|
||||||
minimumRetainedFraction: 0
|
|
||||||
)
|
|
||||||
) -> GeoRelayHarness {
|
) -> GeoRelayHarness {
|
||||||
let userDefaultsSuite = "GeoRelayDirectoryTests.\(UUID().uuidString)"
|
let userDefaultsSuite = "GeoRelayDirectoryTests.\(UUID().uuidString)"
|
||||||
let userDefaults = UserDefaults(suiteName: userDefaultsSuite)!
|
let userDefaults = UserDefaults(suiteName: userDefaultsSuite)!
|
||||||
@@ -537,8 +347,7 @@ final class GeoRelayDirectoryTests: XCTestCase {
|
|||||||
await retryRecorder.record(delay)
|
await retryRecorder.record(delay)
|
||||||
},
|
},
|
||||||
activeNotificationName: activeNotificationName,
|
activeNotificationName: activeNotificationName,
|
||||||
autoStart: autoStart,
|
autoStart: autoStart
|
||||||
validationPolicy: validationPolicy
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return GeoRelayHarness(
|
return GeoRelayHarness(
|
||||||
|
|||||||
@@ -290,7 +290,65 @@ struct NostrProtocolTests {
|
|||||||
#expect(object["limit"] as? Int == 42)
|
#expect(object["limit"] as? Int == 42)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Test func inboundNostrEventRejectsTooManyTags() throws {
|
||||||
|
var eventDict = Self.validInboundEventDict()
|
||||||
|
eventDict["tags"] = Array(
|
||||||
|
repeating: ["g", "u4pruyd"],
|
||||||
|
count: TransportConfig.nostrMaxEventTags + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(throws: NostrError.invalidEvent) {
|
||||||
|
_ = try NostrEvent(from: eventDict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func inboundNostrEventRejectsTooManyTagValues() throws {
|
||||||
|
var eventDict = Self.validInboundEventDict()
|
||||||
|
eventDict["tags"] = [Array(
|
||||||
|
repeating: "value",
|
||||||
|
count: TransportConfig.nostrMaxEventTagValues + 1
|
||||||
|
)]
|
||||||
|
|
||||||
|
#expect(throws: NostrError.invalidEvent) {
|
||||||
|
_ = try NostrEvent(from: eventDict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func inboundNostrEventRejectsOversizedTagValues() throws {
|
||||||
|
var eventDict = Self.validInboundEventDict()
|
||||||
|
eventDict["tags"] = [[
|
||||||
|
"g",
|
||||||
|
String(repeating: "a", count: TransportConfig.nostrMaxEventTagValueBytes + 1)
|
||||||
|
]]
|
||||||
|
|
||||||
|
#expect(throws: NostrError.invalidEvent) {
|
||||||
|
_ = try NostrEvent(from: eventDict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func inboundNostrEventAcceptsTagsWithinLimits() throws {
|
||||||
|
var eventDict = Self.validInboundEventDict()
|
||||||
|
eventDict["tags"] = [["g", "u4pruyd"], ["t", "teleport"]]
|
||||||
|
|
||||||
|
let event = try NostrEvent(from: eventDict)
|
||||||
|
|
||||||
|
#expect(event.tags.count == 2)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
private static func validInboundEventDict() -> [String: Any] {
|
||||||
|
[
|
||||||
|
"id": String(repeating: "0", count: 64),
|
||||||
|
"pubkey": String(repeating: "1", count: 64),
|
||||||
|
"created_at": 1_234_567,
|
||||||
|
"kind": NostrProtocol.EventKind.ephemeralEvent.rawValue,
|
||||||
|
"tags": [["g", "u4pruyd"]],
|
||||||
|
"content": "hello",
|
||||||
|
"sig": String(repeating: "2", count: 128)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
private static func base64URLDecode(_ s: String) -> Data? {
|
private static func base64URLDecode(_ s: String) -> Data? {
|
||||||
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||||
let rem = str.count % 4
|
let rem = str.count % 4
|
||||||
|
|||||||
@@ -1,10 +1,103 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Security
|
||||||
import Testing
|
import Testing
|
||||||
|
import BitFoundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
@Suite("PreviewKeychainManager Tests")
|
@Suite("PreviewKeychainManager Tests")
|
||||||
struct PreviewKeychainManagerTests {
|
struct PreviewKeychainManagerTests {
|
||||||
|
|
||||||
|
@Test("Install lifecycle distinguishes upgrade, reinstall, bootstrap, and unreadable keychain")
|
||||||
|
func installLifecycleDecision() {
|
||||||
|
#expect(KeychainManager.installLifecycleAction(
|
||||||
|
containerKnowsMarker: true,
|
||||||
|
markerRead: .success(Data([1]))
|
||||||
|
) == .markerPresent)
|
||||||
|
#expect(KeychainManager.installLifecycleAction(
|
||||||
|
containerKnowsMarker: false,
|
||||||
|
markerRead: .success(Data([1]))
|
||||||
|
) == .clearStaleKeys)
|
||||||
|
#expect(KeychainManager.installLifecycleAction(
|
||||||
|
containerKnowsMarker: false,
|
||||||
|
markerRead: .itemNotFound
|
||||||
|
) == .bootstrapMarker)
|
||||||
|
#expect(KeychainManager.installLifecycleAction(
|
||||||
|
containerKnowsMarker: false,
|
||||||
|
markerRead: .deviceLocked
|
||||||
|
) == .retryLater)
|
||||||
|
#expect(KeychainManager.installLifecycleAction(
|
||||||
|
containerKnowsMarker: false,
|
||||||
|
cleanupPending: true,
|
||||||
|
markerRead: .itemNotFound
|
||||||
|
) == .clearStaleKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Accessibility migration covers custom services and retries after any incomplete update")
|
||||||
|
func accessibilityMigrationCoversEveryApplicationOwnedService() {
|
||||||
|
let primaryService = "chat.bitchat.test-primary"
|
||||||
|
var visitedServices: [String] = []
|
||||||
|
|
||||||
|
let completed = KeychainManager
|
||||||
|
.migrateAccessibilityForApplicationOwnedServices(
|
||||||
|
primaryService: primaryService
|
||||||
|
) { service in
|
||||||
|
visitedServices.append(service)
|
||||||
|
return service == "chat.bitchat.favorites"
|
||||||
|
? errSecInteractionNotAllowed
|
||||||
|
: errSecItemNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(!completed)
|
||||||
|
#expect(visitedServices.first == primaryService)
|
||||||
|
#expect(Set(visitedServices).isSuperset(of: [
|
||||||
|
"chat.bitchat.nostr",
|
||||||
|
"chat.bitchat.favorites",
|
||||||
|
"chat.bitchat.outbox"
|
||||||
|
]))
|
||||||
|
#expect(Set(visitedServices).count == visitedServices.count)
|
||||||
|
|
||||||
|
let retryCompleted = KeychainManager
|
||||||
|
.migrateAccessibilityForApplicationOwnedServices(
|
||||||
|
primaryService: primaryService
|
||||||
|
) { _ in errSecSuccess }
|
||||||
|
#expect(retryCompleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Keychain cleanup is complete only when every owned scope is clean")
|
||||||
|
func keychainCleanupRequiresEveryApplicationOwnedService() {
|
||||||
|
let primaryService = "chat.bitchat.test-primary"
|
||||||
|
var visitedServices: [String] = []
|
||||||
|
|
||||||
|
let partialCleanup = KeychainManager
|
||||||
|
.deleteApplicationOwnedKeychainServices(
|
||||||
|
primaryService: primaryService
|
||||||
|
) { service in
|
||||||
|
visitedServices.append(service)
|
||||||
|
return service == "chat.bitchat.outbox"
|
||||||
|
? errSecInteractionNotAllowed
|
||||||
|
: errSecSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(!partialCleanup)
|
||||||
|
#expect(visitedServices.first == primaryService)
|
||||||
|
#expect(Set(visitedServices).isSuperset(of: [
|
||||||
|
"chat.bitchat.nostr",
|
||||||
|
"chat.bitchat.favorites",
|
||||||
|
"chat.bitchat.outbox"
|
||||||
|
]))
|
||||||
|
#expect(Set(visitedServices).count == visitedServices.count)
|
||||||
|
|
||||||
|
let emptyCleanup = KeychainManager
|
||||||
|
.deleteApplicationOwnedKeychainServices(
|
||||||
|
primaryService: primaryService
|
||||||
|
) { _ in errSecItemNotFound }
|
||||||
|
#expect(emptyCleanup)
|
||||||
|
#expect(KeychainManager.completedApplicationGroupDelete(status: -34018))
|
||||||
|
#expect(!KeychainManager.completedApplicationGroupDelete(
|
||||||
|
status: errSecInteractionNotAllowed
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
@Test("Preview keychain manager stores identity and service-scoped data in memory")
|
@Test("Preview keychain manager stores identity and service-scoped data in memory")
|
||||||
func previewKeychainManagerRoundTripsData() {
|
func previewKeychainManagerRoundTripsData() {
|
||||||
let manager = PreviewKeychainManager()
|
let manager = PreviewKeychainManager()
|
||||||
@@ -51,4 +144,132 @@ struct PreviewKeychainManagerTests {
|
|||||||
Issue.record("Expected preview keychain to be empty after deleteAllKeychainData")
|
Issue.record("Expected preview keychain to be empty after deleteAllKeychainData")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("Failed reinstall cleanup blocks stale data until a successful retry")
|
||||||
|
func failedReinstallCleanupBlocksEveryNamespaceUntilSuccessfulRetry() {
|
||||||
|
let gate = KeychainInstallAccessGate()
|
||||||
|
var cleanupCanComplete = false
|
||||||
|
var reconciliationAttempts = 0
|
||||||
|
var manager: PreviewKeychainManager!
|
||||||
|
manager = PreviewKeychainManager(
|
||||||
|
installAccessGate: gate
|
||||||
|
) {
|
||||||
|
reconciliationAttempts += 1
|
||||||
|
guard cleanupCanComplete else { return false }
|
||||||
|
return manager.deleteAllKeychainData()
|
||||||
|
}
|
||||||
|
|
||||||
|
let staleIdentity = Data([1, 2, 3])
|
||||||
|
let staleFavorite = Data([4, 5, 6])
|
||||||
|
let staleOutbox = Data([7, 8, 9])
|
||||||
|
let staleCustom = Data([10, 11, 12])
|
||||||
|
#expect(manager.saveIdentityKey(
|
||||||
|
staleIdentity,
|
||||||
|
forKey: "noiseStaticKey"
|
||||||
|
))
|
||||||
|
#expect(manager.saveIdentityKey(
|
||||||
|
staleIdentity,
|
||||||
|
forKey: "identity_noiseStaticKey"
|
||||||
|
))
|
||||||
|
#expect(manager.verifyIdentityKeyExists())
|
||||||
|
manager.save(
|
||||||
|
key: "favorite",
|
||||||
|
data: staleFavorite,
|
||||||
|
service: "chat.bitchat.favorites",
|
||||||
|
accessible: nil
|
||||||
|
)
|
||||||
|
manager.save(
|
||||||
|
key: "outbox",
|
||||||
|
data: staleOutbox,
|
||||||
|
service: "chat.bitchat.outbox",
|
||||||
|
accessible: nil
|
||||||
|
)
|
||||||
|
manager.save(
|
||||||
|
key: "custom",
|
||||||
|
data: staleCustom,
|
||||||
|
service: "chat.bitchat.future-custom",
|
||||||
|
accessible: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
gate.block()
|
||||||
|
|
||||||
|
#expect(manager.getIdentityKey(forKey: "noiseStaticKey") == nil)
|
||||||
|
#expect(!manager.verifyIdentityKeyExists())
|
||||||
|
if case .accessDenied = manager.getIdentityKeyWithResult(
|
||||||
|
forKey: "noiseStaticKey"
|
||||||
|
) {
|
||||||
|
} else {
|
||||||
|
Issue.record("Expected blocked identity read to fail closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
for (key, service) in [
|
||||||
|
("favorite", "chat.bitchat.favorites"),
|
||||||
|
("outbox", "chat.bitchat.outbox"),
|
||||||
|
("custom", "chat.bitchat.future-custom")
|
||||||
|
] {
|
||||||
|
#expect(manager.load(key: key, service: service) == nil)
|
||||||
|
if case .accessDenied = manager.loadWithResult(
|
||||||
|
key: key,
|
||||||
|
service: service
|
||||||
|
) {
|
||||||
|
} else {
|
||||||
|
Issue.record(
|
||||||
|
"Expected blocked \(service) read to fail closed"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(!manager.saveIdentityKey(
|
||||||
|
Data([13]),
|
||||||
|
forKey: "replacement"
|
||||||
|
))
|
||||||
|
if case .accessDenied = manager.saveIdentityKeyWithResult(
|
||||||
|
Data([14]),
|
||||||
|
forKey: "replacement"
|
||||||
|
) {
|
||||||
|
} else {
|
||||||
|
Issue.record("Expected blocked identity save to fail closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
let failedAttempts = reconciliationAttempts
|
||||||
|
#expect(failedAttempts > 0)
|
||||||
|
cleanupCanComplete = true
|
||||||
|
|
||||||
|
// The first access retries cleanup synchronously. It must not return
|
||||||
|
// any surviving value from before the reinstall.
|
||||||
|
#expect(manager.getIdentityKey(forKey: "noiseStaticKey") == nil)
|
||||||
|
#expect(reconciliationAttempts == failedAttempts + 1)
|
||||||
|
#expect(manager.load(
|
||||||
|
key: "favorite",
|
||||||
|
service: "chat.bitchat.favorites"
|
||||||
|
) == nil)
|
||||||
|
#expect(manager.load(
|
||||||
|
key: "outbox",
|
||||||
|
service: "chat.bitchat.outbox"
|
||||||
|
) == nil)
|
||||||
|
#expect(manager.load(
|
||||||
|
key: "custom",
|
||||||
|
service: "chat.bitchat.future-custom"
|
||||||
|
) == nil)
|
||||||
|
|
||||||
|
let replacementIdentity = Data([21, 22, 23])
|
||||||
|
let replacementCustom = Data([24, 25, 26])
|
||||||
|
#expect(manager.saveIdentityKey(
|
||||||
|
replacementIdentity,
|
||||||
|
forKey: "noiseStaticKey"
|
||||||
|
))
|
||||||
|
#expect(manager.getIdentityKey(
|
||||||
|
forKey: "noiseStaticKey"
|
||||||
|
) == replacementIdentity)
|
||||||
|
manager.save(
|
||||||
|
key: "custom",
|
||||||
|
data: replacementCustom,
|
||||||
|
service: "chat.bitchat.future-custom",
|
||||||
|
accessible: nil
|
||||||
|
)
|
||||||
|
#expect(manager.load(
|
||||||
|
key: "custom",
|
||||||
|
service: "chat.bitchat.future-custom"
|
||||||
|
) == replacementCustom)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,6 +145,39 @@ struct PacketsTests {
|
|||||||
#expect(decoded.capabilities?.rawValue == 0x0180)
|
#expect(decoded.capabilities?.rawValue == 0x0180)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func authenticatedPeerStateUsesVersionedCanonicalTLVs() throws {
|
||||||
|
let signingKey = Data(repeating: 0xA5, count: 32)
|
||||||
|
let packet = AuthenticatedPeerStatePacket(
|
||||||
|
capabilities: [.privateMedia, .vouch],
|
||||||
|
signingPublicKey: signingKey
|
||||||
|
)
|
||||||
|
|
||||||
|
var encoded = try #require(packet.encode())
|
||||||
|
#expect(encoded.prefix(5) == Data([0x01, 0x01, 0x02, 0x20, 0x01]))
|
||||||
|
// Unknown TLVs are forward-compatible and do not alter v1 state.
|
||||||
|
encoded.append(makeTLV(type: 0x7F, value: Data([0xCA, 0xFE])))
|
||||||
|
|
||||||
|
#expect(AuthenticatedPeerStatePacket.decode(from: encoded) == packet)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func authenticatedPeerStateRejectsMalformedAmbiguousOrUnknownVersion() {
|
||||||
|
let key = Data(repeating: 0x44, count: 32)
|
||||||
|
let capabilities = makeTLV(type: 0x01, value: Data([0x00, 0x01]))
|
||||||
|
let signing = makeTLV(type: 0x02, value: key)
|
||||||
|
|
||||||
|
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x02]) + capabilities + signing) == nil)
|
||||||
|
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + signing) == nil)
|
||||||
|
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + capabilities + capabilities + signing) == nil)
|
||||||
|
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01, 0x01, 0x00]) + signing) == nil)
|
||||||
|
// 0x0001 is non-minimal little endian; the canonical form is [0x01].
|
||||||
|
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + makeTLV(type: 0x01, value: Data([0x01, 0x00])) + signing) == nil)
|
||||||
|
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + capabilities + makeTLV(type: 0x02, value: Data(key.dropLast()))) == nil)
|
||||||
|
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + capabilities + Data(signing.dropLast())) == nil)
|
||||||
|
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + makeTLV(type: 0x01, value: Data(repeating: 0x01, count: 9)) + signing) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func privateMessagePacketRejectsUnknownTypeAndTruncation() {
|
func privateMessagePacketRejectsUnknownTypeAndTruncation() {
|
||||||
let unknownTLV = Data([0x7F, 0x01, 0x41])
|
let unknownTLV = Data([0x7F, 0x01, 0x41])
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Testing
|
|||||||
struct BLEAnnounceHandlerTests {
|
struct BLEAnnounceHandlerTests {
|
||||||
private final class Recorder {
|
private final class Recorder {
|
||||||
var existingNoisePublicKey: Data?
|
var existingNoisePublicKey: Data?
|
||||||
|
var authenticatedSigningPublicKey: Data?
|
||||||
var signatureValid = true
|
var signatureValid = true
|
||||||
var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false)
|
var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false)
|
||||||
var linkBoundToOtherPeer = false
|
var linkBoundToOtherPeer = false
|
||||||
@@ -37,6 +38,7 @@ struct BLEAnnounceHandlerTests {
|
|||||||
messageTTL: TransportConfig.messageTTLDefault,
|
messageTTL: TransportConfig.messageTTLDefault,
|
||||||
now: { now },
|
now: { now },
|
||||||
existingNoisePublicKey: { _ in recorder.existingNoisePublicKey },
|
existingNoisePublicKey: { _ in recorder.existingNoisePublicKey },
|
||||||
|
authenticatedSigningPublicKey: { _ in recorder.authenticatedSigningPublicKey },
|
||||||
verifySignature: { packet, signingPublicKey in
|
verifySignature: { packet, signingPublicKey in
|
||||||
recorder.verifySignatureCalls.append((packet, signingPublicKey))
|
recorder.verifySignatureCalls.append((packet, signingPublicKey))
|
||||||
return recorder.signatureValid
|
return recorder.signatureValid
|
||||||
|
|||||||
@@ -169,6 +169,23 @@ struct BLEAnnounceHandlingPolicyTests {
|
|||||||
#expect(decision.isVerified)
|
#expect(decision.isVerified)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func trustPolicyRejectsSigningKeyReplacementAfterNoiseBinding() {
|
||||||
|
let noiseKey = Data(repeating: 0xCC, count: 32)
|
||||||
|
let boundSigningKey = Data(repeating: 0x11, count: 32)
|
||||||
|
|
||||||
|
let decision = BLEAnnounceTrustPolicy.evaluate(
|
||||||
|
hasSignature: true,
|
||||||
|
signatureValid: true,
|
||||||
|
existingNoisePublicKey: noiseKey,
|
||||||
|
announcedNoisePublicKey: noiseKey,
|
||||||
|
authenticatedSigningPublicKey: boundSigningKey,
|
||||||
|
announcedSigningPublicKey: Data(repeating: 0x22, count: 32)
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(decision == .reject(.authenticatedSigningKeyMismatch))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func responsePolicyConnectsOnlyForDirectNewOrReconnectedPeers() {
|
func responsePolicyConnectsOnlyForDirectNewOrReconnectedPeers() {
|
||||||
let directNew = BLEAnnounceResponsePolicy.plan(
|
let directNew = BLEAnnounceResponsePolicy.plan(
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ struct BLEFileTransferHandlerTests {
|
|||||||
recorder.signatureVerifyCount += 1
|
recorder.signatureVerifyCount += 1
|
||||||
return recorder.signatureVerifies
|
return recorder.signatureVerifies
|
||||||
},
|
},
|
||||||
|
localSigningPublicKey: { [sampleSigningKey] in sampleSigningKey },
|
||||||
signedSenderDisplayName: { _, peerID in
|
signedSenderDisplayName: { _, peerID in
|
||||||
recorder.signedNameQueries.append(peerID)
|
recorder.signedNameQueries.append(peerID)
|
||||||
return recorder.signedName
|
return recorder.signedName
|
||||||
@@ -92,12 +93,11 @@ struct BLEFileTransferHandlerTests {
|
|||||||
@Test
|
@Test
|
||||||
func selfEchoIsDropped() throws {
|
func selfEchoIsDropped() throws {
|
||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
|
recorder.signatureVerifies = true
|
||||||
let handler = makeHandler(recorder: recorder)
|
let handler = makeHandler(recorder: recorder)
|
||||||
let packet = try makeFileTransferPacket(sender: localPeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8), ttl: 3)
|
let packet = try makeFileTransferPacket(sender: localPeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8), ttl: 3)
|
||||||
|
|
||||||
// The relay pipeline already suppresses self-originated packets, so the
|
#expect(!handler.handle(packet, from: localPeerID))
|
||||||
// handler reports "relayable" rather than treating the echo as forged.
|
|
||||||
#expect(handler.handle(packet, from: localPeerID))
|
|
||||||
|
|
||||||
expectNoSideEffects(recorder)
|
expectNoSideEffects(recorder)
|
||||||
}
|
}
|
||||||
@@ -120,7 +120,12 @@ struct BLEFileTransferHandlerTests {
|
|||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)]
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)]
|
||||||
let handler = makeHandler(recorder: recorder)
|
let handler = makeHandler(recorder: recorder)
|
||||||
let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8))
|
let packet = try makeFileTransferPacket(
|
||||||
|
sender: remotePeerID,
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
content: Data("%PDF-1.7".utf8),
|
||||||
|
hasSignature: false
|
||||||
|
)
|
||||||
|
|
||||||
// Failed sender authentication must also stop the packet from being
|
// Failed sender authentication must also stop the packet from being
|
||||||
// relayed to downstream nodes.
|
// relayed to downstream nodes.
|
||||||
@@ -129,7 +134,7 @@ struct BLEFileTransferHandlerTests {
|
|||||||
// Broadcast files carry an attacker-controllable senderID, so — like
|
// Broadcast files carry an attacker-controllable senderID, so — like
|
||||||
// public messages — a connected-but-unverified peer must present a valid
|
// public messages — a connected-but-unverified peer must present a valid
|
||||||
// packet signature. No signing key + no signed identity means dropped.
|
// packet signature. No signing key + no signed identity means dropped.
|
||||||
#expect(recorder.signedNameQueries == [remotePeerID])
|
#expect(recorder.signedNameQueries.isEmpty)
|
||||||
#expect(recorder.trackedPackets.isEmpty)
|
#expect(recorder.trackedPackets.isEmpty)
|
||||||
#expect(recorder.deliveredMessages.isEmpty)
|
#expect(recorder.deliveredMessages.isEmpty)
|
||||||
}
|
}
|
||||||
@@ -153,12 +158,11 @@ struct BLEFileTransferHandlerTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func selfBroadcastReplayIsDeliveredWithoutSignatureCheck() throws {
|
func signedSelfBroadcastReplayIsDelivered() throws {
|
||||||
// Our own broadcast file replayed via gossip sync arrives with ttl==0
|
// Our own broadcast file replayed via gossip sync arrives with ttl==0;
|
||||||
// (so it is not treated as a self-echo) and cannot be verified against
|
// it is verified against our local signing key before delivery.
|
||||||
// the peer registry — it must still be accepted, matching
|
|
||||||
// BLEPublicMessageHandler's self exemption.
|
|
||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
|
recorder.signatureVerifies = true
|
||||||
let handler = makeHandler(recorder: recorder)
|
let handler = makeHandler(recorder: recorder)
|
||||||
let packet = try makeFileTransferPacket(
|
let packet = try makeFileTransferPacket(
|
||||||
sender: localPeerID,
|
sender: localPeerID,
|
||||||
@@ -169,7 +173,7 @@ struct BLEFileTransferHandlerTests {
|
|||||||
|
|
||||||
#expect(handler.handle(packet, from: localPeerID))
|
#expect(handler.handle(packet, from: localPeerID))
|
||||||
|
|
||||||
#expect(recorder.signatureVerifyCount == 0)
|
#expect(recorder.signatureVerifyCount == 1)
|
||||||
#expect(recorder.signedNameQueries.isEmpty)
|
#expect(recorder.signedNameQueries.isEmpty)
|
||||||
#expect(recorder.deliveredMessages.count == 1)
|
#expect(recorder.deliveredMessages.count == 1)
|
||||||
#expect(recorder.deliveredMessages.first?.sender == "Me")
|
#expect(recorder.deliveredMessages.first?.sender == "Me")
|
||||||
@@ -205,7 +209,8 @@ struct BLEFileTransferHandlerTests {
|
|||||||
sender: remotePeerID,
|
sender: remotePeerID,
|
||||||
mimeType: "audio/mp4",
|
mimeType: "audio/mp4",
|
||||||
content: m4a,
|
content: m4a,
|
||||||
fileName: "voice_1122334455667788"
|
fileName: "voice_1122334455667788",
|
||||||
|
hasSignature: false
|
||||||
)
|
)
|
||||||
|
|
||||||
// The spoofed note must be dropped locally AND not relayed onward.
|
// The spoofed note must be dropped locally AND not relayed onward.
|
||||||
@@ -215,7 +220,7 @@ struct BLEFileTransferHandlerTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func privateFileFromConnectedUnverifiedPeerIsAccepted() throws {
|
func rawDirectedFileWithoutVerifiableSignatureIsDroppedWithoutWriteOrRelay() throws {
|
||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)]
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)]
|
||||||
let handler = makeHandler(recorder: recorder)
|
let handler = makeHandler(recorder: recorder)
|
||||||
@@ -223,23 +228,25 @@ struct BLEFileTransferHandlerTests {
|
|||||||
sender: remotePeerID,
|
sender: remotePeerID,
|
||||||
mimeType: "application/pdf",
|
mimeType: "application/pdf",
|
||||||
content: Data("%PDF-1.7".utf8),
|
content: Data("%PDF-1.7".utf8),
|
||||||
recipientID: Data(hexString: localPeerID.id)
|
recipientID: Data(hexString: localPeerID.id),
|
||||||
|
hasSignature: false
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(handler.handle(packet, from: remotePeerID))
|
#expect(!handler.handle(packet, from: remotePeerID))
|
||||||
|
|
||||||
// Directed transfers keep the lenient connected-peer path (no broadcast
|
|
||||||
// exposure); no signature check is required.
|
|
||||||
#expect(recorder.signatureVerifyCount == 0)
|
#expect(recorder.signatureVerifyCount == 0)
|
||||||
#expect(recorder.signedNameQueries.isEmpty)
|
#expect(recorder.signedNameQueries.isEmpty)
|
||||||
#expect(recorder.deliveredMessages.count == 1)
|
#expect(recorder.trackedPackets.isEmpty)
|
||||||
#expect(recorder.deliveredMessages.first?.isPrivate == true)
|
#expect(recorder.quotaReservations.isEmpty)
|
||||||
|
#expect(recorder.saveCalls.isEmpty)
|
||||||
|
#expect(recorder.deliveredMessages.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func fileDirectedToAnotherPeerIsIgnored() throws {
|
func fileDirectedToAnotherPeerIsIgnored() throws {
|
||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)]
|
||||||
|
recorder.signatureVerifies = true
|
||||||
let handler = makeHandler(recorder: recorder)
|
let handler = makeHandler(recorder: recorder)
|
||||||
let packet = try makeFileTransferPacket(
|
let packet = try makeFileTransferPacket(
|
||||||
sender: remotePeerID,
|
sender: remotePeerID,
|
||||||
@@ -260,7 +267,8 @@ struct BLEFileTransferHandlerTests {
|
|||||||
@Test
|
@Test
|
||||||
func privateFileUpdatesLastSeenAndDeliversPrivateMessage() throws {
|
func privateFileUpdatesLastSeenAndDeliversPrivateMessage() throws {
|
||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)]
|
||||||
|
recorder.signatureVerifies = true
|
||||||
let handler = makeHandler(recorder: recorder)
|
let handler = makeHandler(recorder: recorder)
|
||||||
let packet = try makeFileTransferPacket(
|
let packet = try makeFileTransferPacket(
|
||||||
sender: remotePeerID,
|
sender: remotePeerID,
|
||||||
@@ -282,6 +290,56 @@ struct BLEFileTransferHandlerTests {
|
|||||||
#expect(recorder.deliveredMessages.first?.deliveryStatus == .delivered(to: "Me", at: Date(timeIntervalSince1970: 900)))
|
#expect(recorder.deliveredMessages.first?.deliveryStatus == .delivered(to: "Me", at: Date(timeIntervalSince1970: 900)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func decryptedPrivateFileUsesValidationQuotaAndPrivateDeliveryWithoutRawSignature() throws {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
|
||||||
|
let file = BitchatFilePacket(
|
||||||
|
fileName: "secret.jpg",
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
let payload = try #require(file.encode())
|
||||||
|
let timestamp = Date(timeIntervalSince1970: 1_234)
|
||||||
|
|
||||||
|
#expect(handler.handlePrivatePayload(payload, from: remotePeerID, timestamp: timestamp))
|
||||||
|
|
||||||
|
#expect(recorder.signatureVerifyCount == 0)
|
||||||
|
#expect(recorder.signedNameQueries.isEmpty)
|
||||||
|
#expect(recorder.trackedPackets.isEmpty)
|
||||||
|
#expect(recorder.quotaReservations == [content.count])
|
||||||
|
#expect(recorder.saveCalls.first?.data == content)
|
||||||
|
#expect(recorder.lastSeenUpdates == [remotePeerID])
|
||||||
|
#expect(recorder.deliveredMessages.count == 1)
|
||||||
|
#expect(recorder.deliveredMessages.first?.isPrivate == true)
|
||||||
|
#expect(recorder.deliveredMessages.first?.timestamp == timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func decryptedPrivateFileOverPayloadCapIsRejectedBeforeQuotaOrDiskWrite() {
|
||||||
|
let recorder = Recorder()
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let oversizedCount = FileTransferLimits.maxPayloadBytes + 1
|
||||||
|
var length = UInt32(oversizedCount).bigEndian
|
||||||
|
var payload = Data([0x04]) // BitchatFilePacket CONTENT TLV
|
||||||
|
withUnsafeBytes(of: &length) { payload.append(contentsOf: $0) }
|
||||||
|
payload.append(Data(repeating: 0x41, count: oversizedCount))
|
||||||
|
|
||||||
|
#expect(!handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(recorder.quotaReservations.isEmpty)
|
||||||
|
#expect(recorder.saveCalls.isEmpty)
|
||||||
|
#expect(recorder.lastSeenUpdates.isEmpty)
|
||||||
|
#expect(recorder.deliveredMessages.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func malformedPayloadIsTrackedForSyncButDropped() {
|
func malformedPayloadIsTrackedForSyncButDropped() {
|
||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
@@ -294,7 +352,7 @@ struct BLEFileTransferHandlerTests {
|
|||||||
recipientID: nil,
|
recipientID: nil,
|
||||||
timestamp: 900_000,
|
timestamp: 900_000,
|
||||||
payload: Data([0x01, 0x02, 0x03]),
|
payload: Data([0x01, 0x02, 0x03]),
|
||||||
signature: nil,
|
signature: Data(repeating: 0x5A, count: 64),
|
||||||
ttl: TransportConfig.messageTTLDefault
|
ttl: TransportConfig.messageTTLDefault
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -370,6 +428,132 @@ struct BLEFileTransferHandlerTests {
|
|||||||
#expect(!FileManager.default.fileExists(atPath: evictable.path))
|
#expect(!FileManager.default.fileExists(atPath: evictable.path))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func panicWipeDeletesEveryManagedMediaFileAndRecreatesEmptyDirectories() throws {
|
||||||
|
let base = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("panic-media-wipe-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
defer { try? FileManager.default.removeItem(at: base) }
|
||||||
|
let store = BLEIncomingFileStore(baseDirectory: base)
|
||||||
|
let subdirectories = [
|
||||||
|
"voicenotes/incoming",
|
||||||
|
"voicenotes/outgoing",
|
||||||
|
"images/incoming",
|
||||||
|
"images/outgoing",
|
||||||
|
"files/incoming",
|
||||||
|
"files/outgoing"
|
||||||
|
]
|
||||||
|
|
||||||
|
for subdirectory in subdirectories {
|
||||||
|
let directory = base
|
||||||
|
.appendingPathComponent("files", isDirectory: true)
|
||||||
|
.appendingPathComponent(subdirectory, isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
try Data("secret".utf8).write(to: directory.appendingPathComponent("artifact.bin"))
|
||||||
|
}
|
||||||
|
let unmanaged = base.appendingPathComponent("files/legacy/secret.bin")
|
||||||
|
try FileManager.default.createDirectory(at: unmanaged.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
|
try Data("legacy".utf8).write(to: unmanaged)
|
||||||
|
|
||||||
|
try store.panicWipe()
|
||||||
|
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: unmanaged.path))
|
||||||
|
for subdirectory in subdirectories {
|
||||||
|
let directory = base
|
||||||
|
.appendingPathComponent("files", isDirectory: true)
|
||||||
|
.appendingPathComponent(subdirectory, isDirectory: true)
|
||||||
|
var isDirectory: ObjCBool = false
|
||||||
|
#expect(FileManager.default.fileExists(atPath: directory.path, isDirectory: &isDirectory))
|
||||||
|
#expect(isDirectory.boolValue)
|
||||||
|
#expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func panicWipeAttemptsDeletionWhenMarkerPersistenceFails() throws {
|
||||||
|
enum MarkerFailure: Error { case unavailable }
|
||||||
|
|
||||||
|
let base = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(
|
||||||
|
"panic-marker-failure-\(UUID().uuidString)",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
defer { try? FileManager.default.removeItem(at: base) }
|
||||||
|
let secret = base
|
||||||
|
.appendingPathComponent("files/images/outgoing", isDirectory: true)
|
||||||
|
.appendingPathComponent("secret.jpg")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: secret.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try Data("secret".utf8).write(to: secret)
|
||||||
|
let store = BLEIncomingFileStore(
|
||||||
|
baseDirectory: base,
|
||||||
|
panicMarkerWriter: { _, _ in throw MarkerFailure.unavailable }
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try store.panicWipe(hasDurablePendingMarker: false)
|
||||||
|
Issue.record("Expected the missing durable marker to fail closed")
|
||||||
|
} catch {
|
||||||
|
// The marker error is reported only after the deletion attempt.
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: secret.path))
|
||||||
|
#expect(
|
||||||
|
FileManager.default.fileExists(
|
||||||
|
atPath: secret.deletingLastPathComponent().path
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func externalMarkerAllowsDeletionToCommitWhenFileMarkerFails() throws {
|
||||||
|
enum MarkerFailure: Error { case unavailable }
|
||||||
|
|
||||||
|
let base = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(
|
||||||
|
"panic-external-marker-\(UUID().uuidString)",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
defer { try? FileManager.default.removeItem(at: base) }
|
||||||
|
let secret = base
|
||||||
|
.appendingPathComponent("files/voicenotes/incoming", isDirectory: true)
|
||||||
|
.appendingPathComponent("secret.m4a")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: secret.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try Data("secret".utf8).write(to: secret)
|
||||||
|
let store = BLEIncomingFileStore(
|
||||||
|
baseDirectory: base,
|
||||||
|
panicMarkerWriter: { _, _ in throw MarkerFailure.unavailable }
|
||||||
|
)
|
||||||
|
|
||||||
|
try store.panicWipe(hasDurablePendingMarker: true)
|
||||||
|
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: secret.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func panicRecoveryMarkerPersistsUntilExplicitCommit() throws {
|
||||||
|
let base = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(
|
||||||
|
"panic-recovery-marker-\(UUID().uuidString)",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
defer { try? FileManager.default.removeItem(at: base) }
|
||||||
|
let store = BLEIncomingFileStore(baseDirectory: base)
|
||||||
|
|
||||||
|
try store.markPanicRecoveryPending()
|
||||||
|
#expect(try store.isPanicRecoveryPending())
|
||||||
|
try store.panicWipe(hasDurablePendingMarker: true)
|
||||||
|
#expect(try store.isPanicRecoveryPending())
|
||||||
|
|
||||||
|
try store.completePanicRecovery()
|
||||||
|
|
||||||
|
#expect(try !store.isPanicRecoveryPending())
|
||||||
|
}
|
||||||
|
|
||||||
private func expectNoSideEffects(_ recorder: Recorder) {
|
private func expectNoSideEffects(_ recorder: Recorder) {
|
||||||
#expect(recorder.signedNameQueries.isEmpty)
|
#expect(recorder.signedNameQueries.isEmpty)
|
||||||
#expect(recorder.trackedPackets.isEmpty)
|
#expect(recorder.trackedPackets.isEmpty)
|
||||||
@@ -403,7 +587,8 @@ struct BLEFileTransferHandlerTests {
|
|||||||
content: Data,
|
content: Data,
|
||||||
ttl: UInt8 = TransportConfig.messageTTLDefault,
|
ttl: UInt8 = TransportConfig.messageTTLDefault,
|
||||||
recipientID: Data? = nil,
|
recipientID: Data? = nil,
|
||||||
fileName: String = "sample"
|
fileName: String = "sample",
|
||||||
|
hasSignature: Bool = true
|
||||||
) throws -> BitchatPacket {
|
) throws -> BitchatPacket {
|
||||||
let filePacket = BitchatFilePacket(
|
let filePacket = BitchatFilePacket(
|
||||||
fileName: fileName,
|
fileName: fileName,
|
||||||
@@ -418,7 +603,7 @@ struct BLEFileTransferHandlerTests {
|
|||||||
recipientID: recipientID,
|
recipientID: recipientID,
|
||||||
timestamp: 900_000,
|
timestamp: 900_000,
|
||||||
payload: payload,
|
payload: payload,
|
||||||
signature: nil,
|
signature: hasSignature ? Data(repeating: 0x5A, count: 64) : nil,
|
||||||
ttl: ttl
|
ttl: ttl
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,35 @@ struct BLEFragmentAssemblyBufferTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func encryptedPrivateFileAssemblyGetsFramedFileHeadroom() throws {
|
||||||
|
var buffer = BLEFragmentAssemblyBuffer()
|
||||||
|
let fragmentID = Data(repeating: 0x15, count: 8)
|
||||||
|
let first = try #require(BLEFragmentHeader(packet: makeFragmentPacket(
|
||||||
|
fragmentID: fragmentID,
|
||||||
|
index: 0,
|
||||||
|
total: 2,
|
||||||
|
originalType: MessageType.noiseEncrypted.rawValue,
|
||||||
|
fragmentData: Data(repeating: 0x01, count: FileTransferLimits.maxPayloadBytes)
|
||||||
|
)))
|
||||||
|
let second = try #require(BLEFragmentHeader(packet: makeFragmentPacket(
|
||||||
|
fragmentID: fragmentID,
|
||||||
|
index: 1,
|
||||||
|
total: 2,
|
||||||
|
originalType: MessageType.noiseEncrypted.rawValue,
|
||||||
|
fragmentData: Data([0x02])
|
||||||
|
)))
|
||||||
|
|
||||||
|
_ = buffer.append(first, maxInFlightAssemblies: 8)
|
||||||
|
let result = buffer.append(second, maxInFlightAssemblies: 8)
|
||||||
|
|
||||||
|
if case let .complete(_, data, _) = result {
|
||||||
|
#expect(data.count == FileTransferLimits.maxPayloadBytes + 1)
|
||||||
|
} else {
|
||||||
|
Issue.record("Expected encrypted private-file assembly to use framed-file limit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func removeExpiredDropsOldAssemblies() throws {
|
func removeExpiredDropsOldAssemblies() throws {
|
||||||
var buffer = BLEFragmentAssemblyBuffer()
|
var buffer = BLEFragmentAssemblyBuffer()
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ struct BLENoisePacketHandlerTests {
|
|||||||
|
|
||||||
private final class Recorder {
|
private final class Recorder {
|
||||||
var handshakeResult: Result<Data?, Error> = .success(nil)
|
var handshakeResult: Result<Data?, Error> = .success(nil)
|
||||||
|
var handshakeAuthenticated = false
|
||||||
var hasSession = false
|
var hasSession = false
|
||||||
|
let sessionGeneration = UUID()
|
||||||
var decryptResult: Result<Data, Error> = .success(Data())
|
var decryptResult: Result<Data, Error> = .success(Data())
|
||||||
|
|
||||||
var processedHandshakes: [(peerID: PeerID, message: Data)] = []
|
var processedHandshakes: [(peerID: PeerID, message: Data)] = []
|
||||||
@@ -18,6 +20,7 @@ struct BLENoisePacketHandlerTests {
|
|||||||
var lastSeenUpdates: [PeerID] = []
|
var lastSeenUpdates: [PeerID] = []
|
||||||
var decryptCalls: [(payload: Data, peerID: PeerID)] = []
|
var decryptCalls: [(payload: Data, peerID: PeerID)] = []
|
||||||
var clearedSessions: [PeerID] = []
|
var clearedSessions: [PeerID] = []
|
||||||
|
var authenticatedPeerStates: [(peerID: PeerID, payload: Data, generation: UUID)] = []
|
||||||
var deliveries: [(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)] = []
|
var deliveries: [(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)] = []
|
||||||
/// Ordered side-effect log to assert recovery sequencing.
|
/// Ordered side-effect log to assert recovery sequencing.
|
||||||
var events: [String] = []
|
var events: [String] = []
|
||||||
@@ -38,7 +41,11 @@ struct BLENoisePacketHandlerTests {
|
|||||||
now: { now },
|
now: { now },
|
||||||
processHandshakeMessage: { peerID, message in
|
processHandshakeMessage: { peerID, message in
|
||||||
recorder.processedHandshakes.append((peerID, message))
|
recorder.processedHandshakes.append((peerID, message))
|
||||||
return try recorder.handshakeResult.get()
|
return NoiseHandshakeProcessingResult(
|
||||||
|
response: try recorder.handshakeResult.get(),
|
||||||
|
didEstablishAuthenticatedSession:
|
||||||
|
recorder.handshakeAuthenticated
|
||||||
|
)
|
||||||
},
|
},
|
||||||
hasNoiseSession: { peerID in
|
hasNoiseSession: { peerID in
|
||||||
recorder.hasSessionQueries.append(peerID)
|
recorder.hasSessionQueries.append(peerID)
|
||||||
@@ -56,12 +63,18 @@ struct BLENoisePacketHandlerTests {
|
|||||||
},
|
},
|
||||||
decrypt: { payload, peerID in
|
decrypt: { payload, peerID in
|
||||||
recorder.decryptCalls.append((payload, peerID))
|
recorder.decryptCalls.append((payload, peerID))
|
||||||
return try recorder.decryptResult.get()
|
return BLENoiseDecryptionResult(
|
||||||
|
plaintext: try recorder.decryptResult.get(),
|
||||||
|
sessionGeneration: recorder.sessionGeneration
|
||||||
|
)
|
||||||
},
|
},
|
||||||
clearSession: { peerID in
|
clearSession: { peerID in
|
||||||
recorder.clearedSessions.append(peerID)
|
recorder.clearedSessions.append(peerID)
|
||||||
recorder.events.append("clearSession")
|
recorder.events.append("clearSession")
|
||||||
},
|
},
|
||||||
|
handleAuthenticatedPeerState: { peerID, payload, generation in
|
||||||
|
recorder.authenticatedPeerStates.append((peerID, payload, generation))
|
||||||
|
},
|
||||||
deliverNoisePayload: { peerID, type, payload, timestamp in
|
deliverNoisePayload: { peerID, type, payload, timestamp in
|
||||||
recorder.deliveries.append((peerID, type, payload, timestamp))
|
recorder.deliveries.append((peerID, type, payload, timestamp))
|
||||||
}
|
}
|
||||||
@@ -110,6 +123,24 @@ struct BLENoisePacketHandlerTests {
|
|||||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func handshakeResultPreservesExactCandidateAuthentication() {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.handshakeAuthenticated = true
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let packet = makeHandshakePacket(
|
||||||
|
recipientID: Data(hexString: localPeerID.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
let result = handler.handleHandshakeWithResult(
|
||||||
|
packet,
|
||||||
|
from: remotePeerID
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(result.processed)
|
||||||
|
#expect(result.didEstablishAuthenticatedSession)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func handshakeForAnotherPeerIsIgnored() {
|
func handshakeForAnotherPeerIsIgnored() {
|
||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
@@ -152,6 +183,21 @@ struct BLENoisePacketHandlerTests {
|
|||||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func peerIdentityMismatchDoesNotRecreateHandshakeState() {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.handshakeResult = .failure(NoiseSessionError.peerIdentityMismatch)
|
||||||
|
recorder.hasSession = false
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let packet = makeHandshakePacket(recipientID: Data(hexString: localPeerID.id))
|
||||||
|
|
||||||
|
#expect(!handler.handleHandshake(packet, from: remotePeerID))
|
||||||
|
|
||||||
|
#expect(recorder.hasSessionQueries.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
#expect(recorder.broadcastPackets.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Encrypted
|
// MARK: Encrypted
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -206,6 +252,25 @@ struct BLENoisePacketHandlerTests {
|
|||||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func authenticatedPeerStateIsConsumedByTransportNotDeliveredToUI() {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.decryptResult = .success(Data([
|
||||||
|
NoisePayloadType.authenticatedPeerState.rawValue,
|
||||||
|
0x01, 0x02, 0x03
|
||||||
|
]))
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let packet = makeEncryptedPacket(recipientID: Data(hexString: localPeerID.id))
|
||||||
|
|
||||||
|
handler.handleEncrypted(packet, from: remotePeerID)
|
||||||
|
|
||||||
|
#expect(recorder.authenticatedPeerStates.count == 1)
|
||||||
|
#expect(recorder.authenticatedPeerStates.first?.peerID == remotePeerID)
|
||||||
|
#expect(recorder.authenticatedPeerStates.first?.payload == Data([0x01, 0x02, 0x03]))
|
||||||
|
#expect(recorder.authenticatedPeerStates.first?.generation == recorder.sessionGeneration)
|
||||||
|
#expect(recorder.deliveries.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func emptyDecryptedPayloadIsIgnored() {
|
func emptyDecryptedPayloadIsIgnored() {
|
||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
|
import BitFoundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
struct BLENoisePayloadFactoryTests {
|
struct BLENoisePayloadFactoryTests {
|
||||||
@@ -31,4 +32,63 @@ struct BLENoisePayloadFactoryTests {
|
|||||||
|
|
||||||
#expect(payload == Data([NoisePayloadType.verifyChallenge.rawValue, 0xCA, 0xFE]))
|
#expect(payload == Data([NoisePayloadType.verifyChallenge.rawValue, 0xCA, 0xFE]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func privateFilePayloadPrefixesCanonicalFilePacket() throws {
|
||||||
|
let content = Data("%PDF-secret".utf8)
|
||||||
|
let file = BitchatFilePacket(
|
||||||
|
fileName: "secret.pdf",
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
|
||||||
|
let payload = try #require(BLENoisePayloadFactory.privateFile(file))
|
||||||
|
|
||||||
|
#expect(payload.first == 0x20, "Encrypted files must use Android's deployed wire value")
|
||||||
|
let decoded = try #require(BitchatFilePacket.decode(Data(payload.dropFirst())))
|
||||||
|
#expect(decoded.fileName == "secret.pdf")
|
||||||
|
#expect(decoded.mimeType == "application/pdf")
|
||||||
|
#expect(decoded.content == content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func androidB7f0b33PrivateFilePlaintextFixtureIsByteCompatible() throws {
|
||||||
|
// Runtime-emitted by Android commit b7f0b33d from
|
||||||
|
// BitchatFilePacket("a.txt", 3, "text/plain", [01, 02, 03]) and
|
||||||
|
// NoisePayload(type = FILE_TRANSFER, data = file.encode()).encode().
|
||||||
|
let fixtureHex = "20010005612e7478740200040000000303000a746578742f706c61696e0400000003010203"
|
||||||
|
let fixture = try #require(Data(hexString: fixtureHex))
|
||||||
|
|
||||||
|
let typed = try #require(NoisePayload.decode(fixture))
|
||||||
|
#expect(typed.type == .privateFile)
|
||||||
|
let file = try #require(BitchatFilePacket.decode(typed.data))
|
||||||
|
#expect(file.fileName == "a.txt")
|
||||||
|
#expect(file.fileSize == 3)
|
||||||
|
#expect(file.mimeType == "text/plain")
|
||||||
|
#expect(file.content == Data([0x01, 0x02, 0x03]))
|
||||||
|
#expect(BLENoisePayloadFactory.privateFile(file) == fixture)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func prereleasePrivateFileTypeCanonicalizesOnDecode() throws {
|
||||||
|
let encoded = Data([NoisePayloadType.prereleasePrivateFileRawValue, 0xCA, 0xFE])
|
||||||
|
let decoded = try #require(NoisePayload.decode(encoded))
|
||||||
|
|
||||||
|
#expect(decoded.type == .privateFile)
|
||||||
|
#expect(decoded.data == Data([0xCA, 0xFE]))
|
||||||
|
#expect(decoded.encode().first == 0x20)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func authenticatedPeerStateUsesPermanent0x21Type() throws {
|
||||||
|
let state = AuthenticatedPeerStatePacket(
|
||||||
|
capabilities: .privateMedia,
|
||||||
|
signingPublicKey: Data(repeating: 0x77, count: 32)
|
||||||
|
)
|
||||||
|
let encoded = try #require(BLENoisePayloadFactory.authenticatedPeerState(state))
|
||||||
|
|
||||||
|
#expect(encoded.first == 0x21)
|
||||||
|
#expect(AuthenticatedPeerStatePacket.decode(from: Data(encoded.dropFirst())) == state)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,10 @@ struct BLENoiseSessionQueuesTests {
|
|||||||
queues.appendTypedPayload(Data([0x01]), for: peerID)
|
queues.appendTypedPayload(Data([0x01]), for: peerID)
|
||||||
queues.appendTypedPayload(Data([0x02]), for: peerID)
|
queues.appendTypedPayload(Data([0x02]), for: peerID)
|
||||||
|
|
||||||
#expect(queues.takeTypedPayloads(for: peerID) == [Data([0x01]), Data([0x02])])
|
#expect(queues.takeTypedPayloads(for: peerID) == [
|
||||||
|
BLEPendingTypedPayload(payload: Data([0x01]), transferId: nil),
|
||||||
|
BLEPendingTypedPayload(payload: Data([0x02]), transferId: nil)
|
||||||
|
])
|
||||||
#expect(queues.takeTypedPayloads(for: peerID).isEmpty)
|
#expect(queues.takeTypedPayloads(for: peerID).isEmpty)
|
||||||
#expect(queues.takePrivateMessages(for: peerID).map(\.messageID) == ["m1"])
|
#expect(queues.takePrivateMessages(for: peerID).map(\.messageID) == ["m1"])
|
||||||
}
|
}
|
||||||
@@ -64,4 +67,21 @@ struct BLENoiseSessionQueuesTests {
|
|||||||
|
|
||||||
#expect(queues.isEmpty)
|
#expect(queues.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func transferIDSurvivesHandshakeQueueAndCanBeCancelledBeforeDrain() {
|
||||||
|
let peerID = PeerID(str: "aaaaaaaaaaaaaaaa")
|
||||||
|
var queues = BLENoiseSessionQueues()
|
||||||
|
|
||||||
|
queues.appendTypedPayload(Data([0x20, 0xAA]), transferId: "media-1", for: peerID)
|
||||||
|
queues.appendTypedPayload(Data([0x01, 0xBB]), for: peerID)
|
||||||
|
|
||||||
|
let removed = queues.removeTypedPayload(transferId: "media-1")
|
||||||
|
let removedAgain = queues.removeTypedPayload(transferId: "media-1")
|
||||||
|
#expect(removed)
|
||||||
|
#expect(!removedAgain)
|
||||||
|
#expect(queues.takeTypedPayloads(for: peerID) == [
|
||||||
|
BLEPendingTypedPayload(payload: Data([0x01, 0xBB]), transferId: nil)
|
||||||
|
])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,58 @@ struct BLEOutboundFragmentPlannerTests {
|
|||||||
) == nil)
|
) == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("private media v1 accepts exactly 256 fragments and rejects 257")
|
||||||
|
func privateMediaCrossPlatformFragmentBoundary() throws {
|
||||||
|
let maxPayload = makePayload(count: 160 * 1024, seed: 0xFACE_CAFE)
|
||||||
|
|
||||||
|
func plan(payloadCount: Int) throws -> BLEOutboundFragmentPlan {
|
||||||
|
let packet = BitchatPacket(
|
||||||
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
|
senderID: Data(hexString: "0011223344556677") ?? Data(),
|
||||||
|
recipientID: Data(hexString: "8877665544332211"),
|
||||||
|
timestamp: 0x0102030405,
|
||||||
|
payload: Data(maxPayload.prefix(payloadCount)),
|
||||||
|
signature: nil,
|
||||||
|
ttl: 3,
|
||||||
|
version: 2
|
||||||
|
)
|
||||||
|
return try #require(BLEOutboundFragmentPlanner.makePlan(
|
||||||
|
for: BLEOutboundFragmentTransferRequest(
|
||||||
|
packet: packet,
|
||||||
|
pad: false,
|
||||||
|
maxChunk: nil,
|
||||||
|
directedPeer: PeerID(str: "8877665544332211"),
|
||||||
|
transferId: "boundary"
|
||||||
|
),
|
||||||
|
defaultChunkSize: TransportConfig.bleDefaultFragmentSize,
|
||||||
|
bleMaxMTU: 512,
|
||||||
|
fragmentID: Data(repeating: 0xD4, count: 8)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstPlan(withAtLeast target: Int) throws -> BLEOutboundFragmentPlan {
|
||||||
|
var low = 1
|
||||||
|
var high = maxPayload.count
|
||||||
|
while low < high {
|
||||||
|
let mid = low + (high - low) / 2
|
||||||
|
if try plan(payloadCount: mid).totalFragments >= target {
|
||||||
|
high = mid
|
||||||
|
} else {
|
||||||
|
low = mid + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return try plan(payloadCount: low)
|
||||||
|
}
|
||||||
|
|
||||||
|
let at256 = try firstPlan(withAtLeast: 256)
|
||||||
|
let at257 = try firstPlan(withAtLeast: 257)
|
||||||
|
|
||||||
|
#expect(at256.totalFragments == 256)
|
||||||
|
#expect(BLEOutboundFragmentPlanner.isPrivateMediaV1Compatible(at256))
|
||||||
|
#expect(at257.totalFragments == 257)
|
||||||
|
#expect(!BLEOutboundFragmentPlanner.isPrivateMediaV1Compatible(at257))
|
||||||
|
}
|
||||||
|
|
||||||
private func makePacket(
|
private func makePacket(
|
||||||
payload: Data,
|
payload: Data,
|
||||||
route: [Data]? = nil,
|
route: [Data]? = nil,
|
||||||
|
|||||||
@@ -20,6 +20,24 @@ struct BLEOutboundFragmentTransferSchedulerTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func explicitTransferIDReservesEncryptedPrivateFileFragments() {
|
||||||
|
var scheduler = BLEOutboundFragmentTransferScheduler()
|
||||||
|
let request = makeRequest(
|
||||||
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
|
transferId: "private-media"
|
||||||
|
)
|
||||||
|
|
||||||
|
let result = scheduler.submit(request, maxConcurrentTransfers: 1)
|
||||||
|
|
||||||
|
if case let .start(_, reservedTransferId) = result {
|
||||||
|
#expect(reservedTransferId == "private-media")
|
||||||
|
#expect(scheduler.activeCount == 1)
|
||||||
|
} else {
|
||||||
|
Issue.record("Expected encrypted private media to reserve its progress slot")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func submitQueuesFileTransferWhenSlotsAreFull() {
|
func submitQueuesFileTransferWhenSlotsAreFull() {
|
||||||
var scheduler = BLEOutboundFragmentTransferScheduler()
|
var scheduler = BLEOutboundFragmentTransferScheduler()
|
||||||
|
|||||||
@@ -42,6 +42,37 @@ struct BLEPeerRegistryTests {
|
|||||||
#expect(registry.info(for: peerID)?.nickname == "alice-renamed")
|
#expect(registry.info(for: peerID)?.nickname == "alice-renamed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("registry preserves absent versus explicit empty capabilities")
|
||||||
|
func capabilitiesPresenceIsPreserved() {
|
||||||
|
var registry = BLEPeerRegistry()
|
||||||
|
let oldPeer = PeerID(str: "1122334455667788")
|
||||||
|
let modernPeer = PeerID(str: "8877665544332211")
|
||||||
|
|
||||||
|
_ = registry.upsertVerifiedAnnounce(
|
||||||
|
peerID: oldPeer,
|
||||||
|
nickname: "old",
|
||||||
|
noisePublicKey: Data(repeating: 0x11, count: 32),
|
||||||
|
signingPublicKey: Data(repeating: 0x12, count: 32),
|
||||||
|
isConnected: true,
|
||||||
|
now: Date(),
|
||||||
|
capabilities: nil
|
||||||
|
)
|
||||||
|
_ = registry.upsertVerifiedAnnounce(
|
||||||
|
peerID: modernPeer,
|
||||||
|
nickname: "modern",
|
||||||
|
noisePublicKey: Data(repeating: 0x21, count: 32),
|
||||||
|
signingPublicKey: Data(repeating: 0x22, count: 32),
|
||||||
|
isConnected: true,
|
||||||
|
now: Date(),
|
||||||
|
capabilities: []
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(registry.capabilities(for: oldPeer).isEmpty)
|
||||||
|
#expect(!registry.capabilitiesWereExplicitlyAdvertised(for: oldPeer))
|
||||||
|
#expect(registry.capabilities(for: modernPeer).isEmpty)
|
||||||
|
#expect(registry.capabilitiesWereExplicitlyAdvertised(for: modernPeer))
|
||||||
|
}
|
||||||
|
|
||||||
@Test("reachability keeps recent verified offline peers only when mesh is attached")
|
@Test("reachability keeps recent verified offline peers only when mesh is attached")
|
||||||
func reachabilityRequiresMeshAttachmentForOfflinePeers() {
|
func reachabilityRequiresMeshAttachmentForOfflinePeers() {
|
||||||
let offlinePeer = PeerID(str: "1122334455667788")
|
let offlinePeer = PeerID(str: "1122334455667788")
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ final class GeohashPresenceServiceTests: XCTestCase {
|
|||||||
burstMaxDelay: 0
|
burstMaxDelay: 0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
service.start()
|
||||||
service.performHeartbeat()
|
service.performHeartbeat()
|
||||||
|
|
||||||
let sentAllAllowedChannels = await waitUntil { sentGeohashes.count == 3 }
|
let sentAllAllowedChannels = await waitUntil { sentGeohashes.count == 3 }
|
||||||
@@ -83,7 +84,7 @@ final class GeohashPresenceServiceTests: XCTestCase {
|
|||||||
XCTAssertEqual(Set(sentGeohashes), Set(["9q", "9q8y", "9q8yy"]))
|
XCTAssertEqual(Set(sentGeohashes), Set(["9q", "9q8y", "9q8yy"]))
|
||||||
XCTAssertEqual(Set(lookedUpGeohashes), Set(["9q", "9q8y", "9q8yy"]))
|
XCTAssertEqual(Set(lookedUpGeohashes), Set(["9q", "9q8y", "9q8yy"]))
|
||||||
XCTAssertEqual(sleptNanoseconds.count, 3)
|
XCTAssertEqual(sleptNanoseconds.count, 3)
|
||||||
XCTAssertEqual(scheduler.intervals, [17])
|
XCTAssertEqual(scheduler.intervals, [17, 17])
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_performHeartbeat_skipsBroadcastWhenTorIsNotReady() async {
|
func test_performHeartbeat_skipsBroadcastWhenTorIsNotReady() async {
|
||||||
@@ -97,11 +98,12 @@ final class GeohashPresenceServiceTests: XCTestCase {
|
|||||||
loopMaxInterval: 21
|
loopMaxInterval: 21
|
||||||
)
|
)
|
||||||
|
|
||||||
|
service.start()
|
||||||
service.performHeartbeat()
|
service.performHeartbeat()
|
||||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
|
||||||
XCTAssertEqual(sendCount, 0)
|
XCTAssertEqual(sendCount, 0)
|
||||||
XCTAssertEqual(scheduler.intervals, [21])
|
XCTAssertEqual(scheduler.intervals, [21, 21])
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_performHeartbeat_skipsBroadcastWhenAppIsBackgrounded() async {
|
func test_performHeartbeat_skipsBroadcastWhenAppIsBackgrounded() async {
|
||||||
@@ -115,11 +117,45 @@ final class GeohashPresenceServiceTests: XCTestCase {
|
|||||||
loopMaxInterval: 22
|
loopMaxInterval: 22
|
||||||
)
|
)
|
||||||
|
|
||||||
|
service.start()
|
||||||
service.performHeartbeat()
|
service.performHeartbeat()
|
||||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
|
||||||
XCTAssertEqual(sendCount, 0)
|
XCTAssertEqual(sendCount, 0)
|
||||||
XCTAssertEqual(scheduler.intervals, [22])
|
XCTAssertEqual(scheduler.intervals, [22, 22])
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_stopForPanic_cancelsTimerAndSuppressesDelayedBroadcast() async throws {
|
||||||
|
let identity = try NostrIdentity.generate()
|
||||||
|
let scheduler = MockGeohashPresenceScheduler()
|
||||||
|
var sleeperContinuation: CheckedContinuation<Void, Never>?
|
||||||
|
var sendCount = 0
|
||||||
|
let service = makeService(
|
||||||
|
scheduler: scheduler,
|
||||||
|
deriveIdentity: { _ in identity },
|
||||||
|
relaySender: { _, _ in sendCount += 1 },
|
||||||
|
sleeper: { _ in
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
sleeperContinuation = continuation
|
||||||
|
}
|
||||||
|
},
|
||||||
|
burstMinDelay: 1,
|
||||||
|
burstMaxDelay: 1
|
||||||
|
)
|
||||||
|
|
||||||
|
service.start()
|
||||||
|
service.performHeartbeat()
|
||||||
|
let delayStarted = await waitUntil {
|
||||||
|
sleeperContinuation != nil
|
||||||
|
}
|
||||||
|
XCTAssertTrue(delayStarted)
|
||||||
|
|
||||||
|
service.stopForPanic()
|
||||||
|
sleeperContinuation?.resume()
|
||||||
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
|
||||||
|
XCTAssertEqual(sendCount, 0)
|
||||||
|
XCTAssertEqual(scheduler.timers.first?.invalidateCallCount, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_broadcastPresence_skipsSendWhenNoRelaysAreAvailable() async throws {
|
func test_broadcastPresence_skipsSendWhenNoRelaysAreAvailable() async throws {
|
||||||
|
|||||||
@@ -91,6 +91,53 @@ final class NetworkActivationServiceTests: XCTestCase {
|
|||||||
XCTAssertGreaterThanOrEqual(context.relayController.connectCallCount, 1)
|
XCTAssertGreaterThanOrEqual(context.relayController.connectCallCount, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func test_stopForPanic_synchronouslyStopsAndIgnoresPublisherUpdates() async {
|
||||||
|
let context = makeService(permission: .authorized, favorites: [])
|
||||||
|
|
||||||
|
context.service.start()
|
||||||
|
context.service.stopForPanic()
|
||||||
|
let connectCountAfterStop = context.relayController.connectCallCount
|
||||||
|
let startCountAfterStop = context.torController.startIfNeededCallCount
|
||||||
|
|
||||||
|
context.favoritesSubject.send([Data([0x01])])
|
||||||
|
context.reachability.set(false)
|
||||||
|
context.reachability.set(true)
|
||||||
|
try? await Task.sleep(nanoseconds: 30_000_000)
|
||||||
|
|
||||||
|
XCTAssertFalse(context.service.activationAllowed)
|
||||||
|
XCTAssertEqual(context.reachability.stopCallCount, 1)
|
||||||
|
XCTAssertEqual(context.torController.autoStartAllowedValues.last, false)
|
||||||
|
XCTAssertEqual(context.proxyController.proxyModes.last, false)
|
||||||
|
XCTAssertGreaterThanOrEqual(
|
||||||
|
context.torController.shutdownCompletelyCallCount,
|
||||||
|
1
|
||||||
|
)
|
||||||
|
XCTAssertGreaterThanOrEqual(
|
||||||
|
context.relayController.disconnectCallCount,
|
||||||
|
1
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
context.relayController.connectCallCount,
|
||||||
|
connectCountAfterStop
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
context.torController.startIfNeededCallCount,
|
||||||
|
startCountAfterStop
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_start_afterPanicStop_reestablishesSubscriptions() {
|
||||||
|
let context = makeService(permission: .authorized, favorites: [])
|
||||||
|
|
||||||
|
context.service.start()
|
||||||
|
context.service.stopForPanic()
|
||||||
|
context.service.start()
|
||||||
|
|
||||||
|
XCTAssertTrue(context.service.activationAllowed)
|
||||||
|
XCTAssertEqual(context.reachability.startCallCount, 2)
|
||||||
|
XCTAssertEqual(context.relayController.connectCallCount, 2)
|
||||||
|
}
|
||||||
|
|
||||||
private func makeService(
|
private func makeService(
|
||||||
permission: LocationChannelManager.PermissionState,
|
permission: LocationChannelManager.PermissionState,
|
||||||
favorites: Set<Data>
|
favorites: Set<Data>
|
||||||
@@ -104,6 +151,7 @@ final class NetworkActivationServiceTests: XCTestCase {
|
|||||||
let torController = MockNetworkActivationTorController()
|
let torController = MockNetworkActivationTorController()
|
||||||
let relayController = MockNetworkActivationRelayController()
|
let relayController = MockNetworkActivationRelayController()
|
||||||
let proxyController = MockNetworkActivationProxyController()
|
let proxyController = MockNetworkActivationProxyController()
|
||||||
|
let reachability = MockNetworkActivationReachability()
|
||||||
let notificationCenter = NotificationCenter()
|
let notificationCenter = NotificationCenter()
|
||||||
let service = NetworkActivationService(
|
let service = NetworkActivationService(
|
||||||
storage: storage,
|
storage: storage,
|
||||||
@@ -111,7 +159,7 @@ final class NetworkActivationServiceTests: XCTestCase {
|
|||||||
mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(),
|
mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(),
|
||||||
permissionProvider: { permissionSubject.value },
|
permissionProvider: { permissionSubject.value },
|
||||||
mutualFavoritesProvider: { favoritesSubject.value },
|
mutualFavoritesProvider: { favoritesSubject.value },
|
||||||
reachabilityMonitor: AlwaysReachableMonitor(),
|
reachabilityMonitor: reachability,
|
||||||
torController: torController,
|
torController: torController,
|
||||||
relayController: relayController,
|
relayController: relayController,
|
||||||
proxyController: proxyController,
|
proxyController: proxyController,
|
||||||
@@ -121,6 +169,7 @@ final class NetworkActivationServiceTests: XCTestCase {
|
|||||||
service: service,
|
service: service,
|
||||||
storage: storage,
|
storage: storage,
|
||||||
favoritesSubject: favoritesSubject,
|
favoritesSubject: favoritesSubject,
|
||||||
|
reachability: reachability,
|
||||||
torController: torController,
|
torController: torController,
|
||||||
relayController: relayController,
|
relayController: relayController,
|
||||||
proxyController: proxyController,
|
proxyController: proxyController,
|
||||||
@@ -148,12 +197,38 @@ private struct NetworkActivationTestContext {
|
|||||||
let service: NetworkActivationService
|
let service: NetworkActivationService
|
||||||
let storage: UserDefaults
|
let storage: UserDefaults
|
||||||
let favoritesSubject: CurrentValueSubject<Set<Data>, Never>
|
let favoritesSubject: CurrentValueSubject<Set<Data>, Never>
|
||||||
|
let reachability: MockNetworkActivationReachability
|
||||||
let torController: MockNetworkActivationTorController
|
let torController: MockNetworkActivationTorController
|
||||||
let relayController: MockNetworkActivationRelayController
|
let relayController: MockNetworkActivationRelayController
|
||||||
let proxyController: MockNetworkActivationProxyController
|
let proxyController: MockNetworkActivationProxyController
|
||||||
let notificationCenter: NotificationCenter
|
let notificationCenter: NotificationCenter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class MockNetworkActivationReachability:
|
||||||
|
NetworkReachabilityMonitoring {
|
||||||
|
private let subject = CurrentValueSubject<Bool, Never>(true)
|
||||||
|
private(set) var startCallCount = 0
|
||||||
|
private(set) var stopCallCount = 0
|
||||||
|
|
||||||
|
var isReachable: Bool { subject.value }
|
||||||
|
var reachabilityPublisher: AnyPublisher<Bool, Never> {
|
||||||
|
subject.removeDuplicates().dropFirst().eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
startCallCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
stopCallCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func set(_ reachable: Bool) {
|
||||||
|
subject.send(reachable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private final class MockNetworkActivationTorController: NetworkActivationTorControlling {
|
private final class MockNetworkActivationTorController: NetworkActivationTorControlling {
|
||||||
private(set) var autoStartAllowedValues: [Bool] = []
|
private(set) var autoStartAllowedValues: [Bool] = []
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ private final class ControllableReachabilityMonitor: NetworkReachabilityMonitori
|
|||||||
subject.removeDuplicates().dropFirst().eraseToAnyPublisher()
|
subject.removeDuplicates().dropFirst().eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
func start() { startCalled = true }
|
func start() { startCalled = true }
|
||||||
|
func stop() { startCalled = false }
|
||||||
func set(_ reachable: Bool) { subject.send(reachable) }
|
func set(_ reachable: Bool) { subject.send(reachable) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,39 +91,266 @@ struct NoiseEncryptionServiceTests {
|
|||||||
func handshakeEncryptionAndFingerprintLifecycle() async throws {
|
func handshakeEncryptionAndFingerprintLifecycle() async throws {
|
||||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
let alicePeerID = PeerID(str: "0011223344556677")
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
let bobPeerID = PeerID(str: "8899aabbccddeeff")
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||||
let recorder = AuthenticationRecorder()
|
let recorder = AuthenticationRecorder()
|
||||||
|
|
||||||
#expect(alice.onPeerAuthenticated == nil)
|
#expect(alice.onPeerAuthenticated == nil)
|
||||||
|
#expect(bob.onPeerAuthenticatedWithGeneration == nil)
|
||||||
alice.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:))
|
alice.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:))
|
||||||
bob.onPeerAuthenticated = recorder.record(peerID:fingerprint:)
|
bob.onPeerAuthenticated = recorder.record(peerID:fingerprint:)
|
||||||
|
bob.onPeerAuthenticatedWithGeneration = recorder.record(
|
||||||
|
peerID:fingerprint:sessionGeneration:
|
||||||
|
)
|
||||||
|
|
||||||
try establishSessions(alice: alice, bob: bob, alicePeerID: alicePeerID, bobPeerID: bobPeerID)
|
try establishSessions(alice: alice, bob: bob)
|
||||||
|
|
||||||
let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: 5.0)
|
let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: 5.0)
|
||||||
#expect(authenticated)
|
#expect(authenticated)
|
||||||
#expect(alice.hasEstablishedSession(with: alicePeerID))
|
let generationAuthenticated = await TestHelpers.waitUntil(
|
||||||
#expect(bob.hasEstablishedSession(with: bobPeerID))
|
{ recorder.generationCount >= 1 },
|
||||||
#expect(alice.hasSession(with: alicePeerID))
|
timeout: 5.0
|
||||||
#expect(bob.hasSession(with: bobPeerID))
|
)
|
||||||
#expect(alice.getPeerPublicKeyData(alicePeerID)?.count == 32)
|
#expect(generationAuthenticated)
|
||||||
#expect(bob.getPeerPublicKeyData(bobPeerID)?.count == 32)
|
#expect(alice.hasEstablishedSession(with: bobPeerID))
|
||||||
#expect(alice.getPeerFingerprint(alicePeerID) != nil)
|
#expect(bob.hasEstablishedSession(with: alicePeerID))
|
||||||
#expect(bob.getPeerFingerprint(bobPeerID) != nil)
|
#expect(alice.hasSession(with: bobPeerID))
|
||||||
|
#expect(bob.hasSession(with: alicePeerID))
|
||||||
|
#expect(alice.getPeerPublicKeyData(bobPeerID)?.count == 32)
|
||||||
|
#expect(bob.getPeerPublicKeyData(alicePeerID)?.count == 32)
|
||||||
|
#expect(alice.getPeerFingerprint(bobPeerID) != nil)
|
||||||
|
#expect(bob.getPeerFingerprint(alicePeerID) != nil)
|
||||||
|
#expect(recorder.generation(for: alicePeerID) == bob.sessionGeneration(for: alicePeerID))
|
||||||
|
|
||||||
let plaintext = Data("secret payload".utf8)
|
let plaintext = Data("secret payload".utf8)
|
||||||
let ciphertext = try alice.encrypt(plaintext, for: alicePeerID)
|
let ciphertext = try alice.encrypt(plaintext, for: bobPeerID)
|
||||||
let decrypted = try bob.decrypt(ciphertext, from: bobPeerID)
|
let decrypted = try bob.decrypt(ciphertext, from: alicePeerID)
|
||||||
#expect(decrypted == plaintext)
|
#expect(decrypted == plaintext)
|
||||||
|
|
||||||
alice.clearSession(for: alicePeerID)
|
alice.clearSession(for: bobPeerID)
|
||||||
#expect(!alice.hasSession(with: alicePeerID))
|
#expect(!alice.hasSession(with: bobPeerID))
|
||||||
#expect(alice.getPeerFingerprint(alicePeerID) == nil)
|
#expect(alice.getPeerFingerprint(bobPeerID) == nil)
|
||||||
|
|
||||||
bob.clearEphemeralStateForPanic()
|
bob.clearEphemeralStateForPanic()
|
||||||
#expect(!bob.hasSession(with: bobPeerID))
|
#expect(!bob.hasSession(with: alicePeerID))
|
||||||
#expect(bob.getPeerFingerprint(bobPeerID) == nil)
|
#expect(bob.getPeerFingerprint(alicePeerID) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Handshake rejects a claimed peer ID that does not match the authenticated static key")
|
||||||
|
func handshakeRejectsClaimedPeerIDStaticKeyMismatch() async throws {
|
||||||
|
let receiver = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let claimedAlice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let receiverPeerID = PeerID(publicKey: receiver.getStaticPublicKeyData())
|
||||||
|
let claimedAlicePeerID = PeerID(publicKey: claimedAlice.getStaticPublicKeyData())
|
||||||
|
let recorder = AuthenticationRecorder()
|
||||||
|
receiver.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:))
|
||||||
|
|
||||||
|
let message1 = try mallory.initiateHandshake(with: receiverPeerID)
|
||||||
|
let message2 = try #require(
|
||||||
|
try receiver.processHandshakeMessage(from: claimedAlicePeerID, message: message1)
|
||||||
|
)
|
||||||
|
let message3 = try #require(
|
||||||
|
try mallory.processHandshakeMessage(from: receiverPeerID, message: message2)
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try receiver.processHandshakeMessage(from: claimedAlicePeerID, message: message3)
|
||||||
|
Issue.record("Expected the authenticated Mallory key to be rejected for Alice's peer ID")
|
||||||
|
} catch let error as NoiseSessionError {
|
||||||
|
#expect(error == .peerIdentityMismatch)
|
||||||
|
} catch {
|
||||||
|
Issue.record("Unexpected mismatch error: \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(!receiver.hasSession(with: claimedAlicePeerID))
|
||||||
|
let emittedAuthentication = await TestHelpers.waitUntil(
|
||||||
|
{ recorder.count > 0 },
|
||||||
|
timeout: TestConstants.shortTimeout
|
||||||
|
)
|
||||||
|
#expect(!emittedAuthentication)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Failed forged replacement preserves the established peer session")
|
||||||
|
func forgedReplacementPreservesEstablishedSession() async throws {
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let receiver = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
|
let receiverPeerID = PeerID(publicKey: receiver.getStaticPublicKeyData())
|
||||||
|
let recorder = AuthenticationRecorder()
|
||||||
|
receiver.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:))
|
||||||
|
|
||||||
|
try establishSessions(alice: alice, bob: receiver)
|
||||||
|
let initialAuthentication = await TestHelpers.waitUntil(
|
||||||
|
{ recorder.count == 1 },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
)
|
||||||
|
#expect(initialAuthentication)
|
||||||
|
|
||||||
|
let before = try alice.encrypt(Data("before".utf8), for: receiverPeerID)
|
||||||
|
#expect(try receiver.decrypt(before, from: alicePeerID) == Data("before".utf8))
|
||||||
|
|
||||||
|
let forgedMessage1 = try mallory.initiateHandshake(with: receiverPeerID)
|
||||||
|
let forgedMessage2 = try #require(
|
||||||
|
try receiver.processHandshakeMessage(from: alicePeerID, message: forgedMessage1)
|
||||||
|
)
|
||||||
|
// The replacement has not authenticated yet; the working Alice
|
||||||
|
// transport session must remain available throughout the candidate.
|
||||||
|
#expect(receiver.hasEstablishedSession(with: alicePeerID))
|
||||||
|
let forgedMessage3 = try #require(
|
||||||
|
try mallory.processHandshakeMessage(from: receiverPeerID, message: forgedMessage2)
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try receiver.processHandshakeMessage(from: alicePeerID, message: forgedMessage3)
|
||||||
|
Issue.record("Expected forged replacement to fail peer binding")
|
||||||
|
} catch let error as NoiseSessionError {
|
||||||
|
#expect(error == .peerIdentityMismatch)
|
||||||
|
} catch {
|
||||||
|
Issue.record("Unexpected replacement error: \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(receiver.hasEstablishedSession(with: alicePeerID))
|
||||||
|
let after = try alice.encrypt(Data("after".utf8), for: receiverPeerID)
|
||||||
|
#expect(try receiver.decrypt(after, from: alicePeerID) == Data("after".utf8))
|
||||||
|
let emittedReplacementAuthentication = await TestHelpers.waitUntil(
|
||||||
|
{ recorder.count > 1 },
|
||||||
|
timeout: TestConstants.shortTimeout
|
||||||
|
)
|
||||||
|
#expect(!emittedReplacementAuthentication)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Valid rehandshake atomically replaces the established session")
|
||||||
|
func validRehandshakeReplacesEstablishedSession() throws {
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let receiver = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
|
let receiverPeerID = PeerID(publicKey: receiver.getStaticPublicKeyData())
|
||||||
|
|
||||||
|
try establishSessions(alice: alice, bob: receiver)
|
||||||
|
alice.clearSession(for: receiverPeerID)
|
||||||
|
|
||||||
|
let message1 = try alice.initiateHandshake(with: receiverPeerID)
|
||||||
|
let message2 = try #require(
|
||||||
|
try receiver.processHandshakeMessage(from: alicePeerID, message: message1)
|
||||||
|
)
|
||||||
|
#expect(receiver.hasEstablishedSession(with: alicePeerID))
|
||||||
|
let message3 = try #require(
|
||||||
|
try alice.processHandshakeMessage(from: receiverPeerID, message: message2)
|
||||||
|
)
|
||||||
|
_ = try receiver.processHandshakeMessage(from: alicePeerID, message: message3)
|
||||||
|
|
||||||
|
#expect(alice.hasEstablishedSession(with: receiverPeerID))
|
||||||
|
#expect(receiver.hasEstablishedSession(with: alicePeerID))
|
||||||
|
let ciphertext = try alice.encrypt(Data("new session".utf8), for: receiverPeerID)
|
||||||
|
#expect(try receiver.decrypt(ciphertext, from: alicePeerID) == Data("new session".utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Automatic rekey exposes and completes its exact handshake bytes")
|
||||||
|
func automaticRekeyHandshakeIsNotStranded() throws {
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||||
|
try establishSessions(alice: alice, bob: bob)
|
||||||
|
let originalGeneration = try #require(alice.sessionGeneration(for: bobPeerID))
|
||||||
|
var leaseRan = false
|
||||||
|
let leased = alice.withCurrentSessionGeneration(
|
||||||
|
for: bobPeerID,
|
||||||
|
expected: originalGeneration
|
||||||
|
) {
|
||||||
|
leaseRan = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
#expect(leased == true)
|
||||||
|
#expect(leaseRan)
|
||||||
|
|
||||||
|
var emittedPeerID: PeerID?
|
||||||
|
var emittedMessage: Data?
|
||||||
|
alice.onRekeyHandshakeReady = { peerID, message in
|
||||||
|
emittedPeerID = peerID
|
||||||
|
emittedMessage = message
|
||||||
|
}
|
||||||
|
try alice._test_initiateAutomaticRekey(for: bobPeerID)
|
||||||
|
|
||||||
|
#expect(emittedPeerID == bobPeerID)
|
||||||
|
#expect(alice.sessionGeneration(for: bobPeerID) == nil)
|
||||||
|
leaseRan = false
|
||||||
|
let staleLease = alice.withCurrentSessionGeneration(
|
||||||
|
for: bobPeerID,
|
||||||
|
expected: originalGeneration
|
||||||
|
) {
|
||||||
|
leaseRan = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
#expect(staleLease == nil)
|
||||||
|
#expect(!leaseRan)
|
||||||
|
let message1 = try #require(emittedMessage)
|
||||||
|
#expect(!message1.isEmpty)
|
||||||
|
#expect(alice.hasSession(with: bobPeerID))
|
||||||
|
#expect(!alice.hasEstablishedSession(with: bobPeerID))
|
||||||
|
|
||||||
|
let message2 = try #require(
|
||||||
|
try bob.processHandshakeMessage(from: alicePeerID, message: message1)
|
||||||
|
)
|
||||||
|
let message3 = try #require(
|
||||||
|
try alice.processHandshakeMessage(from: bobPeerID, message: message2)
|
||||||
|
)
|
||||||
|
_ = try bob.processHandshakeMessage(from: alicePeerID, message: message3)
|
||||||
|
|
||||||
|
#expect(alice.hasEstablishedSession(with: bobPeerID))
|
||||||
|
#expect(bob.hasEstablishedSession(with: alicePeerID))
|
||||||
|
#expect(alice.sessionGeneration(for: bobPeerID) != originalGeneration)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Large private-file payloads use the bounded Noise extension")
|
||||||
|
func largePrivateFileNoiseRoundTrip() throws {
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||||
|
try establishSessions(alice: alice, bob: bob)
|
||||||
|
|
||||||
|
let content = Data("%PDF-1.7\n".utf8) + Data(repeating: 0x51, count: 96 * 1024)
|
||||||
|
let file = BitchatFilePacket(
|
||||||
|
fileName: "large-private.pdf",
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
let typedPayload = try #require(BLENoisePayloadFactory.privateFile(file))
|
||||||
|
#expect(typedPayload.count > NoiseSecurityConstants.maxMessageSize)
|
||||||
|
#expect(typedPayload.first == NoisePayloadType.privateFile.rawValue)
|
||||||
|
#expect(
|
||||||
|
typedPayload.count <= NoiseSecurityConstants.maxPrivateFilePlaintextSize,
|
||||||
|
"typedBytes=\(typedPayload.count) limit=\(NoiseSecurityConstants.maxPrivateFilePlaintextSize)"
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try alice.encrypt(typedPayload, for: bobPeerID)
|
||||||
|
Issue.record("Ordinary Noise payload path must retain its 64 KiB ceiling")
|
||||||
|
} catch NoiseSecurityError.messageTooLarge {
|
||||||
|
// Expected: only the purpose-specific private-file API may extend it.
|
||||||
|
}
|
||||||
|
|
||||||
|
let ciphertext: Data
|
||||||
|
do {
|
||||||
|
ciphertext = try alice.encryptPrivateFilePayload(typedPayload, for: bobPeerID)
|
||||||
|
} catch {
|
||||||
|
Issue.record("Private-file encryption failed: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let decrypted: Data
|
||||||
|
do {
|
||||||
|
decrypted = try bob.decrypt(ciphertext, from: alicePeerID)
|
||||||
|
} catch {
|
||||||
|
Issue.record("Private-file decryption failed: \(error); ciphertextBytes=\(ciphertext.count)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(ciphertext.range(of: content) == nil)
|
||||||
|
#expect(decrypted == typedPayload)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Encrypt without a session requests handshake and decrypt without session fails")
|
@Test("Encrypt without a session requests handshake and decrypt without session fails")
|
||||||
@@ -200,16 +427,16 @@ struct NoiseEncryptionServiceTests {
|
|||||||
|
|
||||||
private func establishSessions(
|
private func establishSessions(
|
||||||
alice: NoiseEncryptionService,
|
alice: NoiseEncryptionService,
|
||||||
bob: NoiseEncryptionService,
|
bob: NoiseEncryptionService
|
||||||
alicePeerID: PeerID,
|
|
||||||
bobPeerID: PeerID
|
|
||||||
) throws {
|
) throws {
|
||||||
let message1 = try alice.initiateHandshake(with: alicePeerID)
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
let response = try bob.processHandshakeMessage(from: bobPeerID, message: message1)
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||||
|
let message1 = try alice.initiateHandshake(with: bobPeerID)
|
||||||
|
let response = try bob.processHandshakeMessage(from: alicePeerID, message: message1)
|
||||||
let message2 = try #require(response, "Expected handshake response")
|
let message2 = try #require(response, "Expected handshake response")
|
||||||
let final = try alice.processHandshakeMessage(from: alicePeerID, message: message2)
|
let final = try alice.processHandshakeMessage(from: bobPeerID, message: message2)
|
||||||
let message3 = try #require(final, "Expected handshake final")
|
let message3 = try #require(final, "Expected handshake final")
|
||||||
let finalMessage = try bob.processHandshakeMessage(from: bobPeerID, message: message3)
|
let finalMessage = try bob.processHandshakeMessage(from: alicePeerID, message: message3)
|
||||||
#expect(finalMessage == nil)
|
#expect(finalMessage == nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,6 +444,7 @@ struct NoiseEncryptionServiceTests {
|
|||||||
private final class AuthenticationRecorder: @unchecked Sendable {
|
private final class AuthenticationRecorder: @unchecked Sendable {
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private var entries: [(PeerID, String)] = []
|
private var entries: [(PeerID, String)] = []
|
||||||
|
private var generationEntries: [(PeerID, UUID)] = []
|
||||||
|
|
||||||
var count: Int {
|
var count: Int {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
@@ -224,9 +452,27 @@ private final class AuthenticationRecorder: @unchecked Sendable {
|
|||||||
return entries.count
|
return entries.count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var generationCount: Int {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return generationEntries.count
|
||||||
|
}
|
||||||
|
|
||||||
func record(peerID: PeerID, fingerprint: String) {
|
func record(peerID: PeerID, fingerprint: String) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
entries.append((peerID, fingerprint))
|
entries.append((peerID, fingerprint))
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func record(peerID: PeerID, fingerprint _: String, sessionGeneration: UUID) {
|
||||||
|
lock.lock()
|
||||||
|
generationEntries.append((peerID, sessionGeneration))
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func generation(for peerID: PeerID) -> UUID? {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return generationEntries.last { $0.0 == peerID }?.1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -399,6 +399,59 @@ final class SecureIdentityStateManagerTests: XCTestCase {
|
|||||||
XCTAssertTrue(cleared)
|
XCTAssertTrue(cleared)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func test_privateMediaCapabilityPinPersistsMonotonicallyAndPanicClearRemovesIt() async {
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let fingerprint = Data(repeating: 0x42, count: 32).sha256Fingerprint()
|
||||||
|
let manager = SecureIdentityStateManager(keychain)
|
||||||
|
|
||||||
|
XCTAssertFalse(manager.hasObservedPrivateMediaCapability(fingerprint: fingerprint))
|
||||||
|
manager.markPrivateMediaCapable(fingerprint: fingerprint)
|
||||||
|
XCTAssertTrue(
|
||||||
|
manager.hasObservedPrivateMediaCapability(fingerprint: fingerprint),
|
||||||
|
"pin insertion must be synchronously visible to the next downgrade decision"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Re-marking is idempotent, and the encrypted cache carries the pin
|
||||||
|
// across launches.
|
||||||
|
manager.markPrivateMediaCapable(fingerprint: fingerprint)
|
||||||
|
manager.forceSave()
|
||||||
|
let reloaded = SecureIdentityStateManager(keychain)
|
||||||
|
XCTAssertTrue(reloaded.hasObservedPrivateMediaCapability(fingerprint: fingerprint))
|
||||||
|
|
||||||
|
// ChatViewModel's panic path calls this same wipe after deleting
|
||||||
|
// keychain data; the in-memory pin must disappear immediately too.
|
||||||
|
reloaded.clearAllIdentityData()
|
||||||
|
let cleared = await waitUntil {
|
||||||
|
!reloaded.hasObservedPrivateMediaCapability(fingerprint: fingerprint)
|
||||||
|
}
|
||||||
|
XCTAssertTrue(cleared)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_noiseAuthenticatedSigningKeyBindingPersistsAndPanicClearRemovesIt() async {
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let fingerprint = Data(repeating: 0x31, count: 32).sha256Fingerprint()
|
||||||
|
let firstKey = Data(repeating: 0x41, count: 32)
|
||||||
|
let rotatedKey = Data(repeating: 0x42, count: 32)
|
||||||
|
let manager = SecureIdentityStateManager(keychain)
|
||||||
|
|
||||||
|
manager.bindAuthenticatedSigningPublicKey(firstKey, fingerprint: fingerprint)
|
||||||
|
XCTAssertEqual(manager.authenticatedSigningPublicKey(forFingerprint: fingerprint), firstKey)
|
||||||
|
// A later authenticated Noise session may legitimately rotate the
|
||||||
|
// announcement signing key.
|
||||||
|
manager.bindAuthenticatedSigningPublicKey(rotatedKey, fingerprint: fingerprint)
|
||||||
|
XCTAssertEqual(manager.authenticatedSigningPublicKey(forFingerprint: fingerprint), rotatedKey)
|
||||||
|
|
||||||
|
manager.forceSave()
|
||||||
|
let reloaded = SecureIdentityStateManager(keychain)
|
||||||
|
XCTAssertEqual(reloaded.authenticatedSigningPublicKey(forFingerprint: fingerprint), rotatedKey)
|
||||||
|
|
||||||
|
reloaded.clearAllIdentityData()
|
||||||
|
let cleared = await waitUntil {
|
||||||
|
reloaded.authenticatedSigningPublicKey(forFingerprint: fingerprint) == nil
|
||||||
|
}
|
||||||
|
XCTAssertTrue(cleared)
|
||||||
|
}
|
||||||
|
|
||||||
func test_forceSave_withFailingCacheWriteDoesNotPersistCache() async {
|
func test_forceSave_withFailingCacheWriteDoesNotPersistCache() async {
|
||||||
let keychain = FailingCacheSaveKeychain()
|
let keychain = FailingCacheSaveKeychain()
|
||||||
let manager = SecureIdentityStateManager(keychain)
|
let manager = SecureIdentityStateManager(keychain)
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ struct TransferProgressManagerTests {
|
|||||||
recorder.append("updated:\(id):\(sent):\(total)")
|
recorder.append("updated:\(id):\(sent):\(total)")
|
||||||
case .completed(let id, let total):
|
case .completed(let id, let total):
|
||||||
recorder.append("completed:\(id):\(total)")
|
recorder.append("completed:\(id):\(total)")
|
||||||
case .cancelled:
|
case .cancelled, .rejected:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,7 +85,7 @@ struct TransferProgressManagerTests {
|
|||||||
recorder.append("started:\(id):\(total)")
|
recorder.append("started:\(id):\(total)")
|
||||||
case .cancelled(let id, let sent, let total):
|
case .cancelled(let id, let sent, let total):
|
||||||
recorder.append("cancelled:\(id):\(sent):\(total)")
|
recorder.append("cancelled:\(id):\(sent):\(total)")
|
||||||
case .updated, .completed:
|
case .updated, .completed, .rejected:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,6 +105,28 @@ struct TransferProgressManagerTests {
|
|||||||
#expect(manager.snapshot(id: transferID) == nil)
|
#expect(manager.snapshot(id: transferID) == nil)
|
||||||
_ = cancellable
|
_ = cancellable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("Preflight policy rejection publishes a visible failure reason")
|
||||||
|
@MainActor
|
||||||
|
func rejectBeforeStartPublishesReason() async {
|
||||||
|
let manager = TransferProgressManager()
|
||||||
|
let transferID = "transfer-visible-reject"
|
||||||
|
let recorder = EventRecorder()
|
||||||
|
let cancellable = manager.publisher.sink { event in
|
||||||
|
if case .rejected(let id, let reason) = event {
|
||||||
|
recorder.append("rejected:\(id):\(reason)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.rejectBeforeStart(id: transferID, reason: "upgrade required")
|
||||||
|
|
||||||
|
let didReceive = await TestHelpers.waitUntil({
|
||||||
|
recorder.values == ["rejected:\(transferID):upgrade required"]
|
||||||
|
}, timeout: 5.0)
|
||||||
|
#expect(didReceive)
|
||||||
|
#expect(manager.snapshot(id: transferID) == nil)
|
||||||
|
_ = cancellable
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class EventRecorder: @unchecked Sendable {
|
private final class EventRecorder: @unchecked Sendable {
|
||||||
|
|||||||
@@ -266,6 +266,11 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol {
|
|||||||
verified.removeAll()
|
verified.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func markPrivateMediaCapable(fingerprint: String) {}
|
||||||
|
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool { false }
|
||||||
|
func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String) {}
|
||||||
|
func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data? { nil }
|
||||||
|
|
||||||
func removeEphemeralSession(peerID: PeerID) {}
|
func removeEphemeralSession(peerID: PeerID) {}
|
||||||
|
|
||||||
func setVerified(fingerprint: String, verified: Bool) {
|
func setVerified(fingerprint: String, verified: Bool) {
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ private final class GatedVoiceCaptureSession: VoiceCaptureSession {
|
|||||||
private let startError: Error?
|
private let startError: Error?
|
||||||
private(set) var finishStarted = false
|
private(set) var finishStarted = false
|
||||||
private(set) var cancelCount = 0
|
private(set) var cancelCount = 0
|
||||||
|
private(set) var panicCancelCount = 0
|
||||||
private var finishContinuation: CheckedContinuation<URL?, Never>?
|
private var finishContinuation: CheckedContinuation<URL?, Never>?
|
||||||
|
|
||||||
init(startError: Error? = nil) {
|
init(startError: Error? = nil) {
|
||||||
@@ -83,6 +84,10 @@ private final class GatedVoiceCaptureSession: VoiceCaptureSession {
|
|||||||
cancelCount += 1
|
cancelCount += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func panicCancelSynchronously() {
|
||||||
|
panicCancelCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
func resolveFinish(with url: URL?) {
|
func resolveFinish(with url: URL?) {
|
||||||
let continuation = finishContinuation
|
let continuation = finishContinuation
|
||||||
finishContinuation = nil
|
finishContinuation = nil
|
||||||
@@ -204,4 +209,57 @@ struct VoiceCaptureSessionTests {
|
|||||||
}
|
}
|
||||||
#expect(viewModel.state == .idle)
|
#expect(viewModel.state == .idle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func panicSynchronouslyCancelsActiveCaptureAndResetsUI() async {
|
||||||
|
let session = GatedVoiceCaptureSession()
|
||||||
|
let viewModel = VoiceRecordingViewModel()
|
||||||
|
viewModel.sessionProvider = { session }
|
||||||
|
|
||||||
|
viewModel.start(shouldShow: true)
|
||||||
|
await waitUntil { self.isRecording(viewModel.state) }
|
||||||
|
|
||||||
|
viewModel.panicWipe()
|
||||||
|
|
||||||
|
#expect(session.panicCancelCount == 1)
|
||||||
|
#expect(viewModel.state == .idle)
|
||||||
|
#expect(!viewModel.isLiveStreaming)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func panicInvalidatesARecordingAlreadyFinalizing() async throws {
|
||||||
|
let session = GatedVoiceCaptureSession()
|
||||||
|
let viewModel = VoiceRecordingViewModel()
|
||||||
|
viewModel.sessionProvider = { session }
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("voice-panic-\(UUID().uuidString).m4a")
|
||||||
|
try Data([0x01]).write(to: url)
|
||||||
|
var delivered = false
|
||||||
|
|
||||||
|
viewModel.start(shouldShow: true)
|
||||||
|
await waitUntil { self.isRecording(viewModel.state) }
|
||||||
|
viewModel.finish { _ in delivered = true }
|
||||||
|
await waitUntil { session.finishStarted }
|
||||||
|
|
||||||
|
viewModel.panicWipe()
|
||||||
|
session.resolveFinish(with: url)
|
||||||
|
await waitUntil {
|
||||||
|
!FileManager.default.fileExists(atPath: url.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(!delivered)
|
||||||
|
#expect(viewModel.state == .idle)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func liveSessionPanicStopsCaptureWithoutSendingControl() {
|
||||||
|
let capture = StubPTTCapture(stopResult: (nil, 0))
|
||||||
|
var sentPackets: [Data] = []
|
||||||
|
let session = PTTLiveVoiceSession(
|
||||||
|
sendPacket: { sentPackets.append($0) },
|
||||||
|
capture: capture
|
||||||
|
)
|
||||||
|
|
||||||
|
session.panicCancelSynchronously()
|
||||||
|
|
||||||
|
#expect(capture.cancelCount == 1)
|
||||||
|
#expect(sentPackets.isEmpty)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -360,6 +360,35 @@ struct VoiceRecorderTests {
|
|||||||
#expect(FileManager.default.fileExists(atPath: secondURL.path))
|
#expect(FileManager.default.fileExists(atPath: secondURL.path))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func classicSessionPanicStopsRecorderAndDeletesFileBeforeReturning() async throws {
|
||||||
|
let directory = try makeTemporaryDirectory()
|
||||||
|
defer { try? FileManager.default.removeItem(at: directory) }
|
||||||
|
|
||||||
|
let rawSession = VoiceRecorderTestSession()
|
||||||
|
let coordinator = AudioSessionCoordinator(session: rawSession)
|
||||||
|
let factory = TestVoiceAudioRecorderFactory(plans: [.success])
|
||||||
|
let voiceRecorder = VoiceRecorder(
|
||||||
|
sessionCoordinator: coordinator,
|
||||||
|
recorderFactory: factory,
|
||||||
|
permissionGranted: { true },
|
||||||
|
paddingInterval: 0,
|
||||||
|
outputDirectory: directory
|
||||||
|
)
|
||||||
|
let capture = VoiceNoteCaptureSession(recorder: voiceRecorder)
|
||||||
|
|
||||||
|
try await capture.start()
|
||||||
|
let url = try #require(factory.urls.first)
|
||||||
|
let recorder = try #require(factory.recorders.first)
|
||||||
|
|
||||||
|
capture.panicCancelSynchronously()
|
||||||
|
|
||||||
|
#expect(recorder.stopCallCount == 1)
|
||||||
|
#expect(!recorder.isRecording)
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: url.path))
|
||||||
|
await coordinator.drain()
|
||||||
|
#expect(rawSession.activationCalls == [true, false])
|
||||||
|
}
|
||||||
|
|
||||||
private func verifyFailedStart(
|
private func verifyFailedStart(
|
||||||
firstPlan: TestVoiceAudioRecorderFactory.Plan,
|
firstPlan: TestVoiceAudioRecorderFactory.Plan,
|
||||||
expectedPrepareCalls: Int,
|
expectedPrepareCalls: Int,
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Private-media wire migration
|
||||||
|
|
||||||
|
Private files use the `BitchatFilePacket` TLV shared by iOS and Android. The
|
||||||
|
preferred direct-message wire form encrypts that complete TLV inside the
|
||||||
|
peer's Noise session before BLE fragmentation.
|
||||||
|
|
||||||
|
## Wire values and capability
|
||||||
|
|
||||||
|
- `NoisePayloadType.privateFile` is `0x20`, the value already deployed by the
|
||||||
|
Android client. New sends must use this value.
|
||||||
|
- iOS temporarily accepts `0x09`, which appeared in prerelease builds of the
|
||||||
|
private-media change. Decoders canonicalize it to `privateFile`; they never
|
||||||
|
emit it.
|
||||||
|
- `NoisePayloadType.authenticatedPeerState` is permanently assigned `0x21`.
|
||||||
|
It is emitted after every completed/rekeyed Noise XX session and echoed at
|
||||||
|
most once when the remote state arrives, so message-3/proof reordering over
|
||||||
|
different mesh links converges. This type is part of the protocol security
|
||||||
|
boundary and is not removed when the media migration ends.
|
||||||
|
- The `0x21` payload starts with version `0x01`, followed by one-byte
|
||||||
|
type/length/value fields. Version 1 requires canonical TLV `0x01` (the
|
||||||
|
minimal little-endian `PeerCapabilities` bitfield, 1-8 bytes) and TLV `0x02`
|
||||||
|
(the 32-byte Ed25519 announcement signing key). Duplicate required fields,
|
||||||
|
non-minimal capabilities, malformed lengths, missing fields, and unknown
|
||||||
|
versions are ignored without changing state. Unknown TLVs are skipped.
|
||||||
|
- The public `PeerCapabilities.privateMedia` announce bit is a discovery hint:
|
||||||
|
it starts a Noise handshake, but never selects encrypted sending or creates
|
||||||
|
a pin. A private transfer waits boundedly for the exact session's encrypted
|
||||||
|
`0x21`. A valid bit-8 proof selects Noise `0x20`; a valid no-bit proof or a
|
||||||
|
no-proof timeout reaches the explicit legacy-consent path for an unpinned
|
||||||
|
peer. No timeout automatically sends raw bytes.
|
||||||
|
- An unpinned peer with a stable Noise key but without that capability is
|
||||||
|
eligible for one signed, directed
|
||||||
|
`fileTransfer`, matching the pre-migration wire form used by older iOS and
|
||||||
|
accepted by current Android clients, only after the sender confirms a
|
||||||
|
per-send warning that the file is not end-to-end encrypted and mesh relays
|
||||||
|
can see it. The
|
||||||
|
consent is consumed by that invocation and is never remembered.
|
||||||
|
- A signed announce never creates a pin by itself: an attacker can copy a
|
||||||
|
victim's public Noise key, supply its own Ed25519 key and capability bits,
|
||||||
|
and self-sign an internally consistent announce. Only successfully
|
||||||
|
decrypted `0x21` state pins the authenticated Noise fingerprint and binds
|
||||||
|
the Ed25519 key used by later announces/public messages. A later valid
|
||||||
|
no-bit `0x21` is treated as a downgrade, and raw fallback is blocked even if
|
||||||
|
a caller presents legacy consent. Public no-bit announces cannot overwrite
|
||||||
|
current session-authenticated state.
|
||||||
|
- During migration, both an absent capabilities TLV and an explicit TLV
|
||||||
|
without `privateMedia` are legacy-eligible when that stable fingerprint is
|
||||||
|
not pinned. This supports clients that added capability advertisement before
|
||||||
|
encrypted media. Neither shape bypasses a previously authenticated pin.
|
||||||
|
|
||||||
|
Older clients decrypt and ignore unknown inner type `0x21`; they do not need to
|
||||||
|
understand it to continue using text or the warned legacy media path. They are
|
||||||
|
never inferred capable merely because the handshake succeeded.
|
||||||
|
|
||||||
|
Removal gates are independent and must not share an arbitrary calendar date:
|
||||||
|
|
||||||
|
- Remove the `0x09` receive alias only after every TestFlight/internal build
|
||||||
|
that emitted it has expired and minimum-supported-client policy excludes it.
|
||||||
|
- Remove the signed directed raw `0x22` fallback only after minimum-supported
|
||||||
|
iOS and Android clients emit authenticated bit-8 `0x21` state and the legacy
|
||||||
|
population has aged out.
|
||||||
|
- Nostr kind `1059` compatibility is a separate envelope migration. Its dual
|
||||||
|
publish/removal gate is not evidence that either BLE compatibility shape can
|
||||||
|
be removed.
|
||||||
|
|
||||||
|
## Security boundary
|
||||||
|
|
||||||
|
The encrypted form provides Noise confidentiality and peer authentication.
|
||||||
|
The fallback is signed and its signature is required on receive, so relays
|
||||||
|
cannot forge its sender or contents. It is not confidential: relays can see
|
||||||
|
the raw file TLV. The UI says this explicitly and asks on every send. A peer
|
||||||
|
without a stable Noise key from a verified registry entry cannot use the
|
||||||
|
fallback. Keep it only for the mixed-version migration, and remove it only
|
||||||
|
after minimum-supported Android and iOS releases emit authenticated bit-8
|
||||||
|
`0x21` state and the legacy population has aged out. Never replace it with an
|
||||||
|
unsigned fallback, persist blanket consent, or send both forms.
|
||||||
|
|
||||||
|
Incoming clients accept all three migration-era shapes:
|
||||||
|
|
||||||
|
| Sender | Inbound form | Result |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Current Android | Noise `0x20` | Decrypt and deliver |
|
||||||
|
| Prerelease iOS | Noise `0x09` | Decrypt, canonicalize, and deliver |
|
||||||
|
| Older client | Signed directed `fileTransfer` | Verify signature and deliver |
|
||||||
|
| Forged/unsigned raw sender | Directed `fileTransfer` | Reject |
|
||||||
|
|
||||||
|
Panic wipe clears the persistent capability pins together with the rest of
|
||||||
|
the encrypted identity cache.
|
||||||
|
|
||||||
|
This migration path is mesh-Noise-only (BLE and compatible direct mesh links).
|
||||||
|
Nostr private-media transport is unchanged and remains a follow-up. Nostr
|
||||||
|
inbound paths explicitly ignore `0x21`; do not infer the mesh consent fallback
|
||||||
|
or capability-pin semantics for Nostr delivery.
|
||||||
|
|
||||||
|
## Size interoperability
|
||||||
|
|
||||||
|
iOS bounds inbound file content at 1 MiB and applies the expanded allocation
|
||||||
|
budget only after a large Noise ciphertext authenticates to `0x20` or the
|
||||||
|
temporary `0x09` alias. Ordinary Noise messages retain their 64 KiB limit.
|
||||||
|
|
||||||
|
Current Android builds cap each reassembly at 256 fragments. Depending on the
|
||||||
|
negotiated BLE packet size and routing overhead, that is roughly 110-120 KiB,
|
||||||
|
well below iOS's absolute inbound ceiling. Private-media v1 therefore runs the
|
||||||
|
actual route-aware BLE fragment planner before both encrypted and consented
|
||||||
|
legacy sends and rejects any plan above 256 fragments with a visible failure.
|
||||||
|
This fragment-count contract, rather than a guessed byte threshold, stays
|
||||||
|
correct as route overhead changes.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user