Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdf28aa5bb | ||
|
|
266827ceff | ||
|
|
97bc3f53bc | ||
|
|
9dc0ba6991 | ||
|
|
af954b05ea | ||
|
|
c8381737bb | ||
|
|
fa136d8973 | ||
|
|
7fbe6a4a7e | ||
|
|
4791114406 | ||
|
|
1a6a08f92a | ||
|
|
0b007eee1a | ||
|
|
1480b51c76 | ||
|
|
0e38ccfb3b | ||
|
|
e74f36927f | ||
|
|
cf3b85f65c | ||
|
|
8899cb7f9e | ||
|
|
22be3d6392 | ||
|
|
8a867a17a1 | ||
|
|
ed86ed1065 | ||
|
|
e74d9a7937 | ||
|
|
8289a6d05e | ||
|
|
38331e62f1 | ||
|
|
7fb1f4a219 | ||
|
|
99d1d1dccd | ||
|
|
879d8cba12 | ||
|
|
ac3a2f2d34 | ||
|
|
45650854e7 | ||
|
|
75dd83d9cc | ||
|
|
93d01b8fa6 | ||
|
|
6c0dbbbd0d | ||
|
|
09087b74cc | ||
|
|
cc76086615 | ||
|
|
638f3f5005 | ||
|
|
6091ee83ad | ||
|
|
82736c4991 | ||
|
|
707b22878d | ||
|
|
80bed1f395 | ||
|
|
4b287f7490 | ||
|
|
3caf2d7663 | ||
|
|
14d025cc2a | ||
|
|
19b28cf49d | ||
|
|
a2825ca288 | ||
|
|
3a995e20b6 | ||
|
|
6bda919dd4 | ||
|
|
4093ee6733 | ||
|
|
eb2c128cab | ||
|
|
3eb4f2bd72 | ||
|
|
193cfdc06a | ||
|
|
ffa0d7aa4f | ||
|
|
9e84f5e822 | ||
|
|
df36b19afe | ||
|
|
ab0da61533 | ||
|
|
3be8fbf1c4 | ||
|
|
764f016d17 | ||
|
|
a6cb8872fb | ||
|
|
3daf02732a | ||
|
|
0d1cdc7644 | ||
|
|
43eef0d49c | ||
|
|
bafa461f26 | ||
|
|
602d2316ef | ||
|
|
c60eff2c11 | ||
|
|
4cfcefcda6 | ||
|
|
3e137d784c | ||
|
|
452e9e74fd | ||
|
|
3afd74ffc8 | ||
|
|
951e056a55 | ||
|
|
d265cfc765 | ||
|
|
473f5c7cce | ||
|
|
b5834cfc76 | ||
|
|
097d916da7 | ||
|
|
4b000b0785 | ||
|
|
19437a0bc6 | ||
|
|
34bae4a89d | ||
|
|
0331871980 | ||
|
|
f9f6ac92b8 | ||
|
|
ca374fd823 | ||
|
|
96f2986d65 | ||
|
|
b6e45e3228 | ||
|
|
c88ab3dd05 | ||
|
|
7d83310bc2 | ||
|
|
264a95b61a | ||
|
|
8562a76367 | ||
|
|
7e86d2061f | ||
|
|
a136b5b7e9 | ||
|
|
c043cf6354 | ||
|
|
72093f9648 | ||
|
|
187a5c3195 | ||
|
|
02b2a006c5 | ||
|
|
2403a6eb90 | ||
|
|
e47c5ae7b3 | ||
|
|
c1f5868d2d | ||
|
|
0515f4c6e8 | ||
|
|
8c7e3e7b9b | ||
|
|
90a5e8ba9d | ||
|
|
6206184862 | ||
|
|
74cf0d89cc | ||
|
|
b4b6aa5ca6 | ||
|
|
1e5a52f39f | ||
|
|
3fc64f6168 | ||
|
|
9964710de2 | ||
|
|
806c420313 | ||
|
|
194dedac43 | ||
|
|
9af46a9ff8 | ||
|
|
da3fcd5a21 | ||
|
|
b282536080 | ||
|
|
e156356c71 | ||
|
|
81a6e18d04 | ||
|
|
4fbbd24021 | ||
|
|
ec54877140 | ||
|
|
293d627c28 | ||
|
|
0c3f84224c | ||
|
|
7323c0b96c | ||
|
|
7bb835ffc9 | ||
|
|
23d63ab4df | ||
|
|
fa3c74f941 | ||
|
|
9bac52051a | ||
|
|
7b9ffe464a | ||
|
|
7b940485d9 | ||
|
|
5a87ee3e62 | ||
|
|
342eabbc00 | ||
|
|
241ce2d52c | ||
|
|
18b56e7393 | ||
|
|
beb04fc887 | ||
|
|
5fcffefa28 | ||
|
|
46ae039587 | ||
|
|
917f7ebe5f | ||
|
|
b47fb736f4 | ||
|
|
ebb5bb6558 | ||
|
|
7cfdcfe174 | ||
|
|
21e0cbc607 | ||
|
|
1b439a543e | ||
|
|
d30a3b14cf | ||
|
|
d03128612d | ||
|
|
ae294aab6d | ||
|
|
07b0e146f2 | ||
|
|
cc5939cb13 | ||
|
|
cada784844 | ||
|
|
b1aeb931bc | ||
|
|
b675738664 | ||
|
|
4091a30f10 | ||
|
|
238311aefb | ||
|
|
6defae71c6 | ||
|
|
b533d9560d | ||
|
|
f41a390a94 | ||
|
|
f83316bd1f | ||
|
|
09818a02ed | ||
|
|
9cd955ae2f | ||
|
|
49b1413d85 | ||
|
|
31be6b83a7 | ||
|
|
1563209797 | ||
|
|
99896bcded | ||
|
|
4b3077169a | ||
|
|
b84c36c6fa | ||
|
|
3eac5858e4 | ||
|
|
10b7c1fd80 | ||
|
|
bf3249aef7 | ||
|
|
95a6ec7315 | ||
|
|
74864472c7 | ||
|
|
9404c03477 | ||
|
|
6faa46a22f | ||
|
|
e02f4327c0 | ||
|
|
ce31d85323 | ||
|
|
b6d8a5b758 | ||
|
|
6630f5a792 | ||
|
|
0f5299a0f5 | ||
|
|
eb3bbfd861 | ||
|
|
a37243e780 | ||
|
|
90b134186b | ||
|
|
3c7e14f49d | ||
|
|
31275856dd | ||
|
|
b6cf44a824 | ||
|
|
b6ae08be60 | ||
|
|
d469704c34 | ||
|
|
c975abf2ff | ||
|
|
aff700a15e | ||
|
|
869d766f8d | ||
|
|
1b4f120014 | ||
|
|
bc312e4aef | ||
|
|
6e7509f2be | ||
|
|
ca06f4d51d | ||
|
|
04f57e8713 | ||
|
|
59c3c4e236 | ||
|
|
e887e04f40 | ||
|
|
151b68a497 | ||
|
|
b4a3ee5777 | ||
|
|
8d19d6d62c | ||
|
|
84fd92ef4b | ||
|
|
f71bd506fd | ||
|
|
ddd7ef5668 | ||
|
|
548a20e77d | ||
|
|
6e231d10c5 | ||
|
|
4c9f6e689e | ||
|
|
7e73b65240 | ||
|
|
f5e5f7b98e | ||
|
|
b15d92ebb5 | ||
|
|
6efe9d02fb | ||
|
|
5a66f03400 | ||
|
|
a221b22691 | ||
|
|
4b38e9a23c | ||
|
|
2686d9c82c | ||
|
|
832e1f006c | ||
|
|
090c4cd919 | ||
|
|
37c2b3a8c2 | ||
|
|
6f1c879237 | ||
|
|
81f5101390 | ||
|
|
d56674706d | ||
|
|
33bcfa3147 | ||
|
|
3a54160793 | ||
|
|
5d2745eae6 | ||
|
|
5138ce4a33 | ||
|
|
07a4997192 | ||
|
|
2d703f4dd9 | ||
|
|
56b9457fcf | ||
|
|
9e57bce5c3 | ||
|
|
6eef030386 | ||
|
|
9d8754099d | ||
|
|
7fae4e71fb | ||
|
|
76d8b3fa7f | ||
|
|
b8a8b940b7 | ||
|
|
7a5ab52f4c | ||
|
|
d2edb651c1 | ||
|
|
858e878959 | ||
|
|
fa0a15cbcf | ||
|
|
3e680f40bf | ||
|
|
ec3c16176e | ||
|
|
2a8de7e048 | ||
|
|
e9dd30ccbf | ||
|
|
af28faa91d | ||
|
|
b33fe8086d | ||
|
|
5495874b5a | ||
|
|
0fca551966 | ||
|
|
0492480598 | ||
|
|
86f46c8b90 | ||
|
|
7057fe75b0 | ||
|
|
873788b24f | ||
|
|
b6dd31b285 | ||
|
|
ff7e5a2c2c | ||
|
|
708a5e33cd | ||
|
|
f893f0605a | ||
|
|
ce33132a0f | ||
|
|
c538837300 | ||
|
|
6888bfa351 | ||
|
|
2fed4b7c31 | ||
|
|
b956e407ff | ||
|
|
477cc7fa05 | ||
|
|
dffce9d63f | ||
|
|
792eaa3166 | ||
|
|
6ff9b59ff2 | ||
|
|
bc04cd0bde | ||
|
|
3fe417395e | ||
|
|
e35bd57c6e | ||
|
|
5aefb05a8b | ||
|
|
e4cbf382ce | ||
|
|
7609c6eeba | ||
|
|
f37acf7e4b | ||
|
|
cf528b0daf | ||
|
|
209ccb4ade | ||
|
|
1876e85f8b | ||
|
|
109b7d0e03 | ||
|
|
09a319fb0f | ||
|
|
97ca55cc54 | ||
|
|
394836f247 | ||
|
|
97fc21c23f | ||
|
|
79bd4af912 | ||
|
|
96c78ef5a2 | ||
|
|
01256f3041 | ||
|
|
2edbe2bbbd | ||
|
|
a91978c10e | ||
|
|
14c4e586fc | ||
|
|
83779240ae | ||
|
|
5034732515 | ||
|
|
b6ce4fae43 | ||
|
|
98fa02cc16 | ||
|
|
0812cafd55 | ||
|
|
450955525c | ||
|
|
475bc70c71 | ||
|
|
af6136c01c | ||
|
|
b839ce5f6c | ||
|
|
0776c9813c | ||
|
|
0dd999af6b | ||
|
|
c3a1af7023 | ||
|
|
880813f256 | ||
|
|
64fb634166 | ||
|
|
13b19fb8eb | ||
|
|
d4967ae9c3 | ||
|
|
435744a977 | ||
|
|
70caa9e24a | ||
|
|
5084f87fe5 | ||
|
|
8f56e4f0fb | ||
|
|
aca44f9f55 | ||
|
|
3f00cf9467 | ||
|
|
40e54a5120 | ||
|
|
7040d9ecb0 | ||
|
|
88bafb41cc | ||
|
|
fb43a8b0f5 | ||
|
|
eb35608fa1 | ||
|
|
b81ae0b4c0 | ||
|
|
b6d42261d0 | ||
|
|
3d914dcf46 | ||
|
|
b3ec5eeda0 | ||
|
|
47d75ab9d8 | ||
|
|
3479c7d5df | ||
|
|
8a0727fcf7 | ||
|
|
6588861e34 | ||
|
|
a1647901e5 | ||
|
|
23249f3e41 | ||
|
|
ad4103bacc | ||
|
|
e3149fa098 | ||
|
|
987ba2e694 | ||
|
|
615273a63e | ||
|
|
4563c22d2d | ||
|
|
9f74266527 | ||
|
|
239064e4eb | ||
|
|
d371131ad5 | ||
|
|
d994ccf012 | ||
|
|
302c741d58 | ||
|
|
7ec84857eb | ||
|
|
68482c67d7 | ||
|
|
551a843691 | ||
|
|
fbc15ea08f | ||
|
|
0f23ed0a99 | ||
|
|
c583949031 | ||
|
|
d75700fa2b | ||
|
|
7149182c56 | ||
|
|
13b58aeaa9 | ||
|
|
b5b05977fb | ||
|
|
af6703cf24 | ||
|
|
eb13eec4c5 | ||
|
|
81c90b2924 | ||
|
|
8c368233c4 | ||
|
|
13ebaad42f | ||
|
|
10e3f574ab | ||
|
|
5f44c56a90 | ||
|
|
01ec4573f8 | ||
|
|
f86ae5e2ea | ||
|
|
2673d28686 | ||
|
|
03c357f048 | ||
|
|
64f91bb1d6 | ||
|
|
9ecda048e7 | ||
|
|
bdc31d3be3 | ||
|
|
6846b19d83 | ||
|
|
524a7e916b | ||
|
|
f2473f857b | ||
|
|
8cfee095d3 | ||
|
|
e4ec2ef3fe | ||
|
|
bd11940151 | ||
|
|
90273cee2d | ||
|
|
faae625e53 | ||
|
|
3a35b3acc2 | ||
|
|
787386c66d | ||
|
|
aeec15f054 | ||
|
|
f004f3e7ea | ||
|
|
8d938e8518 | ||
|
|
ea72212f66 | ||
|
|
3183703a63 | ||
|
|
2ffa709cca | ||
|
|
bf712a0610 | ||
|
|
78fb3f1bf6 | ||
|
|
f5af00be88 | ||
|
|
b2214e30f5 | ||
|
|
85063e9359 | ||
|
|
f67f728d79 | ||
|
|
b873b19104 | ||
|
|
0f7bcf17ff | ||
|
|
da2a12296e | ||
|
|
56bd100944 | ||
|
|
eb5bc96a3c | ||
|
|
d01538a2ea | ||
|
|
9abfab3248 | ||
|
|
0ae09f73d8 | ||
|
|
56dd12f3b1 | ||
|
|
f5caa1751a | ||
|
|
de906cb97c | ||
|
|
a41ec65f58 | ||
|
|
1fd2da18f5 | ||
|
|
c49a1b264e | ||
|
|
10c0391eaf | ||
|
|
3a94b57341 | ||
|
|
f8f780d2d6 | ||
|
|
c837afb818 | ||
|
|
47ef82f01a | ||
|
|
d1e5ce21a7 | ||
|
|
f2c1bb2131 | ||
|
|
221819b591 | ||
|
|
4a21ab0531 | ||
|
|
50ae8da5f9 | ||
|
|
54bb812469 | ||
|
|
482fca81ef | ||
|
|
b2e7d2d26e | ||
|
|
6a2832d22b | ||
|
|
56e7324069 | ||
|
|
1733dda6cd | ||
|
|
00ff5fd31c | ||
|
|
f684c452b2 | ||
|
|
9cbdb0a764 | ||
|
|
d1682db79b | ||
|
|
6a6504c6f2 | ||
|
|
ea8d51a36b | ||
|
|
347ce5ece4 | ||
|
|
7c4bde59b9 | ||
|
|
2ac01db9c4 | ||
|
|
42cdc4c123 | ||
|
|
fb94e799a5 | ||
|
|
04671caeb8 | ||
|
|
a485335649 | ||
|
|
de2b5ed142 | ||
|
|
57cc9028cb | ||
|
|
b0a50c663f | ||
|
|
0a98844c1a | ||
|
|
efb9a5070d | ||
|
|
2870acdcc7 | ||
|
|
920dc31795 | ||
|
|
bb3d99bdca | ||
|
|
b01cac4649 | ||
|
|
4b0634d1d0 | ||
|
|
97cbe37f09 | ||
|
|
b5d6e4eeb5 | ||
|
|
a1edf29bd3 | ||
|
|
5f402698a1 | ||
|
|
1e997e1387 | ||
|
|
e602024617 | ||
|
|
deb464f5d8 | ||
|
|
e5a415d885 | ||
|
|
b5382b129e | ||
|
|
5d6aecfc83 | ||
|
|
5ca9222fc2 | ||
|
|
ee16ff5ff4 | ||
|
|
2f83433247 | ||
|
|
e72fe50ffa | ||
|
|
56f1c37129 | ||
|
|
2ade3a3300 | ||
|
|
5f44af19da | ||
|
|
6e1fb15edf | ||
|
|
9166c9e28b | ||
|
|
0ce68bc762 | ||
|
|
5273f13512 | ||
|
|
e79bcf531b | ||
|
|
3e5043f875 | ||
|
|
11cd5527ed | ||
|
|
cf1bfdac6b | ||
|
|
b63a595b04 | ||
|
|
d7b7f1f673 | ||
|
|
7aa3622349 | ||
|
|
96c6fc0c0d | ||
|
|
a7d5b2d7d9 | ||
|
|
680a390b2d | ||
|
|
60b0deee7b | ||
|
|
2f7c0aaaf7 |
@@ -0,0 +1,20 @@
|
||||
# Prevent Github Languages stats skewing:
|
||||
|
||||
# Binaries and assets
|
||||
**/*.xcframework/** linguist-vendored
|
||||
**/*.xcassets/** linguist-vendored
|
||||
|
||||
# Generated files
|
||||
**/*.pbxproj linguist-generated
|
||||
**/*.storyboard linguist-generated
|
||||
Package.resolved linguist-generated
|
||||
|
||||
# Downloaded CSVs
|
||||
relays/online_relays_gps.csv linguist-vendored
|
||||
|
||||
# Docs
|
||||
**/*.md linguist-documentation
|
||||
|
||||
# Configs
|
||||
Configs/*.xcconfig linguist-documentation
|
||||
**/*.plist linguist-documentation
|
||||
@@ -0,0 +1,85 @@
|
||||
name: Arti Binary Provenance
|
||||
|
||||
# The Arti xcframework is a vendored binary; these checks turn the policy in
|
||||
# docs/ARTI-BINARY-PROVENANCE.md into an enforced gate:
|
||||
# 1. The checked-in binary must match the hash manifest in the provenance doc.
|
||||
# 2. A PR that changes the binary must also change at least one provenance
|
||||
# input (Rust source, lockfile, build script, or the doc itself).
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "localPackages/Arti/**"
|
||||
- "docs/ARTI-BINARY-PROVENANCE.md"
|
||||
pull_request:
|
||||
paths:
|
||||
- "localPackages/Arti/**"
|
||||
- "docs/ARTI-BINARY-PROVENANCE.md"
|
||||
|
||||
jobs:
|
||||
verify-hashes:
|
||||
name: Verify xcframework hashes against provenance doc
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Compare artifact hashes with manifest
|
||||
run: |
|
||||
set -euo pipefail
|
||||
doc="docs/ARTI-BINARY-PROVENANCE.md"
|
||||
|
||||
# Extract the manifest: lines of "<sha256> <path>" from the doc.
|
||||
grep -E '^[0-9a-f]{64} localPackages/Arti/Frameworks/arti\.xcframework/' "$doc" \
|
||||
| sort -k2 > expected.txt
|
||||
|
||||
if [ ! -s expected.txt ]; then
|
||||
echo "::error::No hash manifest found in $doc"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Hash the same file set the doc documents.
|
||||
find localPackages/Arti/Frameworks/arti.xcframework -maxdepth 3 -type f -print0 \
|
||||
| sort -z | xargs -0 sha256sum | sed 's/ \.\// /' | sort -k2 > actual.txt
|
||||
|
||||
if ! diff -u expected.txt actual.txt; then
|
||||
echo "::error::Checked-in arti.xcframework does not match the manifest in $doc. If the binary change is intentional, rebuild per the doc and update the manifest in the same PR."
|
||||
exit 1
|
||||
fi
|
||||
echo "All $(wc -l < actual.txt) artifact hashes match the provenance manifest."
|
||||
|
||||
require-provenance-evidence:
|
||||
name: Binary changes must ship with provenance inputs
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check changed files
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base="origin/${{ github.base_ref }}"
|
||||
git fetch --no-tags --depth=1 origin "${{ github.base_ref }}"
|
||||
changed=$(git diff --name-only "$base"...HEAD)
|
||||
echo "Changed files:"
|
||||
echo "$changed"
|
||||
|
||||
if ! echo "$changed" | grep -q '^localPackages/Arti/Frameworks/arti\.xcframework/'; then
|
||||
echo "No binary artifact changes; nothing to verify."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if echo "$changed" | grep -Eq '^(localPackages/Arti/(Cargo\.(toml|lock)|build-ios\.sh|arti-bitchat/)|docs/ARTI-BINARY-PROVENANCE\.md)'; then
|
||||
echo "Binary change is accompanied by provenance inputs."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::error::arti.xcframework changed without matching source, lockfile, build-script, or provenance-doc changes. See docs/ARTI-BINARY-PROVENANCE.md (\"Do not accept an xcframework-only update\")."
|
||||
exit 1
|
||||
@@ -0,0 +1,42 @@
|
||||
name: Fetch GeoRelays Data
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * 0'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
update-relay-data:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Fetch GeoRelays
|
||||
run: |
|
||||
wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
||||
mv nostr_relays.csv ./relays/online_relays_gps.csv
|
||||
|
||||
- name: Check for changes
|
||||
id: git-check
|
||||
run: |
|
||||
git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Commit and push changes
|
||||
if: steps.git-check.outputs.changes == 'true'
|
||||
run: |
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add relays/online_relays_gps.csv
|
||||
git commit -m "Automated update of relay data - $(date -u)"
|
||||
git push
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,142 @@
|
||||
name: Build & Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run Swift Tests (${{ matrix.name }})
|
||||
runs-on: macos-latest
|
||||
# A hung test must fail fast, not hold a runner for GitHub's 360-minute
|
||||
# default (observed: intermittent app-suite hangs starving the queue).
|
||||
timeout-minutes: 15
|
||||
|
||||
strategy:
|
||||
fail-fast: false # Don't cancel other matrix jobs when one fails
|
||||
matrix:
|
||||
include:
|
||||
- name: app
|
||||
path: .
|
||||
- name: BitLogger
|
||||
path: localPackages/BitLogger
|
||||
- name: BitFoundation
|
||||
path: localPackages/BitFoundation
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Swift
|
||||
uses: swift-actions/setup-swift@v2
|
||||
|
||||
- name: Cache build artifacts
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ matrix.path }}/.build
|
||||
key: ${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }}
|
||||
${{ runner.os }}-${{ matrix.name }}-
|
||||
|
||||
- name: Build tests
|
||||
# Built separately so the hang watchdog below times only test
|
||||
# execution: a cold-cache coverage build on a slow runner can
|
||||
# legitimately take several minutes, and is already bounded by the
|
||||
# 15-minute job timeout.
|
||||
run: swift build --build-tests --enable-code-coverage --package-path ${{ matrix.path }}
|
||||
|
||||
- name: Run Tests
|
||||
# Perf benchmarks are excluded here and run in their own serial step
|
||||
# below: measuring while parallel test processes contend for cores
|
||||
# produces noisy numbers, and the XCTest measure machinery has hung
|
||||
# intermittently under parallel workers on loaded runners. Excluded
|
||||
# via --skip (not just the env guard): every app run since the
|
||||
# baselines landed timed out at the 15-minute job limit with the
|
||||
# baseline tests dispatched into the parallel phase.
|
||||
#
|
||||
# The watchdog samples any still-running test processes after 5
|
||||
# minutes (the suite passes in seconds when healthy; the build is
|
||||
# done by this step) and kills the run, so a hang fails fast with
|
||||
# stacks in the log instead of a silent timeout.
|
||||
env:
|
||||
BITCHAT_SKIP_PERF_BASELINES: "1"
|
||||
run: |
|
||||
swift test --skip-build --parallel --quiet --enable-code-coverage \
|
||||
--skip PerformanceBaselineTests \
|
||||
--package-path ${{ matrix.path }} &
|
||||
test_pid=$!
|
||||
(
|
||||
sleep 300
|
||||
if kill -0 "$test_pid" 2>/dev/null; then
|
||||
echo "::group::Tests still running after 5 minutes — sampling before kill"
|
||||
for pid in $(pgrep -if 'swiftpm-testing|xctest|PackageTests' || true); do
|
||||
echo "--- sample of pid $pid ---"
|
||||
sample "$pid" 5 2>/dev/null || true
|
||||
done
|
||||
echo "::endgroup::"
|
||||
pkill -KILL -P "$test_pid" 2>/dev/null || true
|
||||
kill -KILL "$test_pid" 2>/dev/null || true
|
||||
fi
|
||||
) &
|
||||
watchdog_pid=$!
|
||||
wait "$test_pid" && status=0 || status=$?
|
||||
kill "$watchdog_pid" 2>/dev/null || true
|
||||
exit "$status"
|
||||
|
||||
# Benchmarks run serially on an otherwise idle runner for stable
|
||||
# numbers; BITCHAT_PERF_LOG captures the PERF[...] lines for the gate.
|
||||
- name: Run performance benchmarks (serial)
|
||||
if: matrix.name == 'app'
|
||||
timeout-minutes: 6
|
||||
env:
|
||||
BITCHAT_PERF_LOG: ${{ github.workspace }}/perf-output.log
|
||||
run: swift test --quiet --filter PerformanceBaselineTests
|
||||
|
||||
# Order-of-magnitude performance regression gate. Floors are deliberately
|
||||
# generous (see bitchatTests/Performance/perf-floors.json) so this
|
||||
# catches algorithmic regressions, never runner variance.
|
||||
- name: Performance floor gate
|
||||
if: matrix.name == 'app'
|
||||
run: ./scripts/check-perf-floors.sh perf-output.log
|
||||
|
||||
# Informational only: surfaces per-file and total line coverage in the
|
||||
# job log so coverage trends are visible on every PR. No thresholds —
|
||||
# this must never be the reason a build goes red.
|
||||
- name: Coverage summary
|
||||
run: |
|
||||
BIN_PATH=$(swift build --show-bin-path --package-path ${{ matrix.path }})
|
||||
PROF="$BIN_PATH/codecov/default.profdata"
|
||||
XCTEST=$(find "$BIN_PATH" -maxdepth 1 -name '*.xctest' | head -1)
|
||||
BINARY="$XCTEST/Contents/MacOS/$(basename "$XCTEST" .xctest)"
|
||||
if [ -f "$PROF" ] && [ -f "$BINARY" ]; then
|
||||
xcrun llvm-cov report "$BINARY" -instr-profile "$PROF" \
|
||||
-ignore-filename-regex='(Tests|\.build|checkouts|Mocks|_PreviewHelpers)' || true
|
||||
else
|
||||
echo "No coverage data found; skipping summary."
|
||||
fi
|
||||
|
||||
# SPM tests above only compile the macOS slice; this job covers the
|
||||
# iOS-conditional code paths (UIKit, CoreBluetooth restoration, etc.).
|
||||
ios-build:
|
||||
name: Build iOS app (simulator)
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Build iOS (simulator, no signing)
|
||||
# arm64 only: the vendored arti.xcframework has no x86_64 simulator slice.
|
||||
run: |
|
||||
set -o pipefail
|
||||
xcodebuild -project bitchat.xcodeproj \
|
||||
-scheme "bitchat (iOS)" \
|
||||
-sdk iphonesimulator \
|
||||
-destination 'generic/platform=iOS Simulator' \
|
||||
ARCHS=arm64 \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
build
|
||||
@@ -8,9 +8,7 @@ plans/
|
||||
## AI
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
|
||||
## User settings
|
||||
xcuserdata/
|
||||
.claude/
|
||||
|
||||
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
|
||||
*.xcscmblueprint
|
||||
@@ -57,7 +55,8 @@ iOSInjectionProject/
|
||||
|
||||
## Xcode project
|
||||
*.xcodeproj/project.xcworkspace/
|
||||
*.xcodeproj/xcshareddata/
|
||||
## Xcode User settings
|
||||
xcuserdata/
|
||||
|
||||
## Python
|
||||
__pycache__/
|
||||
@@ -68,6 +67,9 @@ __pycache__/
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
## Cache
|
||||
.cache/
|
||||
|
||||
# Local build results
|
||||
.Result*/
|
||||
.Result*.xcresult/
|
||||
@@ -75,3 +77,6 @@ TestResult.xcresult/
|
||||
*.xcresult/
|
||||
build.log
|
||||
*.log
|
||||
|
||||
# Local configs
|
||||
Local.xcconfig
|
||||
|
||||
@@ -37,7 +37,7 @@ This three-message pattern provides:
|
||||
#### NoiseEncryptionService
|
||||
The main service managing all Noise operations:
|
||||
```swift
|
||||
class NoiseEncryptionService {
|
||||
final class NoiseEncryptionService {
|
||||
private let staticIdentityKey: Curve25519.KeyAgreement.PrivateKey
|
||||
private let sessionManager: NoiseSessionManager
|
||||
private let channelEncryption = NoiseChannelEncryption()
|
||||
@@ -47,7 +47,7 @@ class NoiseEncryptionService {
|
||||
#### NoiseSession
|
||||
Individual session state for each peer:
|
||||
```swift
|
||||
class NoiseSession {
|
||||
final class NoiseSession {
|
||||
private var handshakeState: NoiseHandshakeState?
|
||||
private var sendCipher: NoiseCipherState?
|
||||
private var receiveCipher: NoiseCipherState?
|
||||
@@ -58,7 +58,7 @@ class NoiseSession {
|
||||
#### NoiseSessionManager
|
||||
Thread-safe session management:
|
||||
```swift
|
||||
class NoiseSessionManager {
|
||||
final class NoiseSessionManager {
|
||||
private var sessions: [String: NoiseSession] = [:]
|
||||
private let sessionsQueue = DispatchQueue(label: "noise.sessions", attributes: .concurrent)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
#include "Release.xcconfig"
|
||||
|
||||
// Optional include of local configs
|
||||
#include? "Local.xcconfig"
|
||||
@@ -0,0 +1,5 @@
|
||||
// Your Apple Developer Team ID - https://stackoverflow.com/a/18727947
|
||||
DEVELOPMENT_TEAM = ABC123
|
||||
|
||||
// Unique bundle id to be able to register and run locally
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.$(DEVELOPMENT_TEAM)
|
||||
@@ -0,0 +1,12 @@
|
||||
MARKETING_VERSION = 1.5.3
|
||||
CURRENT_PROJECT_VERSION = 1
|
||||
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0
|
||||
SWIFT_VERSION = 5.0
|
||||
|
||||
DEVELOPMENT_TEAM = L3N5LHJD5Y
|
||||
CODE_SIGN_STYLE = Automatic
|
||||
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat
|
||||
APP_GROUP_ID = group.chat.bitchat
|
||||
@@ -14,16 +14,16 @@ default:
|
||||
# Check prerequisites
|
||||
check:
|
||||
@echo "Checking prerequisites..."
|
||||
@command -v xcodegen >/dev/null 2>&1 || (echo "❌ XcodeGen not found. Install with: brew install xcodegen" && exit 1)
|
||||
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ Xcode not found. Install Xcode from App Store" && exit 1)
|
||||
@security find-identity -v -p codesigning | grep -q "Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
|
||||
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install Xcode from App Store" && exit 1)
|
||||
@xcode-select -p | grep -q "Xcode.app" || (echo "❌ Full Xcode required, not just command line tools. Install from App Store and run:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
|
||||
@test -d "/Applications/Xcode.app" || (echo "❌ Xcode.app not found in Applications folder. Install from App Store" && exit 1)
|
||||
@xcodebuild -version >/dev/null 2>&1 || (echo "❌ Xcode not properly configured. Try:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
|
||||
@security find-identity -v -p codesigning | grep -q "Apple Development\|Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
|
||||
@echo "✅ All prerequisites met"
|
||||
|
||||
# Backup original files
|
||||
backup:
|
||||
@echo "Backing up original project configuration..."
|
||||
@cp project.yml project.yml.backup 2>/dev/null || true
|
||||
@# Backup other files that get modified by xcodegen
|
||||
@if [ -f bitchat.xcodeproj/project.pbxproj ]; then cp bitchat.xcodeproj/project.pbxproj bitchat.xcodeproj/project.pbxproj.backup; fi
|
||||
@if [ -f bitchat/Info.plist ]; then cp bitchat/Info.plist bitchat/Info.plist.backup; fi
|
||||
|
||||
@@ -44,13 +44,8 @@ patch-for-macos: backup
|
||||
@# Move iOS-specific files out of the way temporarily
|
||||
@if [ -f bitchat/LaunchScreen.storyboard ]; then mv bitchat/LaunchScreen.storyboard bitchat/LaunchScreen.storyboard.ios; fi
|
||||
|
||||
# Generate Xcode project with patches
|
||||
generate: patch-for-macos
|
||||
@echo "Generating Xcode project..."
|
||||
@xcodegen generate
|
||||
|
||||
# Build the macOS app
|
||||
build: check generate
|
||||
build: #check generate
|
||||
@echo "Building BitChat for macOS..."
|
||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
||||
|
||||
@@ -75,9 +70,7 @@ clean: restore
|
||||
# Quick run without cleaning (for development)
|
||||
dev-run: check
|
||||
@echo "Quick development build..."
|
||||
@if [ ! -f project.yml.backup ]; then just patch-for-macos; fi
|
||||
@xcodegen generate
|
||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
||||
@find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}"
|
||||
|
||||
# Show app info
|
||||
@@ -106,11 +99,9 @@ nuke:
|
||||
@echo "🧨 Nuclear clean - removing all build artifacts and backups..."
|
||||
@rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true
|
||||
@rm -rf bitchat.xcodeproj 2>/dev/null || true
|
||||
@rm -f project.yml.backup 2>/dev/null || true
|
||||
@rm -f project-macos.yml 2>/dev/null || true
|
||||
@rm -f bitchat.xcodeproj/project.pbxproj.backup 2>/dev/null || true
|
||||
@rm -f bitchat/Info.plist.backup 2>/dev/null || true
|
||||
@# Restore iOS-specific files if they were moved
|
||||
@if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi
|
||||
@git checkout -- project.yml bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Not a git repo or no changes to restore"
|
||||
@git checkout bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Not a git repo or no changes to restore"
|
||||
@echo "✅ Nuclear clean complete"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# bitchat Privacy Policy
|
||||
|
||||
*Last updated: January 2025*
|
||||
*Last updated: June 2026*
|
||||
|
||||
## Our Commitment
|
||||
|
||||
@@ -9,7 +9,7 @@ bitchat is designed with privacy as its foundation. We believe private communica
|
||||
## Summary
|
||||
|
||||
- **No personal data collection** - We don't collect names, emails, or phone numbers
|
||||
- **No servers** - Everything happens on your device and through peer-to-peer connections
|
||||
- **No accounts or company servers** - Mesh chat works peer-to-peer; optional Nostr features use public or user-selected relays
|
||||
- **No tracking** - We have no analytics, telemetry, or user tracking
|
||||
- **Open source** - You can verify these claims by reading our code
|
||||
|
||||
@@ -17,11 +17,11 @@ bitchat is designed with privacy as its foundation. We believe private communica
|
||||
|
||||
### On Your Device Only
|
||||
|
||||
1. **Identity Key**
|
||||
- A cryptographic key generated on first launch
|
||||
1. **Identity Keys**
|
||||
- Cryptographic private keys generated on first launch or when optional Nostr identities are created
|
||||
- Stored locally in your device's secure storage
|
||||
- Allows you to maintain "favorite" relationships across app restarts
|
||||
- Never leaves your device
|
||||
- Private keys never leave your device; public keys are shared when needed for messaging
|
||||
|
||||
2. **Nickname**
|
||||
- The display name you choose (or auto-generated)
|
||||
@@ -38,12 +38,19 @@ bitchat is designed with privacy as its foundation. We believe private communica
|
||||
- Stored only on your device
|
||||
- Allows you to recognize these peers in future sessions
|
||||
|
||||
5. **Optional Location Channel State**
|
||||
- Your selected geohash channel, bookmarked geohashes, teleport flags, and bookmark display names
|
||||
- Stored locally on your device so the location-channel UI can restore your choices
|
||||
- Per-geohash Nostr identities are derived locally from a device seed stored in secure storage
|
||||
- Exact latitude and longitude are not persisted by bitchat
|
||||
|
||||
### Temporary Session Data
|
||||
|
||||
During each session, bitchat temporarily maintains:
|
||||
- Active peer connections (forgotten when app closes)
|
||||
- Routing information for message delivery
|
||||
- Cached messages for offline peers (12 hours max)
|
||||
- Your current location while optional location channels are enabled, used locally to compute geohash channels and friendly place names
|
||||
|
||||
## What Information is Shared
|
||||
|
||||
@@ -62,13 +69,21 @@ When you join a password-protected room:
|
||||
- Your nickname appears in the member list
|
||||
- Room owners can see you've joined
|
||||
|
||||
### With Nostr Relays (Optional Features)
|
||||
|
||||
If you enable Nostr-backed features:
|
||||
- Private fallback messages to mutual favorites are sent as encrypted NIP-17 gift wraps. Relays can see event metadata, but not message content.
|
||||
- Public location-channel messages, location notes, and presence are scoped with geohash tags. Relays and other participants can see the geohash tag, event kind, timestamp, and public key used for that geohash.
|
||||
- Exact GPS coordinates are not included in Nostr events by bitchat. The geohash precision you choose can still reveal an approximate area, from region-level to building-level.
|
||||
- Automatic presence heartbeats are limited to low-precision geohashes (region, province, and city). More precise geohash posts happen only when you use those channels or location notes.
|
||||
|
||||
## What We DON'T Do
|
||||
|
||||
bitchat **never**:
|
||||
- Collects personal information
|
||||
- Tracks your location
|
||||
- Stores data on servers
|
||||
- Shares data with third parties
|
||||
- Sells or shares your exact GPS location
|
||||
- Stores data on servers we operate
|
||||
- Sells your data to advertisers or data brokers
|
||||
- Uses analytics or telemetry
|
||||
- Creates user profiles
|
||||
- Requires registration
|
||||
@@ -84,19 +99,27 @@ All private messages use end-to-end encryption:
|
||||
## Your Rights
|
||||
|
||||
You have complete control:
|
||||
- **Delete Everything**: Triple-tap the logo to instantly wipe all data
|
||||
- **Leave Anytime**: Close the app and your presence disappears
|
||||
- **No Account**: Nothing to delete from servers because there are none
|
||||
- **Portability**: Your data never leaves your device unless you export it
|
||||
- **Delete Local State**: Triple-tap the logo to instantly wipe local keys, sessions, caches, and preferences
|
||||
- **Leave Anytime**: Close the app and local presence stops; relay-backed presence ages out
|
||||
- **No Account**: No account record exists for you to delete from us
|
||||
- **Portability**: Your local state stays on your device unless you send messages, use optional relay-backed features, or export it
|
||||
|
||||
## Bluetooth & Permissions
|
||||
|
||||
bitchat requires Bluetooth permission to function:
|
||||
- Used only for peer-to-peer communication
|
||||
- No location data is accessed or stored
|
||||
- Bluetooth is not used for tracking
|
||||
- You can revoke this permission at any time in system settings
|
||||
|
||||
## Location Permission
|
||||
|
||||
Location permission is optional and is used only for location channels:
|
||||
- Used to compute local geohash channels and display names
|
||||
- Requested as when-in-use permission
|
||||
- Exact coordinates are not shared in messages or stored by bitchat
|
||||
- Selected and bookmarked geohashes may persist locally until you remove them, use panic wipe, or delete the app
|
||||
- You can revoke this permission at any time in system settings
|
||||
|
||||
## Children's Privacy
|
||||
|
||||
bitchat does not knowingly collect information from children. The app has no age verification because it collects no personal information from anyone.
|
||||
@@ -106,12 +129,15 @@ bitchat does not knowingly collect information from children. The app has no age
|
||||
- **Messages**: Deleted from memory when app closes (unless room retention is enabled)
|
||||
- **Identity Key**: Persists until you delete the app
|
||||
- **Favorites**: Persist until you remove them or delete the app
|
||||
- **Location channel choices**: Selected/bookmarked geohashes persist locally until removed, panic-wiped, or the app is deleted
|
||||
- **Nostr relay data**: Public geohash events and encrypted gift wraps may be retained by relays according to each relay's policy
|
||||
- **Everything Else**: Exists only during active sessions
|
||||
|
||||
## Security Measures
|
||||
|
||||
- All communication is encrypted
|
||||
- No data transmitted to servers (there are none)
|
||||
- No accounts or company servers
|
||||
- Optional Nostr relays receive only the events needed for Nostr-backed private fallback or public location channels
|
||||
- Open source code for public audit
|
||||
- Regular security updates
|
||||
- Cryptographic signatures prevent tampering
|
||||
@@ -121,7 +147,7 @@ bitchat does not knowingly collect information from children. The app has no age
|
||||
If we update this policy:
|
||||
- The "Last updated" date will change
|
||||
- The updated policy will be included in the app
|
||||
- No retroactive changes can affect data (since we don't collect any)
|
||||
- No retroactive changes can make us collect data already held only in your app
|
||||
|
||||
## Contact
|
||||
|
||||
@@ -132,7 +158,7 @@ bitchat is an open source project. For privacy questions:
|
||||
|
||||
## Philosophy
|
||||
|
||||
Privacy isn't just a feature—it's the entire point. bitchat proves that modern communication doesn't require surrendering your privacy. No accounts, no servers, no surveillance. Just people talking freely.
|
||||
Privacy isn't just a feature—it's the entire point. bitchat proves that modern communication doesn't require surrendering your privacy. No accounts, no company servers, no analytics. Just people talking freely.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "bitchat",
|
||||
defaultLocalization: "en",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
.macOS(.v13)
|
||||
@@ -15,22 +16,55 @@ let package = Package(
|
||||
),
|
||||
],
|
||||
dependencies:[
|
||||
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1"),
|
||||
.package(path: "localPackages/Arti"),
|
||||
.package(path: "localPackages/BitFoundation"),
|
||||
.package(path: "localPackages/BitLogger"),
|
||||
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "bitchat",
|
||||
dependencies: [
|
||||
.product(name: "P256K", package: "swift-secp256k1")
|
||||
.product(name: "P256K", package: "swift-secp256k1"),
|
||||
.product(name: "BitFoundation", package: "BitFoundation"),
|
||||
.product(name: "BitLogger", package: "BitLogger"),
|
||||
.product(name: "Tor", package: "Arti")
|
||||
],
|
||||
path: "bitchat",
|
||||
exclude: [
|
||||
"Info.plist",
|
||||
"Assets.xcassets",
|
||||
"_PreviewHelpers/PreviewAssets.xcassets",
|
||||
"bitchat.entitlements",
|
||||
"bitchat-macOS.entitlements",
|
||||
"LaunchScreen.storyboard"
|
||||
"LaunchScreen.storyboard",
|
||||
"ViewModels/Extensions/README.md"
|
||||
],
|
||||
resources: [
|
||||
.process("Localizable.xcstrings")
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "bitchatTests",
|
||||
dependencies: [
|
||||
"bitchat",
|
||||
.product(name: "BitFoundation", package: "BitFoundation")
|
||||
],
|
||||
path: "bitchatTests",
|
||||
exclude: [
|
||||
"Info.plist",
|
||||
"README.md",
|
||||
// CI perf gate data (read by scripts/check-perf-floors.sh),
|
||||
// not a test resource.
|
||||
"Performance/perf-floors.json"
|
||||
],
|
||||
resources: [
|
||||
.process("Localization"),
|
||||
// Only the vector fixture: declaring the whole "Noise"
|
||||
// directory would claim its .swift test files as resources
|
||||
// and silently drop them from compilation.
|
||||
.process("Noise/NoiseTestVectors.json")
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
@@ -2,87 +2,121 @@
|
||||
|
||||
## bitchat
|
||||
|
||||
A decentralized peer-to-peer messaging app that works over Bluetooth mesh networks. No internet required, no servers, no phone numbers. It's the side-groupchat.
|
||||
A decentralized peer-to-peer messaging app with dual transport architecture: local Bluetooth mesh networks for offline communication and internet-based Nostr protocol for global reach. No accounts, no phone numbers, no central servers. It's the side-groupchat.
|
||||
|
||||
[bitchat.free](http://bitchat.free)
|
||||
|
||||
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
|
||||
|
||||
> [!WARNING]
|
||||
> Private messages have not received external security review and may contain vulnerabilities. Do not use for sensitive use cases, and do not rely on its security until it has been reviewed. Now uses the [Noise Protocol](http://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
|
||||
|
||||
|
||||
## License
|
||||
|
||||
This project is released into the public domain. See the [LICENSE](LICENSE) file for details.
|
||||
|
||||
|
||||
## Features
|
||||
|
||||
- **Dual Transport Architecture**: Bluetooth mesh for offline + Nostr protocol for internet-based messaging
|
||||
- **Location-Based Channels**: Geographic chat rooms using geohash coordinates over global Nostr relays
|
||||
- **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)
|
||||
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
|
||||
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers
|
||||
- **Private Message End-to-End Encryption**: [Noise Protocol](http://noiseprotocol.org)
|
||||
- **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, NIP-17 for Nostr
|
||||
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
|
||||
- **Universal App**: Native support for iOS and macOS
|
||||
- **Emergency Wipe**: Triple-tap to instantly clear all data
|
||||
- **Performance Optimizations**: LZ4 message compression, adaptive battery modes, and optimized networking
|
||||
|
||||
|
||||
## [Technical Architecture](https://deepwiki.com/permissionlesstech/bitchat)
|
||||
|
||||
### Binary Protocol
|
||||
bitchat uses an efficient binary protocol optimized for Bluetooth LE:
|
||||
- Compact packet format with 1-byte type field
|
||||
- TTL-based message routing (max 7 hops)
|
||||
- Automatic fragmentation for large messages
|
||||
- Message deduplication via unique IDs
|
||||
BitChat uses a **hybrid messaging architecture** with two complementary transport layers:
|
||||
|
||||
### Mesh Networking
|
||||
- Each device acts as both client and peripheral
|
||||
- Automatic peer discovery and connection management
|
||||
- Adaptive duty cycling for battery optimization
|
||||
### Bluetooth Mesh Network (Offline)
|
||||
|
||||
- **Local Communication**: Direct peer-to-peer within Bluetooth range
|
||||
- **Multi-hop Relay**: Messages route through nearby devices (max 7 hops)
|
||||
- **No Internet Required**: Works completely offline in disaster scenarios
|
||||
- **Noise Protocol Encryption**: End-to-end encryption with forward secrecy
|
||||
- **Binary Protocol**: Compact packet format optimized for Bluetooth LE constraints
|
||||
- **Automatic Discovery**: Peer discovery and connection management
|
||||
- **Adaptive Power**: Battery-optimized duty cycling
|
||||
|
||||
### Nostr Protocol (Internet)
|
||||
|
||||
- **Global Reach**: Connect with users worldwide via internet relays
|
||||
- **Location Channels**: Geographic chat rooms using geohash coordinates
|
||||
- **290+ Relay Network**: Distributed across the globe for reliability
|
||||
- **NIP-17 Encryption**: Gift-wrapped private messages for internet privacy
|
||||
- **Ephemeral Keys**: Fresh cryptographic identity per geohash area
|
||||
|
||||
### Channel Types
|
||||
|
||||
#### `mesh #bluetooth`
|
||||
|
||||
- **Transport**: Bluetooth Low Energy mesh network
|
||||
- **Scope**: Local devices within multi-hop range
|
||||
- **Internet**: Not required
|
||||
- **Use Case**: Offline communication, protests, disasters, remote areas
|
||||
|
||||
#### Location Channels (`block #dr5rsj7`, `neighborhood #dr5rs`, `country #dr`)
|
||||
|
||||
- **Transport**: Nostr protocol over internet
|
||||
- **Scope**: Geographic areas defined by geohash precision
|
||||
- `block` (7 chars): City block level
|
||||
- `neighborhood` (6 chars): District/neighborhood
|
||||
- `city` (5 chars): City level
|
||||
- `province` (4 chars): State/province
|
||||
- `region` (2 chars): Country/large region
|
||||
- **Internet**: Required (connects to Nostr relays)
|
||||
- **Use Case**: Location-based community chat, local events, regional discussions
|
||||
|
||||
### Direct Message Routing
|
||||
|
||||
Private messages use **intelligent transport selection**:
|
||||
|
||||
1. **Bluetooth First** (preferred when available)
|
||||
|
||||
- Direct connection with established Noise session
|
||||
- Fastest and most private option
|
||||
|
||||
2. **Nostr Fallback** (when Bluetooth unavailable)
|
||||
|
||||
- Uses recipient's Nostr public key
|
||||
- NIP-17 gift-wrapping for privacy
|
||||
- Routes through global relay network
|
||||
|
||||
3. **Smart Queuing** (when neither available)
|
||||
- Messages queued until transport becomes available
|
||||
- Automatic delivery when connection established
|
||||
|
||||
For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.md).
|
||||
|
||||
|
||||
## Setup
|
||||
|
||||
### Option 1: Using XcodeGen (Recommended)
|
||||
### Option 1: Using Xcode
|
||||
|
||||
1. Install XcodeGen if you haven't already:
|
||||
```bash
|
||||
brew install xcodegen
|
||||
```
|
||||
|
||||
2. Generate the Xcode project:
|
||||
```bash
|
||||
cd bitchat
|
||||
xcodegen generate
|
||||
```
|
||||
|
||||
3. Open the generated project:
|
||||
```bash
|
||||
open bitchat.xcodeproj
|
||||
```
|
||||
|
||||
### Option 2: Using Swift Package Manager
|
||||
To run on a device there're a few steps to prepare the code:
|
||||
- Clone the local configs: `cp Configs/Local.xcconfig.example Configs/Local.xcconfig`
|
||||
- Add your Developer Team ID into the newly created `Configs/Local.xcconfig`
|
||||
- Bundle ID would be set to `chat.bitchat.<team_id>` (unless you set to something else)
|
||||
- Entitlements need to be updated manually (TODO: Automate):
|
||||
- Search and replace `group.chat.bitchat` with `group.<your_bundle_id>` (e.g. `group.chat.bitchat.ABC123`)
|
||||
|
||||
### Option 2: Using `just`
|
||||
|
||||
1. Open the project in Xcode:
|
||||
```bash
|
||||
cd bitchat
|
||||
open Package.swift
|
||||
brew install just
|
||||
```
|
||||
|
||||
2. Select your target device and run
|
||||
|
||||
### Option 3: Manual Xcode Project
|
||||
|
||||
1. Open Xcode and create a new iOS/macOS App
|
||||
2. Copy all Swift files from the `bitchat` directory into your project
|
||||
3. Update Info.plist with Bluetooth permissions
|
||||
4. Set deployment target to iOS 16.0 / macOS 13.0
|
||||
|
||||
### Option 4: just
|
||||
|
||||
Want to try this on macos: `just run` will set it up and run from source.
|
||||
Want to try this on macos: `just run` will set it up and run from source.
|
||||
Run `just clean` afterwards to restore things to original state for mobile app building and development.
|
||||
|
||||
## Localization
|
||||
|
||||
- Base app resources live under `bitchat/Localization/Base.lproj/`. Add new copy to `Localizable.strings` and plural rules to `Localizable.stringsdict`.
|
||||
- Share extension strings are separate in `bitchatShareExtension/Localization/Base.lproj/Localizable.strings`.
|
||||
- Prefer keys that describe intent (`app_info.features.offline.title`) and reuse existing ones where possible.
|
||||
- Run `xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGNING_ALLOWED=NO build` to compile-check any localization updates.
|
||||
|
||||
@@ -184,6 +184,28 @@ To minimize bandwidth, `BitchatPacket`s are serialized into a compact binary for
|
||||
|
||||
**Padding:** All packets are padded to the next standard block size (256, 512, 1024, or 2048 bytes) using a PKCS#7-style scheme to obscure the true message length from network observers.
|
||||
|
||||
```mermaid
|
||||
---
|
||||
config:
|
||||
theme: dark
|
||||
---
|
||||
---
|
||||
title: "BitchatPacket"
|
||||
---
|
||||
packet
|
||||
+8: "Version"
|
||||
+8: "Type"
|
||||
+8: "TTL"
|
||||
+64: "Timestamp"
|
||||
+8: "Flags"
|
||||
+16: "Payload Length"
|
||||
+64: "Sender ID"
|
||||
+64: "Recipient ID (optional)"
|
||||
+48: "Payload (variable)"
|
||||
+64: "Signature (optional)"
|
||||
```
|
||||
_A representation of the sizes of the fields in `BitchatPacket`_
|
||||
|
||||
### 6.2. Application Message Format (`BitchatMessage`)
|
||||
|
||||
For packets of type `message`, the payload is a binary-serialized `BitchatMessage` containing the chat content.
|
||||
@@ -198,6 +220,25 @@ For packets of type `message`, the payload is a binary-serialized `BitchatMessag
|
||||
| Original Sender | 1 + len (opt)| Nickname of the original sender if the message is a relay. |
|
||||
| Recipient Nickname | 1 + len (opt)| Nickname of the recipient for private messages. |
|
||||
|
||||
```mermaid
|
||||
---
|
||||
config:
|
||||
theme: dark
|
||||
---
|
||||
---
|
||||
title: "BitchatMessage"
|
||||
---
|
||||
packet
|
||||
+8: "Flags"
|
||||
+64: "Timestamp"
|
||||
+24: "ID (variable)"
|
||||
+32: "Sender (variable)"
|
||||
+32: "Content (variable)"
|
||||
+32: "Original Sender (variable) (optional)"
|
||||
+32: "Recipient Nickname (variable) (optional)"
|
||||
```
|
||||
_A representation of the sizes of the fields in `BitchatMessage`_
|
||||
|
||||
---
|
||||
|
||||
## 7. Message Routing and Propagation
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2650"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "57CA17A36A2532A6CFF367BB"
|
||||
BuildableName = "bitchatShareExtension.appex"
|
||||
BlueprintName = "bitchatShareExtension"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_iOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
codeCoverageEnabled = "YES"
|
||||
onlyGenerateCoverageForSpecifiedTargets = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_iOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<CodeCoverageTargets>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_iOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</CodeCoverageTargets>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES"
|
||||
testExecutionOrdering = "random">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "6CB97DF2EA57234CB3E563B8"
|
||||
BuildableName = "bitchatTests_iOS.xctest"
|
||||
BlueprintName = "bitchatTests_iOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_iOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "BITCHAT_LOG_LEVEL"
|
||||
value = "debug"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_iOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,105 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2650"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "0576A29205865664C0937536"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_macOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "0576A29205865664C0937536"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_macOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "47FF23248747DD7CB666CB91"
|
||||
BuildableName = "bitchatTests_macOS.xctest"
|
||||
BlueprintName = "bitchatTests_macOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "0576A29205865664C0937536"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_macOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "BITCHAT_LOG_LEVEL"
|
||||
value = "debug"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "0576A29205865664C0937536"
|
||||
BuildableName = "bitchat.app"
|
||||
BlueprintName = "bitchat_macOS"
|
||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,102 @@
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
enum SharedContentKind: String, Sendable, Equatable {
|
||||
case text
|
||||
case url
|
||||
}
|
||||
|
||||
enum RuntimeScenePhase: String, Sendable, Equatable {
|
||||
case active
|
||||
case inactive
|
||||
case background
|
||||
}
|
||||
|
||||
enum TorLifecycleEvent: String, Sendable, Equatable {
|
||||
case willStart
|
||||
case willRestart
|
||||
case didBecomeReady
|
||||
case preferenceChanged
|
||||
}
|
||||
|
||||
enum AppEvent: Sendable, Equatable {
|
||||
case launched
|
||||
case startupCompleted
|
||||
case scenePhaseChanged(RuntimeScenePhase)
|
||||
case openedURL(String)
|
||||
case sharedContentAccepted(SharedContentKind)
|
||||
case notificationOpened(peerID: PeerID?)
|
||||
case deepLinkOpened(String)
|
||||
case torLifecycleChanged(TorLifecycleEvent)
|
||||
case nostrRelayConnectionChanged(Bool)
|
||||
case terminationRequested
|
||||
}
|
||||
|
||||
actor AppEventStream {
|
||||
private var continuations: [UUID: AsyncStream<AppEvent>.Continuation] = [:]
|
||||
|
||||
func stream() -> AsyncStream<AppEvent> {
|
||||
let id = UUID()
|
||||
return AsyncStream { continuation in
|
||||
continuations[id] = continuation
|
||||
continuation.onTermination = { [id] _ in
|
||||
Task {
|
||||
await self.removeContinuation(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func emit(_ event: AppEvent) {
|
||||
for continuation in continuations.values {
|
||||
continuation.yield(event)
|
||||
}
|
||||
}
|
||||
|
||||
func finish() {
|
||||
for continuation in continuations.values {
|
||||
continuation.finish()
|
||||
}
|
||||
continuations.removeAll()
|
||||
}
|
||||
|
||||
private func removeContinuation(_ id: UUID) {
|
||||
continuations.removeValue(forKey: id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Identity key for a direct conversation. Equality and hashing use the
|
||||
/// canonical `id` only; `routingPeerID` carries the transport-level peer ID
|
||||
/// the conversation is keyed under (see `ConversationID.directPeer`).
|
||||
struct PeerHandle: Sendable, Identifiable {
|
||||
let id: String
|
||||
let routingPeerID: PeerID
|
||||
}
|
||||
|
||||
extension PeerHandle: Equatable {
|
||||
static func == (lhs: PeerHandle, rhs: PeerHandle) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
}
|
||||
|
||||
extension PeerHandle: Hashable {
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
}
|
||||
|
||||
enum ConversationID: Hashable, Sendable {
|
||||
case mesh
|
||||
case geohash(String)
|
||||
case direct(PeerHandle)
|
||||
|
||||
init(channelID: ChannelID) {
|
||||
switch channelID {
|
||||
case .mesh:
|
||||
self = .mesh
|
||||
case .location(let channel):
|
||||
self = .geohash(channel.geohash.lowercased())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import CoreBluetooth
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class AppChromeModel: ObservableObject {
|
||||
@Published private(set) var hasUnreadPrivateMessages = false
|
||||
@Published var nickname: String
|
||||
@Published var showingFingerprintFor: PeerID?
|
||||
@Published var isAppInfoPresented = false
|
||||
@Published var isLocationChannelsSheetPresented = false
|
||||
@Published var showBluetoothAlert = false
|
||||
@Published var bluetoothAlertMessage = ""
|
||||
@Published var bluetoothState: CBManagerState = .unknown
|
||||
@Published var showScreenshotPrivacyWarning = false
|
||||
|
||||
private let chatViewModel: ChatViewModel
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) {
|
||||
self.chatViewModel = chatViewModel
|
||||
self.nickname = chatViewModel.nickname
|
||||
|
||||
bind(privateInboxModel: privateInboxModel)
|
||||
}
|
||||
|
||||
var shouldSuppressScreenshotNotification: Bool {
|
||||
isLocationChannelsSheetPresented || isAppInfoPresented
|
||||
}
|
||||
|
||||
func setNickname(_ nickname: String) {
|
||||
self.nickname = nickname
|
||||
if chatViewModel.nickname != nickname {
|
||||
chatViewModel.nickname = nickname
|
||||
}
|
||||
}
|
||||
|
||||
func validateAndSaveNickname() {
|
||||
chatViewModel.validateAndSaveNickname()
|
||||
if nickname != chatViewModel.nickname {
|
||||
nickname = chatViewModel.nickname
|
||||
}
|
||||
}
|
||||
|
||||
func openMostRelevantPrivateChat() {
|
||||
chatViewModel.openMostRelevantPrivateChat()
|
||||
}
|
||||
|
||||
func showFingerprint(for peerID: PeerID) {
|
||||
showingFingerprintFor = peerID
|
||||
}
|
||||
|
||||
func clearFingerprint() {
|
||||
showingFingerprintFor = nil
|
||||
}
|
||||
|
||||
func presentAppInfo() {
|
||||
isAppInfoPresented = true
|
||||
}
|
||||
|
||||
func triggerScreenshotPrivacyWarning() {
|
||||
showScreenshotPrivacyWarning = true
|
||||
}
|
||||
|
||||
func panicClearAllData() {
|
||||
chatViewModel.panicClearAllData()
|
||||
}
|
||||
|
||||
private func bind(privateInboxModel: PrivateInboxModel) {
|
||||
privateInboxModel.$unreadPeerIDs
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] unreadPeerIDs in
|
||||
self?.hasUnreadPrivateMessages = !unreadPeerIDs.isEmpty
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
chatViewModel.$nickname
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] nickname in
|
||||
guard let self, self.nickname != nickname else { return }
|
||||
self.nickname = nickname
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
chatViewModel.$showBluetoothAlert
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$showBluetoothAlert)
|
||||
|
||||
chatViewModel.$bluetoothAlertMessage
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$bluetoothAlertMessage)
|
||||
|
||||
chatViewModel.$bluetoothState
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$bluetoothState)
|
||||
|
||||
hasUnreadPrivateMessages = !privateInboxModel.unreadPeerIDs.isEmpty
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Tor
|
||||
import UserNotifications
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
final class AppRuntime: ObservableObject {
|
||||
let chatViewModel: ChatViewModel
|
||||
let events = AppEventStream()
|
||||
/// Single source of truth for conversation message state and selection
|
||||
/// (docs/CONVERSATION-STORE-DESIGN.md). Owned here; the feature models
|
||||
/// and `ChatViewModel` observe and mutate it through its intent API.
|
||||
let conversations: ConversationStore
|
||||
let peerIdentityStore: PeerIdentityStore
|
||||
let locationPresenceStore: LocationPresenceStore
|
||||
let publicChatModel: PublicChatModel
|
||||
let privateInboxModel: PrivateInboxModel
|
||||
let privateConversationModel: PrivateConversationModel
|
||||
let verificationModel: VerificationModel
|
||||
let conversationUIModel: ConversationUIModel
|
||||
let locationChannelsModel: LocationChannelsModel
|
||||
let peerListModel: PeerListModel
|
||||
let appChromeModel: AppChromeModel
|
||||
|
||||
private let idBridge: NostrIdentityBridge
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var started = false
|
||||
private var lastNostrRelayConnectedState = false
|
||||
private var didHandleInitialNostrConnection = false
|
||||
|
||||
#if os(iOS)
|
||||
private var didHandleInitialActive = false
|
||||
private var didEnterBackground = false
|
||||
#endif
|
||||
|
||||
init(
|
||||
keychain: KeychainManagerProtocol = KeychainManager(),
|
||||
idBridge: NostrIdentityBridge = NostrIdentityBridge()
|
||||
) {
|
||||
self.idBridge = idBridge
|
||||
let conversations = ConversationStore()
|
||||
let peerIdentityStore = PeerIdentityStore()
|
||||
let locationPresenceStore = LocationPresenceStore()
|
||||
let locationManager = LocationChannelManager.shared
|
||||
self.conversations = conversations
|
||||
self.peerIdentityStore = peerIdentityStore
|
||||
self.locationPresenceStore = locationPresenceStore
|
||||
self.chatViewModel = ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: SecureIdentityStateManager(keychain),
|
||||
conversations: conversations,
|
||||
peerIdentityStore: peerIdentityStore,
|
||||
locationPresenceStore: locationPresenceStore,
|
||||
locationManager: locationManager
|
||||
)
|
||||
self.publicChatModel = PublicChatModel(conversations: conversations)
|
||||
self.privateInboxModel = PrivateInboxModel(conversations: conversations)
|
||||
self.locationChannelsModel = LocationChannelsModel(manager: locationManager)
|
||||
self.privateConversationModel = PrivateConversationModel(
|
||||
chatViewModel: self.chatViewModel,
|
||||
conversations: conversations,
|
||||
locationChannelsModel: self.locationChannelsModel,
|
||||
peerIdentityStore: peerIdentityStore
|
||||
)
|
||||
self.verificationModel = VerificationModel(
|
||||
chatViewModel: self.chatViewModel,
|
||||
privateConversationModel: self.privateConversationModel,
|
||||
peerIdentityStore: peerIdentityStore
|
||||
)
|
||||
self.conversationUIModel = ConversationUIModel(
|
||||
chatViewModel: self.chatViewModel,
|
||||
privateConversationModel: self.privateConversationModel,
|
||||
conversations: conversations
|
||||
)
|
||||
self.peerListModel = PeerListModel(
|
||||
chatViewModel: self.chatViewModel,
|
||||
conversations: conversations,
|
||||
locationChannelsModel: self.locationChannelsModel,
|
||||
peerIdentityStore: peerIdentityStore,
|
||||
locationPresenceStore: locationPresenceStore
|
||||
)
|
||||
self.appChromeModel = AppChromeModel(
|
||||
chatViewModel: self.chatViewModel,
|
||||
privateInboxModel: self.privateInboxModel
|
||||
)
|
||||
|
||||
GeoRelayDirectory.shared.prefetchIfNeeded()
|
||||
bindRuntimeObservers()
|
||||
NotificationDelegate.shared.runtime = self
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard !started else {
|
||||
checkForSharedContent()
|
||||
return
|
||||
}
|
||||
|
||||
started = true
|
||||
NotificationDelegate.shared.runtime = self
|
||||
VerificationService.shared.configure(with: chatViewModel.meshService)
|
||||
announceInitialTorStatusIfNeeded()
|
||||
|
||||
Task(priority: .utility) { [weak self] in
|
||||
guard let self else { return }
|
||||
let nickname = await MainActor.run { self.chatViewModel.nickname }
|
||||
let npub = await MainActor.run {
|
||||
try? self.idBridge.getCurrentNostrIdentity()?.npub
|
||||
}
|
||||
await MainActor.run {
|
||||
_ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub)
|
||||
}
|
||||
}
|
||||
|
||||
NetworkActivationService.shared.start()
|
||||
GeohashPresenceService.shared.start()
|
||||
checkForSharedContent()
|
||||
|
||||
record(.launched)
|
||||
record(.startupCompleted)
|
||||
}
|
||||
|
||||
func handleOpenURL(_ url: URL) {
|
||||
record(.openedURL(url.absoluteString))
|
||||
|
||||
if url.scheme == "bitchat", url.host == "share" {
|
||||
checkForSharedContent()
|
||||
}
|
||||
}
|
||||
|
||||
func handleDidBecomeActiveNotification() {
|
||||
chatViewModel.handleDidBecomeActive()
|
||||
checkForSharedContent()
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
func handleMacDidBecomeActiveNotification() {
|
||||
record(.scenePhaseChanged(.active))
|
||||
chatViewModel.handleDidBecomeActive()
|
||||
checkForSharedContent()
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
func handleScenePhaseChange(_ newPhase: ScenePhase) {
|
||||
switch newPhase {
|
||||
case .background:
|
||||
record(.scenePhaseChanged(.background))
|
||||
TorManager.shared.setAppForeground(false)
|
||||
TorManager.shared.goDormantOnBackground()
|
||||
chatViewModel.endGeohashSampling()
|
||||
NostrRelayManager.shared.disconnect()
|
||||
didEnterBackground = true
|
||||
|
||||
case .active:
|
||||
record(.scenePhaseChanged(.active))
|
||||
chatViewModel.meshService.startServices()
|
||||
TorManager.shared.setAppForeground(true)
|
||||
let shouldRefreshNostrConnections = didHandleInitialActive && didEnterBackground
|
||||
|
||||
if didHandleInitialActive && didEnterBackground {
|
||||
if TorManager.shared.isAutoStartAllowed() && !TorManager.shared.isReady {
|
||||
TorManager.shared.ensureRunningOnForeground()
|
||||
}
|
||||
} else {
|
||||
didHandleInitialActive = true
|
||||
}
|
||||
|
||||
didEnterBackground = false
|
||||
|
||||
if shouldRefreshNostrConnections && TorManager.shared.isAutoStartAllowed() {
|
||||
Task.detached {
|
||||
let _ = await TorManager.shared.awaitReady(timeout: 60)
|
||||
await MainActor.run {
|
||||
TorURLSession.shared.rebuild()
|
||||
NostrRelayManager.shared.resetAllConnections()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chatViewModel.handleDidBecomeActive()
|
||||
checkForSharedContent()
|
||||
|
||||
case .inactive:
|
||||
record(.scenePhaseChanged(.inactive))
|
||||
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
func applicationWillTerminate() {
|
||||
record(.terminationRequested)
|
||||
chatViewModel.applicationWillTerminate()
|
||||
}
|
||||
|
||||
func handleNotificationResponse(identifier: String, userInfo: [AnyHashable: Any]) {
|
||||
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
|
||||
record(.notificationOpened(peerID: peerID))
|
||||
chatViewModel.startPrivateChat(with: peerID)
|
||||
}
|
||||
|
||||
if let deepLink = userInfo["deeplink"] as? String, let url = URL(string: deepLink) {
|
||||
record(.deepLinkOpened(deepLink))
|
||||
openExternalURL(url)
|
||||
}
|
||||
}
|
||||
|
||||
func presentationOptions(
|
||||
forNotificationIdentifier identifier: String,
|
||||
userInfo: [AnyHashable: Any]
|
||||
) async -> UNNotificationPresentationOptions {
|
||||
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
|
||||
if conversations.selectedPrivatePeerID == peerID {
|
||||
return []
|
||||
}
|
||||
return [.banner, .sound]
|
||||
}
|
||||
|
||||
if identifier.hasPrefix("geo-activity-"),
|
||||
let deepLink = userInfo["deeplink"] as? String,
|
||||
let geohash = deepLink.components(separatedBy: "/").last,
|
||||
case .location(let channel) = locationChannelsModel.selectedChannel,
|
||||
channel.geohash == geohash {
|
||||
return []
|
||||
}
|
||||
|
||||
return [.banner, .sound]
|
||||
}
|
||||
}
|
||||
|
||||
private extension AppRuntime {
|
||||
func bindRuntimeObservers() {
|
||||
NostrRelayManager.shared.$isConnected
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] isConnected in
|
||||
self?.handleNostrRelayConnectionChanged(isConnected)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: .TorWillRestart)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.record(.torLifecycleChanged(.willRestart))
|
||||
self?.chatViewModel.handleTorWillRestart()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: .TorDidBecomeReady)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.record(.torLifecycleChanged(.didBecomeReady))
|
||||
self?.chatViewModel.handleTorDidBecomeReady()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: .TorWillStart)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.record(.torLifecycleChanged(.willStart))
|
||||
self?.chatViewModel.handleTorWillStart()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: .TorUserPreferenceChanged)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] notification in
|
||||
self?.record(.torLifecycleChanged(.preferenceChanged))
|
||||
self?.chatViewModel.handleTorPreferenceChanged(notification)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
#if os(iOS)
|
||||
NotificationCenter.default.publisher(for: UIApplication.userDidTakeScreenshotNotification)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.handleScreenshotCaptured()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
#endif
|
||||
}
|
||||
|
||||
func checkForSharedContent() {
|
||||
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID),
|
||||
let sharedContent = userDefaults.string(forKey: "sharedContent"),
|
||||
let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else {
|
||||
return
|
||||
}
|
||||
|
||||
guard Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds else {
|
||||
return
|
||||
}
|
||||
|
||||
let contentKind = SharedContentKind(rawValue: userDefaults.string(forKey: "sharedContentType") ?? "") ?? .text
|
||||
|
||||
userDefaults.removeObject(forKey: "sharedContent")
|
||||
userDefaults.removeObject(forKey: "sharedContentType")
|
||||
userDefaults.removeObject(forKey: "sharedContentDate")
|
||||
|
||||
switch contentKind {
|
||||
case .url:
|
||||
if let data = sharedContent.data(using: .utf8),
|
||||
let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String],
|
||||
let url = urlData["url"] {
|
||||
chatViewModel.sendMessage(url)
|
||||
} else {
|
||||
chatViewModel.sendMessage(sharedContent)
|
||||
}
|
||||
case .text:
|
||||
chatViewModel.sendMessage(sharedContent)
|
||||
}
|
||||
|
||||
record(.sharedContentAccepted(contentKind))
|
||||
}
|
||||
|
||||
func handleNostrRelayConnectionChanged(_ isConnected: Bool) {
|
||||
record(.nostrRelayConnectionChanged(isConnected))
|
||||
|
||||
let becameConnected = isConnected && !lastNostrRelayConnectedState
|
||||
lastNostrRelayConnectedState = isConnected
|
||||
|
||||
guard started, becameConnected else { return }
|
||||
|
||||
let isInitialConnection = !didHandleInitialNostrConnection
|
||||
didHandleInitialNostrConnection = true
|
||||
|
||||
if !chatViewModel.nostrHandlersSetup {
|
||||
chatViewModel.setupNostrMessageHandling()
|
||||
chatViewModel.nostrHandlersSetup = true
|
||||
}
|
||||
|
||||
guard !isInitialConnection else { return }
|
||||
|
||||
chatViewModel.resubscribeCurrentGeohash()
|
||||
chatViewModel.geoChannelCoordinator?.refreshSampling()
|
||||
}
|
||||
|
||||
func announceInitialTorStatusIfNeeded() {
|
||||
if TorManager.shared.torEnforced &&
|
||||
!chatViewModel.torStatusAnnounced &&
|
||||
TorManager.shared.isAutoStartAllowed() {
|
||||
chatViewModel.torStatusAnnounced = true
|
||||
chatViewModel.addGeohashOnlySystemMessage(
|
||||
String(localized: "system.tor.starting", comment: "System message when Tor is starting")
|
||||
)
|
||||
} else if !TorManager.shared.torEnforced && !chatViewModel.torStatusAnnounced {
|
||||
chatViewModel.torStatusAnnounced = true
|
||||
chatViewModel.addGeohashOnlySystemMessage(
|
||||
String(localized: "system.tor.dev_bypass", comment: "System message when Tor bypass is enabled in development")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func handleScreenshotCaptured() {
|
||||
if appChromeModel.isLocationChannelsSheetPresented {
|
||||
appChromeModel.triggerScreenshotPrivacyWarning()
|
||||
return
|
||||
}
|
||||
|
||||
if appChromeModel.isAppInfoPresented {
|
||||
return
|
||||
}
|
||||
|
||||
chatViewModel.handleScreenshotCaptured()
|
||||
}
|
||||
|
||||
func openExternalURL(_ url: URL) {
|
||||
#if os(iOS)
|
||||
UIApplication.shared.open(url)
|
||||
#else
|
||||
NSWorkspace.shared.open(url)
|
||||
#endif
|
||||
}
|
||||
|
||||
func record(_ event: AppEvent) {
|
||||
Task {
|
||||
await events.emit(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,918 @@
|
||||
//
|
||||
// ConversationStore.swift
|
||||
// bitchat
|
||||
//
|
||||
// Single source of truth for conversation message state (see
|
||||
// docs/CONVERSATION-STORE-DESIGN.md). One `Conversation` object per
|
||||
// `ConversationID`; all mutations flow through the store's intent API and
|
||||
// every mutation emits a `ConversationChange` after state is consistent.
|
||||
//
|
||||
// The store also owns conversation selection: the active public channel and
|
||||
// the selected private peer (the two UI selection axes) plus the derived
|
||||
// `selectedConversationID`.
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import BitLogger
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
// MARK: - Conversation
|
||||
|
||||
/// A single conversation timeline (`.mesh`, `.geohash`, or `.direct`).
|
||||
///
|
||||
/// Publishing granularity is per conversation: views observe ONE
|
||||
/// `Conversation` object, so an append to chat A never invalidates observers
|
||||
/// of chat B.
|
||||
///
|
||||
/// Mutations are `fileprivate` by design — only `ConversationStore`'s intent
|
||||
/// API may mutate a conversation, keeping the store the sole writer.
|
||||
@MainActor
|
||||
final class Conversation: ObservableObject, Identifiable {
|
||||
let id: ConversationID
|
||||
/// Maximum retained messages; oldest are trimmed on overflow.
|
||||
let cap: Int
|
||||
|
||||
@Published private(set) var messages: [BitchatMessage] = []
|
||||
@Published private(set) var isUnread: Bool = false
|
||||
|
||||
/// Incrementally-maintained message-ID → index map for O(1) dedup and
|
||||
/// delivery-status lookup. Kept in sync on every mutation:
|
||||
/// - tail append: single insert
|
||||
/// - out-of-order insert: suffix reindex from the insertion point
|
||||
/// - trim: full rebuild — `removeFirst(k)` is already O(n), so the
|
||||
/// rebuild does not change the asymptotics, and trim only happens once
|
||||
/// the cap (1337) is reached. Simple and correct beats the
|
||||
/// offset-tracking alternative here.
|
||||
private var indexByMessageID: [String: Int] = [:]
|
||||
|
||||
fileprivate init(id: ConversationID, cap: Int) {
|
||||
self.id = id
|
||||
self.cap = max(1, cap)
|
||||
}
|
||||
|
||||
// MARK: Reads
|
||||
|
||||
func containsMessage(withID messageID: String) -> Bool {
|
||||
indexByMessageID[messageID] != nil
|
||||
}
|
||||
|
||||
func message(withID messageID: String) -> BitchatMessage? {
|
||||
guard let index = indexByMessageID[messageID] else { return nil }
|
||||
return messages[index]
|
||||
}
|
||||
|
||||
/// All message IDs currently in this conversation (unordered).
|
||||
var messageIDs: Dictionary<String, Int>.Keys {
|
||||
indexByMessageID.keys
|
||||
}
|
||||
|
||||
// MARK: Store-internal mutations
|
||||
|
||||
/// Result of an ordered insert. `trimmedMessageIDs` reports messages
|
||||
/// evicted by the cap so the store can keep its message-ID →
|
||||
/// conversation map exact.
|
||||
fileprivate struct InsertResult {
|
||||
let inserted: Bool
|
||||
let trimmedMessageIDs: [String]
|
||||
|
||||
static let duplicate = InsertResult(inserted: false, trimmedMessageIDs: [])
|
||||
}
|
||||
|
||||
fileprivate enum UpsertOutcome {
|
||||
case appended(trimmedMessageIDs: [String])
|
||||
case updated
|
||||
}
|
||||
|
||||
/// Inserts a message in timestamp order, deduplicating by message ID.
|
||||
/// Fast path appends when the timestamp is >= the current tail;
|
||||
/// otherwise a binary search finds the upper-bound insertion point so
|
||||
/// arrival order is preserved among equal timestamps.
|
||||
/// Reports `inserted: false` if a message with the same ID already exists.
|
||||
fileprivate func insert(_ message: BitchatMessage) -> InsertResult {
|
||||
guard indexByMessageID[message.id] == nil else { return .duplicate }
|
||||
|
||||
if let last = messages.last, message.timestamp < last.timestamp {
|
||||
let index = insertionIndex(for: message.timestamp)
|
||||
messages.insert(message, at: index)
|
||||
reindex(from: index)
|
||||
} else {
|
||||
messages.append(message)
|
||||
indexByMessageID[message.id] = messages.count - 1
|
||||
}
|
||||
|
||||
return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded())
|
||||
}
|
||||
|
||||
/// Replace-or-append by message ID. An existing message keeps its
|
||||
/// timeline position (in-place updates like media progress reuse the
|
||||
/// original timestamp); a new message goes through ordered insertion.
|
||||
fileprivate func upsert(_ message: BitchatMessage) -> UpsertOutcome {
|
||||
if let index = indexByMessageID[message.id] {
|
||||
messages[index] = message
|
||||
return .updated
|
||||
}
|
||||
let result = insert(message)
|
||||
return .appended(trimmedMessageIDs: result.trimmedMessageIDs)
|
||||
}
|
||||
|
||||
/// Applies a delivery status keyed by message ID, honoring the
|
||||
/// no-downgrade rule (the SOLE enforcement point — every delivery
|
||||
/// update flows through the store): equal statuses are skipped, and
|
||||
/// `.read` is never downgraded to `.delivered` or `.sent`.
|
||||
/// Returns `true` when the status was applied.
|
||||
fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||
guard let index = indexByMessageID[messageID] else { return false }
|
||||
let message = messages[index]
|
||||
guard !Self.shouldSkipStatusUpdate(current: message.deliveryStatus, new: status) else { return false }
|
||||
|
||||
message.deliveryStatus = status
|
||||
// BitchatMessage is a reference type; write back through the
|
||||
// subscript so the @Published wrapper emits.
|
||||
messages[index] = message
|
||||
return true
|
||||
}
|
||||
|
||||
/// Republishes a message without changing state. Used for mirrored
|
||||
/// copies that share a BitchatMessage instance: the first conversation's
|
||||
/// status apply mutated the shared object, so this conversation's
|
||||
/// observers still need an @Published emission to re-render.
|
||||
@discardableResult
|
||||
fileprivate func republishMessage(withID messageID: String) -> Bool {
|
||||
guard let index = indexByMessageID[messageID] else { return false }
|
||||
messages[index] = messages[index]
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
fileprivate func setUnread(_ unread: Bool) -> Bool {
|
||||
guard isUnread != unread else { return false }
|
||||
isUnread = unread
|
||||
return true
|
||||
}
|
||||
|
||||
/// Removes a single message by ID. Returns the removed message, or
|
||||
/// `nil` when no message with that ID exists.
|
||||
fileprivate func remove(messageID: String) -> BitchatMessage? {
|
||||
guard let index = indexByMessageID[messageID] else { return nil }
|
||||
let removed = messages.remove(at: index)
|
||||
indexByMessageID.removeValue(forKey: messageID)
|
||||
reindex(from: index)
|
||||
return removed
|
||||
}
|
||||
|
||||
/// Removes every message matching `predicate`. Returns the removed
|
||||
/// message IDs (empty when nothing matched).
|
||||
fileprivate func removeAll(where predicate: (BitchatMessage) -> Bool) -> [String] {
|
||||
var removedIDs: [String] = []
|
||||
messages.removeAll { message in
|
||||
guard predicate(message) else { return false }
|
||||
removedIDs.append(message.id)
|
||||
return true
|
||||
}
|
||||
guard !removedIDs.isEmpty else { return [] }
|
||||
for id in removedIDs {
|
||||
indexByMessageID.removeValue(forKey: id)
|
||||
}
|
||||
reindex(from: 0)
|
||||
return removedIDs
|
||||
}
|
||||
|
||||
fileprivate func clearMessages() {
|
||||
messages.removeAll()
|
||||
indexByMessageID.removeAll()
|
||||
}
|
||||
|
||||
// MARK: Diagnostics
|
||||
|
||||
/// Appends human-readable invariant violations for this conversation
|
||||
/// (empty when healthy): the ID index must be the exact inverse of the
|
||||
/// messages array, the cap must hold, and timestamps must be
|
||||
/// non-decreasing (equal timestamps keep arrival order, so only strict
|
||||
/// inversions are violations). O(messages); allocates only on violation.
|
||||
fileprivate func collectInvariantViolations(into violations: inout [String], label: String) {
|
||||
if indexByMessageID.count != messages.count {
|
||||
violations.append("\(label): index has \(indexByMessageID.count) entries for \(messages.count) messages")
|
||||
}
|
||||
if messages.count > cap {
|
||||
violations.append("\(label): \(messages.count) messages exceeds cap \(cap)")
|
||||
}
|
||||
var previousTimestamp: Date?
|
||||
for position in messages.indices {
|
||||
let message = messages[position]
|
||||
// Count equality + every message resolving to its own position
|
||||
// proves the index is exactly the inverse map (no stale extras).
|
||||
if let index = indexByMessageID[message.id] {
|
||||
if index != position {
|
||||
violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(index)")
|
||||
}
|
||||
} else {
|
||||
violations.append("\(label): message \(message.id.prefix(8))… at \(position) missing from index")
|
||||
}
|
||||
if let previousTimestamp, message.timestamp < previousTimestamp {
|
||||
violations.append("\(label): timestamp order violated at \(position)")
|
||||
}
|
||||
previousTimestamp = message.timestamp
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Internals
|
||||
|
||||
static func shouldSkipStatusUpdate(current: DeliveryStatus?, new: DeliveryStatus) -> Bool {
|
||||
guard let current else { return false }
|
||||
if current == new { return true }
|
||||
|
||||
switch (current, new) {
|
||||
case (.read, .delivered), (.read, .sent):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Upper-bound binary search: first index whose timestamp is strictly
|
||||
/// greater than `timestamp`, so equal-timestamp messages keep arrival
|
||||
/// order.
|
||||
private func insertionIndex(for timestamp: Date) -> Int {
|
||||
var low = 0
|
||||
var high = messages.count
|
||||
while low < high {
|
||||
let mid = (low + high) / 2
|
||||
if messages[mid].timestamp <= timestamp {
|
||||
low = mid + 1
|
||||
} else {
|
||||
high = mid
|
||||
}
|
||||
}
|
||||
return low
|
||||
}
|
||||
|
||||
private func reindex(from start: Int) {
|
||||
for index in start..<messages.count {
|
||||
indexByMessageID[messages[index].id] = index
|
||||
}
|
||||
}
|
||||
|
||||
/// Trims oldest messages over the cap; returns the trimmed message IDs.
|
||||
private func trimIfNeeded() -> [String] {
|
||||
guard messages.count > cap else { return [] }
|
||||
let overflow = messages.count - cap
|
||||
let trimmedIDs = messages.prefix(overflow).map(\.id)
|
||||
for id in trimmedIDs {
|
||||
indexByMessageID.removeValue(forKey: id)
|
||||
}
|
||||
messages.removeFirst(overflow)
|
||||
reindex(from: 0)
|
||||
return trimmedIDs
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ConversationChange
|
||||
|
||||
/// Typed mutation events for non-UI consumers (delivery tracking,
|
||||
/// notifications, sync) that need "something changed in conversation X"
|
||||
/// without subscribing to whole message arrays. Emitted on the store's
|
||||
/// `changes` subject AFTER the corresponding state is consistent.
|
||||
enum ConversationChange {
|
||||
case appended(ConversationID, BitchatMessage)
|
||||
case updated(ConversationID, messageID: String)
|
||||
case statusChanged(ConversationID, messageID: String, DeliveryStatus)
|
||||
case messageRemoved(ConversationID, messageID: String)
|
||||
case cleared(ConversationID)
|
||||
case removed(ConversationID)
|
||||
case migrated(from: ConversationID, to: ConversationID)
|
||||
case unreadChanged(ConversationID, isUnread: Bool)
|
||||
}
|
||||
|
||||
// MARK: - ConversationStore
|
||||
|
||||
/// Sole writer and sole holder of conversation message state. All mutations
|
||||
/// go through the intent API below; backing collections are `private(set)`.
|
||||
/// Reads are synchronous — writers and readers share the main actor, so
|
||||
/// after an intent returns every observer sees the result.
|
||||
@MainActor
|
||||
final class ConversationStore: ObservableObject {
|
||||
/// Conversation creation order; published so list-style consumers can
|
||||
/// observe conversations appearing/disappearing without rebuilding from
|
||||
/// the dictionary.
|
||||
@Published private(set) var conversationIDs: [ConversationID] = []
|
||||
@Published private(set) var selectedConversationID: ConversationID?
|
||||
@Published private(set) var unreadConversations: Set<ConversationID> = []
|
||||
|
||||
// MARK: Selection state
|
||||
// The two UI selection axes: which public channel is active, and which
|
||||
// private chat (if any) is open on top of it. `selectedConversationID`
|
||||
// is derived: the open private chat wins, otherwise the active public
|
||||
// channel's conversation. Mutate via `setActiveChannel` /
|
||||
// `setSelectedPrivatePeer` only.
|
||||
|
||||
@Published private(set) var activeChannel: ChannelID = .mesh
|
||||
@Published private(set) var selectedPrivatePeerID: PeerID?
|
||||
|
||||
private(set) var conversationsByID: [ConversationID: Conversation] = [:]
|
||||
|
||||
/// Store-level message-ID → conversation-membership map for ID-only
|
||||
/// lookups (delivery receipts arrive with a message ID, not a
|
||||
/// conversation). Maintained incrementally at every mutation point —
|
||||
/// all mutation is centralized in the intent API below, so the map is
|
||||
/// exact, never scanned or rebuilt.
|
||||
///
|
||||
/// The value is a `Set` because a private message can legitimately live
|
||||
/// in TWO direct conversations: step 2's raw per-peer keying mirrors a
|
||||
/// message into both the stable-key and ephemeral-peer chats
|
||||
/// (`mirrorToEphemeralIfNeeded`). A delivery update must reach both
|
||||
/// copies.
|
||||
private var conversationIDsByMessageID: [String: Set<ConversationID>] = [:]
|
||||
|
||||
/// Monotonic count of messages inserted into any conversation (appends,
|
||||
/// upsert-appends, migration inserts). Field-observability only: the
|
||||
/// periodic store audit folds the delta into its heartbeat line so logs
|
||||
/// carry throughput context. Never read on a hot path.
|
||||
private(set) var appendCount: Int = 0
|
||||
|
||||
/// Sample counter for the mirrored-republish debug log in the ID-only
|
||||
/// `setDeliveryStatus` fan-out (first + every Nth occurrence).
|
||||
private var mirroredRepublishLogCount = 0
|
||||
|
||||
let changes = PassthroughSubject<ConversationChange, Never>()
|
||||
|
||||
// MARK: Intent API
|
||||
|
||||
/// Returns the conversation for `id`, creating it (with the cap policy
|
||||
/// for its kind) on first access.
|
||||
@discardableResult
|
||||
func conversation(for id: ConversationID) -> Conversation {
|
||||
if let existing = conversationsByID[id] {
|
||||
return existing
|
||||
}
|
||||
let conversation = Conversation(id: id, cap: Self.cap(for: id))
|
||||
conversationsByID[id] = conversation
|
||||
conversationIDs.append(id)
|
||||
return conversation
|
||||
}
|
||||
|
||||
/// Appends a message in timestamp order. Returns `false` (and emits
|
||||
/// nothing) if a message with the same ID is already present.
|
||||
@discardableResult
|
||||
func append(_ message: BitchatMessage, to id: ConversationID) -> Bool {
|
||||
let conversation = conversation(for: id)
|
||||
let result = conversation.insert(message)
|
||||
guard result.inserted else { return false }
|
||||
registerMessageID(message.id, in: id)
|
||||
unregisterMessageIDs(result.trimmedMessageIDs, from: id)
|
||||
changes.send(.appended(id, message))
|
||||
return true
|
||||
}
|
||||
|
||||
/// Replace-or-append by message ID (media progress, edits).
|
||||
func upsertByID(_ message: BitchatMessage, in id: ConversationID) {
|
||||
let conversation = conversation(for: id)
|
||||
switch conversation.upsert(message) {
|
||||
case .appended(let trimmedMessageIDs):
|
||||
registerMessageID(message.id, in: id)
|
||||
unregisterMessageIDs(trimmedMessageIDs, from: id)
|
||||
changes.send(.appended(id, message))
|
||||
case .updated:
|
||||
changes.send(.updated(id, messageID: message.id))
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies a delivery status keyed by message ID. Returns `false` when
|
||||
/// the message is unknown or the update would downgrade the status
|
||||
/// (read beats delivered beats sent).
|
||||
@discardableResult
|
||||
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, in id: ConversationID) -> Bool {
|
||||
guard let conversation = conversationsByID[id],
|
||||
conversation.applyDeliveryStatus(status, forMessageID: messageID) else {
|
||||
return false
|
||||
}
|
||||
changes.send(.statusChanged(id, messageID: messageID, status))
|
||||
return true
|
||||
}
|
||||
|
||||
/// Applies a delivery status to EVERY conversation containing
|
||||
/// `messageID` (ID-only — delivery receipts don't know conversations;
|
||||
/// mirrored private copies live in two direct chats). Returns `false`
|
||||
/// when the message is unknown or no copy changed (equal status or
|
||||
/// downgrade — read beats delivered beats sent).
|
||||
///
|
||||
/// `BitchatMessage` is a reference type, so mirrored copies sharing one
|
||||
/// instance are mutated by the first conversation's apply. The skipped
|
||||
/// conversations still hold the changed message, so they get an explicit
|
||||
/// republish and `.statusChanged` event - otherwise a view observing the
|
||||
/// mirrored conversation would render stale status. Distinct copies whose
|
||||
/// update was genuinely rejected (downgrade) are left untouched, guarded
|
||||
/// by status equality.
|
||||
@discardableResult
|
||||
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||
guard let ids = conversationIDsByMessageID[messageID] else { return false }
|
||||
var applied = false
|
||||
var skipped: [ConversationID] = []
|
||||
for id in ids {
|
||||
if setDeliveryStatus(status, forMessageID: messageID, in: id) {
|
||||
applied = true
|
||||
} else {
|
||||
skipped.append(id)
|
||||
}
|
||||
}
|
||||
guard applied else { return false }
|
||||
for id in skipped {
|
||||
guard let conversation = conversationsByID[id],
|
||||
conversation.message(withID: messageID)?.deliveryStatus == status,
|
||||
conversation.republishMessage(withID: messageID) else { continue }
|
||||
// Field proof the mirrored-copy republish path actually fires;
|
||||
// sampled (first + every Nth) so mirrored chats can't spam logs.
|
||||
mirroredRepublishLogCount += 1
|
||||
if mirroredRepublishLogCount == 1
|
||||
|| mirroredRepublishLogCount.isMultiple(of: TransportConfig.conversationStoreMirroredRepublishLogInterval) {
|
||||
SecureLogger.debug(
|
||||
"mirrored republish #\(mirroredRepublishLogCount) for \(messageID.prefix(8))… in \(id.auditDescription)",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
changes.send(.statusChanged(id, messageID: messageID, status))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Current delivery status of `messageID` in whichever conversation
|
||||
/// holds it (mirrored copies share status — see `setDeliveryStatus`).
|
||||
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
|
||||
guard let ids = conversationIDsByMessageID[messageID] else { return nil }
|
||||
for id in ids {
|
||||
if let status = conversationsByID[id]?.message(withID: messageID)?.deliveryStatus {
|
||||
return status
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Every conversation currently containing `messageID` (empty when the
|
||||
/// message is unknown).
|
||||
func conversationIDs(forMessageID messageID: String) -> Set<ConversationID> {
|
||||
conversationIDsByMessageID[messageID] ?? []
|
||||
}
|
||||
|
||||
func markRead(_ id: ConversationID) {
|
||||
guard unreadConversations.contains(id) else { return }
|
||||
unreadConversations.remove(id)
|
||||
conversationsByID[id]?.setUnread(false)
|
||||
changes.send(.unreadChanged(id, isUnread: false))
|
||||
}
|
||||
|
||||
func markUnread(_ id: ConversationID) {
|
||||
guard !unreadConversations.contains(id) else { return }
|
||||
let conversation = conversation(for: id)
|
||||
unreadConversations.insert(id)
|
||||
conversation.setUnread(true)
|
||||
changes.send(.unreadChanged(id, isUnread: true))
|
||||
}
|
||||
|
||||
/// Selects a conversation (creating it if needed) or clears the
|
||||
/// selection with `nil`.
|
||||
func select(_ id: ConversationID?) {
|
||||
if let id {
|
||||
conversation(for: id)
|
||||
}
|
||||
guard selectedConversationID != id else { return }
|
||||
selectedConversationID = id
|
||||
}
|
||||
|
||||
/// Switches the active public channel. While no private chat is open
|
||||
/// the selection follows the channel.
|
||||
func setActiveChannel(_ channelID: ChannelID) {
|
||||
if activeChannel != channelID {
|
||||
activeChannel = channelID
|
||||
}
|
||||
refreshDerivedSelection()
|
||||
}
|
||||
|
||||
/// Opens a private chat (`nil` closes it, returning the selection to the
|
||||
/// active public channel's conversation).
|
||||
func setSelectedPrivatePeer(_ peerID: PeerID?) {
|
||||
if selectedPrivatePeerID != peerID {
|
||||
selectedPrivatePeerID = peerID
|
||||
}
|
||||
refreshDerivedSelection()
|
||||
}
|
||||
|
||||
private func refreshDerivedSelection() {
|
||||
if let peerID = selectedPrivatePeerID {
|
||||
select(.directPeer(peerID))
|
||||
} else {
|
||||
select(ConversationID(channelID: activeChannel))
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves all messages from `source` into `destination` (the
|
||||
/// ephemeral↔stable peer-ID handoff): dedups by message ID, preserves
|
||||
/// timestamp order, carries unread state over, and hands off the
|
||||
/// selection — mirroring `ChatPrivateConversationCoordinator`'s
|
||||
/// migration semantics. The source conversation is removed. Emits a
|
||||
/// single `.migrated(from:to:)` once the whole move is consistent.
|
||||
func migrateConversation(from source: ConversationID, to destination: ConversationID) {
|
||||
guard source != destination, let sourceConversation = conversationsByID[source] else { return }
|
||||
|
||||
let destinationConversation = conversation(for: destination)
|
||||
for message in sourceConversation.messages {
|
||||
let result = destinationConversation.insert(message)
|
||||
guard result.inserted else { continue }
|
||||
registerMessageID(message.id, in: destination)
|
||||
unregisterMessageIDs(result.trimmedMessageIDs, from: destination)
|
||||
}
|
||||
for messageID in sourceConversation.messageIDs {
|
||||
unregisterMessageID(messageID, from: source)
|
||||
}
|
||||
|
||||
let wasUnread = unreadConversations.contains(source)
|
||||
let wasSelected = selectedConversationID == source
|
||||
|
||||
conversationsByID.removeValue(forKey: source)
|
||||
conversationIDs.removeAll { $0 == source }
|
||||
unreadConversations.remove(source)
|
||||
|
||||
if wasUnread, !unreadConversations.contains(destination) {
|
||||
unreadConversations.insert(destination)
|
||||
destinationConversation.setUnread(true)
|
||||
}
|
||||
if wasSelected {
|
||||
selectedConversationID = destination
|
||||
// Keep the private-peer selection axis consistent with the
|
||||
// handed-off selection.
|
||||
if let peerID = selectedPrivatePeerID,
|
||||
source == .directPeer(peerID),
|
||||
case .direct(let destinationHandle) = destination {
|
||||
selectedPrivatePeerID = destinationHandle.routingPeerID
|
||||
}
|
||||
}
|
||||
|
||||
changes.send(.migrated(from: source, to: destination))
|
||||
}
|
||||
|
||||
/// Removes a single message by ID from a conversation. Returns the
|
||||
/// removed message, or `nil` (emitting nothing) when the conversation or
|
||||
/// message is unknown.
|
||||
@discardableResult
|
||||
func removeMessage(withID messageID: String, from id: ConversationID) -> BitchatMessage? {
|
||||
guard let conversation = conversationsByID[id],
|
||||
let removed = conversation.remove(messageID: messageID) else {
|
||||
return nil
|
||||
}
|
||||
unregisterMessageID(messageID, from: id)
|
||||
changes.send(.messageRemoved(id, messageID: messageID))
|
||||
return removed
|
||||
}
|
||||
|
||||
/// Removes every message matching `predicate` from a conversation,
|
||||
/// emitting one `.messageRemoved` per removed message after the
|
||||
/// conversation is consistent. No-op for unknown conversations.
|
||||
func removeMessages(from id: ConversationID, where predicate: (BitchatMessage) -> Bool) {
|
||||
guard let conversation = conversationsByID[id] else { return }
|
||||
let removedIDs = conversation.removeAll(where: predicate)
|
||||
unregisterMessageIDs(removedIDs, from: id)
|
||||
for messageID in removedIDs {
|
||||
changes.send(.messageRemoved(id, messageID: messageID))
|
||||
}
|
||||
}
|
||||
|
||||
/// Empties a conversation's timeline but keeps the conversation (and
|
||||
/// its unread/selection state) alive.
|
||||
func clear(_ id: ConversationID) {
|
||||
guard let conversation = conversationsByID[id] else { return }
|
||||
for messageID in conversation.messageIDs {
|
||||
unregisterMessageID(messageID, from: id)
|
||||
}
|
||||
conversation.clearMessages()
|
||||
changes.send(.cleared(id))
|
||||
}
|
||||
|
||||
/// Removes a conversation entirely, including unread state; clears the
|
||||
/// selection if it pointed at the removed conversation.
|
||||
func removeConversation(_ id: ConversationID) {
|
||||
guard let conversation = conversationsByID.removeValue(forKey: id) else { return }
|
||||
for messageID in conversation.messageIDs {
|
||||
unregisterMessageID(messageID, from: id)
|
||||
}
|
||||
conversationIDs.removeAll { $0 == id }
|
||||
unreadConversations.remove(id)
|
||||
if selectedConversationID == id {
|
||||
selectedConversationID = nil
|
||||
}
|
||||
changes.send(.removed(id))
|
||||
}
|
||||
|
||||
func clearAll() {
|
||||
let removedIDs = conversationIDs
|
||||
guard !removedIDs.isEmpty || selectedConversationID != nil else { return }
|
||||
|
||||
conversationsByID.removeAll()
|
||||
conversationIDs.removeAll()
|
||||
unreadConversations.removeAll()
|
||||
conversationIDsByMessageID.removeAll()
|
||||
if selectedConversationID != nil {
|
||||
selectedConversationID = nil
|
||||
}
|
||||
|
||||
for id in removedIDs {
|
||||
changes.send(.removed(id))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Diagnostics
|
||||
|
||||
/// Total messages across all conversations. O(#conversations) — heartbeat
|
||||
/// logging only, never a hot path.
|
||||
var totalMessageCount: Int {
|
||||
conversationsByID.values.reduce(0) { $0 + $1.messages.count }
|
||||
}
|
||||
|
||||
/// Number of distinct message IDs in the store-level membership map.
|
||||
var messageIDMapCount: Int {
|
||||
conversationIDsByMessageID.count
|
||||
}
|
||||
|
||||
/// Verifies the store's correctness invariants and returns human-readable
|
||||
/// violations (empty = healthy). Intended for a periodic field audit:
|
||||
/// O(total messages) and allocation-free while healthy. Checks:
|
||||
/// - the `conversationIDs` ordering array matches `conversationsByID`
|
||||
/// - per conversation: ID index exact, cap held, timestamp order
|
||||
/// (see `Conversation.collectInvariantViolations`)
|
||||
/// - the message-ID → conversation map matches reality exactly: every
|
||||
/// mapped membership points at a live conversation actually holding
|
||||
/// the message, and total memberships equal total messages (with the
|
||||
/// forward check, equality proves no conversation message is missing
|
||||
/// from the map)
|
||||
/// - `unreadConversations` only references existing conversations
|
||||
/// - `selectedConversationID`, when set, references an existing
|
||||
/// conversation (`select(_:)` creates on selection and
|
||||
/// `removeConversation`/`clearAll` clear it, so existence is the
|
||||
/// invariant for both the channel-derived and direct-peer cases)
|
||||
func auditInvariants() -> [String] {
|
||||
var violations: [String] = []
|
||||
|
||||
if conversationIDs.count != conversationsByID.count {
|
||||
violations.append("conversationIDs lists \(conversationIDs.count) conversations but dictionary holds \(conversationsByID.count)")
|
||||
}
|
||||
for id in conversationIDs where conversationsByID[id] == nil {
|
||||
violations.append("conversationIDs lists \(id.auditDescription) but no conversation exists")
|
||||
}
|
||||
|
||||
var totalMessages = 0
|
||||
for (id, conversation) in conversationsByID {
|
||||
totalMessages += conversation.messages.count
|
||||
conversation.collectInvariantViolations(into: &violations, label: id.auditDescription)
|
||||
}
|
||||
|
||||
var totalMappedMemberships = 0
|
||||
for (messageID, ids) in conversationIDsByMessageID {
|
||||
totalMappedMemberships += ids.count
|
||||
if ids.isEmpty {
|
||||
violations.append("message map: \(messageID.prefix(8))… has an empty membership set")
|
||||
}
|
||||
for id in ids {
|
||||
guard let conversation = conversationsByID[id] else {
|
||||
violations.append("message map: \(messageID.prefix(8))… claims unknown conversation \(id.auditDescription)")
|
||||
continue
|
||||
}
|
||||
if !conversation.containsMessage(withID: messageID) {
|
||||
violations.append("message map: \(messageID.prefix(8))… not present in claimed conversation \(id.auditDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
if totalMappedMemberships != totalMessages {
|
||||
violations.append("message map holds \(totalMappedMemberships) memberships but conversations hold \(totalMessages) messages")
|
||||
}
|
||||
|
||||
for id in unreadConversations where conversationsByID[id] == nil {
|
||||
violations.append("unreadConversations contains unknown conversation \(id.auditDescription)")
|
||||
}
|
||||
|
||||
if let selected = selectedConversationID, conversationsByID[selected] == nil {
|
||||
violations.append("selectedConversationID \(selected.auditDescription) has no conversation")
|
||||
}
|
||||
|
||||
return violations
|
||||
}
|
||||
|
||||
// MARK: Internals
|
||||
|
||||
private func registerMessageID(_ messageID: String, in id: ConversationID) {
|
||||
conversationIDsByMessageID[messageID, default: []].insert(id)
|
||||
// Single choke point for every successful insertion (append, upsert
|
||||
// append, migration insert) — the audit heartbeat's throughput delta.
|
||||
appendCount += 1
|
||||
}
|
||||
|
||||
private func unregisterMessageID(_ messageID: String, from id: ConversationID) {
|
||||
guard var ids = conversationIDsByMessageID[messageID] else { return }
|
||||
ids.remove(id)
|
||||
if ids.isEmpty {
|
||||
conversationIDsByMessageID.removeValue(forKey: messageID)
|
||||
} else {
|
||||
conversationIDsByMessageID[messageID] = ids
|
||||
}
|
||||
}
|
||||
|
||||
private func unregisterMessageIDs(_ messageIDs: [String], from id: ConversationID) {
|
||||
for messageID in messageIDs {
|
||||
unregisterMessageID(messageID, from: id)
|
||||
}
|
||||
}
|
||||
|
||||
private static func cap(for id: ConversationID) -> Int {
|
||||
switch id {
|
||||
case .mesh:
|
||||
return TransportConfig.meshTimelineCap
|
||||
case .geohash:
|
||||
return TransportConfig.geoTimelineCap
|
||||
case .direct:
|
||||
return TransportConfig.privateChatCap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Direct-conversation keying + derived views
|
||||
|
||||
extension ConversationID {
|
||||
/// Direct-conversation ID keyed by the *raw* routing peer ID.
|
||||
///
|
||||
/// Direct conversations are deliberately keyed per `PeerID`, not per
|
||||
/// resolved identity: the private-chat coordinators mirror messages into
|
||||
/// both the ephemeral and stable peer's conversations
|
||||
/// (`mirrorToEphemeralIfNeeded`) and consolidate/migrate between them
|
||||
/// explicitly, so a raw lookup by whichever peer ID is selected always
|
||||
/// finds the right timeline without an identity-resolution layer.
|
||||
static func directPeer(_ peerID: PeerID) -> ConversationID {
|
||||
.direct(PeerHandle(id: "peer:\(peerID.id)", routingPeerID: peerID))
|
||||
}
|
||||
}
|
||||
|
||||
extension ConversationStore {
|
||||
/// All direct conversations' messages keyed by routing peer ID — the
|
||||
/// shape `ChatViewModel.privateChats` exposes to the coordinators.
|
||||
/// Values are the conversations' backing arrays (COW), so building this
|
||||
/// is O(#conversations), not O(#messages).
|
||||
func directMessagesByRoutingPeerID() -> [PeerID: [BitchatMessage]] {
|
||||
var messagesByPeerID: [PeerID: [BitchatMessage]] = [:]
|
||||
messagesByPeerID.reserveCapacity(conversationsByID.count)
|
||||
for (id, conversation) in conversationsByID {
|
||||
guard case .direct(let handle) = id else { continue }
|
||||
messagesByPeerID[handle.routingPeerID] = conversation.messages
|
||||
}
|
||||
return messagesByPeerID
|
||||
}
|
||||
|
||||
/// Unread direct conversations as routing peer IDs — the shape
|
||||
/// `ChatViewModel.unreadPrivateMessages` exposes to the coordinators.
|
||||
func unreadDirectRoutingPeerIDs() -> Set<PeerID> {
|
||||
var peerIDs = Set<PeerID>()
|
||||
for id in unreadConversations {
|
||||
guard case .direct(let handle) = id else { continue }
|
||||
peerIDs.insert(handle.routingPeerID)
|
||||
}
|
||||
return peerIDs
|
||||
}
|
||||
|
||||
/// `true` when any direct conversation contains a message with `messageID`
|
||||
/// (O(1) via the store-level message-ID → conversation map).
|
||||
func directConversationsContainMessage(withID messageID: String) -> Bool {
|
||||
conversationIDs(forMessageID: messageID).contains { id in
|
||||
if case .direct = id { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Message IDs across all direct conversations (read-receipt pruning
|
||||
/// keeps only receipts whose messages still exist).
|
||||
func directMessageIDs() -> Set<String> {
|
||||
var messageIDs = Set<String>()
|
||||
for (id, conversation) in conversationsByID {
|
||||
guard case .direct = id else { continue }
|
||||
messageIDs.formUnion(conversation.messageIDs)
|
||||
}
|
||||
return messageIDs
|
||||
}
|
||||
|
||||
/// Removes every direct conversation (panic clear).
|
||||
func removeAllDirectConversations() {
|
||||
let directIDs = conversationIDs.filter { id in
|
||||
if case .direct = id { return true }
|
||||
return false
|
||||
}
|
||||
for id in directIDs {
|
||||
removeConversation(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Diagnostics support
|
||||
|
||||
extension ConversationID {
|
||||
/// Short, log-safe description for audit/diagnostic lines. Direct
|
||||
/// conversations truncate the handle so full peer keys never hit logs.
|
||||
fileprivate var auditDescription: String {
|
||||
switch self {
|
||||
case .mesh:
|
||||
return "mesh"
|
||||
case .geohash(let geohash):
|
||||
return "geo:\(geohash)"
|
||||
case .direct(let handle):
|
||||
return "direct:\(handle.id.prefix(13))…"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
// Test-only corruption hooks for `auditInvariants()` tests. The store is the
|
||||
// sole writer by design — `Conversation`'s mutators are fileprivate and the
|
||||
// store's backing collections are private — so the inconsistent states the
|
||||
// audit exists to catch CANNOT be manufactured through the intent API. These
|
||||
// DEBUG-only hooks deliberately bypass that lockdown to inject exactly those
|
||||
// impossible states. Never call them outside tests.
|
||||
extension Conversation {
|
||||
/// Points an existing message's index entry at the wrong position
|
||||
/// (positions 0 and 1 swap their index entries). Requires >= 2 messages.
|
||||
func _testCorruptIndexEntries() {
|
||||
guard messages.count >= 2 else { return }
|
||||
indexByMessageID[messages[0].id] = 1
|
||||
indexByMessageID[messages[1].id] = 0
|
||||
}
|
||||
|
||||
/// Drops a message's index entry entirely (count mismatch + missing).
|
||||
func _testRemoveIndexEntry(forMessageID messageID: String) {
|
||||
indexByMessageID.removeValue(forKey: messageID)
|
||||
}
|
||||
|
||||
/// Swaps the first and last messages while keeping the index consistent,
|
||||
/// so ONLY the timestamp-order invariant is violated (requires the two
|
||||
/// messages to have distinct timestamps).
|
||||
func _testCorruptOrderingPreservingIndex() {
|
||||
guard messages.count >= 2 else { return }
|
||||
messages.swapAt(0, messages.count - 1)
|
||||
indexByMessageID[messages[0].id] = 0
|
||||
indexByMessageID[messages[messages.count - 1].id] = messages.count - 1
|
||||
}
|
||||
}
|
||||
|
||||
extension ConversationStore {
|
||||
/// Adds a map membership that the conversation does not actually hold.
|
||||
func _testRegisterPhantomMessageID(_ messageID: String, in id: ConversationID) {
|
||||
conversationIDsByMessageID[messageID, default: []].insert(id)
|
||||
}
|
||||
|
||||
/// Drops a real map membership (conversation message missing from map).
|
||||
func _testUnregisterMessageID(_ messageID: String, from id: ConversationID) {
|
||||
conversationIDsByMessageID[messageID]?.remove(id)
|
||||
if conversationIDsByMessageID[messageID]?.isEmpty == true {
|
||||
conversationIDsByMessageID.removeValue(forKey: messageID)
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends past the conversation cap, bypassing trim (map kept exact so
|
||||
/// only the cap invariant is violated).
|
||||
func _testAppendBypassingCap(_ message: BitchatMessage, to id: ConversationID) {
|
||||
let conversation = conversation(for: id)
|
||||
conversation._testAppendBypassingTrim(message)
|
||||
conversationIDsByMessageID[message.id, default: []].insert(id)
|
||||
}
|
||||
|
||||
/// Marks a nonexistent conversation unread without creating it.
|
||||
func _testInsertUnreadConversationID(_ id: ConversationID) {
|
||||
unreadConversations.insert(id)
|
||||
}
|
||||
|
||||
/// Sets the selection directly, without `select(_:)`'s create-on-select.
|
||||
func _testSetSelectedConversationID(_ id: ConversationID?) {
|
||||
selectedConversationID = id
|
||||
}
|
||||
}
|
||||
|
||||
extension Conversation {
|
||||
fileprivate func _testAppendBypassingTrim(_ message: BitchatMessage) {
|
||||
messages.append(message)
|
||||
indexByMessageID[message.id] = messages.count - 1
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Public timeline derived views
|
||||
|
||||
extension ConversationStore {
|
||||
/// Removes a message by ID from whichever public (mesh/geohash)
|
||||
/// conversation contains it. Returns the removed message, if any.
|
||||
@discardableResult
|
||||
func removePublicMessage(withID messageID: String) -> BitchatMessage? {
|
||||
for id in conversationIDs(forMessageID: messageID) {
|
||||
switch id {
|
||||
case .mesh, .geohash:
|
||||
return removeMessage(withID: messageID, from: id)
|
||||
case .direct:
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
final class ConversationUIModel: ObservableObject {
|
||||
@Published private(set) var showAutocomplete = false
|
||||
@Published private(set) var autocompleteSuggestions: [String] = []
|
||||
@Published private(set) var currentNickname: String
|
||||
@Published private(set) var isBatchingPublic = false
|
||||
@Published private(set) var canSendMediaInCurrentContext = true
|
||||
|
||||
private let chatViewModel: ChatViewModel
|
||||
private let privateConversationModel: PrivateConversationModel
|
||||
private let conversations: ConversationStore
|
||||
private var activeChannel: ChannelID
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(
|
||||
chatViewModel: ChatViewModel,
|
||||
privateConversationModel: PrivateConversationModel,
|
||||
conversations: ConversationStore
|
||||
) {
|
||||
self.chatViewModel = chatViewModel
|
||||
self.privateConversationModel = privateConversationModel
|
||||
self.conversations = conversations
|
||||
self.activeChannel = conversations.activeChannel
|
||||
self.currentNickname = chatViewModel.nickname
|
||||
self.isBatchingPublic = chatViewModel.isBatchingPublic
|
||||
self.showAutocomplete = chatViewModel.showAutocomplete
|
||||
self.autocompleteSuggestions = chatViewModel.autocompleteSuggestions
|
||||
self.canSendMediaInCurrentContext = chatViewModel.canSendMediaInCurrentContext
|
||||
|
||||
bind()
|
||||
}
|
||||
|
||||
func setCurrentColorScheme(_ colorScheme: ColorScheme) {
|
||||
chatViewModel.currentColorScheme = colorScheme
|
||||
}
|
||||
|
||||
func setCurrentTheme(_ theme: AppTheme) {
|
||||
chatViewModel.currentTheme = theme
|
||||
}
|
||||
|
||||
func sendMessage(_ message: String) {
|
||||
chatViewModel.sendMessage(message)
|
||||
}
|
||||
|
||||
func clearCurrentConversation() {
|
||||
chatViewModel.sendMessage("/clear")
|
||||
}
|
||||
|
||||
func sendHug(to sender: String) {
|
||||
chatViewModel.sendMessage("/hug @\(sender)")
|
||||
}
|
||||
|
||||
func sendSlap(to sender: String) {
|
||||
chatViewModel.sendMessage("/slap @\(sender)")
|
||||
}
|
||||
|
||||
func block(peerID: PeerID?, displayName: String?) {
|
||||
guard let displayName else { return }
|
||||
|
||||
if let peerID, peerID.isGeoChat,
|
||||
let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) {
|
||||
chatViewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: displayName)
|
||||
} else {
|
||||
chatViewModel.sendMessage("/block \(displayName)")
|
||||
}
|
||||
}
|
||||
|
||||
func updateAutocomplete(for text: String, cursorPosition: Int) {
|
||||
chatViewModel.updateAutocomplete(for: text, cursorPosition: cursorPosition)
|
||||
}
|
||||
|
||||
func completeNickname(_ nickname: String, in text: inout String) -> Int {
|
||||
chatViewModel.completeNickname(nickname, in: &text)
|
||||
}
|
||||
|
||||
func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString {
|
||||
chatViewModel.formatMessageAsText(message, colorScheme: colorScheme, theme: theme)
|
||||
}
|
||||
|
||||
func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString {
|
||||
chatViewModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme)
|
||||
}
|
||||
|
||||
func mediaAttachment(for message: BitchatMessage) -> BitchatMessage.Media? {
|
||||
message.mediaAttachment(for: currentNickname)
|
||||
}
|
||||
|
||||
func isSelfSender(peerID: PeerID?, displayName: String?) -> Bool {
|
||||
chatViewModel.isSelfSender(peerID: peerID, displayName: displayName)
|
||||
}
|
||||
|
||||
func isSentByCurrentUser(_ message: BitchatMessage) -> Bool {
|
||||
message.sender == currentNickname || message.sender.hasPrefix(currentNickname + "#")
|
||||
}
|
||||
|
||||
func isMediaMessageFromCurrentUser(_ message: BitchatMessage) -> Bool {
|
||||
message.sender == currentNickname || message.senderPeerID == chatViewModel.meshService.myPeerID
|
||||
}
|
||||
|
||||
func senderDisplayName(for peerID: PeerID, fallbackMessages: [BitchatMessage]) -> String? {
|
||||
if peerID.isGeoDM || peerID.isGeoChat {
|
||||
return chatViewModel.geohashDisplayName(for: peerID)
|
||||
}
|
||||
if let nickname = chatViewModel.meshService.peerNickname(peerID: peerID) {
|
||||
return nickname
|
||||
}
|
||||
return fallbackMessages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
func processSelectedImage(_ image: UIImage?) {
|
||||
chatViewModel.processThenSendImage(image)
|
||||
}
|
||||
#endif
|
||||
|
||||
func processSelectedImage(from url: URL?) {
|
||||
#if os(macOS)
|
||||
chatViewModel.processThenSendImage(from: url)
|
||||
#endif
|
||||
}
|
||||
|
||||
func sendVoiceNote(at url: URL) {
|
||||
chatViewModel.sendVoiceNote(at: url)
|
||||
}
|
||||
|
||||
func cancelMediaSend(messageID: String) {
|
||||
chatViewModel.cancelMediaSend(messageID: messageID)
|
||||
}
|
||||
|
||||
func deleteMediaMessage(messageID: String) {
|
||||
chatViewModel.deleteMediaMessage(messageID: messageID)
|
||||
}
|
||||
|
||||
private func bind() {
|
||||
chatViewModel.$nickname
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$currentNickname)
|
||||
|
||||
chatViewModel.$showAutocomplete
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$showAutocomplete)
|
||||
|
||||
chatViewModel.$autocompleteSuggestions
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$autocompleteSuggestions)
|
||||
|
||||
chatViewModel.$isBatchingPublic
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$isBatchingPublic)
|
||||
|
||||
conversations.$activeChannel
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] channel in
|
||||
self?.activeChannel = channel
|
||||
self?.refreshComputedState()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
privateConversationModel.$selectedPeerID
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refreshComputedState()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func refreshComputedState() {
|
||||
if let selectedPeerID = privateConversationModel.selectedPeerID {
|
||||
canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat)
|
||||
return
|
||||
}
|
||||
|
||||
switch activeChannel {
|
||||
case .mesh:
|
||||
canSendMediaInCurrentContext = true
|
||||
case .location:
|
||||
canSendMediaInCurrentContext = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class LocationChannelsModel: ObservableObject {
|
||||
@Published private(set) var permissionState: LocationChannelManager.PermissionState
|
||||
@Published private(set) var availableChannels: [GeohashChannel]
|
||||
@Published private(set) var selectedChannel: ChannelID
|
||||
@Published private(set) var teleported: Bool
|
||||
@Published private(set) var bookmarks: [String]
|
||||
@Published private(set) var bookmarkNames: [String: String]
|
||||
@Published private(set) var locationNames: [GeohashChannelLevel: String]
|
||||
@Published private(set) var userTorEnabled: Bool
|
||||
|
||||
private let manager: LocationChannelManager
|
||||
private let network: NetworkActivationService
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(
|
||||
manager: LocationChannelManager? = nil,
|
||||
network: NetworkActivationService? = nil
|
||||
) {
|
||||
let manager = manager ?? .shared
|
||||
let network = network ?? .shared
|
||||
|
||||
self.manager = manager
|
||||
self.network = network
|
||||
self.permissionState = manager.permissionState
|
||||
self.availableChannels = manager.availableChannels
|
||||
self.selectedChannel = manager.selectedChannel
|
||||
self.teleported = manager.teleported
|
||||
self.bookmarks = manager.bookmarks
|
||||
self.bookmarkNames = manager.bookmarkNames
|
||||
self.locationNames = manager.locationNames
|
||||
self.userTorEnabled = network.userTorEnabled
|
||||
|
||||
bind()
|
||||
}
|
||||
|
||||
var currentBuildingGeohash: String? {
|
||||
availableChannels.first(where: { $0.level == .building })?.geohash
|
||||
}
|
||||
|
||||
func isSelected(_ channel: GeohashChannel) -> Bool {
|
||||
guard case .location(let selected) = selectedChannel else { return false }
|
||||
return selected == channel
|
||||
}
|
||||
|
||||
func isBookmarked(_ geohash: String) -> Bool {
|
||||
manager.isBookmarked(geohash)
|
||||
}
|
||||
|
||||
func enableLocationChannels() {
|
||||
manager.enableLocationChannels()
|
||||
}
|
||||
|
||||
func refreshChannels() {
|
||||
manager.refreshChannels()
|
||||
}
|
||||
|
||||
func enableAndRefresh() {
|
||||
manager.enableLocationChannels()
|
||||
manager.refreshChannels()
|
||||
}
|
||||
|
||||
func beginLiveRefresh() {
|
||||
manager.beginLiveRefresh()
|
||||
}
|
||||
|
||||
func endLiveRefresh() {
|
||||
manager.endLiveRefresh()
|
||||
}
|
||||
|
||||
func select(_ channel: ChannelID) {
|
||||
manager.select(channel)
|
||||
}
|
||||
|
||||
func markTeleported(for geohash: String, _ flag: Bool) {
|
||||
manager.markTeleported(for: geohash, flag)
|
||||
}
|
||||
|
||||
func toggleBookmark(_ geohash: String) {
|
||||
manager.toggleBookmark(geohash)
|
||||
}
|
||||
|
||||
func resolveBookmarkNameIfNeeded(for geohash: String) {
|
||||
manager.resolveBookmarkNameIfNeeded(for: geohash)
|
||||
}
|
||||
|
||||
func locationName(for level: GeohashChannelLevel) -> String? {
|
||||
locationNames[level]
|
||||
}
|
||||
|
||||
func setUserTorEnabled(_ enabled: Bool) {
|
||||
network.setUserTorEnabled(enabled)
|
||||
}
|
||||
|
||||
func refreshMeshChannelsIfNeeded() {
|
||||
guard case .mesh = selectedChannel,
|
||||
permissionState == .authorized,
|
||||
availableChannels.isEmpty else {
|
||||
return
|
||||
}
|
||||
refreshChannels()
|
||||
}
|
||||
|
||||
func openLocationChannel(for geohash: String) {
|
||||
let normalized = geohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
guard (2...12).contains(normalized.count),
|
||||
normalized.allSatisfy({ allowed.contains($0) }) else {
|
||||
return
|
||||
}
|
||||
|
||||
let channel = GeohashChannel(level: level(forLength: normalized.count), geohash: normalized)
|
||||
let isRegional = availableChannels.contains { $0.geohash == normalized }
|
||||
if !isRegional && !availableChannels.isEmpty {
|
||||
markTeleported(for: normalized, true)
|
||||
}
|
||||
select(.location(channel))
|
||||
}
|
||||
|
||||
func teleport(to geohash: String) {
|
||||
let normalized = geohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let channel = GeohashChannel(level: level(forLength: normalized.count), geohash: normalized)
|
||||
markTeleported(for: normalized, true)
|
||||
select(.location(channel))
|
||||
}
|
||||
|
||||
private func bind() {
|
||||
manager.$permissionState
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$permissionState)
|
||||
|
||||
manager.$availableChannels
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$availableChannels)
|
||||
|
||||
manager.$selectedChannel
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$selectedChannel)
|
||||
|
||||
manager.$teleported
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$teleported)
|
||||
|
||||
manager.$bookmarks
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$bookmarks)
|
||||
|
||||
manager.$bookmarkNames
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$bookmarkNames)
|
||||
|
||||
manager.$locationNames
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$locationNames)
|
||||
|
||||
network.$userTorEnabled
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$userTorEnabled)
|
||||
}
|
||||
|
||||
private func level(forLength length: Int) -> GeohashChannelLevel {
|
||||
switch length {
|
||||
case 0...2: return .region
|
||||
case 3...4: return .province
|
||||
case 5: return .city
|
||||
case 6: return .neighborhood
|
||||
case 7: return .block
|
||||
case 8...12: return .building
|
||||
default: return .block
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class LocationPresenceStore: ObservableObject {
|
||||
@Published private(set) var currentGeohash: String?
|
||||
@Published private(set) var geoNicknames: [String: String] = [:]
|
||||
@Published private(set) var teleportedGeo: Set<String> = []
|
||||
|
||||
func setCurrentGeohash(_ geohash: String?) {
|
||||
currentGeohash = geohash?.lowercased()
|
||||
}
|
||||
|
||||
func setNickname(_ nickname: String, for pubkeyHex: String) {
|
||||
geoNicknames[pubkeyHex.lowercased()] = nickname
|
||||
}
|
||||
|
||||
func replaceGeoNicknames(_ nicknames: [String: String]) {
|
||||
geoNicknames = Dictionary(
|
||||
uniqueKeysWithValues: nicknames.map { key, value in
|
||||
(key.lowercased(), value)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func clearGeoNicknames() {
|
||||
geoNicknames.removeAll()
|
||||
}
|
||||
|
||||
func markTeleported(_ pubkeyHex: String) {
|
||||
teleportedGeo.insert(pubkeyHex.lowercased())
|
||||
}
|
||||
|
||||
func clearTeleported(_ pubkeyHex: String) {
|
||||
teleportedGeo.remove(pubkeyHex.lowercased())
|
||||
}
|
||||
|
||||
func replaceTeleportedGeo(_ pubkeys: Set<String>) {
|
||||
teleportedGeo = Set(pubkeys.map { $0.lowercased() })
|
||||
}
|
||||
|
||||
func clearTeleportedGeo() {
|
||||
teleportedGeo.removeAll()
|
||||
}
|
||||
|
||||
func reset() {
|
||||
currentGeohash = nil
|
||||
geoNicknames.removeAll()
|
||||
teleportedGeo.removeAll()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class PeerIdentityStore: ObservableObject {
|
||||
@Published private(set) var encryptionStatuses: [PeerID: EncryptionStatus] = [:]
|
||||
@Published private(set) var verifiedFingerprints: Set<String> = []
|
||||
|
||||
private(set) var peerFingerprintsByPeerID: [PeerID: String] = [:]
|
||||
private(set) var selectedPrivateChatFingerprint: String?
|
||||
|
||||
private var stablePeerIDsByShortID: [PeerID: PeerID] = [:]
|
||||
private var encryptionStatusCache: [PeerID: EncryptionStatus] = [:]
|
||||
|
||||
func stablePeerID(forShortID peerID: PeerID) -> PeerID? {
|
||||
stablePeerIDsByShortID[peerID]
|
||||
}
|
||||
|
||||
func shortPeerID(forStablePeerID stablePeerID: PeerID) -> PeerID? {
|
||||
stablePeerIDsByShortID.first(where: { $0.value == stablePeerID })?.key
|
||||
}
|
||||
|
||||
func setStablePeerID(_ stablePeerID: PeerID, forShortID peerID: PeerID) {
|
||||
stablePeerIDsByShortID[peerID] = stablePeerID
|
||||
}
|
||||
|
||||
func replaceStablePeerIDs(_ mappings: [PeerID: PeerID]) {
|
||||
stablePeerIDsByShortID = mappings
|
||||
}
|
||||
|
||||
func fingerprint(for peerID: PeerID) -> String? {
|
||||
peerFingerprintsByPeerID[peerID]
|
||||
}
|
||||
|
||||
func setFingerprint(_ fingerprint: String?, for peerID: PeerID) {
|
||||
if let fingerprint {
|
||||
peerFingerprintsByPeerID[peerID] = fingerprint
|
||||
} else {
|
||||
peerFingerprintsByPeerID.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func replaceFingerprintMappings(_ mappings: [PeerID: String]) {
|
||||
peerFingerprintsByPeerID = mappings
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func migrateFingerprintMapping(
|
||||
from oldPeerID: PeerID,
|
||||
to newPeerID: PeerID,
|
||||
fallback: String? = nil
|
||||
) -> String? {
|
||||
let fingerprint = peerFingerprintsByPeerID.removeValue(forKey: oldPeerID) ?? fallback
|
||||
if let fingerprint {
|
||||
peerFingerprintsByPeerID[newPeerID] = fingerprint
|
||||
if selectedPrivateChatFingerprint == nil {
|
||||
selectedPrivateChatFingerprint = fingerprint
|
||||
}
|
||||
}
|
||||
return fingerprint
|
||||
}
|
||||
|
||||
func setSelectedPrivateChatFingerprint(_ fingerprint: String?) {
|
||||
selectedPrivateChatFingerprint = fingerprint
|
||||
}
|
||||
|
||||
func cachedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus? {
|
||||
encryptionStatusCache[peerID]
|
||||
}
|
||||
|
||||
func setCachedEncryptionStatus(_ status: EncryptionStatus, for peerID: PeerID) {
|
||||
encryptionStatusCache[peerID] = status
|
||||
}
|
||||
|
||||
func invalidateEncryptionCache(for peerID: PeerID? = nil) {
|
||||
if let peerID {
|
||||
encryptionStatusCache.removeValue(forKey: peerID)
|
||||
} else {
|
||||
encryptionStatusCache.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
func encryptionStatus(for peerID: PeerID) -> EncryptionStatus? {
|
||||
encryptionStatuses[peerID]
|
||||
}
|
||||
|
||||
func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) {
|
||||
if let status {
|
||||
encryptionStatuses[peerID] = status
|
||||
} else {
|
||||
encryptionStatuses.removeValue(forKey: peerID)
|
||||
}
|
||||
invalidateEncryptionCache(for: peerID)
|
||||
}
|
||||
|
||||
func replaceEncryptionStatuses(_ statuses: [PeerID: EncryptionStatus]) {
|
||||
encryptionStatuses = statuses
|
||||
}
|
||||
|
||||
func setVerifiedFingerprints(_ fingerprints: Set<String>) {
|
||||
verifiedFingerprints = fingerprints
|
||||
}
|
||||
|
||||
func setVerified(_ fingerprint: String, verified: Bool) {
|
||||
if verified {
|
||||
verifiedFingerprints.insert(fingerprint)
|
||||
} else {
|
||||
verifiedFingerprints.remove(fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
func isVerified(_ fingerprint: String) -> Bool {
|
||||
verifiedFingerprints.contains(fingerprint)
|
||||
}
|
||||
|
||||
func clearAll() {
|
||||
encryptionStatuses.removeAll()
|
||||
verifiedFingerprints.removeAll()
|
||||
peerFingerprintsByPeerID.removeAll()
|
||||
selectedPrivateChatFingerprint = nil
|
||||
stablePeerIDsByShortID.removeAll()
|
||||
encryptionStatusCache.removeAll()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
struct MeshPeerRow: Identifiable, Equatable {
|
||||
let peerID: PeerID
|
||||
let displayName: String
|
||||
let isMe: Bool
|
||||
let hasUnread: Bool
|
||||
let isBlocked: Bool
|
||||
let isFavorite: Bool
|
||||
let isConnected: Bool
|
||||
let isReachable: Bool
|
||||
let isMutualFavorite: Bool
|
||||
let encryptionStatus: EncryptionStatus
|
||||
let showsVerifiedBadgeWhenOffline: Bool
|
||||
|
||||
var id: String { peerID.id }
|
||||
}
|
||||
|
||||
struct GeohashPersonRow: Identifiable, Equatable {
|
||||
let id: String
|
||||
let displayName: String
|
||||
let isMe: Bool
|
||||
let isTeleported: Bool
|
||||
let isBlocked: Bool
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class PeerListModel: ObservableObject {
|
||||
@Published private(set) var allPeers: [BitchatPeer] = []
|
||||
@Published private(set) var meshRows: [MeshPeerRow] = []
|
||||
@Published private(set) var geohashPeople: [GeohashPersonRow] = []
|
||||
@Published private(set) var reachableMeshPeerCount = 0
|
||||
@Published private(set) var connectedMeshPeerCount = 0
|
||||
@Published private(set) var visibleGeohashPeerCount = 0
|
||||
@Published private(set) var renderID = ""
|
||||
|
||||
private let chatViewModel: ChatViewModel
|
||||
private let conversations: ConversationStore
|
||||
private let locationChannelsModel: LocationChannelsModel
|
||||
private let peerIdentityStore: PeerIdentityStore
|
||||
private let locationPresenceStore: LocationPresenceStore
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(
|
||||
chatViewModel: ChatViewModel,
|
||||
conversations: ConversationStore,
|
||||
locationChannelsModel: LocationChannelsModel? = nil,
|
||||
peerIdentityStore: PeerIdentityStore? = nil,
|
||||
locationPresenceStore: LocationPresenceStore? = nil
|
||||
) {
|
||||
self.chatViewModel = chatViewModel
|
||||
self.conversations = conversations
|
||||
self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel()
|
||||
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
|
||||
self.locationPresenceStore = locationPresenceStore ?? chatViewModel.locationPresenceStore
|
||||
self.allPeers = chatViewModel.allPeers
|
||||
|
||||
bind()
|
||||
refresh()
|
||||
}
|
||||
|
||||
func colorForMeshPeer(id peerID: PeerID, isDark: Bool) -> Color {
|
||||
chatViewModel.colorForMeshPeer(id: peerID, isDark: isDark)
|
||||
}
|
||||
|
||||
func colorForGeohashPerson(id: String, isDark: Bool) -> Color {
|
||||
chatViewModel.colorForNostrPubkey(id, isDark: isDark)
|
||||
}
|
||||
|
||||
func participantCount(for geohash: String) -> Int {
|
||||
chatViewModel.geohashParticipantCount(for: geohash)
|
||||
}
|
||||
|
||||
func startConversation(with peerID: PeerID) {
|
||||
chatViewModel.startPrivateChat(with: peerID)
|
||||
}
|
||||
|
||||
func toggleFavorite(peerID: PeerID) {
|
||||
chatViewModel.toggleFavorite(peerID: peerID)
|
||||
}
|
||||
|
||||
func openGeohashDirectMessage(with pubkeyHex: String) {
|
||||
chatViewModel.startGeohashDM(withPubkeyHex: pubkeyHex)
|
||||
}
|
||||
|
||||
func blockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
|
||||
chatViewModel.blockGeohashUser(
|
||||
pubkeyHexLowercased: pubkeyHexLowercased,
|
||||
displayName: displayName
|
||||
)
|
||||
}
|
||||
|
||||
func unblockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
|
||||
chatViewModel.unblockGeohashUser(
|
||||
pubkeyHexLowercased: pubkeyHexLowercased,
|
||||
displayName: displayName
|
||||
)
|
||||
}
|
||||
|
||||
private func bind() {
|
||||
chatViewModel.$allPeers
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] peers in
|
||||
self?.allPeers = peers
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
chatViewModel.$nickname
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
locationPresenceStore.$teleportedGeo
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
conversations.$unreadConversations
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
peerIdentityStore.$encryptionStatuses
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
peerIdentityStore.$verifiedFingerprints
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
chatViewModel.participantTracker.$visiblePeople
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
locationChannelsModel.$selectedChannel
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
locationChannelsModel.$teleported
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
locationChannelsModel.$availableChannels
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func refresh() {
|
||||
let myPeerID = chatViewModel.meshService.myPeerID
|
||||
let meshRows = allPeers.map { peer in
|
||||
let isMe = peer.peerID == myPeerID
|
||||
let verifiedBadge: Bool
|
||||
if !isMe && !peer.isConnected,
|
||||
let fingerprint = chatViewModel.getFingerprint(for: peer.peerID) {
|
||||
verifiedBadge = peerIdentityStore.isVerified(fingerprint)
|
||||
} else {
|
||||
verifiedBadge = false
|
||||
}
|
||||
|
||||
return MeshPeerRow(
|
||||
peerID: peer.peerID,
|
||||
displayName: isMe ? chatViewModel.nickname : peer.nickname,
|
||||
isMe: isMe,
|
||||
hasUnread: chatViewModel.hasUnreadMessages(for: peer.peerID),
|
||||
isBlocked: !isMe && chatViewModel.isPeerBlocked(peer.peerID),
|
||||
isFavorite: peer.favoriteStatus?.isFavorite ?? false,
|
||||
isConnected: peer.isConnected,
|
||||
isReachable: peer.isReachable,
|
||||
isMutualFavorite: peer.isMutualFavorite,
|
||||
encryptionStatus: chatViewModel.getEncryptionStatus(for: peer.peerID),
|
||||
showsVerifiedBadgeWhenOffline: verifiedBadge
|
||||
)
|
||||
}
|
||||
|
||||
let meshCounts = meshRows.reduce(into: (reachable: 0, connected: 0)) { counts, row in
|
||||
guard !row.isMe else { return }
|
||||
if row.isConnected {
|
||||
counts.connected += 1
|
||||
counts.reachable += 1
|
||||
} else if row.isReachable {
|
||||
counts.reachable += 1
|
||||
}
|
||||
}
|
||||
|
||||
let geohashPeople = buildGeohashPeople()
|
||||
|
||||
self.meshRows = meshRows
|
||||
reachableMeshPeerCount = meshCounts.reachable
|
||||
connectedMeshPeerCount = meshCounts.connected
|
||||
self.geohashPeople = geohashPeople
|
||||
visibleGeohashPeerCount = geohashPeople.count
|
||||
renderID = (
|
||||
meshRows.map {
|
||||
"\($0.id)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
|
||||
} +
|
||||
geohashPeople.map {
|
||||
"geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)"
|
||||
}
|
||||
).joined(separator: "|")
|
||||
}
|
||||
|
||||
private func buildGeohashPeople() -> [GeohashPersonRow] {
|
||||
let myHex = currentGeohashIdentityHex()
|
||||
let teleportedSet = Set(locationPresenceStore.teleportedGeo.map { $0.lowercased() })
|
||||
|
||||
return chatViewModel.visibleGeohashPeople().map { person in
|
||||
let isMe = person.id == myHex
|
||||
return GeohashPersonRow(
|
||||
id: person.id,
|
||||
displayName: person.displayName,
|
||||
isMe: isMe,
|
||||
isTeleported: teleportedSet.contains(person.id.lowercased()) || (isMe && locationChannelsModel.teleported),
|
||||
isBlocked: !isMe && chatViewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func currentGeohashIdentityHex() -> String? {
|
||||
guard case .location(let channel) = locationChannelsModel.selectedChannel,
|
||||
let identity = try? chatViewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return identity.publicKeyHex.lowercased()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
/// Feature model for private (direct) conversations.
|
||||
///
|
||||
/// Reads the single-writer `ConversationStore` directly: `messages(for:)`
|
||||
/// returns the peer's conversation backing array (no mirror dictionary), and
|
||||
/// the store's typed `changes` subject drives invalidation — a change in the
|
||||
/// SELECTED peer's conversation republishes this model, while appends to
|
||||
/// other private chats only surface through the unread set. Direct
|
||||
/// conversations are keyed by raw routing peer ID; the coordinators'
|
||||
/// ephemeral/stable mirroring guarantees the selected peer's key always
|
||||
/// holds the full timeline (see `ConversationID.directPeer`).
|
||||
@MainActor
|
||||
final class PrivateInboxModel: ObservableObject {
|
||||
@Published private(set) var selectedPeerID: PeerID?
|
||||
@Published private(set) var unreadPeerIDs: Set<PeerID> = []
|
||||
|
||||
private let conversations: ConversationStore
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(conversations: ConversationStore) {
|
||||
self.conversations = conversations
|
||||
self.selectedPeerID = conversations.selectedPrivatePeerID
|
||||
self.unreadPeerIDs = conversations.unreadDirectRoutingPeerIDs()
|
||||
|
||||
bind()
|
||||
}
|
||||
|
||||
func messages(for peerID: PeerID?) -> [BitchatMessage] {
|
||||
guard let peerID else { return [] }
|
||||
return conversations.conversationsByID[.directPeer(peerID)]?.messages ?? []
|
||||
}
|
||||
|
||||
private func bind() {
|
||||
conversations.$selectedPrivatePeerID
|
||||
.dropFirst()
|
||||
.sink { [weak self] peerID in
|
||||
guard let self, self.selectedPeerID != peerID else { return }
|
||||
self.selectedPeerID = peerID
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
conversations.changes
|
||||
.sink { [weak self] change in
|
||||
self?.apply(change)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func apply(_ change: ConversationChange) {
|
||||
switch change {
|
||||
case .appended(let id, _),
|
||||
.updated(let id, _),
|
||||
.statusChanged(let id, _, _),
|
||||
.messageRemoved(let id, _),
|
||||
.cleared(let id):
|
||||
republishIfSelected(id)
|
||||
|
||||
case .unreadChanged(let id, _):
|
||||
guard isDirect(id) else { return }
|
||||
refreshUnreadPeerIDs()
|
||||
|
||||
case .removed(let id):
|
||||
guard isDirect(id) else { return }
|
||||
refreshUnreadPeerIDs()
|
||||
republishIfSelected(id)
|
||||
|
||||
case .migrated(let source, let destination):
|
||||
guard isDirect(source) || isDirect(destination) else { return }
|
||||
refreshUnreadPeerIDs()
|
||||
republishIfSelected(source)
|
||||
republishIfSelected(destination)
|
||||
}
|
||||
}
|
||||
|
||||
private func republishIfSelected(_ id: ConversationID) {
|
||||
guard let selectedPeerID, id == .directPeer(selectedPeerID) else { return }
|
||||
objectWillChange.send()
|
||||
}
|
||||
|
||||
private func refreshUnreadPeerIDs() {
|
||||
let next = conversations.unreadDirectRoutingPeerIDs()
|
||||
guard unreadPeerIDs != next else { return }
|
||||
unreadPeerIDs = next
|
||||
}
|
||||
|
||||
private func isDirect(_ id: ConversationID) -> Bool {
|
||||
if case .direct = id { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
enum PrivateConversationAvailability: Equatable {
|
||||
case bluetoothConnected
|
||||
case meshReachable
|
||||
case nostrAvailable
|
||||
case offline
|
||||
}
|
||||
|
||||
struct PrivateConversationHeaderState: Equatable {
|
||||
let conversationPeerID: PeerID
|
||||
let headerPeerID: PeerID
|
||||
let displayName: String
|
||||
let availability: PrivateConversationAvailability
|
||||
let isFavorite: Bool
|
||||
let encryptionStatus: EncryptionStatus?
|
||||
|
||||
var supportsFavoriteToggle: Bool {
|
||||
!conversationPeerID.isGeoDM
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class PrivateConversationModel: ObservableObject {
|
||||
@Published private(set) var selectedPeerID: PeerID?
|
||||
@Published private(set) var selectedHeaderState: PrivateConversationHeaderState?
|
||||
|
||||
private let chatViewModel: ChatViewModel
|
||||
private let conversations: ConversationStore
|
||||
private let locationChannelsModel: LocationChannelsModel
|
||||
private let peerIdentityStore: PeerIdentityStore
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(
|
||||
chatViewModel: ChatViewModel,
|
||||
conversations: ConversationStore,
|
||||
locationChannelsModel: LocationChannelsModel? = nil,
|
||||
peerIdentityStore: PeerIdentityStore? = nil
|
||||
) {
|
||||
self.chatViewModel = chatViewModel
|
||||
self.conversations = conversations
|
||||
self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel()
|
||||
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
|
||||
let initialPeerID = conversations.selectedPrivatePeerID
|
||||
self.selectedPeerID = initialPeerID
|
||||
self.selectedHeaderState = initialPeerID.flatMap { peerID in
|
||||
makeHeaderState(for: peerID)
|
||||
}
|
||||
|
||||
bind()
|
||||
}
|
||||
|
||||
func startConversation(with peerID: PeerID) {
|
||||
chatViewModel.startPrivateChat(with: peerID)
|
||||
refreshSelectedConversation()
|
||||
}
|
||||
|
||||
func openConversation(for peerID: PeerID) {
|
||||
if peerID.isGeoChat {
|
||||
guard let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) else { return }
|
||||
chatViewModel.startGeohashDM(withPubkeyHex: full)
|
||||
} else {
|
||||
chatViewModel.startPrivateChat(with: peerID)
|
||||
}
|
||||
|
||||
refreshSelectedConversation()
|
||||
}
|
||||
|
||||
func endConversation() {
|
||||
chatViewModel.endPrivateChat()
|
||||
refreshSelectedConversation()
|
||||
}
|
||||
|
||||
func toggleFavorite(peerID: PeerID) {
|
||||
chatViewModel.toggleFavorite(peerID: peerID)
|
||||
refreshSelectedConversation()
|
||||
}
|
||||
|
||||
func toggleFavoriteForSelectedConversation() {
|
||||
guard let headerPeerID = selectedHeaderState?.headerPeerID else { return }
|
||||
toggleFavorite(peerID: headerPeerID)
|
||||
}
|
||||
|
||||
func markMessagesAsRead(from peerID: PeerID) {
|
||||
chatViewModel.markPrivateMessagesAsRead(from: peerID)
|
||||
}
|
||||
|
||||
private func bind() {
|
||||
conversations.$selectedPrivatePeerID
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refreshSelectedConversation()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
chatViewModel.$allPeers
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refreshSelectedConversation()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
peerIdentityStore.$encryptionStatuses
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refreshSelectedConversation()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: .favoriteStatusChanged)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refreshSelectedConversation()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refreshSelectedConversation()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
locationChannelsModel.$selectedChannel
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refreshSelectedConversation()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func refreshSelectedConversation() {
|
||||
selectedPeerID = conversations.selectedPrivatePeerID
|
||||
selectedHeaderState = selectedPeerID.flatMap { peerID in
|
||||
makeHeaderState(for: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeHeaderState(for conversationPeerID: PeerID) -> PrivateConversationHeaderState {
|
||||
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
|
||||
let peer = chatViewModel.getPeer(byID: headerPeerID)
|
||||
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
|
||||
let availability = resolveAvailability(for: headerPeerID, peer: peer)
|
||||
let encryptionStatus: EncryptionStatus? = conversationPeerID.isGeoDM
|
||||
? nil
|
||||
: chatViewModel.getEncryptionStatus(for: headerPeerID)
|
||||
|
||||
return PrivateConversationHeaderState(
|
||||
conversationPeerID: conversationPeerID,
|
||||
headerPeerID: headerPeerID,
|
||||
displayName: displayName,
|
||||
availability: availability,
|
||||
isFavorite: chatViewModel.isFavorite(peerID: headerPeerID),
|
||||
encryptionStatus: encryptionStatus
|
||||
)
|
||||
}
|
||||
|
||||
private func resolveDisplayName(
|
||||
for conversationPeerID: PeerID,
|
||||
headerPeerID: PeerID,
|
||||
peer: BitchatPeer?
|
||||
) -> String {
|
||||
if conversationPeerID.isGeoDM, case .location(let channel) = locationChannelsModel.selectedChannel {
|
||||
return "#\(channel.geohash)/@\(chatViewModel.geohashDisplayName(for: conversationPeerID))"
|
||||
}
|
||||
if let displayName = peer?.displayName {
|
||||
return displayName
|
||||
}
|
||||
if let nickname = chatViewModel.meshService.peerNickname(peerID: headerPeerID) {
|
||||
return nickname
|
||||
}
|
||||
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(
|
||||
for: Data(hexString: headerPeerID.id) ?? Data()
|
||||
), !favorite.peerNickname.isEmpty {
|
||||
return favorite.peerNickname
|
||||
}
|
||||
if headerPeerID.id.count == 16 {
|
||||
let candidates = chatViewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
|
||||
if let identity = candidates.first,
|
||||
let social = chatViewModel.identityManager.getSocialIdentity(for: identity.fingerprint) {
|
||||
if let pet = social.localPetname, !pet.isEmpty {
|
||||
return pet
|
||||
}
|
||||
if !social.claimedNickname.isEmpty {
|
||||
return social.claimedNickname
|
||||
}
|
||||
}
|
||||
} else if let noiseKey = headerPeerID.noiseKey {
|
||||
let fingerprint = noiseKey.sha256Fingerprint()
|
||||
if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint) {
|
||||
if let pet = social.localPetname, !pet.isEmpty {
|
||||
return pet
|
||||
}
|
||||
if !social.claimedNickname.isEmpty {
|
||||
return social.claimedNickname
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return String(localized: "common.unknown", comment: "Fallback label for unknown peer")
|
||||
}
|
||||
|
||||
private func resolveAvailability(for headerPeerID: PeerID, peer: BitchatPeer?) -> PrivateConversationAvailability {
|
||||
if let connectionState = peer?.connectionState {
|
||||
switch connectionState {
|
||||
case .bluetoothConnected:
|
||||
return .bluetoothConnected
|
||||
case .meshReachable:
|
||||
return .meshReachable
|
||||
case .nostrAvailable:
|
||||
return .nostrAvailable
|
||||
case .offline:
|
||||
return .offline
|
||||
}
|
||||
}
|
||||
|
||||
if chatViewModel.meshService.isPeerReachable(headerPeerID) {
|
||||
return .meshReachable
|
||||
}
|
||||
if let noiseKey = Data(hexString: headerPeerID.id),
|
||||
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||
favoriteStatus.isMutual {
|
||||
return .nostrAvailable
|
||||
}
|
||||
if chatViewModel.meshService.isPeerConnected(headerPeerID) || chatViewModel.connectedPeers.contains(headerPeerID) {
|
||||
return .bluetoothConnected
|
||||
}
|
||||
|
||||
return .offline
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
/// Feature model for the active public (mesh/geohash) timeline.
|
||||
///
|
||||
/// Observes ONE `Conversation` object in the single-writer
|
||||
/// `ConversationStore` — the active channel's — so appends to background
|
||||
/// conversations (other geohashes, private chats) never invalidate it.
|
||||
/// `messages` reads the observed conversation's backing array directly;
|
||||
/// there is no mirror copy.
|
||||
@MainActor
|
||||
final class PublicChatModel: ObservableObject {
|
||||
@Published private(set) var activeChannel: ChannelID
|
||||
|
||||
/// The active public conversation's timeline.
|
||||
var messages: [BitchatMessage] { activeConversation.messages }
|
||||
|
||||
private let conversations: ConversationStore
|
||||
private var activeConversation: Conversation
|
||||
private var activeConversationCancellable: AnyCancellable?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(conversations: ConversationStore) {
|
||||
let channel = conversations.activeChannel
|
||||
self.conversations = conversations
|
||||
self.activeChannel = channel
|
||||
self.activeConversation = conversations.conversation(for: ConversationID(channelID: channel))
|
||||
|
||||
observeActiveConversation()
|
||||
bind()
|
||||
}
|
||||
|
||||
private func bind() {
|
||||
conversations.$activeChannel
|
||||
.dropFirst()
|
||||
.sink { [weak self] channel in
|
||||
guard let self else { return }
|
||||
self.activeChannel = channel
|
||||
self.retargetActiveConversation(to: channel)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// The store replaces a conversation's object when it is removed
|
||||
// (panic clear); retarget to the fresh instance so the observation
|
||||
// never goes stale.
|
||||
conversations.changes
|
||||
.sink { [weak self] change in
|
||||
guard let self,
|
||||
case .removed(let id) = change,
|
||||
id == self.activeConversation.id else { return }
|
||||
self.retargetActiveConversation(to: self.activeChannel)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func retargetActiveConversation(to channel: ChannelID) {
|
||||
let conversation = conversations.conversation(for: ConversationID(channelID: channel))
|
||||
guard conversation !== activeConversation else {
|
||||
// Same object (e.g. re-selected channel): keep the existing
|
||||
// observation, but `messages` may still differ from what views
|
||||
// last rendered, so republish.
|
||||
objectWillChange.send()
|
||||
return
|
||||
}
|
||||
objectWillChange.send()
|
||||
activeConversation = conversation
|
||||
observeActiveConversation()
|
||||
}
|
||||
|
||||
private func observeActiveConversation() {
|
||||
activeConversationCancellable = activeConversation.objectWillChange
|
||||
.sink { [weak self] _ in
|
||||
self?.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
struct FingerprintPresentationState: Equatable {
|
||||
let statusPeerID: PeerID
|
||||
let peerNickname: String
|
||||
let encryptionStatus: EncryptionStatus
|
||||
let theirFingerprint: String?
|
||||
let myFingerprint: String
|
||||
let isVerified: Bool
|
||||
|
||||
var canToggleVerification: Bool {
|
||||
encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified
|
||||
}
|
||||
}
|
||||
|
||||
enum VerificationScanOutcome: Equatable {
|
||||
case requested(String)
|
||||
case notFound
|
||||
case invalid
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class VerificationModel: ObservableObject {
|
||||
@Published private(set) var currentNickname: String
|
||||
@Published private(set) var selectedPeerID: PeerID?
|
||||
|
||||
private let chatViewModel: ChatViewModel
|
||||
private let peerIdentityStore: PeerIdentityStore
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(
|
||||
chatViewModel: ChatViewModel,
|
||||
privateConversationModel: PrivateConversationModel,
|
||||
peerIdentityStore: PeerIdentityStore? = nil
|
||||
) {
|
||||
self.chatViewModel = chatViewModel
|
||||
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
|
||||
self.currentNickname = chatViewModel.nickname
|
||||
self.selectedPeerID = privateConversationModel.selectedPeerID
|
||||
|
||||
bind(privateConversationModel: privateConversationModel)
|
||||
}
|
||||
|
||||
func myQRString() -> String {
|
||||
let npub = try? chatViewModel.idBridge.getCurrentNostrIdentity()?.npub
|
||||
return VerificationService.shared.buildMyQRString(nickname: currentNickname, npub: npub) ?? ""
|
||||
}
|
||||
|
||||
func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {
|
||||
chatViewModel.beginQRVerification(with: qr)
|
||||
}
|
||||
|
||||
func verifyScannedPayload(_ payload: String) -> VerificationScanOutcome {
|
||||
guard let qr = VerificationService.shared.verifyScannedQR(payload) else {
|
||||
return .invalid
|
||||
}
|
||||
|
||||
guard chatViewModel.beginQRVerification(with: qr) else {
|
||||
return .notFound
|
||||
}
|
||||
|
||||
return .requested(qr.nickname)
|
||||
}
|
||||
|
||||
func verifyFingerprint(for peerID: PeerID) {
|
||||
chatViewModel.verifyFingerprint(for: peerID)
|
||||
}
|
||||
|
||||
func unverifyFingerprint(for peerID: PeerID) {
|
||||
chatViewModel.unverifyFingerprint(for: peerID)
|
||||
}
|
||||
|
||||
func isVerified(peerID: PeerID) -> Bool {
|
||||
guard let fingerprint = chatViewModel.getFingerprint(for: peerID) else { return false }
|
||||
return peerIdentityStore.isVerified(fingerprint)
|
||||
}
|
||||
|
||||
func fingerprintPresentation(for peerID: PeerID) -> FingerprintPresentationState {
|
||||
let statusPeerID = chatViewModel.getShortIDForNoiseKey(peerID)
|
||||
let encryptionStatus = chatViewModel.getEncryptionStatus(for: statusPeerID)
|
||||
let theirFingerprint = chatViewModel.getFingerprint(for: statusPeerID)
|
||||
let peerNickname = resolveDisplayName(for: peerID, statusPeerID: statusPeerID)
|
||||
|
||||
return FingerprintPresentationState(
|
||||
statusPeerID: statusPeerID,
|
||||
peerNickname: peerNickname,
|
||||
encryptionStatus: encryptionStatus,
|
||||
theirFingerprint: theirFingerprint,
|
||||
myFingerprint: chatViewModel.getMyFingerprint(),
|
||||
isVerified: theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false
|
||||
)
|
||||
}
|
||||
|
||||
private func bind(privateConversationModel: PrivateConversationModel) {
|
||||
chatViewModel.$nickname
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$currentNickname)
|
||||
|
||||
privateConversationModel.$selectedPeerID
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$selectedPeerID)
|
||||
|
||||
peerIdentityStore.$encryptionStatuses
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.objectWillChange.send()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
peerIdentityStore.$verifiedFingerprints
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.objectWillChange.send()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
chatViewModel.$allPeers
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.objectWillChange.send()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func resolveDisplayName(for peerID: PeerID, statusPeerID: PeerID) -> String {
|
||||
if let peer = chatViewModel.getPeer(byID: statusPeerID) {
|
||||
return peer.displayName
|
||||
}
|
||||
if let name = chatViewModel.meshService.peerNickname(peerID: statusPeerID) {
|
||||
return name
|
||||
}
|
||||
if let data = peerID.noiseKey {
|
||||
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: data),
|
||||
!favorite.peerNickname.isEmpty {
|
||||
return favorite.peerNickname
|
||||
}
|
||||
let fingerprint = data.sha256Fingerprint()
|
||||
if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint) {
|
||||
if let pet = social.localPetname, !pet.isEmpty {
|
||||
return pet
|
||||
}
|
||||
if !social.claimedNickname.isEmpty {
|
||||
return social.claimedNickname
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return String(localized: "common.unknown", comment: "Label for an unknown peer")
|
||||
}
|
||||
}
|
||||
@@ -1,111 +1,9 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon_20x20@2x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_20x20@3x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_29x29@2x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_29x29@3x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_40x40@2x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_40x40@3x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_60x60@2x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_60x60@3x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_20x20.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_20x20@2x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_29x29.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_29x29@2x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_40x40.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_40x40@2x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_76x76.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_76x76@2x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_83.5x83.5@2x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "83.5x83.5"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_1024x1024.png",
|
||||
"idiom" : "ios-marketing",
|
||||
"scale" : "1x",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
|
||||
|
Before Width: | Height: | Size: 378 B |
|
Before Width: | Height: | Size: 497 B |
|
Before Width: | Height: | Size: 570 B |
|
Before Width: | Height: | Size: 401 B |
|
Before Width: | Height: | Size: 564 B |
|
Before Width: | Height: | Size: 668 B |
|
Before Width: | Height: | Size: 497 B |
|
Before Width: | Height: | Size: 641 B |
|
Before Width: | Height: | Size: 765 B |
|
Before Width: | Height: | Size: 765 B |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 628 B |
|
Before Width: | Height: | Size: 930 B |
|
Before Width: | Height: | Size: 976 B |
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "image-1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
@@ -3,4 +3,4 @@
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,67 +11,52 @@ import UserNotifications
|
||||
|
||||
@main
|
||||
struct BitchatApp: App {
|
||||
@StateObject private var chatViewModel = ChatViewModel()
|
||||
static let bundleID = Bundle.main.bundleIdentifier ?? "chat.bitchat"
|
||||
static let groupID = "group.\(bundleID)"
|
||||
|
||||
@StateObject private var runtime: AppRuntime
|
||||
@AppStorage(AppTheme.storageKey) private var appThemeRawValue = AppTheme.matrix.rawValue
|
||||
#if os(iOS)
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
#elseif os(macOS)
|
||||
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
||||
#endif
|
||||
|
||||
|
||||
init() {
|
||||
_runtime = StateObject(wrappedValue: AppRuntime())
|
||||
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
|
||||
// Warm up georelay directory and refresh if stale (once/day)
|
||||
GeoRelayDirectory.shared.prefetchIfNeeded()
|
||||
}
|
||||
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environmentObject(chatViewModel)
|
||||
.environment(\.appTheme, AppTheme(rawValue: appThemeRawValue) ?? .matrix)
|
||||
.environmentObject(runtime.publicChatModel)
|
||||
.environmentObject(runtime.privateInboxModel)
|
||||
.environmentObject(runtime.privateConversationModel)
|
||||
.environmentObject(runtime.verificationModel)
|
||||
.environmentObject(runtime.conversationUIModel)
|
||||
.environmentObject(runtime.locationChannelsModel)
|
||||
.environmentObject(runtime.peerListModel)
|
||||
.environmentObject(runtime.appChromeModel)
|
||||
.onAppear {
|
||||
NotificationDelegate.shared.chatViewModel = chatViewModel
|
||||
// Inject live Noise service into VerificationService to avoid creating new BLE instances
|
||||
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
||||
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
|
||||
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
|
||||
}
|
||||
#if os(iOS)
|
||||
appDelegate.chatViewModel = chatViewModel
|
||||
#elseif os(macOS)
|
||||
appDelegate.chatViewModel = chatViewModel
|
||||
#endif
|
||||
// Check for shared content
|
||||
checkForSharedContent()
|
||||
appDelegate.runtime = runtime
|
||||
runtime.start()
|
||||
}
|
||||
.onOpenURL { url in
|
||||
handleURL(url)
|
||||
runtime.handleOpenURL(url)
|
||||
}
|
||||
#if os(iOS)
|
||||
.onChange(of: scenePhase) { newPhase in
|
||||
switch newPhase {
|
||||
case .background:
|
||||
// Keep BLE mesh running in background; BLEService adapts scanning automatically
|
||||
break
|
||||
case .active:
|
||||
// Restart services when becoming active
|
||||
chatViewModel.meshService.startServices()
|
||||
checkForSharedContent()
|
||||
case .inactive:
|
||||
break
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
runtime.handleScenePhaseChange(newPhase)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
||||
// Check for shared content when app becomes active
|
||||
checkForSharedContent()
|
||||
runtime.handleDidBecomeActiveNotification()
|
||||
}
|
||||
#elseif os(macOS)
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
||||
// App became active
|
||||
runtime.handleMacDidBecomeActiveNotification()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -80,62 +65,18 @@ struct BitchatApp: App {
|
||||
.windowResizability(.contentSize)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func handleURL(_ url: URL) {
|
||||
if url.scheme == "bitchat" && url.host == "share" {
|
||||
// Handle shared content
|
||||
checkForSharedContent()
|
||||
}
|
||||
}
|
||||
|
||||
private func checkForSharedContent() {
|
||||
// Check app group for shared content from extension
|
||||
guard let userDefaults = UserDefaults(suiteName: "group.chat.bitchat") else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let sharedContent = userDefaults.string(forKey: "sharedContent"),
|
||||
let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else {
|
||||
return
|
||||
}
|
||||
|
||||
// Only process if shared within last 30 seconds
|
||||
if Date().timeIntervalSince(sharedDate) < 30 {
|
||||
let contentType = userDefaults.string(forKey: "sharedContentType") ?? "text"
|
||||
|
||||
// Clear the shared content
|
||||
userDefaults.removeObject(forKey: "sharedContent")
|
||||
userDefaults.removeObject(forKey: "sharedContentType")
|
||||
userDefaults.removeObject(forKey: "sharedContentDate")
|
||||
userDefaults.synchronize()
|
||||
|
||||
// Send the shared content immediately on the main queue
|
||||
DispatchQueue.main.async {
|
||||
if contentType == "url" {
|
||||
// Try to parse as JSON first
|
||||
if let data = sharedContent.data(using: .utf8),
|
||||
let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String],
|
||||
let url = urlData["url"] {
|
||||
// Send plain URL
|
||||
self.chatViewModel.sendMessage(url)
|
||||
} else {
|
||||
// Fallback to simple URL
|
||||
self.chatViewModel.sendMessage(sharedContent)
|
||||
}
|
||||
} else {
|
||||
self.chatViewModel.sendMessage(sharedContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
weak var chatViewModel: ChatViewModel?
|
||||
|
||||
final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
weak var runtime: AppRuntime?
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||
return true
|
||||
true
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
runtime?.applicationWillTerminate()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -143,63 +84,43 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
|
||||
class MacAppDelegate: NSObject, NSApplicationDelegate {
|
||||
weak var chatViewModel: ChatViewModel?
|
||||
|
||||
final class MacAppDelegate: NSObject, NSApplicationDelegate {
|
||||
weak var runtime: AppRuntime?
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
chatViewModel?.applicationWillTerminate()
|
||||
runtime?.applicationWillTerminate()
|
||||
}
|
||||
|
||||
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||
return true
|
||||
true
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
static let shared = NotificationDelegate()
|
||||
weak var chatViewModel: ChatViewModel?
|
||||
|
||||
weak var runtime: AppRuntime?
|
||||
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
||||
let identifier = response.notification.request.identifier
|
||||
let userInfo = response.notification.request.content.userInfo
|
||||
|
||||
// Check if this is a private message notification
|
||||
if identifier.hasPrefix("private-") {
|
||||
// Get peer ID from userInfo
|
||||
if let peerID = userInfo["peerID"] as? String {
|
||||
DispatchQueue.main.async {
|
||||
self.chatViewModel?.startPrivateChat(with: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
self.runtime?.handleNotificationResponse(identifier: identifier, userInfo: userInfo)
|
||||
}
|
||||
|
||||
completionHandler()
|
||||
}
|
||||
|
||||
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
||||
let identifier = notification.request.identifier
|
||||
let userInfo = notification.request.content.userInfo
|
||||
|
||||
// Check if this is a private message notification
|
||||
if identifier.hasPrefix("private-") {
|
||||
// Get peer ID from userInfo
|
||||
if let peerID = userInfo["peerID"] as? String {
|
||||
// Don't show notification if the private chat is already open
|
||||
if chatViewModel?.selectedPrivateChatPeer == peerID {
|
||||
completionHandler([])
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show notification in all other cases
|
||||
completionHandler([.banner, .sound])
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
var nilIfEmpty: String? {
|
||||
self.isEmpty ? nil : self
|
||||
Task {
|
||||
let options = await self.runtime?.presentationOptions(
|
||||
forNotificationIdentifier: identifier,
|
||||
userInfo: userInfo
|
||||
) ?? [.banner, .sound]
|
||||
completionHandler(options)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import UniformTypeIdentifiers
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
enum ImageUtilsError: Error {
|
||||
case invalidImage
|
||||
case encodingFailed
|
||||
}
|
||||
|
||||
enum ImageUtils {
|
||||
private static let compressionQuality: CGFloat = 0.82
|
||||
private static let targetImageBytes: Int = 45_000
|
||||
private static let maxSourceImageBytes: Int = 10 * 1024 * 1024
|
||||
|
||||
static func processImage(at url: URL, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
||||
try validateImageSource(at: url)
|
||||
|
||||
let data = try Data(contentsOf: url)
|
||||
#if os(iOS)
|
||||
guard let image = UIImage(data: data) else { throw ImageUtilsError.invalidImage }
|
||||
return try processImage(image, maxDimension: maxDimension, outputDirectory: outputDirectory)
|
||||
#else
|
||||
guard let image = NSImage(data: data) else { throw ImageUtilsError.invalidImage }
|
||||
return try processImage(image, maxDimension: maxDimension, outputDirectory: outputDirectory)
|
||||
#endif
|
||||
}
|
||||
|
||||
static func validateImageSource(at url: URL) throws {
|
||||
// Security H1: Check file size BEFORE reading into memory.
|
||||
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
|
||||
guard let fileSize = attrs[.size] as? Int,
|
||||
fileSize > 0,
|
||||
fileSize <= maxSourceImageBytes else {
|
||||
throw ImageUtilsError.invalidImage
|
||||
}
|
||||
|
||||
let options = [kCGImageSourceShouldCache: false] as CFDictionary
|
||||
guard let source = CGImageSourceCreateWithURL(url as CFURL, options),
|
||||
CGImageSourceGetType(source) != nil else {
|
||||
throw ImageUtilsError.invalidImage
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
||||
return try autoreleasepool {
|
||||
// Scale the image first
|
||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||
|
||||
// Get CGImage from UIImage - this is the key to stripping metadata
|
||||
guard let cgImage = scaled.cgImage else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
|
||||
// Use CGImageDestination to encode without metadata (same as macOS)
|
||||
var quality = compressionQuality
|
||||
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
|
||||
// Compress to target size
|
||||
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||
quality -= 0.1
|
||||
autoreleasepool {
|
||||
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
||||
jpegData = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
||||
try jpegData.write(to: outputURL, options: .atomic)
|
||||
return outputURL
|
||||
}
|
||||
}
|
||||
|
||||
private static func scaledImage(_ image: UIImage, maxDimension: CGFloat) -> UIImage {
|
||||
let size = image.size
|
||||
let maxSide = max(size.width, size.height)
|
||||
guard maxSide > maxDimension else { return image }
|
||||
let scale = maxDimension / maxSide
|
||||
let newSize = CGSize(width: size.width * scale, height: size.height * scale)
|
||||
|
||||
// Draw into a new context to get a clean CGImage without metadata
|
||||
UIGraphicsBeginImageContextWithOptions(newSize, true, 1.0)
|
||||
image.draw(in: CGRect(origin: .zero, size: newSize))
|
||||
let rendered = UIGraphicsGetImageFromCurrentImageContext()
|
||||
UIGraphicsEndImageContext()
|
||||
return rendered ?? image
|
||||
}
|
||||
|
||||
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
|
||||
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
||||
guard let data = CFDataCreateMutable(nil, 0) else {
|
||||
return nil
|
||||
}
|
||||
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
||||
return nil
|
||||
}
|
||||
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
||||
// By only specifying compression quality and no metadata keys,
|
||||
// we ensure a clean JPEG with no privacy-leaking information
|
||||
let options: [CFString: Any] = [
|
||||
kCGImageDestinationLossyCompressionQuality: quality
|
||||
]
|
||||
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
|
||||
guard CGImageDestinationFinalize(destination) else {
|
||||
return nil
|
||||
}
|
||||
return data as Data
|
||||
}
|
||||
#else
|
||||
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
||||
return try autoreleasepool {
|
||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
let width = inputCG.width
|
||||
let height = inputCG.height
|
||||
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
|
||||
guard let context = CGContext(
|
||||
data: nil,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: colorSpace,
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
context.draw(inputCG, in: CGRect(x: 0, y: 0, width: width, height: height))
|
||||
guard let cgImage = context.makeImage() else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
var quality = compressionQuality
|
||||
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||
quality -= 0.1
|
||||
autoreleasepool {
|
||||
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
||||
jpegData = next
|
||||
}
|
||||
}
|
||||
}
|
||||
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
||||
try jpegData.write(to: outputURL, options: .atomic)
|
||||
return outputURL
|
||||
}
|
||||
}
|
||||
|
||||
private static func scaledImage(_ image: NSImage, maxDimension: CGFloat) -> NSImage {
|
||||
let size = image.size
|
||||
let maxSide = max(size.width, size.height)
|
||||
guard maxSide > maxDimension else { return image }
|
||||
let scale = maxDimension / maxSide
|
||||
let newSize = NSSize(width: size.width * scale, height: size.height * scale)
|
||||
let scaledImage = NSImage(size: newSize)
|
||||
scaledImage.lockFocus()
|
||||
image.draw(in: NSRect(origin: .zero, size: newSize),
|
||||
from: NSRect(origin: .zero, size: size),
|
||||
operation: .copy,
|
||||
fraction: 1.0)
|
||||
scaledImage.unlockFocus()
|
||||
return scaledImage
|
||||
}
|
||||
|
||||
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
|
||||
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
||||
guard let data = CFDataCreateMutable(nil, 0) else {
|
||||
return nil
|
||||
}
|
||||
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
||||
return nil
|
||||
}
|
||||
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
||||
// By only specifying compression quality and no metadata keys,
|
||||
// we ensure a clean JPEG with no privacy-leaking information
|
||||
let options: [CFString: Any] = [
|
||||
kCGImageDestinationLossyCompressionQuality: quality
|
||||
]
|
||||
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
|
||||
guard CGImageDestinationFinalize(destination) else {
|
||||
return nil
|
||||
}
|
||||
return data as Data
|
||||
}
|
||||
#endif
|
||||
|
||||
private static func makeOutputURL(outputDirectory: URL? = nil) throws -> URL {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||
let fileName = "img_\(formatter.string(from: Date()))_\(UUID().uuidString).jpg"
|
||||
|
||||
let directory: URL
|
||||
if let outputDirectory {
|
||||
directory = outputDirectory
|
||||
} else {
|
||||
directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true)
|
||||
}
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||
return directory.appendingPathComponent(fileName)
|
||||
}
|
||||
|
||||
private static func applicationFilesDirectory() throws -> URL {
|
||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
return base.appendingPathComponent("files", isDirectory: true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import BitLogger
|
||||
|
||||
/// Controls playback for a single voice note and coordinates exclusive playback across the app.
|
||||
final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlayerDelegate {
|
||||
@Published private(set) var isPlaying: Bool = false
|
||||
@Published private(set) var currentTime: TimeInterval = 0
|
||||
@Published private(set) var duration: TimeInterval = 0
|
||||
@Published private(set) var progress: Double = 0
|
||||
|
||||
/// rounded so 4.9s shows "00:05"
|
||||
var roundedDuration: Int {
|
||||
guard duration.isFinite else { return 0 }
|
||||
return Int(duration.rounded())
|
||||
}
|
||||
|
||||
/// ceil so "00:01" stays visible until playback ends, capped to rounded duration
|
||||
var remainingSeconds: Int {
|
||||
let remaining = max(0, duration - currentTime)
|
||||
return min(roundedDuration, Int(ceil(remaining)))
|
||||
}
|
||||
|
||||
private var player: AVAudioPlayer?
|
||||
private var timer: Timer?
|
||||
private var url: URL
|
||||
|
||||
init(url: URL) {
|
||||
self.url = url
|
||||
super.init()
|
||||
// Don't load anything eagerly - wait until user interaction or view is fully displayed
|
||||
}
|
||||
|
||||
func loadDuration() {
|
||||
guard duration == 0 else { return }
|
||||
|
||||
DispatchQueue.global(qos: .utility).async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
do {
|
||||
let player = try AVAudioPlayer(contentsOf: self.url)
|
||||
let loadedDuration = player.duration
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self, self.duration == 0 else { return }
|
||||
self.duration = loadedDuration
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Failed to load audio duration: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
timer?.invalidate()
|
||||
}
|
||||
|
||||
func replaceURL(_ url: URL) {
|
||||
guard url != self.url else { return }
|
||||
stop()
|
||||
self.url = url
|
||||
player = nil
|
||||
duration = 0
|
||||
// Duration will be loaded on demand when needed
|
||||
}
|
||||
|
||||
func togglePlayback() {
|
||||
isPlaying ? pause() : play()
|
||||
}
|
||||
|
||||
func play() {
|
||||
guard ensurePlayerReady() else { return }
|
||||
VoiceNotePlaybackCoordinator.shared.activate(self)
|
||||
player?.play()
|
||||
startTimer()
|
||||
updateProgress()
|
||||
isPlaying = true
|
||||
}
|
||||
|
||||
func pause() {
|
||||
player?.pause()
|
||||
stopTimer()
|
||||
updateProgress()
|
||||
isPlaying = false
|
||||
}
|
||||
|
||||
func stop() {
|
||||
player?.stop()
|
||||
player?.currentTime = 0
|
||||
stopTimer()
|
||||
updateProgress()
|
||||
isPlaying = false
|
||||
VoiceNotePlaybackCoordinator.shared.deactivate(self)
|
||||
}
|
||||
|
||||
func seek(to fraction: Double) {
|
||||
guard ensurePlayerReady() else { return }
|
||||
let clamped = max(0, min(1, fraction))
|
||||
if let player = player {
|
||||
player.currentTime = clamped * player.duration
|
||||
if isPlaying {
|
||||
player.play()
|
||||
}
|
||||
updateProgress()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AVAudioPlayerDelegate
|
||||
|
||||
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
|
||||
// Delegate callback may be on background thread - ensure main thread for UI updates
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.stopTimer()
|
||||
self.updateProgress()
|
||||
self.isPlaying = false
|
||||
VoiceNotePlaybackCoordinator.shared.deactivate(self)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func preparePlayer(for url: URL) {
|
||||
// Prepare player synchronously (only called when playback is requested)
|
||||
do {
|
||||
let player = try AVAudioPlayer(contentsOf: url)
|
||||
player.delegate = self
|
||||
player.prepareToPlay()
|
||||
self.player = player
|
||||
duration = player.duration
|
||||
currentTime = player.currentTime
|
||||
progress = duration > 0 ? currentTime / duration : 0
|
||||
} catch {
|
||||
SecureLogger.error("Voice note playback failed for \(url.lastPathComponent): \(error)", category: .session)
|
||||
player = nil
|
||||
duration = 0
|
||||
currentTime = 0
|
||||
progress = 0
|
||||
}
|
||||
}
|
||||
|
||||
private func ensurePlayerReady() -> Bool {
|
||||
if player == nil {
|
||||
preparePlayer(for: url)
|
||||
}
|
||||
#if os(iOS)
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
|
||||
try session.setActive(true, options: [])
|
||||
} catch {
|
||||
SecureLogger.error("Failed to activate audio session: \(error)", category: .session)
|
||||
}
|
||||
#endif
|
||||
return player != nil
|
||||
}
|
||||
|
||||
private func startTimer() {
|
||||
if timer != nil { return }
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
|
||||
self?.updateProgress()
|
||||
}
|
||||
if let timer = timer {
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
}
|
||||
}
|
||||
|
||||
private func stopTimer() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
|
||||
private func updateProgress() {
|
||||
guard let player = player else {
|
||||
currentTime = 0
|
||||
duration = 0
|
||||
progress = 0
|
||||
return
|
||||
}
|
||||
currentTime = player.currentTime
|
||||
duration = player.duration
|
||||
progress = duration > 0 ? currentTime / duration : 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensures only one voice note plays at a time.
|
||||
final class VoiceNotePlaybackCoordinator {
|
||||
static let shared = VoiceNotePlaybackCoordinator()
|
||||
|
||||
private weak var activeController: VoiceNotePlaybackController?
|
||||
|
||||
private init() {}
|
||||
|
||||
func activate(_ controller: VoiceNotePlaybackController) {
|
||||
if activeController === controller {
|
||||
return
|
||||
}
|
||||
activeController?.pause()
|
||||
activeController = controller
|
||||
}
|
||||
|
||||
func deactivate(_ controller: VoiceNotePlaybackController) {
|
||||
if activeController === controller {
|
||||
activeController = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
|
||||
/// Manages audio capture for mesh voice notes with predictable encoding settings.
|
||||
actor VoiceRecorder {
|
||||
enum RecorderError: Error {
|
||||
case microphoneAccessDenied
|
||||
case recorderInitializationFailed
|
||||
case recordingInProgress
|
||||
}
|
||||
|
||||
static let shared = VoiceRecorder()
|
||||
|
||||
private let paddingInterval: TimeInterval = 0.5
|
||||
private let maxRecordingDuration: TimeInterval = 120
|
||||
static let minRecordingDuration: TimeInterval = 1
|
||||
|
||||
private var recorder: AVAudioRecorder?
|
||||
private var currentURL: URL?
|
||||
|
||||
// MARK: - Permissions
|
||||
|
||||
nonisolated
|
||||
func requestPermission() async -> Bool {
|
||||
#if os(iOS)
|
||||
return await withCheckedContinuation { continuation in
|
||||
AVAudioSession.sharedInstance().requestRecordPermission { granted in
|
||||
continuation.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
#elseif os(macOS)
|
||||
return await withCheckedContinuation { continuation in
|
||||
AVCaptureDevice.requestAccess(for: .audio) { granted in
|
||||
continuation.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
#else
|
||||
return true
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Recording Lifecycle
|
||||
|
||||
@discardableResult
|
||||
func startRecording() throws -> URL {
|
||||
if recorder?.isRecording == true {
|
||||
throw RecorderError.recordingInProgress
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
guard session.recordPermission == .granted else {
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
}
|
||||
#if targetEnvironment(simulator)
|
||||
// allowBluetoothHFP is not available on iOS Simulator
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .default,
|
||||
options: [.defaultToSpeaker, .allowBluetoothA2DP]
|
||||
)
|
||||
#else
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .default,
|
||||
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
|
||||
)
|
||||
#endif
|
||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
}
|
||||
#endif
|
||||
|
||||
let outputURL = try makeOutputURL()
|
||||
let settings: [String: Any] = [
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVSampleRateKey: 16_000,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVEncoderBitRateKey: 16_000
|
||||
]
|
||||
|
||||
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
||||
audioRecorder.isMeteringEnabled = true
|
||||
audioRecorder.prepareToRecord()
|
||||
audioRecorder.record(forDuration: maxRecordingDuration)
|
||||
|
||||
recorder = audioRecorder
|
||||
currentURL = outputURL
|
||||
return outputURL
|
||||
}
|
||||
|
||||
func stopRecording() async -> URL? {
|
||||
guard let recorder, recorder.isRecording else {
|
||||
return currentURL
|
||||
}
|
||||
|
||||
let sessionURL = currentURL
|
||||
|
||||
try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000))
|
||||
|
||||
recorder.stop()
|
||||
|
||||
// A new session may have started during the sleep — don't touch its state
|
||||
if self.recorder === recorder {
|
||||
cleanupSession()
|
||||
self.recorder = nil
|
||||
currentURL = nil
|
||||
}
|
||||
|
||||
return sessionURL
|
||||
}
|
||||
|
||||
func cancelRecording() {
|
||||
if let recorder, recorder.isRecording {
|
||||
recorder.stop()
|
||||
}
|
||||
cleanupSession()
|
||||
if let currentURL {
|
||||
try? FileManager.default.removeItem(at: currentURL)
|
||||
}
|
||||
recorder = nil
|
||||
currentURL = nil
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func makeOutputURL() throws -> URL {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||
let fileName = "voice_\(formatter.string(from: Date())).m4a"
|
||||
|
||||
let baseDirectory = try applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: nil)
|
||||
return baseDirectory.appendingPathComponent(fileName)
|
||||
}
|
||||
|
||||
private func applicationFilesDirectory() throws -> URL {
|
||||
#if os(iOS)
|
||||
return try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
.appendingPathComponent("files", isDirectory: true)
|
||||
#else
|
||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
return base.appendingPathComponent("files", isDirectory: true)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func cleanupSession() {
|
||||
#if os(iOS)
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import BitLogger
|
||||
|
||||
/// Generates and caches downsampled waveforms for audio files so UI rendering is cheap.
|
||||
final class WaveformCache {
|
||||
static let shared = WaveformCache()
|
||||
|
||||
private let queue = DispatchQueue(label: "com.bitchat.waveform-cache", attributes: .concurrent)
|
||||
private var cache: [URL: (waveform: [Float], lastAccess: Date)] = [:]
|
||||
private let maxCacheSize = 20 // Limit cache to prevent unbounded memory growth
|
||||
|
||||
private init() {}
|
||||
|
||||
func cachedWaveform(for url: URL) -> [Float]? {
|
||||
queue.sync {
|
||||
guard let entry = cache[url] else { return nil }
|
||||
return entry.waveform
|
||||
}
|
||||
}
|
||||
|
||||
func waveform(for url: URL, bins: Int = 120, completion: @escaping ([Float]) -> Void) {
|
||||
queue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Check cache (read-only, no update needed on cache hit for performance)
|
||||
if let entry = self.cache[url] {
|
||||
DispatchQueue.main.async { completion(entry.waveform) }
|
||||
return
|
||||
}
|
||||
|
||||
guard let computed = self.computeWaveform(url: url, bins: bins) else {
|
||||
DispatchQueue.main.async { completion([]) }
|
||||
return
|
||||
}
|
||||
|
||||
self.queue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Evict oldest entry if cache is full
|
||||
if self.cache.count >= self.maxCacheSize {
|
||||
if let oldest = self.cache.min(by: { $0.value.lastAccess < $1.value.lastAccess }) {
|
||||
self.cache.removeValue(forKey: oldest.key)
|
||||
}
|
||||
}
|
||||
|
||||
self.cache[url] = (computed, Date())
|
||||
}
|
||||
DispatchQueue.main.async { completion(computed) }
|
||||
}
|
||||
}
|
||||
|
||||
func purge(url: URL) {
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
self?.cache.removeValue(forKey: url)
|
||||
}
|
||||
}
|
||||
|
||||
func purgeAll() {
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
self?.cache.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
private func computeWaveform(url: URL, bins: Int) -> [Float]? {
|
||||
guard bins > 0 else { return nil }
|
||||
// Use autoreleasepool to manage memory from audio buffer allocations
|
||||
return autoreleasepool {
|
||||
do {
|
||||
let audioFile = try AVAudioFile(forReading: url)
|
||||
let length = Int(audioFile.length)
|
||||
guard length > 0 else { return nil }
|
||||
|
||||
guard let buffer = AVAudioPCMBuffer(pcmFormat: audioFile.processingFormat, frameCapacity: AVAudioFrameCount(length)) else {
|
||||
return nil
|
||||
}
|
||||
try audioFile.read(into: buffer, frameCount: AVAudioFrameCount(length))
|
||||
guard let channelData = buffer.floatChannelData else { return nil }
|
||||
|
||||
let channelCount = Int(audioFile.processingFormat.channelCount)
|
||||
let frameLength = Int(buffer.frameLength)
|
||||
let samplesPerBin = max(1, frameLength / bins)
|
||||
|
||||
var magnitudes: [Float] = Array(repeating: 0, count: bins)
|
||||
for bin in 0..<bins {
|
||||
let start = bin * samplesPerBin
|
||||
let end = min(frameLength, start + samplesPerBin)
|
||||
if start >= end { break }
|
||||
|
||||
var sum: Float = 0
|
||||
var sampleCount = 0
|
||||
for frame in start..<end {
|
||||
var sampleValue: Float = 0
|
||||
for channel in 0..<channelCount {
|
||||
sampleValue += fabsf(channelData[channel][frame])
|
||||
}
|
||||
sum += sampleValue / Float(channelCount)
|
||||
sampleCount += 1
|
||||
}
|
||||
magnitudes[bin] = sampleCount > 0 ? sum / Float(sampleCount) : 0
|
||||
}
|
||||
|
||||
if let maxMagnitude = magnitudes.max(), maxMagnitude > 0 {
|
||||
magnitudes = magnitudes.map { min($0 / maxMagnitude, 1.0) }
|
||||
}
|
||||
return magnitudes
|
||||
} catch {
|
||||
SecureLogger.error("Waveform extraction failed for \(url.lastPathComponent): \(error)", category: .session)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,13 +81,14 @@
|
||||
///
|
||||
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
|
||||
// MARK: - Three-Layer Identity Model
|
||||
|
||||
/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy.
|
||||
/// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships.
|
||||
struct EphemeralIdentity {
|
||||
let peerID: String // 8 random bytes
|
||||
let peerID: PeerID // 8 random bytes
|
||||
let sessionStart: Date
|
||||
var handshakeState: HandshakeState
|
||||
}
|
||||
@@ -106,6 +107,8 @@ enum HandshakeState {
|
||||
struct CryptographicIdentity: Codable {
|
||||
let fingerprint: String // SHA256 of public key
|
||||
let publicKey: Data // Noise static public key
|
||||
// Optional Ed25519 signing public key (used to authenticate public messages)
|
||||
var signingPublicKey: Data? = nil
|
||||
let firstSeen: Date
|
||||
let lastHandshake: Date?
|
||||
}
|
||||
@@ -156,23 +159,6 @@ struct IdentityCache: Codable {
|
||||
var version: Int = 1
|
||||
}
|
||||
|
||||
// MARK: - Identity Resolution
|
||||
|
||||
enum IdentityHint {
|
||||
case unknown
|
||||
case likelyKnown(fingerprint: String)
|
||||
case ambiguous(candidates: Set<String>)
|
||||
case verified(fingerprint: String)
|
||||
}
|
||||
|
||||
// MARK: - Pending Actions
|
||||
|
||||
struct PendingActions {
|
||||
var toggleFavorite: Bool?
|
||||
var setTrustLevel: TrustLevel?
|
||||
var setPetname: String?
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
// MARK: - Migration Support
|
||||
|
||||
@@ -90,27 +90,64 @@
|
||||
/// - Advanced conflict resolution
|
||||
///
|
||||
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
protocol SecureIdentityStateManagerProtocol {
|
||||
// MARK: Secure Loading/Saving
|
||||
func forceSave()
|
||||
|
||||
// MARK: Social Identity Management
|
||||
func getSocialIdentity(for fingerprint: String) -> SocialIdentity?
|
||||
|
||||
// MARK: Cryptographic Identities
|
||||
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?)
|
||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity]
|
||||
func updateSocialIdentity(_ identity: SocialIdentity)
|
||||
|
||||
// MARK: Favorites Management
|
||||
func getFavorites() -> Set<String>
|
||||
func setFavorite(_ fingerprint: String, isFavorite: Bool)
|
||||
func isFavorite(fingerprint: String) -> Bool
|
||||
|
||||
// MARK: Blocked Users Management
|
||||
func isBlocked(fingerprint: String) -> Bool
|
||||
func setBlocked(_ fingerprint: String, isBlocked: Bool)
|
||||
|
||||
// MARK: Geohash (Nostr) Blocking
|
||||
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool
|
||||
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool)
|
||||
func getBlockedNostrPubkeys() -> Set<String>
|
||||
|
||||
// MARK: Ephemeral Session Management
|
||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState)
|
||||
func updateHandshakeState(peerID: PeerID, state: HandshakeState)
|
||||
|
||||
// MARK: Cleanup
|
||||
func clearAllIdentityData()
|
||||
func removeEphemeralSession(peerID: PeerID)
|
||||
|
||||
// MARK: Verification
|
||||
func setVerified(fingerprint: String, verified: Bool)
|
||||
func isVerified(fingerprint: String) -> Bool
|
||||
func getVerifiedFingerprints() -> Set<String>
|
||||
}
|
||||
|
||||
/// Singleton manager for secure identity state persistence and retrieval.
|
||||
/// Provides thread-safe access to identity mappings with encryption at rest.
|
||||
/// All identity data is stored encrypted in the device Keychain for security.
|
||||
class SecureIdentityStateManager {
|
||||
static let shared = SecureIdentityStateManager()
|
||||
|
||||
private let keychain = KeychainManager.shared
|
||||
final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private let cacheKey = "bitchat.identityCache.v2"
|
||||
private let encryptionKeyName = "identityCacheEncryptionKey"
|
||||
|
||||
// In-memory state
|
||||
private var ephemeralSessions: [String: EphemeralIdentity] = [:]
|
||||
private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:]
|
||||
private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
|
||||
private var cache: IdentityCache = IdentityCache()
|
||||
|
||||
// Pending actions before handshake
|
||||
private var pendingActions: [String: PendingActions] = [:]
|
||||
|
||||
// Thread safety
|
||||
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
|
||||
|
||||
@@ -122,14 +159,16 @@ class SecureIdentityStateManager {
|
||||
// Encryption key
|
||||
private let encryptionKey: SymmetricKey
|
||||
|
||||
private init() {
|
||||
init(_ keychain: KeychainManagerProtocol) {
|
||||
self.keychain = keychain
|
||||
|
||||
// Generate or retrieve encryption key from keychain
|
||||
let loadedKey: SymmetricKey
|
||||
|
||||
// Try to load from keychain
|
||||
if let keyData = keychain.getIdentityKey(forKey: encryptionKeyName) {
|
||||
loadedKey = SymmetricKey(data: keyData)
|
||||
SecureLogger.logKeyOperation("load", keyType: "identity cache encryption key", success: true)
|
||||
SecureLogger.logKeyOperation(.load, keyType: "identity cache encryption key", success: true)
|
||||
}
|
||||
// Generate new key if needed
|
||||
else {
|
||||
@@ -137,7 +176,7 @@ class SecureIdentityStateManager {
|
||||
let keyData = loadedKey.withUnsafeBytes { Data($0) }
|
||||
// Save to keychain
|
||||
let saved = keychain.saveIdentityKey(keyData, forKey: encryptionKeyName)
|
||||
SecureLogger.logKeyOperation("generate", keyType: "identity cache encryption key", success: saved)
|
||||
SecureLogger.logKeyOperation(.generate, keyType: "identity cache encryption key", success: saved)
|
||||
}
|
||||
|
||||
self.encryptionKey = loadedKey
|
||||
@@ -146,9 +185,13 @@ class SecureIdentityStateManager {
|
||||
loadIdentityCache()
|
||||
}
|
||||
|
||||
deinit {
|
||||
forceSave()
|
||||
}
|
||||
|
||||
// MARK: - Secure Loading/Saving
|
||||
|
||||
func loadIdentityCache() {
|
||||
private func loadIdentityCache() {
|
||||
guard let encryptedData = keychain.getIdentityKey(forKey: cacheKey) else {
|
||||
// No existing cache, start fresh
|
||||
return
|
||||
@@ -159,17 +202,16 @@ class SecureIdentityStateManager {
|
||||
let decryptedData = try AES.GCM.open(sealedBox, using: encryptionKey)
|
||||
cache = try JSONDecoder().decode(IdentityCache.self, from: decryptedData)
|
||||
} catch {
|
||||
// Log error but continue with empty cache
|
||||
SecureLogger.logError(error, context: "Failed to load identity cache", category: SecureLogger.security)
|
||||
cache = IdentityCache()
|
||||
let deleted = keychain.deleteIdentityKey(forKey: cacheKey)
|
||||
SecureLogger.warning(
|
||||
"Discarded unreadable identity cache; starting fresh (deleted=\(deleted), error=\(error.localizedDescription))",
|
||||
category: .security
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Force save any pending changes
|
||||
forceSave()
|
||||
}
|
||||
|
||||
func saveIdentityCache() {
|
||||
private func saveIdentityCache() {
|
||||
// Mark that we need to save
|
||||
pendingSave = true
|
||||
|
||||
@@ -191,35 +233,17 @@ class SecureIdentityStateManager {
|
||||
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
|
||||
let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey)
|
||||
if saved {
|
||||
SecureLogger.log("Identity cache saved to keychain", category: SecureLogger.security, level: .debug)
|
||||
SecureLogger.debug("Identity cache saved to keychain", category: .security)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.logError(error, context: "Failed to save identity cache", category: SecureLogger.security)
|
||||
SecureLogger.error(error, context: "Failed to save identity cache", category: .security)
|
||||
}
|
||||
}
|
||||
|
||||
// Force immediate save (for app termination)
|
||||
func forceSave() {
|
||||
saveTimer?.invalidate()
|
||||
if pendingSave {
|
||||
performSave()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Identity Resolution
|
||||
|
||||
func resolveIdentity(peerID: String, claimedNickname: String) -> IdentityHint {
|
||||
queue.sync {
|
||||
// Check if we have candidates based on nickname
|
||||
if let fingerprints = cache.nicknameIndex[claimedNickname] {
|
||||
if fingerprints.count == 1 {
|
||||
return .likelyKnown(fingerprint: fingerprints.first!)
|
||||
} else {
|
||||
return .ambiguous(candidates: fingerprints)
|
||||
}
|
||||
}
|
||||
return .unknown
|
||||
}
|
||||
performSave()
|
||||
}
|
||||
|
||||
// MARK: - Social Identity Management
|
||||
@@ -229,25 +253,98 @@ class SecureIdentityStateManager {
|
||||
return cache.socialIdentities[fingerprint]
|
||||
}
|
||||
}
|
||||
|
||||
func getAllSocialIdentities() -> [SocialIdentity] {
|
||||
|
||||
// MARK: - Cryptographic Identities
|
||||
|
||||
/// Insert or update a cryptographic identity and optionally persist its signing key and claimed nickname.
|
||||
/// - Parameters:
|
||||
/// - fingerprint: SHA-256 hex of the Noise static public key
|
||||
/// - noisePublicKey: Noise static public key data
|
||||
/// - signingPublicKey: Optional Ed25519 signing public key for authenticating public messages
|
||||
/// - claimedNickname: Optional latest claimed nickname to persist into social identity
|
||||
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String? = nil) {
|
||||
queue.async(flags: .barrier) {
|
||||
let now = Date()
|
||||
if var existing = self.cryptographicIdentities[fingerprint] {
|
||||
// Update keys if changed
|
||||
if existing.publicKey != noisePublicKey {
|
||||
existing = CryptographicIdentity(
|
||||
fingerprint: fingerprint,
|
||||
publicKey: noisePublicKey,
|
||||
signingPublicKey: signingPublicKey ?? existing.signingPublicKey,
|
||||
firstSeen: existing.firstSeen,
|
||||
lastHandshake: now
|
||||
)
|
||||
self.cryptographicIdentities[fingerprint] = existing
|
||||
} else {
|
||||
// Update signing key and lastHandshake
|
||||
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
|
||||
let updated = CryptographicIdentity(
|
||||
fingerprint: existing.fingerprint,
|
||||
publicKey: existing.publicKey,
|
||||
signingPublicKey: existing.signingPublicKey,
|
||||
firstSeen: existing.firstSeen,
|
||||
lastHandshake: now
|
||||
)
|
||||
self.cryptographicIdentities[fingerprint] = updated
|
||||
}
|
||||
// Persist updated state (already assigned in branches above)
|
||||
} else {
|
||||
// New entry
|
||||
let entry = CryptographicIdentity(
|
||||
fingerprint: fingerprint,
|
||||
publicKey: noisePublicKey,
|
||||
signingPublicKey: signingPublicKey,
|
||||
firstSeen: now,
|
||||
lastHandshake: now
|
||||
)
|
||||
self.cryptographicIdentities[fingerprint] = entry
|
||||
}
|
||||
|
||||
// Optionally persist claimed nickname into social identity
|
||||
if let claimed = claimedNickname {
|
||||
var identity = self.cache.socialIdentities[fingerprint] ?? SocialIdentity(
|
||||
fingerprint: fingerprint,
|
||||
localPetname: nil,
|
||||
claimedNickname: claimed,
|
||||
trustLevel: .unknown,
|
||||
isFavorite: false,
|
||||
isBlocked: false,
|
||||
notes: nil
|
||||
)
|
||||
// Update claimed nickname if changed
|
||||
if identity.claimedNickname != claimed {
|
||||
identity.claimedNickname = claimed
|
||||
self.cache.socialIdentities[fingerprint] = identity
|
||||
} else if self.cache.socialIdentities[fingerprint] == nil {
|
||||
self.cache.socialIdentities[fingerprint] = identity
|
||||
}
|
||||
}
|
||||
|
||||
self.saveIdentityCache()
|
||||
}
|
||||
}
|
||||
|
||||
/// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID
|
||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity] {
|
||||
queue.sync {
|
||||
return Array(cache.socialIdentities.values)
|
||||
// Defensive: ensure hex and correct length
|
||||
guard peerID.isShort else { return [] }
|
||||
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
|
||||
}
|
||||
}
|
||||
|
||||
func updateSocialIdentity(_ identity: SocialIdentity) {
|
||||
queue.async(flags: .barrier) {
|
||||
let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname
|
||||
self.cache.socialIdentities[identity.fingerprint] = identity
|
||||
|
||||
// Update nickname index
|
||||
if let existingIdentity = self.cache.socialIdentities[identity.fingerprint] {
|
||||
// Remove old nickname from index if changed
|
||||
if existingIdentity.claimedNickname != identity.claimedNickname {
|
||||
self.cache.nicknameIndex[existingIdentity.claimedNickname]?.remove(identity.fingerprint)
|
||||
if self.cache.nicknameIndex[existingIdentity.claimedNickname]?.isEmpty == true {
|
||||
self.cache.nicknameIndex.removeValue(forKey: existingIdentity.claimedNickname)
|
||||
}
|
||||
if let previousClaimedNickname,
|
||||
previousClaimedNickname != identity.claimedNickname {
|
||||
self.cache.nicknameIndex[previousClaimedNickname]?.remove(identity.fingerprint)
|
||||
if self.cache.nicknameIndex[previousClaimedNickname]?.isEmpty == true {
|
||||
self.cache.nicknameIndex.removeValue(forKey: previousClaimedNickname)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,7 +407,7 @@ class SecureIdentityStateManager {
|
||||
}
|
||||
|
||||
func setBlocked(_ fingerprint: String, isBlocked: Bool) {
|
||||
SecureLogger.log("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: SecureLogger.security, level: .info)
|
||||
SecureLogger.info("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: .security)
|
||||
|
||||
queue.async(flags: .barrier) {
|
||||
if var identity = self.cache.socialIdentities[fingerprint] {
|
||||
@@ -362,7 +459,7 @@ class SecureIdentityStateManager {
|
||||
|
||||
// MARK: - Ephemeral Session Management
|
||||
|
||||
func registerEphemeralSession(peerID: String, handshakeState: HandshakeState = .none) {
|
||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.ephemeralSessions[peerID] = EphemeralIdentity(
|
||||
peerID: peerID,
|
||||
@@ -372,7 +469,7 @@ class SecureIdentityStateManager {
|
||||
}
|
||||
}
|
||||
|
||||
func updateHandshakeState(peerID: String, state: HandshakeState) {
|
||||
func updateHandshakeState(peerID: PeerID, state: HandshakeState) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.ephemeralSessions[peerID]?.handshakeState = state
|
||||
|
||||
@@ -384,81 +481,32 @@ class SecureIdentityStateManager {
|
||||
}
|
||||
}
|
||||
|
||||
func getHandshakeState(peerID: String) -> HandshakeState? {
|
||||
queue.sync {
|
||||
return ephemeralSessions[peerID]?.handshakeState
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pending Actions
|
||||
|
||||
func setPendingAction(peerID: String, action: PendingActions) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.pendingActions[peerID] = action
|
||||
}
|
||||
}
|
||||
|
||||
func applyPendingActions(peerID: String, fingerprint: String) {
|
||||
queue.async(flags: .barrier) {
|
||||
guard let actions = self.pendingActions[peerID] else { return }
|
||||
|
||||
// Get or create social identity
|
||||
var identity = self.cache.socialIdentities[fingerprint] ?? SocialIdentity(
|
||||
fingerprint: fingerprint,
|
||||
localPetname: nil,
|
||||
claimedNickname: "Unknown",
|
||||
trustLevel: .unknown,
|
||||
isFavorite: false,
|
||||
isBlocked: false,
|
||||
notes: nil
|
||||
)
|
||||
|
||||
// Apply pending actions
|
||||
if let toggleFavorite = actions.toggleFavorite {
|
||||
identity.isFavorite = toggleFavorite
|
||||
}
|
||||
if let trustLevel = actions.setTrustLevel {
|
||||
identity.trustLevel = trustLevel
|
||||
}
|
||||
if let petname = actions.setPetname {
|
||||
identity.localPetname = petname
|
||||
}
|
||||
|
||||
// Save updated identity
|
||||
self.cache.socialIdentities[fingerprint] = identity
|
||||
self.pendingActions.removeValue(forKey: peerID)
|
||||
self.saveIdentityCache()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cleanup
|
||||
|
||||
func clearAllIdentityData() {
|
||||
SecureLogger.log("Clearing all identity data", category: SecureLogger.security, level: .warning)
|
||||
SecureLogger.warning("Clearing all identity data", category: .security)
|
||||
|
||||
queue.async(flags: .barrier) {
|
||||
self.cache = IdentityCache()
|
||||
self.ephemeralSessions.removeAll()
|
||||
self.cryptographicIdentities.removeAll()
|
||||
self.pendingActions.removeAll()
|
||||
|
||||
// Delete from keychain
|
||||
let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey)
|
||||
SecureLogger.logKeyOperation("delete", keyType: "identity cache", success: deleted)
|
||||
SecureLogger.logKeyOperation(.delete, keyType: "identity cache", success: deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func removeEphemeralSession(peerID: String) {
|
||||
func removeEphemeralSession(peerID: PeerID) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.ephemeralSessions.removeValue(forKey: peerID)
|
||||
self.pendingActions.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Verification
|
||||
|
||||
func setVerified(fingerprint: String, verified: Bool) {
|
||||
SecureLogger.log("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: SecureLogger.security, level: .info)
|
||||
SecureLogger.info("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: .security)
|
||||
|
||||
queue.async(flags: .barrier) {
|
||||
if verified {
|
||||
@@ -488,4 +536,16 @@ class SecureIdentityStateManager {
|
||||
return cache.verifiedFingerprints
|
||||
}
|
||||
}
|
||||
|
||||
var debugNicknameIndex: [String: Set<String>] {
|
||||
queue.sync { cache.nicknameIndex }
|
||||
}
|
||||
|
||||
func debugEphemeralSession(for peerID: PeerID) -> EphemeralIdentity? {
|
||||
queue.sync { ephemeralSessions[peerID] }
|
||||
}
|
||||
|
||||
func debugLastInteraction(for fingerprint: String) -> Date? {
|
||||
queue.sync { cache.lastInteractions[fingerprint] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>AppGroupID</key>
|
||||
<string>$(APP_GROUP_ID)</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
@@ -35,10 +37,14 @@
|
||||
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
|
||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>bluetooth-central</string>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//
|
||||
// BitchatMessage+Media.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
extension BitchatMessage {
|
||||
enum Media {
|
||||
case voice(URL)
|
||||
case image(URL)
|
||||
|
||||
var url: URL {
|
||||
switch self {
|
||||
case .voice(let url), .image(let url):
|
||||
return url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
|
||||
private struct Cache {
|
||||
let filesDir: URL?
|
||||
|
||||
static let shared = Cache()
|
||||
private init() {
|
||||
do {
|
||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
let filesDir = base.appendingPathComponent("files", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
||||
self.filesDir = filesDir
|
||||
} catch {
|
||||
filesDir = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mediaAttachment(for nickname: String) -> Media? {
|
||||
guard let baseDirectory = Cache.shared.filesDir else { return nil }
|
||||
|
||||
func url(for category: MimeType.Category) -> URL? {
|
||||
guard content.hasPrefix(category.messagePrefix),
|
||||
let filename = String(content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check outgoing first for sent messages, incoming for received
|
||||
let subdir = sender == nickname ? "\(category.mediaDir)/outgoing" : "\(category.mediaDir)/incoming"
|
||||
|
||||
// Construct URL directly without fileExists check (avoids blocking disk I/O in view body)
|
||||
// Files are checked during playback/display, so missing files fail gracefully
|
||||
let directory = baseDirectory.appendingPathComponent(subdir, isDirectory: true)
|
||||
return directory.appendingPathComponent(filename)
|
||||
}
|
||||
|
||||
if let url = url(for: .audio) {
|
||||
return .voice(url)
|
||||
}
|
||||
if let url = url(for: .image) {
|
||||
return .image(url)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
import Foundation
|
||||
import CoreBluetooth
|
||||
import BitFoundation
|
||||
|
||||
/// Represents a peer in the BitChat network with all associated metadata
|
||||
struct BitchatPeer: Identifiable, Equatable {
|
||||
let id: String // Hex-encoded peer ID
|
||||
struct BitchatPeer: Equatable {
|
||||
let peerID: PeerID // Hex-encoded peer ID
|
||||
let noisePublicKey: Data
|
||||
let nickname: String
|
||||
let lastSeen: Date
|
||||
let isConnected: Bool
|
||||
let isReachable: Bool
|
||||
|
||||
// Favorite-related properties
|
||||
var favoriteStatus: FavoritesPersistenceService.FavoriteRelationship?
|
||||
@@ -18,6 +20,7 @@ struct BitchatPeer: Identifiable, Equatable {
|
||||
// Connection state
|
||||
enum ConnectionState {
|
||||
case bluetoothConnected
|
||||
case meshReachable // Seen via mesh recently, not directly connected
|
||||
case nostrAvailable // Mutual favorite, reachable via Nostr
|
||||
case offline // Not connected via any transport
|
||||
}
|
||||
@@ -25,6 +28,8 @@ struct BitchatPeer: Identifiable, Equatable {
|
||||
var connectionState: ConnectionState {
|
||||
if isConnected {
|
||||
return .bluetoothConnected
|
||||
} else if isReachable {
|
||||
return .meshReachable
|
||||
} else if favoriteStatus?.isMutual == true {
|
||||
// Mutual favorites can communicate via Nostr when offline
|
||||
return .nostrAvailable
|
||||
@@ -47,13 +52,15 @@ struct BitchatPeer: Identifiable, Equatable {
|
||||
|
||||
// Display helpers
|
||||
var displayName: String {
|
||||
nickname.isEmpty ? String(id.prefix(8)) : nickname
|
||||
nickname.isEmpty ? String(peerID.id.prefix(8)) : nickname
|
||||
}
|
||||
|
||||
var statusIcon: String {
|
||||
switch connectionState {
|
||||
case .bluetoothConnected:
|
||||
return "📻" // Radio icon for mesh connection
|
||||
case .meshReachable:
|
||||
return "📡" // Antenna for mesh reachable
|
||||
case .nostrAvailable:
|
||||
return "🌐" // Purple globe for Nostr
|
||||
case .offline:
|
||||
@@ -67,17 +74,19 @@ struct BitchatPeer: Identifiable, Equatable {
|
||||
|
||||
// Initialize from mesh service data
|
||||
init(
|
||||
id: String,
|
||||
peerID: PeerID,
|
||||
noisePublicKey: Data,
|
||||
nickname: String,
|
||||
lastSeen: Date = Date(),
|
||||
isConnected: Bool = false
|
||||
isConnected: Bool = false,
|
||||
isReachable: Bool = false
|
||||
) {
|
||||
self.id = id
|
||||
self.peerID = peerID
|
||||
self.noisePublicKey = noisePublicKey
|
||||
self.nickname = nickname
|
||||
self.lastSeen = lastSeen
|
||||
self.isConnected = isConnected
|
||||
self.isReachable = isReachable
|
||||
|
||||
// Load favorite status - will be set later by the manager
|
||||
self.favoriteStatus = nil
|
||||
@@ -85,8 +94,6 @@ struct BitchatPeer: Identifiable, Equatable {
|
||||
}
|
||||
|
||||
static func == (lhs: BitchatPeer, rhs: BitchatPeer) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
lhs.peerID == rhs.peerID
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
//
|
||||
// CommandsInfo.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - CommandInfo Enum
|
||||
|
||||
enum CommandInfo: String, Identifiable {
|
||||
case block
|
||||
case clear
|
||||
case hug
|
||||
case message = "dm"
|
||||
case slap
|
||||
case unblock
|
||||
case who
|
||||
case favorite
|
||||
case unfavorite
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var alias: String { "/" + rawValue }
|
||||
|
||||
var placeholder: String? {
|
||||
switch self {
|
||||
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
|
||||
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
|
||||
case .clear, .who:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .block: String(localized: "content.commands.block")
|
||||
case .clear: String(localized: "content.commands.clear")
|
||||
case .hug: String(localized: "content.commands.hug")
|
||||
case .message: String(localized: "content.commands.message")
|
||||
case .slap: String(localized: "content.commands.slap")
|
||||
case .unblock: String(localized: "content.commands.unblock")
|
||||
case .who: String(localized: "content.commands.who")
|
||||
case .favorite: String(localized: "content.commands.favorite")
|
||||
case .unfavorite: String(localized: "content.commands.unfavorite")
|
||||
}
|
||||
}
|
||||
|
||||
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
|
||||
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .hug, .message, .slap, .who]
|
||||
if isGeoPublic || isGeoDM {
|
||||
return baseCommands + [.favorite, .unfavorite]
|
||||
}
|
||||
return baseCommands
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//
|
||||
// NoisePayload.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Helper to create typed Noise payloads
|
||||
struct NoisePayload {
|
||||
let type: NoisePayloadType
|
||||
let data: Data
|
||||
|
||||
/// Encode payload with type prefix
|
||||
func encode() -> Data {
|
||||
var encoded = Data()
|
||||
encoded.append(type.rawValue)
|
||||
encoded.append(data)
|
||||
return encoded
|
||||
}
|
||||
|
||||
/// Decode payload from data
|
||||
static func decode(_ data: Data) -> NoisePayload? {
|
||||
// Ensure we have at least 1 byte for the type
|
||||
guard !data.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Safely get the first byte
|
||||
let firstByte = data[data.startIndex]
|
||||
guard let type = NoisePayloadType(rawValue: firstByte) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create a proper Data copy (not a subsequence) for thread safety
|
||||
let payloadData = data.count > 1 ? Data(data.dropFirst()) : Data()
|
||||
return NoisePayload(type: type, data: payloadData)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//
|
||||
// ReadReceipt.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
|
||||
struct ReadReceipt: Codable {
|
||||
let originalMessageID: String
|
||||
let receiptID: String
|
||||
var readerID: PeerID // Who read it
|
||||
let readerNickname: String
|
||||
let timestamp: Date
|
||||
|
||||
init(originalMessageID: String, readerID: PeerID, readerNickname: String) {
|
||||
self.originalMessageID = originalMessageID
|
||||
self.receiptID = UUID().uuidString
|
||||
self.readerID = readerID
|
||||
self.readerNickname = readerNickname
|
||||
self.timestamp = Date()
|
||||
}
|
||||
|
||||
// For binary decoding
|
||||
private init(originalMessageID: String, receiptID: String, readerID: PeerID, readerNickname: String, timestamp: Date) {
|
||||
self.originalMessageID = originalMessageID
|
||||
self.receiptID = receiptID
|
||||
self.readerID = readerID
|
||||
self.readerNickname = readerNickname
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
|
||||
func encode() -> Data? {
|
||||
try? JSONEncoder().encode(self)
|
||||
}
|
||||
|
||||
static func decode(from data: Data) -> ReadReceipt? {
|
||||
try? JSONDecoder().decode(ReadReceipt.self, from: data)
|
||||
}
|
||||
|
||||
// MARK: - Binary Encoding
|
||||
|
||||
func toBinaryData() -> Data {
|
||||
var data = Data()
|
||||
data.appendUUID(originalMessageID)
|
||||
data.appendUUID(receiptID)
|
||||
// ReaderID as 8-byte hex string
|
||||
var readerData = Data()
|
||||
var tempID = readerID.id
|
||||
while tempID.count >= 2 && readerData.count < 8 {
|
||||
let hexByte = String(tempID.prefix(2))
|
||||
if let byte = UInt8(hexByte, radix: 16) {
|
||||
readerData.append(byte)
|
||||
}
|
||||
tempID = String(tempID.dropFirst(2))
|
||||
}
|
||||
while readerData.count < 8 {
|
||||
readerData.append(0)
|
||||
}
|
||||
data.append(readerData)
|
||||
data.appendDate(timestamp)
|
||||
data.appendString(readerNickname)
|
||||
return data
|
||||
}
|
||||
|
||||
static func fromBinaryData(_ data: Data) -> ReadReceipt? {
|
||||
// Create defensive copy
|
||||
let dataCopy = Data(data)
|
||||
|
||||
// Minimum size: 2 UUIDs (32) + readerID (8) + timestamp (8) + min nickname
|
||||
guard dataCopy.count >= 49 else { return nil }
|
||||
|
||||
var offset = 0
|
||||
|
||||
guard let originalMessageID = dataCopy.readUUID(at: &offset),
|
||||
let receiptID = dataCopy.readUUID(at: &offset) else { return nil }
|
||||
|
||||
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
||||
let readerID = PeerID(hexData: readerIDData)
|
||||
guard readerID.isValid else { return nil }
|
||||
|
||||
guard let timestamp = dataCopy.readDate(at: &offset),
|
||||
InputValidator.validateTimestamp(timestamp),
|
||||
let readerNicknameRaw = dataCopy.readString(at: &offset),
|
||||
let readerNickname = InputValidator.validateNickname(readerNicknameRaw) else { return nil }
|
||||
|
||||
return ReadReceipt(originalMessageID: originalMessageID,
|
||||
receiptID: receiptID,
|
||||
readerID: readerID,
|
||||
readerNickname: readerNickname,
|
||||
timestamp: timestamp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import Foundation
|
||||
|
||||
// REQUEST_SYNC payload TLV (type, length16, value)
|
||||
// - 0x01: P (uint8) — Golomb-Rice parameter
|
||||
// - 0x02: M (uint32, big-endian) — hash range (N * 2^P)
|
||||
// - 0x03: data (opaque) — GR bitstream bytes (MSB-first)
|
||||
struct RequestSyncPacket {
|
||||
let p: Int
|
||||
let m: UInt32
|
||||
let data: Data
|
||||
let types: SyncTypeFlags?
|
||||
let sinceTimestamp: UInt64?
|
||||
let fragmentIdFilter: String?
|
||||
|
||||
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil, sinceTimestamp: UInt64? = nil, fragmentIdFilter: String? = nil) {
|
||||
self.p = p
|
||||
self.m = m
|
||||
self.data = data
|
||||
self.types = types
|
||||
self.sinceTimestamp = sinceTimestamp
|
||||
self.fragmentIdFilter = fragmentIdFilter
|
||||
}
|
||||
|
||||
func encode() -> Data {
|
||||
var out = Data()
|
||||
func putTLV(_ t: UInt8, _ v: Data) {
|
||||
out.append(t)
|
||||
let len = UInt16(v.count)
|
||||
out.append(UInt8((len >> 8) & 0xFF))
|
||||
out.append(UInt8(len & 0xFF))
|
||||
out.append(v)
|
||||
}
|
||||
// P
|
||||
putTLV(0x01, Data([UInt8(p & 0xFF)]))
|
||||
// M (uint32)
|
||||
var mBE = m.bigEndian
|
||||
putTLV(0x02, withUnsafeBytes(of: &mBE) { Data($0) })
|
||||
// data
|
||||
putTLV(0x03, data)
|
||||
if let typesData = types?.toData() {
|
||||
putTLV(0x04, typesData)
|
||||
}
|
||||
if let ts = sinceTimestamp {
|
||||
var tsBE = ts.bigEndian
|
||||
putTLV(0x05, withUnsafeBytes(of: &tsBE) { Data($0) })
|
||||
}
|
||||
if let fid = fragmentIdFilter, let fidData = fid.data(using: .utf8) {
|
||||
putTLV(0x06, fidData)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
static func decode(from data: Data, maxAcceptBytes: Int = 1024) -> RequestSyncPacket? {
|
||||
var off = 0
|
||||
var p: Int? = nil
|
||||
var m: UInt32? = nil
|
||||
var payload: Data? = nil
|
||||
var types: SyncTypeFlags? = nil
|
||||
var sinceTimestamp: UInt64? = nil
|
||||
var fragmentIdFilter: String? = nil
|
||||
|
||||
while off + 3 <= data.count {
|
||||
let t = Int(data[off]); off += 1
|
||||
guard off + 2 <= data.count else { return nil }
|
||||
let len = (Int(data[off]) << 8) | Int(data[off+1]); off += 2
|
||||
guard off + len <= data.count else { return nil }
|
||||
let v = data.subdata(in: off..<(off+len)); off += len
|
||||
switch t {
|
||||
case 0x01:
|
||||
if v.count == 1 { p = Int(v[0]) }
|
||||
case 0x02:
|
||||
if v.count == 4 {
|
||||
var mm: UInt32 = 0
|
||||
for b in v { mm = (mm << 8) | UInt32(b) }
|
||||
m = mm
|
||||
}
|
||||
case 0x03:
|
||||
if v.count > maxAcceptBytes { return nil }
|
||||
payload = v
|
||||
case 0x04:
|
||||
if let decoded = SyncTypeFlags.decode(v) {
|
||||
types = decoded
|
||||
}
|
||||
case 0x05:
|
||||
if v.count == 8 {
|
||||
var ts: UInt64 = 0
|
||||
for b in v { ts = (ts << 8) | UInt64(b) }
|
||||
sinceTimestamp = ts
|
||||
}
|
||||
case 0x06:
|
||||
if let fid = String(data: v, encoding: .utf8) {
|
||||
fragmentIdFilter = fid
|
||||
}
|
||||
default:
|
||||
break // forward compatible; ignore unknown TLVs
|
||||
}
|
||||
}
|
||||
|
||||
guard let pp = p, let mm = m, let dd = payload, pp >= 1, pp <= GCSFilter.maxP, mm > 0 else { return nil }
|
||||
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types, sinceTimestamp: sinceTimestamp, fragmentIdFilter: fragmentIdFilter)
|
||||
}
|
||||
}
|
||||
@@ -1,369 +0,0 @@
|
||||
//
|
||||
// NoiseHandshakeCoordinator.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Coordinates Noise handshakes to prevent race conditions and ensure reliable encryption establishment
|
||||
class NoiseHandshakeCoordinator {
|
||||
|
||||
// MARK: - Handshake State
|
||||
|
||||
enum HandshakeState: Equatable {
|
||||
case idle
|
||||
case waitingToInitiate(since: Date)
|
||||
case initiating(attempt: Int, lastAttempt: Date)
|
||||
case responding(since: Date)
|
||||
case waitingForResponse(messagesSent: [Data], timeout: Date)
|
||||
case established(since: Date)
|
||||
case failed(reason: String, canRetry: Bool, lastAttempt: Date)
|
||||
|
||||
var isActive: Bool {
|
||||
switch self {
|
||||
case .idle, .established, .failed:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private var handshakeStates: [String: HandshakeState] = [:]
|
||||
private var handshakeQueue = DispatchQueue(label: "chat.bitchat.noise.handshake", attributes: .concurrent)
|
||||
|
||||
// Configuration
|
||||
private let maxHandshakeAttempts = 3
|
||||
private let handshakeTimeout: TimeInterval = 10.0
|
||||
private let retryDelay: TimeInterval = 2.0
|
||||
private let minTimeBetweenHandshakes: TimeInterval = 1.0 // Reduced from 5.0 for faster recovery
|
||||
private let establishedSessionTTL: TimeInterval = 300.0 // 5 minutes - sessions older than this can be cleaned up
|
||||
private let maxEstablishedSessions = 50 // Limit total established sessions
|
||||
|
||||
// Track handshake messages to detect duplicates
|
||||
private var processedHandshakeMessages: Set<Data> = []
|
||||
private let messageHistoryLimit = 100
|
||||
|
||||
// MARK: - Role Determination
|
||||
|
||||
/// Deterministically determine who should initiate the handshake
|
||||
/// Lower peer ID becomes the initiator to prevent simultaneous attempts
|
||||
func determineHandshakeRole(myPeerID: String, remotePeerID: String) -> NoiseRole {
|
||||
// Use simple string comparison for deterministic ordering
|
||||
return myPeerID < remotePeerID ? .initiator : .responder
|
||||
}
|
||||
|
||||
/// Check if we should initiate handshake with a peer
|
||||
func shouldInitiateHandshake(myPeerID: String, remotePeerID: String, forceIfStale: Bool = false) -> Bool {
|
||||
return handshakeQueue.sync {
|
||||
// Check if we're already in an active handshake
|
||||
if let state = handshakeStates[remotePeerID], state.isActive {
|
||||
// Check if the handshake is stale and we should force a new one
|
||||
if forceIfStale {
|
||||
switch state {
|
||||
case .initiating(_, let lastAttempt):
|
||||
if Date().timeIntervalSince(lastAttempt) > handshakeTimeout {
|
||||
SecureLogger.log("Forcing new handshake with \(remotePeerID) - previous stuck in initiating",
|
||||
category: SecureLogger.handshake, level: .warning)
|
||||
return true
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
SecureLogger.log("Already in active handshake with \(remotePeerID), state: \(state)",
|
||||
category: SecureLogger.handshake, level: .debug)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check role
|
||||
let role = determineHandshakeRole(myPeerID: myPeerID, remotePeerID: remotePeerID)
|
||||
if role != .initiator {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if we've failed recently and can't retry yet
|
||||
if case .failed(_, let canRetry, let lastAttempt) = handshakeStates[remotePeerID] {
|
||||
if !canRetry {
|
||||
return false
|
||||
}
|
||||
if Date().timeIntervalSince(lastAttempt) < retryDelay {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Record that we're initiating a handshake
|
||||
func recordHandshakeInitiation(peerID: String) {
|
||||
handshakeQueue.async(flags: .barrier) {
|
||||
let attempt = self.getCurrentAttempt(for: peerID) + 1
|
||||
self.handshakeStates[peerID] = .initiating(attempt: attempt, lastAttempt: Date())
|
||||
SecureLogger.log("Recording handshake initiation with \(peerID), attempt \(attempt)",
|
||||
category: SecureLogger.handshake, level: .info)
|
||||
}
|
||||
}
|
||||
|
||||
/// Record that we're responding to a handshake
|
||||
func recordHandshakeResponse(peerID: String) {
|
||||
handshakeQueue.async(flags: .barrier) {
|
||||
self.handshakeStates[peerID] = .responding(since: Date())
|
||||
SecureLogger.log("Recording handshake response to \(peerID)",
|
||||
category: SecureLogger.handshake, level: .info)
|
||||
}
|
||||
}
|
||||
|
||||
/// Record successful handshake completion
|
||||
func recordHandshakeSuccess(peerID: String) {
|
||||
handshakeQueue.async(flags: .barrier) {
|
||||
self.handshakeStates[peerID] = .established(since: Date())
|
||||
SecureLogger.log("Handshake successfully established with \(peerID)",
|
||||
category: SecureLogger.handshake, level: .info)
|
||||
}
|
||||
}
|
||||
|
||||
/// Record handshake failure
|
||||
func recordHandshakeFailure(peerID: String, reason: String) {
|
||||
handshakeQueue.async(flags: .barrier) {
|
||||
let attempts = self.getCurrentAttempt(for: peerID)
|
||||
let canRetry = attempts < self.maxHandshakeAttempts
|
||||
self.handshakeStates[peerID] = .failed(reason: reason, canRetry: canRetry, lastAttempt: Date())
|
||||
SecureLogger.log("Handshake failed with \(peerID): \(reason), canRetry: \(canRetry)",
|
||||
category: SecureLogger.handshake, level: .warning)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if we should accept an incoming handshake initiation
|
||||
func shouldAcceptHandshakeInitiation(myPeerID: String, remotePeerID: String) -> Bool {
|
||||
return handshakeQueue.sync {
|
||||
// If we're already established, reject new handshakes
|
||||
if case .established = handshakeStates[remotePeerID] {
|
||||
SecureLogger.log("Rejecting handshake from \(remotePeerID) - already established",
|
||||
category: SecureLogger.handshake, level: .debug)
|
||||
return false
|
||||
}
|
||||
|
||||
let role = determineHandshakeRole(myPeerID: myPeerID, remotePeerID: remotePeerID)
|
||||
|
||||
// If we're the initiator and already initiating, this is a race condition
|
||||
if role == .initiator {
|
||||
if case .initiating = handshakeStates[remotePeerID] {
|
||||
// They shouldn't be initiating, but accept it to recover from race condition
|
||||
SecureLogger.log("Accepting handshake from \(remotePeerID) despite being initiator (race condition recovery)",
|
||||
category: SecureLogger.handshake, level: .warning)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// If we're the responder, we should accept
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this is a duplicate handshake message
|
||||
func isDuplicateHandshakeMessage(_ data: Data) -> Bool {
|
||||
return handshakeQueue.sync {
|
||||
if processedHandshakeMessages.contains(data) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Add to processed messages with size limit
|
||||
if processedHandshakeMessages.count >= messageHistoryLimit {
|
||||
processedHandshakeMessages.removeAll()
|
||||
}
|
||||
processedHandshakeMessages.insert(data)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Get time to wait before next handshake attempt
|
||||
func getRetryDelay(for peerID: String) -> TimeInterval? {
|
||||
return handshakeQueue.sync {
|
||||
guard let state = handshakeStates[peerID] else { return nil }
|
||||
|
||||
switch state {
|
||||
case .failed(_, let canRetry, let lastAttempt):
|
||||
if !canRetry { return nil }
|
||||
let timeSinceFailure = Date().timeIntervalSince(lastAttempt)
|
||||
if timeSinceFailure >= retryDelay {
|
||||
return 0
|
||||
}
|
||||
return retryDelay - timeSinceFailure
|
||||
|
||||
case .initiating(_, let lastAttempt):
|
||||
let timeSinceAttempt = Date().timeIntervalSince(lastAttempt)
|
||||
if timeSinceAttempt >= minTimeBetweenHandshakes {
|
||||
return 0
|
||||
}
|
||||
return minTimeBetweenHandshakes - timeSinceAttempt
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset handshake state for a peer
|
||||
func resetHandshakeState(for peerID: String) {
|
||||
handshakeQueue.async(flags: .barrier) {
|
||||
self.handshakeStates.removeValue(forKey: peerID)
|
||||
SecureLogger.log("Reset handshake state for \(peerID)",
|
||||
category: SecureLogger.handshake, level: .debug)
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean up stale handshake states and old established sessions
|
||||
func cleanupStaleHandshakes(staleTimeout: TimeInterval = 30.0) -> [String] {
|
||||
return handshakeQueue.sync {
|
||||
let now = Date()
|
||||
var stalePeerIDs: [String] = []
|
||||
var establishedSessions: [(peerID: String, since: Date)] = []
|
||||
|
||||
for (peerID, state) in handshakeStates {
|
||||
var isStale = false
|
||||
|
||||
switch state {
|
||||
case .initiating(_, let lastAttempt):
|
||||
if now.timeIntervalSince(lastAttempt) > staleTimeout {
|
||||
isStale = true
|
||||
}
|
||||
case .responding(let since):
|
||||
if now.timeIntervalSince(since) > staleTimeout {
|
||||
isStale = true
|
||||
}
|
||||
case .waitingForResponse(_, let timeout):
|
||||
if now > timeout {
|
||||
isStale = true
|
||||
}
|
||||
case .established(let since):
|
||||
// Track established sessions for potential cleanup
|
||||
establishedSessions.append((peerID, since))
|
||||
// Clean up very old established sessions
|
||||
if now.timeIntervalSince(since) > establishedSessionTTL {
|
||||
isStale = true
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if isStale {
|
||||
stalePeerIDs.append(peerID)
|
||||
SecureLogger.log("Found stale handshake state for \(peerID): \(state)",
|
||||
category: SecureLogger.handshake, level: .warning)
|
||||
}
|
||||
}
|
||||
|
||||
// If we have too many established sessions, clean up the oldest ones
|
||||
if establishedSessions.count > maxEstablishedSessions {
|
||||
// Sort by age (oldest first)
|
||||
let sortedSessions = establishedSessions.sorted { $0.since < $1.since }
|
||||
let sessionsToRemove = sortedSessions.count - maxEstablishedSessions
|
||||
|
||||
for i in 0..<sessionsToRemove {
|
||||
let peerID = sortedSessions[i].peerID
|
||||
stalePeerIDs.append(peerID)
|
||||
SecureLogger.log("Removing old established session for \(peerID) to maintain session limit",
|
||||
category: SecureLogger.handshake, level: .info)
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up stale states
|
||||
for peerID in stalePeerIDs {
|
||||
handshakeStates.removeValue(forKey: peerID)
|
||||
}
|
||||
|
||||
if !stalePeerIDs.isEmpty {
|
||||
SecureLogger.log("Cleaned up \(stalePeerIDs.count) stale handshake states",
|
||||
category: SecureLogger.handshake, level: .info)
|
||||
}
|
||||
|
||||
return stalePeerIDs
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current handshake state
|
||||
func getHandshakeState(for peerID: String) -> HandshakeState {
|
||||
return handshakeQueue.sync {
|
||||
return handshakeStates[peerID] ?? .idle
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current retry count for a peer
|
||||
func getRetryCount(for peerID: String) -> Int {
|
||||
return handshakeQueue.sync {
|
||||
switch handshakeStates[peerID] {
|
||||
case .initiating(let attempt, _):
|
||||
return attempt - 1 // Attempts start at 1, retries start at 0
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Increment retry count for a peer
|
||||
func incrementRetryCount(for peerID: String) {
|
||||
handshakeQueue.async(flags: .barrier) {
|
||||
let currentAttempt = self.getCurrentAttempt(for: peerID)
|
||||
self.handshakeStates[peerID] = .initiating(attempt: currentAttempt + 1, lastAttempt: Date())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func getCurrentAttempt(for peerID: String) -> Int {
|
||||
switch handshakeStates[peerID] {
|
||||
case .initiating(let attempt, _):
|
||||
return attempt
|
||||
case .failed(_, _, _):
|
||||
// Count previous attempts
|
||||
return 1 // Simplified for now
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Log current handshake states for debugging
|
||||
func logHandshakeStates() {
|
||||
handshakeQueue.sync {
|
||||
SecureLogger.log("=== Handshake States ===", category: SecureLogger.handshake, level: .debug)
|
||||
for (peerID, state) in handshakeStates {
|
||||
let stateDesc: String
|
||||
switch state {
|
||||
case .idle:
|
||||
stateDesc = "idle"
|
||||
case .waitingToInitiate(let since):
|
||||
stateDesc = "waiting to initiate (since \(since))"
|
||||
case .initiating(let attempt, let lastAttempt):
|
||||
stateDesc = "initiating (attempt \(attempt), last: \(lastAttempt))"
|
||||
case .responding(let since):
|
||||
stateDesc = "responding (since: \(since))"
|
||||
case .waitingForResponse(let messages, let timeout):
|
||||
stateDesc = "waiting for response (\(messages.count) messages, timeout: \(timeout))"
|
||||
case .established(let since):
|
||||
stateDesc = "established (since \(since))"
|
||||
case .failed(let reason, let canRetry, let lastAttempt):
|
||||
stateDesc = "failed: \(reason) (canRetry: \(canRetry), last: \(lastAttempt))"
|
||||
}
|
||||
SecureLogger.log(" \(peerID): \(stateDesc)", category: SecureLogger.handshake, level: .debug)
|
||||
}
|
||||
SecureLogger.log("========================", category: SecureLogger.handshake, level: .debug)
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all handshake states - used during panic mode
|
||||
func clearAllHandshakeStates() {
|
||||
handshakeQueue.async(flags: .barrier) {
|
||||
SecureLogger.log("Clearing all handshake states for panic mode", category: SecureLogger.handshake, level: .warning)
|
||||
self.handshakeStates.removeAll()
|
||||
self.processedHandshakeMessages.removeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,9 +77,10 @@
|
||||
/// - Noise Specification: http://www.noiseprotocol.org/noise.html
|
||||
///
|
||||
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import os.log
|
||||
|
||||
// Core Noise Protocol implementation
|
||||
// Based on the Noise Protocol Framework specification
|
||||
@@ -127,7 +128,7 @@ struct NoiseProtocolName {
|
||||
/// Handles ChaCha20-Poly1305 AEAD encryption with automatic nonce management
|
||||
/// and replay protection using a sliding window algorithm.
|
||||
/// - Warning: Nonce reuse would be catastrophic for security
|
||||
class NoiseCipherState {
|
||||
final class NoiseCipherState {
|
||||
// Constants for replay protection
|
||||
private static let NONCE_SIZE_BYTES = 4
|
||||
private static let REPLAY_WINDOW_SIZE = 1024
|
||||
@@ -165,19 +166,23 @@ class NoiseCipherState {
|
||||
// MARK: - Sliding Window Replay Protection
|
||||
|
||||
/// Check if nonce is valid for replay protection
|
||||
/// BCH-01-010: Use safe arithmetic to prevent integer overflow
|
||||
private func isValidNonce(_ receivedNonce: UInt64) -> Bool {
|
||||
if receivedNonce + UInt64(Self.REPLAY_WINDOW_SIZE) <= highestReceivedNonce {
|
||||
// Safe overflow check: instead of (receivedNonce + WINDOW_SIZE <= highest)
|
||||
// use (highest >= WINDOW_SIZE && receivedNonce <= highest - WINDOW_SIZE)
|
||||
let windowSize = UInt64(Self.REPLAY_WINDOW_SIZE)
|
||||
if highestReceivedNonce >= windowSize && receivedNonce <= highestReceivedNonce - windowSize {
|
||||
return false // Too old, outside window
|
||||
}
|
||||
|
||||
|
||||
if receivedNonce > highestReceivedNonce {
|
||||
return true // Always accept newer nonces
|
||||
}
|
||||
|
||||
|
||||
let offset = Int(highestReceivedNonce - receivedNonce)
|
||||
let byteIndex = offset / 8
|
||||
let bitIndex = offset % 8
|
||||
|
||||
|
||||
return (replayWindow[byteIndex] & (1 << bitIndex)) == 0 // Not yet seen
|
||||
}
|
||||
|
||||
@@ -222,7 +227,7 @@ class NoiseCipherState {
|
||||
guard combinedPayload.count >= Self.NONCE_SIZE_BYTES else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
// Extract 4-byte nonce (big-endian)
|
||||
let nonceData = combinedPayload.prefix(Self.NONCE_SIZE_BYTES)
|
||||
let extractedNonce = nonceData.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) -> UInt64 in
|
||||
@@ -233,18 +238,18 @@ class NoiseCipherState {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
// Extract ciphertext (remaining bytes)
|
||||
let ciphertext = combinedPayload.dropFirst(Self.NONCE_SIZE_BYTES)
|
||||
|
||||
|
||||
return (nonce: extractedNonce, ciphertext: Data(ciphertext))
|
||||
}
|
||||
|
||||
|
||||
/// Convert nonce to 4-byte array (big-endian)
|
||||
private func nonceToBytes(_ nonce: UInt64) -> Data {
|
||||
var bytes = Data(count: Self.NONCE_SIZE_BYTES)
|
||||
withUnsafeBytes(of: nonce.bigEndian) { ptr in
|
||||
// Copy only the last 4 bytes from the 8-byte UInt64
|
||||
// Copy only the last 4 bytes from the 8-byte UInt64
|
||||
let sourceBytes = ptr.bindMemory(to: UInt8.self)
|
||||
bytes.replaceSubrange(0..<Self.NONCE_SIZE_BYTES, with: sourceBytes.suffix(Self.NONCE_SIZE_BYTES))
|
||||
}
|
||||
@@ -273,7 +278,7 @@ class NoiseCipherState {
|
||||
let sealedBox = try ChaChaPoly.seal(plaintext, using: key, nonce: ChaChaPoly.Nonce(data: nonceData), authenticating: associatedData)
|
||||
// increment local nonce
|
||||
nonce += 1
|
||||
|
||||
|
||||
// Create combined payload: <nonce><ciphertext>
|
||||
let combinedPayload: Data
|
||||
if (useExtractedNonce) {
|
||||
@@ -285,9 +290,9 @@ class NoiseCipherState {
|
||||
|
||||
// Log high nonce values that might indicate issues
|
||||
if currentNonce > Self.HIGH_NONCE_WARNING_THRESHOLD {
|
||||
SecureLogger.log("High nonce value detected: \(currentNonce) - consider rekeying", category: SecureLogger.encryption, level: .warning)
|
||||
SecureLogger.warning("High nonce value detected: \(currentNonce) - consider rekeying", category: .encryption)
|
||||
}
|
||||
|
||||
|
||||
return combinedPayload
|
||||
}
|
||||
|
||||
@@ -307,16 +312,16 @@ class NoiseCipherState {
|
||||
if useExtractedNonce {
|
||||
// Extract nonce and ciphertext from combined payload
|
||||
guard let (extractedNonce, actualCiphertext) = try extractNonceFromCiphertextPayload(ciphertext) else {
|
||||
SecureLogger.log("Decrypt failed: Could not extract nonce from payload")
|
||||
SecureLogger.debug("Decrypt failed: Could not extract nonce from payload")
|
||||
throw NoiseError.invalidCiphertext
|
||||
}
|
||||
|
||||
// Validate nonce with sliding window replay protection
|
||||
guard isValidNonce(extractedNonce) else {
|
||||
SecureLogger.log("Replay attack detected: nonce \(extractedNonce) rejected")
|
||||
SecureLogger.debug("Replay attack detected: nonce \(extractedNonce) rejected")
|
||||
throw NoiseError.replayDetected
|
||||
}
|
||||
|
||||
|
||||
// Split ciphertext and tag
|
||||
encryptedData = actualCiphertext.prefix(actualCiphertext.count - 16)
|
||||
tag = actualCiphertext.suffix(16)
|
||||
@@ -342,22 +347,26 @@ class NoiseCipherState {
|
||||
|
||||
// Log high nonce values that might indicate issues
|
||||
if decryptionNonce > Self.HIGH_NONCE_WARNING_THRESHOLD {
|
||||
SecureLogger.log("High nonce value detected: \(decryptionNonce) - consider rekeying", category: SecureLogger.encryption, level: .warning)
|
||||
SecureLogger.warning("High nonce value detected: \(decryptionNonce) - consider rekeying", category: .encryption)
|
||||
}
|
||||
|
||||
do {
|
||||
let plaintext = try ChaChaPoly.open(sealedBox, using: key, authenticating: associatedData)
|
||||
|
||||
|
||||
// BCH-01-010: Atomic nonce state update
|
||||
// Both replay window marking and nonce increment must complete together
|
||||
// to prevent state desynchronization. We perform both after successful
|
||||
// decryption only, ensuring state consistency on any failure path.
|
||||
if useExtractedNonce {
|
||||
// Mark nonce as seen after successful decryption
|
||||
markNonceAsSeen(decryptionNonce)
|
||||
}
|
||||
nonce += 1
|
||||
|
||||
return plaintext
|
||||
} catch {
|
||||
SecureLogger.log("Decrypt failed: \(error) for nonce \(decryptionNonce)")
|
||||
// Log authentication failures with nonce info
|
||||
SecureLogger.log("Decryption failed at nonce \(decryptionNonce)", category: SecureLogger.encryption, level: .error)
|
||||
// Decryption failed - nonce state remains unchanged (atomic rollback)
|
||||
SecureLogger.debug("Decrypt failed: \(error) for nonce \(decryptionNonce)")
|
||||
SecureLogger.error("Decryption failed at nonce \(decryptionNonce)", category: .encryption)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -376,6 +385,16 @@ class NoiseCipherState {
|
||||
replayWindow[i] = 0
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func setNonceForTesting(_ nonce: UInt64) {
|
||||
self.nonce = nonce
|
||||
}
|
||||
|
||||
func extractNonceFromCiphertextPayloadForTesting(_ combinedPayload: Data) throws -> (nonce: UInt64, ciphertext: Data)? {
|
||||
try extractNonceFromCiphertextPayload(combinedPayload)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Symmetric State
|
||||
@@ -384,7 +403,7 @@ class NoiseCipherState {
|
||||
/// Responsible for key derivation, protocol name hashing, and maintaining
|
||||
/// the chaining key that provides key separation between handshake messages.
|
||||
/// - Note: This class implements the SymmetricState object from the Noise spec
|
||||
class NoiseSymmetricState {
|
||||
final class NoiseSymmetricState {
|
||||
private var cipherState: NoiseCipherState
|
||||
private var chainingKey: Data
|
||||
private var hash: Data
|
||||
@@ -397,7 +416,7 @@ class NoiseSymmetricState {
|
||||
if nameData.count <= 32 {
|
||||
self.hash = nameData + Data(repeating: 0, count: 32 - nameData.count)
|
||||
} else {
|
||||
self.hash = Data(SHA256.hash(data: nameData))
|
||||
self.hash = nameData.sha256Hash()
|
||||
}
|
||||
self.chainingKey = self.hash
|
||||
}
|
||||
@@ -410,7 +429,7 @@ class NoiseSymmetricState {
|
||||
}
|
||||
|
||||
func mixHash(_ data: Data) {
|
||||
hash = Data(SHA256.hash(data: hash + data))
|
||||
hash = (hash + data).sha256Hash()
|
||||
}
|
||||
|
||||
func mixKeyAndHash(_ inputKeyMaterial: Data) {
|
||||
@@ -451,17 +470,40 @@ class NoiseSymmetricState {
|
||||
}
|
||||
}
|
||||
|
||||
func split() -> (NoiseCipherState, NoiseCipherState) {
|
||||
func split(useExtractedNonce: Bool) -> (NoiseCipherState, NoiseCipherState) {
|
||||
let output = hkdf(chainingKey: chainingKey, inputKeyMaterial: Data(), numOutputs: 2)
|
||||
let tempKey1 = SymmetricKey(data: output[0])
|
||||
let tempKey2 = SymmetricKey(data: output[1])
|
||||
|
||||
let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: true)
|
||||
let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: true)
|
||||
|
||||
|
||||
let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: useExtractedNonce)
|
||||
let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: useExtractedNonce)
|
||||
|
||||
// BCH-01-010: Clear symmetric state after split per Noise spec
|
||||
// The chaining key and hash should not be retained after handshake completes
|
||||
clearSensitiveData()
|
||||
|
||||
return (c1, c2)
|
||||
}
|
||||
|
||||
|
||||
/// BCH-01-010: Securely clear sensitive cryptographic state
|
||||
/// Called after split() to clear chaining key and hash per Noise spec
|
||||
func clearSensitiveData() {
|
||||
// Clear chaining key by overwriting with zeros
|
||||
let chainingKeyCount = chainingKey.count
|
||||
chainingKey = Data(repeating: 0, count: chainingKeyCount)
|
||||
|
||||
// Clear hash by overwriting with zeros
|
||||
let hashCount = hash.count
|
||||
hash = Data(repeating: 0, count: hashCount)
|
||||
|
||||
// Clear the internal cipher state
|
||||
cipherState.clearSensitiveData()
|
||||
}
|
||||
|
||||
deinit {
|
||||
clearSensitiveData()
|
||||
}
|
||||
|
||||
// HKDF implementation
|
||||
private func hkdf(chainingKey: Data, inputKeyMaterial: Data, numOutputs: Int) -> [Data] {
|
||||
let tempKey = HMAC<SHA256>.authenticationCode(for: inputKeyMaterial, using: SymmetricKey(data: chainingKey))
|
||||
@@ -488,9 +530,10 @@ class NoiseSymmetricState {
|
||||
/// This is the main interface for establishing encrypted sessions between peers.
|
||||
/// Manages the handshake state machine, message patterns, and key derivation.
|
||||
/// - Important: Each handshake instance should only be used once
|
||||
class NoiseHandshakeState {
|
||||
final class NoiseHandshakeState {
|
||||
private let role: NoiseRole
|
||||
private let pattern: NoisePattern
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private var symmetricState: NoiseSymmetricState
|
||||
|
||||
// Keys
|
||||
@@ -506,9 +549,24 @@ class NoiseHandshakeState {
|
||||
private var messagePatterns: [[NoiseMessagePattern]] = []
|
||||
private var currentPattern = 0
|
||||
|
||||
init(role: NoiseRole, pattern: NoisePattern, localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil, remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil) {
|
||||
// Test support: predetermined ephemeral keys for test vectors
|
||||
private var predeterminedEphemeralKey: Curve25519.KeyAgreement.PrivateKey?
|
||||
private var prologueData: Data
|
||||
|
||||
init(
|
||||
role: NoiseRole,
|
||||
pattern: NoisePattern,
|
||||
keychain: KeychainManagerProtocol,
|
||||
localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil,
|
||||
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil,
|
||||
prologue: Data = Data(),
|
||||
predeterminedEphemeralKey: Curve25519.KeyAgreement.PrivateKey? = nil
|
||||
) {
|
||||
self.role = role
|
||||
self.pattern = pattern
|
||||
self.keychain = keychain
|
||||
self.prologueData = prologue
|
||||
self.predeterminedEphemeralKey = predeterminedEphemeralKey
|
||||
|
||||
// Initialize static keys
|
||||
if let localKey = localStaticKey {
|
||||
@@ -529,8 +587,8 @@ class NoiseHandshakeState {
|
||||
}
|
||||
|
||||
private func mixPreMessageKeys() {
|
||||
// Mix prologue (empty for XX pattern normally)
|
||||
symmetricState.mixHash(Data()) // Empty prologue for XX pattern
|
||||
// Mix prologue
|
||||
symmetricState.mixHash(self.prologueData)
|
||||
// For XX pattern, no pre-message keys
|
||||
// For IK/NK patterns, we'd mix the responder's static key here
|
||||
switch pattern {
|
||||
@@ -538,8 +596,9 @@ class NoiseHandshakeState {
|
||||
break // No pre-message keys
|
||||
case .IK, .NK:
|
||||
if role == .initiator, let remoteStatic = remoteStaticPublic {
|
||||
_ = symmetricState.getHandshakeHash()
|
||||
symmetricState.mixHash(remoteStatic.rawRepresentation)
|
||||
} else if role == .responder, let localStatic = localStaticPublic {
|
||||
symmetricState.mixHash(localStatic.rawRepresentation)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -548,15 +607,20 @@ class NoiseHandshakeState {
|
||||
guard currentPattern < messagePatterns.count else {
|
||||
throw NoiseError.handshakeComplete
|
||||
}
|
||||
|
||||
|
||||
var messageBuffer = Data()
|
||||
let patterns = messagePatterns[currentPattern]
|
||||
|
||||
for pattern in patterns {
|
||||
switch pattern {
|
||||
case .e:
|
||||
// Generate ephemeral key
|
||||
localEphemeralPrivate = Curve25519.KeyAgreement.PrivateKey()
|
||||
// Generate ephemeral key (or use predetermined key for tests)
|
||||
if let predetermined = predeterminedEphemeralKey {
|
||||
localEphemeralPrivate = predetermined
|
||||
predeterminedEphemeralKey = nil
|
||||
} else {
|
||||
localEphemeralPrivate = Curve25519.KeyAgreement.PrivateKey()
|
||||
}
|
||||
localEphemeralPublic = localEphemeralPrivate!.publicKey
|
||||
messageBuffer.append(localEphemeralPublic!.rawRepresentation)
|
||||
symmetricState.mixHash(localEphemeralPublic!.rawRepresentation)
|
||||
@@ -579,7 +643,7 @@ class NoiseHandshakeState {
|
||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||
symmetricState.mixKey(sharedData)
|
||||
// Clear sensitive shared secret
|
||||
KeychainManager.secureClear(&sharedData)
|
||||
keychain.secureClear(&sharedData)
|
||||
|
||||
case .es:
|
||||
// DH(ephemeral, static) - direction depends on role
|
||||
@@ -589,14 +653,20 @@ class NoiseHandshakeState {
|
||||
throw NoiseError.missingKeys
|
||||
}
|
||||
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic)
|
||||
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||
symmetricState.mixKey(sharedData)
|
||||
// Clear sensitive shared secret
|
||||
keychain.secureClear(&sharedData)
|
||||
} else {
|
||||
guard let localStatic = localStaticPrivate,
|
||||
let remoteEphemeral = remoteEphemeralPublic else {
|
||||
throw NoiseError.missingKeys
|
||||
}
|
||||
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral)
|
||||
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||
symmetricState.mixKey(sharedData)
|
||||
// Clear sensitive shared secret
|
||||
keychain.secureClear(&sharedData)
|
||||
}
|
||||
|
||||
case .se:
|
||||
@@ -607,14 +677,20 @@ class NoiseHandshakeState {
|
||||
throw NoiseError.missingKeys
|
||||
}
|
||||
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral)
|
||||
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||
symmetricState.mixKey(sharedData)
|
||||
// Clear sensitive shared secret
|
||||
keychain.secureClear(&sharedData)
|
||||
} else {
|
||||
guard let localEphemeral = localEphemeralPrivate,
|
||||
let remoteStatic = remoteStaticPublic else {
|
||||
throw NoiseError.missingKeys
|
||||
}
|
||||
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic)
|
||||
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||
symmetricState.mixKey(sharedData)
|
||||
// Clear sensitive shared secret
|
||||
keychain.secureClear(&sharedData)
|
||||
}
|
||||
|
||||
case .ss:
|
||||
@@ -627,7 +703,7 @@ class NoiseHandshakeState {
|
||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||
symmetricState.mixKey(sharedData)
|
||||
// Clear sensitive shared secret
|
||||
KeychainManager.secureClear(&sharedData)
|
||||
keychain.secureClear(&sharedData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -644,7 +720,7 @@ class NoiseHandshakeState {
|
||||
guard currentPattern < messagePatterns.count else {
|
||||
throw NoiseError.handshakeComplete
|
||||
}
|
||||
|
||||
|
||||
var buffer = message
|
||||
let patterns = messagePatterns[currentPattern]
|
||||
|
||||
@@ -661,7 +737,7 @@ class NoiseHandshakeState {
|
||||
do {
|
||||
remoteEphemeralPublic = try NoiseHandshakeState.validatePublicKey(ephemeralData)
|
||||
} catch {
|
||||
SecureLogger.log("Invalid ephemeral public key received", category: SecureLogger.security, level: .warning)
|
||||
SecureLogger.warning("Invalid ephemeral public key received", category: .security)
|
||||
throw NoiseError.invalidMessage
|
||||
}
|
||||
symmetricState.mixHash(ephemeralData)
|
||||
@@ -678,7 +754,7 @@ class NoiseHandshakeState {
|
||||
let decrypted = try symmetricState.decryptAndHash(staticData)
|
||||
remoteStaticPublic = try NoiseHandshakeState.validatePublicKey(decrypted)
|
||||
} catch {
|
||||
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: "Unknown - handshake"), level: .error)
|
||||
SecureLogger.error(.authenticationFailed(peerID: "Unknown - handshake"))
|
||||
throw NoiseError.authenticationFailure
|
||||
}
|
||||
|
||||
@@ -703,8 +779,11 @@ class NoiseHandshakeState {
|
||||
throw NoiseError.missingKeys
|
||||
}
|
||||
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteEphemeral)
|
||||
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||
|
||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||
symmetricState.mixKey(sharedData)
|
||||
// Clear sensitive shared secret
|
||||
keychain.secureClear(&sharedData)
|
||||
|
||||
case .es:
|
||||
if role == .initiator {
|
||||
guard let localEphemeral = localEphemeralPrivate,
|
||||
@@ -715,7 +794,7 @@ class NoiseHandshakeState {
|
||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||
symmetricState.mixKey(sharedData)
|
||||
// Clear sensitive shared secret
|
||||
KeychainManager.secureClear(&sharedData)
|
||||
keychain.secureClear(&sharedData)
|
||||
} else {
|
||||
guard let localStatic = localStaticPrivate,
|
||||
let remoteEphemeral = remoteEphemeralPublic else {
|
||||
@@ -725,7 +804,7 @@ class NoiseHandshakeState {
|
||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||
symmetricState.mixKey(sharedData)
|
||||
// Clear sensitive shared secret
|
||||
KeychainManager.secureClear(&sharedData)
|
||||
keychain.secureClear(&sharedData)
|
||||
}
|
||||
|
||||
case .se:
|
||||
@@ -738,7 +817,7 @@ class NoiseHandshakeState {
|
||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||
symmetricState.mixKey(sharedData)
|
||||
// Clear sensitive shared secret
|
||||
KeychainManager.secureClear(&sharedData)
|
||||
keychain.secureClear(&sharedData)
|
||||
} else {
|
||||
guard let localEphemeral = localEphemeralPrivate,
|
||||
let remoteStatic = remoteStaticPublic else {
|
||||
@@ -748,7 +827,7 @@ class NoiseHandshakeState {
|
||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||
symmetricState.mixKey(sharedData)
|
||||
// Clear sensitive shared secret
|
||||
KeychainManager.secureClear(&sharedData)
|
||||
keychain.secureClear(&sharedData)
|
||||
}
|
||||
|
||||
case .ss:
|
||||
@@ -757,9 +836,12 @@ class NoiseHandshakeState {
|
||||
throw NoiseError.missingKeys
|
||||
}
|
||||
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteStatic)
|
||||
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||
|
||||
default:
|
||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||
symmetricState.mixKey(sharedData)
|
||||
// Clear sensitive shared secret
|
||||
keychain.secureClear(&sharedData)
|
||||
|
||||
case .e, .s:
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -768,16 +850,20 @@ class NoiseHandshakeState {
|
||||
return currentPattern >= messagePatterns.count
|
||||
}
|
||||
|
||||
func getTransportCiphers() throws -> (send: NoiseCipherState, receive: NoiseCipherState) {
|
||||
func getTransportCiphers(useExtractedNonce: Bool) throws -> (send: NoiseCipherState, receive: NoiseCipherState, handshakeHash: Data) {
|
||||
guard isHandshakeComplete() else {
|
||||
throw NoiseError.handshakeNotComplete
|
||||
}
|
||||
|
||||
let (c1, c2) = symmetricState.split()
|
||||
|
||||
|
||||
// BCH-01-010: Capture handshake hash BEFORE split() clears symmetric state
|
||||
let finalHandshakeHash = symmetricState.getHandshakeHash()
|
||||
|
||||
let (c1, c2) = symmetricState.split(useExtractedNonce: useExtractedNonce)
|
||||
|
||||
// Initiator uses c1 for sending, c2 for receiving
|
||||
// Responder uses c2 for sending, c1 for receiving
|
||||
return role == .initiator ? (c1, c2) : (c2, c1)
|
||||
let ciphers = role == .initiator ? (c1, c2) : (c2, c1)
|
||||
return (send: ciphers.0, receive: ciphers.1, handshakeHash: finalHandshakeHash)
|
||||
}
|
||||
|
||||
func getRemoteStaticPublicKey() -> Curve25519.KeyAgreement.PublicKey? {
|
||||
@@ -787,6 +873,20 @@ class NoiseHandshakeState {
|
||||
func getHandshakeHash() -> Data {
|
||||
return symmetricState.getHandshakeHash()
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func performDHOperationForTesting(_ pattern: NoiseMessagePattern) throws {
|
||||
try performDHOperation(pattern)
|
||||
}
|
||||
|
||||
func setCurrentPatternForTesting(_ currentPattern: Int) {
|
||||
self.currentPattern = currentPattern
|
||||
}
|
||||
|
||||
func setRemoteEphemeralPublicKeyForTesting(_ key: Curve25519.KeyAgreement.PublicKey?) {
|
||||
self.remoteEphemeralPublic = key
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Pattern Extensions
|
||||
@@ -838,22 +938,47 @@ enum NoiseError: Error {
|
||||
case nonceExceeded
|
||||
}
|
||||
|
||||
// MARK: - Constant-Time Operations
|
||||
|
||||
/// BCH-01-010: Constant-time comparison to prevent timing side-channel attacks
|
||||
/// This function compares two Data objects in constant time, preventing
|
||||
/// information leakage via timing analysis.
|
||||
private func constantTimeCompare(_ a: Data, _ b: Data) -> Bool {
|
||||
guard a.count == b.count else { return false }
|
||||
|
||||
var result: UInt8 = 0
|
||||
for i in 0..<a.count {
|
||||
result |= a[a.startIndex.advanced(by: i)] ^ b[b.startIndex.advanced(by: i)]
|
||||
}
|
||||
return result == 0
|
||||
}
|
||||
|
||||
/// BCH-01-010: Constant-time check if all bytes are zero
|
||||
private func constantTimeIsZero(_ data: Data) -> Bool {
|
||||
var result: UInt8 = 0
|
||||
for byte in data {
|
||||
result |= byte
|
||||
}
|
||||
return result == 0
|
||||
}
|
||||
|
||||
// MARK: - Key Validation
|
||||
|
||||
extension NoiseHandshakeState {
|
||||
/// Validate a Curve25519 public key
|
||||
/// Checks for weak/invalid keys that could compromise security
|
||||
/// BCH-01-010: Uses constant-time operations to prevent timing side-channels
|
||||
static func validatePublicKey(_ keyData: Data) throws -> Curve25519.KeyAgreement.PublicKey {
|
||||
// Check key length
|
||||
guard keyData.count == 32 else {
|
||||
throw NoiseError.invalidPublicKey
|
||||
}
|
||||
|
||||
// Check for all-zero key (point at infinity)
|
||||
if keyData.allSatisfy({ $0 == 0 }) {
|
||||
|
||||
// BCH-01-010: Constant-time check for all-zero key (point at infinity)
|
||||
if constantTimeIsZero(keyData) {
|
||||
throw NoiseError.invalidPublicKey
|
||||
}
|
||||
|
||||
|
||||
// Check for low-order points that could enable small subgroup attacks
|
||||
// These are the known bad points for Curve25519
|
||||
let lowOrderPoints: [Data] = [
|
||||
@@ -874,20 +999,28 @@ extension NoiseHandshakeState {
|
||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]) // Another bad point
|
||||
]
|
||||
|
||||
// Check against known bad points
|
||||
if lowOrderPoints.contains(keyData) {
|
||||
SecureLogger.log("Low-order point detected", category: SecureLogger.security, level: .warning)
|
||||
|
||||
// BCH-01-010: Constant-time check against known bad points
|
||||
// We check all points and accumulate matches to avoid early exit timing leaks
|
||||
var foundBadPoint = false
|
||||
for badPoint in lowOrderPoints {
|
||||
if constantTimeCompare(keyData, badPoint) {
|
||||
foundBadPoint = true
|
||||
}
|
||||
}
|
||||
|
||||
if foundBadPoint {
|
||||
SecureLogger.warning("Low-order point detected", category: .security)
|
||||
throw NoiseError.invalidPublicKey
|
||||
}
|
||||
|
||||
|
||||
// Try to create the key - CryptoKit will validate curve points internally
|
||||
do {
|
||||
let publicKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: keyData)
|
||||
return publicKey
|
||||
} catch {
|
||||
// If CryptoKit rejects it, it's invalid
|
||||
SecureLogger.log("CryptoKit validation failed", category: SecureLogger.security, level: .warning)
|
||||
SecureLogger.warning("CryptoKit validation failed", category: .security)
|
||||
throw NoiseError.invalidPublicKey
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
//
|
||||
// NoiseRateLimiter.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
final class NoiseRateLimiter {
|
||||
private var handshakeTimestamps: [PeerID: [Date]] = [:]
|
||||
private var messageTimestamps: [PeerID: [Date]] = [:]
|
||||
|
||||
// Global rate limiting
|
||||
private var globalHandshakeTimestamps: [Date] = []
|
||||
private var globalMessageTimestamps: [Date] = []
|
||||
|
||||
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
|
||||
|
||||
func allowHandshake(from peerID: PeerID) -> Bool {
|
||||
return queue.sync(flags: .barrier) {
|
||||
let now = Date()
|
||||
let oneMinuteAgo = now.addingTimeInterval(-60)
|
||||
|
||||
// Check global rate limit first
|
||||
globalHandshakeTimestamps = globalHandshakeTimestamps.filter { $0 > oneMinuteAgo }
|
||||
if globalHandshakeTimestamps.count >= NoiseSecurityConstants.maxGlobalHandshakesPerMinute {
|
||||
SecureLogger.warning("Global handshake rate limit exceeded: \(globalHandshakeTimestamps.count)/\(NoiseSecurityConstants.maxGlobalHandshakesPerMinute) per minute", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check per-peer rate limit
|
||||
var timestamps = handshakeTimestamps[peerID] ?? []
|
||||
timestamps = timestamps.filter { $0 > oneMinuteAgo }
|
||||
|
||||
if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute {
|
||||
SecureLogger.warning("Per-peer handshake rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
// Record new handshake
|
||||
timestamps.append(now)
|
||||
handshakeTimestamps[peerID] = timestamps
|
||||
globalHandshakeTimestamps.append(now)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func allowMessage(from peerID: PeerID) -> Bool {
|
||||
return queue.sync(flags: .barrier) {
|
||||
let now = Date()
|
||||
let oneSecondAgo = now.addingTimeInterval(-1)
|
||||
|
||||
// Check global rate limit first
|
||||
globalMessageTimestamps = globalMessageTimestamps.filter { $0 > oneSecondAgo }
|
||||
if globalMessageTimestamps.count >= NoiseSecurityConstants.maxGlobalMessagesPerSecond {
|
||||
SecureLogger.warning("Global message rate limit exceeded: \(globalMessageTimestamps.count)/\(NoiseSecurityConstants.maxGlobalMessagesPerSecond) per second", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check per-peer rate limit
|
||||
var timestamps = messageTimestamps[peerID] ?? []
|
||||
timestamps = timestamps.filter { $0 > oneSecondAgo }
|
||||
|
||||
if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond {
|
||||
SecureLogger.warning("Per-peer message rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: .security)
|
||||
return false
|
||||
}
|
||||
|
||||
// Record new message
|
||||
timestamps.append(now)
|
||||
messageTimestamps[peerID] = timestamps
|
||||
globalMessageTimestamps.append(now)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func reset(for peerID: PeerID) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.handshakeTimestamps.removeValue(forKey: peerID)
|
||||
self.messageTimestamps.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func resetAll() {
|
||||
queue.async(flags: .barrier) {
|
||||
self.handshakeTimestamps.removeAll()
|
||||
self.messageTimestamps.removeAll()
|
||||
self.globalHandshakeTimestamps.removeAll()
|
||||
self.globalMessageTimestamps.removeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
//
|
||||
// NoiseSecurityConsiderations.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
// MARK: - Security Constants
|
||||
|
||||
enum NoiseSecurityConstants {
|
||||
// Maximum message size to prevent memory exhaustion
|
||||
static let maxMessageSize = 65535 // 64KB as per Noise spec
|
||||
|
||||
// Maximum handshake message size
|
||||
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
|
||||
|
||||
// Session timeout - sessions older than this should be renegotiated
|
||||
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
||||
|
||||
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
|
||||
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
|
||||
|
||||
// Handshake timeout - abandon incomplete handshakes
|
||||
static let handshakeTimeout: TimeInterval = 60 // 1 minute
|
||||
|
||||
// Maximum concurrent sessions per peer
|
||||
static let maxSessionsPerPeer = 3
|
||||
|
||||
// Rate limiting
|
||||
static let maxHandshakesPerMinute = 10
|
||||
static let maxMessagesPerSecond = 100
|
||||
|
||||
// Global rate limiting (across all peers)
|
||||
static let maxGlobalHandshakesPerMinute = 30
|
||||
static let maxGlobalMessagesPerSecond = 500
|
||||
}
|
||||
|
||||
// MARK: - Security Validations
|
||||
|
||||
struct NoiseSecurityValidator {
|
||||
|
||||
/// Validate message size
|
||||
static func validateMessageSize(_ data: Data) -> Bool {
|
||||
return data.count <= NoiseSecurityConstants.maxMessageSize
|
||||
}
|
||||
|
||||
/// Validate handshake message size
|
||||
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
||||
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
||||
}
|
||||
|
||||
/// Validate peer ID format using unified validator
|
||||
static func validatePeerID(_ peerID: String) -> Bool {
|
||||
return InputValidator.validatePeerID(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Enhanced Noise Session with Security
|
||||
|
||||
class SecureNoiseSession: NoiseSession {
|
||||
private(set) var messageCount: UInt64 = 0
|
||||
private let sessionStartTime = Date()
|
||||
private(set) var lastActivityTime = Date()
|
||||
|
||||
override func encrypt(_ plaintext: Data) throws -> Data {
|
||||
// Check session age
|
||||
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
|
||||
throw NoiseSecurityError.sessionExpired
|
||||
}
|
||||
|
||||
// Check message count
|
||||
if messageCount >= NoiseSecurityConstants.maxMessagesPerSession {
|
||||
throw NoiseSecurityError.sessionExhausted
|
||||
}
|
||||
|
||||
// Validate message size
|
||||
guard NoiseSecurityValidator.validateMessageSize(plaintext) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
|
||||
let encrypted = try super.encrypt(plaintext)
|
||||
messageCount += 1
|
||||
lastActivityTime = Date()
|
||||
|
||||
return encrypted
|
||||
}
|
||||
|
||||
override func decrypt(_ ciphertext: Data) throws -> Data {
|
||||
// Check session age
|
||||
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
|
||||
throw NoiseSecurityError.sessionExpired
|
||||
}
|
||||
|
||||
// Validate message size
|
||||
guard NoiseSecurityValidator.validateMessageSize(ciphertext) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
|
||||
let decrypted = try super.decrypt(ciphertext)
|
||||
lastActivityTime = Date()
|
||||
|
||||
return decrypted
|
||||
}
|
||||
|
||||
func needsRenegotiation() -> Bool {
|
||||
// Check if we've used more than 90% of message limit
|
||||
let messageThreshold = UInt64(Double(NoiseSecurityConstants.maxMessagesPerSession) * 0.9)
|
||||
if messageCount >= messageThreshold {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if last activity was more than 30 minutes ago
|
||||
if Date().timeIntervalSince(lastActivityTime) > NoiseSecurityConstants.sessionTimeout {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Testing Support
|
||||
#if DEBUG
|
||||
func setLastActivityTimeForTesting(_ date: Date) {
|
||||
lastActivityTime = date
|
||||
}
|
||||
|
||||
func setMessageCountForTesting(_ count: UInt64) {
|
||||
messageCount = count
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Rate Limiter
|
||||
|
||||
class NoiseRateLimiter {
|
||||
private var handshakeTimestamps: [String: [Date]] = [:] // peerID -> timestamps
|
||||
private var messageTimestamps: [String: [Date]] = [:] // peerID -> timestamps
|
||||
|
||||
// Global rate limiting
|
||||
private var globalHandshakeTimestamps: [Date] = []
|
||||
private var globalMessageTimestamps: [Date] = []
|
||||
|
||||
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
|
||||
|
||||
func allowHandshake(from peerID: String) -> Bool {
|
||||
return queue.sync(flags: .barrier) {
|
||||
let now = Date()
|
||||
let oneMinuteAgo = now.addingTimeInterval(-60)
|
||||
|
||||
// Check global rate limit first
|
||||
globalHandshakeTimestamps = globalHandshakeTimestamps.filter { $0 > oneMinuteAgo }
|
||||
if globalHandshakeTimestamps.count >= NoiseSecurityConstants.maxGlobalHandshakesPerMinute {
|
||||
SecureLogger.log("Global handshake rate limit exceeded: \(globalHandshakeTimestamps.count)/\(NoiseSecurityConstants.maxGlobalHandshakesPerMinute) per minute", category: SecureLogger.security, level: .warning)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check per-peer rate limit
|
||||
var timestamps = handshakeTimestamps[peerID] ?? []
|
||||
timestamps = timestamps.filter { $0 > oneMinuteAgo }
|
||||
|
||||
if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute {
|
||||
SecureLogger.log("Per-peer handshake rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: SecureLogger.security, level: .warning)
|
||||
return false
|
||||
}
|
||||
|
||||
// Record new handshake
|
||||
timestamps.append(now)
|
||||
handshakeTimestamps[peerID] = timestamps
|
||||
globalHandshakeTimestamps.append(now)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func allowMessage(from peerID: String) -> Bool {
|
||||
return queue.sync(flags: .barrier) {
|
||||
let now = Date()
|
||||
let oneSecondAgo = now.addingTimeInterval(-1)
|
||||
|
||||
// Check global rate limit first
|
||||
globalMessageTimestamps = globalMessageTimestamps.filter { $0 > oneSecondAgo }
|
||||
if globalMessageTimestamps.count >= NoiseSecurityConstants.maxGlobalMessagesPerSecond {
|
||||
SecureLogger.log("Global message rate limit exceeded: \(globalMessageTimestamps.count)/\(NoiseSecurityConstants.maxGlobalMessagesPerSecond) per second", category: SecureLogger.security, level: .warning)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check per-peer rate limit
|
||||
var timestamps = messageTimestamps[peerID] ?? []
|
||||
timestamps = timestamps.filter { $0 > oneSecondAgo }
|
||||
|
||||
if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond {
|
||||
SecureLogger.log("Per-peer message rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: SecureLogger.security, level: .warning)
|
||||
return false
|
||||
}
|
||||
|
||||
// Record new message
|
||||
timestamps.append(now)
|
||||
messageTimestamps[peerID] = timestamps
|
||||
globalMessageTimestamps.append(now)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func reset(for peerID: String) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.handshakeTimestamps.removeValue(forKey: peerID)
|
||||
self.messageTimestamps.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Security Errors
|
||||
|
||||
enum NoiseSecurityError: Error {
|
||||
case sessionExpired
|
||||
case sessionExhausted
|
||||
case messageTooLarge
|
||||
case invalidPeerID
|
||||
case rateLimitExceeded
|
||||
case handshakeTimeout
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//
|
||||
// NoiseSecurityConstants.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum NoiseSecurityConstants {
|
||||
// Maximum message size to prevent memory exhaustion
|
||||
static let maxMessageSize = 65535 // 64KB as per Noise spec
|
||||
|
||||
// Maximum handshake message size
|
||||
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
|
||||
|
||||
// Session timeout - sessions older than this should be renegotiated
|
||||
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
||||
|
||||
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
|
||||
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
|
||||
|
||||
// Handshake timeout - abandon incomplete handshakes
|
||||
static let handshakeTimeout: TimeInterval = 60 // 1 minute
|
||||
|
||||
// Maximum concurrent sessions per peer
|
||||
static let maxSessionsPerPeer = 3
|
||||
|
||||
// Rate limiting
|
||||
static let maxHandshakesPerMinute = 10
|
||||
static let maxMessagesPerSecond = 100
|
||||
|
||||
// Global rate limiting (across all peers)
|
||||
static let maxGlobalHandshakesPerMinute = 30
|
||||
static let maxGlobalMessagesPerSecond = 500
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// NoiseSecurityError.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum NoiseSecurityError: Error {
|
||||
case sessionExpired
|
||||
case sessionExhausted
|
||||
case messageTooLarge
|
||||
case invalidPeerID
|
||||
case rateLimitExceeded
|
||||
case handshakeTimeout
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// NoiseSecurityValidator.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct NoiseSecurityValidator {
|
||||
|
||||
/// Validate message size
|
||||
static func validateMessageSize(_ data: Data) -> Bool {
|
||||
return data.count <= NoiseSecurityConstants.maxMessageSize
|
||||
}
|
||||
|
||||
/// Validate handshake message size
|
||||
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
||||
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
||||
}
|
||||
}
|
||||
@@ -6,37 +6,15 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import os.log
|
||||
|
||||
// MARK: - Noise Session State
|
||||
|
||||
enum NoiseSessionState: Equatable {
|
||||
case uninitialized
|
||||
case handshaking
|
||||
case established
|
||||
case failed(Error)
|
||||
|
||||
static func == (lhs: NoiseSessionState, rhs: NoiseSessionState) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.uninitialized, .uninitialized),
|
||||
(.handshaking, .handshaking),
|
||||
(.established, .established):
|
||||
return true
|
||||
case (.failed, .failed):
|
||||
return true // We don't compare the errors
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Noise Session
|
||||
import BitFoundation
|
||||
|
||||
class NoiseSession {
|
||||
let peerID: String
|
||||
let peerID: PeerID
|
||||
let role: NoiseRole
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private var state: NoiseSessionState = .uninitialized
|
||||
private var handshakeState: NoiseHandshakeState?
|
||||
private var sendCipher: NoiseCipherState?
|
||||
@@ -53,9 +31,16 @@ class NoiseSession {
|
||||
// Thread safety
|
||||
private let sessionQueue = DispatchQueue(label: "chat.bitchat.noise.session", attributes: .concurrent)
|
||||
|
||||
init(peerID: String, role: NoiseRole, localStaticKey: Curve25519.KeyAgreement.PrivateKey, remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil) {
|
||||
init(
|
||||
peerID: PeerID,
|
||||
role: NoiseRole,
|
||||
keychain: KeychainManagerProtocol,
|
||||
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
|
||||
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil
|
||||
) {
|
||||
self.peerID = peerID
|
||||
self.role = role
|
||||
self.keychain = keychain
|
||||
self.localStaticKey = localStaticKey
|
||||
self.remoteStaticPublicKey = remoteStaticKey
|
||||
}
|
||||
@@ -72,6 +57,7 @@ class NoiseSession {
|
||||
handshakeState = NoiseHandshakeState(
|
||||
role: role,
|
||||
pattern: .XX,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey,
|
||||
remoteStaticKey: nil
|
||||
)
|
||||
@@ -92,18 +78,19 @@ class NoiseSession {
|
||||
|
||||
func processHandshakeMessage(_ message: Data) throws -> Data? {
|
||||
return try sessionQueue.sync(flags: .barrier) {
|
||||
SecureLogger.log("NoiseSession[\(peerID)]: Processing handshake message, current state: \(state), role: \(role)", category: SecureLogger.noise, level: .debug)
|
||||
SecureLogger.debug("NoiseSession[\(peerID)]: Processing handshake message, current state: \(state), role: \(role)")
|
||||
|
||||
// Initialize handshake state if needed (for responders)
|
||||
if state == .uninitialized && role == .responder {
|
||||
handshakeState = NoiseHandshakeState(
|
||||
role: role,
|
||||
pattern: .XX,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey,
|
||||
remoteStaticKey: nil
|
||||
)
|
||||
state = .handshaking
|
||||
SecureLogger.log("NoiseSession[\(peerID)]: Initialized handshake state for responder", category: SecureLogger.noise, level: .debug)
|
||||
SecureLogger.debug("NoiseSession[\(peerID)]: Initialized handshake state for responder")
|
||||
}
|
||||
|
||||
guard case .handshaking = state, let handshake = handshakeState else {
|
||||
@@ -112,52 +99,52 @@ class NoiseSession {
|
||||
|
||||
// Process incoming message
|
||||
_ = try handshake.readMessage(message)
|
||||
SecureLogger.log("NoiseSession[\(peerID)]: Read handshake message, checking if complete", category: SecureLogger.noise, level: .debug)
|
||||
SecureLogger.debug("NoiseSession[\(peerID)]: Read handshake message, checking if complete")
|
||||
|
||||
// Check if handshake is complete
|
||||
if handshake.isHandshakeComplete() {
|
||||
// Get transport ciphers
|
||||
let (send, receive) = try handshake.getTransportCiphers()
|
||||
// Get transport ciphers and handshake hash (hash captured before split clears state)
|
||||
let (send, receive, hash) = try handshake.getTransportCiphers(useExtractedNonce: true)
|
||||
sendCipher = send
|
||||
receiveCipher = receive
|
||||
|
||||
|
||||
// Store remote static key
|
||||
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
|
||||
|
||||
|
||||
// Store handshake hash for channel binding
|
||||
handshakeHash = handshake.getHandshakeHash()
|
||||
|
||||
handshakeHash = hash
|
||||
|
||||
state = .established
|
||||
handshakeState = nil // Clear handshake state
|
||||
|
||||
SecureLogger.log("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established", category: SecureLogger.noise, level: .debug)
|
||||
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID))
|
||||
|
||||
|
||||
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established")
|
||||
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||
|
||||
return nil
|
||||
} else {
|
||||
// Generate response
|
||||
let response = try handshake.writeMessage()
|
||||
sentHandshakeMessages.append(response)
|
||||
SecureLogger.log("NoiseSession[\(peerID)]: Generated handshake response of size \(response.count)", category: SecureLogger.noise, level: .debug)
|
||||
SecureLogger.debug("NoiseSession[\(peerID)]: Generated handshake response of size \(response.count)")
|
||||
|
||||
// Check if handshake is complete after writing
|
||||
if handshake.isHandshakeComplete() {
|
||||
// Get transport ciphers
|
||||
let (send, receive) = try handshake.getTransportCiphers()
|
||||
// Get transport ciphers and handshake hash (hash captured before split clears state)
|
||||
let (send, receive, hash) = try handshake.getTransportCiphers(useExtractedNonce: true)
|
||||
sendCipher = send
|
||||
receiveCipher = receive
|
||||
|
||||
|
||||
// Store remote static key
|
||||
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
|
||||
|
||||
|
||||
// Store handshake hash for channel binding
|
||||
handshakeHash = handshake.getHandshakeHash()
|
||||
|
||||
handshakeHash = hash
|
||||
|
||||
state = .established
|
||||
handshakeState = nil // Clear handshake state
|
||||
|
||||
SecureLogger.log("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established", category: SecureLogger.noise, level: .debug)
|
||||
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID))
|
||||
|
||||
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established")
|
||||
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||
}
|
||||
|
||||
return response
|
||||
@@ -210,12 +197,6 @@ class NoiseSession {
|
||||
}
|
||||
}
|
||||
|
||||
func getHandshakeHash() -> Data? {
|
||||
return sessionQueue.sync {
|
||||
return handshakeHash
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
sessionQueue.sync(flags: .barrier) {
|
||||
let wasEstablished = state == .established
|
||||
@@ -231,247 +212,19 @@ class NoiseSession {
|
||||
// Clear sent handshake messages
|
||||
for i in 0..<sentHandshakeMessages.count {
|
||||
var message = sentHandshakeMessages[i]
|
||||
KeychainManager.secureClear(&message)
|
||||
keychain.secureClear(&message)
|
||||
}
|
||||
sentHandshakeMessages.removeAll()
|
||||
|
||||
// Clear handshake hash
|
||||
if var hash = handshakeHash {
|
||||
KeychainManager.secureClear(&hash)
|
||||
keychain.secureClear(&hash)
|
||||
}
|
||||
handshakeHash = nil
|
||||
|
||||
if wasEstablished {
|
||||
SecureLogger.logSecurityEvent(.sessionExpired(peerID: peerID))
|
||||
SecureLogger.info(.sessionExpired(peerID: peerID.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session Manager
|
||||
|
||||
class NoiseSessionManager {
|
||||
private var sessions: [String: NoiseSession] = [:]
|
||||
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
|
||||
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
||||
|
||||
// Callbacks
|
||||
var onSessionEstablished: ((String, Curve25519.KeyAgreement.PublicKey) -> Void)?
|
||||
var onSessionFailed: ((String, Error) -> Void)?
|
||||
|
||||
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey) {
|
||||
self.localStaticKey = localStaticKey
|
||||
}
|
||||
|
||||
// MARK: - Session Management
|
||||
|
||||
func createSession(for peerID: String, role: NoiseRole) -> NoiseSession {
|
||||
return managerQueue.sync(flags: .barrier) {
|
||||
let session = SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: role,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
sessions[peerID] = session
|
||||
return session
|
||||
}
|
||||
}
|
||||
|
||||
func getSession(for peerID: String) -> NoiseSession? {
|
||||
return managerQueue.sync {
|
||||
return sessions[peerID]
|
||||
}
|
||||
}
|
||||
|
||||
func removeSession(for peerID: String) {
|
||||
managerQueue.sync(flags: .barrier) {
|
||||
if let session = sessions[peerID] {
|
||||
if session.isEstablished() {
|
||||
SecureLogger.logSecurityEvent(.sessionExpired(peerID: peerID))
|
||||
}
|
||||
// Clear sensitive data before removing
|
||||
session.reset()
|
||||
}
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func getEstablishedSessions() -> [String: NoiseSession] {
|
||||
return managerQueue.sync {
|
||||
return sessions.filter { $0.value.isEstablished() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Handshake Helpers
|
||||
|
||||
func initiateHandshake(with peerID: String) throws -> Data {
|
||||
return try managerQueue.sync(flags: .barrier) {
|
||||
// Check if we already have an established session
|
||||
if let existingSession = sessions[peerID], existingSession.isEstablished() {
|
||||
// Session already established, don't recreate
|
||||
throw NoiseSessionError.alreadyEstablished
|
||||
}
|
||||
|
||||
// Remove any existing non-established session
|
||||
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
}
|
||||
|
||||
// Create new initiator session
|
||||
let session = SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: .initiator,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
sessions[peerID] = session
|
||||
|
||||
do {
|
||||
let handshakeData = try session.startHandshake()
|
||||
return handshakeData
|
||||
} catch {
|
||||
// Clean up failed session
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
SecureLogger.logSecurityEvent(.handshakeFailed(peerID: peerID, error: error.localizedDescription), level: .error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleIncomingHandshake(from peerID: String, message: Data) throws -> Data? {
|
||||
// Process everything within the synchronized block to prevent race conditions
|
||||
return try managerQueue.sync(flags: .barrier) {
|
||||
var shouldCreateNew = false
|
||||
var existingSession: NoiseSession? = nil
|
||||
|
||||
if let existing = sessions[peerID] {
|
||||
// If we have an established session, the peer must have cleared their session
|
||||
// for a good reason (e.g., decryption failure, restart, etc.)
|
||||
// We should accept the new handshake to re-establish encryption
|
||||
if existing.isEstablished() {
|
||||
SecureLogger.log("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session",
|
||||
category: SecureLogger.session, level: .info)
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
shouldCreateNew = true
|
||||
} else {
|
||||
// If we're in the middle of a handshake and receive a new initiation,
|
||||
// reset and start fresh (the other side may have restarted)
|
||||
if existing.getState() == .handshaking && message.count == 32 {
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
shouldCreateNew = true
|
||||
} else {
|
||||
existingSession = existing
|
||||
}
|
||||
}
|
||||
} else {
|
||||
shouldCreateNew = true
|
||||
}
|
||||
|
||||
// Get or create session
|
||||
let session: NoiseSession
|
||||
if shouldCreateNew {
|
||||
let newSession = SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: .responder,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
sessions[peerID] = newSession
|
||||
session = newSession
|
||||
} else {
|
||||
session = existingSession!
|
||||
}
|
||||
|
||||
// Process the handshake message within the synchronized block
|
||||
do {
|
||||
let response = try session.processHandshakeMessage(message)
|
||||
|
||||
// Check if session is established after processing
|
||||
if session.isEstablished() {
|
||||
if let remoteKey = session.getRemoteStaticPublicKey() {
|
||||
// Schedule callback outside the synchronized block to prevent deadlock
|
||||
DispatchQueue.global().async { [weak self] in
|
||||
self?.onSessionEstablished?(peerID, remoteKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
} catch {
|
||||
// Reset the session on handshake failure so next attempt can start fresh
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
|
||||
// Schedule callback outside the synchronized block to prevent deadlock
|
||||
DispatchQueue.global().async { [weak self] in
|
||||
self?.onSessionFailed?(peerID, error)
|
||||
}
|
||||
|
||||
SecureLogger.logSecurityEvent(.handshakeFailed(peerID: peerID, error: error.localizedDescription), level: .error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Encryption/Decryption
|
||||
|
||||
func encrypt(_ plaintext: Data, for peerID: String) throws -> Data {
|
||||
guard let session = getSession(for: peerID) else {
|
||||
throw NoiseSessionError.sessionNotFound
|
||||
}
|
||||
|
||||
return try session.encrypt(plaintext)
|
||||
}
|
||||
|
||||
func decrypt(_ ciphertext: Data, from peerID: String) throws -> Data {
|
||||
guard let session = getSession(for: peerID) else {
|
||||
throw NoiseSessionError.sessionNotFound
|
||||
}
|
||||
|
||||
return try session.decrypt(ciphertext)
|
||||
}
|
||||
|
||||
// MARK: - Key Management
|
||||
|
||||
func getRemoteStaticKey(for peerID: String) -> Curve25519.KeyAgreement.PublicKey? {
|
||||
return getSession(for: peerID)?.getRemoteStaticPublicKey()
|
||||
}
|
||||
|
||||
func getHandshakeHash(for peerID: String) -> Data? {
|
||||
return getSession(for: peerID)?.getHandshakeHash()
|
||||
}
|
||||
|
||||
// MARK: - Session Rekeying
|
||||
|
||||
func getSessionsNeedingRekey() -> [(peerID: String, needsRekey: Bool)] {
|
||||
return managerQueue.sync {
|
||||
var needingRekey: [(peerID: String, needsRekey: Bool)] = []
|
||||
|
||||
for (peerID, session) in sessions {
|
||||
if let secureSession = session as? SecureNoiseSession,
|
||||
secureSession.isEstablished(),
|
||||
secureSession.needsRenegotiation() {
|
||||
needingRekey.append((peerID: peerID, needsRekey: true))
|
||||
}
|
||||
}
|
||||
|
||||
return needingRekey
|
||||
}
|
||||
}
|
||||
|
||||
func initiateRekey(for peerID: String) throws {
|
||||
// Remove old session
|
||||
removeSession(for: peerID)
|
||||
|
||||
// Initiate new handshake
|
||||
_ = try initiateHandshake(with: peerID)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum NoiseSessionError: Error {
|
||||
case invalidState
|
||||
case notEstablished
|
||||
case sessionNotFound
|
||||
case handshakeFailed(Error)
|
||||
case alreadyEstablished
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// NoiseSessionError.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
enum NoiseSessionError: Error, Equatable {
|
||||
case invalidState
|
||||
case notEstablished
|
||||
case sessionNotFound
|
||||
case alreadyEstablished
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//
|
||||
// NoiseSessionManager.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
|
||||
final class NoiseSessionManager {
|
||||
private var sessions: [PeerID: NoiseSession] = [:]
|
||||
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
|
||||
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
||||
|
||||
// Callbacks
|
||||
var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)?
|
||||
var onSessionFailed: ((PeerID, Error) -> Void)?
|
||||
|
||||
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
||||
self.localStaticKey = localStaticKey
|
||||
self.keychain = keychain
|
||||
self.sessionFactory = { peerID, role in
|
||||
SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: role,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
init(
|
||||
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
|
||||
keychain: KeychainManagerProtocol,
|
||||
sessionFactory: @escaping (PeerID, NoiseRole) -> NoiseSession
|
||||
) {
|
||||
self.localStaticKey = localStaticKey
|
||||
self.keychain = keychain
|
||||
self.sessionFactory = sessionFactory
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Session Management
|
||||
|
||||
func getSession(for peerID: PeerID) -> NoiseSession? {
|
||||
return managerQueue.sync {
|
||||
return sessions[peerID]
|
||||
}
|
||||
}
|
||||
|
||||
func removeSession(for peerID: PeerID) {
|
||||
managerQueue.sync(flags: .barrier) {
|
||||
if let session = sessions.removeValue(forKey: peerID) {
|
||||
session.reset() // Clear sensitive data before removing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeAllSessions() {
|
||||
managerQueue.sync(flags: .barrier) {
|
||||
for (_, session) in sessions {
|
||||
session.reset()
|
||||
}
|
||||
sessions.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Handshake Helpers
|
||||
|
||||
func initiateHandshake(with peerID: PeerID) throws -> Data {
|
||||
return try managerQueue.sync(flags: .barrier) {
|
||||
// Check if we already have an established session
|
||||
if let existingSession = sessions[peerID], existingSession.isEstablished() {
|
||||
// Session already established, don't recreate
|
||||
throw NoiseSessionError.alreadyEstablished
|
||||
}
|
||||
|
||||
// Remove any existing non-established session
|
||||
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
}
|
||||
|
||||
// Create new initiator session
|
||||
let session = sessionFactory(peerID, .initiator)
|
||||
sessions[peerID] = session
|
||||
|
||||
do {
|
||||
let handshakeData = try session.startHandshake()
|
||||
return handshakeData
|
||||
} catch {
|
||||
// Clean up failed session
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
|
||||
// Process everything within the synchronized block to prevent race conditions
|
||||
return try managerQueue.sync(flags: .barrier) {
|
||||
var shouldCreateNew = false
|
||||
var existingSession: NoiseSession? = nil
|
||||
|
||||
if let existing = sessions[peerID] {
|
||||
// If we have an established session, the peer must have cleared their session
|
||||
// for a good reason (e.g., decryption failure, restart, etc.)
|
||||
// We should accept the new handshake to re-establish encryption
|
||||
if existing.isEstablished() {
|
||||
SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session)
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
shouldCreateNew = true
|
||||
} else {
|
||||
// If we're in the middle of a handshake and receive a new initiation,
|
||||
// reset and start fresh (the other side may have restarted)
|
||||
if existing.getState() == .handshaking && message.count == 32 {
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
shouldCreateNew = true
|
||||
} else {
|
||||
existingSession = existing
|
||||
}
|
||||
}
|
||||
} else {
|
||||
shouldCreateNew = true
|
||||
}
|
||||
|
||||
// Get or create session
|
||||
let session: NoiseSession
|
||||
if shouldCreateNew {
|
||||
let newSession = sessionFactory(peerID, .responder)
|
||||
sessions[peerID] = newSession
|
||||
session = newSession
|
||||
} else {
|
||||
session = existingSession!
|
||||
}
|
||||
|
||||
// Process the handshake message within the synchronized block
|
||||
do {
|
||||
let response = try session.processHandshakeMessage(message)
|
||||
|
||||
// Check if session is established after processing
|
||||
if session.isEstablished() {
|
||||
if let remoteKey = session.getRemoteStaticPublicKey() {
|
||||
// Schedule callback outside the synchronized block to prevent deadlock
|
||||
DispatchQueue.global().async { [weak self] in
|
||||
self?.onSessionEstablished?(peerID, remoteKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
} catch {
|
||||
// Reset the session on handshake failure so next attempt can start fresh
|
||||
_ = sessions.removeValue(forKey: peerID)
|
||||
|
||||
// Schedule callback outside the synchronized block to prevent deadlock
|
||||
DispatchQueue.global().async { [weak self] in
|
||||
self?.onSessionFailed?(peerID, error)
|
||||
}
|
||||
|
||||
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Encryption/Decryption
|
||||
|
||||
func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
|
||||
guard let session = getSession(for: peerID) else {
|
||||
throw NoiseSessionError.sessionNotFound
|
||||
}
|
||||
|
||||
return try session.encrypt(plaintext)
|
||||
}
|
||||
|
||||
func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data {
|
||||
guard let session = getSession(for: peerID) else {
|
||||
throw NoiseSessionError.sessionNotFound
|
||||
}
|
||||
|
||||
return try session.decrypt(ciphertext)
|
||||
}
|
||||
|
||||
// MARK: - Key Management
|
||||
|
||||
func getRemoteStaticKey(for peerID: PeerID) -> Curve25519.KeyAgreement.PublicKey? {
|
||||
return getSession(for: peerID)?.getRemoteStaticPublicKey()
|
||||
}
|
||||
|
||||
// MARK: - Session Rekeying
|
||||
|
||||
func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)] {
|
||||
return managerQueue.sync {
|
||||
var needingRekey: [(peerID: PeerID, needsRekey: Bool)] = []
|
||||
|
||||
for (peerID, session) in sessions {
|
||||
if let secureSession = session as? SecureNoiseSession,
|
||||
secureSession.isEstablished(),
|
||||
secureSession.needsRenegotiation() {
|
||||
needingRekey.append((peerID: peerID, needsRekey: true))
|
||||
}
|
||||
}
|
||||
|
||||
return needingRekey
|
||||
}
|
||||
}
|
||||
|
||||
func initiateRekey(for peerID: PeerID) throws {
|
||||
// Remove old session
|
||||
removeSession(for: peerID)
|
||||
|
||||
// Initiate new handshake
|
||||
_ = try initiateHandshake(with: peerID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//
|
||||
// NoiseSessionState.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
enum NoiseSessionState: Equatable {
|
||||
case uninitialized
|
||||
case handshaking
|
||||
case established
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//
|
||||
// SecureNoiseSession.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
final class SecureNoiseSession: NoiseSession {
|
||||
private(set) var messageCount: UInt64 = 0
|
||||
private var sessionStartTime = Date()
|
||||
private(set) var lastActivityTime = Date()
|
||||
|
||||
override func encrypt(_ plaintext: Data) throws -> Data {
|
||||
// Check session age
|
||||
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
|
||||
throw NoiseSecurityError.sessionExpired
|
||||
}
|
||||
|
||||
// Check message count
|
||||
if messageCount >= NoiseSecurityConstants.maxMessagesPerSession {
|
||||
throw NoiseSecurityError.sessionExhausted
|
||||
}
|
||||
|
||||
// Validate message size
|
||||
guard NoiseSecurityValidator.validateMessageSize(plaintext) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
|
||||
let encrypted = try super.encrypt(plaintext)
|
||||
messageCount += 1
|
||||
lastActivityTime = Date()
|
||||
|
||||
return encrypted
|
||||
}
|
||||
|
||||
override func decrypt(_ ciphertext: Data) throws -> Data {
|
||||
// Check session age
|
||||
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
|
||||
throw NoiseSecurityError.sessionExpired
|
||||
}
|
||||
|
||||
// Validate message size
|
||||
guard NoiseSecurityValidator.validateMessageSize(ciphertext) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
|
||||
let decrypted = try super.decrypt(ciphertext)
|
||||
lastActivityTime = Date()
|
||||
|
||||
return decrypted
|
||||
}
|
||||
|
||||
func needsRenegotiation() -> Bool {
|
||||
// Check if we've used more than 90% of message limit
|
||||
let messageThreshold = UInt64(Double(NoiseSecurityConstants.maxMessagesPerSession) * 0.9)
|
||||
if messageCount >= messageThreshold {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if last activity was more than 30 minutes ago
|
||||
if Date().timeIntervalSince(lastActivityTime) > NoiseSecurityConstants.sessionTimeout {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Testing Support
|
||||
#if DEBUG
|
||||
func setLastActivityTimeForTesting(_ date: Date) {
|
||||
lastActivityTime = date
|
||||
}
|
||||
|
||||
func setMessageCountForTesting(_ count: UInt64) {
|
||||
messageCount = count
|
||||
}
|
||||
|
||||
func setSessionStartTimeForTesting(_ date: Date) {
|
||||
sessionStartTime = date
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import Foundation
|
||||
|
||||
enum Base64URLCoding {
|
||||
static func encode(_ data: Data) -> String {
|
||||
data.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
|
||||
static func decode(_ string: String) -> Data? {
|
||||
var base64 = string
|
||||
let padding = (4 - (base64.count % 4)) % 4
|
||||
if padding > 0 {
|
||||
base64 += String(repeating: "=", count: padding)
|
||||
}
|
||||
base64 = base64
|
||||
.replacingOccurrences(of: "-", with: "+")
|
||||
.replacingOccurrences(of: "_", with: "/")
|
||||
return Data(base64Encoded: base64)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import Foundation
|
||||
|
||||
/// Bech32 encoding for Nostr (minimal implementation)
|
||||
enum Bech32 {
|
||||
private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
|
||||
|
||||
static func encode(hrp: String, data: Data) throws -> String {
|
||||
let values = convertBits(from: 8, to: 5, pad: true, data: Array(data))
|
||||
let checksum = createChecksum(hrp: hrp, values: values)
|
||||
let combined = values + checksum
|
||||
|
||||
return hrp + "1" + combined.map {
|
||||
let index = charset.index(charset.startIndex, offsetBy: Int($0))
|
||||
return String(charset[index])
|
||||
}.joined()
|
||||
}
|
||||
|
||||
static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {
|
||||
// Find the last occurrence of '1'
|
||||
guard let separatorIndex = bech32String.lastIndex(of: "1") else {
|
||||
throw Bech32Error.invalidFormat
|
||||
}
|
||||
|
||||
let hrp = String(bech32String[..<separatorIndex])
|
||||
|
||||
// Validate HRP contains only ASCII characters
|
||||
for char in hrp {
|
||||
guard char.asciiValue != nil else {
|
||||
throw Bech32Error.invalidCharacter
|
||||
}
|
||||
}
|
||||
|
||||
let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])
|
||||
|
||||
// Convert characters to values
|
||||
var values = [UInt8]()
|
||||
for char in dataString {
|
||||
guard let index = charset.firstIndex(of: char) else {
|
||||
throw Bech32Error.invalidCharacter
|
||||
}
|
||||
values.append(UInt8(charset.distance(from: charset.startIndex, to: index)))
|
||||
}
|
||||
|
||||
// Verify checksum
|
||||
guard values.count >= 6 else {
|
||||
throw Bech32Error.invalidChecksum
|
||||
}
|
||||
|
||||
let payloadValues = Array(values.dropLast(6))
|
||||
let checksum = Array(values.suffix(6))
|
||||
let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues)
|
||||
|
||||
guard checksum == expectedChecksum else {
|
||||
throw Bech32Error.invalidChecksum
|
||||
}
|
||||
|
||||
// Convert back to bytes
|
||||
let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues)
|
||||
return (hrp: hrp, data: Data(bytes))
|
||||
}
|
||||
|
||||
enum Bech32Error: Error {
|
||||
case invalidFormat
|
||||
case invalidCharacter
|
||||
case invalidChecksum
|
||||
}
|
||||
|
||||
private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] {
|
||||
var acc = 0
|
||||
var bits = 0
|
||||
var result = [UInt8]()
|
||||
let maxv = (1 << to) - 1
|
||||
|
||||
for value in data {
|
||||
acc = (acc << from) | Int(value)
|
||||
bits += from
|
||||
|
||||
while bits >= to {
|
||||
bits -= to
|
||||
result.append(UInt8((acc >> bits) & maxv))
|
||||
}
|
||||
}
|
||||
|
||||
if pad && bits > 0 {
|
||||
result.append(UInt8((acc << (to - bits)) & maxv))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] {
|
||||
let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0]
|
||||
let polymod = polymod(checksumValues) ^ 1
|
||||
var checksum = [UInt8]()
|
||||
|
||||
for i in 0..<6 {
|
||||
checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31))
|
||||
}
|
||||
|
||||
return checksum
|
||||
}
|
||||
|
||||
private static func hrpExpand(_ hrp: String) -> [UInt8] {
|
||||
var result = [UInt8]()
|
||||
for c in hrp {
|
||||
guard let asciiValue = c.asciiValue else {
|
||||
return [] // Return empty array for invalid input
|
||||
}
|
||||
result.append(UInt8(asciiValue >> 5))
|
||||
}
|
||||
result.append(0)
|
||||
for c in hrp {
|
||||
guard let asciiValue = c.asciiValue else {
|
||||
return [] // Return empty array for invalid input
|
||||
}
|
||||
result.append(UInt8(asciiValue & 31))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static func polymod(_ values: [UInt8]) -> Int {
|
||||
var chk = 1
|
||||
for value in values {
|
||||
let b = chk >> 25
|
||||
chk = (chk & 0x1ffffff) << 5 ^ Int(value)
|
||||
for i in 0..<5 {
|
||||
if (b >> i) & 1 == 1 {
|
||||
chk ^= generator[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return chk
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,166 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
import Tor
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
extension Notification.Name {
|
||||
/// Posted after the geo relay directory successfully refreshes its entries.
|
||||
static let geoRelayDirectoryDidRefresh = Notification.Name("bitchat.geoRelayDirectoryDidRefresh")
|
||||
}
|
||||
|
||||
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
||||
struct GeoRelayDirectoryDependencies {
|
||||
var userDefaults: UserDefaults
|
||||
var notificationCenter: NotificationCenter
|
||||
var now: () -> Date
|
||||
var remoteURL: URL
|
||||
var fetchInterval: TimeInterval
|
||||
var refreshCheckInterval: TimeInterval
|
||||
var retryInitialSeconds: TimeInterval
|
||||
var retryMaxSeconds: TimeInterval
|
||||
var awaitTorReady: @Sendable () async -> Bool
|
||||
var makeFetchData: @MainActor @Sendable () -> (@Sendable (URLRequest) async throws -> Data)
|
||||
var readData: (URL) -> Data?
|
||||
var writeData: (Data, URL) throws -> Void
|
||||
var cacheURL: () -> URL?
|
||||
var bundledCSVURLs: () -> [URL]
|
||||
var currentDirectoryPath: () -> String?
|
||||
var retrySleep: (TimeInterval) async -> Void
|
||||
var activeNotificationName: Notification.Name?
|
||||
var autoStart: Bool
|
||||
}
|
||||
|
||||
private extension GeoRelayDirectoryDependencies {
|
||||
@MainActor
|
||||
static func live() -> Self {
|
||||
#if os(iOS)
|
||||
let activeNotificationName: Notification.Name? = UIApplication.didBecomeActiveNotification
|
||||
#elseif os(macOS)
|
||||
let activeNotificationName: Notification.Name? = NSApplication.didBecomeActiveNotification
|
||||
#else
|
||||
let activeNotificationName: Notification.Name? = nil
|
||||
#endif
|
||||
|
||||
return Self(
|
||||
userDefaults: .standard,
|
||||
notificationCenter: .default,
|
||||
now: Date.init,
|
||||
remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!,
|
||||
fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds,
|
||||
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
|
||||
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
|
||||
retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds,
|
||||
awaitTorReady: { await TorManager.shared.awaitReady() },
|
||||
makeFetchData: {
|
||||
let session = TorURLSession.shared.session
|
||||
return { request in
|
||||
let (data, _) = try await session.data(for: request)
|
||||
return data
|
||||
}
|
||||
},
|
||||
readData: { try? Data(contentsOf: $0) },
|
||||
writeData: { data, url in
|
||||
try data.write(to: url, options: .atomic)
|
||||
},
|
||||
cacheURL: {
|
||||
do {
|
||||
let base = try FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
return dir.appendingPathComponent("georelays_cache.csv")
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
},
|
||||
bundledCSVURLs: {
|
||||
[
|
||||
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
|
||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
|
||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
|
||||
].compactMap { $0 }
|
||||
},
|
||||
currentDirectoryPath: { FileManager.default.currentDirectoryPath },
|
||||
retrySleep: { delay in
|
||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||
},
|
||||
activeNotificationName: activeNotificationName,
|
||||
autoStart: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class GeoRelayDirectory {
|
||||
struct Entry: Hashable {
|
||||
private final class CleanupState {
|
||||
let notificationCenter: NotificationCenter
|
||||
var observers: [NSObjectProtocol] = []
|
||||
var refreshTimer: Timer?
|
||||
var retryTask: Task<Void, Never>?
|
||||
|
||||
init(notificationCenter: NotificationCenter) {
|
||||
self.notificationCenter = notificationCenter
|
||||
}
|
||||
|
||||
deinit {
|
||||
observers.forEach { notificationCenter.removeObserver($0) }
|
||||
refreshTimer?.invalidate()
|
||||
retryTask?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
struct Entry: Hashable, Sendable {
|
||||
let host: String
|
||||
let lat: Double
|
||||
let lon: Double
|
||||
}
|
||||
|
||||
private enum DetachedFetchOutcome: Sendable {
|
||||
case success(entries: [Entry], csv: String)
|
||||
case torNotReady
|
||||
case invalidData
|
||||
case network(String)
|
||||
}
|
||||
|
||||
static let shared = GeoRelayDirectory()
|
||||
|
||||
private(set) var entries: [Entry] = []
|
||||
private let cacheFileName = "georelays_cache.csv"
|
||||
private let lastFetchKey = "georelay.lastFetchAt"
|
||||
private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
|
||||
private let fetchInterval: TimeInterval = 60 * 60 * 24 // 24h
|
||||
private let dependencies: GeoRelayDirectoryDependencies
|
||||
private let cleanupState: CleanupState
|
||||
|
||||
private var retryAttempt: Int = 0
|
||||
private var isFetching: Bool = false
|
||||
|
||||
private init() {
|
||||
// Load cached or bundled data synchronously
|
||||
self.entries = self.loadLocalEntries()
|
||||
// Fire-and-forget remote refresh if stale
|
||||
prefetchIfNeeded()
|
||||
self.dependencies = .live()
|
||||
self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter)
|
||||
entries = loadLocalEntries()
|
||||
if dependencies.autoStart {
|
||||
registerObservers()
|
||||
startRefreshTimer()
|
||||
prefetchIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
internal init(dependencies: GeoRelayDirectoryDependencies) {
|
||||
self.dependencies = dependencies
|
||||
self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter)
|
||||
entries = loadLocalEntries()
|
||||
if dependencies.autoStart {
|
||||
registerObservers()
|
||||
startRefreshTimer()
|
||||
prefetchIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
|
||||
@@ -30,115 +170,263 @@ final class GeoRelayDirectory {
|
||||
}
|
||||
|
||||
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
|
||||
/// Ties break by host so every device with the same directory picks the
|
||||
/// same relay set — publishers and subscribers must agree on relays.
|
||||
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
|
||||
guard !entries.isEmpty else { return [] }
|
||||
let sorted = entries
|
||||
.sorted { a, b in
|
||||
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
|
||||
}
|
||||
guard !entries.isEmpty, count > 0 else { return [] }
|
||||
|
||||
return entries
|
||||
.map { (entry: $0, distance: haversineKm(lat, lon, $0.lat, $0.lon)) }
|
||||
.sorted { ($0.distance, $0.entry.host) < ($1.distance, $1.entry.host) }
|
||||
.prefix(count)
|
||||
return sorted.map { "wss://\($0.host)" }
|
||||
.map { "wss://\($0.entry.host)" }
|
||||
}
|
||||
|
||||
// MARK: - Remote Fetch
|
||||
func prefetchIfNeeded() {
|
||||
let now = Date()
|
||||
let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
||||
guard now.timeIntervalSince(last) >= fetchInterval else { return }
|
||||
func prefetchIfNeeded(force: Bool = false) {
|
||||
guard !isFetching else { return }
|
||||
|
||||
let now = dependencies.now()
|
||||
let last = dependencies.userDefaults.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
||||
|
||||
if !force {
|
||||
guard now.timeIntervalSince(last) >= dependencies.fetchInterval else { return }
|
||||
} else if last != .distantPast,
|
||||
now.timeIntervalSince(last) < dependencies.retryInitialSeconds {
|
||||
// Skip forced fetches if we just refreshed moments ago.
|
||||
return
|
||||
}
|
||||
|
||||
cancelRetry()
|
||||
fetchRemote()
|
||||
}
|
||||
|
||||
private func fetchRemote() {
|
||||
let req = URLRequest(url: remoteURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
|
||||
let task = URLSession.shared.dataTask(with: req) { [weak self] data, _, error in
|
||||
guard let self = self else { return }
|
||||
if let data = data, error == nil, let text = String(data: data, encoding: .utf8) {
|
||||
let parsed = GeoRelayDirectory.parseCSV(text)
|
||||
if !parsed.isEmpty {
|
||||
Task { @MainActor in
|
||||
self.entries = parsed
|
||||
self.persistCache(text)
|
||||
UserDefaults.standard.set(Date(), forKey: self.lastFetchKey)
|
||||
SecureLogger.log("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: SecureLogger.session, level: .info)
|
||||
}
|
||||
return
|
||||
}
|
||||
guard !isFetching else { return }
|
||||
isFetching = true
|
||||
|
||||
let request = URLRequest(
|
||||
url: dependencies.remoteURL,
|
||||
cachePolicy: .reloadIgnoringLocalCacheData,
|
||||
timeoutInterval: 15
|
||||
)
|
||||
let awaitTorReady = dependencies.awaitTorReady
|
||||
let fetchData = dependencies.makeFetchData()
|
||||
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
let outcome = await Self.fetchRemoteOutcome(
|
||||
request: request,
|
||||
awaitTorReady: awaitTorReady,
|
||||
fetchData: fetchData
|
||||
)
|
||||
|
||||
switch outcome {
|
||||
case .success(let parsed, let csv):
|
||||
self.handleFetchSuccess(entries: parsed, csv: csv)
|
||||
case .torNotReady:
|
||||
self.handleFetchFailure(.torNotReady)
|
||||
case .invalidData:
|
||||
self.handleFetchFailure(.invalidData)
|
||||
case .network(let description):
|
||||
self.handleFetchFailure(.network(description))
|
||||
}
|
||||
SecureLogger.log("GeoRelayDirectory: remote fetch failed; keeping local entries", category: SecureLogger.session, level: .warning)
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
|
||||
nonisolated private static func fetchRemoteOutcome(
|
||||
request: URLRequest,
|
||||
awaitTorReady: @escaping @Sendable () async -> Bool,
|
||||
fetchData: @escaping @Sendable (URLRequest) async throws -> Data
|
||||
) async -> DetachedFetchOutcome {
|
||||
await Task.detached(priority: .utility) {
|
||||
let ready = await awaitTorReady()
|
||||
guard ready else { return .torNotReady }
|
||||
|
||||
do {
|
||||
let data = try await fetchData(request)
|
||||
guard let text = String(data: data, encoding: .utf8) else {
|
||||
return .invalidData
|
||||
}
|
||||
|
||||
let parsed = Self.parseCSV(text)
|
||||
guard !parsed.isEmpty else {
|
||||
return .invalidData
|
||||
}
|
||||
|
||||
return .success(entries: parsed, csv: text)
|
||||
} catch {
|
||||
return .network(error.localizedDescription)
|
||||
}
|
||||
}.value
|
||||
}
|
||||
|
||||
private enum FetchFailure {
|
||||
case torNotReady
|
||||
case invalidData
|
||||
case network(String)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
|
||||
entries = parsed
|
||||
persistCache(csv)
|
||||
dependencies.userDefaults.set(dependencies.now(), forKey: lastFetchKey)
|
||||
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
|
||||
isFetching = false
|
||||
retryAttempt = 0
|
||||
cancelRetry()
|
||||
// Let waiters (e.g. location notes stuck in a "no relays" state) retry.
|
||||
dependencies.notificationCenter.post(name: .geoRelayDirectoryDidRefresh, object: nil)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handleFetchFailure(_ reason: FetchFailure) {
|
||||
switch reason {
|
||||
case .torNotReady:
|
||||
SecureLogger.warning("GeoRelayDirectory: Tor not ready; scheduling retry", category: .session)
|
||||
case .invalidData:
|
||||
SecureLogger.warning("GeoRelayDirectory: remote fetch returned invalid data; scheduling retry", category: .session)
|
||||
case .network(let errorDescription):
|
||||
SecureLogger.warning("GeoRelayDirectory: remote fetch failed with error: \(errorDescription)", category: .session)
|
||||
}
|
||||
isFetching = false
|
||||
scheduleRetry()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func scheduleRetry() {
|
||||
retryAttempt = min(retryAttempt + 1, 10)
|
||||
let base = dependencies.retryInitialSeconds
|
||||
let maxDelay = dependencies.retryMaxSeconds
|
||||
let multiplier = pow(2.0, Double(max(retryAttempt - 1, 0)))
|
||||
let calculated = base * multiplier
|
||||
let delay = min(maxDelay, max(base, calculated))
|
||||
|
||||
cancelRetry()
|
||||
cleanupState.retryTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.dependencies.retrySleep(delay)
|
||||
guard !Task.isCancelled else { return }
|
||||
await MainActor.run {
|
||||
self.prefetchIfNeeded(force: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func cancelRetry() {
|
||||
cleanupState.retryTask?.cancel()
|
||||
cleanupState.retryTask = nil
|
||||
}
|
||||
|
||||
private func persistCache(_ text: String) {
|
||||
guard let url = cacheURL() else { return }
|
||||
guard let url = dependencies.cacheURL() else { return }
|
||||
guard let data = text.data(using: .utf8) else { return }
|
||||
do {
|
||||
try text.data(using: .utf8)?.write(to: url, options: .atomic)
|
||||
try dependencies.writeData(data, url)
|
||||
} catch {
|
||||
SecureLogger.log("GeoRelayDirectory: failed to write cache: \(error)", category: SecureLogger.session, level: .warning)
|
||||
SecureLogger.warning("GeoRelayDirectory: failed to write cache: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Loading
|
||||
private func loadLocalEntries() -> [Entry] {
|
||||
// Prefer cached file if present
|
||||
if let cache = self.cacheURL(),
|
||||
let data = try? Data(contentsOf: cache),
|
||||
if let cache = dependencies.cacheURL(),
|
||||
let data = dependencies.readData(cache),
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
let arr = Self.parseCSV(text)
|
||||
if !arr.isEmpty { return arr }
|
||||
}
|
||||
|
||||
// Try bundled resource(s)
|
||||
let bundleCandidates = [
|
||||
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
|
||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
|
||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
|
||||
].compactMap { $0 }
|
||||
let bundleCandidates = dependencies.bundledCSVURLs()
|
||||
|
||||
for url in bundleCandidates {
|
||||
if let data = try? Data(contentsOf: url), let text = String(data: data, encoding: .utf8) {
|
||||
if let data = dependencies.readData(url),
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
let arr = Self.parseCSV(text)
|
||||
if !arr.isEmpty { return arr }
|
||||
}
|
||||
}
|
||||
|
||||
// Try filesystem path (development/test)
|
||||
if let cwd = FileManager.default.currentDirectoryPath as String?,
|
||||
let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
||||
if let cwd = dependencies.currentDirectoryPath(),
|
||||
let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
return Self.parseCSV(text)
|
||||
}
|
||||
SecureLogger.log("GeoRelayDirectory: no local CSV found; entries empty", category: SecureLogger.session, level: .warning)
|
||||
|
||||
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
|
||||
return []
|
||||
}
|
||||
|
||||
nonisolated static func parseCSV(_ text: String) -> [Entry] {
|
||||
var result: Set<Entry> = []
|
||||
let lines = text.split(whereSeparator: { $0.isNewline })
|
||||
// Skip header if present
|
||||
for (idx, raw) in lines.enumerated() {
|
||||
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if line.isEmpty { continue }
|
||||
guard let line = raw.trimmedOrNilIfEmpty else { continue }
|
||||
if idx == 0 && line.lowercased().contains("relay url") { continue }
|
||||
let parts = line.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) }
|
||||
let parts = line.split(separator: ",").map { $0.trimmed }
|
||||
guard parts.count >= 3 else { continue }
|
||||
var host = parts[0]
|
||||
host = host.replacingOccurrences(of: "https://", with: "")
|
||||
host = host.replacingOccurrences(of: "http://", with: "")
|
||||
host = host.replacingOccurrences(of: "wss://", with: "")
|
||||
host = host.replacingOccurrences(of: "ws://", with: "")
|
||||
host = host.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
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)
|
||||
}
|
||||
|
||||
private func cacheURL() -> URL? {
|
||||
do {
|
||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
return dir.appendingPathComponent(cacheFileName)
|
||||
} catch { return nil }
|
||||
// MARK: - Observers & Timers
|
||||
private func registerObservers() {
|
||||
let center = dependencies.notificationCenter
|
||||
|
||||
let torReady = center.addObserver(
|
||||
forName: .TorDidBecomeReady,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.prefetchIfNeeded(force: true)
|
||||
}
|
||||
}
|
||||
cleanupState.observers.append(torReady)
|
||||
|
||||
if let activeNotificationName = dependencies.activeNotificationName {
|
||||
let didBecomeActive = center.addObserver(
|
||||
forName: activeNotificationName,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.prefetchIfNeeded()
|
||||
}
|
||||
}
|
||||
cleanupState.observers.append(didBecomeActive)
|
||||
}
|
||||
}
|
||||
|
||||
private func startRefreshTimer() {
|
||||
cleanupState.refreshTimer?.invalidate()
|
||||
let interval = dependencies.refreshCheckInterval
|
||||
guard interval > 0 else { return }
|
||||
|
||||
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.prefetchIfNeeded()
|
||||
}
|
||||
}
|
||||
cleanupState.refreshTimer = timer
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
}
|
||||
|
||||
var debugRetryAttempt: Int { retryAttempt }
|
||||
var debugHasRetryTask: Bool { cleanupState.retryTask != nil }
|
||||
var debugObserverCount: Int { cleanupState.observers.count }
|
||||
}
|
||||
|
||||
// MARK: - Distance
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
|
||||
// MARK: - BitChat-over-Nostr Adapter
|
||||
|
||||
struct NostrEmbeddedBitChat {
|
||||
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a private message for Nostr DMs.
|
||||
static func encodePMForNostr(content: String, messageID: String, recipientPeerID: String, senderPeerID: String) -> String? {
|
||||
static func encodePMForNostr(content: String, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> String? {
|
||||
// TLV-encode the private message
|
||||
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
||||
guard let tlv = pm.encode() else { return nil }
|
||||
@@ -14,12 +15,12 @@ struct NostrEmbeddedBitChat {
|
||||
payload.append(tlv)
|
||||
|
||||
// Determine 8-byte recipient ID to embed
|
||||
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
|
||||
let recipientID = normalizeRecipientPeerID(recipientPeerID)
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||
recipientID: Data(hexString: recipientIDHex),
|
||||
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
||||
recipientID: Data(hexString: recipientID.id),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
@@ -31,18 +32,18 @@ struct NostrEmbeddedBitChat {
|
||||
}
|
||||
|
||||
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a delivery/read ack for Nostr DMs.
|
||||
static func encodeAckForNostr(type: NoisePayloadType, messageID: String, recipientPeerID: String, senderPeerID: String) -> String? {
|
||||
static func encodeAckForNostr(type: NoisePayloadType, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> String? {
|
||||
guard type == .delivered || type == .readReceipt else { return nil }
|
||||
|
||||
var payload = Data([type.rawValue])
|
||||
payload.append(Data(messageID.utf8))
|
||||
|
||||
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
|
||||
let recipientID = normalizeRecipientPeerID(recipientPeerID)
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||
recipientID: Data(hexString: recipientIDHex),
|
||||
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
||||
recipientID: Data(hexString: recipientID.id),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
@@ -54,7 +55,7 @@ struct NostrEmbeddedBitChat {
|
||||
}
|
||||
|
||||
/// Build a `bitchat1:` ACK (delivered/read) without an embedded recipient peer ID (geohash DMs).
|
||||
static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: String) -> String? {
|
||||
static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: PeerID) -> String? {
|
||||
guard type == .delivered || type == .readReceipt else { return nil }
|
||||
|
||||
var payload = Data([type.rawValue])
|
||||
@@ -62,7 +63,7 @@ struct NostrEmbeddedBitChat {
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
@@ -75,7 +76,7 @@ struct NostrEmbeddedBitChat {
|
||||
}
|
||||
|
||||
/// Build a `bitchat1:` payload without an embedded recipient peer ID (used for geohash DMs).
|
||||
static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: String) -> String? {
|
||||
static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: PeerID) -> String? {
|
||||
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
||||
guard let tlv = pm.encode() else { return nil }
|
||||
|
||||
@@ -84,7 +85,7 @@ struct NostrEmbeddedBitChat {
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
@@ -96,11 +97,11 @@ struct NostrEmbeddedBitChat {
|
||||
return "bitchat1:" + base64URLEncode(data)
|
||||
}
|
||||
|
||||
private static func normalizeRecipientPeerID(_ recipientPeerID: String) -> String {
|
||||
if let maybeData = Data(hexString: recipientPeerID) {
|
||||
private static func normalizeRecipientPeerID(_ recipientPeerID: PeerID) -> PeerID {
|
||||
if let maybeData = Data(hexString: recipientPeerID.id) {
|
||||
if maybeData.count == 32 {
|
||||
// Treat as Noise static public key; derive peerID from fingerprint
|
||||
return PeerIDUtils.derivePeerID(fromPublicKey: maybeData)
|
||||
return PeerID(publicKey: maybeData)
|
||||
} else if maybeData.count == 8 {
|
||||
// Already an 8-byte peer ID
|
||||
return recipientPeerID
|
||||
|
||||
@@ -1,50 +1,5 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import P256K
|
||||
import Security
|
||||
|
||||
// Keychain helper for secure storage
|
||||
struct KeychainHelper {
|
||||
static func save(key: String, data: Data, service: String, accessible: CFString? = nil) {
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecValueData as String: data
|
||||
]
|
||||
if let accessible = accessible {
|
||||
query[kSecAttrAccessible as String] = accessible
|
||||
}
|
||||
|
||||
SecItemDelete(query as CFDictionary)
|
||||
SecItemAdd(query as CFDictionary, nil)
|
||||
}
|
||||
|
||||
static func load(key: String, service: String) -> Data? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecReturnData as String: true
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
guard status == errSecSuccess else { return nil }
|
||||
return result as? Data
|
||||
}
|
||||
|
||||
static func delete(key: String, service: String) {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key
|
||||
]
|
||||
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
|
||||
struct NostrIdentity: Codable {
|
||||
@@ -103,251 +58,3 @@ struct NostrIdentity: Codable {
|
||||
return publicKey.hexEncodedString()
|
||||
}
|
||||
}
|
||||
|
||||
/// Bridge between Noise and Nostr identities
|
||||
struct NostrIdentityBridge {
|
||||
private static let keychainService = "chat.bitchat.nostr"
|
||||
private static let currentIdentityKey = "nostr-current-identity"
|
||||
private static let deviceSeedKey = "nostr-device-seed"
|
||||
// In-memory cache to avoid transient keychain access issues
|
||||
private static var deviceSeedCache: Data?
|
||||
|
||||
/// Get or create the current Nostr identity
|
||||
static func getCurrentNostrIdentity() throws -> NostrIdentity? {
|
||||
// Check if we already have a Nostr identity
|
||||
if let existingData = KeychainHelper.load(key: currentIdentityKey, service: keychainService),
|
||||
let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) {
|
||||
return identity
|
||||
}
|
||||
|
||||
// Generate new Nostr identity
|
||||
let nostrIdentity = try NostrIdentity.generate()
|
||||
|
||||
// Store it
|
||||
let data = try JSONEncoder().encode(nostrIdentity)
|
||||
KeychainHelper.save(key: currentIdentityKey, data: data, service: keychainService)
|
||||
|
||||
return nostrIdentity
|
||||
}
|
||||
|
||||
/// Associate a Nostr identity with a Noise public key (for favorites)
|
||||
static func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
|
||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||
if let data = nostrPubkey.data(using: .utf8) {
|
||||
KeychainHelper.save(key: key, data: data, service: keychainService)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get Nostr public key associated with a Noise public key
|
||||
static func getNostrPublicKey(for noisePublicKey: Data) -> String? {
|
||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||
guard let data = KeychainHelper.load(key: key, service: keychainService),
|
||||
let pubkey = String(data: data, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
return pubkey
|
||||
}
|
||||
|
||||
/// Clear all Nostr identity associations and current identity
|
||||
static func clearAllAssociations() {
|
||||
// Delete current Nostr identity
|
||||
KeychainHelper.delete(key: currentIdentityKey, service: keychainService)
|
||||
KeychainHelper.delete(key: deviceSeedKey, service: keychainService)
|
||||
|
||||
// Note: We can't efficiently delete all noise-nostr associations
|
||||
// without tracking them, but they'll be orphaned and eventually cleaned up
|
||||
// The important part is deleting the current identity so a new one is generated
|
||||
}
|
||||
|
||||
// MARK: - Per-Geohash Identities (Location Channels)
|
||||
|
||||
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
|
||||
/// Stored only on device keychain.
|
||||
private static func getOrCreateDeviceSeed() -> Data {
|
||||
if let cached = deviceSeedCache { return cached }
|
||||
if let existing = KeychainHelper.load(key: deviceSeedKey, service: keychainService) {
|
||||
// Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock
|
||||
KeychainHelper.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||
deviceSeedCache = existing
|
||||
return existing
|
||||
}
|
||||
var seed = Data(count: 32)
|
||||
_ = seed.withUnsafeMutableBytes { ptr in
|
||||
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
||||
}
|
||||
// Ensure availability after first unlock to prevent unintended rotation when locked
|
||||
KeychainHelper.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||
deviceSeedCache = seed
|
||||
return seed
|
||||
}
|
||||
|
||||
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
|
||||
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
||||
/// if the candidate is not a valid secp256k1 private key.
|
||||
static func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
||||
let seed = getOrCreateDeviceSeed()
|
||||
guard let msg = geohash.data(using: .utf8) else {
|
||||
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
|
||||
}
|
||||
|
||||
func candidateKey(iteration: UInt32) -> Data {
|
||||
var input = Data(msg)
|
||||
var iterBE = iteration.bigEndian
|
||||
withUnsafeBytes(of: &iterBE) { bytes in
|
||||
input.append(contentsOf: bytes)
|
||||
}
|
||||
let code = CryptoKit.HMAC<CryptoKit.SHA256>.authenticationCode(for: input, using: SymmetricKey(data: seed))
|
||||
return Data(code)
|
||||
}
|
||||
|
||||
// Try a few iterations to ensure a valid key can be formed
|
||||
for i in 0..<10 {
|
||||
let keyData = candidateKey(iteration: UInt32(i))
|
||||
if let identity = try? NostrIdentity(privateKeyData: keyData) {
|
||||
return identity
|
||||
}
|
||||
}
|
||||
// As a final fallback, hash the seed+msg and try again
|
||||
var combined = Data()
|
||||
combined.append(seed)
|
||||
combined.append(msg)
|
||||
let fallback = Data(CryptoKit.SHA256.hash(data: combined))
|
||||
return try NostrIdentity(privateKeyData: fallback)
|
||||
}
|
||||
}
|
||||
|
||||
// Bech32 encoding for Nostr (minimal implementation)
|
||||
enum Bech32 {
|
||||
private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
|
||||
|
||||
static func encode(hrp: String, data: Data) throws -> String {
|
||||
let values = convertBits(from: 8, to: 5, pad: true, data: Array(data))
|
||||
let checksum = createChecksum(hrp: hrp, values: values)
|
||||
let combined = values + checksum
|
||||
|
||||
return hrp + "1" + combined.map {
|
||||
let index = charset.index(charset.startIndex, offsetBy: Int($0))
|
||||
return String(charset[index])
|
||||
}.joined()
|
||||
}
|
||||
|
||||
static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {
|
||||
// Find the last occurrence of '1'
|
||||
guard let separatorIndex = bech32String.lastIndex(of: "1") else {
|
||||
throw Bech32Error.invalidFormat
|
||||
}
|
||||
|
||||
let hrp = String(bech32String[..<separatorIndex])
|
||||
|
||||
// Validate HRP contains only ASCII characters
|
||||
for char in hrp {
|
||||
guard char.asciiValue != nil else {
|
||||
throw Bech32Error.invalidCharacter
|
||||
}
|
||||
}
|
||||
|
||||
let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])
|
||||
|
||||
// Convert characters to values
|
||||
var values = [UInt8]()
|
||||
for char in dataString {
|
||||
guard let index = charset.firstIndex(of: char) else {
|
||||
throw Bech32Error.invalidCharacter
|
||||
}
|
||||
values.append(UInt8(charset.distance(from: charset.startIndex, to: index)))
|
||||
}
|
||||
|
||||
// Verify checksum
|
||||
guard values.count >= 6 else {
|
||||
throw Bech32Error.invalidChecksum
|
||||
}
|
||||
|
||||
let payloadValues = Array(values.dropLast(6))
|
||||
let checksum = Array(values.suffix(6))
|
||||
let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues)
|
||||
|
||||
guard checksum == expectedChecksum else {
|
||||
throw Bech32Error.invalidChecksum
|
||||
}
|
||||
|
||||
// Convert back to bytes
|
||||
let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues)
|
||||
return (hrp: hrp, data: Data(bytes))
|
||||
}
|
||||
|
||||
enum Bech32Error: Error {
|
||||
case invalidFormat
|
||||
case invalidCharacter
|
||||
case invalidChecksum
|
||||
}
|
||||
|
||||
private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] {
|
||||
var acc = 0
|
||||
var bits = 0
|
||||
var result = [UInt8]()
|
||||
let maxv = (1 << to) - 1
|
||||
|
||||
for value in data {
|
||||
acc = (acc << from) | Int(value)
|
||||
bits += from
|
||||
|
||||
while bits >= to {
|
||||
bits -= to
|
||||
result.append(UInt8((acc >> bits) & maxv))
|
||||
}
|
||||
}
|
||||
|
||||
if pad && bits > 0 {
|
||||
result.append(UInt8((acc << (to - bits)) & maxv))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] {
|
||||
let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0]
|
||||
let polymod = polymod(checksumValues) ^ 1
|
||||
var checksum = [UInt8]()
|
||||
|
||||
for i in 0..<6 {
|
||||
checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31))
|
||||
}
|
||||
|
||||
return checksum
|
||||
}
|
||||
|
||||
private static func hrpExpand(_ hrp: String) -> [UInt8] {
|
||||
var result = [UInt8]()
|
||||
for c in hrp {
|
||||
guard let asciiValue = c.asciiValue else {
|
||||
return [] // Return empty array for invalid input
|
||||
}
|
||||
result.append(UInt8(asciiValue >> 5))
|
||||
}
|
||||
result.append(0)
|
||||
for c in hrp {
|
||||
guard let asciiValue = c.asciiValue else {
|
||||
return [] // Return empty array for invalid input
|
||||
}
|
||||
result.append(UInt8(asciiValue & 31))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static func polymod(_ values: [UInt8]) -> Int {
|
||||
var chk = 1
|
||||
for value in values {
|
||||
let b = chk >> 25
|
||||
chk = (chk & 0x1ffffff) << 5 ^ Int(value)
|
||||
for i in 0..<5 {
|
||||
if (b >> i) & 1 == 1 {
|
||||
chk ^= generator[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return chk
|
||||
}
|
||||
}
|
||||
|
||||
// Data hex encoding extension moved to BinaryEncodingUtils.swift to avoid duplication
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// Bridge between Noise and Nostr identities
|
||||
final class NostrIdentityBridge {
|
||||
private let keychainService = "chat.bitchat.nostr"
|
||||
private let currentIdentityKey = "nostr-current-identity"
|
||||
private let deviceSeedKey = "nostr-device-seed"
|
||||
// In-memory cache to avoid transient keychain access issues
|
||||
private var deviceSeedCache: Data?
|
||||
// Cache derived identities to avoid repeated crypto during view rendering
|
||||
private var derivedIdentityCache: [String: NostrIdentity] = [:]
|
||||
private let cacheLock = NSLock()
|
||||
|
||||
private let keychain: KeychainManagerProtocol
|
||||
|
||||
init(keychain: KeychainManagerProtocol = KeychainManager()) {
|
||||
self.keychain = keychain
|
||||
}
|
||||
|
||||
/// Get or create the current Nostr identity
|
||||
func getCurrentNostrIdentity() throws -> NostrIdentity? {
|
||||
// Check if we already have a Nostr identity
|
||||
if let existingData = keychain.load(key: currentIdentityKey, service: keychainService),
|
||||
let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) {
|
||||
return identity
|
||||
}
|
||||
|
||||
// Generate new Nostr identity
|
||||
let nostrIdentity = try NostrIdentity.generate()
|
||||
|
||||
// Store it
|
||||
let data = try JSONEncoder().encode(nostrIdentity)
|
||||
keychain.save(key: currentIdentityKey, data: data, service: keychainService, accessible: nil)
|
||||
|
||||
return nostrIdentity
|
||||
}
|
||||
|
||||
/// Associate a Nostr identity with a Noise public key (for favorites)
|
||||
func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
|
||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||
if let data = nostrPubkey.data(using: .utf8) {
|
||||
keychain.save(key: key, data: data, service: keychainService, accessible: nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get Nostr public key associated with a Noise public key
|
||||
func getNostrPublicKey(for noisePublicKey: Data) -> String? {
|
||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||
guard let data = keychain.load(key: key, service: keychainService),
|
||||
let pubkey = String(data: data, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
return pubkey
|
||||
}
|
||||
|
||||
/// Clear all Nostr identity associations and current identity
|
||||
func clearAllAssociations() {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService,
|
||||
kSecMatchLimit as String: kSecMatchLimitAll,
|
||||
kSecReturnAttributes as String: true
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status == errSecSuccess, let items = result as? [[String: Any]] {
|
||||
for item in items {
|
||||
var deleteQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService
|
||||
]
|
||||
if let account = item[kSecAttrAccount as String] as? String {
|
||||
deleteQuery[kSecAttrAccount as String] = account
|
||||
}
|
||||
SecItemDelete(deleteQuery as CFDictionary)
|
||||
}
|
||||
} else if status == errSecItemNotFound {
|
||||
// nothing persisted; no action needed
|
||||
}
|
||||
|
||||
deviceSeedCache = nil
|
||||
}
|
||||
|
||||
// MARK: - Per-Geohash Identities (Location Channels)
|
||||
|
||||
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
|
||||
/// Stored only on device keychain.
|
||||
private func getOrCreateDeviceSeed() -> Data {
|
||||
if let cached = deviceSeedCache { return cached }
|
||||
if let existing = keychain.load(key: deviceSeedKey, service: keychainService) {
|
||||
// Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock
|
||||
keychain.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||
deviceSeedCache = existing
|
||||
return existing
|
||||
}
|
||||
var seed = Data(count: 32)
|
||||
_ = seed.withUnsafeMutableBytes { ptr in
|
||||
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
||||
}
|
||||
// Ensure availability after first unlock to prevent unintended rotation when locked
|
||||
keychain.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||
deviceSeedCache = seed
|
||||
return seed
|
||||
}
|
||||
|
||||
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
|
||||
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
||||
/// if the candidate is not a valid secp256k1 private key.
|
||||
func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
||||
// Check cache first to avoid repeated crypto + keychain I/O during view rendering
|
||||
cacheLock.lock()
|
||||
if let cached = derivedIdentityCache[geohash] {
|
||||
cacheLock.unlock()
|
||||
return cached
|
||||
}
|
||||
cacheLock.unlock()
|
||||
|
||||
let seed = getOrCreateDeviceSeed()
|
||||
guard let msg = geohash.data(using: .utf8) else {
|
||||
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
|
||||
}
|
||||
|
||||
func candidateKey(iteration: UInt32) -> Data {
|
||||
var input = Data(msg)
|
||||
var iterBE = iteration.bigEndian
|
||||
withUnsafeBytes(of: &iterBE) { bytes in
|
||||
input.append(contentsOf: bytes)
|
||||
}
|
||||
let code = HMAC<SHA256>.authenticationCode(for: input, using: SymmetricKey(data: seed))
|
||||
return Data(code)
|
||||
}
|
||||
|
||||
// Try a few iterations to ensure a valid key can be formed
|
||||
for i in 0..<10 {
|
||||
let keyData = candidateKey(iteration: UInt32(i))
|
||||
if let identity = try? NostrIdentity(privateKeyData: keyData) {
|
||||
// Cache the result
|
||||
cacheLock.lock()
|
||||
derivedIdentityCache[geohash] = identity
|
||||
cacheLock.unlock()
|
||||
return identity
|
||||
}
|
||||
}
|
||||
// As a final fallback, hash the seed+msg and try again
|
||||
let fallback = (seed + msg).sha256Hash()
|
||||
let identity = try NostrIdentity(privateKeyData: fallback)
|
||||
|
||||
// Cache the result
|
||||
cacheLock.lock()
|
||||
derivedIdentityCache[geohash] = identity
|
||||
cacheLock.unlock()
|
||||
|
||||
return identity
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import P256K
|
||||
@@ -17,6 +18,7 @@ struct NostrProtocol {
|
||||
case seal = 13 // NIP-17 sealed event
|
||||
case giftWrap = 1059 // NIP-59 gift wrap
|
||||
case ephemeralEvent = 20000
|
||||
case geohashPresence = 20001
|
||||
}
|
||||
|
||||
/// Create a NIP-17 private message
|
||||
@@ -78,8 +80,7 @@ struct NostrProtocol {
|
||||
)
|
||||
// Successfully unwrapped gift wrap
|
||||
} catch {
|
||||
SecureLogger.log("❌ Failed to unwrap gift wrap: \(error)",
|
||||
category: SecureLogger.session, level: .error)
|
||||
SecureLogger.error("❌ Failed to unwrap gift wrap: \(error)", category: .session)
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -92,8 +93,7 @@ struct NostrProtocol {
|
||||
)
|
||||
// Successfully opened seal
|
||||
} catch {
|
||||
SecureLogger.log("❌ Failed to open seal: \(error)",
|
||||
category: SecureLogger.session, level: .error)
|
||||
SecureLogger.error("❌ Failed to open seal: \(error)", category: .session)
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ struct NostrProtocol {
|
||||
teleported: Bool = false
|
||||
) throws -> NostrEvent {
|
||||
var tags = [["g", geohash]]
|
||||
if let nickname = nickname, !nickname.isEmpty {
|
||||
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
||||
tags.append(["n", nickname])
|
||||
}
|
||||
if teleported {
|
||||
@@ -122,8 +122,48 @@ struct NostrProtocol {
|
||||
tags: tags,
|
||||
content: content
|
||||
)
|
||||
let signingKey = try senderIdentity.signingKey()
|
||||
return try event.sign(with: signingKey)
|
||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||
return try event.sign(with: schnorrKey)
|
||||
}
|
||||
|
||||
/// Create a geohash presence heartbeat (kind 20001)
|
||||
/// Must contain empty content and NO nickname tag
|
||||
static func createGeohashPresenceEvent(
|
||||
geohash: String,
|
||||
senderIdentity: NostrIdentity
|
||||
) throws -> NostrEvent {
|
||||
let tags = [["g", geohash]]
|
||||
let event = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .geohashPresence,
|
||||
tags: tags,
|
||||
content: ""
|
||||
)
|
||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||
return try event.sign(with: schnorrKey)
|
||||
}
|
||||
|
||||
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
|
||||
static func createGeohashTextNote(
|
||||
content: String,
|
||||
geohash: String,
|
||||
senderIdentity: NostrIdentity,
|
||||
nickname: String? = nil
|
||||
) throws -> NostrEvent {
|
||||
var tags = [["g", geohash]]
|
||||
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
||||
tags.append(["n", nickname])
|
||||
}
|
||||
let event = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .textNote,
|
||||
tags: tags,
|
||||
content: content
|
||||
)
|
||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||
return try event.sign(with: schnorrKey)
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
@@ -149,9 +189,8 @@ struct NostrProtocol {
|
||||
content: encrypted
|
||||
)
|
||||
|
||||
// Convert to P256K.Signing.PrivateKey for signing (temporary until we update sign method)
|
||||
let signingKey = try P256K.Signing.PrivateKey(dataRepresentation: senderKey.dataRepresentation)
|
||||
return try seal.sign(with: signingKey)
|
||||
// Sign the seal with the sender's Schnorr private key
|
||||
return try seal.sign(with: senderKey)
|
||||
}
|
||||
|
||||
private static func createGiftWrap(
|
||||
@@ -181,9 +220,8 @@ struct NostrProtocol {
|
||||
content: encrypted
|
||||
)
|
||||
|
||||
// Convert to P256K.Signing.PrivateKey for signing (temporary until we update sign method)
|
||||
let signingKey = try P256K.Signing.PrivateKey(dataRepresentation: wrapKey.dataRepresentation)
|
||||
return try giftWrap.sign(with: signingKey)
|
||||
// Sign the gift wrap with the wrap Schnorr private key
|
||||
return try giftWrap.sign(with: wrapKey)
|
||||
}
|
||||
|
||||
private static func unwrapGiftWrap(
|
||||
@@ -265,7 +303,7 @@ struct NostrProtocol {
|
||||
combined.append(nonce24)
|
||||
combined.append(sealed.ciphertext)
|
||||
combined.append(sealed.tag)
|
||||
return "v2:" + base64URLEncode(combined)
|
||||
return "v2:" + Base64URLCoding.encode(combined)
|
||||
}
|
||||
|
||||
private static func decrypt(
|
||||
@@ -276,7 +314,7 @@ struct NostrProtocol {
|
||||
// Expect NIP-44 v2 format
|
||||
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
|
||||
let encoded = String(ciphertext.dropFirst(3))
|
||||
guard let data = base64URLDecode(encoded),
|
||||
guard let data = Base64URLCoding.decode(encoded),
|
||||
data.count > (24 + 16),
|
||||
let senderPubkeyData = Data(hexString: senderPubkey) else {
|
||||
throw NostrError.invalidCiphertext
|
||||
@@ -472,19 +510,16 @@ struct NostrEvent: Codable {
|
||||
self.sig = dict["sig"] as? String
|
||||
}
|
||||
|
||||
func sign(with key: P256K.Signing.PrivateKey) throws -> NostrEvent {
|
||||
func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent {
|
||||
let (eventId, eventIdHash) = try calculateEventId()
|
||||
|
||||
// Convert to Schnorr key for Nostr signing
|
||||
let schnorrKey = try P256K.Schnorr.PrivateKey(dataRepresentation: key.dataRepresentation)
|
||||
|
||||
// Sign with Schnorr
|
||||
// Sign with Schnorr (BIP-340)
|
||||
var messageBytes = [UInt8](eventIdHash)
|
||||
var auxRand = [UInt8](repeating: 0, count: 32)
|
||||
_ = auxRand.withUnsafeMutableBytes { ptr in
|
||||
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
||||
}
|
||||
let schnorrSignature = try schnorrKey.signature(message: &messageBytes, auxiliaryRand: &auxRand)
|
||||
let schnorrSignature = try key.signature(message: &messageBytes, auxiliaryRand: &auxRand)
|
||||
|
||||
let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString()
|
||||
|
||||
@@ -493,6 +528,26 @@ struct NostrEvent: Codable {
|
||||
signed.sig = signatureHex
|
||||
return signed
|
||||
}
|
||||
|
||||
/// Validate that the event ID and Schnorr signature match the content and pubkey.
|
||||
/// Returns false when the signature is missing, malformed, or does not verify.
|
||||
func isValidSignature() -> Bool {
|
||||
guard let sig = sig,
|
||||
let sigData = Data(hexString: sig),
|
||||
let pubData = Data(hexString: pubkey),
|
||||
sigData.count == 64,
|
||||
pubData.count == 32,
|
||||
let signature = try? P256K.Schnorr.SchnorrSignature(dataRepresentation: sigData),
|
||||
let (expectedId, eventHash) = try? calculateEventId(),
|
||||
expectedId == id
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
var messageBytes = [UInt8](eventHash)
|
||||
let xonly = P256K.Schnorr.XonlyKey(dataRepresentation: pubData)
|
||||
return xonly.isValid(signature, for: &messageBytes)
|
||||
}
|
||||
|
||||
private func calculateEventId() throws -> (String, Data) {
|
||||
let serialized = [
|
||||
@@ -505,10 +560,7 @@ struct NostrEvent: Codable {
|
||||
] as [Any]
|
||||
|
||||
let data = try JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
|
||||
let hash = CryptoKit.SHA256.hash(data: data)
|
||||
let hashData = Data(hash)
|
||||
let hashHex = hash.compactMap { String(format: "%02x", $0) }.joined()
|
||||
return (hashHex, hashData)
|
||||
return (data.sha256Fingerprint(), data.sha256Hash())
|
||||
}
|
||||
|
||||
func jsonString() throws -> String {
|
||||
@@ -528,24 +580,9 @@ enum NostrError: Error {
|
||||
case encryptionFailed
|
||||
}
|
||||
|
||||
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305 + base64url)
|
||||
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
|
||||
|
||||
private extension NostrProtocol {
|
||||
static func base64URLEncode(_ data: Data) -> String {
|
||||
return data.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
|
||||
static func base64URLDecode(_ s: String) -> Data? {
|
||||
var str = s
|
||||
let pad = (4 - (str.count % 4)) % 4
|
||||
if pad > 0 { str += String(repeating: "=", count: pad) }
|
||||
str = str.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||
return Data(base64Encoded: str)
|
||||
}
|
||||
|
||||
static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data {
|
||||
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
||||
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import Foundation
|
||||
|
||||
enum NostrRelayURL {
|
||||
static func normalized(_ rawValue: String, defaultScheme: String? = nil) -> String? {
|
||||
var value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !value.isEmpty else { return nil }
|
||||
|
||||
if !value.contains("://"), let defaultScheme {
|
||||
value = "\(defaultScheme)://\(value)"
|
||||
}
|
||||
|
||||
guard var components = URLComponents(string: value),
|
||||
let rawScheme = components.scheme?.lowercased(),
|
||||
let rawHost = components.host?.lowercased(),
|
||||
!rawHost.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch rawScheme {
|
||||
case "wss", "https":
|
||||
components.scheme = "wss"
|
||||
if components.port == 443 {
|
||||
components.port = nil
|
||||
}
|
||||
case "ws", "http":
|
||||
components.scheme = "ws"
|
||||
if components.port == 80 {
|
||||
components.port = nil
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
components.host = rawHost
|
||||
if components.path == "/" {
|
||||
components.path = ""
|
||||
}
|
||||
components.fragment = nil
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -5,16 +5,27 @@ import CryptoKit
|
||||
/// Implements HChaCha20 to derive a subkey and reduces the 24-byte nonce to a 12-byte nonce
|
||||
/// as per XChaCha20 construction.
|
||||
enum XChaCha20Poly1305Compat {
|
||||
|
||||
/// Errors that can occur during XChaCha20-Poly1305 operations
|
||||
enum Error: Swift.Error {
|
||||
case invalidKeyLength(expected: Int, got: Int)
|
||||
case invalidNonceLength(expected: Int, got: Int)
|
||||
}
|
||||
|
||||
struct SealBox {
|
||||
let ciphertext: Data
|
||||
let tag: Data
|
||||
}
|
||||
|
||||
static func seal(plaintext: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> SealBox {
|
||||
precondition(key.count == 32, "XChaCha20 key must be 32 bytes")
|
||||
precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes")
|
||||
guard key.count == 32 else {
|
||||
throw Error.invalidKeyLength(expected: 32, got: key.count)
|
||||
}
|
||||
guard nonce24.count == 24 else {
|
||||
throw Error.invalidNonceLength(expected: 24, got: nonce24.count)
|
||||
}
|
||||
|
||||
let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16))
|
||||
let subkey = try hchacha20(key: key, nonce16: Data(nonce24.prefix(16)))
|
||||
let nonce12 = derive12ByteNonce(from24: nonce24)
|
||||
let chachaKey = SymmetricKey(data: subkey)
|
||||
let nonce = try ChaChaPoly.Nonce(data: nonce12)
|
||||
@@ -23,10 +34,14 @@ enum XChaCha20Poly1305Compat {
|
||||
}
|
||||
|
||||
static func open(ciphertext: Data, tag: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> Data {
|
||||
precondition(key.count == 32, "XChaCha20 key must be 32 bytes")
|
||||
precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes")
|
||||
guard key.count == 32 else {
|
||||
throw Error.invalidKeyLength(expected: 32, got: key.count)
|
||||
}
|
||||
guard nonce24.count == 24 else {
|
||||
throw Error.invalidNonceLength(expected: 24, got: nonce24.count)
|
||||
}
|
||||
|
||||
let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16))
|
||||
let subkey = try hchacha20(key: key, nonce16: Data(nonce24.prefix(16)))
|
||||
let nonce12 = derive12ByteNonce(from24: nonce24)
|
||||
let chachaKey = SymmetricKey(data: subkey)
|
||||
let box = try ChaChaPoly.SealedBox(nonce: ChaChaPoly.Nonce(data: nonce12), ciphertext: ciphertext, tag: tag)
|
||||
@@ -43,10 +58,14 @@ enum XChaCha20Poly1305Compat {
|
||||
return out
|
||||
}
|
||||
|
||||
private static func hchacha20(key: Data, nonce16: Data) -> Data {
|
||||
private static func hchacha20(key: Data, nonce16: Data) throws -> Data {
|
||||
// HChaCha20 based on the original ChaCha20 core with a 16-byte nonce.
|
||||
precondition(key.count == 32)
|
||||
precondition(nonce16.count == 16)
|
||||
guard key.count == 32 else {
|
||||
throw Error.invalidKeyLength(expected: 32, got: key.count)
|
||||
}
|
||||
guard nonce16.count == 16 else {
|
||||
throw Error.invalidNonceLength(expected: 16, got: nonce16.count)
|
||||
}
|
||||
|
||||
// Constants "expand 32-byte k"
|
||||
var state: [UInt32] = [
|
||||
|
||||
@@ -1,588 +0,0 @@
|
||||
//
|
||||
// BinaryProtocol.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
///
|
||||
/// # BinaryProtocol
|
||||
///
|
||||
/// Low-level binary encoding and decoding for BitChat protocol messages.
|
||||
/// Optimized for Bluetooth LE's limited bandwidth and MTU constraints.
|
||||
///
|
||||
/// ## Overview
|
||||
/// BinaryProtocol implements an efficient binary wire format that minimizes
|
||||
/// overhead while maintaining extensibility. It handles:
|
||||
/// - Compact binary encoding with fixed headers
|
||||
/// - Optional field support via flags
|
||||
/// - Automatic compression for large payloads
|
||||
/// - Endianness handling for cross-platform compatibility
|
||||
///
|
||||
/// ## Wire Format
|
||||
/// ```
|
||||
/// Header (Fixed 13 bytes):
|
||||
/// +--------+------+-----+-----------+-------+----------------+
|
||||
/// |Version | Type | TTL | Timestamp | Flags | PayloadLength |
|
||||
/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 bytes |
|
||||
/// +--------+------+-----+-----------+-------+----------------+
|
||||
///
|
||||
/// Variable sections:
|
||||
/// +----------+-------------+---------+------------+
|
||||
/// | SenderID | RecipientID | Payload | Signature |
|
||||
/// | 8 bytes | 8 bytes* | Variable| 64 bytes* |
|
||||
/// +----------+-------------+---------+------------+
|
||||
/// * Optional fields based on flags
|
||||
/// ```
|
||||
///
|
||||
/// ## Design Rationale
|
||||
/// The protocol is designed for:
|
||||
/// - **Efficiency**: Minimal overhead for small messages
|
||||
/// - **Flexibility**: Optional fields via flag bits
|
||||
/// - **Compatibility**: Network byte order (big-endian)
|
||||
/// - **Performance**: Zero-copy where possible
|
||||
///
|
||||
/// ## Compression Strategy
|
||||
/// - Automatic compression for payloads > 256 bytes
|
||||
/// - zlib compression for broad compatibility on Apple platforms
|
||||
/// - Original size stored for decompression
|
||||
/// - Flag bit indicates compressed payload
|
||||
///
|
||||
/// ## Flag Bits
|
||||
/// - Bit 0: Has recipient ID (directed message)
|
||||
/// - Bit 1: Has signature (authenticated message)
|
||||
/// - Bit 2: Is compressed (LZ4 compression applied)
|
||||
/// - Bits 3-7: Reserved for future use
|
||||
///
|
||||
/// ## Size Constraints
|
||||
/// - Maximum packet size: 65,535 bytes (16-bit length field)
|
||||
/// - Typical packet size: < 512 bytes (BLE MTU)
|
||||
/// - Minimum packet size: 21 bytes (header + sender ID)
|
||||
///
|
||||
/// ## Encoding Process
|
||||
/// 1. Construct header with fixed fields
|
||||
/// 2. Set appropriate flags
|
||||
/// 3. Compress payload if beneficial
|
||||
/// 4. Append variable-length fields
|
||||
/// 5. Calculate and append signature if needed
|
||||
///
|
||||
/// ## Decoding Process
|
||||
/// 1. Validate minimum packet size
|
||||
/// 2. Parse fixed header
|
||||
/// 3. Extract flags and determine field presence
|
||||
/// 4. Parse variable fields based on flags
|
||||
/// 5. Decompress payload if compressed
|
||||
/// 6. Verify signature if present
|
||||
///
|
||||
/// ## Error Handling
|
||||
/// - Graceful handling of malformed packets
|
||||
/// - Clear error messages for debugging
|
||||
/// - No crashes on invalid input
|
||||
/// - Logging of protocol violations
|
||||
///
|
||||
/// ## Performance Notes
|
||||
/// - Allocation-free for small messages
|
||||
/// - Streaming support for large payloads
|
||||
/// - Efficient bit manipulation
|
||||
/// - Platform-optimized byte swapping
|
||||
///
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Data {
|
||||
func trimmingNullBytes() -> Data {
|
||||
// Find the first null byte
|
||||
if let nullIndex = self.firstIndex(of: 0) {
|
||||
return self.prefix(nullIndex)
|
||||
}
|
||||
return self
|
||||
}
|
||||
}
|
||||
|
||||
/// Implements binary encoding and decoding for BitChat protocol messages.
|
||||
/// Provides static methods for converting between BitchatPacket objects and
|
||||
/// their binary wire format representation.
|
||||
/// - Note: All multi-byte values use network byte order (big-endian)
|
||||
struct BinaryProtocol {
|
||||
static let headerSize = 13
|
||||
static let senderIDSize = 8
|
||||
static let recipientIDSize = 8
|
||||
static let signatureSize = 64
|
||||
|
||||
struct Flags {
|
||||
static let hasRecipient: UInt8 = 0x01
|
||||
static let hasSignature: UInt8 = 0x02
|
||||
static let isCompressed: UInt8 = 0x04
|
||||
}
|
||||
|
||||
// Encode BitchatPacket to binary format
|
||||
static func encode(_ packet: BitchatPacket, padding: Bool = true) -> Data? {
|
||||
var data = Data()
|
||||
|
||||
|
||||
// Try to compress payload if beneficial
|
||||
var payload = packet.payload
|
||||
var originalPayloadSize: UInt16? = nil
|
||||
var isCompressed = false
|
||||
|
||||
if CompressionUtil.shouldCompress(payload) {
|
||||
if let compressedPayload = CompressionUtil.compress(payload) {
|
||||
// Store original size for decompression (2 bytes after payload)
|
||||
originalPayloadSize = UInt16(payload.count)
|
||||
payload = compressedPayload
|
||||
isCompressed = true
|
||||
|
||||
} else {
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
// Header
|
||||
data.append(packet.version)
|
||||
data.append(packet.type)
|
||||
data.append(packet.ttl)
|
||||
|
||||
// Timestamp (8 bytes, big-endian)
|
||||
for i in (0..<8).reversed() {
|
||||
data.append(UInt8((packet.timestamp >> (i * 8)) & 0xFF))
|
||||
}
|
||||
|
||||
// Flags
|
||||
var flags: UInt8 = 0
|
||||
if packet.recipientID != nil {
|
||||
flags |= Flags.hasRecipient
|
||||
}
|
||||
if packet.signature != nil {
|
||||
flags |= Flags.hasSignature
|
||||
}
|
||||
if isCompressed {
|
||||
flags |= Flags.isCompressed
|
||||
}
|
||||
data.append(flags)
|
||||
|
||||
// Payload length (2 bytes, big-endian) - includes original size if compressed
|
||||
let payloadDataSize = payload.count + (isCompressed ? 2 : 0)
|
||||
let payloadLength = UInt16(payloadDataSize)
|
||||
|
||||
|
||||
data.append(UInt8((payloadLength >> 8) & 0xFF))
|
||||
data.append(UInt8(payloadLength & 0xFF))
|
||||
|
||||
// SenderID (exactly 8 bytes)
|
||||
let senderBytes = packet.senderID.prefix(senderIDSize)
|
||||
data.append(senderBytes)
|
||||
if senderBytes.count < senderIDSize {
|
||||
data.append(Data(repeating: 0, count: senderIDSize - senderBytes.count))
|
||||
}
|
||||
|
||||
// RecipientID (if present)
|
||||
if let recipientID = packet.recipientID {
|
||||
let recipientBytes = recipientID.prefix(recipientIDSize)
|
||||
data.append(recipientBytes)
|
||||
if recipientBytes.count < recipientIDSize {
|
||||
data.append(Data(repeating: 0, count: recipientIDSize - recipientBytes.count))
|
||||
}
|
||||
}
|
||||
|
||||
// Payload (with original size prepended if compressed)
|
||||
if isCompressed, let originalSize = originalPayloadSize {
|
||||
// Prepend original size (2 bytes, big-endian)
|
||||
data.append(UInt8((originalSize >> 8) & 0xFF))
|
||||
data.append(UInt8(originalSize & 0xFF))
|
||||
}
|
||||
data.append(payload)
|
||||
|
||||
// Signature (if present)
|
||||
if let signature = packet.signature {
|
||||
data.append(signature.prefix(signatureSize))
|
||||
}
|
||||
|
||||
|
||||
// Apply padding to standard block sizes for traffic analysis resistance
|
||||
if padding {
|
||||
let optimalSize = MessagePadding.optimalBlockSize(for: data.count)
|
||||
let paddedData = MessagePadding.pad(data, toSize: optimalSize)
|
||||
return paddedData
|
||||
} else {
|
||||
// Caller explicitly requested no padding (e.g., BLE write path)
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
// Decode binary data to BitchatPacket
|
||||
static func decode(_ data: Data) -> BitchatPacket? {
|
||||
// Try decode as-is first (robust when padding wasn't applied)
|
||||
if let pkt = decodeCore(data) { return pkt }
|
||||
// If that fails, try after removing padding
|
||||
let unpadded = MessagePadding.unpad(data)
|
||||
if unpadded as NSData === data as NSData { return nil }
|
||||
return decodeCore(unpadded)
|
||||
}
|
||||
|
||||
// Core decoding implementation used by decode(_:) with and without padding removal
|
||||
private static func decodeCore(_ raw: Data) -> BitchatPacket? {
|
||||
// Minimum size check: header + senderID
|
||||
guard raw.count >= headerSize + senderIDSize else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Convert to array for safer indexed access
|
||||
let dataArray = Array(raw)
|
||||
var offset = 0
|
||||
|
||||
// Header parsing with bounds checks
|
||||
guard offset < dataArray.count else { return nil }
|
||||
let version = dataArray[offset]; offset += 1
|
||||
|
||||
// Check if version is 1 (only supported version)
|
||||
guard version == 1 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard offset < dataArray.count else { return nil }
|
||||
let type = dataArray[offset]; offset += 1
|
||||
|
||||
guard offset < dataArray.count else { return nil }
|
||||
let ttl = dataArray[offset]; offset += 1
|
||||
|
||||
// Timestamp - need 8 bytes
|
||||
guard offset + 8 <= dataArray.count else { return nil }
|
||||
let timestampData = Data(dataArray[offset..<offset+8])
|
||||
let timestamp = timestampData.reduce(0) { result, byte in
|
||||
(result << 8) | UInt64(byte)
|
||||
}
|
||||
offset += 8
|
||||
|
||||
// Flags
|
||||
guard offset < dataArray.count else { return nil }
|
||||
let flags = dataArray[offset]; offset += 1
|
||||
let hasRecipient = (flags & Flags.hasRecipient) != 0
|
||||
let hasSignature = (flags & Flags.hasSignature) != 0
|
||||
let isCompressed = (flags & Flags.isCompressed) != 0
|
||||
|
||||
// Payload length - need 2 bytes
|
||||
guard offset + 2 <= dataArray.count else { return nil }
|
||||
let payloadLengthData = Data(dataArray[offset..<offset+2])
|
||||
let payloadLength = payloadLengthData.reduce(0) { result, byte in
|
||||
(result << 8) | UInt16(byte)
|
||||
}
|
||||
offset += 2
|
||||
|
||||
// Validate payloadLength is reasonable (prevent integer overflow)
|
||||
guard payloadLength <= 65535 else { return nil }
|
||||
|
||||
// SenderID - need 8 bytes
|
||||
guard offset + senderIDSize <= dataArray.count else { return nil }
|
||||
let senderID = Data(dataArray[offset..<offset+senderIDSize])
|
||||
offset += senderIDSize
|
||||
|
||||
// RecipientID if present
|
||||
var recipientID: Data?
|
||||
if hasRecipient {
|
||||
guard offset + recipientIDSize <= dataArray.count else { return nil }
|
||||
recipientID = Data(dataArray[offset..<offset+recipientIDSize])
|
||||
offset += recipientIDSize
|
||||
}
|
||||
|
||||
// Payload handling with comprehensive bounds checking
|
||||
let payload: Data
|
||||
if isCompressed {
|
||||
// Compressed payload needs at least 2 bytes for original size
|
||||
guard Int(payloadLength) >= 2 else { return nil }
|
||||
|
||||
// Check we have enough data for the original size prefix
|
||||
guard offset + 2 <= dataArray.count else { return nil }
|
||||
let originalSizeData = Data(dataArray[offset..<offset+2])
|
||||
let originalSize = Int(originalSizeData.reduce(0) { result, byte in
|
||||
(result << 8) | UInt16(byte)
|
||||
})
|
||||
offset += 2
|
||||
|
||||
// Validate original size is reasonable
|
||||
guard originalSize >= 0 && originalSize <= 1048576 else { return nil } // Max 1MB
|
||||
|
||||
// Check we have enough data for the compressed payload
|
||||
let compressedPayloadSize = Int(payloadLength) - 2
|
||||
guard compressedPayloadSize >= 0 && offset + compressedPayloadSize <= dataArray.count else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let compressedPayload = Data(dataArray[offset..<offset+compressedPayloadSize])
|
||||
offset += compressedPayloadSize
|
||||
|
||||
// Decompress with error handling
|
||||
guard let decompressedPayload = CompressionUtil.decompress(compressedPayload, originalSize: originalSize) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Verify decompressed size matches expected
|
||||
guard decompressedPayload.count == originalSize else {
|
||||
return nil
|
||||
}
|
||||
|
||||
payload = decompressedPayload
|
||||
} else {
|
||||
// Uncompressed payload
|
||||
guard Int(payloadLength) >= 0 && offset + Int(payloadLength) <= dataArray.count else {
|
||||
return nil
|
||||
}
|
||||
payload = Data(dataArray[offset..<offset+Int(payloadLength)])
|
||||
offset += Int(payloadLength)
|
||||
}
|
||||
|
||||
// Signature if present
|
||||
var signature: Data?
|
||||
if hasSignature {
|
||||
guard offset + signatureSize <= dataArray.count else { return nil }
|
||||
signature = Data(dataArray[offset..<offset+signatureSize])
|
||||
offset += signatureSize
|
||||
}
|
||||
|
||||
// Final validation: ensure we haven't gone past the end
|
||||
guard offset <= dataArray.count else { return nil }
|
||||
|
||||
return BitchatPacket(
|
||||
type: type,
|
||||
senderID: senderID,
|
||||
recipientID: recipientID,
|
||||
timestamp: timestamp,
|
||||
payload: payload,
|
||||
signature: signature,
|
||||
ttl: ttl
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Binary encoding for BitchatMessage
|
||||
extension BitchatMessage {
|
||||
func toBinaryPayload() -> Data? {
|
||||
var data = Data()
|
||||
|
||||
// Message format:
|
||||
// - Flags: 1 byte (bit 0: isRelay, bit 1: isPrivate, bit 2: hasOriginalSender, bit 3: hasRecipientNickname, bit 4: hasSenderPeerID, bit 5: hasMentions)
|
||||
// - Timestamp: 8 bytes (seconds since epoch)
|
||||
// - ID length: 1 byte
|
||||
// - ID: variable
|
||||
// - Sender length: 1 byte
|
||||
// - Sender: variable
|
||||
// - Content length: 2 bytes
|
||||
// - Content: variable
|
||||
// Optional fields based on flags:
|
||||
// - Original sender length + data
|
||||
// - Recipient nickname length + data
|
||||
// - Sender peer ID length + data
|
||||
// - Mentions array
|
||||
|
||||
var flags: UInt8 = 0
|
||||
if isRelay { flags |= 0x01 }
|
||||
if isPrivate { flags |= 0x02 }
|
||||
if originalSender != nil { flags |= 0x04 }
|
||||
if recipientNickname != nil { flags |= 0x08 }
|
||||
if senderPeerID != nil { flags |= 0x10 }
|
||||
if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }
|
||||
|
||||
data.append(flags)
|
||||
|
||||
// Timestamp (in milliseconds)
|
||||
let timestampMillis = UInt64(timestamp.timeIntervalSince1970 * 1000)
|
||||
// Encode as 8 bytes, big-endian
|
||||
for i in (0..<8).reversed() {
|
||||
data.append(UInt8((timestampMillis >> (i * 8)) & 0xFF))
|
||||
}
|
||||
|
||||
// ID
|
||||
if let idData = id.data(using: .utf8) {
|
||||
data.append(UInt8(min(idData.count, 255)))
|
||||
data.append(idData.prefix(255))
|
||||
} else {
|
||||
data.append(0)
|
||||
}
|
||||
|
||||
// Sender
|
||||
if let senderData = sender.data(using: .utf8) {
|
||||
data.append(UInt8(min(senderData.count, 255)))
|
||||
data.append(senderData.prefix(255))
|
||||
} else {
|
||||
data.append(0)
|
||||
}
|
||||
|
||||
// Content
|
||||
if let contentData = content.data(using: .utf8) {
|
||||
let length = UInt16(min(contentData.count, 65535))
|
||||
// Encode length as 2 bytes, big-endian
|
||||
data.append(UInt8((length >> 8) & 0xFF))
|
||||
data.append(UInt8(length & 0xFF))
|
||||
data.append(contentData.prefix(Int(length)))
|
||||
} else {
|
||||
data.append(contentsOf: [0, 0])
|
||||
}
|
||||
|
||||
// Optional fields
|
||||
if let originalSender = originalSender, let origData = originalSender.data(using: .utf8) {
|
||||
data.append(UInt8(min(origData.count, 255)))
|
||||
data.append(origData.prefix(255))
|
||||
}
|
||||
|
||||
if let recipientNickname = recipientNickname, let recipData = recipientNickname.data(using: .utf8) {
|
||||
data.append(UInt8(min(recipData.count, 255)))
|
||||
data.append(recipData.prefix(255))
|
||||
}
|
||||
|
||||
if let senderPeerID = senderPeerID, let peerData = senderPeerID.data(using: .utf8) {
|
||||
data.append(UInt8(min(peerData.count, 255)))
|
||||
data.append(peerData.prefix(255))
|
||||
}
|
||||
|
||||
// Mentions array
|
||||
if let mentions = mentions {
|
||||
data.append(UInt8(min(mentions.count, 255))) // Number of mentions
|
||||
for mention in mentions.prefix(255) {
|
||||
if let mentionData = mention.data(using: .utf8) {
|
||||
data.append(UInt8(min(mentionData.count, 255)))
|
||||
data.append(mentionData.prefix(255))
|
||||
} else {
|
||||
data.append(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
static func fromBinaryPayload(_ data: Data) -> BitchatMessage? {
|
||||
// Create an immutable copy to prevent threading issues
|
||||
let dataCopy = Data(data)
|
||||
|
||||
|
||||
guard dataCopy.count >= 13 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var offset = 0
|
||||
|
||||
// Flags
|
||||
guard offset < dataCopy.count else {
|
||||
return nil
|
||||
}
|
||||
let flags = dataCopy[offset]; offset += 1
|
||||
let isRelay = (flags & 0x01) != 0
|
||||
let isPrivate = (flags & 0x02) != 0
|
||||
let hasOriginalSender = (flags & 0x04) != 0
|
||||
let hasRecipientNickname = (flags & 0x08) != 0
|
||||
let hasSenderPeerID = (flags & 0x10) != 0
|
||||
let hasMentions = (flags & 0x20) != 0
|
||||
|
||||
// Timestamp
|
||||
guard offset + 8 <= dataCopy.count else {
|
||||
return nil
|
||||
}
|
||||
let timestampData = dataCopy[offset..<offset+8]
|
||||
let timestampMillis = timestampData.reduce(0) { result, byte in
|
||||
(result << 8) | UInt64(byte)
|
||||
}
|
||||
offset += 8
|
||||
let timestamp = Date(timeIntervalSince1970: TimeInterval(timestampMillis) / 1000.0)
|
||||
|
||||
// ID
|
||||
guard offset < dataCopy.count else {
|
||||
return nil
|
||||
}
|
||||
let idLength = Int(dataCopy[offset]); offset += 1
|
||||
guard offset + idLength <= dataCopy.count else {
|
||||
return nil
|
||||
}
|
||||
let id = String(data: dataCopy[offset..<offset+idLength], encoding: .utf8) ?? UUID().uuidString
|
||||
offset += idLength
|
||||
|
||||
// Sender
|
||||
guard offset < dataCopy.count else {
|
||||
return nil
|
||||
}
|
||||
let senderLength = Int(dataCopy[offset]); offset += 1
|
||||
guard offset + senderLength <= dataCopy.count else {
|
||||
return nil
|
||||
}
|
||||
let sender = String(data: dataCopy[offset..<offset+senderLength], encoding: .utf8) ?? "unknown"
|
||||
offset += senderLength
|
||||
|
||||
// Content
|
||||
guard offset + 2 <= dataCopy.count else {
|
||||
return nil
|
||||
}
|
||||
let contentLengthData = dataCopy[offset..<offset+2]
|
||||
let contentLength = Int(contentLengthData.reduce(0) { result, byte in
|
||||
(result << 8) | UInt16(byte)
|
||||
})
|
||||
offset += 2
|
||||
guard offset + contentLength <= dataCopy.count else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let content = String(data: dataCopy[offset..<offset+contentLength], encoding: .utf8) ?? ""
|
||||
offset += contentLength
|
||||
|
||||
// Optional fields
|
||||
var originalSender: String?
|
||||
if hasOriginalSender && offset < dataCopy.count {
|
||||
let length = Int(dataCopy[offset]); offset += 1
|
||||
if offset + length <= dataCopy.count {
|
||||
originalSender = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
|
||||
offset += length
|
||||
}
|
||||
}
|
||||
|
||||
var recipientNickname: String?
|
||||
if hasRecipientNickname && offset < dataCopy.count {
|
||||
let length = Int(dataCopy[offset]); offset += 1
|
||||
if offset + length <= dataCopy.count {
|
||||
recipientNickname = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
|
||||
offset += length
|
||||
}
|
||||
}
|
||||
|
||||
var senderPeerID: String?
|
||||
if hasSenderPeerID && offset < dataCopy.count {
|
||||
let length = Int(dataCopy[offset]); offset += 1
|
||||
if offset + length <= dataCopy.count {
|
||||
senderPeerID = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
|
||||
offset += length
|
||||
}
|
||||
}
|
||||
|
||||
// Mentions array
|
||||
var mentions: [String]?
|
||||
if hasMentions && offset < dataCopy.count {
|
||||
let mentionCount = Int(dataCopy[offset]); offset += 1
|
||||
if mentionCount > 0 {
|
||||
mentions = []
|
||||
for _ in 0..<mentionCount {
|
||||
if offset < dataCopy.count {
|
||||
let length = Int(dataCopy[offset]); offset += 1
|
||||
if offset + length <= dataCopy.count {
|
||||
if let mention = String(data: dataCopy[offset..<offset+length], encoding: .utf8) {
|
||||
mentions?.append(mention)
|
||||
}
|
||||
offset += length
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let message = BitchatMessage(
|
||||
id: id,
|
||||
sender: sender,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
isRelay: isRelay,
|
||||
originalSender: originalSender,
|
||||
isPrivate: isPrivate,
|
||||
recipientNickname: recipientNickname,
|
||||
senderPeerID: senderPeerID,
|
||||
mentions: mentions
|
||||
)
|
||||
return message
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//
|
||||
// BitchatFilePacket.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
import BitLogger
|
||||
|
||||
/// TLV payload for Bluetooth mesh file transfers (voice notes, images, generic files).
|
||||
/// Mirrors the Android client specification to ensure cross-platform interoperability.
|
||||
struct BitchatFilePacket {
|
||||
var fileName: String?
|
||||
var fileSize: UInt64?
|
||||
var mimeType: String?
|
||||
var content: Data
|
||||
|
||||
/// Canonical TLV tags defined by the Android implementation.
|
||||
private enum TLVType: UInt8 {
|
||||
case fileName = 0x01
|
||||
case fileSize = 0x02
|
||||
case mimeType = 0x03
|
||||
case content = 0x04
|
||||
}
|
||||
|
||||
/// Encodes the packet using v2 canonical TLVs (4-byte FILE_SIZE, 4-byte CONTENT length).
|
||||
/// Returns `nil` when fields exceed protocol limits (e.g., content > UInt32.max).
|
||||
func encode() -> Data? {
|
||||
let resolvedSize = fileSize ?? UInt64(content.count)
|
||||
guard resolvedSize <= UInt64(UInt32.max) else { return nil }
|
||||
guard resolvedSize <= UInt64(FileTransferLimits.maxPayloadBytes) else { return nil }
|
||||
guard content.count <= Int(UInt32.max) else { return nil }
|
||||
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
||||
|
||||
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
|
||||
var big = value.bigEndian
|
||||
withUnsafeBytes(of: &big) { data.append(contentsOf: $0) }
|
||||
}
|
||||
|
||||
var encoded = Data()
|
||||
|
||||
if let name = fileName, let nameData = name.data(using: .utf8), nameData.count <= Int(UInt16.max) {
|
||||
encoded.append(TLVType.fileName.rawValue)
|
||||
appendBE(UInt16(nameData.count), into: &encoded)
|
||||
encoded.append(nameData)
|
||||
}
|
||||
|
||||
encoded.append(TLVType.fileSize.rawValue)
|
||||
appendBE(UInt16(4), into: &encoded)
|
||||
appendBE(UInt32(resolvedSize), into: &encoded)
|
||||
|
||||
if let mime = mimeType, let mimeData = mime.data(using: .utf8), mimeData.count <= Int(UInt16.max) {
|
||||
encoded.append(TLVType.mimeType.rawValue)
|
||||
appendBE(UInt16(mimeData.count), into: &encoded)
|
||||
encoded.append(mimeData)
|
||||
}
|
||||
|
||||
encoded.append(TLVType.content.rawValue)
|
||||
appendBE(UInt32(content.count), into: &encoded)
|
||||
encoded.append(content)
|
||||
|
||||
return encoded
|
||||
}
|
||||
|
||||
/// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible.
|
||||
static func decode(_ data: Data) -> BitchatFilePacket? {
|
||||
var cursor = data.startIndex
|
||||
let end = data.endIndex
|
||||
|
||||
var fileName: String?
|
||||
var fileSize: UInt64?
|
||||
var mimeType: String?
|
||||
var content = Data()
|
||||
|
||||
while cursor < end {
|
||||
let typeRaw = data[cursor]
|
||||
cursor = data.index(after: cursor)
|
||||
|
||||
guard cursor <= end else { return nil }
|
||||
let tlvType = TLVType(rawValue: typeRaw)
|
||||
|
||||
func readBigEndianLength(bytes: Int) -> Int? {
|
||||
guard data.distance(from: cursor, to: end) >= bytes else { return nil }
|
||||
// Use UInt64 to prevent integer overflow during shift operations
|
||||
var result: UInt64 = 0
|
||||
for _ in 0..<bytes {
|
||||
result = (result << 8) | UInt64(data[cursor])
|
||||
cursor = data.index(after: cursor)
|
||||
}
|
||||
// Safely convert to Int with overflow check
|
||||
guard result <= Int.max else { return nil }
|
||||
return Int(result)
|
||||
}
|
||||
|
||||
let length: Int?
|
||||
if tlvType == .content {
|
||||
let snapshot = cursor
|
||||
let canonical = readBigEndianLength(bytes: 4)
|
||||
if let canonical = canonical,
|
||||
canonical <= data.distance(from: cursor, to: end) {
|
||||
length = canonical
|
||||
} else {
|
||||
cursor = snapshot
|
||||
length = readBigEndianLength(bytes: 2)
|
||||
}
|
||||
} else {
|
||||
length = readBigEndianLength(bytes: 2)
|
||||
}
|
||||
|
||||
guard let tlvLength = length, tlvLength >= 0 else { return nil }
|
||||
guard data.distance(from: cursor, to: end) >= tlvLength else { return nil }
|
||||
|
||||
let valueStart = cursor
|
||||
cursor = data.index(cursor, offsetBy: tlvLength)
|
||||
let value = data[valueStart..<cursor]
|
||||
|
||||
switch tlvType {
|
||||
case .fileName:
|
||||
fileName = String(data: Data(value), encoding: .utf8)
|
||||
case .fileSize:
|
||||
if tlvLength == 4 || tlvLength == 8 {
|
||||
var size: UInt64 = 0
|
||||
for byte in value {
|
||||
size = (size << 8) | UInt64(byte)
|
||||
}
|
||||
if size > UInt64(FileTransferLimits.maxPayloadBytes) {
|
||||
return nil
|
||||
}
|
||||
fileSize = size
|
||||
}
|
||||
case .mimeType:
|
||||
mimeType = String(data: Data(value), encoding: .utf8)
|
||||
case .content:
|
||||
let proposedSize = content.count + value.count
|
||||
if proposedSize > FileTransferLimits.maxPayloadBytes {
|
||||
return nil
|
||||
}
|
||||
content.append(contentsOf: value)
|
||||
case nil:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
guard !content.isEmpty else { return nil }
|
||||
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
||||
return BitchatFilePacket(
|
||||
fileName: fileName,
|
||||
fileSize: fileSize ?? UInt64(content.count),
|
||||
mimeType: mimeType,
|
||||
content: content
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -59,91 +59,8 @@
|
||||
///
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
// MARK: - Message Padding
|
||||
|
||||
/// Provides privacy-preserving message padding to obscure actual content length.
|
||||
/// Uses PKCS#7-style padding with random bytes to prevent traffic analysis.
|
||||
struct MessagePadding {
|
||||
// Standard block sizes for padding
|
||||
static let blockSizes = [256, 512, 1024, 2048]
|
||||
|
||||
// Add PKCS#7-style padding to reach target size
|
||||
static func pad(_ data: Data, toSize targetSize: Int) -> Data {
|
||||
guard data.count < targetSize else { return data }
|
||||
|
||||
let paddingNeeded = targetSize - data.count
|
||||
// Constrain to 255 to fit a single-byte pad length marker
|
||||
guard paddingNeeded > 0 && paddingNeeded <= 255 else { return data }
|
||||
|
||||
var padded = data
|
||||
// PKCS#7: All pad bytes are equal to the pad length
|
||||
padded.append(contentsOf: Array(repeating: UInt8(paddingNeeded), count: paddingNeeded))
|
||||
return padded
|
||||
}
|
||||
|
||||
// Remove padding from data
|
||||
static func unpad(_ data: Data) -> Data {
|
||||
guard !data.isEmpty else { return data }
|
||||
let last = data.last!
|
||||
let paddingLength = Int(last)
|
||||
// Must have at least 1 pad byte and not exceed data length
|
||||
guard paddingLength > 0 && paddingLength <= data.count else { return data }
|
||||
// Verify PKCS#7: all last N bytes equal to pad length
|
||||
let start = data.count - paddingLength
|
||||
let tail = data[start...]
|
||||
for b in tail { if b != last { return data } }
|
||||
return Data(data[..<start])
|
||||
}
|
||||
|
||||
// Find optimal block size for data
|
||||
static func optimalBlockSize(for dataSize: Int) -> Int {
|
||||
// Account for encryption overhead (~16 bytes for AES-GCM tag)
|
||||
let totalSize = dataSize + 16
|
||||
|
||||
// Find smallest block that fits
|
||||
for blockSize in blockSizes {
|
||||
if totalSize <= blockSize {
|
||||
return blockSize
|
||||
}
|
||||
}
|
||||
|
||||
// For very large messages, just use the original size
|
||||
// (will be fragmented anyway)
|
||||
return dataSize
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Message Types
|
||||
|
||||
/// Simplified BitChat protocol message types.
|
||||
/// Reduced from 24 types to just 6 essential ones.
|
||||
/// All private communication metadata (receipts, status) is embedded in noiseEncrypted payloads.
|
||||
enum MessageType: UInt8 {
|
||||
// Public messages (unencrypted)
|
||||
case announce = 0x01 // "I'm here" with nickname
|
||||
case message = 0x02 // Public chat message
|
||||
case leave = 0x03 // "I'm leaving"
|
||||
|
||||
// Noise encryption
|
||||
case noiseHandshake = 0x10 // Handshake (init or response determined by payload)
|
||||
case noiseEncrypted = 0x11 // All encrypted payloads (messages, receipts, etc.)
|
||||
|
||||
// Fragmentation (simplified)
|
||||
case fragment = 0x20 // Single fragment type for large messages
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .announce: return "announce"
|
||||
case .message: return "message"
|
||||
case .leave: return "leave"
|
||||
case .noiseHandshake: return "noiseHandshake"
|
||||
case .noiseEncrypted: return "noiseEncrypted"
|
||||
case .fragment: return "fragment"
|
||||
}
|
||||
}
|
||||
}
|
||||
import CoreBluetooth
|
||||
import BitFoundation
|
||||
|
||||
// MARK: - Noise Payload Types
|
||||
|
||||
@@ -181,300 +98,25 @@ enum LazyHandshakeState {
|
||||
case failed(Error) // Handshake failed
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
// MARK: - Core Protocol Structures
|
||||
|
||||
/// The core packet structure for all BitChat protocol messages.
|
||||
/// Encapsulates all data needed for routing through the mesh network,
|
||||
/// including TTL for hop limiting and optional encryption.
|
||||
/// - Note: Packets larger than BLE MTU (512 bytes) are automatically fragmented
|
||||
struct BitchatPacket: Codable {
|
||||
let version: UInt8
|
||||
let type: UInt8
|
||||
let senderID: Data
|
||||
let recipientID: Data?
|
||||
let timestamp: UInt64
|
||||
let payload: Data
|
||||
var signature: Data?
|
||||
var ttl: UInt8
|
||||
|
||||
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) {
|
||||
self.version = 1
|
||||
self.type = type
|
||||
self.senderID = senderID
|
||||
self.recipientID = recipientID
|
||||
self.timestamp = timestamp
|
||||
self.payload = payload
|
||||
self.signature = signature
|
||||
self.ttl = ttl
|
||||
}
|
||||
|
||||
// Convenience initializer for new binary format
|
||||
init(type: UInt8, ttl: UInt8, senderID: String, payload: Data) {
|
||||
self.version = 1
|
||||
self.type = type
|
||||
// Convert hex string peer ID to binary data (8 bytes)
|
||||
var senderData = Data()
|
||||
var tempID = senderID
|
||||
while tempID.count >= 2 {
|
||||
let hexByte = String(tempID.prefix(2))
|
||||
if let byte = UInt8(hexByte, radix: 16) {
|
||||
senderData.append(byte)
|
||||
}
|
||||
tempID = String(tempID.dropFirst(2))
|
||||
}
|
||||
self.senderID = senderData
|
||||
self.recipientID = nil
|
||||
self.timestamp = UInt64(Date().timeIntervalSince1970 * 1000) // milliseconds
|
||||
self.payload = payload
|
||||
self.signature = nil
|
||||
self.ttl = ttl
|
||||
}
|
||||
|
||||
var data: Data? {
|
||||
BinaryProtocol.encode(self)
|
||||
}
|
||||
|
||||
func toBinaryData(padding: Bool = true) -> Data? {
|
||||
BinaryProtocol.encode(self, padding: padding)
|
||||
}
|
||||
|
||||
// Backward-compatible helper (defaults to padded encoding)
|
||||
func toBinaryData() -> Data? {
|
||||
toBinaryData(padding: true)
|
||||
}
|
||||
|
||||
/// Create binary representation for signing (without signature and TTL fields)
|
||||
/// TTL is excluded because it changes during packet relay operations
|
||||
func toBinaryDataForSigning() -> Data? {
|
||||
// Create a copy without signature and with fixed TTL for signing
|
||||
// TTL must be excluded because it changes during relay
|
||||
let unsignedPacket = BitchatPacket(
|
||||
type: type,
|
||||
senderID: senderID,
|
||||
recipientID: recipientID,
|
||||
timestamp: timestamp,
|
||||
payload: payload,
|
||||
signature: nil, // Remove signature for signing
|
||||
ttl: 0 // Use fixed TTL=0 for signing to ensure relay compatibility
|
||||
)
|
||||
return BinaryProtocol.encode(unsignedPacket)
|
||||
}
|
||||
|
||||
static func from(_ data: Data) -> BitchatPacket? {
|
||||
BinaryProtocol.decode(data)
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
// MARK: - Read Receipts
|
||||
|
||||
// Read receipt structure
|
||||
struct ReadReceipt: Codable {
|
||||
let originalMessageID: String
|
||||
let receiptID: String
|
||||
var readerID: String // Who read it
|
||||
let readerNickname: String
|
||||
let timestamp: Date
|
||||
|
||||
init(originalMessageID: String, readerID: String, readerNickname: String) {
|
||||
self.originalMessageID = originalMessageID
|
||||
self.receiptID = UUID().uuidString
|
||||
self.readerID = readerID
|
||||
self.readerNickname = readerNickname
|
||||
self.timestamp = Date()
|
||||
}
|
||||
|
||||
// For binary decoding
|
||||
private init(originalMessageID: String, receiptID: String, readerID: String, readerNickname: String, timestamp: Date) {
|
||||
self.originalMessageID = originalMessageID
|
||||
self.receiptID = receiptID
|
||||
self.readerID = readerID
|
||||
self.readerNickname = readerNickname
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
|
||||
func encode() -> Data? {
|
||||
try? JSONEncoder().encode(self)
|
||||
}
|
||||
|
||||
static func decode(from data: Data) -> ReadReceipt? {
|
||||
try? JSONDecoder().decode(ReadReceipt.self, from: data)
|
||||
}
|
||||
|
||||
// MARK: - Binary Encoding
|
||||
|
||||
func toBinaryData() -> Data {
|
||||
var data = Data()
|
||||
data.appendUUID(originalMessageID)
|
||||
data.appendUUID(receiptID)
|
||||
// ReaderID as 8-byte hex string
|
||||
var readerData = Data()
|
||||
var tempID = readerID
|
||||
while tempID.count >= 2 && readerData.count < 8 {
|
||||
let hexByte = String(tempID.prefix(2))
|
||||
if let byte = UInt8(hexByte, radix: 16) {
|
||||
readerData.append(byte)
|
||||
}
|
||||
tempID = String(tempID.dropFirst(2))
|
||||
}
|
||||
while readerData.count < 8 {
|
||||
readerData.append(0)
|
||||
}
|
||||
data.append(readerData)
|
||||
data.appendDate(timestamp)
|
||||
data.appendString(readerNickname)
|
||||
return data
|
||||
}
|
||||
|
||||
static func fromBinaryData(_ data: Data) -> ReadReceipt? {
|
||||
// Create defensive copy
|
||||
let dataCopy = Data(data)
|
||||
|
||||
// Minimum size: 2 UUIDs (32) + readerID (8) + timestamp (8) + min nickname
|
||||
guard dataCopy.count >= 49 else { return nil }
|
||||
|
||||
var offset = 0
|
||||
|
||||
guard let originalMessageID = dataCopy.readUUID(at: &offset),
|
||||
let receiptID = dataCopy.readUUID(at: &offset) else { return nil }
|
||||
|
||||
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
||||
let readerID = readerIDData.hexEncodedString()
|
||||
guard InputValidator.validatePeerID(readerID) else { return nil }
|
||||
|
||||
guard let timestamp = dataCopy.readDate(at: &offset),
|
||||
InputValidator.validateTimestamp(timestamp),
|
||||
let readerNicknameRaw = dataCopy.readString(at: &offset),
|
||||
let readerNickname = InputValidator.validateNickname(readerNicknameRaw) else { return nil }
|
||||
|
||||
return ReadReceipt(originalMessageID: originalMessageID,
|
||||
receiptID: receiptID,
|
||||
readerID: readerID,
|
||||
readerNickname: readerNickname,
|
||||
timestamp: timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
|
||||
|
||||
// MARK: - Delivery Status
|
||||
|
||||
// Delivery status for messages
|
||||
enum DeliveryStatus: Codable, Equatable {
|
||||
case sending
|
||||
case sent // Left our device
|
||||
case delivered(to: String, at: Date) // Confirmed by recipient
|
||||
case read(by: String, at: Date) // Seen by recipient
|
||||
case failed(reason: String)
|
||||
case partiallyDelivered(reached: Int, total: Int) // For rooms
|
||||
|
||||
var displayText: String {
|
||||
switch self {
|
||||
case .sending:
|
||||
return "Sending..."
|
||||
case .sent:
|
||||
return "Sent"
|
||||
case .delivered(let nickname, _):
|
||||
return "Delivered to \(nickname)"
|
||||
case .read(let nickname, _):
|
||||
return "Read by \(nickname)"
|
||||
case .failed(let reason):
|
||||
return "Failed: \(reason)"
|
||||
case .partiallyDelivered(let reached, let total):
|
||||
return "Delivered to \(reached)/\(total)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Message Model
|
||||
|
||||
/// Represents a user-visible message in the BitChat system.
|
||||
/// Handles both broadcast messages and private encrypted messages,
|
||||
/// with support for mentions, replies, and delivery tracking.
|
||||
/// - Note: This is the primary data model for chat messages
|
||||
class BitchatMessage: Codable {
|
||||
let id: String
|
||||
let sender: String
|
||||
let content: String
|
||||
let timestamp: Date
|
||||
let isRelay: Bool
|
||||
let originalSender: String?
|
||||
let isPrivate: Bool
|
||||
let recipientNickname: String?
|
||||
let senderPeerID: String?
|
||||
let mentions: [String]? // Array of mentioned nicknames
|
||||
var deliveryStatus: DeliveryStatus? // Delivery tracking
|
||||
|
||||
// Cached formatted text (not included in Codable)
|
||||
private var _cachedFormattedText: [String: AttributedString] = [:]
|
||||
|
||||
func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? {
|
||||
return _cachedFormattedText["\(isDark)-\(isSelf)"]
|
||||
}
|
||||
|
||||
func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) {
|
||||
_cachedFormattedText["\(isDark)-\(isSelf)"] = text
|
||||
}
|
||||
|
||||
// Codable implementation
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, sender, content, timestamp, isRelay, originalSender
|
||||
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
|
||||
}
|
||||
|
||||
init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, deliveryStatus: DeliveryStatus? = nil) {
|
||||
self.id = id ?? UUID().uuidString
|
||||
self.sender = sender
|
||||
self.content = content
|
||||
self.timestamp = timestamp
|
||||
self.isRelay = isRelay
|
||||
self.originalSender = originalSender
|
||||
self.isPrivate = isPrivate
|
||||
self.recipientNickname = recipientNickname
|
||||
self.senderPeerID = senderPeerID
|
||||
self.mentions = mentions
|
||||
self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Equatable conformance for BitchatMessage
|
||||
extension BitchatMessage: Equatable {
|
||||
static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool {
|
||||
return lhs.id == rhs.id &&
|
||||
lhs.sender == rhs.sender &&
|
||||
lhs.content == rhs.content &&
|
||||
lhs.timestamp == rhs.timestamp &&
|
||||
lhs.isRelay == rhs.isRelay &&
|
||||
lhs.originalSender == rhs.originalSender &&
|
||||
lhs.isPrivate == rhs.isPrivate &&
|
||||
lhs.recipientNickname == rhs.recipientNickname &&
|
||||
lhs.senderPeerID == rhs.senderPeerID &&
|
||||
lhs.mentions == rhs.mentions &&
|
||||
lhs.deliveryStatus == rhs.deliveryStatus
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Delegate Protocol
|
||||
|
||||
protocol BitchatDelegate: AnyObject {
|
||||
func didReceiveMessage(_ message: BitchatMessage)
|
||||
func didConnectToPeer(_ peerID: String)
|
||||
func didDisconnectFromPeer(_ peerID: String)
|
||||
func didUpdatePeerList(_ peers: [String])
|
||||
|
||||
func didConnectToPeer(_ peerID: PeerID)
|
||||
func didDisconnectFromPeer(_ peerID: PeerID)
|
||||
func didUpdatePeerList(_ peers: [PeerID])
|
||||
|
||||
// Optional method to check if a fingerprint belongs to a favorite peer
|
||||
func isFavorite(fingerprint: String) -> Bool
|
||||
|
||||
|
||||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus)
|
||||
|
||||
// Low-level events for better separation of concerns
|
||||
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date)
|
||||
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date)
|
||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
|
||||
|
||||
// Bluetooth state updates for user notifications
|
||||
func didUpdateBluetoothState(_ state: CBManagerState)
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
||||
}
|
||||
|
||||
// Provide default implementation to make it effectively optional
|
||||
@@ -487,45 +129,11 @@ extension BitchatDelegate {
|
||||
// Default empty implementation
|
||||
}
|
||||
|
||||
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date) {
|
||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {
|
||||
// Default empty implementation
|
||||
}
|
||||
|
||||
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||
// Default empty implementation
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Noise Payload Helpers
|
||||
|
||||
/// Helper to create typed Noise payloads
|
||||
struct NoisePayload {
|
||||
let type: NoisePayloadType
|
||||
let data: Data
|
||||
|
||||
/// Encode payload with type prefix
|
||||
func encode() -> Data {
|
||||
var encoded = Data()
|
||||
encoded.append(type.rawValue)
|
||||
encoded.append(data)
|
||||
return encoded
|
||||
}
|
||||
|
||||
/// Decode payload from data
|
||||
static func decode(_ data: Data) -> NoisePayload? {
|
||||
// Ensure we have at least 1 byte for the type
|
||||
guard !data.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Safely get the first byte
|
||||
let firstByte = data[data.startIndex]
|
||||
guard let type = NoisePayloadType(rawValue: firstByte) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create a proper Data copy (not a subsequence) for thread safety
|
||||
let payloadData = data.count > 1 ? Data(data.dropFirst()) : Data()
|
||||
return NoisePayload(type: type, data: payloadData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,14 @@ enum Geohash {
|
||||
return map
|
||||
}()
|
||||
|
||||
/// Validates a geohash string for building-level precision (8 characters).
|
||||
/// - Parameter geohash: The geohash string to validate
|
||||
/// - Returns: true if valid 8-character base32 geohash, false otherwise
|
||||
static func isValidBuildingGeohash(_ geohash: String) -> Bool {
|
||||
guard geohash.count == 8 else { return false }
|
||||
return geohash.lowercased().allSatisfy { base32Map[$0] != nil }
|
||||
}
|
||||
|
||||
/// Encodes the provided coordinates into a geohash string.
|
||||
/// - Parameters:
|
||||
/// - latitude: Latitude in degrees (-90...90)
|
||||
@@ -87,4 +95,81 @@ enum Geohash {
|
||||
let lon = (lonInterval.0 + lonInterval.1) / 2
|
||||
return (lat, lon)
|
||||
}
|
||||
|
||||
/// Decodes a geohash into its latitude and longitude bounds.
|
||||
/// - Parameter geohash: Base32 geohash string.
|
||||
/// - Returns: (latMin, latMax, lonMin, lonMax)
|
||||
static func decodeBounds(_ geohash: String) -> (latMin: Double, latMax: Double, lonMin: Double, lonMax: Double) {
|
||||
var latInterval: (Double, Double) = (-90.0, 90.0)
|
||||
var lonInterval: (Double, Double) = (-180.0, 180.0)
|
||||
|
||||
var isEven = true
|
||||
for ch in geohash.lowercased() {
|
||||
guard let cd = base32Map[ch] else { continue }
|
||||
for mask in [16, 8, 4, 2, 1] {
|
||||
if isEven {
|
||||
let mid = (lonInterval.0 + lonInterval.1) / 2
|
||||
if (cd & mask) != 0 { lonInterval.0 = mid } else { lonInterval.1 = mid }
|
||||
} else {
|
||||
let mid = (latInterval.0 + latInterval.1) / 2
|
||||
if (cd & mask) != 0 { latInterval.0 = mid } else { latInterval.1 = mid }
|
||||
}
|
||||
isEven.toggle()
|
||||
}
|
||||
}
|
||||
return (latInterval.0, latInterval.1, lonInterval.0, lonInterval.1)
|
||||
}
|
||||
|
||||
/// Returns all 8 neighboring geohash cells at the same precision.
|
||||
/// - Parameter geohash: Base32 geohash string.
|
||||
/// - Returns: Array of 8 neighboring geohashes (N, NE, E, SE, S, SW, W, NW order).
|
||||
static func neighbors(of geohash: String) -> [String] {
|
||||
guard !geohash.isEmpty else { return [] }
|
||||
|
||||
let precision = geohash.count
|
||||
let bounds = decodeBounds(geohash)
|
||||
let center = decodeCenter(geohash)
|
||||
|
||||
// Calculate cell dimensions
|
||||
let latHeight = bounds.latMax - bounds.latMin
|
||||
let lonWidth = bounds.lonMax - bounds.lonMin
|
||||
|
||||
// Helper to wrap longitude around ±180
|
||||
func wrapLongitude(_ lon: Double) -> Double {
|
||||
var wrapped = lon
|
||||
while wrapped > 180.0 { wrapped -= 360.0 }
|
||||
while wrapped < -180.0 { wrapped += 360.0 }
|
||||
return wrapped
|
||||
}
|
||||
|
||||
// Helper to clamp latitude to ±90
|
||||
func clampLatitude(_ lat: Double) -> Double {
|
||||
return max(-90.0, min(90.0, lat))
|
||||
}
|
||||
|
||||
// Calculate 8 neighbor centers
|
||||
let neighbors: [(lat: Double, lon: Double)] = [
|
||||
(center.lat + latHeight, center.lon), // N
|
||||
(center.lat + latHeight, center.lon + lonWidth), // NE
|
||||
(center.lat, center.lon + lonWidth), // E
|
||||
(center.lat - latHeight, center.lon + lonWidth), // SE
|
||||
(center.lat - latHeight, center.lon), // S
|
||||
(center.lat - latHeight, center.lon - lonWidth), // SW
|
||||
(center.lat, center.lon - lonWidth), // W
|
||||
(center.lat + latHeight, center.lon - lonWidth) // NW
|
||||
]
|
||||
|
||||
// Encode each neighbor, handling boundary conditions
|
||||
return neighbors.compactMap { neighbor in
|
||||
let lat = clampLatitude(neighbor.lat)
|
||||
let lon = wrapLongitude(neighbor.lon)
|
||||
|
||||
// Skip if we've crossed a pole (latitude clamped to boundary)
|
||||
if (neighbor.lat > 90.0 || neighbor.lat < -90.0) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return encode(latitude: lat, longitude: lon, precision: precision)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import Foundation
|
||||
|
||||
/// Levels of location channels mapped to geohash precisions.
|
||||
enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
||||
case building
|
||||
case block
|
||||
case neighborhood
|
||||
case city
|
||||
@@ -11,6 +12,7 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
||||
/// Geohash length used for this level.
|
||||
var precision: Int {
|
||||
switch self {
|
||||
case .building: return 8
|
||||
case .block: return 7
|
||||
case .neighborhood: return 6
|
||||
case .city: return 5
|
||||
@@ -21,20 +23,28 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .block: return "Block"
|
||||
case .neighborhood: return "Neighborhood"
|
||||
case .city: return "City"
|
||||
case .province: return "Province"
|
||||
case .region: return "Region"
|
||||
case .building:
|
||||
return String(localized: "location_levels.building", comment: "Name for building-level location channel")
|
||||
case .block:
|
||||
return String(localized: "location_levels.block", comment: "Name for block-level location channel")
|
||||
case .neighborhood:
|
||||
return String(localized: "location_levels.neighborhood", comment: "Name for neighborhood-level location channel")
|
||||
case .city:
|
||||
return String(localized: "location_levels.city", comment: "Name for city-level location channel")
|
||||
case .province:
|
||||
return String(localized: "location_levels.province", comment: "Name for province-level location channel")
|
||||
case .region:
|
||||
return String(localized: "location_levels.region", comment: "Name for region-level location channel")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Backward-compatible Codable for renamed cases
|
||||
extension GeohashChannelLevel {
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if let raw = try? container.decode(String.self) {
|
||||
switch raw {
|
||||
case "building": self = .building
|
||||
case "block": self = .block
|
||||
case "neighborhood": self = .neighborhood
|
||||
case "city": self = .city
|
||||
@@ -46,6 +56,7 @@ extension GeohashChannelLevel {
|
||||
}
|
||||
} else if let precision = try? container.decode(Int.self) {
|
||||
switch precision {
|
||||
case 8: self = .building
|
||||
case 7: self = .block
|
||||
case 6: self = .neighborhood
|
||||
case 5: self = .city
|
||||
@@ -61,6 +72,7 @@ extension GeohashChannelLevel {
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
switch self {
|
||||
case .building: try container.encode("building")
|
||||
case .block: try container.encode("block")
|
||||
case .neighborhood: try container.encode("neighborhood")
|
||||
case .city: try container.encode("city")
|
||||
@@ -104,4 +116,18 @@ enum ChannelID: Equatable, Codable {
|
||||
case .location(let ch): return ch.geohash
|
||||
}
|
||||
}
|
||||
|
||||
var isMesh: Bool {
|
||||
switch self {
|
||||
case .mesh: true
|
||||
case .location: false
|
||||
}
|
||||
}
|
||||
|
||||
var isLocation: Bool {
|
||||
switch self {
|
||||
case .mesh: false
|
||||
case .location: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,15 +6,19 @@ struct AnnouncementPacket {
|
||||
let nickname: String
|
||||
let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement)
|
||||
let signingPublicKey: Data // Ed25519 public key for signing
|
||||
let directNeighbors: [Data]? // 8-byte peer IDs
|
||||
|
||||
private enum TLVType: UInt8 {
|
||||
case nickname = 0x01
|
||||
case noisePublicKey = 0x02
|
||||
case signingPublicKey = 0x03
|
||||
case directNeighbors = 0x04
|
||||
}
|
||||
|
||||
func encode() -> Data? {
|
||||
var data = Data()
|
||||
// Reserve: TLVs for nickname (2 + n), noise key (2 + 32), signing key (2 + 32)
|
||||
data.reserveCapacity(2 + min(nickname.count, 255) + 2 + noisePublicKey.count + 2 + signingPublicKey.count)
|
||||
|
||||
// TLV for nickname
|
||||
guard let nicknameData = nickname.data(using: .utf8), nicknameData.count <= 255 else { return nil }
|
||||
@@ -33,6 +37,16 @@ struct AnnouncementPacket {
|
||||
data.append(TLVType.signingPublicKey.rawValue)
|
||||
data.append(UInt8(signingPublicKey.count))
|
||||
data.append(signingPublicKey)
|
||||
|
||||
// TLV for direct neighbors (optional)
|
||||
if let neighbors = directNeighbors, !neighbors.isEmpty {
|
||||
let neighborsData = neighbors.prefix(10).reduce(Data()) { $0 + $1 }
|
||||
if !neighborsData.isEmpty && neighborsData.count % 8 == 0 {
|
||||
data.append(TLVType.directNeighbors.rawValue)
|
||||
data.append(UInt8(neighborsData.count))
|
||||
data.append(neighborsData)
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -42,6 +56,7 @@ struct AnnouncementPacket {
|
||||
var nickname: String?
|
||||
var noisePublicKey: Data?
|
||||
var signingPublicKey: Data?
|
||||
var directNeighbors: [Data]?
|
||||
|
||||
while offset + 2 <= data.count {
|
||||
let typeRaw = data[offset]
|
||||
@@ -61,6 +76,17 @@ struct AnnouncementPacket {
|
||||
noisePublicKey = Data(value)
|
||||
case .signingPublicKey:
|
||||
signingPublicKey = Data(value)
|
||||
case .directNeighbors:
|
||||
if length > 0 && length % 8 == 0 {
|
||||
var neighbors = [Data]()
|
||||
let count = length / 8
|
||||
for i in 0..<count {
|
||||
let start = value.startIndex + i * 8
|
||||
let end = start + 8
|
||||
neighbors.append(Data(value[start..<end]))
|
||||
}
|
||||
directNeighbors = neighbors
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Unknown TLV; skip (tolerant decoder for forward compatibility)
|
||||
@@ -72,7 +98,8 @@ struct AnnouncementPacket {
|
||||
return AnnouncementPacket(
|
||||
nickname: nickname,
|
||||
noisePublicKey: noisePublicKey,
|
||||
signingPublicKey: signingPublicKey
|
||||
signingPublicKey: signingPublicKey,
|
||||
directNeighbors: directNeighbors
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -88,6 +115,7 @@ struct PrivateMessagePacket {
|
||||
|
||||
func encode() -> Data? {
|
||||
var data = Data()
|
||||
data.reserveCapacity(2 + min(messageID.count, 255) + 2 + min(content.count, 255))
|
||||
|
||||
// TLV for messageID
|
||||
guard let messageIDData = messageID.data(using: .utf8), messageIDData.count <= 255 else { return nil }
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
// MARK: - Peer ID Utilities
|
||||
|
||||
struct PeerIDUtils {
|
||||
/// Derive the stable 16-hex peer ID from a Noise static public key
|
||||
static func derivePeerID(fromPublicKey publicKey: Data) -> String {
|
||||
let digest = SHA256.hash(data: publicKey)
|
||||
let hex = digest.map { String(format: "%02x", $0) }.joined()
|
||||
return String(hex.prefix(16))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import Foundation
|
||||
|
||||
/// Manages autocomplete functionality for chat
|
||||
class AutocompleteService {
|
||||
final class AutocompleteService {
|
||||
private let mentionRegex = try? NSRegularExpression(pattern: "@([\\p{L}0-9_]*)$", options: [])
|
||||
private let commandRegex = try? NSRegularExpression(pattern: "^/([a-z]*)$", options: [])
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import BitFoundation
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
/// Narrow environment for `BLEAnnounceHandler`.
|
||||
///
|
||||
/// All queue hops (collections barrier, BLE-queue link-state reads, main-actor
|
||||
/// UI notification, delayed re-announce) live inside the closures supplied by
|
||||
/// `BLEService`, keeping the handler queue-agnostic and synchronously testable.
|
||||
struct BLEAnnounceHandlerEnvironment {
|
||||
/// Local peer identity at the time the announce is handled.
|
||||
let localPeerID: () -> PeerID
|
||||
/// TTL value used for direct (non-relayed) packets.
|
||||
let messageTTL: UInt8
|
||||
/// Current time source.
|
||||
let now: () -> Date
|
||||
/// Noise public key already recorded for the peer, if any (registry read).
|
||||
let existingNoisePublicKey: (PeerID) -> Data?
|
||||
/// Verifies the packet signature against the announced signing key.
|
||||
let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
||||
/// Direct link state for the peer (BLE-queue read).
|
||||
let linkState: (PeerID) -> (hasPeripheral: Bool, hasCentral: Bool)
|
||||
/// Runs the registry mutation phase under the collections barrier.
|
||||
let withRegistryBarrier: (() -> Void) -> Void
|
||||
/// Upserts the verified announce into the peer registry.
|
||||
/// Must only be called from inside `withRegistryBarrier`.
|
||||
let upsertVerifiedAnnounce: (
|
||||
_ peerID: PeerID,
|
||||
_ announcement: AnnouncementPacket,
|
||||
_ isConnected: Bool,
|
||||
_ now: Date
|
||||
) -> BLEPeerAnnounceUpdate
|
||||
/// Debounced reconnect-log decision.
|
||||
/// Must only be called from inside `withRegistryBarrier`.
|
||||
let shouldEmitReconnectLog: (_ peerID: PeerID, _ now: Date) -> Bool
|
||||
/// Records verified direct-neighbor claims in the mesh topology.
|
||||
let updateTopology: (_ peerID: PeerID, _ neighbors: [Data]) -> Void
|
||||
/// Persists the announced cryptographic identity for offline verification.
|
||||
let persistIdentity: (AnnouncementPacket) -> Void
|
||||
/// Announce-back dedup check.
|
||||
let dedupContains: (String) -> Bool
|
||||
/// Announce-back dedup marking.
|
||||
let dedupMarkProcessed: (String) -> Void
|
||||
/// Delivers the announce UI events as one ordered main-actor hop:
|
||||
/// `.peerConnected` (if flagged) → initial gossip sync scheduling (if
|
||||
/// flagged) → peer-ID snapshot + data publish + `.peerListUpdated`.
|
||||
/// A single closure keeps the original in-order delivery guarantee that
|
||||
/// separate unstructured tasks would not provide.
|
||||
let deliverAnnounceUIEvents: (
|
||||
_ peerID: PeerID,
|
||||
_ notifyPeerConnected: Bool,
|
||||
_ scheduleInitialSync: Bool
|
||||
) -> Void
|
||||
/// Tracks the announce packet for gossip sync.
|
||||
let trackPacketSeen: (BitchatPacket) -> Void
|
||||
/// Reciprocates the announce for bidirectional discovery.
|
||||
let sendAnnounceBack: () -> Void
|
||||
/// Schedules a delayed re-announce (afterglow) after the given delay.
|
||||
let scheduleAfterglow: (TimeInterval) -> Void
|
||||
}
|
||||
|
||||
/// Orchestrates inbound announce packets: preflight validation, signature
|
||||
/// trust, registry/topology updates, identity persistence, UI notification,
|
||||
/// gossip tracking, and the reciprocal announce response.
|
||||
final class BLEAnnounceHandler {
|
||||
private let environment: BLEAnnounceHandlerEnvironment
|
||||
|
||||
init(environment: BLEAnnounceHandlerEnvironment) {
|
||||
self.environment = environment
|
||||
}
|
||||
|
||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
let env = environment
|
||||
let now = env.now()
|
||||
let preflight = BLEAnnouncePreflightPolicy.evaluate(
|
||||
packet: packet,
|
||||
from: peerID,
|
||||
localPeerID: env.localPeerID(),
|
||||
now: now
|
||||
)
|
||||
|
||||
let announcement: AnnouncementPacket
|
||||
switch preflight {
|
||||
case .accept(let acceptance):
|
||||
announcement = acceptance.announcement
|
||||
case .reject(.malformed):
|
||||
SecureLogger.error("❌ Failed to decode announce packet from \(peerID.id.prefix(8))…", category: .session)
|
||||
return
|
||||
case .reject(.senderMismatch(let derivedFromKey)):
|
||||
SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))…", category: .security)
|
||||
return
|
||||
case .reject(.selfAnnounce):
|
||||
return
|
||||
case .reject(.stale(let ageSeconds)):
|
||||
SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
// Suppress announce logs to reduce noise
|
||||
|
||||
// Precompute signature verification outside barrier to reduce contention
|
||||
let existingNoisePublicKey = env.existingNoisePublicKey(peerID)
|
||||
let hasSignature = packet.signature != nil
|
||||
let signatureValid: Bool
|
||||
if hasSignature {
|
||||
signatureValid = env.verifySignature(packet, announcement.signingPublicKey)
|
||||
if !signatureValid {
|
||||
SecureLogger.warning("⚠️ Signature verification for announce failed \(peerID.id.prefix(8))", category: .security)
|
||||
}
|
||||
} else {
|
||||
signatureValid = false
|
||||
}
|
||||
let trustDecision = BLEAnnounceTrustPolicy.evaluate(
|
||||
hasSignature: hasSignature,
|
||||
signatureValid: signatureValid,
|
||||
existingNoisePublicKey: existingNoisePublicKey,
|
||||
announcedNoisePublicKey: announcement.noisePublicKey
|
||||
)
|
||||
if case .reject(.keyMismatch) = trustDecision {
|
||||
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security)
|
||||
}
|
||||
let verifiedAnnounce = trustDecision.isVerified
|
||||
|
||||
var isNewPeer = false
|
||||
var isReconnectedPeer = false
|
||||
let directLinkState = env.linkState(peerID)
|
||||
let isDirectAnnounce = packet.ttl == env.messageTTL
|
||||
|
||||
env.withRegistryBarrier {
|
||||
let hasPeripheralConnection = directLinkState.hasPeripheral
|
||||
let hasCentralSubscription = directLinkState.hasCentral
|
||||
|
||||
// Require verified announce; ignore otherwise (no backward compatibility)
|
||||
if !verifiedAnnounce {
|
||||
SecureLogger.warning("❌ Ignoring unverified announce from \(peerID.id.prefix(8))…", category: .security)
|
||||
// Reset flags to prevent post-barrier code from acting on unverified announces
|
||||
isNewPeer = false
|
||||
isReconnectedPeer = false
|
||||
return
|
||||
}
|
||||
|
||||
let update = env.upsertVerifiedAnnounce(
|
||||
peerID,
|
||||
announcement,
|
||||
isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
|
||||
now
|
||||
)
|
||||
isNewPeer = update.isNewPeer
|
||||
isReconnectedPeer = update.wasDisconnected
|
||||
|
||||
// Log connection status only for direct connectivity changes; debounce to reduce spam
|
||||
if isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription {
|
||||
let now = env.now()
|
||||
if update.isNewPeer {
|
||||
SecureLogger.debug("🆕 New peer: \(announcement.nickname)", category: .session)
|
||||
} else if update.wasDisconnected {
|
||||
if env.shouldEmitReconnectLog(peerID, now) {
|
||||
SecureLogger.debug("🔄 Peer \(announcement.nickname) reconnected", category: .session)
|
||||
}
|
||||
} else if let previousNickname = update.previousNickname, previousNickname != announcement.nickname {
|
||||
SecureLogger.debug("🔄 Peer \(peerID.id.prefix(8))… changed nickname: \(previousNickname) -> \(announcement.nickname)", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update topology with verified neighbor claims (only for authenticated announces)
|
||||
if verifiedAnnounce, let neighbors = announcement.directNeighbors {
|
||||
env.updateTopology(peerID, neighbors)
|
||||
}
|
||||
|
||||
// Persist cryptographic identity and signing key for robust offline verification
|
||||
env.persistIdentity(announcement)
|
||||
|
||||
let announceBackID = "announce-back-\(peerID)"
|
||||
let shouldSendBack = !env.dedupContains(announceBackID)
|
||||
if shouldSendBack {
|
||||
env.dedupMarkProcessed(announceBackID)
|
||||
}
|
||||
let responsePlan = BLEAnnounceResponsePolicy.plan(
|
||||
isDirectAnnounce: isDirectAnnounce,
|
||||
isNewPeer: isNewPeer,
|
||||
isReconnectedPeer: isReconnectedPeer,
|
||||
shouldSendAnnounceBack: shouldSendBack
|
||||
)
|
||||
|
||||
// Only notify of connection for new or reconnected peers when it is a
|
||||
// direct announce; the list update always follows in the same hop.
|
||||
env.deliverAnnounceUIEvents(
|
||||
peerID,
|
||||
responsePlan.shouldNotifyPeerConnected,
|
||||
responsePlan.shouldNotifyPeerConnected && responsePlan.shouldScheduleInitialSync
|
||||
)
|
||||
|
||||
// Track for sync (include our own and others' announces)
|
||||
env.trackPacketSeen(packet)
|
||||
|
||||
if responsePlan.shouldSendAnnounceBack {
|
||||
// Reciprocate announce for bidirectional discovery
|
||||
// Force send to ensure the peer receives our announce
|
||||
env.sendAnnounceBack()
|
||||
}
|
||||
|
||||
// Afterglow: on first-seen peers, schedule a short re-announce to push presence one more hop
|
||||
if responsePlan.shouldScheduleAfterglow {
|
||||
let delay = Double.random(in: 0.3...0.6)
|
||||
env.scheduleAfterglow(delay)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct BLEAnnouncePreflightAcceptance {
|
||||
let announcement: AnnouncementPacket
|
||||
let derivedPeerID: PeerID
|
||||
}
|
||||
|
||||
enum BLEAnnouncePreflightRejection: Equatable {
|
||||
case malformed
|
||||
case senderMismatch(derivedPeerID: PeerID)
|
||||
case selfAnnounce
|
||||
case stale(ageSeconds: Double)
|
||||
}
|
||||
|
||||
enum BLEAnnouncePreflightDecision {
|
||||
case accept(BLEAnnouncePreflightAcceptance)
|
||||
case reject(BLEAnnouncePreflightRejection)
|
||||
}
|
||||
|
||||
enum BLEAnnouncePreflightPolicy {
|
||||
static func evaluate(
|
||||
packet: BitchatPacket,
|
||||
from peerID: PeerID,
|
||||
localPeerID: PeerID,
|
||||
now: Date
|
||||
) -> BLEAnnouncePreflightDecision {
|
||||
guard let announcement = AnnouncementPacket.decode(from: packet.payload) else {
|
||||
return .reject(.malformed)
|
||||
}
|
||||
|
||||
let derivedPeerID = PeerID(publicKey: announcement.noisePublicKey)
|
||||
guard derivedPeerID == peerID else {
|
||||
return .reject(.senderMismatch(derivedPeerID: derivedPeerID))
|
||||
}
|
||||
|
||||
guard peerID != localPeerID else {
|
||||
return .reject(.selfAnnounce)
|
||||
}
|
||||
|
||||
guard !BLEPacketFreshnessPolicy.isStale(timestampMilliseconds: packet.timestamp, now: now) else {
|
||||
return .reject(.stale(ageSeconds: BLEPacketFreshnessPolicy.ageSeconds(
|
||||
timestampMilliseconds: packet.timestamp,
|
||||
now: now
|
||||
)))
|
||||
}
|
||||
|
||||
return .accept(BLEAnnouncePreflightAcceptance(
|
||||
announcement: announcement,
|
||||
derivedPeerID: derivedPeerID
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
enum BLEAnnounceTrustRejection: Equatable {
|
||||
case missingSignature
|
||||
case invalidSignature
|
||||
case keyMismatch
|
||||
}
|
||||
|
||||
enum BLEAnnounceTrustDecision: Equatable {
|
||||
case verified
|
||||
case reject(BLEAnnounceTrustRejection)
|
||||
|
||||
var isVerified: Bool {
|
||||
self == .verified
|
||||
}
|
||||
}
|
||||
|
||||
enum BLEAnnounceTrustPolicy {
|
||||
static func evaluate(
|
||||
hasSignature: Bool,
|
||||
signatureValid: Bool,
|
||||
existingNoisePublicKey: Data?,
|
||||
announcedNoisePublicKey: Data
|
||||
) -> BLEAnnounceTrustDecision {
|
||||
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
|
||||
return .reject(.keyMismatch)
|
||||
}
|
||||
|
||||
guard hasSignature else {
|
||||
return .reject(.missingSignature)
|
||||
}
|
||||
|
||||
guard signatureValid else {
|
||||
return .reject(.invalidSignature)
|
||||
}
|
||||
|
||||
return .verified
|
||||
}
|
||||
}
|
||||
|
||||
struct BLEAnnounceResponsePlan: Equatable {
|
||||
let shouldNotifyPeerConnected: Bool
|
||||
let shouldScheduleInitialSync: Bool
|
||||
let shouldSendAnnounceBack: Bool
|
||||
let shouldScheduleAfterglow: Bool
|
||||
}
|
||||
|
||||
enum BLEAnnounceResponsePolicy {
|
||||
static func plan(
|
||||
isDirectAnnounce: Bool,
|
||||
isNewPeer: Bool,
|
||||
isReconnectedPeer: Bool,
|
||||
shouldSendAnnounceBack: Bool
|
||||
) -> BLEAnnounceResponsePlan {
|
||||
let shouldNotifyPeerConnected = isDirectAnnounce && (isNewPeer || isReconnectedPeer)
|
||||
|
||||
return BLEAnnounceResponsePlan(
|
||||
shouldNotifyPeerConnected: shouldNotifyPeerConnected,
|
||||
shouldScheduleInitialSync: shouldNotifyPeerConnected,
|
||||
shouldSendAnnounceBack: shouldSendAnnounceBack,
|
||||
shouldScheduleAfterglow: isNewPeer
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Foundation
|
||||
|
||||
struct BLEAnnounceThrottle {
|
||||
private var lastSent: Date
|
||||
private let normalMinimumInterval: TimeInterval
|
||||
private let forcedMinimumInterval: TimeInterval
|
||||
|
||||
init(
|
||||
lastSent: Date = .distantPast,
|
||||
normalMinimumInterval: TimeInterval = TransportConfig.bleAnnounceMinInterval,
|
||||
forcedMinimumInterval: TimeInterval = TransportConfig.bleForceAnnounceMinIntervalSeconds
|
||||
) {
|
||||
self.lastSent = lastSent
|
||||
self.normalMinimumInterval = normalMinimumInterval
|
||||
self.forcedMinimumInterval = forcedMinimumInterval
|
||||
}
|
||||
|
||||
func elapsed(since now: Date) -> TimeInterval {
|
||||
now.timeIntervalSince(lastSent)
|
||||
}
|
||||
|
||||
mutating func shouldSend(force: Bool, now: Date) -> Bool {
|
||||
let minimumInterval = force ? forcedMinimumInterval : normalMinimumInterval
|
||||
guard elapsed(since: now) >= minimumInterval else {
|
||||
return false
|
||||
}
|
||||
|
||||
lastSent = now
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import Foundation
|
||||
|
||||
struct BLEConnectionCandidate<Peripheral> {
|
||||
let peripheral: Peripheral
|
||||
let peripheralID: String
|
||||
let rssi: Int
|
||||
let name: String
|
||||
let isConnectable: Bool
|
||||
let discoveredAt: Date
|
||||
}
|
||||
|
||||
struct BLEExistingConnectionState {
|
||||
let isConnecting: Bool
|
||||
let isConnected: Bool
|
||||
let lastConnectionAttempt: Date?
|
||||
}
|
||||
|
||||
enum BLEPeripheralConnectionState {
|
||||
case disconnected
|
||||
case connecting
|
||||
case connected
|
||||
}
|
||||
|
||||
enum BLEDiscoveryDecision: Equatable {
|
||||
case ignore
|
||||
case queued
|
||||
case scheduleRetry(after: TimeInterval)
|
||||
case cancelStaleConnection
|
||||
case connectNow
|
||||
}
|
||||
|
||||
enum BLEConnectionQueueDecision<Peripheral> {
|
||||
case none
|
||||
case retryAfter(TimeInterval)
|
||||
case connect(BLEConnectionCandidate<Peripheral>)
|
||||
}
|
||||
|
||||
final class BLEConnectionScheduler<Peripheral> {
|
||||
private let maxCentralLinks: Int
|
||||
private let connectRateLimitInterval: TimeInterval
|
||||
private let candidateCap: Int
|
||||
private let weakLinkCooldownSeconds: TimeInterval
|
||||
private let weakLinkRSSICutoff: Int
|
||||
|
||||
private var lastGlobalConnectAttempt: Date = .distantPast
|
||||
private var candidates: [BLEConnectionCandidate<Peripheral>] = []
|
||||
private var failureCounts: [String: Int] = [:]
|
||||
private var recentConnectTimeouts: [String: Date] = [:]
|
||||
// Tracked separately from connect timeouts: a peer we held a connection
|
||||
// with and lost (walked out of range) usually comes back, so it only gets
|
||||
// a brief rediscovery ignore — not the timeout backoff/cooldown treatment
|
||||
// reserved for peers that never answered a connect attempt.
|
||||
private var recentDisconnects: [String: Date] = [:]
|
||||
private var lastIsolatedAt: Date?
|
||||
|
||||
private let initialDynamicRSSIThreshold: Int
|
||||
private(set) var dynamicRSSIThreshold: Int
|
||||
|
||||
var candidateCount: Int {
|
||||
candidates.count
|
||||
}
|
||||
|
||||
init(
|
||||
maxCentralLinks: Int = TransportConfig.bleMaxCentralLinks,
|
||||
connectRateLimitInterval: TimeInterval = TransportConfig.bleConnectRateLimitInterval,
|
||||
candidateCap: Int = TransportConfig.bleConnectionCandidatesMax,
|
||||
weakLinkCooldownSeconds: TimeInterval = TransportConfig.bleWeakLinkCooldownSeconds,
|
||||
weakLinkRSSICutoff: Int = TransportConfig.bleWeakLinkRSSICutoff,
|
||||
dynamicRSSIThreshold: Int = TransportConfig.bleDynamicRSSIThresholdDefault
|
||||
) {
|
||||
self.maxCentralLinks = maxCentralLinks
|
||||
self.connectRateLimitInterval = connectRateLimitInterval
|
||||
self.candidateCap = candidateCap
|
||||
self.weakLinkCooldownSeconds = weakLinkCooldownSeconds
|
||||
self.weakLinkRSSICutoff = weakLinkRSSICutoff
|
||||
self.initialDynamicRSSIThreshold = dynamicRSSIThreshold
|
||||
self.dynamicRSSIThreshold = dynamicRSSIThreshold
|
||||
}
|
||||
|
||||
func handleDiscovery(
|
||||
_ candidate: BLEConnectionCandidate<Peripheral>,
|
||||
connectedOrConnectingCount: Int,
|
||||
existingState: BLEExistingConnectionState?,
|
||||
peripheralState: BLEPeripheralConnectionState,
|
||||
now: Date
|
||||
) -> BLEDiscoveryDecision {
|
||||
guard candidate.isConnectable else { return .ignore }
|
||||
|
||||
if candidate.rssi <= dynamicRSSIThreshold {
|
||||
enqueue(candidate)
|
||||
return .queued
|
||||
}
|
||||
|
||||
if connectedOrConnectingCount >= maxCentralLinks {
|
||||
enqueue(candidate)
|
||||
return .queued
|
||||
}
|
||||
|
||||
if let retryDelay = rateLimitRetryDelay(now: now) {
|
||||
enqueue(candidate)
|
||||
return .scheduleRetry(after: retryDelay)
|
||||
}
|
||||
|
||||
if let existingState {
|
||||
if existingState.isConnected || existingState.isConnecting {
|
||||
return .ignore
|
||||
}
|
||||
|
||||
if let lastAttempt = existingState.lastConnectionAttempt,
|
||||
now.timeIntervalSince(lastAttempt) < 2.0 {
|
||||
return .ignore
|
||||
}
|
||||
}
|
||||
|
||||
if let lastTimeout = recentConnectTimeouts[candidate.peripheralID],
|
||||
now.timeIntervalSince(lastTimeout) < TransportConfig.bleTimeoutDiscoveryIgnoreSeconds {
|
||||
return .ignore
|
||||
}
|
||||
|
||||
if let lastDisconnect = recentDisconnects[candidate.peripheralID],
|
||||
now.timeIntervalSince(lastDisconnect) < TransportConfig.bleDisconnectDiscoveryIgnoreSeconds {
|
||||
return .ignore
|
||||
}
|
||||
|
||||
switch peripheralState {
|
||||
case .disconnected:
|
||||
return .connectNow
|
||||
case .connecting, .connected:
|
||||
return .cancelStaleConnection
|
||||
}
|
||||
}
|
||||
|
||||
func enqueue(_ candidate: BLEConnectionCandidate<Peripheral>) {
|
||||
if let existingIndex = candidates.firstIndex(where: { $0.peripheralID == candidate.peripheralID }) {
|
||||
candidates[existingIndex] = candidate
|
||||
} else {
|
||||
candidates.append(candidate)
|
||||
}
|
||||
|
||||
candidates.sort {
|
||||
if $0.rssi != $1.rssi { return $0.rssi > $1.rssi }
|
||||
return $0.discoveredAt < $1.discoveredAt
|
||||
}
|
||||
if candidates.count > candidateCap {
|
||||
candidates.removeLast(candidates.count - candidateCap)
|
||||
}
|
||||
}
|
||||
|
||||
func nextCandidate(
|
||||
connectedOrConnectingCount: Int,
|
||||
isAlreadyConnectingOrConnected: (String) -> Bool,
|
||||
now: Date
|
||||
) -> BLEConnectionQueueDecision<Peripheral> {
|
||||
guard connectedOrConnectingCount < maxCentralLinks else { return .none }
|
||||
|
||||
if let retryDelay = rateLimitRetryDelay(now: now) {
|
||||
return .retryAfter(retryDelay)
|
||||
}
|
||||
|
||||
while !candidates.isEmpty {
|
||||
candidates.sort { score($0, now: now) > score($1, now: now) }
|
||||
let candidate = candidates.removeFirst()
|
||||
guard candidate.isConnectable else { continue }
|
||||
|
||||
if let delay = weakLinkRetryDelay(for: candidate, now: now) {
|
||||
enqueue(candidate)
|
||||
return .retryAfter(delay)
|
||||
}
|
||||
|
||||
if let delay = disconnectSettleDelay(for: candidate, now: now) {
|
||||
enqueue(candidate)
|
||||
return .retryAfter(delay)
|
||||
}
|
||||
|
||||
if isAlreadyConnectingOrConnected(candidate.peripheralID) {
|
||||
continue
|
||||
}
|
||||
|
||||
return .connect(candidate)
|
||||
}
|
||||
|
||||
return .none
|
||||
}
|
||||
|
||||
func recordConnectionAttempt(at now: Date) {
|
||||
lastGlobalConnectAttempt = now
|
||||
}
|
||||
|
||||
func recordConnectionSuccess(peripheralID: String) {
|
||||
failureCounts[peripheralID] = 0
|
||||
recentConnectTimeouts.removeValue(forKey: peripheralID)
|
||||
recentDisconnects.removeValue(forKey: peripheralID)
|
||||
}
|
||||
|
||||
func recordConnectionFailure(peripheralID: String) {
|
||||
failureCounts[peripheralID, default: 0] += 1
|
||||
}
|
||||
|
||||
func recordDisconnectError(peripheralID: String, at now: Date) {
|
||||
recentDisconnects[peripheralID] = now
|
||||
}
|
||||
|
||||
func recordConnectionTimeout(peripheralID: String, at now: Date) {
|
||||
recentConnectTimeouts[peripheralID] = now
|
||||
recordConnectionFailure(peripheralID: peripheralID)
|
||||
}
|
||||
|
||||
func pruneConnectionTimeouts(before cutoff: Date) {
|
||||
recentConnectTimeouts = recentConnectTimeouts.filter { $0.value >= cutoff }
|
||||
recentDisconnects = recentDisconnects.filter { $0.value >= cutoff }
|
||||
}
|
||||
|
||||
func reset() {
|
||||
lastGlobalConnectAttempt = .distantPast
|
||||
candidates.removeAll()
|
||||
failureCounts.removeAll()
|
||||
recentConnectTimeouts.removeAll()
|
||||
recentDisconnects.removeAll()
|
||||
lastIsolatedAt = nil
|
||||
dynamicRSSIThreshold = initialDynamicRSSIThreshold
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func updateRSSIThreshold(
|
||||
connectedCount: Int,
|
||||
connectedOrConnectingLinkCount: Int,
|
||||
now: Date
|
||||
) -> Int {
|
||||
if connectedCount == 0 {
|
||||
if lastIsolatedAt == nil { lastIsolatedAt = now }
|
||||
let isolatedAt = lastIsolatedAt ?? now
|
||||
let elapsed = now.timeIntervalSince(isolatedAt)
|
||||
dynamicRSSIThreshold = elapsed > TransportConfig.bleIsolationRelaxThresholdSeconds
|
||||
? TransportConfig.bleRSSIIsolatedRelaxed
|
||||
: TransportConfig.bleRSSIIsolatedBase
|
||||
return dynamicRSSIThreshold
|
||||
}
|
||||
|
||||
lastIsolatedAt = nil
|
||||
// Flaky links are handled per-peripheral (weak-link cooldown, discovery
|
||||
// ignore window, score bias) — never globally, so one flaky distant peer
|
||||
// can't blind us to every other edge-of-range peer.
|
||||
var threshold = TransportConfig.bleDynamicRSSIThresholdDefault
|
||||
if connectedOrConnectingLinkCount >= maxCentralLinks || candidates.count >= candidateCap {
|
||||
threshold = TransportConfig.bleRSSIConnectedThreshold
|
||||
}
|
||||
|
||||
dynamicRSSIThreshold = threshold
|
||||
return threshold
|
||||
}
|
||||
|
||||
private func rateLimitRetryDelay(now: Date) -> TimeInterval? {
|
||||
let elapsed = now.timeIntervalSince(lastGlobalConnectAttempt)
|
||||
guard elapsed < connectRateLimitInterval else { return nil }
|
||||
return connectRateLimitInterval - elapsed + 0.05
|
||||
}
|
||||
|
||||
private func weakLinkRetryDelay(
|
||||
for candidate: BLEConnectionCandidate<Peripheral>,
|
||||
now: Date
|
||||
) -> TimeInterval? {
|
||||
guard let lastTimeout = recentConnectTimeouts[candidate.peripheralID] else { return nil }
|
||||
let elapsed = now.timeIntervalSince(lastTimeout)
|
||||
guard elapsed < weakLinkCooldownSeconds && candidate.rssi <= weakLinkRSSICutoff else { return nil }
|
||||
let remaining = weakLinkCooldownSeconds - elapsed
|
||||
return min(max(2.0, remaining), 15.0)
|
||||
}
|
||||
|
||||
// The disconnect settle window must hold on the queue path too: a stale
|
||||
// candidate enqueued while the peripheral was still connected would
|
||||
// otherwise reconnect immediately via the post-disconnect queue drain,
|
||||
// bypassing the window and recreating reconnect/cancel thrash.
|
||||
private func disconnectSettleDelay(
|
||||
for candidate: BLEConnectionCandidate<Peripheral>,
|
||||
now: Date
|
||||
) -> TimeInterval? {
|
||||
guard let lastDisconnect = recentDisconnects[candidate.peripheralID] else { return nil }
|
||||
let remaining = TransportConfig.bleDisconnectDiscoveryIgnoreSeconds - now.timeIntervalSince(lastDisconnect)
|
||||
guard remaining > 0 else { return nil }
|
||||
return remaining + 0.05
|
||||
}
|
||||
|
||||
private func score(_ candidate: BLEConnectionCandidate<Peripheral>, now: Date) -> Int {
|
||||
let failures = failureCounts[candidate.peripheralID] ?? 0
|
||||
let penalty = min(20, 1 << min(4, failures))
|
||||
let timeoutBias = recentConnectTimeouts[candidate.peripheralID].map {
|
||||
now.timeIntervalSince($0) < 60 ? 10 : 0
|
||||
} ?? 0
|
||||
let base = (candidate.isConnectable ? 1000 : 0) + (candidate.rssi + 100) * 2
|
||||
let recency = -Int(now.timeIntervalSince(candidate.discoveredAt) * 10)
|
||||
return base + recency - penalty - timeoutBias
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct BLEDirectedRelaySpoolEntry {
|
||||
let recipient: PeerID
|
||||
let packet: BitchatPacket
|
||||
}
|
||||
|
||||
struct BLEDirectedRelaySpool {
|
||||
private struct StoredPacket {
|
||||
let packet: BitchatPacket
|
||||
let enqueuedAt: Date
|
||||
}
|
||||
|
||||
private var packetsByRecipient: [PeerID: [String: StoredPacket]] = [:]
|
||||
|
||||
var isEmpty: Bool {
|
||||
packetsByRecipient.isEmpty
|
||||
}
|
||||
|
||||
var count: Int {
|
||||
packetsByRecipient.values.reduce(0) { $0 + $1.count }
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
mutating func enqueue(
|
||||
packet: BitchatPacket,
|
||||
recipient: PeerID,
|
||||
messageID: String,
|
||||
enqueuedAt: Date
|
||||
) -> Bool {
|
||||
var packets = packetsByRecipient[recipient] ?? [:]
|
||||
guard packets[messageID] == nil else {
|
||||
return false
|
||||
}
|
||||
|
||||
packets[messageID] = StoredPacket(packet: packet, enqueuedAt: enqueuedAt)
|
||||
packetsByRecipient[recipient] = packets
|
||||
return true
|
||||
}
|
||||
|
||||
mutating func drainUnexpired(now: Date, window: TimeInterval) -> [BLEDirectedRelaySpoolEntry] {
|
||||
var entries: [BLEDirectedRelaySpoolEntry] = []
|
||||
|
||||
for (recipient, packets) in packetsByRecipient {
|
||||
for stored in packets.values where now.timeIntervalSince(stored.enqueuedAt) <= window {
|
||||
entries.append(BLEDirectedRelaySpoolEntry(recipient: recipient, packet: stored.packet))
|
||||
}
|
||||
}
|
||||
|
||||
packetsByRecipient.removeAll()
|
||||
return entries
|
||||
}
|
||||
|
||||
mutating func pruneExpired(now: Date, window: TimeInterval) {
|
||||
guard !packetsByRecipient.isEmpty else { return }
|
||||
|
||||
var pruned: [PeerID: [String: StoredPacket]] = [:]
|
||||
for (recipient, packets) in packetsByRecipient {
|
||||
let freshPackets = packets.filter { now.timeIntervalSince($0.value.enqueuedAt) <= window }
|
||||
if !freshPackets.isEmpty {
|
||||
pruned[recipient] = freshPackets
|
||||
}
|
||||
}
|
||||
packetsByRecipient = pruned
|
||||
}
|
||||
|
||||
mutating func removeAll() {
|
||||
packetsByRecipient.removeAll()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import BitFoundation
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
struct BLEFanoutSelection: Equatable {
|
||||
let peripheralIDs: Set<String>
|
||||
let centralIDs: Set<String>
|
||||
}
|
||||
|
||||
enum BLEFanoutSelector {
|
||||
static func selectLinks(
|
||||
peripheralIDs: [String],
|
||||
centralIDs: [String],
|
||||
ingressLink: BLEIngressLinkID?,
|
||||
excludedLinks: Set<BLEIngressLinkID> = [],
|
||||
peripheralPeerBindings: [String: PeerID] = [:],
|
||||
centralPeerBindings: [String: PeerID] = [:],
|
||||
directedPeerHint: PeerID?,
|
||||
packetType: UInt8,
|
||||
messageID: String
|
||||
) -> BLEFanoutSelection {
|
||||
let allowed = collapseDuplicateLinksPerPeer(
|
||||
allowedLinks(
|
||||
peripheralIDs: peripheralIDs,
|
||||
centralIDs: centralIDs,
|
||||
ingressLink: ingressLink,
|
||||
excludedLinks: excludedLinks
|
||||
),
|
||||
peripheralPeerBindings: peripheralPeerBindings,
|
||||
centralPeerBindings: centralPeerBindings
|
||||
)
|
||||
|
||||
guard shouldSubset(packetType: packetType, directedPeerHint: directedPeerHint) else {
|
||||
return BLEFanoutSelection(
|
||||
peripheralIDs: Set(allowed.peripheralIDs),
|
||||
centralIDs: Set(allowed.centralIDs)
|
||||
)
|
||||
}
|
||||
|
||||
return BLEFanoutSelection(
|
||||
peripheralIDs: deterministicSubset(
|
||||
ids: allowed.peripheralIDs,
|
||||
k: subsetSize(for: allowed.peripheralIDs.count),
|
||||
seed: messageID
|
||||
),
|
||||
centralIDs: deterministicSubset(
|
||||
ids: allowed.centralIDs,
|
||||
k: subsetSize(for: allowed.centralIDs.count),
|
||||
seed: messageID
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private static func allowedLinks(
|
||||
peripheralIDs: [String],
|
||||
centralIDs: [String],
|
||||
ingressLink: BLEIngressLinkID?,
|
||||
excludedLinks: Set<BLEIngressLinkID>
|
||||
) -> (peripheralIDs: [String], centralIDs: [String]) {
|
||||
var allowedPeripheralIDs = peripheralIDs
|
||||
var allowedCentralIDs = centralIDs
|
||||
var blockedLinks = excludedLinks
|
||||
|
||||
if let ingressLink {
|
||||
blockedLinks.insert(ingressLink)
|
||||
}
|
||||
|
||||
allowedPeripheralIDs.removeAll { blockedLinks.contains(.peripheral($0)) }
|
||||
allowedCentralIDs.removeAll { blockedLinks.contains(.central($0)) }
|
||||
|
||||
return (allowedPeripheralIDs, allowedCentralIDs)
|
||||
}
|
||||
|
||||
// Dual-role pairs hold two live links (we-as-central writing to their
|
||||
// peripheral, and they-as-central subscribed to ours). Sending the same
|
||||
// packet down both doubles airtime for nothing — the receiver's assembler
|
||||
// and deduplicator just discard the copy. Keep one link per bound peer,
|
||||
// preferring the peripheral (write) side: it has per-link flow control
|
||||
// via canSendWriteWithoutResponse, while notifications share the
|
||||
// peripheral manager's update queue across all centrals. Links with no
|
||||
// bound peer yet (pre-announce) pass through untouched.
|
||||
private static func collapseDuplicateLinksPerPeer(
|
||||
_ links: (peripheralIDs: [String], centralIDs: [String]),
|
||||
peripheralPeerBindings: [String: PeerID],
|
||||
centralPeerBindings: [String: PeerID]
|
||||
) -> (peripheralIDs: [String], centralIDs: [String]) {
|
||||
guard !peripheralPeerBindings.isEmpty || !centralPeerBindings.isEmpty else {
|
||||
return links
|
||||
}
|
||||
|
||||
var seenPeers = Set<PeerID>()
|
||||
var keptPeripheralIDs: [String] = []
|
||||
for id in links.peripheralIDs {
|
||||
if let peer = peripheralPeerBindings[id], !seenPeers.insert(peer).inserted {
|
||||
continue
|
||||
}
|
||||
keptPeripheralIDs.append(id)
|
||||
}
|
||||
|
||||
var keptCentralIDs: [String] = []
|
||||
for id in links.centralIDs {
|
||||
if let peer = centralPeerBindings[id], !seenPeers.insert(peer).inserted {
|
||||
continue
|
||||
}
|
||||
keptCentralIDs.append(id)
|
||||
}
|
||||
|
||||
return (keptPeripheralIDs, keptCentralIDs)
|
||||
}
|
||||
|
||||
private static func shouldSubset(packetType: UInt8, directedPeerHint: PeerID?) -> Bool {
|
||||
directedPeerHint == nil
|
||||
&& packetType != MessageType.fragment.rawValue
|
||||
&& packetType != MessageType.announce.rawValue
|
||||
&& packetType != MessageType.requestSync.rawValue
|
||||
}
|
||||
|
||||
private static func subsetSize(for count: Int) -> Int {
|
||||
guard count > 0 else { return 0 }
|
||||
if count <= 2 { return count }
|
||||
|
||||
var value = count - 1
|
||||
var bits = 0
|
||||
while value > 0 {
|
||||
value >>= 1
|
||||
bits += 1
|
||||
}
|
||||
return min(count, max(1, bits + 1))
|
||||
}
|
||||
|
||||
private static func deterministicSubset(ids: [String], k: Int, seed: String) -> Set<String> {
|
||||
guard k > 0 && ids.count > k else { return Set(ids) }
|
||||
|
||||
var scored: [(score: [UInt8], id: String)] = []
|
||||
for id in ids {
|
||||
let data = (seed + "::" + id).data(using: .utf8) ?? Data()
|
||||
let digest = Array(SHA256.hash(data: data))
|
||||
scored.append((digest, id))
|
||||
}
|
||||
|
||||
scored.sort { lhs, rhs in
|
||||
for index in 0..<min(lhs.score.count, rhs.score.count) {
|
||||
if lhs.score[index] != rhs.score[index] {
|
||||
return lhs.score[index] < rhs.score[index]
|
||||
}
|
||||
}
|
||||
return lhs.id < rhs.id
|
||||
}
|
||||
|
||||
return Set(scored.prefix(k).map(\.id))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import BitFoundation
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
/// Narrow environment for `BLEFileTransferHandler`.
|
||||
///
|
||||
/// All queue hops (collections registry reads/writes, main-actor UI
|
||||
/// notification) live inside the closures supplied by `BLEService`, keeping
|
||||
/// the handler queue-agnostic and synchronously testable.
|
||||
struct BLEFileTransferHandlerEnvironment {
|
||||
/// Local peer identity at the time the transfer is handled.
|
||||
let localPeerID: () -> PeerID
|
||||
/// Local nickname used for sender resolution and collision checks.
|
||||
let localNickname: () -> String
|
||||
/// Snapshot of known peers keyed by ID (registry read).
|
||||
let peersSnapshot: () -> [PeerID: BLEPeerInfo]
|
||||
/// Resolves a display name from a verified packet signature for peers missing from the registry.
|
||||
let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String?
|
||||
/// Tracks the broadcast file packet for gossip sync.
|
||||
let trackPacketSeen: (BitchatPacket) -> Void
|
||||
/// Enforces the incoming-media storage quota before saving (BCH-01-002).
|
||||
let enforceStorageQuota: (_ reservingBytes: Int) -> Void
|
||||
/// Persists the validated file to the incoming-media store; returns the destination URL.
|
||||
let saveIncomingFile: (
|
||||
_ data: Data,
|
||||
_ preferredName: String?,
|
||||
_ subdirectory: String,
|
||||
_ fallbackExtension: String?,
|
||||
_ defaultPrefix: String
|
||||
) -> URL?
|
||||
/// Updates the registry last-seen timestamp for the peer (async barrier write).
|
||||
let updatePeerLastSeen: (PeerID) -> Void
|
||||
/// Delivers `.messageReceived` to the UI as one main-actor hop.
|
||||
let deliverMessage: (BitchatMessage) -> Void
|
||||
}
|
||||
|
||||
/// Orchestrates inbound file transfers: self-echo policy, sender display-name
|
||||
/// resolution, delivery planning, payload validation, quota-checked storage,
|
||||
/// and UI delivery.
|
||||
final class BLEFileTransferHandler {
|
||||
private let environment: BLEFileTransferHandlerEnvironment
|
||||
|
||||
init(environment: BLEFileTransferHandlerEnvironment) {
|
||||
self.environment = environment
|
||||
}
|
||||
|
||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
let env = environment
|
||||
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return }
|
||||
|
||||
let peersSnapshot = env.peersSnapshot()
|
||||
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||
peerID: peerID,
|
||||
localPeerID: env.localPeerID(),
|
||||
localNickname: env.localNickname(),
|
||||
peers: peersSnapshot,
|
||||
allowConnectedUnverified: true
|
||||
) ?? env.signedSenderDisplayName(packet, peerID) else {
|
||||
SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security)
|
||||
return
|
||||
}
|
||||
|
||||
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: env.localPeerID()) else {
|
||||
return
|
||||
}
|
||||
if deliveryPlan.shouldTrackForSync {
|
||||
env.trackPacketSeen(packet)
|
||||
}
|
||||
|
||||
let filePacket: BitchatFilePacket
|
||||
let mime: MimeType
|
||||
switch BLEIncomingFileValidator.validate(payload: packet.payload) {
|
||||
case .success(let acceptance):
|
||||
filePacket = acceptance.filePacket
|
||||
mime = acceptance.mime
|
||||
case .failure(.malformedPayload):
|
||||
SecureLogger.error("❌ Failed to decode file transfer payload", category: .session)
|
||||
return
|
||||
case .failure(.payloadTooLarge(let bytes)):
|
||||
SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security)
|
||||
return
|
||||
case .failure(.unsupportedMime(let mimeType, let bytes)):
|
||||
SecureLogger.warning("🚫 MIME REJECT: '\(mimeType ?? "<empty>")' not supported. Size=\(bytes)b from \(peerID.id.prefix(8))...", category: .security)
|
||||
return
|
||||
case .failure(.magicMismatch(let mime, let bytes, let prefixHex)):
|
||||
SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(bytes)b prefix=[\(prefixHex)] from \(peerID.id.prefix(8))...", category: .security)
|
||||
return
|
||||
}
|
||||
|
||||
// BCH-01-002: Enforce storage quota before saving
|
||||
env.enforceStorageQuota(filePacket.content.count)
|
||||
|
||||
guard let destination = env.saveIncomingFile(
|
||||
filePacket.content,
|
||||
filePacket.fileName,
|
||||
"\(mime.category.mediaDir)/incoming",
|
||||
mime.defaultExtension,
|
||||
mime.category.rawValue
|
||||
) else {
|
||||
return
|
||||
}
|
||||
|
||||
if deliveryPlan.isPrivateMessage {
|
||||
env.updatePeerLastSeen(peerID)
|
||||
}
|
||||
|
||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||
let message = BitchatMessage(
|
||||
sender: senderNickname,
|
||||
content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)",
|
||||
timestamp: ts,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: deliveryPlan.isPrivateMessage,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: peerID
|
||||
)
|
||||
|
||||
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
|
||||
|
||||
env.deliverMessage(message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct BLEFileTransferDeliveryPlan: Equatable {
|
||||
let isPrivateMessage: Bool
|
||||
let shouldTrackForSync: Bool
|
||||
}
|
||||
|
||||
enum BLEFileTransferPolicy {
|
||||
static func isSelfEcho(packet: BitchatPacket, from peerID: PeerID, localPeerID: PeerID) -> Bool {
|
||||
peerID == localPeerID && packet.ttl != 0
|
||||
}
|
||||
|
||||
static func deliveryPlan(packet: BitchatPacket, localPeerID: PeerID) -> BLEFileTransferDeliveryPlan? {
|
||||
guard let recipientID = packet.recipientID else {
|
||||
return BLEFileTransferDeliveryPlan(isPrivateMessage: false, shouldTrackForSync: true)
|
||||
}
|
||||
|
||||
let isBroadcast = recipientID.allSatisfy { $0 == 0xFF }
|
||||
if isBroadcast {
|
||||
return BLEFileTransferDeliveryPlan(isPrivateMessage: false, shouldTrackForSync: true)
|
||||
}
|
||||
|
||||
guard PeerID(hexData: recipientID) == localPeerID else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return BLEFileTransferDeliveryPlan(isPrivateMessage: true, shouldTrackForSync: false)
|
||||
}
|
||||
}
|
||||
|
||||
struct BLEIncomingFileAcceptance {
|
||||
let filePacket: BitchatFilePacket
|
||||
let mime: MimeType
|
||||
}
|
||||
|
||||
enum BLEIncomingFileRejection: Error, Equatable {
|
||||
case malformedPayload
|
||||
case payloadTooLarge(bytes: Int)
|
||||
case unsupportedMime(mimeType: String?, bytes: Int)
|
||||
case magicMismatch(mime: MimeType, bytes: Int, prefixHex: String)
|
||||
}
|
||||
|
||||
enum BLEIncomingFileValidator {
|
||||
static func validate(payload: Data) -> Result<BLEIncomingFileAcceptance, BLEIncomingFileRejection> {
|
||||
guard let filePacket = BitchatFilePacket.decode(payload) else {
|
||||
return .failure(.malformedPayload)
|
||||
}
|
||||
|
||||
guard FileTransferLimits.isValidPayload(filePacket.content.count) else {
|
||||
return .failure(.payloadTooLarge(bytes: filePacket.content.count))
|
||||
}
|
||||
|
||||
guard let mime = MimeType(filePacket.mimeType), mime.isAllowed else {
|
||||
return .failure(.unsupportedMime(
|
||||
mimeType: filePacket.mimeType,
|
||||
bytes: filePacket.content.count
|
||||
))
|
||||
}
|
||||
|
||||
guard mime.matches(data: filePacket.content) else {
|
||||
return .failure(.magicMismatch(
|
||||
mime: mime,
|
||||
bytes: filePacket.content.count,
|
||||
prefixHex: filePacket.content.prefix(20).map { String(format: "%02x", $0) }.joined(separator: " ")
|
||||
))
|
||||
}
|
||||
|
||||
return .success(BLEIncomingFileAcceptance(filePacket: filePacket, mime: mime))
|
||||
}
|
||||
}
|
||||