Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41c297b889 | ||
|
|
45fec95af2 | ||
|
|
1a569cfb80 | ||
|
|
3ad9cbe48f | ||
|
|
4f62364bf4 | ||
|
|
fb003ba25e | ||
|
|
52c51c9be6 | ||
|
|
96a580491a | ||
|
|
fec5769fd2 | ||
|
|
12d3e91182 | ||
|
|
cf6d169337 | ||
|
|
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 | ||
|
|
7c4c3f1391 | ||
|
|
3c06bd6386 | ||
|
|
acc11101ad | ||
|
|
5fd9140ffa | ||
|
|
e6903456ab | ||
|
|
85f99984c4 | ||
|
|
75c8933091 | ||
|
|
d2bfbbcfd4 | ||
|
|
0260798712 | ||
|
|
2758afa126 | ||
|
|
2229a28bb4 | ||
|
|
dadc896ed8 | ||
|
|
b2504a7ff5 | ||
|
|
7f2cbd6621 | ||
|
|
60a42ffecb | ||
|
|
27e5e2962c | ||
|
|
9d5105a8bf | ||
|
|
d4b779080a | ||
|
|
4bfe9ac80d | ||
|
|
adffe7dfd6 | ||
|
|
16fdb7b49e | ||
|
|
ff5fd3f3fe | ||
|
|
f8a955214b | ||
|
|
d5e712e27f | ||
|
|
244c8cdf81 | ||
|
|
0d064084aa | ||
|
|
7b49268694 | ||
|
|
5650e1f2c2 | ||
|
|
c4b422ad3f | ||
|
|
e82fda1093 | ||
|
|
806d451135 | ||
|
|
13f8b0c636 | ||
|
|
2d0f9aff0a | ||
|
|
83ee5abb60 | ||
|
|
bc27e16899 | ||
|
|
d3d9a22757 | ||
|
|
8a269d4fec | ||
|
|
33fbca67d6 | ||
|
|
c63350a4d3 | ||
|
|
222854c60a | ||
|
|
c624611e7d | ||
|
|
eb0debd52a | ||
|
|
916f535503 | ||
|
|
de39ab6687 | ||
|
|
a0a973eb81 | ||
|
|
ba1dd100ec | ||
|
|
97b1463b30 | ||
|
|
28c87a41c1 | ||
|
|
68c61476d8 | ||
|
|
602e93d1b2 | ||
|
|
e7706fc9cb | ||
|
|
f30677403f | ||
|
|
b4fcea2672 | ||
|
|
43166bcd64 | ||
|
|
496972dcc9 | ||
|
|
3074fa0fcb | ||
|
|
bdbcbb5d2b | ||
|
|
ed4d8b667e | ||
|
|
a29f517783 | ||
|
|
78917fa7c9 | ||
|
|
eb16d128f2 | ||
|
|
b09710a7aa | ||
|
|
1c33a92765 | ||
|
|
a30b73dd99 | ||
|
|
4c0bb5f93a | ||
|
|
7836daa6d3 | ||
|
|
4f1ac30f12 | ||
|
|
6fbf7eee25 | ||
|
|
3ebfa85e90 | ||
|
|
fb1988ac27 | ||
|
|
845ffc601b | ||
|
|
47b0829685 | ||
|
|
030d6e0f11 | ||
|
|
db3c3b77f5 | ||
|
|
26bcdf72d7 | ||
|
|
275f0ebaaf | ||
|
|
f4b8168ef9 | ||
|
|
63f05b5d7e | ||
|
|
a36eda3fbe | ||
|
|
99c0f6523e | ||
|
|
7a7c89e689 | ||
|
|
3226b9cd14 | ||
|
|
04c2b0caa6 | ||
|
|
29f1308e37 | ||
|
|
c6c186c77b | ||
|
|
d4262fab6c | ||
|
|
7876c8d96f | ||
|
|
397c9f182b | ||
|
|
3ed37dfd0b | ||
|
|
327fca9cb1 | ||
|
|
871456896d | ||
|
|
b4b22e0e05 | ||
|
|
8ec39f566d | ||
|
|
172b06721b | ||
|
|
e2d50e3684 | ||
|
|
d605a0b6db | ||
|
|
1d698c2006 | ||
|
|
a92828471b | ||
|
|
69c8161cf8 | ||
|
|
aa1ecf40fc | ||
|
|
b004bfa4aa | ||
|
|
a6c2e751d2 | ||
|
|
8f32edaa64 | ||
|
|
5a44b32719 | ||
|
|
d85c9e226b | ||
|
|
f953c50565 | ||
|
|
2a081f65fc | ||
|
|
71b7f8edd3 | ||
|
|
46fd1d23d9 | ||
|
|
6a9a1cdb3b | ||
|
|
c74d6ad75e | ||
|
|
439fb59fdd | ||
|
|
0a5e4b248f |
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/Swift.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||||
|
dependencies:
|
||||||
|
- mtime: 1757258659000000000
|
||||||
|
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/Swift.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||||
|
size: 14166264
|
||||||
|
- mtime: 1754189697000000000
|
||||||
|
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||||
|
size: 2261306
|
||||||
|
sdk_relative: true
|
||||||
|
version: 1
|
||||||
|
...
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/SwiftOnoneSupport.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||||
|
dependencies:
|
||||||
|
- mtime: 1757258662000000000
|
||||||
|
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/SwiftOnoneSupport.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||||
|
size: 18068
|
||||||
|
- mtime: 1754189697000000000
|
||||||
|
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||||
|
size: 2261306
|
||||||
|
sdk_relative: true
|
||||||
|
- mtime: 1754191141000000000
|
||||||
|
path: 'usr/lib/swift/SwiftOnoneSupport.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||||
|
size: 1224
|
||||||
|
sdk_relative: true
|
||||||
|
version: 1
|
||||||
|
...
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_Concurrency.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||||
|
dependencies:
|
||||||
|
- mtime: 1757258669000000000
|
||||||
|
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_Concurrency.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||||
|
size: 699544
|
||||||
|
- mtime: 1754189697000000000
|
||||||
|
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||||
|
size: 2261306
|
||||||
|
sdk_relative: true
|
||||||
|
- mtime: 1754192470000000000
|
||||||
|
path: 'usr/lib/swift/_Concurrency.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||||
|
size: 364219
|
||||||
|
sdk_relative: true
|
||||||
|
version: 1
|
||||||
|
...
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_StringProcessing.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||||
|
dependencies:
|
||||||
|
- mtime: 1757258664000000000
|
||||||
|
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_StringProcessing.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||||
|
size: 83568
|
||||||
|
- mtime: 1754189697000000000
|
||||||
|
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||||
|
size: 2261306
|
||||||
|
sdk_relative: true
|
||||||
|
- mtime: 1754192532000000000
|
||||||
|
path: 'usr/lib/swift/_StringProcessing.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||||
|
size: 24507
|
||||||
|
sdk_relative: true
|
||||||
|
version: 1
|
||||||
|
...
|
||||||
@@ -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,40 @@
|
|||||||
|
name: Fetch GeoRelays Data
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 6 * * 0'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update-relay-data:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Fetch GeoRelays
|
||||||
|
run: |
|
||||||
|
wget 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,27 @@
|
|||||||
|
name: Build & Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Run Swift Tests
|
||||||
|
runs-on: macos-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Set up Swift
|
||||||
|
uses: swift-actions/setup-swift@v2
|
||||||
|
|
||||||
|
- name: Build the package
|
||||||
|
run: swift build
|
||||||
|
|
||||||
|
- name: Run Tests
|
||||||
|
run: swift test --parallel
|
||||||
@@ -5,8 +5,9 @@
|
|||||||
## implementation plans
|
## implementation plans
|
||||||
plans/
|
plans/
|
||||||
|
|
||||||
## User settings
|
## AI
|
||||||
xcuserdata/
|
CLAUDE.md
|
||||||
|
AGENTS.md
|
||||||
|
|
||||||
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
|
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
|
||||||
*.xcscmblueprint
|
*.xcscmblueprint
|
||||||
@@ -15,6 +16,7 @@ xcuserdata/
|
|||||||
## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
|
## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
|
||||||
build/
|
build/
|
||||||
DerivedData/
|
DerivedData/
|
||||||
|
.DerivedData/
|
||||||
*.moved-aside
|
*.moved-aside
|
||||||
*.pbxuser
|
*.pbxuser
|
||||||
!default.pbxuser
|
!default.pbxuser
|
||||||
@@ -52,7 +54,8 @@ iOSInjectionProject/
|
|||||||
|
|
||||||
## Xcode project
|
## Xcode project
|
||||||
*.xcodeproj/project.xcworkspace/
|
*.xcodeproj/project.xcworkspace/
|
||||||
*.xcodeproj/xcshareddata/
|
## Xcode User settings
|
||||||
|
xcuserdata/
|
||||||
|
|
||||||
## Python
|
## Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
@@ -62,3 +65,14 @@ __pycache__/
|
|||||||
## Temporary files
|
## Temporary files
|
||||||
*.tmp
|
*.tmp
|
||||||
*.temp
|
*.temp
|
||||||
|
|
||||||
|
# Local build results
|
||||||
|
.Result*/
|
||||||
|
.Result*.xcresult/
|
||||||
|
TestResult.xcresult/
|
||||||
|
*.xcresult/
|
||||||
|
build.log
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Local configs
|
||||||
|
Local.xcconfig
|
||||||
|
|||||||
@@ -1,472 +0,0 @@
|
|||||||
# AI Context for BitChat
|
|
||||||
|
|
||||||
This document provides essential context for AI assistants working on the BitChat codebase. Read this first to understand the project's architecture, design decisions, and key concepts.
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
BitChat is a decentralized, peer-to-peer messaging application that works over Bluetooth mesh networks without requiring internet connectivity, servers, or phone numbers. It's designed for scenarios where traditional communication infrastructure is unavailable or untrusted.
|
|
||||||
|
|
||||||
### Key Features
|
|
||||||
- **Bluetooth Mesh Networking**: Multi-hop message relay over BLE
|
|
||||||
- **Privacy-First Design**: No accounts, no persistent identifiers
|
|
||||||
- **End-to-End Encryption**: Uses Noise Protocol Framework for private messages
|
|
||||||
- **Store & Forward**: Messages cached for offline peers
|
|
||||||
- **IRC-Style Commands**: Familiar `/msg`, `/who` interface
|
|
||||||
- **Cross-Platform**: Native iOS and macOS support
|
|
||||||
- **Nostr Integration**: Seamless fallback for mutual favorites when out of Bluetooth range
|
|
||||||
- **Hybrid Transport**: Automatic switching between Bluetooth and Nostr
|
|
||||||
|
|
||||||
## Architecture Overview
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ User Interface │
|
|
||||||
│ (ContentView, ChatViewModel) │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ Application Services │
|
|
||||||
│ (MessageRetryService, DeliveryTracker, NotificationService) │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ Message Router │
|
|
||||||
│ (Transport selection, Favorites integration) │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
│ │
|
|
||||||
┌───────────────────────────────┐ ┌────────────────────────────────┐
|
|
||||||
│ Security Layer │ │ Nostr Protocol Layer │
|
|
||||||
│ (NoiseEncryptionService, │ │ (NostrProtocol, NIP-17, │
|
|
||||||
│ SecureIdentityStateManager) │ │ NostrRelayManager) │
|
|
||||||
└───────────────────────────────┘ └────────────────────────────────┘
|
|
||||||
│ │
|
|
||||||
┌───────────────────────────────┐ ┌────────────────────────────────┐
|
|
||||||
│ Protocol Layer │ │ Transport │
|
|
||||||
│ (BitchatProtocol, Binary- │ │ (WebSocket to Nostr │
|
|
||||||
│ Protocol, NoiseProtocol) │ │ relay servers) │
|
|
||||||
└───────────────────────────────┘ └────────────────────────────────┘
|
|
||||||
│
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ Bluetooth Transport Layer │
|
|
||||||
│ (BluetoothMeshService) │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Core Components
|
|
||||||
|
|
||||||
### 1. BluetoothMeshService (Transport Layer)
|
|
||||||
- **Location**: `bitchat/Services/BluetoothMeshService.swift`
|
|
||||||
- **Purpose**: Manages BLE connections and implements mesh networking
|
|
||||||
- **Key Responsibilities**:
|
|
||||||
- Peer discovery (scanning and advertising simultaneously)
|
|
||||||
- Connection management (acts as both central and peripheral)
|
|
||||||
- Message routing and relay
|
|
||||||
- Version negotiation with peers
|
|
||||||
- Automatic reconnection and topology management
|
|
||||||
|
|
||||||
### 2. BitchatProtocol (Protocol Layer)
|
|
||||||
- **Location**: `bitchat/Protocols/BitchatProtocol.swift`
|
|
||||||
- **Purpose**: Defines the application-level messaging protocol
|
|
||||||
- **Key Features**:
|
|
||||||
- Binary packet format for efficiency
|
|
||||||
- Message types: Chat, Announcement, PrivateMessage, etc.
|
|
||||||
- TTL-based routing (max 7 hops)
|
|
||||||
- Message deduplication via unique IDs
|
|
||||||
- Privacy features: padding, timing obfuscation
|
|
||||||
|
|
||||||
### 3. NoiseProtocol Implementation
|
|
||||||
- **Locations**:
|
|
||||||
- `bitchat/Noise/NoiseProtocol.swift` - Core protocol implementation
|
|
||||||
- `bitchat/Noise/NoiseSession.swift` - Session management
|
|
||||||
- `bitchat/Services/NoiseEncryptionService.swift` - High-level encryption API
|
|
||||||
- **Purpose**: Provides end-to-end encryption for private messages
|
|
||||||
- **Implementation Details**:
|
|
||||||
- Uses Noise_XX_25519_AESGCM_SHA256 pattern
|
|
||||||
- Mutual authentication via static keys
|
|
||||||
- Forward secrecy via ephemeral keys
|
|
||||||
- Integrated with identity management
|
|
||||||
|
|
||||||
### 4. Identity System
|
|
||||||
- **Location**: `bitchat/Identity/`
|
|
||||||
- **Three-Layer Model**:
|
|
||||||
1. **Ephemeral Identity**: Short-lived, rotates frequently
|
|
||||||
2. **Cryptographic Identity**: Long-term Noise static keypair
|
|
||||||
3. **Social Identity**: User-chosen nickname and metadata
|
|
||||||
- **Trust Levels**: Untrusted → Verified → Trusted → Blocked
|
|
||||||
|
|
||||||
### 5. ChatViewModel
|
|
||||||
- **Location**: `bitchat/ViewModels/ChatViewModel.swift`
|
|
||||||
- **Purpose**: Central state management and business logic
|
|
||||||
- **Responsibilities**:
|
|
||||||
- Message handling and batching
|
|
||||||
- Command processing (/msg, /who, etc.)
|
|
||||||
- UI state management
|
|
||||||
- Private chat coordination
|
|
||||||
|
|
||||||
### 6. Nostr Integration
|
|
||||||
- **Locations**:
|
|
||||||
- `bitchat/Nostr/NostrProtocol.swift` - NIP-17 private message implementation
|
|
||||||
- `bitchat/Nostr/NostrRelayManager.swift` - WebSocket relay connections
|
|
||||||
- `bitchat/Nostr/NostrIdentity.swift` - Nostr key management
|
|
||||||
- `bitchat/Services/MessageRouter.swift` - Transport selection logic
|
|
||||||
- **Purpose**: Enables communication with mutual favorites when out of Bluetooth range
|
|
||||||
- **Key Features**:
|
|
||||||
- NIP-17 gift-wrapped private messages for metadata privacy
|
|
||||||
- Automatic relay connection management
|
|
||||||
- Seamless transport switching between Bluetooth and Nostr
|
|
||||||
- Integrated with favorites system for mutual authentication
|
|
||||||
|
|
||||||
### 7. MessageRouter
|
|
||||||
- **Location**: `bitchat/Services/MessageRouter.swift`
|
|
||||||
- **Purpose**: Intelligent routing between Bluetooth mesh and Nostr transports
|
|
||||||
- **Transport Selection Logic**:
|
|
||||||
1. Always prefer Bluetooth mesh when peer is connected
|
|
||||||
2. Use Nostr for mutual favorites when peer is offline
|
|
||||||
3. Fail gracefully when no transport is available
|
|
||||||
- **Message Types Routed**:
|
|
||||||
- Regular chat messages
|
|
||||||
- Favorite/unfavorite notifications
|
|
||||||
- Delivery acknowledgments
|
|
||||||
- Read receipts
|
|
||||||
|
|
||||||
## Key Design Decisions
|
|
||||||
|
|
||||||
### 1. Protocol Design
|
|
||||||
- **Binary Protocol**: Chosen for efficiency over BLE's limited bandwidth
|
|
||||||
- **No JSON**: Reduces parsing overhead and message size
|
|
||||||
- **Custom Framing**: Handles BLE's 512-byte MTU limitations
|
|
||||||
|
|
||||||
### 2. Security Architecture
|
|
||||||
- **Noise Protocol**: Industry-standard, well-analyzed framework
|
|
||||||
- **XX Pattern**: Provides mutual authentication and forward secrecy
|
|
||||||
- **No Long-Term Identifiers**: Enhances privacy and deniability
|
|
||||||
|
|
||||||
### 3. Mesh Networking
|
|
||||||
- **Store & Forward**: Essential for intermittent connectivity
|
|
||||||
- **TTL-Based Routing**: Prevents infinite loops in mesh
|
|
||||||
- **Bloom Filters**: Efficient duplicate detection
|
|
||||||
|
|
||||||
### 4. Privacy Features
|
|
||||||
- **Message Padding**: Obscures message length
|
|
||||||
- **Cover Traffic**: Optional dummy messages
|
|
||||||
- **Timing Obfuscation**: Randomized delays
|
|
||||||
- **Emergency Wipe**: Triple-tap to clear all data
|
|
||||||
|
|
||||||
### 5. Nostr Integration
|
|
||||||
- **NIP-17 Gift Wraps**: Maximum metadata privacy
|
|
||||||
- **Ephemeral Keys**: Each message uses unique ephemeral keys
|
|
||||||
- **Mutual Favorites Only**: Requires bidirectional trust
|
|
||||||
- **Transport Abstraction**: Users don't need to know about Nostr
|
|
||||||
|
|
||||||
## Code Organization
|
|
||||||
|
|
||||||
### Services (`/bitchat/Services/`)
|
|
||||||
Application-level services that coordinate between layers:
|
|
||||||
- `BluetoothMeshService`: Core networking
|
|
||||||
- `NoiseEncryptionService`: Encryption coordination
|
|
||||||
- `MessageRetryService`: Reliability layer
|
|
||||||
- `DeliveryTracker`: Acknowledgment handling
|
|
||||||
- `NotificationService`: System notifications
|
|
||||||
|
|
||||||
### Protocols (`/bitchat/Protocols/`)
|
|
||||||
Protocol definitions and implementations:
|
|
||||||
- `BitchatProtocol`: Application protocol
|
|
||||||
- `BinaryProtocol`: Low-level encoding
|
|
||||||
- `BinaryEncodingUtils`: Helper functions
|
|
||||||
|
|
||||||
### Noise (`/bitchat/Noise/`)
|
|
||||||
Noise Protocol Framework implementation:
|
|
||||||
- `NoiseProtocol`: Core cryptographic operations
|
|
||||||
- `NoiseSession`: Session state management
|
|
||||||
- `NoiseHandshakeCoordinator`: Handshake orchestration
|
|
||||||
- `NoiseSecurityConsiderations`: Security validations
|
|
||||||
|
|
||||||
### Views & ViewModels
|
|
||||||
MVVM architecture for UI:
|
|
||||||
- `ContentView`: Main chat interface
|
|
||||||
- `ChatViewModel`: Business logic and state
|
|
||||||
- Supporting views for settings, identity, etc.
|
|
||||||
|
|
||||||
## Nostr Protocol Implementation
|
|
||||||
|
|
||||||
### Overview
|
|
||||||
BitChat integrates Nostr as a secondary transport for communicating with mutual favorites when Bluetooth connectivity is unavailable. This integration is transparent to users - messages automatically route through Nostr when needed.
|
|
||||||
|
|
||||||
### NIP-17 Private Direct Messages
|
|
||||||
BitChat implements NIP-17 (Private Direct Messages) for metadata-private communication:
|
|
||||||
|
|
||||||
1. **Gift Wrap Structure**:
|
|
||||||
```
|
|
||||||
Gift Wrap (kind 1059) → Seal (kind 13) → Rumor (kind 1)
|
|
||||||
```
|
|
||||||
- **Rumor**: The actual message content (unsigned)
|
|
||||||
- **Seal**: Encrypted rumor, hides sender identity
|
|
||||||
- **Gift Wrap**: Double-encrypted, tagged for recipient
|
|
||||||
|
|
||||||
2. **Ephemeral Keys**:
|
|
||||||
- Each message uses TWO ephemeral key pairs
|
|
||||||
- Seal uses one ephemeral key
|
|
||||||
- Gift wrap uses a different ephemeral key
|
|
||||||
- Provides sender anonymity and forward secrecy
|
|
||||||
|
|
||||||
3. **Timestamp Randomization**:
|
|
||||||
- ±1 minute randomization (reduced from NIP-17's ±15 minutes)
|
|
||||||
- Prevents timing correlation attacks
|
|
||||||
- Configurable in `NostrProtocol.randomizedTimestamp()`
|
|
||||||
|
|
||||||
### Favorites Integration
|
|
||||||
|
|
||||||
The Nostr transport is only available for mutual favorites:
|
|
||||||
|
|
||||||
1. **Favorite Establishment**:
|
|
||||||
- User favorites a peer via `/fav` command
|
|
||||||
- Favorite notification sent via Bluetooth (if connected)
|
|
||||||
- Peer's Nostr public key exchanged during favorite process
|
|
||||||
- Stored in `FavoritesPersistenceService`
|
|
||||||
|
|
||||||
2. **Mutual Requirement**:
|
|
||||||
- Both peers must favorite each other
|
|
||||||
- Prevents spam and unwanted Nostr messages
|
|
||||||
- Enforced by `MessageRouter` transport selection
|
|
||||||
|
|
||||||
3. **Nostr Key Management**:
|
|
||||||
- Derived from Noise static key using BIP-32
|
|
||||||
- Path: `m/44'/1237'/0'/0/0` (1237 = "NOSTR" in decimal)
|
|
||||||
- Consistent npub across app reinstalls
|
|
||||||
- Keys never leave the device
|
|
||||||
|
|
||||||
### Message Routing Logic
|
|
||||||
|
|
||||||
`MessageRouter` automatically selects transport:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
if peerAvailableOnMesh {
|
|
||||||
transport = .bluetoothMesh // Always prefer mesh
|
|
||||||
} else if isMutualFavorite {
|
|
||||||
transport = .nostr // Use Nostr for offline favorites
|
|
||||||
} else {
|
|
||||||
throw MessageRouterError.peerNotReachable
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Relay Configuration
|
|
||||||
|
|
||||||
Default relays (hardcoded for reliability):
|
|
||||||
- `wss://relay.damus.io`
|
|
||||||
- `wss://relay.primal.net`
|
|
||||||
- `wss://offchain.pub`
|
|
||||||
- `wss://nostr21.com`
|
|
||||||
|
|
||||||
Relay selection criteria:
|
|
||||||
- Geographic distribution
|
|
||||||
- High uptime
|
|
||||||
- No authentication required
|
|
||||||
- Support for ephemeral events
|
|
||||||
|
|
||||||
### Message Format
|
|
||||||
|
|
||||||
Structured content for different message types:
|
|
||||||
- Chat: `MSG:<messageID>:<content>`
|
|
||||||
- Favorite: `FAVORITED:<senderNpub>` or `UNFAVORITED:<senderNpub>`
|
|
||||||
- Delivery ACK: `DELIVERED:<messageID>`
|
|
||||||
- Read Receipt: `READ:<base64EncodedReceipt>`
|
|
||||||
|
|
||||||
### Implementation Details
|
|
||||||
|
|
||||||
1. **NostrRelayManager**:
|
|
||||||
- Manages WebSocket connections to relays
|
|
||||||
- Handles reconnection logic
|
|
||||||
- Processes EVENT, EOSE, OK, NOTICE messages
|
|
||||||
- Implements NIP-01 relay protocol
|
|
||||||
|
|
||||||
2. **NostrProtocol**:
|
|
||||||
- Implements NIP-17 encryption/decryption
|
|
||||||
- Handles gift wrap creation/unwrapping
|
|
||||||
- Manages ephemeral key generation
|
|
||||||
- Provides Schnorr signatures
|
|
||||||
|
|
||||||
3. **ProcessedMessagesService**:
|
|
||||||
- Prevents duplicate message processing
|
|
||||||
- Tracks last subscription timestamp
|
|
||||||
- Persists across app launches
|
|
||||||
- 30-day retention window
|
|
||||||
|
|
||||||
### Security Considerations
|
|
||||||
|
|
||||||
1. **Metadata Protection**:
|
|
||||||
- Sender identity hidden via ephemeral keys
|
|
||||||
- Recipient only visible in gift wrap p-tag
|
|
||||||
- Timing correlation prevented via randomization
|
|
||||||
- Message content double-encrypted
|
|
||||||
|
|
||||||
2. **Relay Trust**:
|
|
||||||
- Relays cannot read message content
|
|
||||||
- Relays can see recipient pubkey (gift wrap)
|
|
||||||
- Relays cannot determine sender
|
|
||||||
- Multiple relays used for redundancy
|
|
||||||
|
|
||||||
3. **Key Hygiene**:
|
|
||||||
- Ephemeral keys used once and discarded
|
|
||||||
- Static Nostr key derived from Noise key
|
|
||||||
- No key reuse between messages
|
|
||||||
- Keys cleared from memory after use
|
|
||||||
|
|
||||||
### Debugging Nostr Issues
|
|
||||||
|
|
||||||
1. **Check relay connections**:
|
|
||||||
- Look for "Connected to Nostr relay" in logs
|
|
||||||
- Verify WebSocket state in NostrRelayManager
|
|
||||||
- Check for relay errors/notices
|
|
||||||
|
|
||||||
2. **Verify gift wrap creation**:
|
|
||||||
- Enable debug logging in NostrProtocol
|
|
||||||
- Check ephemeral key generation
|
|
||||||
- Verify encryption steps
|
|
||||||
|
|
||||||
3. **Message delivery**:
|
|
||||||
- Check ProcessedMessagesService for duplicates
|
|
||||||
- Verify subscription filters
|
|
||||||
- Look for EVENT messages in relay responses
|
|
||||||
|
|
||||||
## Development Guidelines
|
|
||||||
|
|
||||||
### 1. Security First
|
|
||||||
- Never log sensitive data (keys, message content)
|
|
||||||
- Use `SecureLogger` for security-aware logging
|
|
||||||
- Validate all inputs from network
|
|
||||||
- Follow principle of least privilege
|
|
||||||
|
|
||||||
### 2. Performance Considerations
|
|
||||||
- BLE has limited bandwidth (~20KB/s practical)
|
|
||||||
- Minimize protocol overhead
|
|
||||||
- Batch operations where possible
|
|
||||||
- Use compression for large messages
|
|
||||||
|
|
||||||
### 3. Testing
|
|
||||||
- Unit tests for protocol logic
|
|
||||||
- Integration tests for service interactions
|
|
||||||
- End-to-end tests for user flows
|
|
||||||
- Mock objects for BLE testing
|
|
||||||
|
|
||||||
### 4. Error Handling
|
|
||||||
- Graceful degradation for network issues
|
|
||||||
- Clear error messages for users
|
|
||||||
- Automatic retry with backoff
|
|
||||||
- Never expose internal errors
|
|
||||||
|
|
||||||
## Common Tasks
|
|
||||||
|
|
||||||
### Adding a New Message Type
|
|
||||||
1. Define in `MessageType` enum in `BitchatProtocol.swift`
|
|
||||||
2. Implement encoding/decoding logic
|
|
||||||
3. Add handling in `ChatViewModel`
|
|
||||||
4. Update UI if needed
|
|
||||||
5. Add tests
|
|
||||||
|
|
||||||
### Implementing a New Command
|
|
||||||
1. Add to `ChatViewModel.processCommand()`
|
|
||||||
2. Define any new message types needed
|
|
||||||
3. Implement command logic
|
|
||||||
4. Add autocomplete support
|
|
||||||
5. Update help text
|
|
||||||
|
|
||||||
### Debugging Bluetooth Issues
|
|
||||||
1. Check `BluetoothMeshService` logs
|
|
||||||
2. Verify peer states and connections
|
|
||||||
3. Monitor characteristic updates
|
|
||||||
4. Use Bluetooth debugging tools
|
|
||||||
|
|
||||||
### Working with Nostr Transport
|
|
||||||
1. Verify mutual favorite status in `FavoritesPersistenceService`
|
|
||||||
2. Check Nostr key derivation in `NostrIdentity`
|
|
||||||
3. Monitor relay connections in `NostrRelayManager`
|
|
||||||
4. Test gift wrap encryption/decryption
|
|
||||||
5. Verify transport selection in `MessageRouter`
|
|
||||||
|
|
||||||
### Adding Nostr Features
|
|
||||||
1. Understand NIP-17 gift wrap structure
|
|
||||||
2. Maintain ephemeral key hygiene
|
|
||||||
3. Test with multiple relays
|
|
||||||
4. Preserve metadata privacy
|
|
||||||
5. Handle relay disconnections gracefully
|
|
||||||
|
|
||||||
## Security Threat Model
|
|
||||||
|
|
||||||
### Assumptions
|
|
||||||
- Adversaries can intercept all Bluetooth traffic
|
|
||||||
- Devices may be compromised
|
|
||||||
- No trusted infrastructure available
|
|
||||||
|
|
||||||
### Protections
|
|
||||||
- End-to-end encryption for private messages
|
|
||||||
- Message authentication via HMAC
|
|
||||||
- Forward secrecy via ephemeral keys
|
|
||||||
- Deniability through lack of signatures
|
|
||||||
|
|
||||||
### Limitations
|
|
||||||
- Public messages are unencrypted by design
|
|
||||||
- Metadata (who talks to whom) partially visible
|
|
||||||
- Timing attacks possible on mesh network
|
|
||||||
- No protection against flooding/spam (yet)
|
|
||||||
|
|
||||||
## Performance Optimizations
|
|
||||||
|
|
||||||
### Implemented
|
|
||||||
- LZ4 compression for messages
|
|
||||||
- Adaptive duty cycling for battery
|
|
||||||
- Connection caching and reuse
|
|
||||||
- Bloom filters for deduplication
|
|
||||||
|
|
||||||
### Future Improvements
|
|
||||||
- Protocol buffer encoding
|
|
||||||
- Better mesh routing algorithms
|
|
||||||
- Predictive pre-connection
|
|
||||||
- Smarter retransmission
|
|
||||||
|
|
||||||
## Troubleshooting Guide
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
1. **Peers not discovering**: Check Bluetooth permissions, ensure app is in foreground
|
|
||||||
2. **Messages not delivering**: Verify mesh connectivity, check TTL values
|
|
||||||
3. **Handshake failures**: Ensure identity state is consistent, check key storage
|
|
||||||
4. **Performance issues**: Monitor connection count, check for message loops
|
|
||||||
|
|
||||||
## External Dependencies
|
|
||||||
|
|
||||||
### Swift Packages
|
|
||||||
- CryptoKit: Apple's crypto framework
|
|
||||||
- Network.framework: For future internet support
|
|
||||||
- No third-party dependencies (by design)
|
|
||||||
|
|
||||||
### System Requirements
|
|
||||||
- iOS 14.0+ / macOS 11.0+
|
|
||||||
- Bluetooth LE hardware
|
|
||||||
- ~50MB storage for app + data
|
|
||||||
|
|
||||||
## Future Roadmap
|
|
||||||
|
|
||||||
### Planned Features
|
|
||||||
- Internet bridging for hybrid networks
|
|
||||||
- Group chat with forward secrecy
|
|
||||||
- Voice messages with Opus codec
|
|
||||||
- File transfer support
|
|
||||||
|
|
||||||
### Architecture Evolution
|
|
||||||
- Plugin system for transports
|
|
||||||
- Modular protocol stack
|
|
||||||
- Cross-platform core library
|
|
||||||
- Federation between networks
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick Start for AI Assistants
|
|
||||||
|
|
||||||
1. **Understand the layers**: Transport → Protocol → Security → Services → UI
|
|
||||||
2. **Follow the data flow**: BLE/Nostr → Binary/JSON → Protocol → ViewModel → View
|
|
||||||
3. **Respect security boundaries**: Never mix trusted and untrusted data
|
|
||||||
4. **Test thoroughly**: This is critical infrastructure for users
|
|
||||||
5. **Ask about design decisions**: Many choices have non-obvious reasons
|
|
||||||
6. **Dual Transport**: Remember that messages can flow over Bluetooth OR Nostr
|
|
||||||
7. **Favorites System**: Nostr only works between mutual favorites
|
|
||||||
|
|
||||||
When in doubt, prioritize security and privacy over features. BitChat users depend on this app in situations where traditional communication has failed them.
|
|
||||||
@@ -37,7 +37,7 @@ This three-message pattern provides:
|
|||||||
#### NoiseEncryptionService
|
#### NoiseEncryptionService
|
||||||
The main service managing all Noise operations:
|
The main service managing all Noise operations:
|
||||||
```swift
|
```swift
|
||||||
class NoiseEncryptionService {
|
final class NoiseEncryptionService {
|
||||||
private let staticIdentityKey: Curve25519.KeyAgreement.PrivateKey
|
private let staticIdentityKey: Curve25519.KeyAgreement.PrivateKey
|
||||||
private let sessionManager: NoiseSessionManager
|
private let sessionManager: NoiseSessionManager
|
||||||
private let channelEncryption = NoiseChannelEncryption()
|
private let channelEncryption = NoiseChannelEncryption()
|
||||||
@@ -47,7 +47,7 @@ class NoiseEncryptionService {
|
|||||||
#### NoiseSession
|
#### NoiseSession
|
||||||
Individual session state for each peer:
|
Individual session state for each peer:
|
||||||
```swift
|
```swift
|
||||||
class NoiseSession {
|
final class NoiseSession {
|
||||||
private var handshakeState: NoiseHandshakeState?
|
private var handshakeState: NoiseHandshakeState?
|
||||||
private var sendCipher: NoiseCipherState?
|
private var sendCipher: NoiseCipherState?
|
||||||
private var receiveCipher: NoiseCipherState?
|
private var receiveCipher: NoiseCipherState?
|
||||||
@@ -58,7 +58,7 @@ class NoiseSession {
|
|||||||
#### NoiseSessionManager
|
#### NoiseSessionManager
|
||||||
Thread-safe session management:
|
Thread-safe session management:
|
||||||
```swift
|
```swift
|
||||||
class NoiseSessionManager {
|
final class NoiseSessionManager {
|
||||||
private var sessions: [String: NoiseSession] = [:]
|
private var sessions: [String: NoiseSession] = [:]
|
||||||
private let sessionsQueue = DispatchQueue(label: "noise.sessions", attributes: .concurrent)
|
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,11 @@
|
|||||||
|
MARKETING_VERSION = 1.4.4
|
||||||
|
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
|
||||||
@@ -14,7 +14,6 @@ default:
|
|||||||
# Check prerequisites
|
# Check prerequisites
|
||||||
check:
|
check:
|
||||||
@echo "Checking prerequisites..."
|
@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)
|
@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)
|
@security find-identity -v -p codesigning | grep -q "Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
|
||||||
@echo "✅ All prerequisites met"
|
@echo "✅ All prerequisites met"
|
||||||
@@ -22,8 +21,6 @@ check:
|
|||||||
# Backup original files
|
# Backup original files
|
||||||
backup:
|
backup:
|
||||||
@echo "Backing up original project configuration..."
|
@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.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
|
@if [ -f bitchat/Info.plist ]; then cp bitchat/Info.plist bitchat/Info.plist.backup; fi
|
||||||
|
|
||||||
@@ -44,20 +41,15 @@ patch-for-macos: backup
|
|||||||
@# Move iOS-specific files out of the way temporarily
|
@# Move iOS-specific files out of the way temporarily
|
||||||
@if [ -f bitchat/LaunchScreen.storyboard ]; then mv bitchat/LaunchScreen.storyboard bitchat/LaunchScreen.storyboard.ios; fi
|
@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 the macOS app
|
||||||
build: check generate
|
build: #check generate
|
||||||
@echo "Building BitChat for macOS..."
|
@echo "Building BitChat for macOS..."
|
||||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
||||||
|
|
||||||
# Run the macOS app
|
# Run the macOS app
|
||||||
run: build
|
run: build
|
||||||
@echo "Launching BitChat..."
|
@echo "Launching BitChat..."
|
||||||
@find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" | head -1 | xargs -I {} open "{}"
|
@find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}"
|
||||||
|
|
||||||
# Clean build artifacts and restore original files
|
# Clean build artifacts and restore original files
|
||||||
clean: restore
|
clean: restore
|
||||||
@@ -75,10 +67,8 @@ clean: restore
|
|||||||
# Quick run without cleaning (for development)
|
# Quick run without cleaning (for development)
|
||||||
dev-run: check
|
dev-run: check
|
||||||
@echo "Quick development build..."
|
@echo "Quick development build..."
|
||||||
@if [ ! -f project.yml.backup ]; then just patch-for-macos; fi
|
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
||||||
@xcodegen generate
|
@find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}"
|
||||||
@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/*" | head -1 | xargs -I {} open "{}"
|
|
||||||
|
|
||||||
# Show app info
|
# Show app info
|
||||||
info:
|
info:
|
||||||
@@ -106,11 +96,9 @@ nuke:
|
|||||||
@echo "🧨 Nuclear clean - removing all build artifacts and backups..."
|
@echo "🧨 Nuclear clean - removing all build artifacts and backups..."
|
||||||
@rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true
|
@rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true
|
||||||
@rm -rf bitchat.xcodeproj 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.xcodeproj/project.pbxproj.backup 2>/dev/null || true
|
||||||
@rm -f bitchat/Info.plist.backup 2>/dev/null || true
|
@rm -f bitchat/Info.plist.backup 2>/dev/null || true
|
||||||
@# Restore iOS-specific files if they were moved
|
@# Restore iOS-specific files if they were moved
|
||||||
@if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi
|
@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"
|
@echo "✅ Nuclear clean complete"
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "swift-secp256k1",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/21-DOT-DEV/swift-secp256k1",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "8c62aba8a3011c9bcea232e5ee007fb0b34a15e2",
|
||||||
|
"version" : "0.21.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 2
|
||||||
|
}
|
||||||
@@ -4,8 +4,9 @@ import PackageDescription
|
|||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "bitchat",
|
name: "bitchat",
|
||||||
|
defaultLocalization: "en",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v16),
|
.iOS(.v17),
|
||||||
.macOS(.v13)
|
.macOS(.v13)
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
@@ -14,9 +15,19 @@ let package = Package(
|
|||||||
targets: ["bitchat"]
|
targets: ["bitchat"]
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
dependencies:[
|
||||||
|
.package(path: "localPackages/Tor"),
|
||||||
|
.package(path: "localPackages/BitLogger"),
|
||||||
|
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
|
||||||
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.executableTarget(
|
.executableTarget(
|
||||||
name: "bitchat",
|
name: "bitchat",
|
||||||
|
dependencies: [
|
||||||
|
.product(name: "P256K", package: "swift-secp256k1"),
|
||||||
|
.product(name: "BitLogger", package: "BitLogger"),
|
||||||
|
.product(name: "Tor", package: "Tor")
|
||||||
|
],
|
||||||
path: "bitchat",
|
path: "bitchat",
|
||||||
exclude: [
|
exclude: [
|
||||||
"Info.plist",
|
"Info.plist",
|
||||||
@@ -24,7 +35,22 @@ let package = Package(
|
|||||||
"bitchat.entitlements",
|
"bitchat.entitlements",
|
||||||
"bitchat-macOS.entitlements",
|
"bitchat-macOS.entitlements",
|
||||||
"LaunchScreen.storyboard"
|
"LaunchScreen.storyboard"
|
||||||
|
],
|
||||||
|
resources: [
|
||||||
|
.process("Localizable.xcstrings")
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "bitchatTests",
|
||||||
|
dependencies: ["bitchat"],
|
||||||
|
path: "bitchatTests",
|
||||||
|
exclude: [
|
||||||
|
"Info.plist",
|
||||||
|
"README.md"
|
||||||
|
],
|
||||||
|
resources: [
|
||||||
|
.process("Localization")
|
||||||
|
]
|
||||||
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,90 +1,125 @@
|
|||||||
<img height="300" alt="bitchat" src="https://github.com/user-attachments/assets/2660f828-49c7-444d-beca-d8b01854667a" />
|
<img width="256" height="256" alt="icon_128x128@2x" src="https://github.com/user-attachments/assets/90133f83-b4f6-41c6-aab9-25d0859d2a47" />
|
||||||
|
|
||||||
## bitchat
|
## 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)
|
[bitchat.free](http://bitchat.free)
|
||||||
|
|
||||||
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
|
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> Private message and channel features 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.
|
> 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](https://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
|
||||||
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is released into the public domain. See the [LICENSE](LICENSE) file for details.
|
This project is released into the public domain. See the [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
|
|
||||||
## Features
|
## 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
|
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
|
||||||
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers
|
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers
|
||||||
- **Cover Traffic**: Timing obfuscation and dummy messages for enhanced privacy
|
- **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, NIP-17 for Nostr
|
||||||
- **Private Message End-to-End Encryption**: [Noise Protocol](http://noiseprotocol.org)
|
|
||||||
- **Store & Forward**: Messages cached for offline peers and delivered when they reconnect
|
|
||||||
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
|
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
|
||||||
- **Universal App**: Native support for iOS and macOS
|
- **Universal App**: Native support for iOS and macOS
|
||||||
- **Emergency Wipe**: Triple-tap to instantly clear all data
|
- **Emergency Wipe**: Triple-tap to instantly clear all data
|
||||||
- **Performance Optimizations**: LZ4 message compression, adaptive battery modes, and optimized networking
|
- **Performance Optimizations**: LZ4 message compression, adaptive battery modes, and optimized networking
|
||||||
|
|
||||||
|
## [Technical Architecture](https://deepwiki.com/permissionlesstech/bitchat)
|
||||||
|
|
||||||
## Technical Architecture
|
BitChat uses a **hybrid messaging architecture** with two complementary transport layers:
|
||||||
|
|
||||||
### Binary Protocol
|
### Bluetooth Mesh Network (Offline)
|
||||||
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
|
|
||||||
|
|
||||||
### Mesh Networking
|
- **Local Communication**: Direct peer-to-peer within Bluetooth range
|
||||||
- Each device acts as both client and peripheral
|
- **Multi-hop Relay**: Messages route through nearby devices (max 7 hops)
|
||||||
- Automatic peer discovery and connection management
|
- **No Internet Required**: Works completely offline in disaster scenarios
|
||||||
- Store-and-forward for offline message delivery
|
- **Noise Protocol Encryption**: End-to-end encryption with forward secrecy
|
||||||
- Adaptive duty cycling for battery optimization
|
- **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).
|
For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.md).
|
||||||
|
|
||||||
|
|
||||||
## Setup
|
## 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
|
```bash
|
||||||
cd bitchat
|
cd bitchat
|
||||||
xcodegen generate
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Open the generated project:
|
|
||||||
```bash
|
|
||||||
open bitchat.xcodeproj
|
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
|
```bash
|
||||||
cd bitchat
|
brew install just
|
||||||
open Package.swift
|
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Select your target device and run
|
Want to try this on macos: `just run` will set it up and run from source.
|
||||||
|
|
||||||
### 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.
|
|
||||||
Run `just clean` afterwards to restore things to original state for mobile app building and development.
|
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.
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ While the Noise handshake cryptographically authenticates a peer's key, it doesn
|
|||||||
### 4.2. Favorites and Blocking
|
### 4.2. Favorites and Blocking
|
||||||
|
|
||||||
To improve the user experience and provide control over interactions, the protocol supports:
|
To improve the user experience and provide control over interactions, the protocol supports:
|
||||||
* **Favorites:** Users can mark trusted or frequently contacted peers as "favorites." This is a local designation that can be used by the application to prioritize notifications or display peers more prominently.
|
* **Favorites:** Users can mark trusted or frequently contacted peers as "favorites". This is a local designation that can be used by the application to prioritize notifications or display peers more prominently.
|
||||||
* **Blocking:** Users can block peers. When a peer is blocked, the application will discard any incoming packets from that peer's fingerprint at the earliest possible stage, effectively silencing them without notifying the blocked peer.
|
* **Blocking:** Users can block peers. When a peer is blocked, the application will discard any incoming packets from that peer's fingerprint at the earliest possible stage, effectively silencing them without notifying the blocked peer.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -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.
|
**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`)
|
### 6.2. Application Message Format (`BitchatMessage`)
|
||||||
|
|
||||||
For packets of type `message`, the payload is a binary-serialized `BitchatMessage` containing the chat content.
|
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. |
|
| 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. |
|
| 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
|
## 7. Message Routing and Propagation
|
||||||
@@ -215,7 +256,7 @@ To send messages to peers that are not directly connected, BitChat employs a "fl
|
|||||||
The logic is as follows:
|
The logic is as follows:
|
||||||
|
|
||||||
1. A peer receives a packet.
|
1. A peer receives a packet.
|
||||||
2. It checks the Bloom filter to see if the packet's ID has likely been seen before. If so, the packet is discarded. Bloom filters can have false positives (though they are rare), but they guarantee no false negatives. This means a new packet will never be accidentally discarded.
|
2. It checks the Bloom filter to see if the packet's ID has likely been seen before. If so, the packet is discarded. Bloom filters can have false positives (though they are rare), but they guarantee no false negatives. This means that while some packets may be incorrectly discarded due to false positives, the gossip protocol's redundancy ensures these packets will eventually be received through subsequent exchanges with other peers.
|
||||||
3. If the packet is new, its ID is added to the Bloom filter.
|
3. If the packet is new, its ID is added to the Bloom filter.
|
||||||
4. The peer decrements the packet's Time-To-Live (TTL) field.
|
4. The peer decrements the packet's Time-To-Live (TTL) field.
|
||||||
5. If the TTL is greater than zero, the peer re-broadcasts the packet to all of its connected peers, *except* for the peer from which it received the packet.
|
5. If the TTL is greater than zero, the peer re-broadcasts the packet to all of its connected peers, *except* for the peer from which it received the packet.
|
||||||
@@ -265,4 +306,4 @@ Receiving peers collect all fragments and reassemble them in the correct order b
|
|||||||
|
|
||||||
## 9. Conclusion
|
## 9. Conclusion
|
||||||
|
|
||||||
The BitChat Protocol provides a robust and secure foundation for decentralized, peer-to-peer communication. By layering a flexible application protocol on top of the well-regarded Noise Protocol Framework, it achieves strong confidentiality, authentication, and forward secrecy. The use of a compact binary format and thoughtful security considerations like rate limiting and traffic analysis resistance make it suitable for use in challenging network environments.
|
The BitChat Protocol provides a robust and secure foundation for decentralized, peer-to-peer communication. By layering a flexible application protocol on top of the well-regarded Noise Protocol Framework, it achieves strong confidentiality, authentication, and forward secrecy. The use of a compact binary format and thoughtful security considerations like rate limiting and traffic analysis resistance make it suitable for use in challenging network environments.
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1640"
|
||||||
|
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 = "-DBITCHAT_DEV_ALLOW_CLEARNET"
|
||||||
|
value = ""
|
||||||
|
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 = "1640"
|
||||||
|
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>
|
||||||
@@ -1,111 +1,9 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"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",
|
"filename" : "icon_1024x1024.png",
|
||||||
"idiom" : "ios-marketing",
|
"idiom" : "universal",
|
||||||
"scale" : "1x",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"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,38 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "image-1024.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"filename" : "image-1024 1.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "tinted"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"filename" : "image-1024 2.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 85 KiB |
@@ -3,4 +3,4 @@
|
|||||||
"author" : "xcode",
|
"author" : "xcode",
|
||||||
"version" : 1
|
"version" : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,20 +6,38 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Tor
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct BitchatApp: App {
|
struct BitchatApp: App {
|
||||||
@StateObject private var chatViewModel = ChatViewModel()
|
static let bundleID = Bundle.main.bundleIdentifier ?? "chat.bitchat"
|
||||||
|
static let groupID = "group.\(bundleID)"
|
||||||
|
|
||||||
|
@StateObject private var chatViewModel: ChatViewModel
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
|
@Environment(\.scenePhase) var scenePhase
|
||||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||||
|
// Skip the very first .active-triggered Tor restart on cold launch
|
||||||
|
@State private var didHandleInitialActive: Bool = false
|
||||||
|
@State private var didEnterBackground: Bool = false
|
||||||
#elseif os(macOS)
|
#elseif os(macOS)
|
||||||
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
let keychain = KeychainManager()
|
||||||
|
_chatViewModel = StateObject(
|
||||||
|
wrappedValue: ChatViewModel(
|
||||||
|
keychain: keychain,
|
||||||
|
identityManager: SecureIdentityStateManager(keychain)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
|
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
|
||||||
|
// Warm up georelay directory and refresh if stale (once/day)
|
||||||
|
GeoRelayDirectory.shared.prefetchIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
@@ -28,11 +46,20 @@ struct BitchatApp: App {
|
|||||||
.environmentObject(chatViewModel)
|
.environmentObject(chatViewModel)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
NotificationDelegate.shared.chatViewModel = chatViewModel
|
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)
|
#if os(iOS)
|
||||||
appDelegate.chatViewModel = chatViewModel
|
appDelegate.chatViewModel = chatViewModel
|
||||||
#elseif os(macOS)
|
#elseif os(macOS)
|
||||||
appDelegate.chatViewModel = chatViewModel
|
appDelegate.chatViewModel = chatViewModel
|
||||||
#endif
|
#endif
|
||||||
|
// Initialize network activation policy; will start Tor/Nostr only when allowed
|
||||||
|
NetworkActivationService.shared.start()
|
||||||
// Check for shared content
|
// Check for shared content
|
||||||
checkForSharedContent()
|
checkForSharedContent()
|
||||||
}
|
}
|
||||||
@@ -40,16 +67,59 @@ struct BitchatApp: App {
|
|||||||
handleURL(url)
|
handleURL(url)
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
|
.onChange(of: scenePhase) { newPhase in
|
||||||
|
switch newPhase {
|
||||||
|
case .background:
|
||||||
|
// Keep BLE mesh running in background; BLEService adapts scanning automatically
|
||||||
|
// Always send Tor to dormant on background for a clean restart later.
|
||||||
|
TorManager.shared.setAppForeground(false)
|
||||||
|
TorManager.shared.goDormantOnBackground()
|
||||||
|
// Stop geohash sampling while backgrounded
|
||||||
|
Task { @MainActor in
|
||||||
|
chatViewModel.endGeohashSampling()
|
||||||
|
}
|
||||||
|
// Proactively disconnect Nostr to avoid spurious socket errors while Tor is down
|
||||||
|
NostrRelayManager.shared.disconnect()
|
||||||
|
didEnterBackground = true
|
||||||
|
case .active:
|
||||||
|
// Restart services when becoming active
|
||||||
|
chatViewModel.meshService.startServices()
|
||||||
|
TorManager.shared.setAppForeground(true)
|
||||||
|
// On initial cold launch, Tor was just started in onAppear.
|
||||||
|
// Skip the deterministic restart the first time we become active.
|
||||||
|
if didHandleInitialActive && didEnterBackground {
|
||||||
|
if TorManager.shared.isAutoStartAllowed() && !TorManager.shared.isReady {
|
||||||
|
TorManager.shared.ensureRunningOnForeground()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
didHandleInitialActive = true
|
||||||
|
}
|
||||||
|
didEnterBackground = false
|
||||||
|
if TorManager.shared.isAutoStartAllowed() {
|
||||||
|
Task.detached {
|
||||||
|
let _ = await TorManager.shared.awaitReady(timeout: 60)
|
||||||
|
await MainActor.run {
|
||||||
|
// Rebuild proxied sessions to bind to the live Tor after readiness
|
||||||
|
TorURLSession.shared.rebuild()
|
||||||
|
// Reconnect Nostr via fresh sessions; will gate until Tor 100%
|
||||||
|
NostrRelayManager.shared.resetAllConnections()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkForSharedContent()
|
||||||
|
case .inactive:
|
||||||
|
break
|
||||||
|
@unknown default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
||||||
// Check for shared content when app becomes active
|
// Check for shared content when app becomes active
|
||||||
checkForSharedContent()
|
checkForSharedContent()
|
||||||
// Notify MessageRouter to check for Nostr messages
|
|
||||||
NotificationCenter.default.post(name: .appDidBecomeActive, object: nil)
|
|
||||||
}
|
}
|
||||||
#elseif os(macOS)
|
#elseif os(macOS)
|
||||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
||||||
// Notify MessageRouter to check for Nostr messages
|
// App became active
|
||||||
NotificationCenter.default.post(name: .appDidBecomeActive, object: nil)
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@@ -68,7 +138,7 @@ struct BitchatApp: App {
|
|||||||
|
|
||||||
private func checkForSharedContent() {
|
private func checkForSharedContent() {
|
||||||
// Check app group for shared content from extension
|
// Check app group for shared content from extension
|
||||||
guard let userDefaults = UserDefaults(suiteName: "group.chat.bitchat") else {
|
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,30 +147,18 @@ struct BitchatApp: App {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only process if shared within last 30 seconds
|
// Only process if shared within configured window
|
||||||
if Date().timeIntervalSince(sharedDate) < 30 {
|
if Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds {
|
||||||
let contentType = userDefaults.string(forKey: "sharedContentType") ?? "text"
|
let contentType = userDefaults.string(forKey: "sharedContentType") ?? "text"
|
||||||
|
|
||||||
// Clear the shared content
|
// Clear the shared content
|
||||||
userDefaults.removeObject(forKey: "sharedContent")
|
userDefaults.removeObject(forKey: "sharedContent")
|
||||||
userDefaults.removeObject(forKey: "sharedContentType")
|
userDefaults.removeObject(forKey: "sharedContentType")
|
||||||
userDefaults.removeObject(forKey: "sharedContentDate")
|
userDefaults.removeObject(forKey: "sharedContentDate")
|
||||||
userDefaults.synchronize()
|
// No need to force synchronize here
|
||||||
|
|
||||||
// Show notification about shared content
|
// Send the shared content immediately on the main queue
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
// Add system message about sharing
|
|
||||||
let systemMessage = BitchatMessage(
|
|
||||||
sender: "system",
|
|
||||||
content: "preparing to share \(contentType)...",
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false
|
|
||||||
)
|
|
||||||
self.chatViewModel.messages.append(systemMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send the shared content after a short delay
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
|
||||||
if contentType == "url" {
|
if contentType == "url" {
|
||||||
// Try to parse as JSON first
|
// Try to parse as JSON first
|
||||||
if let data = sharedContent.data(using: .utf8),
|
if let data = sharedContent.data(using: .utf8),
|
||||||
@@ -121,7 +179,7 @@ struct BitchatApp: App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||||
weak var chatViewModel: ChatViewModel?
|
weak var chatViewModel: ChatViewModel?
|
||||||
|
|
||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||||
@@ -133,7 +191,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
import AppKit
|
import AppKit
|
||||||
|
|
||||||
class MacAppDelegate: NSObject, NSApplicationDelegate {
|
final class MacAppDelegate: NSObject, NSApplicationDelegate {
|
||||||
weak var chatViewModel: ChatViewModel?
|
weak var chatViewModel: ChatViewModel?
|
||||||
|
|
||||||
func applicationWillTerminate(_ notification: Notification) {
|
func applicationWillTerminate(_ notification: Notification) {
|
||||||
@@ -146,7 +204,7 @@ class MacAppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||||
static let shared = NotificationDelegate()
|
static let shared = NotificationDelegate()
|
||||||
weak var chatViewModel: ChatViewModel?
|
weak var chatViewModel: ChatViewModel?
|
||||||
|
|
||||||
@@ -163,6 +221,14 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Handle deeplink (e.g., geohash activity)
|
||||||
|
if let deep = userInfo["deeplink"] as? String, let url = URL(string: deep) {
|
||||||
|
#if os(iOS)
|
||||||
|
DispatchQueue.main.async { UIApplication.shared.open(url) }
|
||||||
|
#else
|
||||||
|
DispatchQueue.main.async { NSWorkspace.shared.open(url) }
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
completionHandler()
|
completionHandler()
|
||||||
}
|
}
|
||||||
@@ -182,6 +248,15 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Suppress geohash activity notification if we're already in that geohash channel
|
||||||
|
if identifier.hasPrefix("geo-activity-"),
|
||||||
|
let deep = userInfo["deeplink"] as? String,
|
||||||
|
let gh = deep.components(separatedBy: "/").last {
|
||||||
|
if case .location(let ch) = LocationChannelManager.shared.selectedChannel, ch.geohash == gh {
|
||||||
|
completionHandler([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Show notification in all other cases
|
// Show notification in all other cases
|
||||||
completionHandler([.banner, .sound])
|
completionHandler([.banner, .sound])
|
||||||
@@ -192,4 +267,4 @@ extension String {
|
|||||||
var nilIfEmpty: String? {
|
var nilIfEmpty: String? {
|
||||||
self.isEmpty ? nil : self
|
self.isEmpty ? nil : self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ import Foundation
|
|||||||
/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy.
|
/// 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.
|
/// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships.
|
||||||
struct EphemeralIdentity {
|
struct EphemeralIdentity {
|
||||||
let peerID: String // 8 random bytes
|
let peerID: PeerID // 8 random bytes
|
||||||
let sessionStart: Date
|
let sessionStart: Date
|
||||||
var handshakeState: HandshakeState
|
var handshakeState: HandshakeState
|
||||||
}
|
}
|
||||||
@@ -106,6 +106,8 @@ enum HandshakeState {
|
|||||||
struct CryptographicIdentity: Codable {
|
struct CryptographicIdentity: Codable {
|
||||||
let fingerprint: String // SHA256 of public key
|
let fingerprint: String // SHA256 of public key
|
||||||
let publicKey: Data // Noise static 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 firstSeen: Date
|
||||||
let lastHandshake: Date?
|
let lastHandshake: Date?
|
||||||
}
|
}
|
||||||
@@ -149,77 +151,14 @@ struct IdentityCache: Codable {
|
|||||||
// Last interaction timestamps (privacy: optional)
|
// Last interaction timestamps (privacy: optional)
|
||||||
var lastInteractions: [String: Date] = [:]
|
var lastInteractions: [String: Date] = [:]
|
||||||
|
|
||||||
|
// Blocked Nostr pubkeys (lowercased hex) for geohash chats
|
||||||
|
var blockedNostrPubkeys: Set<String> = []
|
||||||
|
|
||||||
// Schema version for future migrations
|
// Schema version for future migrations
|
||||||
var version: Int = 1
|
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: - Privacy Settings
|
|
||||||
|
|
||||||
struct PrivacySettings: Codable {
|
|
||||||
// Level 1: Maximum privacy (default)
|
|
||||||
var persistIdentityCache = false
|
|
||||||
var showLastSeen = false
|
|
||||||
|
|
||||||
// Level 2: Convenience
|
|
||||||
var autoAcceptKnownFingerprints = false
|
|
||||||
var rememberNicknameHistory = false
|
|
||||||
|
|
||||||
// Level 3: Social
|
|
||||||
var shareTrustNetworkHints = false // "3 mutual contacts trust this person"
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Conflict Resolution
|
|
||||||
|
|
||||||
/// Strategies for resolving identity conflicts in the decentralized network.
|
|
||||||
/// Handles cases where multiple peers claim the same nickname or when
|
|
||||||
/// identity mappings become ambiguous due to network partitions.
|
|
||||||
enum ConflictResolution {
|
|
||||||
case acceptNew(petname: String) // "John (2)"
|
|
||||||
case rejectNew
|
|
||||||
case blockFingerprint(String)
|
|
||||||
case alertUser(message: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - UI State
|
|
||||||
|
|
||||||
struct PeerUIState {
|
|
||||||
let peerID: String
|
|
||||||
let nickname: String
|
|
||||||
var identityState: IdentityState
|
|
||||||
var connectionQuality: ConnectionQuality
|
|
||||||
|
|
||||||
enum IdentityState {
|
|
||||||
case unknown // Gray - No identity info
|
|
||||||
case unverifiedKnown(String) // Blue - Handshake done, matches cache
|
|
||||||
case verified(String) // Green - Cryptographically verified
|
|
||||||
case conflict(String, String) // Red - Nickname doesn't match fingerprint
|
|
||||||
case pending // Yellow - Handshake in progress
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ConnectionQuality {
|
|
||||||
case excellent
|
|
||||||
case good
|
|
||||||
case poor
|
|
||||||
case disconnected
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Migration Support
|
// MARK: - Migration Support
|
||||||
// Removed LegacyFavorite - no longer needed
|
//
|
||||||
|
|||||||
@@ -90,27 +90,63 @@
|
|||||||
/// - Advanced conflict resolution
|
/// - Advanced conflict resolution
|
||||||
///
|
///
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
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.
|
/// Singleton manager for secure identity state persistence and retrieval.
|
||||||
/// Provides thread-safe access to identity mappings with encryption at rest.
|
/// Provides thread-safe access to identity mappings with encryption at rest.
|
||||||
/// All identity data is stored encrypted in the device Keychain for security.
|
/// All identity data is stored encrypted in the device Keychain for security.
|
||||||
class SecureIdentityStateManager {
|
final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||||
static let shared = SecureIdentityStateManager()
|
private let keychain: KeychainManagerProtocol
|
||||||
|
|
||||||
private let keychain = KeychainManager.shared
|
|
||||||
private let cacheKey = "bitchat.identityCache.v2"
|
private let cacheKey = "bitchat.identityCache.v2"
|
||||||
private let encryptionKeyName = "identityCacheEncryptionKey"
|
private let encryptionKeyName = "identityCacheEncryptionKey"
|
||||||
|
|
||||||
// In-memory state
|
// In-memory state
|
||||||
private var ephemeralSessions: [String: EphemeralIdentity] = [:]
|
private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:]
|
||||||
private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
|
private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
|
||||||
private var cache: IdentityCache = IdentityCache()
|
private var cache: IdentityCache = IdentityCache()
|
||||||
|
|
||||||
// Pending actions before handshake
|
|
||||||
private var pendingActions: [String: PendingActions] = [:]
|
|
||||||
|
|
||||||
// Thread safety
|
// Thread safety
|
||||||
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
|
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
|
||||||
|
|
||||||
@@ -122,14 +158,16 @@ class SecureIdentityStateManager {
|
|||||||
// Encryption key
|
// Encryption key
|
||||||
private let encryptionKey: SymmetricKey
|
private let encryptionKey: SymmetricKey
|
||||||
|
|
||||||
private init() {
|
init(_ keychain: KeychainManagerProtocol) {
|
||||||
|
self.keychain = keychain
|
||||||
|
|
||||||
// Generate or retrieve encryption key from keychain
|
// Generate or retrieve encryption key from keychain
|
||||||
let loadedKey: SymmetricKey
|
let loadedKey: SymmetricKey
|
||||||
|
|
||||||
// Try to load from keychain
|
// Try to load from keychain
|
||||||
if let keyData = keychain.getIdentityKey(forKey: encryptionKeyName) {
|
if let keyData = keychain.getIdentityKey(forKey: encryptionKeyName) {
|
||||||
loadedKey = SymmetricKey(data: keyData)
|
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
|
// Generate new key if needed
|
||||||
else {
|
else {
|
||||||
@@ -137,7 +175,7 @@ class SecureIdentityStateManager {
|
|||||||
let keyData = loadedKey.withUnsafeBytes { Data($0) }
|
let keyData = loadedKey.withUnsafeBytes { Data($0) }
|
||||||
// Save to keychain
|
// Save to keychain
|
||||||
let saved = keychain.saveIdentityKey(keyData, forKey: encryptionKeyName)
|
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
|
self.encryptionKey = loadedKey
|
||||||
@@ -146,9 +184,13 @@ class SecureIdentityStateManager {
|
|||||||
loadIdentityCache()
|
loadIdentityCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
forceSave()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Secure Loading/Saving
|
// MARK: - Secure Loading/Saving
|
||||||
|
|
||||||
func loadIdentityCache() {
|
private func loadIdentityCache() {
|
||||||
guard let encryptedData = keychain.getIdentityKey(forKey: cacheKey) else {
|
guard let encryptedData = keychain.getIdentityKey(forKey: cacheKey) else {
|
||||||
// No existing cache, start fresh
|
// No existing cache, start fresh
|
||||||
return
|
return
|
||||||
@@ -160,16 +202,11 @@ class SecureIdentityStateManager {
|
|||||||
cache = try JSONDecoder().decode(IdentityCache.self, from: decryptedData)
|
cache = try JSONDecoder().decode(IdentityCache.self, from: decryptedData)
|
||||||
} catch {
|
} catch {
|
||||||
// Log error but continue with empty cache
|
// Log error but continue with empty cache
|
||||||
SecureLogger.logError(error, context: "Failed to load identity cache", category: SecureLogger.security)
|
SecureLogger.error(error, context: "Failed to load identity cache", category: .security)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
private func saveIdentityCache() {
|
||||||
// Force save any pending changes
|
|
||||||
forceSave()
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveIdentityCache() {
|
|
||||||
// Mark that we need to save
|
// Mark that we need to save
|
||||||
pendingSave = true
|
pendingSave = true
|
||||||
|
|
||||||
@@ -191,35 +228,17 @@ class SecureIdentityStateManager {
|
|||||||
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
|
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
|
||||||
let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey)
|
let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey)
|
||||||
if saved {
|
if saved {
|
||||||
SecureLogger.log("Identity cache saved to keychain", category: SecureLogger.security, level: .debug)
|
SecureLogger.debug("Identity cache saved to keychain", category: .security)
|
||||||
}
|
}
|
||||||
} catch {
|
} 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)
|
// Force immediate save (for app termination)
|
||||||
func forceSave() {
|
func forceSave() {
|
||||||
saveTimer?.invalidate()
|
saveTimer?.invalidate()
|
||||||
if pendingSave {
|
performSave()
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Social Identity Management
|
// MARK: - Social Identity Management
|
||||||
@@ -229,10 +248,84 @@ class SecureIdentityStateManager {
|
|||||||
return cache.socialIdentities[fingerprint]
|
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 {
|
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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,7 +403,7 @@ class SecureIdentityStateManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func setBlocked(_ fingerprint: String, isBlocked: Bool) {
|
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) {
|
queue.async(flags: .barrier) {
|
||||||
if var identity = self.cache.socialIdentities[fingerprint] {
|
if var identity = self.cache.socialIdentities[fingerprint] {
|
||||||
@@ -335,10 +428,34 @@ class SecureIdentityStateManager {
|
|||||||
self.saveIdentityCache()
|
self.saveIdentityCache()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Geohash (Nostr) Blocking
|
||||||
|
|
||||||
|
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool {
|
||||||
|
queue.sync {
|
||||||
|
return cache.blockedNostrPubkeys.contains(pubkeyHexLowercased.lowercased())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) {
|
||||||
|
let key = pubkeyHexLowercased.lowercased()
|
||||||
|
queue.async(flags: .barrier) {
|
||||||
|
if isBlocked {
|
||||||
|
self.cache.blockedNostrPubkeys.insert(key)
|
||||||
|
} else {
|
||||||
|
self.cache.blockedNostrPubkeys.remove(key)
|
||||||
|
}
|
||||||
|
self.saveIdentityCache()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBlockedNostrPubkeys() -> Set<String> {
|
||||||
|
queue.sync { cache.blockedNostrPubkeys }
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Ephemeral Session Management
|
// MARK: - Ephemeral Session Management
|
||||||
|
|
||||||
func registerEphemeralSession(peerID: String, handshakeState: HandshakeState = .none) {
|
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
self.ephemeralSessions[peerID] = EphemeralIdentity(
|
self.ephemeralSessions[peerID] = EphemeralIdentity(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
@@ -348,7 +465,7 @@ class SecureIdentityStateManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateHandshakeState(peerID: String, state: HandshakeState) {
|
func updateHandshakeState(peerID: PeerID, state: HandshakeState) {
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
self.ephemeralSessions[peerID]?.handshakeState = state
|
self.ephemeralSessions[peerID]?.handshakeState = state
|
||||||
|
|
||||||
@@ -360,81 +477,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
|
// MARK: - Cleanup
|
||||||
|
|
||||||
func clearAllIdentityData() {
|
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) {
|
queue.async(flags: .barrier) {
|
||||||
self.cache = IdentityCache()
|
self.cache = IdentityCache()
|
||||||
self.ephemeralSessions.removeAll()
|
self.ephemeralSessions.removeAll()
|
||||||
self.cryptographicIdentities.removeAll()
|
self.cryptographicIdentities.removeAll()
|
||||||
self.pendingActions.removeAll()
|
|
||||||
|
|
||||||
// Delete from keychain
|
// Delete from keychain
|
||||||
let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey)
|
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) {
|
queue.async(flags: .barrier) {
|
||||||
self.ephemeralSessions.removeValue(forKey: peerID)
|
self.ephemeralSessions.removeValue(forKey: peerID)
|
||||||
self.pendingActions.removeValue(forKey: peerID)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Verification
|
// MARK: - Verification
|
||||||
|
|
||||||
func setVerified(fingerprint: String, verified: Bool) {
|
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) {
|
queue.async(flags: .barrier) {
|
||||||
if verified {
|
if verified {
|
||||||
@@ -464,4 +532,4 @@ class SecureIdentityStateManager {
|
|||||||
return cache.verifiedFingerprints
|
return cache.verifiedFingerprints
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,37 +35,22 @@
|
|||||||
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
|
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
|
||||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</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>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>bluetooth-central</string>
|
<string>bluetooth-central</string>
|
||||||
<string>bluetooth-peripheral</string>
|
<string>bluetooth-peripheral</string>
|
||||||
<string>remote-notification</string>
|
|
||||||
</array>
|
</array>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string>LaunchScreen</string>
|
<string>LaunchScreen</string>
|
||||||
|
<key>UIRequiresFullScreen</key>
|
||||||
|
<true/>
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
|
||||||
<array>
|
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
|
||||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
|
||||||
</array>
|
|
||||||
<key>NSAppTransportSecurity</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSAllowsArbitraryLoads</key>
|
|
||||||
<false/>
|
|
||||||
<key>NSAllowsArbitraryLoadsForMedia</key>
|
|
||||||
<false/>
|
|
||||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
|
||||||
<false/>
|
|
||||||
<key>NSAllowsLocalNetworking</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
|
||||||
<string>bitchat uses local network access to discover and connect with other bitchat users on your network.</string>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -37,4 +37,4 @@
|
|||||||
<point key="canvasLocation" x="53" y="375"/>
|
<point key="canvasLocation" x="53" y="375"/>
|
||||||
</scene>
|
</scene>
|
||||||
</scenes>
|
</scenes>
|
||||||
</document>
|
</document>
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
//
|
||||||
|
// BitchatMessage.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
final 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: PeerID?
|
||||||
|
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: PeerID? = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Equatable Conformance
|
||||||
|
|
||||||
|
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: - Binary encoding
|
||||||
|
|
||||||
|
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 peerData = senderPeerID?.id.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
|
||||||
|
}
|
||||||
|
|
||||||
|
convenience init?(_ data: Data) {
|
||||||
|
// 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: PeerID?
|
||||||
|
if hasSenderPeerID && offset < dataCopy.count {
|
||||||
|
let length = Int(dataCopy[offset]); offset += 1
|
||||||
|
if offset + length <= dataCopy.count {
|
||||||
|
senderPeerID = PeerID(data: dataCopy[offset..<offset+length])
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.init(
|
||||||
|
id: id,
|
||||||
|
sender: sender,
|
||||||
|
content: content,
|
||||||
|
timestamp: timestamp,
|
||||||
|
isRelay: isRelay,
|
||||||
|
originalSender: originalSender,
|
||||||
|
isPrivate: isPrivate,
|
||||||
|
recipientNickname: recipientNickname,
|
||||||
|
senderPeerID: senderPeerID,
|
||||||
|
mentions: mentions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
extension BitchatMessage {
|
||||||
|
|
||||||
|
private static let timestampFormatter: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "HH:mm:ss"
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
var formattedTimestamp: String {
|
||||||
|
Self.timestampFormatter.string(from: timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - System Message Factory
|
||||||
|
|
||||||
|
extension BitchatMessage {
|
||||||
|
/// Creates a system message with default values
|
||||||
|
static func system(_ content: String, timestamp: Date = Date()) -> BitchatMessage {
|
||||||
|
return BitchatMessage(
|
||||||
|
sender: "system",
|
||||||
|
content: content,
|
||||||
|
timestamp: timestamp,
|
||||||
|
isRelay: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Array where Element == BitchatMessage {
|
||||||
|
/// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest)
|
||||||
|
func cleanedAndDeduped() -> [Element] {
|
||||||
|
let arr = filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false }
|
||||||
|
guard arr.count > 1 else {
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
var seen = Set<String>()
|
||||||
|
var dedup: [BitchatMessage] = []
|
||||||
|
for m in arr.sorted(by: { $0.timestamp < $1.timestamp }) {
|
||||||
|
if !seen.contains(m.id) {
|
||||||
|
dedup.append(m)
|
||||||
|
seen.insert(m.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dedup
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
//
|
||||||
|
// BitchatPacket.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 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: PeerID, payload: Data) {
|
||||||
|
self.version = 1
|
||||||
|
self.type = type
|
||||||
|
// Convert hex string peer ID to binary data (8 bytes)
|
||||||
|
var senderData = Data()
|
||||||
|
var tempID = senderID.id
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,13 @@ import Foundation
|
|||||||
import CoreBluetooth
|
import CoreBluetooth
|
||||||
|
|
||||||
/// Represents a peer in the BitChat network with all associated metadata
|
/// Represents a peer in the BitChat network with all associated metadata
|
||||||
struct BitchatPeer: Identifiable, Equatable {
|
struct BitchatPeer: Equatable {
|
||||||
let id: String // Hex-encoded peer ID
|
let peerID: PeerID // Hex-encoded peer ID
|
||||||
let noisePublicKey: Data
|
let noisePublicKey: Data
|
||||||
let nickname: String
|
let nickname: String
|
||||||
let lastSeen: Date
|
let lastSeen: Date
|
||||||
let isConnected: Bool
|
let isConnected: Bool
|
||||||
|
let isReachable: Bool
|
||||||
|
|
||||||
// Favorite-related properties
|
// Favorite-related properties
|
||||||
var favoriteStatus: FavoritesPersistenceService.FavoriteRelationship?
|
var favoriteStatus: FavoritesPersistenceService.FavoriteRelationship?
|
||||||
@@ -18,7 +19,7 @@ struct BitchatPeer: Identifiable, Equatable {
|
|||||||
// Connection state
|
// Connection state
|
||||||
enum ConnectionState {
|
enum ConnectionState {
|
||||||
case bluetoothConnected
|
case bluetoothConnected
|
||||||
case relayConnected // Connected via mesh relay (another peer)
|
case meshReachable // Seen via mesh recently, not directly connected
|
||||||
case nostrAvailable // Mutual favorite, reachable via Nostr
|
case nostrAvailable // Mutual favorite, reachable via Nostr
|
||||||
case offline // Not connected via any transport
|
case offline // Not connected via any transport
|
||||||
}
|
}
|
||||||
@@ -26,8 +27,8 @@ struct BitchatPeer: Identifiable, Equatable {
|
|||||||
var connectionState: ConnectionState {
|
var connectionState: ConnectionState {
|
||||||
if isConnected {
|
if isConnected {
|
||||||
return .bluetoothConnected
|
return .bluetoothConnected
|
||||||
} else if isRelayConnected {
|
} else if isReachable {
|
||||||
return .relayConnected
|
return .meshReachable
|
||||||
} else if favoriteStatus?.isMutual == true {
|
} else if favoriteStatus?.isMutual == true {
|
||||||
// Mutual favorites can communicate via Nostr when offline
|
// Mutual favorites can communicate via Nostr when offline
|
||||||
return .nostrAvailable
|
return .nostrAvailable
|
||||||
@@ -36,8 +37,6 @@ struct BitchatPeer: Identifiable, Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var isRelayConnected: Bool = false // Set by PeerManager based on session state
|
|
||||||
|
|
||||||
var isFavorite: Bool {
|
var isFavorite: Bool {
|
||||||
favoriteStatus?.isFavorite ?? false
|
favoriteStatus?.isFavorite ?? false
|
||||||
}
|
}
|
||||||
@@ -52,15 +51,15 @@ struct BitchatPeer: Identifiable, Equatable {
|
|||||||
|
|
||||||
// Display helpers
|
// Display helpers
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
nickname.isEmpty ? String(id.prefix(8)) : nickname
|
nickname.isEmpty ? String(peerID.id.prefix(8)) : nickname
|
||||||
}
|
}
|
||||||
|
|
||||||
var statusIcon: String {
|
var statusIcon: String {
|
||||||
switch connectionState {
|
switch connectionState {
|
||||||
case .bluetoothConnected:
|
case .bluetoothConnected:
|
||||||
return "📻" // Radio icon for mesh connection
|
return "📻" // Radio icon for mesh connection
|
||||||
case .relayConnected:
|
case .meshReachable:
|
||||||
return "🔗" // Chain link for relay connection
|
return "📡" // Antenna for mesh reachable
|
||||||
case .nostrAvailable:
|
case .nostrAvailable:
|
||||||
return "🌐" // Purple globe for Nostr
|
return "🌐" // Purple globe for Nostr
|
||||||
case .offline:
|
case .offline:
|
||||||
@@ -74,19 +73,19 @@ struct BitchatPeer: Identifiable, Equatable {
|
|||||||
|
|
||||||
// Initialize from mesh service data
|
// Initialize from mesh service data
|
||||||
init(
|
init(
|
||||||
id: String,
|
peerID: PeerID,
|
||||||
noisePublicKey: Data,
|
noisePublicKey: Data,
|
||||||
nickname: String,
|
nickname: String,
|
||||||
lastSeen: Date = Date(),
|
lastSeen: Date = Date(),
|
||||||
isConnected: Bool = false,
|
isConnected: Bool = false,
|
||||||
isRelayConnected: Bool = false
|
isReachable: Bool = false
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.peerID = peerID
|
||||||
self.noisePublicKey = noisePublicKey
|
self.noisePublicKey = noisePublicKey
|
||||||
self.nickname = nickname
|
self.nickname = nickname
|
||||||
self.lastSeen = lastSeen
|
self.lastSeen = lastSeen
|
||||||
self.isConnected = isConnected
|
self.isConnected = isConnected
|
||||||
self.isRelayConnected = isRelayConnected
|
self.isReachable = isReachable
|
||||||
|
|
||||||
// Load favorite status - will be set later by the manager
|
// Load favorite status - will be set later by the manager
|
||||||
self.favoriteStatus = nil
|
self.favoriteStatus = nil
|
||||||
@@ -94,243 +93,6 @@ struct BitchatPeer: Identifiable, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func == (lhs: BitchatPeer, rhs: BitchatPeer) -> Bool {
|
static func == (lhs: BitchatPeer, rhs: BitchatPeer) -> Bool {
|
||||||
lhs.id == rhs.id
|
lhs.peerID == rhs.peerID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Peer Manager
|
|
||||||
|
|
||||||
/// Manages the collection of peers and their states
|
|
||||||
@MainActor
|
|
||||||
class PeerManager: ObservableObject {
|
|
||||||
@Published var peers: [BitchatPeer] = []
|
|
||||||
@Published var favorites: [BitchatPeer] = []
|
|
||||||
@Published var mutualFavorites: [BitchatPeer] = []
|
|
||||||
|
|
||||||
private let meshService: BluetoothMeshService
|
|
||||||
private let favoritesService = FavoritesPersistenceService.shared
|
|
||||||
|
|
||||||
init(meshService: BluetoothMeshService) {
|
|
||||||
self.meshService = meshService
|
|
||||||
updatePeers()
|
|
||||||
|
|
||||||
// Listen for updates
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
self,
|
|
||||||
selector: #selector(handleFavoriteChanged),
|
|
||||||
name: .favoriteStatusChanged,
|
|
||||||
object: nil
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func handleFavoriteChanged() {
|
|
||||||
SecureLogger.log("⭐ Favorite status changed notification received, updating peers",
|
|
||||||
category: SecureLogger.session, level: .debug)
|
|
||||||
updatePeers()
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
NotificationCenter.default.removeObserver(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
func updatePeers() {
|
|
||||||
// Reduce log verbosity - only log when count changes
|
|
||||||
let previousCount = peers.count
|
|
||||||
|
|
||||||
// Get current mesh peers
|
|
||||||
let meshPeers = meshService.getPeerNicknames()
|
|
||||||
|
|
||||||
// Build peer list
|
|
||||||
var allPeers: [BitchatPeer] = []
|
|
||||||
var connectedNicknames: Set<String> = []
|
|
||||||
|
|
||||||
// Add connected mesh peers (only if actually connected or relay connected)
|
|
||||||
for (peerID, nickname) in meshPeers {
|
|
||||||
guard let noiseKey = Data(hexString: peerID) else { continue }
|
|
||||||
|
|
||||||
// Safety check: Never add our own peer ID
|
|
||||||
if peerID == meshService.myPeerID {
|
|
||||||
SecureLogger.log("⚠️ Skipping self peer ID \(peerID) in peer list",
|
|
||||||
category: SecureLogger.session, level: .warning)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this peer is actually connected (not just known via relay)
|
|
||||||
let isConnected = meshService.isPeerConnected(peerID)
|
|
||||||
let isKnown = meshService.isPeerKnown(peerID)
|
|
||||||
// In a mesh network, a peer can only be relay-connected if:
|
|
||||||
// 1. We know about them (have received announce)
|
|
||||||
// 2. We're not directly connected
|
|
||||||
// 3. There are other peers that could relay (mesh peer count > 2)
|
|
||||||
// For now, disable relay detection until we have proper relay tracking
|
|
||||||
let isRelayConnected = false
|
|
||||||
|
|
||||||
// Debug logging for relay connection detection
|
|
||||||
if isKnown && !isConnected {
|
|
||||||
SecureLogger.log("Peer \(nickname) (\(peerID)): isConnected=\(isConnected), isKnown=\(isKnown), isRelayConnected=\(isRelayConnected)",
|
|
||||||
category: SecureLogger.session, level: .debug)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip disconnected peers unless they're favorites (handled later)
|
|
||||||
if !isConnected && !isRelayConnected {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if isConnected || isRelayConnected {
|
|
||||||
connectedNicknames.insert(nickname)
|
|
||||||
}
|
|
||||||
|
|
||||||
var peer = BitchatPeer(
|
|
||||||
id: peerID,
|
|
||||||
noisePublicKey: noiseKey,
|
|
||||||
nickname: nickname,
|
|
||||||
isConnected: isConnected,
|
|
||||||
isRelayConnected: isRelayConnected
|
|
||||||
)
|
|
||||||
// Set favorite status - check both by current noise key and by nickname
|
|
||||||
if let favoriteStatus = favoritesService.getFavoriteStatus(for: noiseKey) {
|
|
||||||
peer.favoriteStatus = favoriteStatus
|
|
||||||
peer.nostrPublicKey = favoriteStatus.peerNostrPublicKey
|
|
||||||
} else {
|
|
||||||
// Check if we have a favorite for this nickname (peer may have reconnected with new ID)
|
|
||||||
let favoriteByNickname = favoritesService.favorites.values.first { $0.peerNickname == nickname }
|
|
||||||
if let favorite = favoriteByNickname {
|
|
||||||
SecureLogger.log("🔄 Found favorite for '\(nickname)' by nickname, updating noise key",
|
|
||||||
category: SecureLogger.session, level: .info)
|
|
||||||
// Update the favorite's noise key to match the current connection
|
|
||||||
favoritesService.updateNoisePublicKey(from: favorite.peerNoisePublicKey, to: noiseKey, peerNickname: nickname)
|
|
||||||
// Get the updated favorite with the new key
|
|
||||||
peer.favoriteStatus = favoritesService.getFavoriteStatus(for: noiseKey)
|
|
||||||
peer.nostrPublicKey = peer.favoriteStatus?.peerNostrPublicKey ?? favorite.peerNostrPublicKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
allPeers.append(peer)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add offline favorites (only those not currently connected/relay-connected AND that we actively favorite)
|
|
||||||
SecureLogger.log("📋 Processing \(favoritesService.favorites.count) favorite relationships (connected/relay nicknames: \(connectedNicknames))",
|
|
||||||
category: SecureLogger.session, level: .info)
|
|
||||||
|
|
||||||
for (favoriteKey, favorite) in favoritesService.favorites {
|
|
||||||
let favoriteID = favorite.peerNoisePublicKey.hexEncodedString()
|
|
||||||
|
|
||||||
// Skip if this peer is already connected or relay-connected (by nickname)
|
|
||||||
if connectedNicknames.contains(favorite.peerNickname) {
|
|
||||||
SecureLogger.log(" - Skipping '\(favorite.peerNickname)' (key: \(favoriteKey.hexEncodedString())) - already connected/relay-connected",
|
|
||||||
category: SecureLogger.session, level: .debug)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only add peers that WE favorite (not just ones who favorite us)
|
|
||||||
if !favorite.isFavorite {
|
|
||||||
SecureLogger.log(" - Skipping '\(favorite.peerNickname)' - we don't favorite them (they favorite us: \(favorite.theyFavoritedUs))",
|
|
||||||
category: SecureLogger.session, level: .debug)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add this favorite as an offline peer
|
|
||||||
SecureLogger.log(" - Adding offline favorite '\(favorite.peerNickname)' (key: \(favoriteKey.hexEncodedString()), ID: \(favoriteID), mutual: \(favorite.isMutual))",
|
|
||||||
category: SecureLogger.session, level: .info)
|
|
||||||
|
|
||||||
var peer = BitchatPeer(
|
|
||||||
id: favoriteID,
|
|
||||||
noisePublicKey: favorite.peerNoisePublicKey,
|
|
||||||
nickname: favorite.peerNickname,
|
|
||||||
isConnected: false
|
|
||||||
)
|
|
||||||
// Set favorite status
|
|
||||||
peer.favoriteStatus = favorite
|
|
||||||
peer.nostrPublicKey = favorite.peerNostrPublicKey
|
|
||||||
allPeers.append(peer)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter out "Unknown" peers unless they are favorites or have a favorite relationship
|
|
||||||
allPeers = allPeers.filter { peer in
|
|
||||||
!(peer.displayName == "Unknown" && peer.favoriteStatus == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort: Connected first (direct then relay), then favorites, then alphabetical
|
|
||||||
allPeers.sort { lhs, rhs in
|
|
||||||
// Direct connections first
|
|
||||||
if lhs.isConnected != rhs.isConnected {
|
|
||||||
return lhs.isConnected
|
|
||||||
}
|
|
||||||
// Then relay connections
|
|
||||||
if lhs.isRelayConnected != rhs.isRelayConnected {
|
|
||||||
return lhs.isRelayConnected
|
|
||||||
}
|
|
||||||
// Then favorites
|
|
||||||
if lhs.isFavorite != rhs.isFavorite {
|
|
||||||
return lhs.isFavorite
|
|
||||||
}
|
|
||||||
// Finally alphabetical
|
|
||||||
return lhs.displayName < rhs.displayName
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single pass to compute all subsets and counts
|
|
||||||
var favorites: [BitchatPeer] = []
|
|
||||||
var mutualFavorites: [BitchatPeer] = []
|
|
||||||
var connectedCount = 0
|
|
||||||
var offlineCount = 0
|
|
||||||
|
|
||||||
for peer in allPeers {
|
|
||||||
if peer.isFavorite {
|
|
||||||
favorites.append(peer)
|
|
||||||
}
|
|
||||||
if peer.isMutualFavorite {
|
|
||||||
mutualFavorites.append(peer)
|
|
||||||
}
|
|
||||||
if peer.isConnected {
|
|
||||||
connectedCount += 1
|
|
||||||
} else {
|
|
||||||
offlineCount += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.peers = allPeers
|
|
||||||
self.favorites = favorites
|
|
||||||
self.mutualFavorites = mutualFavorites
|
|
||||||
|
|
||||||
// Always log favorites debug info when there are favorites
|
|
||||||
if favoritesService.favorites.count > 0 {
|
|
||||||
SecureLogger.log("📊 Peer list update: \(allPeers.count) total (\(connectedCount) connected, \(offlineCount) offline), \(favorites.count) favorites, \(mutualFavorites.count) mutual",
|
|
||||||
category: SecureLogger.session, level: .info)
|
|
||||||
|
|
||||||
// Log each peer's status
|
|
||||||
for peer in allPeers {
|
|
||||||
// Use the actual statusIcon from the peer which accounts for relay connections
|
|
||||||
let statusIcon: String
|
|
||||||
switch peer.connectionState {
|
|
||||||
case .bluetoothConnected:
|
|
||||||
statusIcon = "🟢"
|
|
||||||
case .relayConnected:
|
|
||||||
statusIcon = "🔗"
|
|
||||||
case .nostrAvailable:
|
|
||||||
statusIcon = "🌐"
|
|
||||||
case .offline:
|
|
||||||
statusIcon = "🔴"
|
|
||||||
}
|
|
||||||
let favoriteIcon = peer.isMutualFavorite ? "💕" : (peer.isFavorite ? "⭐" : (peer.theyFavoritedUs ? "🌙" : ""))
|
|
||||||
SecureLogger.log(" \(statusIcon) \(peer.displayName) (ID: \(peer.id.prefix(8))...) \(favoriteIcon)",
|
|
||||||
category: SecureLogger.session, level: .debug)
|
|
||||||
}
|
|
||||||
} else if previousCount != allPeers.count {
|
|
||||||
// Only log non-favorite updates if count changed
|
|
||||||
SecureLogger.log("✅ Updated peer list: \(allPeers.count) total peers",
|
|
||||||
category: SecureLogger.session, level: .info)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func toggleFavorite(_ peer: BitchatPeer) {
|
|
||||||
if peer.isFavorite {
|
|
||||||
favoritesService.removeFavorite(peerNoisePublicKey: peer.noisePublicKey)
|
|
||||||
} else {
|
|
||||||
favoritesService.addFavorite(
|
|
||||||
peerNoisePublicKey: peer.noisePublicKey,
|
|
||||||
peerNostrPublicKey: peer.nostrPublicKey,
|
|
||||||
peerNickname: peer.nickname
|
|
||||||
)
|
|
||||||
}
|
|
||||||
updatePeers()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
//
|
||||||
|
// GeoPerson.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// Model representing a participant in a geohash channel
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Represents a person participating in a geohash-based location channel
|
||||||
|
struct GeoPerson: Identifiable, Equatable {
|
||||||
|
let id: String // pubkey hex (lowercased)
|
||||||
|
let displayName: String
|
||||||
|
let lastSeen: Date
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
//
|
||||||
|
// MessagePadding.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,212 @@
|
|||||||
|
//
|
||||||
|
// PeerID.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct PeerID: Equatable, Hashable {
|
||||||
|
enum Prefix: String, CaseIterable {
|
||||||
|
/// When no prefix is provided
|
||||||
|
case empty = ""
|
||||||
|
/// `"mesh:"`
|
||||||
|
case mesh = "mesh:"
|
||||||
|
/// `"name:"`
|
||||||
|
case name = "name:"
|
||||||
|
/// `"noise:"` (+ 64 characters hex)
|
||||||
|
case noise = "noise:"
|
||||||
|
/// `"nostr_"` (+ 16 characters hex)
|
||||||
|
case geoDM = "nostr_"
|
||||||
|
/// `"nostr:"` (+ 8 characters hex)
|
||||||
|
case geoChat = "nostr:"
|
||||||
|
}
|
||||||
|
|
||||||
|
let prefix: Prefix
|
||||||
|
|
||||||
|
/// Returns the actual value without any prefix
|
||||||
|
let bare: String
|
||||||
|
|
||||||
|
/// Returns the full `id` value by combining `(prefix + bare)`
|
||||||
|
var id: String { prefix.rawValue + bare }
|
||||||
|
|
||||||
|
// Private so the callers have to go through a convenience init
|
||||||
|
private init(prefix: Prefix, bare: any StringProtocol) {
|
||||||
|
self.prefix = prefix
|
||||||
|
self.bare = String(bare)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Convenience Inits
|
||||||
|
|
||||||
|
extension PeerID {
|
||||||
|
/// Convenience init to create GeoDM PeerID by appending `"nostr_"` to the first 16 characters of `pubKey`
|
||||||
|
init(nostr_ pubKey: String) {
|
||||||
|
self.init(prefix: .geoDM, bare: pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience init to create GeoChat PeerID by appending `"nostr:"` to the first 8 characters of `pubKey`
|
||||||
|
init(nostr pubKey: String) {
|
||||||
|
self.init(prefix: .geoChat, bare: pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience init to create PeerID from String/Substring by splitting it into prefix and bare parts
|
||||||
|
init(str: any StringProtocol) {
|
||||||
|
if let prefix = Prefix.allCases.first(where: { $0 != .empty && str.hasPrefix($0.rawValue) }) {
|
||||||
|
self.init(prefix: prefix, bare: String(str).dropFirst(prefix.rawValue.count))
|
||||||
|
} else {
|
||||||
|
self.init(prefix: .empty, bare: str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience init to handle `Optional<String>`
|
||||||
|
init?(str: (any StringProtocol)?) {
|
||||||
|
guard let str else { return nil }
|
||||||
|
self.init(str: str)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience init to create PeerID by converting Data to String
|
||||||
|
init?(data: Data) {
|
||||||
|
self.init(str: String(data: data, encoding: .utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience init to "hide" hex-encoding implementation detail
|
||||||
|
init(hexData: Data) {
|
||||||
|
self.init(str: hexData.hexEncodedString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Noise Public Key Helpers
|
||||||
|
|
||||||
|
extension PeerID {
|
||||||
|
/// Derive the stable 16-hex peer ID from a Noise static public key
|
||||||
|
init(publicKey: Data) {
|
||||||
|
self.init(str: publicKey.sha256Fingerprint().prefix(16))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a 16-hex short peer ID derived from a 64-hex Noise public key if needed
|
||||||
|
func toShort() -> PeerID {
|
||||||
|
if let noiseKey {
|
||||||
|
return PeerID(publicKey: noiseKey)
|
||||||
|
}
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Codable
|
||||||
|
|
||||||
|
extension PeerID: Codable {
|
||||||
|
init(from decoder: any Decoder) throws {
|
||||||
|
self.init(str: try decoder.singleValueContainer().decode(String.self))
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: any Encoder) throws {
|
||||||
|
var container = encoder.singleValueContainer()
|
||||||
|
try container.encode(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
extension PeerID {
|
||||||
|
var isEmpty: Bool {
|
||||||
|
id.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if `id` starts with "`nostr:`"
|
||||||
|
var isGeoChat: Bool {
|
||||||
|
prefix == .geoChat
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if `id` starts with "`nostr_`"
|
||||||
|
var isGeoDM: Bool {
|
||||||
|
prefix == .geoDM
|
||||||
|
}
|
||||||
|
|
||||||
|
func toPercentEncoded() -> String {
|
||||||
|
id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Validation
|
||||||
|
|
||||||
|
extension PeerID {
|
||||||
|
private enum Constants {
|
||||||
|
static let maxIDLength = 64
|
||||||
|
static let hexIDLength = 16 // 8 bytes = 16 hex chars
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
|
||||||
|
var isValid: Bool {
|
||||||
|
if prefix != .empty {
|
||||||
|
return PeerID(str: bare).isValid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept short routing IDs (exact 16-hex) or Full Noise key hex (exact 64-hex)
|
||||||
|
if isShort || isNoiseKeyHex {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If length equals short or full but isn't valid hex, reject
|
||||||
|
if id.count == Constants.hexIDLength || id.count == Constants.maxIDLength {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal format: alphanumeric + dash/underscore up to 63 (not 16 or 64)
|
||||||
|
let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
|
||||||
|
return !id.isEmpty &&
|
||||||
|
id.count < Constants.maxIDLength &&
|
||||||
|
id.rangeOfCharacter(from: validCharset.inverted) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Short routing IDs (exact 16-hex)
|
||||||
|
var isShort: Bool {
|
||||||
|
bare.count == Constants.hexIDLength && Data(hexString: bare) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full Noise key hex (exact 64-hex)
|
||||||
|
var isNoiseKeyHex: Bool {
|
||||||
|
noiseKey != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full Noise key (exact 64-hex) as Data
|
||||||
|
var noiseKey: Data? {
|
||||||
|
guard bare.count == Constants.maxIDLength else { return nil }
|
||||||
|
return Data(hexString: bare)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Comparable
|
||||||
|
|
||||||
|
extension PeerID: Comparable {
|
||||||
|
static func < (lhs: PeerID, rhs: PeerID) -> Bool {
|
||||||
|
lhs.id < rhs.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - String Interop Helpers
|
||||||
|
|
||||||
|
// MARK: CustomStringConvertible
|
||||||
|
|
||||||
|
extension PeerID: CustomStringConvertible {
|
||||||
|
/// So it returns the actual `id` like before even inside another String
|
||||||
|
var description: String {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Custom Equatable w/ String & Optionality
|
||||||
|
|
||||||
|
// PeerID <> String
|
||||||
|
extension Optional where Wrapped == PeerID {
|
||||||
|
static func ==(lhs: Optional<Wrapped>, rhs: Optional<String>) -> Bool { lhs?.id == rhs }
|
||||||
|
static func !=(lhs: Optional<Wrapped>, rhs: Optional<String>) -> Bool { lhs?.id != rhs }
|
||||||
|
}
|
||||||
|
|
||||||
|
// String <> PeerID
|
||||||
|
extension Optional where Wrapped == String {
|
||||||
|
static func ==(lhs: Optional<Wrapped>, rhs: Optional<PeerID>) -> Bool { lhs == rhs?.id }
|
||||||
|
static func !=(lhs: Optional<Wrapped>, rhs: Optional<PeerID>) -> Bool { lhs != rhs?.id }
|
||||||
|
}
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import CoreBluetooth
|
|
||||||
|
|
||||||
/// Unified model for tracking all peer session data
|
|
||||||
/// Consolidates multiple redundant data structures into a single source of truth
|
|
||||||
class PeerSession {
|
|
||||||
// Core identification
|
|
||||||
let peerID: String
|
|
||||||
var nickname: String
|
|
||||||
|
|
||||||
// Bluetooth connection
|
|
||||||
var peripheral: CBPeripheral?
|
|
||||||
var peripheralID: String?
|
|
||||||
var characteristic: CBCharacteristic?
|
|
||||||
|
|
||||||
// Authentication and encryption
|
|
||||||
var isAuthenticated: Bool = false
|
|
||||||
var hasEstablishedNoiseSession: Bool = false
|
|
||||||
var fingerprint: String?
|
|
||||||
|
|
||||||
// Connection state
|
|
||||||
var isConnected: Bool = false
|
|
||||||
var lastSeen: Date
|
|
||||||
|
|
||||||
// Protocol state
|
|
||||||
var hasAnnounced: Bool = false
|
|
||||||
var hasReceivedAnnounce: Bool = false
|
|
||||||
var isActivePeer: Bool = false
|
|
||||||
|
|
||||||
// Message tracking
|
|
||||||
var lastMessageSent: Date?
|
|
||||||
var lastMessageReceived: Date?
|
|
||||||
var pendingMessages: [String] = []
|
|
||||||
|
|
||||||
// Connection timing
|
|
||||||
var lastConnectionTime: Date?
|
|
||||||
var lastSuccessfulMessageTime: Date?
|
|
||||||
var lastHeardFromPeer: Date?
|
|
||||||
|
|
||||||
// Availability tracking
|
|
||||||
var isAvailable: Bool = false
|
|
||||||
|
|
||||||
// Identity binding
|
|
||||||
var identityBinding: PeerIdentityBinding?
|
|
||||||
|
|
||||||
init(peerID: String, nickname: String = "Unknown") {
|
|
||||||
self.peerID = peerID
|
|
||||||
self.nickname = nickname
|
|
||||||
self.lastSeen = Date()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update Bluetooth connection info
|
|
||||||
func updateBluetoothConnection(peripheral: CBPeripheral?, characteristic: CBCharacteristic?) {
|
|
||||||
self.peripheral = peripheral
|
|
||||||
self.peripheralID = peripheral?.identifier.uuidString
|
|
||||||
self.characteristic = characteristic
|
|
||||||
self.isConnected = (peripheral?.state == .connected)
|
|
||||||
if isConnected {
|
|
||||||
self.lastSeen = Date()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update authentication state
|
|
||||||
func updateAuthenticationState(authenticated: Bool, noiseSession: Bool) {
|
|
||||||
self.isAuthenticated = authenticated
|
|
||||||
self.hasEstablishedNoiseSession = noiseSession
|
|
||||||
if authenticated {
|
|
||||||
self.isActivePeer = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// Check if session is stale
|
|
||||||
var isStale: Bool {
|
|
||||||
// Consider stale if not seen for more than 5 minutes and not connected
|
|
||||||
return !isConnected && Date().timeIntervalSince(lastSeen) > 300
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get display status for UI
|
|
||||||
var displayStatus: String {
|
|
||||||
if isConnected {
|
|
||||||
if isAuthenticated {
|
|
||||||
return "🟢" // Connected and authenticated
|
|
||||||
} else {
|
|
||||||
return "🟡" // Connected but not authenticated
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return "🔴" // Not connected
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
//
|
||||||
|
// ReadReceipt.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
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 PeerID(str: 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,63 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
default:
|
||||||
|
break // forward compatible; ignore unknown TLVs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil }
|
||||||
|
return RequestSyncPacket(p: pp, m: mm, data: dd)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,371 +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 {
|
|
||||||
SecureLogger.log("Not initiator for handshake with \(remotePeerID) (my: \(myPeerID), their: \(remotePeerID))",
|
|
||||||
category: SecureLogger.handshake, level: .debug)
|
|
||||||
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,9 @@
|
|||||||
/// - Noise Specification: http://www.noiseprotocol.org/noise.html
|
/// - Noise Specification: http://www.noiseprotocol.org/noise.html
|
||||||
///
|
///
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import os.log
|
|
||||||
|
|
||||||
// Core Noise Protocol implementation
|
// Core Noise Protocol implementation
|
||||||
// Based on the Noise Protocol Framework specification
|
// Based on the Noise Protocol Framework specification
|
||||||
@@ -127,7 +127,7 @@ struct NoiseProtocolName {
|
|||||||
/// Handles ChaCha20-Poly1305 AEAD encryption with automatic nonce management
|
/// Handles ChaCha20-Poly1305 AEAD encryption with automatic nonce management
|
||||||
/// and replay protection using a sliding window algorithm.
|
/// and replay protection using a sliding window algorithm.
|
||||||
/// - Warning: Nonce reuse would be catastrophic for security
|
/// - Warning: Nonce reuse would be catastrophic for security
|
||||||
class NoiseCipherState {
|
final class NoiseCipherState {
|
||||||
// Constants for replay protection
|
// Constants for replay protection
|
||||||
private static let NONCE_SIZE_BYTES = 4
|
private static let NONCE_SIZE_BYTES = 4
|
||||||
private static let REPLAY_WINDOW_SIZE = 1024
|
private static let REPLAY_WINDOW_SIZE = 1024
|
||||||
@@ -149,6 +149,10 @@ class NoiseCipherState {
|
|||||||
self.useExtractedNonce = useExtractedNonce
|
self.useExtractedNonce = useExtractedNonce
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
clearSensitiveData()
|
||||||
|
}
|
||||||
|
|
||||||
func initializeKey(_ key: SymmetricKey) {
|
func initializeKey(_ key: SymmetricKey) {
|
||||||
self.key = key
|
self.key = key
|
||||||
self.nonce = 0
|
self.nonce = 0
|
||||||
@@ -281,7 +285,7 @@ class NoiseCipherState {
|
|||||||
|
|
||||||
// Log high nonce values that might indicate issues
|
// Log high nonce values that might indicate issues
|
||||||
if currentNonce > Self.HIGH_NONCE_WARNING_THRESHOLD {
|
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
|
return combinedPayload
|
||||||
@@ -303,13 +307,13 @@ class NoiseCipherState {
|
|||||||
if useExtractedNonce {
|
if useExtractedNonce {
|
||||||
// Extract nonce and ciphertext from combined payload
|
// Extract nonce and ciphertext from combined payload
|
||||||
guard let (extractedNonce, actualCiphertext) = try extractNonceFromCiphertextPayload(ciphertext) else {
|
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
|
throw NoiseError.invalidCiphertext
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate nonce with sliding window replay protection
|
// Validate nonce with sliding window replay protection
|
||||||
guard isValidNonce(extractedNonce) else {
|
guard isValidNonce(extractedNonce) else {
|
||||||
SecureLogger.log("Replay attack detected: nonce \(extractedNonce) rejected")
|
SecureLogger.debug("Replay attack detected: nonce \(extractedNonce) rejected")
|
||||||
throw NoiseError.replayDetected
|
throw NoiseError.replayDetected
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,7 +342,7 @@ class NoiseCipherState {
|
|||||||
|
|
||||||
// Log high nonce values that might indicate issues
|
// Log high nonce values that might indicate issues
|
||||||
if decryptionNonce > Self.HIGH_NONCE_WARNING_THRESHOLD {
|
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 {
|
do {
|
||||||
@@ -351,12 +355,27 @@ class NoiseCipherState {
|
|||||||
nonce += 1
|
nonce += 1
|
||||||
return plaintext
|
return plaintext
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.log("Decrypt failed: \(error) for nonce \(decryptionNonce)")
|
SecureLogger.debug("Decrypt failed: \(error) for nonce \(decryptionNonce)")
|
||||||
// Log authentication failures with nonce info
|
// Log authentication failures with nonce info
|
||||||
SecureLogger.log("Decryption failed at nonce \(decryptionNonce)", category: SecureLogger.encryption, level: .error)
|
SecureLogger.error("Decryption failed at nonce \(decryptionNonce)", category: .encryption)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Securely clear sensitive cryptographic data from memory
|
||||||
|
func clearSensitiveData() {
|
||||||
|
// Clear the symmetric key
|
||||||
|
key = nil
|
||||||
|
|
||||||
|
// Reset nonce
|
||||||
|
nonce = 0
|
||||||
|
highestReceivedNonce = 0
|
||||||
|
|
||||||
|
// Clear replay window
|
||||||
|
for i in 0..<replayWindow.count {
|
||||||
|
replayWindow[i] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Symmetric State
|
// MARK: - Symmetric State
|
||||||
@@ -365,7 +384,7 @@ class NoiseCipherState {
|
|||||||
/// Responsible for key derivation, protocol name hashing, and maintaining
|
/// Responsible for key derivation, protocol name hashing, and maintaining
|
||||||
/// the chaining key that provides key separation between handshake messages.
|
/// the chaining key that provides key separation between handshake messages.
|
||||||
/// - Note: This class implements the SymmetricState object from the Noise spec
|
/// - Note: This class implements the SymmetricState object from the Noise spec
|
||||||
class NoiseSymmetricState {
|
final class NoiseSymmetricState {
|
||||||
private var cipherState: NoiseCipherState
|
private var cipherState: NoiseCipherState
|
||||||
private var chainingKey: Data
|
private var chainingKey: Data
|
||||||
private var hash: Data
|
private var hash: Data
|
||||||
@@ -378,7 +397,7 @@ class NoiseSymmetricState {
|
|||||||
if nameData.count <= 32 {
|
if nameData.count <= 32 {
|
||||||
self.hash = nameData + Data(repeating: 0, count: 32 - nameData.count)
|
self.hash = nameData + Data(repeating: 0, count: 32 - nameData.count)
|
||||||
} else {
|
} else {
|
||||||
self.hash = Data(SHA256.hash(data: nameData))
|
self.hash = nameData.sha256Hash()
|
||||||
}
|
}
|
||||||
self.chainingKey = self.hash
|
self.chainingKey = self.hash
|
||||||
}
|
}
|
||||||
@@ -391,7 +410,7 @@ class NoiseSymmetricState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func mixHash(_ data: Data) {
|
func mixHash(_ data: Data) {
|
||||||
hash = Data(SHA256.hash(data: hash + data))
|
hash = (hash + data).sha256Hash()
|
||||||
}
|
}
|
||||||
|
|
||||||
func mixKeyAndHash(_ inputKeyMaterial: Data) {
|
func mixKeyAndHash(_ inputKeyMaterial: Data) {
|
||||||
@@ -469,9 +488,10 @@ class NoiseSymmetricState {
|
|||||||
/// This is the main interface for establishing encrypted sessions between peers.
|
/// This is the main interface for establishing encrypted sessions between peers.
|
||||||
/// Manages the handshake state machine, message patterns, and key derivation.
|
/// Manages the handshake state machine, message patterns, and key derivation.
|
||||||
/// - Important: Each handshake instance should only be used once
|
/// - Important: Each handshake instance should only be used once
|
||||||
class NoiseHandshakeState {
|
final class NoiseHandshakeState {
|
||||||
private let role: NoiseRole
|
private let role: NoiseRole
|
||||||
private let pattern: NoisePattern
|
private let pattern: NoisePattern
|
||||||
|
private let keychain: KeychainManagerProtocol
|
||||||
private var symmetricState: NoiseSymmetricState
|
private var symmetricState: NoiseSymmetricState
|
||||||
|
|
||||||
// Keys
|
// Keys
|
||||||
@@ -487,9 +507,16 @@ class NoiseHandshakeState {
|
|||||||
private var messagePatterns: [[NoiseMessagePattern]] = []
|
private var messagePatterns: [[NoiseMessagePattern]] = []
|
||||||
private var currentPattern = 0
|
private var currentPattern = 0
|
||||||
|
|
||||||
init(role: NoiseRole, pattern: NoisePattern, localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil, remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil) {
|
init(
|
||||||
|
role: NoiseRole,
|
||||||
|
pattern: NoisePattern,
|
||||||
|
keychain: KeychainManagerProtocol,
|
||||||
|
localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil,
|
||||||
|
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil
|
||||||
|
) {
|
||||||
self.role = role
|
self.role = role
|
||||||
self.pattern = pattern
|
self.pattern = pattern
|
||||||
|
self.keychain = keychain
|
||||||
|
|
||||||
// Initialize static keys
|
// Initialize static keys
|
||||||
if let localKey = localStaticKey {
|
if let localKey = localStaticKey {
|
||||||
@@ -557,7 +584,10 @@ class NoiseHandshakeState {
|
|||||||
throw NoiseError.missingKeys
|
throw NoiseError.missingKeys
|
||||||
}
|
}
|
||||||
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteEphemeral)
|
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:
|
case .es:
|
||||||
// DH(ephemeral, static) - direction depends on role
|
// DH(ephemeral, static) - direction depends on role
|
||||||
@@ -602,7 +632,10 @@ class NoiseHandshakeState {
|
|||||||
throw NoiseError.missingKeys
|
throw NoiseError.missingKeys
|
||||||
}
|
}
|
||||||
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteStatic)
|
let shared = try localStatic.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -636,7 +669,7 @@ class NoiseHandshakeState {
|
|||||||
do {
|
do {
|
||||||
remoteEphemeralPublic = try NoiseHandshakeState.validatePublicKey(ephemeralData)
|
remoteEphemeralPublic = try NoiseHandshakeState.validatePublicKey(ephemeralData)
|
||||||
} catch {
|
} 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
|
throw NoiseError.invalidMessage
|
||||||
}
|
}
|
||||||
symmetricState.mixHash(ephemeralData)
|
symmetricState.mixHash(ephemeralData)
|
||||||
@@ -653,7 +686,7 @@ class NoiseHandshakeState {
|
|||||||
let decrypted = try symmetricState.decryptAndHash(staticData)
|
let decrypted = try symmetricState.decryptAndHash(staticData)
|
||||||
remoteStaticPublic = try NoiseHandshakeState.validatePublicKey(decrypted)
|
remoteStaticPublic = try NoiseHandshakeState.validatePublicKey(decrypted)
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: "Unknown - handshake"), level: .error)
|
SecureLogger.error(.authenticationFailed(peerID: "Unknown - handshake"))
|
||||||
throw NoiseError.authenticationFailure
|
throw NoiseError.authenticationFailure
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -687,14 +720,20 @@ class NoiseHandshakeState {
|
|||||||
throw NoiseError.missingKeys
|
throw NoiseError.missingKeys
|
||||||
}
|
}
|
||||||
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic)
|
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 {
|
} else {
|
||||||
guard let localStatic = localStaticPrivate,
|
guard let localStatic = localStaticPrivate,
|
||||||
let remoteEphemeral = remoteEphemeralPublic else {
|
let remoteEphemeral = remoteEphemeralPublic else {
|
||||||
throw NoiseError.missingKeys
|
throw NoiseError.missingKeys
|
||||||
}
|
}
|
||||||
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral)
|
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:
|
case .se:
|
||||||
@@ -704,14 +743,20 @@ class NoiseHandshakeState {
|
|||||||
throw NoiseError.missingKeys
|
throw NoiseError.missingKeys
|
||||||
}
|
}
|
||||||
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral)
|
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 {
|
} else {
|
||||||
guard let localEphemeral = localEphemeralPrivate,
|
guard let localEphemeral = localEphemeralPrivate,
|
||||||
let remoteStatic = remoteStaticPublic else {
|
let remoteStatic = remoteStaticPublic else {
|
||||||
throw NoiseError.missingKeys
|
throw NoiseError.missingKeys
|
||||||
}
|
}
|
||||||
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic)
|
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:
|
case .ss:
|
||||||
@@ -840,7 +885,7 @@ extension NoiseHandshakeState {
|
|||||||
|
|
||||||
// Check against known bad points
|
// Check against known bad points
|
||||||
if lowOrderPoints.contains(keyData) {
|
if lowOrderPoints.contains(keyData) {
|
||||||
SecureLogger.log("Low-order point detected", category: SecureLogger.security, level: .warning)
|
SecureLogger.warning("Low-order point detected", category: .security)
|
||||||
throw NoiseError.invalidPublicKey
|
throw NoiseError.invalidPublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -850,7 +895,7 @@ extension NoiseHandshakeState {
|
|||||||
return publicKey
|
return publicKey
|
||||||
} catch {
|
} catch {
|
||||||
// If CryptoKit rejects it, it's invalid
|
// 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
|
throw NoiseError.invalidPublicKey
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
|
||||||
|
|
||||||
// MARK: - Security Constants
|
// MARK: - Security Constants
|
||||||
|
|
||||||
@@ -52,20 +52,11 @@ struct NoiseSecurityValidator {
|
|||||||
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
||||||
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate peer ID format
|
|
||||||
static func validatePeerID(_ peerID: String) -> Bool {
|
|
||||||
// Peer ID should be reasonable length and contain valid characters
|
|
||||||
let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
|
|
||||||
return peerID.count > 0 &&
|
|
||||||
peerID.count <= 64 &&
|
|
||||||
peerID.rangeOfCharacter(from: validCharset.inverted) == nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Enhanced Noise Session with Security
|
// MARK: - Enhanced Noise Session with Security
|
||||||
|
|
||||||
class SecureNoiseSession: NoiseSession {
|
final class SecureNoiseSession: NoiseSession {
|
||||||
private(set) var messageCount: UInt64 = 0
|
private(set) var messageCount: UInt64 = 0
|
||||||
private let sessionStartTime = Date()
|
private let sessionStartTime = Date()
|
||||||
private(set) var lastActivityTime = Date()
|
private(set) var lastActivityTime = Date()
|
||||||
@@ -139,9 +130,9 @@ class SecureNoiseSession: NoiseSession {
|
|||||||
|
|
||||||
// MARK: - Rate Limiter
|
// MARK: - Rate Limiter
|
||||||
|
|
||||||
class NoiseRateLimiter {
|
final class NoiseRateLimiter {
|
||||||
private var handshakeTimestamps: [String: [Date]] = [:] // peerID -> timestamps
|
private var handshakeTimestamps: [PeerID: [Date]] = [:]
|
||||||
private var messageTimestamps: [String: [Date]] = [:] // peerID -> timestamps
|
private var messageTimestamps: [PeerID: [Date]] = [:]
|
||||||
|
|
||||||
// Global rate limiting
|
// Global rate limiting
|
||||||
private var globalHandshakeTimestamps: [Date] = []
|
private var globalHandshakeTimestamps: [Date] = []
|
||||||
@@ -149,7 +140,7 @@ class NoiseRateLimiter {
|
|||||||
|
|
||||||
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
|
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
|
||||||
|
|
||||||
func allowHandshake(from peerID: String) -> Bool {
|
func allowHandshake(from peerID: PeerID) -> Bool {
|
||||||
return queue.sync(flags: .barrier) {
|
return queue.sync(flags: .barrier) {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let oneMinuteAgo = now.addingTimeInterval(-60)
|
let oneMinuteAgo = now.addingTimeInterval(-60)
|
||||||
@@ -157,7 +148,7 @@ class NoiseRateLimiter {
|
|||||||
// Check global rate limit first
|
// Check global rate limit first
|
||||||
globalHandshakeTimestamps = globalHandshakeTimestamps.filter { $0 > oneMinuteAgo }
|
globalHandshakeTimestamps = globalHandshakeTimestamps.filter { $0 > oneMinuteAgo }
|
||||||
if globalHandshakeTimestamps.count >= NoiseSecurityConstants.maxGlobalHandshakesPerMinute {
|
if globalHandshakeTimestamps.count >= NoiseSecurityConstants.maxGlobalHandshakesPerMinute {
|
||||||
SecureLogger.log("Global handshake rate limit exceeded: \(globalHandshakeTimestamps.count)/\(NoiseSecurityConstants.maxGlobalHandshakesPerMinute) per minute", category: SecureLogger.security, level: .warning)
|
SecureLogger.warning("Global handshake rate limit exceeded: \(globalHandshakeTimestamps.count)/\(NoiseSecurityConstants.maxGlobalHandshakesPerMinute) per minute", category: .security)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +157,7 @@ class NoiseRateLimiter {
|
|||||||
timestamps = timestamps.filter { $0 > oneMinuteAgo }
|
timestamps = timestamps.filter { $0 > oneMinuteAgo }
|
||||||
|
|
||||||
if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute {
|
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)
|
SecureLogger.warning("Per-peer handshake rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: .security)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +169,7 @@ class NoiseRateLimiter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func allowMessage(from peerID: String) -> Bool {
|
func allowMessage(from peerID: PeerID) -> Bool {
|
||||||
return queue.sync(flags: .barrier) {
|
return queue.sync(flags: .barrier) {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let oneSecondAgo = now.addingTimeInterval(-1)
|
let oneSecondAgo = now.addingTimeInterval(-1)
|
||||||
@@ -186,7 +177,7 @@ class NoiseRateLimiter {
|
|||||||
// Check global rate limit first
|
// Check global rate limit first
|
||||||
globalMessageTimestamps = globalMessageTimestamps.filter { $0 > oneSecondAgo }
|
globalMessageTimestamps = globalMessageTimestamps.filter { $0 > oneSecondAgo }
|
||||||
if globalMessageTimestamps.count >= NoiseSecurityConstants.maxGlobalMessagesPerSecond {
|
if globalMessageTimestamps.count >= NoiseSecurityConstants.maxGlobalMessagesPerSecond {
|
||||||
SecureLogger.log("Global message rate limit exceeded: \(globalMessageTimestamps.count)/\(NoiseSecurityConstants.maxGlobalMessagesPerSecond) per second", category: SecureLogger.security, level: .warning)
|
SecureLogger.warning("Global message rate limit exceeded: \(globalMessageTimestamps.count)/\(NoiseSecurityConstants.maxGlobalMessagesPerSecond) per second", category: .security)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +186,7 @@ class NoiseRateLimiter {
|
|||||||
timestamps = timestamps.filter { $0 > oneSecondAgo }
|
timestamps = timestamps.filter { $0 > oneSecondAgo }
|
||||||
|
|
||||||
if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond {
|
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)
|
SecureLogger.warning("Per-peer message rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: .security)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,12 +198,21 @@ class NoiseRateLimiter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func reset(for peerID: String) {
|
func reset(for peerID: PeerID) {
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
self.handshakeTimestamps.removeValue(forKey: peerID)
|
self.handshakeTimestamps.removeValue(forKey: peerID)
|
||||||
self.messageTimestamps.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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Security Errors
|
// MARK: - Security Errors
|
||||||
|
|||||||
@@ -6,37 +6,14 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
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
|
|
||||||
|
|
||||||
class NoiseSession {
|
class NoiseSession {
|
||||||
let peerID: String
|
let peerID: PeerID
|
||||||
let role: NoiseRole
|
let role: NoiseRole
|
||||||
|
private let keychain: KeychainManagerProtocol
|
||||||
private var state: NoiseSessionState = .uninitialized
|
private var state: NoiseSessionState = .uninitialized
|
||||||
private var handshakeState: NoiseHandshakeState?
|
private var handshakeState: NoiseHandshakeState?
|
||||||
private var sendCipher: NoiseCipherState?
|
private var sendCipher: NoiseCipherState?
|
||||||
@@ -53,9 +30,16 @@ class NoiseSession {
|
|||||||
// Thread safety
|
// Thread safety
|
||||||
private let sessionQueue = DispatchQueue(label: "chat.bitchat.noise.session", attributes: .concurrent)
|
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.peerID = peerID
|
||||||
self.role = role
|
self.role = role
|
||||||
|
self.keychain = keychain
|
||||||
self.localStaticKey = localStaticKey
|
self.localStaticKey = localStaticKey
|
||||||
self.remoteStaticPublicKey = remoteStaticKey
|
self.remoteStaticPublicKey = remoteStaticKey
|
||||||
}
|
}
|
||||||
@@ -72,6 +56,7 @@ class NoiseSession {
|
|||||||
handshakeState = NoiseHandshakeState(
|
handshakeState = NoiseHandshakeState(
|
||||||
role: role,
|
role: role,
|
||||||
pattern: .XX,
|
pattern: .XX,
|
||||||
|
keychain: keychain,
|
||||||
localStaticKey: localStaticKey,
|
localStaticKey: localStaticKey,
|
||||||
remoteStaticKey: nil
|
remoteStaticKey: nil
|
||||||
)
|
)
|
||||||
@@ -92,18 +77,19 @@ class NoiseSession {
|
|||||||
|
|
||||||
func processHandshakeMessage(_ message: Data) throws -> Data? {
|
func processHandshakeMessage(_ message: Data) throws -> Data? {
|
||||||
return try sessionQueue.sync(flags: .barrier) {
|
return try sessionQueue.sync(flags: .barrier) {
|
||||||
SecureLogger.log("NoiseSession[\(peerID)]: Processing handshake message, current state: \(state), role: \(role)", category: SecureLogger.noise, level: .info)
|
SecureLogger.debug("NoiseSession[\(peerID)]: Processing handshake message, current state: \(state), role: \(role)")
|
||||||
|
|
||||||
// Initialize handshake state if needed (for responders)
|
// Initialize handshake state if needed (for responders)
|
||||||
if state == .uninitialized && role == .responder {
|
if state == .uninitialized && role == .responder {
|
||||||
handshakeState = NoiseHandshakeState(
|
handshakeState = NoiseHandshakeState(
|
||||||
role: role,
|
role: role,
|
||||||
pattern: .XX,
|
pattern: .XX,
|
||||||
|
keychain: keychain,
|
||||||
localStaticKey: localStaticKey,
|
localStaticKey: localStaticKey,
|
||||||
remoteStaticKey: nil
|
remoteStaticKey: nil
|
||||||
)
|
)
|
||||||
state = .handshaking
|
state = .handshaking
|
||||||
SecureLogger.log("NoiseSession[\(peerID)]: Initialized handshake state for responder", category: SecureLogger.noise, level: .info)
|
SecureLogger.debug("NoiseSession[\(peerID)]: Initialized handshake state for responder")
|
||||||
}
|
}
|
||||||
|
|
||||||
guard case .handshaking = state, let handshake = handshakeState else {
|
guard case .handshaking = state, let handshake = handshakeState else {
|
||||||
@@ -112,7 +98,7 @@ class NoiseSession {
|
|||||||
|
|
||||||
// Process incoming message
|
// Process incoming message
|
||||||
_ = try handshake.readMessage(message)
|
_ = try handshake.readMessage(message)
|
||||||
SecureLogger.log("NoiseSession[\(peerID)]: Read handshake message, checking if complete", category: SecureLogger.noise, level: .info)
|
SecureLogger.debug("NoiseSession[\(peerID)]: Read handshake message, checking if complete")
|
||||||
|
|
||||||
// Check if handshake is complete
|
// Check if handshake is complete
|
||||||
if handshake.isHandshakeComplete() {
|
if handshake.isHandshakeComplete() {
|
||||||
@@ -130,15 +116,15 @@ class NoiseSession {
|
|||||||
state = .established
|
state = .established
|
||||||
handshakeState = nil // Clear handshake state
|
handshakeState = nil // Clear handshake state
|
||||||
|
|
||||||
SecureLogger.log("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established", category: SecureLogger.noise, level: .info)
|
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established")
|
||||||
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID))
|
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
} else {
|
} else {
|
||||||
// Generate response
|
// Generate response
|
||||||
let response = try handshake.writeMessage()
|
let response = try handshake.writeMessage()
|
||||||
sentHandshakeMessages.append(response)
|
sentHandshakeMessages.append(response)
|
||||||
SecureLogger.log("NoiseSession[\(peerID)]: Generated handshake response of size \(response.count)", category: SecureLogger.noise, level: .info)
|
SecureLogger.debug("NoiseSession[\(peerID)]: Generated handshake response of size \(response.count)")
|
||||||
|
|
||||||
// Check if handshake is complete after writing
|
// Check if handshake is complete after writing
|
||||||
if handshake.isHandshakeComplete() {
|
if handshake.isHandshakeComplete() {
|
||||||
@@ -156,8 +142,8 @@ class NoiseSession {
|
|||||||
state = .established
|
state = .established
|
||||||
handshakeState = nil // Clear handshake state
|
handshakeState = nil // Clear handshake state
|
||||||
|
|
||||||
SecureLogger.log("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established", category: SecureLogger.noise, level: .info)
|
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established")
|
||||||
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID))
|
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
return response
|
||||||
@@ -221,251 +207,29 @@ class NoiseSession {
|
|||||||
let wasEstablished = state == .established
|
let wasEstablished = state == .established
|
||||||
state = .uninitialized
|
state = .uninitialized
|
||||||
handshakeState = nil
|
handshakeState = nil
|
||||||
|
|
||||||
|
// Clear sensitive cipher states
|
||||||
|
sendCipher?.clearSensitiveData()
|
||||||
|
receiveCipher?.clearSensitiveData()
|
||||||
sendCipher = nil
|
sendCipher = nil
|
||||||
receiveCipher = nil
|
receiveCipher = nil
|
||||||
|
|
||||||
|
// Clear sent handshake messages
|
||||||
|
for i in 0..<sentHandshakeMessages.count {
|
||||||
|
var message = sentHandshakeMessages[i]
|
||||||
|
keychain.secureClear(&message)
|
||||||
|
}
|
||||||
sentHandshakeMessages.removeAll()
|
sentHandshakeMessages.removeAll()
|
||||||
|
|
||||||
|
// Clear handshake hash
|
||||||
|
if var hash = handshakeHash {
|
||||||
|
keychain.secureClear(&hash)
|
||||||
|
}
|
||||||
handshakeHash = nil
|
handshakeHash = nil
|
||||||
|
|
||||||
if wasEstablished {
|
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], session.isEstablished() {
|
|
||||||
SecureLogger.logSecurityEvent(.sessionExpired(peerID: peerID))
|
|
||||||
}
|
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func migrateSession(from oldPeerID: String, to newPeerID: String) {
|
|
||||||
managerQueue.sync(flags: .barrier) {
|
|
||||||
// Check if we have a session for the old peer ID
|
|
||||||
if let session = sessions[oldPeerID] {
|
|
||||||
// Move the session to the new peer ID
|
|
||||||
sessions[newPeerID] = session
|
|
||||||
_ = sessions.removeValue(forKey: oldPeerID)
|
|
||||||
|
|
||||||
SecureLogger.log("Migrated Noise session from \(oldPeerID) to \(newPeerID)", category: SecureLogger.noise, level: .info)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,15 @@
|
|||||||
|
//
|
||||||
|
// NoiseSessionError.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
enum NoiseSessionError: Error {
|
||||||
|
case invalidState
|
||||||
|
case notEstablished
|
||||||
|
case sessionNotFound
|
||||||
|
case handshakeFailed(Error)
|
||||||
|
case alreadyEstablished
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
|
|
||||||
|
final class NoiseSessionManager {
|
||||||
|
private var sessions: [PeerID: NoiseSession] = [:]
|
||||||
|
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
|
||||||
|
private let keychain: KeychainManagerProtocol
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session Management
|
||||||
|
|
||||||
|
func createSession(for peerID: PeerID, role: NoiseRole) -> NoiseSession {
|
||||||
|
return managerQueue.sync(flags: .barrier) {
|
||||||
|
let session = SecureNoiseSession(
|
||||||
|
peerID: peerID,
|
||||||
|
role: role,
|
||||||
|
keychain: keychain,
|
||||||
|
localStaticKey: localStaticKey
|
||||||
|
)
|
||||||
|
sessions[peerID] = session
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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[peerID] {
|
||||||
|
if session.isEstablished() {
|
||||||
|
SecureLogger.info(.sessionExpired(peerID: peerID.id))
|
||||||
|
}
|
||||||
|
// Clear sensitive data before removing
|
||||||
|
session.reset()
|
||||||
|
}
|
||||||
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeAllSessions() {
|
||||||
|
managerQueue.sync(flags: .barrier) {
|
||||||
|
for (_, session) in sessions {
|
||||||
|
session.reset()
|
||||||
|
}
|
||||||
|
sessions.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEstablishedSessions() -> [PeerID: NoiseSession] {
|
||||||
|
return managerQueue.sync {
|
||||||
|
return sessions.filter { $0.value.isEstablished() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 = SecureNoiseSession(
|
||||||
|
peerID: peerID,
|
||||||
|
role: .initiator,
|
||||||
|
keychain: keychain,
|
||||||
|
localStaticKey: localStaticKey
|
||||||
|
)
|
||||||
|
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 = SecureNoiseSession(
|
||||||
|
peerID: peerID,
|
||||||
|
role: .responder,
|
||||||
|
keychain: keychain,
|
||||||
|
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.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()
|
||||||
|
}
|
||||||
|
|
||||||
|
func getHandshakeHash(for peerID: PeerID) -> Data? {
|
||||||
|
return getSession(for: peerID)?.getHandshakeHash()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,162 @@
|
|||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
import Tor
|
||||||
|
|
||||||
|
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
||||||
|
@MainActor
|
||||||
|
final class GeoRelayDirectory {
|
||||||
|
struct Entry: Hashable {
|
||||||
|
let host: String
|
||||||
|
let lat: Double
|
||||||
|
let lon: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = TransportConfig.geoRelayFetchIntervalSeconds // 24h
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
// Load cached or bundled data synchronously
|
||||||
|
self.entries = self.loadLocalEntries()
|
||||||
|
// Fire-and-forget remote refresh if stale
|
||||||
|
prefetchIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
|
||||||
|
func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] {
|
||||||
|
let center = Geohash.decodeCenter(geohash)
|
||||||
|
return closestRelays(toLat: center.lat, lon: center.lon, count: count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
.prefix(count)
|
||||||
|
return sorted.map { "wss://\($0.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 }
|
||||||
|
fetchRemote()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchRemote() {
|
||||||
|
let req = URLRequest(url: remoteURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
|
||||||
|
// Ensure Tor readiness before fetching (fail-closed by default)
|
||||||
|
Task.detached {
|
||||||
|
let ready = await TorManager.shared.awaitReady()
|
||||||
|
if !ready {
|
||||||
|
SecureLogger.warning("GeoRelayDirectory: Tor not ready; skipping remote fetch (fail-closed)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let task = TorURLSession.shared.session.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.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SecureLogger.warning("GeoRelayDirectory: remote fetch failed; keeping local entries", category: .session)
|
||||||
|
}
|
||||||
|
task.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persistCache(_ text: String) {
|
||||||
|
guard let url = cacheURL() else { return }
|
||||||
|
do {
|
||||||
|
try text.data(using: .utf8)?.write(to: url, options: .atomic)
|
||||||
|
} catch {
|
||||||
|
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),
|
||||||
|
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 }
|
||||||
|
for url in bundleCandidates {
|
||||||
|
if let data = try? Data(contentsOf: 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")),
|
||||||
|
let text = String(data: data, encoding: .utf8) {
|
||||||
|
return Self.parseCSV(text)
|
||||||
|
}
|
||||||
|
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 }
|
||||||
|
if idx == 0 && line.lowercased().contains("relay url") { continue }
|
||||||
|
let parts = line.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) }
|
||||||
|
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 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: - Distance
|
||||||
|
private func haversineKm(_ lat1: Double, _ lon1: Double, _ lat2: Double, _ lon2: Double) -> Double {
|
||||||
|
let r = 6371.0 // Earth radius in km
|
||||||
|
let dLat = (lat2 - lat1) * .pi / 180
|
||||||
|
let dLon = (lon2 - lon1) * .pi / 180
|
||||||
|
let a = sin(dLat/2) * sin(dLat/2) + cos(lat1 * .pi/180) * cos(lat2 * .pi/180) * sin(dLon/2) * sin(dLon/2)
|
||||||
|
let c = 2 * atan2(sqrt(a), sqrt(1 - a))
|
||||||
|
return r * c
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// 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? {
|
||||||
|
// TLV-encode the private message
|
||||||
|
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
||||||
|
guard let tlv = pm.encode() else { return nil }
|
||||||
|
|
||||||
|
// Prefix with NoisePayloadType
|
||||||
|
var payload = Data([NoisePayloadType.privateMessage.rawValue])
|
||||||
|
payload.append(tlv)
|
||||||
|
|
||||||
|
// Determine 8-byte recipient ID to embed
|
||||||
|
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
|
||||||
|
|
||||||
|
let packet = BitchatPacket(
|
||||||
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
|
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||||
|
recipientID: Data(hexString: recipientIDHex),
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
payload: payload,
|
||||||
|
signature: nil,
|
||||||
|
ttl: 7
|
||||||
|
)
|
||||||
|
|
||||||
|
guard let data = packet.toBinaryData() else { return nil }
|
||||||
|
return "bitchat1:" + base64URLEncode(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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? {
|
||||||
|
guard type == .delivered || type == .readReceipt else { return nil }
|
||||||
|
|
||||||
|
var payload = Data([type.rawValue])
|
||||||
|
payload.append(Data(messageID.utf8))
|
||||||
|
|
||||||
|
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
|
||||||
|
|
||||||
|
let packet = BitchatPacket(
|
||||||
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
|
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||||
|
recipientID: Data(hexString: recipientIDHex),
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
payload: payload,
|
||||||
|
signature: nil,
|
||||||
|
ttl: 7
|
||||||
|
)
|
||||||
|
|
||||||
|
guard let data = packet.toBinaryData() else { return nil }
|
||||||
|
return "bitchat1:" + base64URLEncode(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a `bitchat1:` ACK (delivered/read) without an embedded recipient peer ID (geohash DMs).
|
||||||
|
static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: String) -> String? {
|
||||||
|
guard type == .delivered || type == .readReceipt else { return nil }
|
||||||
|
|
||||||
|
var payload = Data([type.rawValue])
|
||||||
|
payload.append(Data(messageID.utf8))
|
||||||
|
|
||||||
|
let packet = BitchatPacket(
|
||||||
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
|
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||||
|
recipientID: nil,
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
payload: payload,
|
||||||
|
signature: nil,
|
||||||
|
ttl: 7
|
||||||
|
)
|
||||||
|
|
||||||
|
guard let data = packet.toBinaryData() else { return nil }
|
||||||
|
return "bitchat1:" + base64URLEncode(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a `bitchat1:` payload without an embedded recipient peer ID (used for geohash DMs).
|
||||||
|
static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: String) -> String? {
|
||||||
|
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
||||||
|
guard let tlv = pm.encode() else { return nil }
|
||||||
|
|
||||||
|
var payload = Data([NoisePayloadType.privateMessage.rawValue])
|
||||||
|
payload.append(tlv)
|
||||||
|
|
||||||
|
let packet = BitchatPacket(
|
||||||
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
|
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||||
|
recipientID: nil,
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
payload: payload,
|
||||||
|
signature: nil,
|
||||||
|
ttl: 7
|
||||||
|
)
|
||||||
|
|
||||||
|
guard let data = packet.toBinaryData() else { return nil }
|
||||||
|
return "bitchat1:" + base64URLEncode(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizeRecipientPeerID(_ recipientPeerID: String) -> String {
|
||||||
|
if let maybeData = Data(hexString: recipientPeerID) {
|
||||||
|
if maybeData.count == 32 {
|
||||||
|
// Treat as Noise static public key; derive peerID from fingerprint
|
||||||
|
return PeerID(publicKey: maybeData).id
|
||||||
|
} else if maybeData.count == 8 {
|
||||||
|
// Already an 8-byte peer ID
|
||||||
|
return recipientPeerID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: return as-is (expecting 16 hex chars) – caller should pass a valid peer ID
|
||||||
|
return recipientPeerID
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Base64url encode without padding
|
||||||
|
private static func base64URLEncode(_ data: Data) -> String {
|
||||||
|
let b64 = data.base64EncodedString()
|
||||||
|
return b64
|
||||||
|
.replacingOccurrences(of: "+", with: "-")
|
||||||
|
.replacingOccurrences(of: "/", with: "_")
|
||||||
|
.replacingOccurrences(of: "=", with: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import P256K
|
import P256K
|
||||||
|
import Security
|
||||||
|
|
||||||
// Keychain helper for secure storage
|
// Keychain helper for secure storage
|
||||||
struct KeychainHelper {
|
struct KeychainHelper {
|
||||||
static func save(key: String, data: Data, service: String) {
|
static func save(key: String, data: Data, service: String, accessible: CFString? = nil) {
|
||||||
let query: [String: Any] = [
|
var query: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrService as String: service,
|
kSecAttrService as String: service,
|
||||||
kSecAttrAccount as String: key,
|
kSecAttrAccount as String: key,
|
||||||
kSecValueData as String: data
|
kSecValueData as String: data
|
||||||
]
|
]
|
||||||
|
if let accessible = accessible {
|
||||||
|
query[kSecAttrAccessible as String] = accessible
|
||||||
|
}
|
||||||
|
|
||||||
SecItemDelete(query as CFDictionary)
|
SecItemDelete(query as CFDictionary)
|
||||||
SecItemAdd(query as CFDictionary, nil)
|
SecItemAdd(query as CFDictionary, nil)
|
||||||
@@ -104,6 +108,9 @@ struct NostrIdentity: Codable {
|
|||||||
struct NostrIdentityBridge {
|
struct NostrIdentityBridge {
|
||||||
private static let keychainService = "chat.bitchat.nostr"
|
private static let keychainService = "chat.bitchat.nostr"
|
||||||
private static let currentIdentityKey = "nostr-current-identity"
|
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
|
/// Get or create the current Nostr identity
|
||||||
static func getCurrentNostrIdentity() throws -> NostrIdentity? {
|
static func getCurrentNostrIdentity() throws -> NostrIdentity? {
|
||||||
@@ -140,6 +147,88 @@ struct NostrIdentityBridge {
|
|||||||
}
|
}
|
||||||
return pubkey
|
return pubkey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clear all Nostr identity associations and current identity
|
||||||
|
static 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 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
|
||||||
|
let fallback = (seed + msg).sha256Hash()
|
||||||
|
return try NostrIdentity(privateKeyData: fallback)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bech32 encoding for Nostr (minimal implementation)
|
// Bech32 encoding for Nostr (minimal implementation)
|
||||||
@@ -165,6 +254,14 @@ enum Bech32 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let hrp = String(bech32String[..<separatorIndex])
|
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)...])
|
let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])
|
||||||
|
|
||||||
// Convert characters to values
|
// Convert characters to values
|
||||||
@@ -238,11 +335,17 @@ enum Bech32 {
|
|||||||
private static func hrpExpand(_ hrp: String) -> [UInt8] {
|
private static func hrpExpand(_ hrp: String) -> [UInt8] {
|
||||||
var result = [UInt8]()
|
var result = [UInt8]()
|
||||||
for c in hrp {
|
for c in hrp {
|
||||||
result.append(UInt8(c.asciiValue! >> 5))
|
guard let asciiValue = c.asciiValue else {
|
||||||
|
return [] // Return empty array for invalid input
|
||||||
|
}
|
||||||
|
result.append(UInt8(asciiValue >> 5))
|
||||||
}
|
}
|
||||||
result.append(0)
|
result.append(0)
|
||||||
for c in hrp {
|
for c in hrp {
|
||||||
result.append(UInt8(c.asciiValue! & 31))
|
guard let asciiValue = c.asciiValue else {
|
||||||
|
return [] // Return empty array for invalid input
|
||||||
|
}
|
||||||
|
result.append(UInt8(asciiValue & 31))
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import P256K
|
import P256K
|
||||||
|
import Security
|
||||||
|
|
||||||
// Note: This file depends on Data extension from BinaryEncodingUtils.swift
|
// Note: This file depends on Data extension from BinaryEncodingUtils.swift
|
||||||
// Make sure BinaryEncodingUtils.swift is included in the target
|
// Make sure BinaryEncodingUtils.swift is included in the target
|
||||||
@@ -12,8 +14,9 @@ struct NostrProtocol {
|
|||||||
enum EventKind: Int {
|
enum EventKind: Int {
|
||||||
case metadata = 0
|
case metadata = 0
|
||||||
case textNote = 1
|
case textNote = 1
|
||||||
|
case dm = 14 // NIP-17 DM rumor kind
|
||||||
case seal = 13 // NIP-17 sealed event
|
case seal = 13 // NIP-17 sealed event
|
||||||
case giftWrap = 1059 // NIP-17 gift wrap
|
case giftWrap = 1059 // NIP-59 gift wrap
|
||||||
case ephemeralEvent = 20000
|
case ephemeralEvent = 20000
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,14 +33,13 @@ struct NostrProtocol {
|
|||||||
let rumor = NostrEvent(
|
let rumor = NostrEvent(
|
||||||
pubkey: senderIdentity.publicKeyHex,
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
kind: .textNote,
|
kind: .dm, // NIP-17: DM rumor kind 14
|
||||||
tags: [],
|
tags: [],
|
||||||
content: content
|
content: content
|
||||||
)
|
)
|
||||||
|
|
||||||
// 2. Create ephemeral key for this message
|
// 2. Create ephemeral key for this message
|
||||||
let ephemeralKey = try P256K.Schnorr.PrivateKey()
|
let ephemeralKey = try P256K.Schnorr.PrivateKey()
|
||||||
let _ = Data(ephemeralKey.xonly.bytes).hexEncodedString()
|
|
||||||
// Created ephemeral key for seal
|
// Created ephemeral key for seal
|
||||||
|
|
||||||
// 3. Seal the rumor (encrypt to recipient)
|
// 3. Seal the rumor (encrypt to recipient)
|
||||||
@@ -60,10 +62,11 @@ struct NostrProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypt a received NIP-17 message
|
/// Decrypt a received NIP-17 message
|
||||||
|
/// Returns the content, sender pubkey, and the actual message timestamp (not the randomized gift wrap timestamp)
|
||||||
static func decryptPrivateMessage(
|
static func decryptPrivateMessage(
|
||||||
giftWrap: NostrEvent,
|
giftWrap: NostrEvent,
|
||||||
recipientIdentity: NostrIdentity
|
recipientIdentity: NostrIdentity
|
||||||
) throws -> (content: String, senderPubkey: String) {
|
) throws -> (content: String, senderPubkey: String, timestamp: Int) {
|
||||||
|
|
||||||
// Starting decryption
|
// Starting decryption
|
||||||
|
|
||||||
@@ -76,8 +79,7 @@ struct NostrProtocol {
|
|||||||
)
|
)
|
||||||
// Successfully unwrapped gift wrap
|
// Successfully unwrapped gift wrap
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.log("❌ Failed to unwrap gift wrap: \(error)",
|
SecureLogger.error("❌ Failed to unwrap gift wrap: \(error)", category: .session)
|
||||||
category: SecureLogger.session, level: .error)
|
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,12 +92,59 @@ struct NostrProtocol {
|
|||||||
)
|
)
|
||||||
// Successfully opened seal
|
// Successfully opened seal
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.log("❌ Failed to open seal: \(error)",
|
SecureLogger.error("❌ Failed to open seal: \(error)", category: .session)
|
||||||
category: SecureLogger.session, level: .error)
|
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
return (content: rumor.content, senderPubkey: rumor.pubkey)
|
return (content: rumor.content, senderPubkey: rumor.pubkey, timestamp: rumor.created_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a geohash-scoped ephemeral public message (kind 20000)
|
||||||
|
static func createEphemeralGeohashEvent(
|
||||||
|
content: String,
|
||||||
|
geohash: String,
|
||||||
|
senderIdentity: NostrIdentity,
|
||||||
|
nickname: String? = nil,
|
||||||
|
teleported: Bool = false
|
||||||
|
) throws -> NostrEvent {
|
||||||
|
var tags = [["g", geohash]]
|
||||||
|
if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty {
|
||||||
|
tags.append(["n", nickname])
|
||||||
|
}
|
||||||
|
if teleported {
|
||||||
|
tags.append(["t", "teleport"])
|
||||||
|
}
|
||||||
|
let event = NostrEvent(
|
||||||
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
|
createdAt: Date(),
|
||||||
|
kind: .ephemeralEvent,
|
||||||
|
tags: tags,
|
||||||
|
content: 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?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty {
|
||||||
|
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
|
// MARK: - Private Methods
|
||||||
@@ -121,9 +170,8 @@ struct NostrProtocol {
|
|||||||
content: encrypted
|
content: encrypted
|
||||||
)
|
)
|
||||||
|
|
||||||
// Convert to P256K.Signing.PrivateKey for signing (temporary until we update sign method)
|
// Sign the seal with the sender's Schnorr private key
|
||||||
let signingKey = try P256K.Signing.PrivateKey(dataRepresentation: senderKey.dataRepresentation)
|
return try seal.sign(with: senderKey)
|
||||||
return try seal.sign(with: signingKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func createGiftWrap(
|
private static func createGiftWrap(
|
||||||
@@ -153,9 +201,8 @@ struct NostrProtocol {
|
|||||||
content: encrypted
|
content: encrypted
|
||||||
)
|
)
|
||||||
|
|
||||||
// Convert to P256K.Signing.PrivateKey for signing (temporary until we update sign method)
|
// Sign the gift wrap with the wrap Schnorr private key
|
||||||
let signingKey = try P256K.Signing.PrivateKey(dataRepresentation: wrapKey.dataRepresentation)
|
return try giftWrap.sign(with: wrapKey)
|
||||||
return try giftWrap.sign(with: signingKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func unwrapGiftWrap(
|
private static func unwrapGiftWrap(
|
||||||
@@ -201,7 +248,7 @@ struct NostrProtocol {
|
|||||||
return try NostrEvent(from: rumorDict)
|
return try NostrEvent(from: rumorDict)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Encryption (NIP-44 style)
|
// MARK: - Encryption (NIP-44 v2)
|
||||||
|
|
||||||
private static func encrypt(
|
private static func encrypt(
|
||||||
plaintext: String,
|
plaintext: String,
|
||||||
@@ -213,34 +260,31 @@ struct NostrProtocol {
|
|||||||
throw NostrError.invalidPublicKey
|
throw NostrError.invalidPublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = Data(senderKey.xonly.bytes).hexEncodedString()
|
// Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned)
|
||||||
// Encrypting message
|
|
||||||
|
|
||||||
// Derive shared secret
|
// Derive shared secret
|
||||||
let sharedSecret = try deriveSharedSecret(
|
let sharedSecret = try deriveSharedSecret(
|
||||||
privateKey: senderKey,
|
privateKey: senderKey,
|
||||||
publicKey: recipientPubkeyData
|
publicKey: recipientPubkeyData
|
||||||
)
|
)
|
||||||
|
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info)
|
||||||
|
let key = try deriveNIP44V2Key(from: sharedSecret)
|
||||||
|
|
||||||
// Derived shared secret
|
// 24-byte random nonce for XChaCha20-Poly1305
|
||||||
|
var nonce24 = Data(count: 24)
|
||||||
|
_ = nonce24.withUnsafeMutableBytes { ptr in
|
||||||
|
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
|
||||||
|
}
|
||||||
|
|
||||||
// Generate nonce
|
let pt = Data(plaintext.utf8)
|
||||||
let nonce = AES.GCM.Nonce()
|
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24)
|
||||||
|
|
||||||
// Encrypt
|
// v2: base64url(nonce24 || ciphertext || tag)
|
||||||
let sealed = try AES.GCM.seal(
|
var combined = Data()
|
||||||
plaintext.data(using: .utf8)!,
|
combined.append(nonce24)
|
||||||
using: SymmetricKey(data: sharedSecret),
|
combined.append(sealed.ciphertext)
|
||||||
nonce: nonce
|
combined.append(sealed.tag)
|
||||||
)
|
return "v2:" + base64URLEncode(combined)
|
||||||
|
|
||||||
// Combine nonce + ciphertext + tag
|
|
||||||
var result = Data()
|
|
||||||
result.append(nonce.withUnsafeBytes { Data($0) })
|
|
||||||
result.append(sealed.ciphertext)
|
|
||||||
result.append(sealed.tag)
|
|
||||||
|
|
||||||
return result.base64EncodedString()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func decrypt(
|
private static func decrypt(
|
||||||
@@ -248,86 +292,45 @@ struct NostrProtocol {
|
|||||||
senderPubkey: String,
|
senderPubkey: String,
|
||||||
recipientKey: P256K.Schnorr.PrivateKey
|
recipientKey: P256K.Schnorr.PrivateKey
|
||||||
) throws -> String {
|
) throws -> String {
|
||||||
|
// Expect NIP-44 v2 format
|
||||||
// Decrypting message
|
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
|
||||||
|
let encoded = String(ciphertext.dropFirst(3))
|
||||||
guard let data = Data(base64Encoded: ciphertext),
|
guard let data = base64URLDecode(encoded),
|
||||||
|
data.count > (24 + 16),
|
||||||
let senderPubkeyData = Data(hexString: senderPubkey) else {
|
let senderPubkeyData = Data(hexString: senderPubkey) else {
|
||||||
SecureLogger.log("❌ Invalid ciphertext or sender pubkey format",
|
|
||||||
category: SecureLogger.session, level: .error)
|
|
||||||
throw NostrError.invalidCiphertext
|
throw NostrError.invalidCiphertext
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ciphertext data parsed
|
let nonce24 = data.prefix(24)
|
||||||
|
let rest = data.dropFirst(24)
|
||||||
// Extract components
|
let tag = rest.suffix(16)
|
||||||
let nonceData = data.prefix(12)
|
let ct = rest.dropLast(16)
|
||||||
let ciphertextData = data.dropFirst(12).dropLast(16)
|
|
||||||
let tagData = data.suffix(16)
|
// Try decryption with even-Y then odd-Y when sender pubkey is x-only
|
||||||
|
func attemptDecrypt(using pubKeyData: Data) throws -> Data {
|
||||||
// Components parsed
|
let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData)
|
||||||
|
let key = try deriveNIP44V2Key(from: ss)
|
||||||
// Derive shared secret - try with default Y coordinate first
|
return try XChaCha20Poly1305Compat.open(
|
||||||
var sharedSecret: Data
|
ciphertext: Data(ct),
|
||||||
var decrypted: Data? = nil
|
tag: Data(tag),
|
||||||
|
key: key,
|
||||||
do {
|
nonce24: Data(nonce24)
|
||||||
sharedSecret = try deriveSharedSecret(
|
|
||||||
privateKey: recipientKey,
|
|
||||||
publicKey: senderPubkeyData
|
|
||||||
)
|
)
|
||||||
// Derived shared secret with first Y coordinate
|
}
|
||||||
|
|
||||||
// Try to decrypt
|
// If 32 bytes (x-only) try both parities, otherwise single try
|
||||||
let sealedBox = try AES.GCM.SealedBox(
|
if senderPubkeyData.count == 32 {
|
||||||
nonce: AES.GCM.Nonce(data: nonceData),
|
let even = Data([0x02]) + senderPubkeyData
|
||||||
ciphertext: ciphertextData,
|
if let pt = try? attemptDecrypt(using: even) {
|
||||||
tag: tagData
|
return String(data: pt, encoding: .utf8) ?? ""
|
||||||
)
|
|
||||||
|
|
||||||
do {
|
|
||||||
decrypted = try AES.GCM.open(
|
|
||||||
sealedBox,
|
|
||||||
using: SymmetricKey(data: sharedSecret)
|
|
||||||
)
|
|
||||||
// AES-GCM decryption successful
|
|
||||||
} catch {
|
|
||||||
// AES-GCM decryption failed, trying alternate
|
|
||||||
|
|
||||||
// If the sender pubkey is x-only (32 bytes), try the other Y coordinate
|
|
||||||
if senderPubkeyData.count == 32 {
|
|
||||||
// Trying alternate Y coordinate
|
|
||||||
|
|
||||||
// Force deriveSharedSecret to use odd Y by manipulating the data
|
|
||||||
var altPubkey = Data()
|
|
||||||
altPubkey.append(0x03) // Force odd Y
|
|
||||||
altPubkey.append(senderPubkeyData)
|
|
||||||
|
|
||||||
sharedSecret = try deriveSharedSecretDirect(
|
|
||||||
privateKey: recipientKey,
|
|
||||||
publicKey: altPubkey
|
|
||||||
)
|
|
||||||
|
|
||||||
decrypted = try AES.GCM.open(
|
|
||||||
sealedBox,
|
|
||||||
using: SymmetricKey(data: sharedSecret)
|
|
||||||
)
|
|
||||||
// AES-GCM decryption successful with alternate Y
|
|
||||||
} else {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {
|
let odd = Data([0x03]) + senderPubkeyData
|
||||||
SecureLogger.log("❌ Failed to derive shared secret or decrypt: \(error)",
|
let pt = try attemptDecrypt(using: odd)
|
||||||
category: SecureLogger.session, level: .error)
|
return String(data: pt, encoding: .utf8) ?? ""
|
||||||
throw error
|
} else {
|
||||||
|
let pt = try attemptDecrypt(using: senderPubkeyData)
|
||||||
|
return String(data: pt, encoding: .utf8) ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let finalDecrypted = decrypted else {
|
|
||||||
throw NostrError.encryptionFailed
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(data: finalDecrypted, encoding: .utf8) ?? ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func deriveSharedSecret(
|
private static func deriveSharedSecret(
|
||||||
@@ -387,17 +390,8 @@ struct NostrProtocol {
|
|||||||
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
||||||
// ECDH shared secret derived
|
// ECDH shared secret derived
|
||||||
|
|
||||||
// Derive key using HKDF for NIP-44 v2
|
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
|
||||||
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
return sharedSecretData
|
||||||
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
|
||||||
salt: "nip44-v2".data(using: .utf8)!,
|
|
||||||
info: Data(),
|
|
||||||
outputByteCount: 32
|
|
||||||
)
|
|
||||||
|
|
||||||
let result = derivedKey.withUnsafeBytes { Data($0) }
|
|
||||||
// Final derived key ready
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direct version that doesn't try to add prefixes
|
// Direct version that doesn't try to add prefixes
|
||||||
@@ -427,34 +421,25 @@ struct NostrProtocol {
|
|||||||
// Convert SharedSecret to Data
|
// Convert SharedSecret to Data
|
||||||
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
||||||
|
|
||||||
// Derive key using HKDF for NIP-44 v2
|
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
|
||||||
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
return sharedSecretData
|
||||||
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
|
||||||
salt: "nip44-v2".data(using: .utf8)!,
|
|
||||||
info: Data(),
|
|
||||||
outputByteCount: 32
|
|
||||||
)
|
|
||||||
|
|
||||||
return derivedKey.withUnsafeBytes { Data($0) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func randomizedTimestamp() -> Date {
|
private static func randomizedTimestamp() -> Date {
|
||||||
// Add random offset to current time for privacy
|
// Add random offset to current time for privacy
|
||||||
// TEMPORARY: Reduced range to debug timestamp issue
|
// This prevents timing correlation attacks while the actual message timestamp
|
||||||
let offset = TimeInterval.random(in: -60...60) // +/- 1 minute (was +/- 15 minutes)
|
// is preserved in the encrypted rumor
|
||||||
|
let offset = TimeInterval.random(in: -900...900) // +/- 15 minutes
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let randomized = now.addingTimeInterval(offset)
|
let randomized = now.addingTimeInterval(offset)
|
||||||
|
|
||||||
// Log with explicit UTC and local time for debugging
|
// Log with explicit UTC and local time for debugging
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
|
//
|
||||||
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||||
formatter.timeZone = TimeZone(abbreviation: "UTC")
|
formatter.timeZone = TimeZone(abbreviation: "UTC")
|
||||||
let _ = formatter.string(from: now)
|
|
||||||
let _ = formatter.string(from: randomized)
|
|
||||||
|
|
||||||
formatter.timeZone = TimeZone.current
|
formatter.timeZone = TimeZone.current
|
||||||
let _ = formatter.string(from: now)
|
|
||||||
let _ = formatter.string(from: randomized)
|
|
||||||
|
|
||||||
// Timestamp randomized for privacy
|
// Timestamp randomized for privacy
|
||||||
|
|
||||||
@@ -506,16 +491,16 @@ struct NostrEvent: Codable {
|
|||||||
self.sig = dict["sig"] as? String
|
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()
|
let (eventId, eventIdHash) = try calculateEventId()
|
||||||
|
|
||||||
// Convert to Schnorr key for Nostr signing
|
// Sign with Schnorr (BIP-340)
|
||||||
let schnorrKey = try P256K.Schnorr.PrivateKey(dataRepresentation: key.dataRepresentation)
|
|
||||||
|
|
||||||
// Sign with Schnorr
|
|
||||||
var messageBytes = [UInt8](eventIdHash)
|
var messageBytes = [UInt8](eventIdHash)
|
||||||
var auxRand = [UInt8](repeating: 0, count: 32) // Zero auxiliary randomness for deterministic signing
|
var auxRand = [UInt8](repeating: 0, count: 32)
|
||||||
let schnorrSignature = try schnorrKey.signature(message: &messageBytes, auxiliaryRand: &auxRand)
|
_ = auxRand.withUnsafeMutableBytes { ptr in
|
||||||
|
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
||||||
|
}
|
||||||
|
let schnorrSignature = try key.signature(message: &messageBytes, auxiliaryRand: &auxRand)
|
||||||
|
|
||||||
let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString()
|
let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString()
|
||||||
|
|
||||||
@@ -536,10 +521,7 @@ struct NostrEvent: Codable {
|
|||||||
] as [Any]
|
] as [Any]
|
||||||
|
|
||||||
let data = try JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
|
let data = try JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
|
||||||
let hash = CryptoKit.SHA256.hash(data: data)
|
return (data.sha256Fingerprint(), data.sha256Hash())
|
||||||
let hashData = Data(hash)
|
|
||||||
let hashHex = hash.compactMap { String(format: "%02x", $0) }.joined()
|
|
||||||
return (hashHex, hashData)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func jsonString() throws -> String {
|
func jsonString() throws -> String {
|
||||||
@@ -557,4 +539,33 @@ enum NostrError: Error {
|
|||||||
case invalidCiphertext
|
case invalidCiphertext
|
||||||
case signingFailed
|
case signingFailed
|
||||||
case encryptionFailed
|
case encryptionFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305 + base64url)
|
||||||
|
|
||||||
|
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),
|
||||||
|
salt: Data(),
|
||||||
|
info: "nip44-v2".data(using: .utf8)!,
|
||||||
|
outputByteCount: 32
|
||||||
|
)
|
||||||
|
return derivedKey.withUnsafeBytes { Data($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
import Combine
|
import Combine
|
||||||
|
import Tor
|
||||||
|
|
||||||
/// Manages WebSocket connections to Nostr relays
|
/// Manages WebSocket connections to Nostr relays
|
||||||
@MainActor
|
@MainActor
|
||||||
class NostrRelayManager: ObservableObject {
|
final class NostrRelayManager: ObservableObject {
|
||||||
static let shared = NostrRelayManager()
|
static let shared = NostrRelayManager()
|
||||||
|
// Track gift-wraps (kind 1059) we initiated so we can log OK acks at info
|
||||||
|
private(set) static var pendingGiftWrapIDs = Set<String>()
|
||||||
|
static func registerPendingGiftWrap(id: String) {
|
||||||
|
pendingGiftWrapIDs.insert(id)
|
||||||
|
}
|
||||||
|
|
||||||
struct Relay: Identifiable {
|
struct Relay: Identifiable {
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
@@ -23,127 +30,385 @@ class NostrRelayManager: ObservableObject {
|
|||||||
// Default relay list (can be customized)
|
// Default relay list (can be customized)
|
||||||
private static let defaultRelays = [
|
private static let defaultRelays = [
|
||||||
"wss://relay.damus.io",
|
"wss://relay.damus.io",
|
||||||
|
"wss://nos.lol",
|
||||||
"wss://relay.primal.net",
|
"wss://relay.primal.net",
|
||||||
"wss://offchain.pub",
|
"wss://offchain.pub",
|
||||||
"wss://nostr21.com"
|
"wss://nostr21.com"
|
||||||
// For local testing, you can add: "ws://localhost:8080"
|
// For local testing, you can add: "ws://localhost:8080"
|
||||||
]
|
]
|
||||||
|
private static let defaultRelaySet = Set(defaultRelays)
|
||||||
|
|
||||||
@Published private(set) var relays: [Relay] = []
|
@Published private(set) var relays: [Relay] = []
|
||||||
@Published private(set) var isConnected = false
|
@Published private(set) var isConnected = false
|
||||||
|
|
||||||
|
private var allowDefaultRelays: Bool = false
|
||||||
|
private var hasMutualFavorites: Bool = false
|
||||||
|
private var hasLocationPermission: Bool = false
|
||||||
private var connections: [String: URLSessionWebSocketTask] = [:]
|
private var connections: [String: URLSessionWebSocketTask] = [:]
|
||||||
private var subscriptions: [String: Set<String>] = [:] // relay URL -> subscription IDs
|
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
|
||||||
|
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
|
||||||
private var messageHandlers: [String: (NostrEvent) -> Void] = [:]
|
private var messageHandlers: [String: (NostrEvent) -> Void] = [:]
|
||||||
|
// Coalesce duplicate subscribe requests for the same id within a short window
|
||||||
|
private var subscribeCoalesce: [String: Date] = [:]
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
// Track EOSE per subscription to signal when initial stored events are done
|
||||||
|
private struct EOSETracker {
|
||||||
|
var pendingRelays: Set<String>
|
||||||
|
var callback: () -> Void
|
||||||
|
var timer: Timer?
|
||||||
|
}
|
||||||
|
private var eoseTrackers: [String: EOSETracker] = [:]
|
||||||
|
|
||||||
// Message queue for reliability
|
// Message queue for reliability
|
||||||
private var messageQueue: [(event: NostrEvent, relayUrls: [String])] = []
|
// Pending sends held only for relays that are not yet connected.
|
||||||
|
private struct PendingSend {
|
||||||
|
var event: NostrEvent
|
||||||
|
var pendingRelays: Set<String>
|
||||||
|
}
|
||||||
|
private var messageQueue: [PendingSend] = []
|
||||||
private let messageQueueLock = NSLock()
|
private let messageQueueLock = NSLock()
|
||||||
|
private let encoder = JSONEncoder()
|
||||||
|
private let decoder = JSONDecoder()
|
||||||
|
private var networkService: NetworkActivationService { NetworkActivationService.shared }
|
||||||
|
private var shouldUseTor: Bool { networkService.userTorEnabled }
|
||||||
|
|
||||||
// Exponential backoff configuration
|
// Exponential backoff configuration
|
||||||
private let initialBackoffInterval: TimeInterval = 1.0 // Start with 1 second
|
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
|
||||||
private let maxBackoffInterval: TimeInterval = 300.0 // Max 5 minutes
|
private let maxBackoffInterval: TimeInterval = TransportConfig.nostrRelayMaxBackoffSeconds
|
||||||
private let backoffMultiplier: Double = 2.0 // Double each time
|
private let backoffMultiplier: Double = TransportConfig.nostrRelayBackoffMultiplier
|
||||||
private let maxReconnectAttempts = 10 // Stop after 10 attempts
|
private let maxReconnectAttempts = TransportConfig.nostrRelayMaxReconnectAttempts
|
||||||
|
|
||||||
// Reconnection timer
|
// Reconnection timer
|
||||||
private var reconnectionTimer: Timer?
|
private var reconnectionTimer: Timer?
|
||||||
|
// Bump generation to invalidate scheduled reconnects when we reset/disconnect
|
||||||
|
private var connectionGeneration: Int = 0
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Initialize with default relays
|
hasMutualFavorites = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
|
||||||
self.relays = Self.defaultRelays.map { Relay(url: $0) }
|
hasLocationPermission = LocationChannelManager.shared.permissionState == .authorized
|
||||||
|
applyDefaultRelayPolicy(force: true)
|
||||||
|
// Deterministic JSON shape for outbound requests
|
||||||
|
self.encoder.outputFormatting = .sortedKeys
|
||||||
|
FavoritesPersistenceService.shared.$mutualFavorites
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] favorites in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.hasMutualFavorites = !favorites.isEmpty
|
||||||
|
self.applyDefaultRelayPolicy()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
LocationChannelManager.shared.$permissionState
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] state in
|
||||||
|
guard let self = self else { return }
|
||||||
|
let authorized = (state == .authorized)
|
||||||
|
if authorized == self.hasLocationPermission { return }
|
||||||
|
self.hasLocationPermission = authorized
|
||||||
|
self.applyDefaultRelayPolicy()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
// Clean up timers and active connections
|
||||||
|
reconnectionTimer?.invalidate()
|
||||||
|
for (_, tracker) in eoseTrackers {
|
||||||
|
tracker.timer?.invalidate()
|
||||||
|
}
|
||||||
|
for (_, task) in connections {
|
||||||
|
task.cancel(with: .goingAway, reason: nil)
|
||||||
|
}
|
||||||
|
cancellables.removeAll()
|
||||||
|
SecureLogger.debug("NostrRelayManager deinitialized", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
/// Connect to all configured relays
|
/// Connect to all configured relays
|
||||||
func connect() {
|
func connect() {
|
||||||
for relay in relays {
|
// Global network policy gate
|
||||||
connectToRelay(relay.url)
|
guard networkService.activationAllowed else { return }
|
||||||
|
if shouldUseTor {
|
||||||
|
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
|
||||||
|
Task.detached {
|
||||||
|
let ready = await TorManager.shared.awaitReady()
|
||||||
|
await MainActor.run {
|
||||||
|
if !ready {
|
||||||
|
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
|
||||||
|
for relay in self.relays {
|
||||||
|
self.connectToRelay(relay.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (direct)", category: .session)
|
||||||
|
for relay in self.relays {
|
||||||
|
connectToRelay(relay.url)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Disconnect from all relays
|
/// Disconnect from all relays
|
||||||
func disconnect() {
|
func disconnect() {
|
||||||
|
connectionGeneration &+= 1
|
||||||
for (_, task) in connections {
|
for (_, task) in connections {
|
||||||
task.cancel(with: .goingAway, reason: nil)
|
task.cancel(with: .goingAway, reason: nil)
|
||||||
}
|
}
|
||||||
connections.removeAll()
|
connections.removeAll()
|
||||||
|
// Clear known subscriptions and any queued subs since connections are gone
|
||||||
|
subscriptions.removeAll()
|
||||||
|
pendingSubscriptions.removeAll()
|
||||||
updateConnectionStatus()
|
updateConnectionStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ensure connections exist to the given relay URLs (idempotent).
|
||||||
|
func ensureConnections(to relayUrls: [String]) {
|
||||||
|
// Global network policy gate
|
||||||
|
guard networkService.activationAllowed else { return }
|
||||||
|
let targets = allowedRelayList(from: relayUrls)
|
||||||
|
guard !targets.isEmpty else { return }
|
||||||
|
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||||
|
// Defer until Tor is fully ready; avoid queuing connection attempts early
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
let ready = await TorManager.shared.awaitReady()
|
||||||
|
await MainActor.run { if ready { self.ensureConnections(to: relayUrls) } }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var existing = Set(relays.map { $0.url })
|
||||||
|
for url in targets where !existing.contains(url) {
|
||||||
|
relays.append(Relay(url: url))
|
||||||
|
existing.insert(url)
|
||||||
|
}
|
||||||
|
for url in targets where connections[url] == nil {
|
||||||
|
connectToRelay(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Send an event to specified relays (or all if none specified)
|
/// Send an event to specified relays (or all if none specified)
|
||||||
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
|
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
|
||||||
let targetRelays = relayUrls ?? relays.map { $0.url }
|
// Global network policy gate
|
||||||
|
guard networkService.activationAllowed else { return }
|
||||||
// Add to queue for reliability
|
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||||
messageQueueLock.lock()
|
// Defer sends until Tor is ready to avoid premature queueing
|
||||||
messageQueue.append((event, targetRelays))
|
Task.detached { [weak self] in
|
||||||
messageQueueLock.unlock()
|
guard let self = self else { return }
|
||||||
|
let ready = await TorManager.shared.awaitReady()
|
||||||
// Attempt immediate send
|
await MainActor.run { if ready { self.sendEvent(event, to: relayUrls) } }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let requestedRelays = relayUrls ?? Self.defaultRelays
|
||||||
|
let targetRelays = allowedRelayList(from: requestedRelays)
|
||||||
|
guard !targetRelays.isEmpty else { return }
|
||||||
|
ensureConnections(to: targetRelays)
|
||||||
|
|
||||||
|
// Attempt immediate send to relays with active connections; queue the rest
|
||||||
|
var stillPending = Set<String>()
|
||||||
for relayUrl in targetRelays {
|
for relayUrl in targetRelays {
|
||||||
if let connection = connections[relayUrl] {
|
if let connection = connections[relayUrl] {
|
||||||
sendToRelay(event: event, connection: connection, relayUrl: relayUrl)
|
sendToRelay(event: event, connection: connection, relayUrl: relayUrl)
|
||||||
|
} else {
|
||||||
|
stillPending.insert(relayUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !stillPending.isEmpty {
|
||||||
|
messageQueueLock.lock()
|
||||||
|
messageQueue.append(PendingSend(event: event, pendingRelays: stillPending))
|
||||||
|
messageQueueLock.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to flush any queued messages for relays that are now connected.
|
||||||
|
private func flushMessageQueue(for relayUrl: String? = nil) {
|
||||||
|
messageQueueLock.lock()
|
||||||
|
defer { messageQueueLock.unlock() }
|
||||||
|
guard !messageQueue.isEmpty else { return }
|
||||||
|
if let target = relayUrl {
|
||||||
|
// Flush only for a specific relay
|
||||||
|
for i in (0..<messageQueue.count).reversed() {
|
||||||
|
var item = messageQueue[i]
|
||||||
|
if item.pendingRelays.contains(target), let conn = connections[target] {
|
||||||
|
sendToRelay(event: item.event, connection: conn, relayUrl: target)
|
||||||
|
item.pendingRelays.remove(target)
|
||||||
|
if item.pendingRelays.isEmpty {
|
||||||
|
messageQueue.remove(at: i)
|
||||||
|
} else {
|
||||||
|
messageQueue[i] = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Flush for any relays that now have connections
|
||||||
|
for i in (0..<messageQueue.count).reversed() {
|
||||||
|
var item = messageQueue[i]
|
||||||
|
for url in item.pendingRelays {
|
||||||
|
if let conn = connections[url] {
|
||||||
|
sendToRelay(event: item.event, connection: conn, relayUrl: url)
|
||||||
|
item.pendingRelays.remove(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if item.pendingRelays.isEmpty {
|
||||||
|
messageQueue.remove(at: i)
|
||||||
|
} else {
|
||||||
|
messageQueue[i] = item
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subscribe to events matching a filter
|
/// Subscribe to events matching a filter. If `relayUrls` provided, targets only those relays.
|
||||||
func subscribe(
|
func subscribe(
|
||||||
filter: NostrFilter,
|
filter: NostrFilter,
|
||||||
id: String = UUID().uuidString,
|
id: String = UUID().uuidString,
|
||||||
handler: @escaping (NostrEvent) -> Void
|
relayUrls: [String]? = nil,
|
||||||
|
handler: @escaping (NostrEvent) -> Void,
|
||||||
|
onEOSE: (() -> Void)? = nil
|
||||||
) {
|
) {
|
||||||
|
// Global network policy gate
|
||||||
|
guard networkService.activationAllowed else { return }
|
||||||
|
// Coalesce rapid duplicate subscribe requests only if a handler already exists
|
||||||
|
let now = Date()
|
||||||
|
if messageHandlers[id] != nil {
|
||||||
|
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < 1.0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
subscribeCoalesce[id] = now
|
||||||
|
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||||
|
// Defer subscription setup until Tor is ready; avoid queuing subs early
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
let ready = await TorManager.shared.awaitReady()
|
||||||
|
await MainActor.run {
|
||||||
|
if ready {
|
||||||
|
self.subscribe(filter: filter, id: id, relayUrls: relayUrls, handler: handler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
messageHandlers[id] = handler
|
messageHandlers[id] = handler
|
||||||
|
|
||||||
let req = NostrRequest.subscribe(id: id, filters: [filter])
|
let req = NostrRequest.subscribe(id: id, filters: [filter])
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let encoder = JSONEncoder()
|
|
||||||
encoder.outputFormatting = .sortedKeys // For consistent output
|
|
||||||
let message = try encoder.encode(req)
|
let message = try encoder.encode(req)
|
||||||
guard let messageString = String(data: message, encoding: .utf8) else {
|
guard let messageString = String(data: message, encoding: .utf8) else {
|
||||||
SecureLogger.log("❌ Failed to encode subscription request", category: SecureLogger.session, level: .error)
|
SecureLogger.error("❌ Failed to encode subscription request", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sending subscription to relays
|
// SecureLogger.debug("📋 Subscription filter JSON: \(messageString.prefix(200))...", category: .session)
|
||||||
// Filter JSON prepared
|
|
||||||
// Full filter JSON logged
|
|
||||||
|
|
||||||
// Send subscription to all connected relays
|
// Target specific relays if provided; else default. Filter permanently failed relays.
|
||||||
for (relayUrl, connection) in connections {
|
let baseUrls = relayUrls ?? Self.defaultRelays
|
||||||
connection.send(.string(messageString)) { error in
|
let candidateUrls = baseUrls.filter { !isPermanentlyFailed($0) }
|
||||||
if let error = error {
|
let urls = allowedRelayList(from: candidateUrls)
|
||||||
SecureLogger.log("❌ Failed to send subscription to \(relayUrl): \(error)",
|
// Always queue subscriptions; sending happens when a relay reports connected
|
||||||
category: SecureLogger.session, level: .error)
|
let existingSet = Set(relays.map { $0.url })
|
||||||
} else {
|
for url in urls where !existingSet.contains(url) {
|
||||||
// Subscription sent successfully
|
relays.append(Relay(url: url))
|
||||||
|
}
|
||||||
|
for url in candidateUrls {
|
||||||
|
var map = self.pendingSubscriptions[url] ?? [:]
|
||||||
|
map[id] = messageString
|
||||||
|
self.pendingSubscriptions[url] = map
|
||||||
|
}
|
||||||
|
// Initialize EOSE tracking if requested
|
||||||
|
if let onEOSE = onEOSE {
|
||||||
|
if urls.isEmpty {
|
||||||
|
onEOSE()
|
||||||
|
} else {
|
||||||
|
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)
|
||||||
|
// Fallback timeout to avoid hanging if a relay never sends EOSE
|
||||||
|
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
var subs = self.subscriptions[relayUrl] ?? Set<String>()
|
guard let self = self else { return }
|
||||||
subs.insert(id)
|
if let t = self.eoseTrackers[id] {
|
||||||
self.subscriptions[relayUrl] = subs
|
t.timer?.invalidate()
|
||||||
|
self.eoseTrackers.removeValue(forKey: id)
|
||||||
|
onEOSE()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
eoseTrackers[id] = tracker
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session)
|
||||||
if connections.isEmpty {
|
// Ensure we actually have sockets opening to these relays so queued REQs can flush
|
||||||
SecureLogger.log("⚠️ No relay connections available for subscription",
|
ensureConnections(to: urls)
|
||||||
category: SecureLogger.session, level: .warning)
|
// If some targets are already connected, flush immediately for them
|
||||||
|
for url in urls {
|
||||||
|
if let r = relays.first(where: { $0.url == url }), r.isConnected {
|
||||||
|
flushPendingSubscriptions(for: url)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.log("❌ Failed to encode subscription request: \(error)",
|
SecureLogger.error("❌ Failed to encode subscription request: \(error)", category: .session)
|
||||||
category: SecureLogger.session, level: .error)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func applyDefaultRelayPolicy(force: Bool = false) {
|
||||||
|
let shouldAllow = hasMutualFavorites || hasLocationPermission
|
||||||
|
if !force && shouldAllow == allowDefaultRelays { return }
|
||||||
|
allowDefaultRelays = shouldAllow
|
||||||
|
if shouldAllow {
|
||||||
|
var existing = Set(relays.map { $0.url })
|
||||||
|
for url in Self.defaultRelays where !existing.contains(url) {
|
||||||
|
relays.append(Relay(url: url))
|
||||||
|
existing.insert(url)
|
||||||
|
}
|
||||||
|
if networkService.activationAllowed {
|
||||||
|
ensureConnections(to: Self.defaultRelays)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for url in Self.defaultRelays {
|
||||||
|
if let connection = connections[url] {
|
||||||
|
connection.cancel(with: .goingAway, reason: nil)
|
||||||
|
}
|
||||||
|
connections.removeValue(forKey: url)
|
||||||
|
subscriptions.removeValue(forKey: url)
|
||||||
|
}
|
||||||
|
messageQueueLock.lock()
|
||||||
|
for index in (0..<messageQueue.count).reversed() {
|
||||||
|
var item = messageQueue[index]
|
||||||
|
item.pendingRelays.subtract(Self.defaultRelaySet)
|
||||||
|
if item.pendingRelays.isEmpty {
|
||||||
|
messageQueue.remove(at: index)
|
||||||
|
} else {
|
||||||
|
messageQueue[index] = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
messageQueueLock.unlock()
|
||||||
|
relays.removeAll { Self.defaultRelaySet.contains($0.url) }
|
||||||
|
updateConnectionStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func allowedRelayList(from urls: [String]) -> [String] {
|
||||||
|
var seen = Set<String>()
|
||||||
|
var result: [String] = []
|
||||||
|
for url in urls {
|
||||||
|
if !allowDefaultRelays && Self.defaultRelaySet.contains(url) { continue }
|
||||||
|
if seen.insert(url).inserted {
|
||||||
|
result.append(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
/// Unsubscribe from a subscription
|
/// Unsubscribe from a subscription
|
||||||
func unsubscribe(id: String) {
|
func unsubscribe(id: String) {
|
||||||
messageHandlers.removeValue(forKey: id)
|
messageHandlers.removeValue(forKey: id)
|
||||||
|
// Allow immediate re-subscription by clearing coalescer timestamp
|
||||||
|
subscribeCoalesce.removeValue(forKey: id)
|
||||||
|
|
||||||
let req = NostrRequest.close(id: id)
|
let req = NostrRequest.close(id: id)
|
||||||
let message = try? JSONEncoder().encode(req)
|
let message = try? encoder.encode(req)
|
||||||
|
|
||||||
guard let messageData = message,
|
guard let messageData = message,
|
||||||
let messageString = String(data: messageData, encoding: .utf8) else { return }
|
let messageString = String(data: messageData, encoding: .utf8) else { return }
|
||||||
@@ -163,14 +428,42 @@ class NostrRelayManager: ObservableObject {
|
|||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
|
|
||||||
private func connectToRelay(_ urlString: String) {
|
private func connectToRelay(_ urlString: String) {
|
||||||
|
// Global network policy gate
|
||||||
|
guard networkService.activationAllowed else { return }
|
||||||
guard let url = URL(string: urlString) else {
|
guard let url = URL(string: urlString) else {
|
||||||
SecureLogger.log("Invalid relay URL: \(urlString)", category: SecureLogger.session, level: .warning)
|
SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Avoid initiating connections while app is backgrounded; we'll reconnect on foreground
|
||||||
|
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isForeground() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Attempting to connect to Nostr relay
|
// Skip if we already have a connection object
|
||||||
|
if connections[urlString] != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isPermanentlyFailed(urlString) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let session = URLSession(configuration: .default)
|
// Attempting to connect to Nostr relay via the proxied session
|
||||||
|
|
||||||
|
// If Tor is enforced but not ready, delay connection until it is.
|
||||||
|
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
let ready = await TorManager.shared.awaitReady()
|
||||||
|
await MainActor.run {
|
||||||
|
if ready { self.connectToRelay(urlString) }
|
||||||
|
else { SecureLogger.error("❌ Tor not ready; skipping connection to \(urlString)", category: .session) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = TorURLSession.shared.session
|
||||||
let task = session.webSocketTask(with: url)
|
let task = session.webSocketTask(with: url)
|
||||||
|
|
||||||
connections[urlString] = task
|
connections[urlString] = task
|
||||||
@@ -183,13 +476,12 @@ class NostrRelayManager: ObservableObject {
|
|||||||
task.sendPing { [weak self] error in
|
task.sendPing { [weak self] error in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
if error == nil {
|
if error == nil {
|
||||||
// Successfully connected to Nostr relay
|
SecureLogger.debug("✅ Connected to Nostr relay: \(urlString)", category: .session)
|
||||||
self?.updateRelayStatus(urlString, isConnected: true)
|
self?.updateRelayStatus(urlString, isConnected: true)
|
||||||
SecureLogger.log("Successfully connected to Nostr relay \(urlString)",
|
// Flush any pending subscriptions for this relay
|
||||||
category: SecureLogger.session, level: .info)
|
self?.flushPendingSubscriptions(for: urlString)
|
||||||
} else {
|
} else {
|
||||||
SecureLogger.log("Failed to connect to Nostr relay \(urlString): \(error?.localizedDescription ?? "Unknown error")",
|
SecureLogger.error("❌ Failed to connect to Nostr relay \(urlString): \(error?.localizedDescription ?? "Unknown error")", category: .session)
|
||||||
category: SecureLogger.session, level: .error)
|
|
||||||
self?.updateRelayStatus(urlString, isConnected: false, error: error)
|
self?.updateRelayStatus(urlString, isConnected: false, error: error)
|
||||||
// Trigger disconnection handler for proper backoff
|
// Trigger disconnection handler for proper backoff
|
||||||
self?.handleDisconnection(relayUrl: urlString, error: error ?? NSError(domain: "NostrRelay", code: -1, userInfo: nil))
|
self?.handleDisconnection(relayUrl: urlString, error: error ?? NSError(domain: "NostrRelay", code: -1, userInfo: nil))
|
||||||
@@ -197,6 +489,27 @@ class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send any queued subscriptions for a relay that just connected.
|
||||||
|
private func flushPendingSubscriptions(for relayUrl: String) {
|
||||||
|
guard let map = pendingSubscriptions[relayUrl], !map.isEmpty else { return }
|
||||||
|
guard let connection = connections[relayUrl] else { return }
|
||||||
|
for (id, messageString) in map {
|
||||||
|
if self.subscriptions[relayUrl]?.contains(id) == true { continue }
|
||||||
|
connection.send(.string(messageString)) { error in
|
||||||
|
if let error = error {
|
||||||
|
SecureLogger.error("❌ Failed to send pending subscription to \(relayUrl): \(error)", category: .session)
|
||||||
|
} else {
|
||||||
|
Task { @MainActor in
|
||||||
|
var subs = self.subscriptions[relayUrl] ?? Set<String>()
|
||||||
|
subs.insert(id)
|
||||||
|
self.subscriptions[relayUrl] = subs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pendingSubscriptions[relayUrl] = nil
|
||||||
|
}
|
||||||
|
|
||||||
private func receiveMessage(from task: URLSessionWebSocketTask, relayUrl: String) {
|
private func receiveMessage(from task: URLSessionWebSocketTask, relayUrl: String) {
|
||||||
task.receive { [weak self] result in
|
task.receive { [weak self] result in
|
||||||
@@ -204,19 +517,12 @@ class NostrRelayManager: ObservableObject {
|
|||||||
|
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let message):
|
case .success(let message):
|
||||||
switch message {
|
// Parse off-main to reduce UI jank, then hop back for state updates
|
||||||
case .string(let text):
|
Task.detached(priority: .utility) {
|
||||||
Task { @MainActor in
|
guard let parsed = ParsedInbound(message) else { return }
|
||||||
self.handleMessage(text, from: relayUrl)
|
await MainActor.run {
|
||||||
|
NostrRelayManager.shared.handleParsedMessage(parsed, from: relayUrl)
|
||||||
}
|
}
|
||||||
case .data(let data):
|
|
||||||
if let text = String(data: data, encoding: .utf8) {
|
|
||||||
Task { @MainActor in
|
|
||||||
self.handleMessage(text, from: relayUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@unknown default:
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Continue receiving
|
// Continue receiving
|
||||||
@@ -232,73 +538,50 @@ class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleMessage(_ message: String, from relayUrl: String) {
|
// Parsed inbound message type (off-main)
|
||||||
guard let data = message.data(using: .utf8) else { return }
|
// Note: declared at file scope below to avoid MainActor isolation inside this class
|
||||||
|
// and keep parsing off the main actor.
|
||||||
do {
|
|
||||||
// Try to decode as an array first
|
// Handle parsed message on MainActor (state updates and handlers)
|
||||||
if let array = try JSONSerialization.jsonObject(with: data) as? [Any],
|
private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) {
|
||||||
array.count >= 2,
|
switch parsed {
|
||||||
let type = array[0] as? String {
|
case .event(let subId, let event):
|
||||||
|
if event.kind != 1059 {
|
||||||
// Received message from relay
|
SecureLogger.debug("📥 Event kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
|
||||||
|
}
|
||||||
switch type {
|
if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) {
|
||||||
case "EVENT":
|
self.relays[index].messagesReceived += 1
|
||||||
if array.count >= 3,
|
}
|
||||||
let subId = array[1] as? String,
|
if let handler = self.messageHandlers[subId] {
|
||||||
let eventDict = array[2] as? [String: Any] {
|
handler(event)
|
||||||
|
} else {
|
||||||
let event = try NostrEvent(from: eventDict)
|
SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session)
|
||||||
|
}
|
||||||
// Processing event
|
case .eose(let subId):
|
||||||
|
if var tracker = eoseTrackers[subId] {
|
||||||
DispatchQueue.main.async {
|
tracker.pendingRelays.remove(relayUrl)
|
||||||
// Update relay stats
|
if tracker.pendingRelays.isEmpty {
|
||||||
if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) {
|
tracker.timer?.invalidate()
|
||||||
self.relays[index].messagesReceived += 1
|
eoseTrackers.removeValue(forKey: subId)
|
||||||
}
|
tracker.callback()
|
||||||
|
} else {
|
||||||
// Call handler
|
eoseTrackers[subId] = tracker
|
||||||
if let handler = self.messageHandlers[subId] {
|
|
||||||
handler(event)
|
|
||||||
} else {
|
|
||||||
SecureLogger.log("⚠️ No handler for subscription \(subId)",
|
|
||||||
category: SecureLogger.session, level: .warning)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case "EOSE":
|
|
||||||
if array.count >= 2,
|
|
||||||
let _ = array[1] as? String {
|
|
||||||
// End of stored events
|
|
||||||
}
|
|
||||||
|
|
||||||
case "OK":
|
|
||||||
if array.count >= 3,
|
|
||||||
let eventId = array[1] as? String,
|
|
||||||
let success = array[2] as? Bool {
|
|
||||||
let reason = array.count >= 4 ? (array[3] as? String ?? "no reason given") : "no reason given"
|
|
||||||
if !success {
|
|
||||||
SecureLogger.log("📮 Event \(eventId) rejected by relay: \(reason)",
|
|
||||||
category: SecureLogger.session, level: .error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case "NOTICE":
|
|
||||||
if array.count >= 2,
|
|
||||||
let notice = array[1] as? String {
|
|
||||||
SecureLogger.log("📢 Relay notice: \(notice)",
|
|
||||||
category: SecureLogger.session, level: .info)
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
break // Unknown message type
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
case .ok(let eventId, let success, let reason):
|
||||||
SecureLogger.log("Failed to parse Nostr message: \(error)", category: SecureLogger.session, level: .error)
|
if success {
|
||||||
|
_ = Self.pendingGiftWrapIDs.remove(eventId)
|
||||||
|
SecureLogger.debug("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)", category: .session)
|
||||||
|
} else {
|
||||||
|
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
|
||||||
|
if isGiftWrap {
|
||||||
|
SecureLogger.warning("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", category: .session)
|
||||||
|
} else {
|
||||||
|
SecureLogger.error("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .notice:
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,20 +589,17 @@ class NostrRelayManager: ObservableObject {
|
|||||||
let req = NostrRequest.event(event)
|
let req = NostrRequest.event(event)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let encoder = JSONEncoder()
|
|
||||||
encoder.outputFormatting = .sortedKeys
|
|
||||||
let data = try encoder.encode(req)
|
let data = try encoder.encode(req)
|
||||||
let message = String(data: data, encoding: .utf8) ?? ""
|
let message = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
|
||||||
// Sending event to relay
|
SecureLogger.debug("📤 Send kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
|
||||||
// Event JSON prepared
|
|
||||||
|
|
||||||
connection.send(.string(message)) { [weak self] error in
|
connection.send(.string(message)) { [weak self] error in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
if let error = error {
|
if let error = error {
|
||||||
SecureLogger.log("❌ Failed to send event to \(relayUrl): \(error)",
|
SecureLogger.error("❌ Failed to send event to \(relayUrl): \(error)", category: .session)
|
||||||
category: SecureLogger.session, level: .error)
|
|
||||||
} else {
|
} else {
|
||||||
|
// SecureLogger.debug("✅ Event sent to relay: \(relayUrl)", category: .session)
|
||||||
// Update relay stats
|
// Update relay stats
|
||||||
if let index = self?.relays.firstIndex(where: { $0.url == relayUrl }) {
|
if let index = self?.relays.firstIndex(where: { $0.url == relayUrl }) {
|
||||||
self?.relays[index].messagesSent += 1
|
self?.relays[index].messagesSent += 1
|
||||||
@@ -328,7 +608,7 @@ class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.log("Failed to encode event: \(error)", category: SecureLogger.session, level: .error)
|
SecureLogger.error("Failed to encode event: \(error)", category: .session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,6 +625,10 @@ class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateConnectionStatus()
|
updateConnectionStatus()
|
||||||
|
// If we just connected to this relay, flush any queued sends targeting it
|
||||||
|
if isConnected {
|
||||||
|
flushMessageQueue(for: url)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateConnectionStatus() {
|
private func updateConnectionStatus() {
|
||||||
@@ -352,22 +636,32 @@ class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleDisconnection(relayUrl: String, error: Error) {
|
private func handleDisconnection(relayUrl: String, error: Error) {
|
||||||
|
// If networking is disallowed, do not schedule reconnection
|
||||||
|
if !networkService.activationAllowed {
|
||||||
|
connections.removeValue(forKey: relayUrl)
|
||||||
|
subscriptions.removeValue(forKey: relayUrl)
|
||||||
|
updateRelayStatus(relayUrl, isConnected: false, error: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
connections.removeValue(forKey: relayUrl)
|
connections.removeValue(forKey: relayUrl)
|
||||||
subscriptions.removeValue(forKey: relayUrl)
|
subscriptions.removeValue(forKey: relayUrl)
|
||||||
updateRelayStatus(relayUrl, isConnected: false, error: error)
|
updateRelayStatus(relayUrl, isConnected: false, error: error)
|
||||||
|
|
||||||
// Check if this is a DNS error
|
// Check if this is a DNS or handshake error; treat as permanent
|
||||||
let errorDescription = error.localizedDescription.lowercased()
|
let errorDescription = error.localizedDescription.lowercased()
|
||||||
|
let ns = error as NSError
|
||||||
if errorDescription.contains("hostname could not be found") ||
|
if errorDescription.contains("hostname could not be found") ||
|
||||||
errorDescription.contains("dns") {
|
errorDescription.contains("dns") ||
|
||||||
// Only log once for DNS failures
|
(ns.domain == NSURLErrorDomain && ns.code == NSURLErrorBadServerResponse) {
|
||||||
if relays.first(where: { $0.url == relayUrl })?.lastError == nil {
|
if relays.first(where: { $0.url == relayUrl })?.lastError == nil {
|
||||||
SecureLogger.log("Nostr relay DNS failure for \(relayUrl) - not retrying", category: SecureLogger.session, level: .warning)
|
SecureLogger.warning("Nostr relay permanent failure for \(relayUrl) - not retrying (code=\(ns.code))", category: .session)
|
||||||
}
|
}
|
||||||
// Mark relay as permanently failed
|
|
||||||
if let index = relays.firstIndex(where: { $0.url == relayUrl }) {
|
if let index = relays.firstIndex(where: { $0.url == relayUrl }) {
|
||||||
relays[index].lastError = error
|
relays[index].lastError = error
|
||||||
|
relays[index].reconnectAttempts = maxReconnectAttempts
|
||||||
|
relays[index].nextReconnectTime = nil
|
||||||
}
|
}
|
||||||
|
pendingSubscriptions[relayUrl] = nil
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,8 +672,7 @@ class NostrRelayManager: ObservableObject {
|
|||||||
|
|
||||||
// Stop attempting after max attempts
|
// Stop attempting after max attempts
|
||||||
if relays[index].reconnectAttempts >= maxReconnectAttempts {
|
if relays[index].reconnectAttempts >= maxReconnectAttempts {
|
||||||
SecureLogger.log("Max reconnection attempts (\(maxReconnectAttempts)) reached for \(relayUrl)",
|
SecureLogger.warning("Max reconnection attempts (\(maxReconnectAttempts)) reached for \(relayUrl)", category: .session)
|
||||||
category: SecureLogger.session, level: .warning)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,13 +685,13 @@ class NostrRelayManager: ObservableObject {
|
|||||||
let nextReconnectTime = Date().addingTimeInterval(backoffInterval)
|
let nextReconnectTime = Date().addingTimeInterval(backoffInterval)
|
||||||
relays[index].nextReconnectTime = nextReconnectTime
|
relays[index].nextReconnectTime = nextReconnectTime
|
||||||
|
|
||||||
SecureLogger.log("Scheduling reconnection to \(relayUrl) in \(Int(backoffInterval))s (attempt \(relays[index].reconnectAttempts))",
|
|
||||||
category: SecureLogger.session, level: .info)
|
|
||||||
|
|
||||||
// Schedule reconnection with exponential backoff
|
// Schedule reconnection with exponential backoff
|
||||||
|
let gen = connectionGeneration
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + backoffInterval) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + backoffInterval) { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
// Ignore stale scheduled reconnects from a previous generation
|
||||||
|
guard gen == self.connectionGeneration else { return }
|
||||||
// Check if we should still reconnect (relay might have been removed)
|
// Check if we should still reconnect (relay might have been removed)
|
||||||
if self.relays.contains(where: { $0.url == relayUrl }) {
|
if self.relays.contains(where: { $0.url == relayUrl }) {
|
||||||
self.connectToRelay(relayUrl)
|
self.connectToRelay(relayUrl)
|
||||||
@@ -439,6 +732,8 @@ class NostrRelayManager: ObservableObject {
|
|||||||
/// Reset all relay connections
|
/// Reset all relay connections
|
||||||
func resetAllConnections() {
|
func resetAllConnections() {
|
||||||
disconnect()
|
disconnect()
|
||||||
|
// New generation begins now
|
||||||
|
connectionGeneration &+= 1
|
||||||
|
|
||||||
// Reset all relay states
|
// Reset all relay states
|
||||||
for index in relays.indices {
|
for index in relays.indices {
|
||||||
@@ -450,6 +745,81 @@ class NostrRelayManager: ObservableObject {
|
|||||||
// Reconnect
|
// Reconnect
|
||||||
connect()
|
connect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Failure classification
|
||||||
|
private func isPermanentlyFailed(_ url: String) -> Bool {
|
||||||
|
guard let r = relays.first(where: { $0.url == url }) else { return false }
|
||||||
|
if r.reconnectAttempts >= maxReconnectAttempts { return true }
|
||||||
|
if let ns = r.lastError as NSError?, ns.domain == NSURLErrorDomain {
|
||||||
|
if ns.code == NSURLErrorBadServerResponse || ns.code == NSURLErrorCannotFindHost {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Off-main inbound parsing helpers (file scope, non-isolated)
|
||||||
|
|
||||||
|
private enum ParsedInbound {
|
||||||
|
case event(subId: String, event: NostrEvent)
|
||||||
|
case ok(eventId: String, success: Bool, reason: String)
|
||||||
|
case eose(subscriptionId: String)
|
||||||
|
case notice(String)
|
||||||
|
|
||||||
|
init?(_ message: URLSessionWebSocketTask.Message) {
|
||||||
|
guard let data = message.data,
|
||||||
|
let array = try? JSONSerialization.jsonObject(with: data) as? [Any],
|
||||||
|
array.count >= 2,
|
||||||
|
let type = array[0] as? String else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch type {
|
||||||
|
case "EVENT":
|
||||||
|
if array.count >= 3,
|
||||||
|
let subId = array[1] as? String,
|
||||||
|
let eventDict = array[2] as? [String: Any],
|
||||||
|
let event = try? NostrEvent(from: eventDict) {
|
||||||
|
self = .event(subId: subId, event: event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case "EOSE":
|
||||||
|
if let subId = array[1] as? String {
|
||||||
|
self = .eose(subscriptionId: subId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case "OK":
|
||||||
|
if array.count >= 3,
|
||||||
|
let eventId = array[1] as? String,
|
||||||
|
let success = array[2] as? Bool {
|
||||||
|
let reason = array.count >= 4 ? (array[3] as? String ?? "no reason given") : "no reason given"
|
||||||
|
self = .ok(eventId: eventId, success: success, reason: reason)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case "NOTICE":
|
||||||
|
if array.count >= 2, let msg = array[1] as? String {
|
||||||
|
self = .notice(msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension URLSessionWebSocketTask.Message {
|
||||||
|
var data: Data? {
|
||||||
|
switch self {
|
||||||
|
case .string(let text): text.data(using: .utf8)
|
||||||
|
case .data(let data): data
|
||||||
|
@unknown default: nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Nostr Protocol Types
|
// MARK: - Nostr Protocol Types
|
||||||
@@ -526,7 +896,27 @@ struct NostrFilter: Encodable {
|
|||||||
filter.kinds = [1059] // Gift wrap kind
|
filter.kinds = [1059] // Gift wrap kind
|
||||||
filter.since = since?.timeIntervalSince1970.toInt()
|
filter.since = since?.timeIntervalSince1970.toInt()
|
||||||
filter.tagFilters = ["p": [pubkey]]
|
filter.tagFilters = ["p": [pubkey]]
|
||||||
filter.limit = 100 // Add a reasonable limit
|
filter.limit = TransportConfig.nostrRelayDefaultFetchLimit // reasonable limit
|
||||||
|
return filter
|
||||||
|
}
|
||||||
|
|
||||||
|
// For location channels: geohash-scoped ephemeral events (kind 20000)
|
||||||
|
static func geohashEphemeral(_ geohash: String, since: Date? = nil, limit: Int = 200) -> NostrFilter {
|
||||||
|
var filter = NostrFilter()
|
||||||
|
filter.kinds = [20000]
|
||||||
|
filter.since = since?.timeIntervalSince1970.toInt()
|
||||||
|
filter.tagFilters = ["g": [geohash]]
|
||||||
|
filter.limit = limit
|
||||||
|
return filter
|
||||||
|
}
|
||||||
|
|
||||||
|
// For location notes: persistent text notes (kind 1) tagged with geohash
|
||||||
|
static func geohashNotes(_ geohash: String, since: Date? = nil, limit: Int = 200) -> NostrFilter {
|
||||||
|
var filter = NostrFilter()
|
||||||
|
filter.kinds = [1]
|
||||||
|
filter.since = since?.timeIntervalSince1970.toInt()
|
||||||
|
filter.tagFilters = ["g": [geohash]]
|
||||||
|
filter.limit = limit
|
||||||
return filter
|
return filter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
/// Minimal XChaCha20-Poly1305 compatibility wrapper using CryptoKit's ChaChaPoly.
|
||||||
|
/// Implements HChaCha20 to derive a subkey and reduces the 24-byte nonce to a 12-byte nonce
|
||||||
|
/// as per XChaCha20 construction.
|
||||||
|
enum XChaCha20Poly1305Compat {
|
||||||
|
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")
|
||||||
|
|
||||||
|
let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16))
|
||||||
|
let nonce12 = derive12ByteNonce(from24: nonce24)
|
||||||
|
let chachaKey = SymmetricKey(data: subkey)
|
||||||
|
let nonce = try ChaChaPoly.Nonce(data: nonce12)
|
||||||
|
let sealed = try ChaChaPoly.seal(plaintext, using: chachaKey, nonce: nonce, authenticating: aad ?? Data())
|
||||||
|
return SealBox(ciphertext: sealed.ciphertext, tag: sealed.tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
let subkey = hchacha20(key: key, nonce16: 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)
|
||||||
|
return try ChaChaPoly.open(box, using: chachaKey, authenticating: aad ?? Data())
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internals
|
||||||
|
|
||||||
|
private static func derive12ByteNonce(from24 nonce24: Data) -> Data {
|
||||||
|
// XChaCha20-Poly1305: 12-byte nonce = 4 zero bytes || last 8 bytes of the 24-byte nonce
|
||||||
|
var out = Data(count: 12)
|
||||||
|
out.replaceSubrange(0..<4, with: [0, 0, 0, 0])
|
||||||
|
out.replaceSubrange(4..<12, with: nonce24.suffix(8))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func hchacha20(key: Data, nonce16: Data) -> Data {
|
||||||
|
// HChaCha20 based on the original ChaCha20 core with a 16-byte nonce.
|
||||||
|
precondition(key.count == 32)
|
||||||
|
precondition(nonce16.count == 16)
|
||||||
|
|
||||||
|
// Constants "expand 32-byte k"
|
||||||
|
var state: [UInt32] = [
|
||||||
|
0x61707865, 0x3320646e, 0x79622d32, 0x6b206574,
|
||||||
|
// key (8 words)
|
||||||
|
key.loadLEWord(0), key.loadLEWord(4), key.loadLEWord(8), key.loadLEWord(12),
|
||||||
|
key.loadLEWord(16), key.loadLEWord(20), key.loadLEWord(24), key.loadLEWord(28),
|
||||||
|
// nonce (4 words)
|
||||||
|
nonce16.loadLEWord(0), nonce16.loadLEWord(4), nonce16.loadLEWord(8), nonce16.loadLEWord(12)
|
||||||
|
]
|
||||||
|
|
||||||
|
// 20 rounds (10 double rounds)
|
||||||
|
for _ in 0..<10 {
|
||||||
|
// Column rounds
|
||||||
|
quarterRound(&state, 0, 4, 8, 12)
|
||||||
|
quarterRound(&state, 1, 5, 9, 13)
|
||||||
|
quarterRound(&state, 2, 6, 10, 14)
|
||||||
|
quarterRound(&state, 3, 7, 11, 15)
|
||||||
|
// Diagonal rounds
|
||||||
|
quarterRound(&state, 0, 5, 10, 15)
|
||||||
|
quarterRound(&state, 1, 6, 11, 12)
|
||||||
|
quarterRound(&state, 2, 7, 8, 13)
|
||||||
|
quarterRound(&state, 3, 4, 9, 14)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output subkey: state[0..3] and state[12..15]
|
||||||
|
var out = Data(count: 32)
|
||||||
|
out.storeLEWord(state[0], at: 0)
|
||||||
|
out.storeLEWord(state[1], at: 4)
|
||||||
|
out.storeLEWord(state[2], at: 8)
|
||||||
|
out.storeLEWord(state[3], at: 12)
|
||||||
|
out.storeLEWord(state[12], at: 16)
|
||||||
|
out.storeLEWord(state[13], at: 20)
|
||||||
|
out.storeLEWord(state[14], at: 24)
|
||||||
|
out.storeLEWord(state[15], at: 28)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func quarterRound(_ s: inout [UInt32], _ a: Int, _ b: Int, _ c: Int, _ d: Int) {
|
||||||
|
s[a] = s[a] &+ s[b]; s[d] ^= s[a]; s[d] = (s[d] << 16) | (s[d] >> 16)
|
||||||
|
s[c] = s[c] &+ s[d]; s[b] ^= s[c]; s[b] = (s[b] << 12) | (s[b] >> 20)
|
||||||
|
s[a] = s[a] &+ s[b]; s[d] ^= s[a]; s[d] = (s[d] << 8) | (s[d] >> 24)
|
||||||
|
s[c] = s[c] &+ s[d]; s[b] ^= s[c]; s[b] = (s[b] << 7) | (s[b] >> 25)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Data {
|
||||||
|
func loadLEWord(_ offset: Int) -> UInt32 {
|
||||||
|
let range = offset..<(offset+4)
|
||||||
|
let bytes = self[range]
|
||||||
|
return bytes.withUnsafeBytes { ptr -> UInt32 in
|
||||||
|
let b = ptr.bindMemory(to: UInt8.self)
|
||||||
|
return UInt32(b[0]) | (UInt32(b[1]) << 8) | (UInt32(b[2]) << 16) | (UInt32(b[3]) << 24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func storeLEWord(_ value: UInt32, at offset: Int) {
|
||||||
|
let bytes: [UInt8] = [
|
||||||
|
UInt8(value & 0xff),
|
||||||
|
UInt8((value >> 8) & 0xff),
|
||||||
|
UInt8((value >> 16) & 0xff),
|
||||||
|
UInt8((value >> 24) & 0xff)
|
||||||
|
]
|
||||||
|
replaceSubrange(offset..<(offset+4), with: bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -40,23 +40,23 @@ extension Data {
|
|||||||
extension Data {
|
extension Data {
|
||||||
// MARK: Writing
|
// MARK: Writing
|
||||||
|
|
||||||
mutating func appendUInt8(_ value: UInt8) {
|
@inlinable mutating func appendUInt8(_ value: UInt8) {
|
||||||
self.append(value)
|
self.append(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func appendUInt16(_ value: UInt16) {
|
@inlinable mutating func appendUInt16(_ value: UInt16) {
|
||||||
self.append(UInt8((value >> 8) & 0xFF))
|
self.append(UInt8((value >> 8) & 0xFF))
|
||||||
self.append(UInt8(value & 0xFF))
|
self.append(UInt8(value & 0xFF))
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func appendUInt32(_ value: UInt32) {
|
@inlinable mutating func appendUInt32(_ value: UInt32) {
|
||||||
self.append(UInt8((value >> 24) & 0xFF))
|
self.append(UInt8((value >> 24) & 0xFF))
|
||||||
self.append(UInt8((value >> 16) & 0xFF))
|
self.append(UInt8((value >> 16) & 0xFF))
|
||||||
self.append(UInt8((value >> 8) & 0xFF))
|
self.append(UInt8((value >> 8) & 0xFF))
|
||||||
self.append(UInt8(value & 0xFF))
|
self.append(UInt8(value & 0xFF))
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func appendUInt64(_ value: UInt64) {
|
@inlinable mutating func appendUInt64(_ value: UInt64) {
|
||||||
for i in (0..<8).reversed() {
|
for i in (0..<8).reversed() {
|
||||||
self.append(UInt8((value >> (i * 8)) & 0xFF))
|
self.append(UInt8((value >> (i * 8)) & 0xFF))
|
||||||
}
|
}
|
||||||
@@ -113,21 +113,21 @@ extension Data {
|
|||||||
|
|
||||||
// MARK: Reading
|
// MARK: Reading
|
||||||
|
|
||||||
func readUInt8(at offset: inout Int) -> UInt8? {
|
@inlinable func readUInt8(at offset: inout Int) -> UInt8? {
|
||||||
guard offset >= 0 && offset < self.count else { return nil }
|
guard offset >= 0 && offset < self.count else { return nil }
|
||||||
let value = self[offset]
|
let value = self[offset]
|
||||||
offset += 1
|
offset += 1
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
func readUInt16(at offset: inout Int) -> UInt16? {
|
@inlinable func readUInt16(at offset: inout Int) -> UInt16? {
|
||||||
guard offset + 2 <= self.count else { return nil }
|
guard offset + 2 <= self.count else { return nil }
|
||||||
let value = UInt16(self[offset]) << 8 | UInt16(self[offset + 1])
|
let value = UInt16(self[offset]) << 8 | UInt16(self[offset + 1])
|
||||||
offset += 2
|
offset += 2
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
func readUInt32(at offset: inout Int) -> UInt32? {
|
@inlinable func readUInt32(at offset: inout Int) -> UInt32? {
|
||||||
guard offset + 4 <= self.count else { return nil }
|
guard offset + 4 <= self.count else { return nil }
|
||||||
let value = UInt32(self[offset]) << 24 |
|
let value = UInt32(self[offset]) << 24 |
|
||||||
UInt32(self[offset + 1]) << 16 |
|
UInt32(self[offset + 1]) << 16 |
|
||||||
@@ -137,7 +137,7 @@ extension Data {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
func readUInt64(at offset: inout Int) -> UInt64? {
|
@inlinable func readUInt64(at offset: inout Int) -> UInt64? {
|
||||||
guard offset + 8 <= self.count else { return nil }
|
guard offset + 8 <= self.count else { return nil }
|
||||||
var value: UInt64 = 0
|
var value: UInt64 = 0
|
||||||
for i in 0..<8 {
|
for i in 0..<8 {
|
||||||
@@ -197,7 +197,7 @@ extension Data {
|
|||||||
offset += 16
|
offset += 16
|
||||||
|
|
||||||
// Convert 16 bytes to UUID string format
|
// Convert 16 bytes to UUID string format
|
||||||
let uuid = uuidData.map { String(format: "%02x", $0) }.joined()
|
let uuid = uuidData.hexEncodedString()
|
||||||
|
|
||||||
// Insert hyphens at proper positions: 8-4-4-4-12
|
// Insert hyphens at proper positions: 8-4-4-4-12
|
||||||
var result = ""
|
var result = ""
|
||||||
@@ -220,21 +220,3 @@ extension Data {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Binary Message Protocol
|
|
||||||
|
|
||||||
protocol BinaryEncodable {
|
|
||||||
func toBinaryData() -> Data
|
|
||||||
static func fromBinaryData(_ data: Data) -> Self?
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Message Type Registry
|
|
||||||
|
|
||||||
enum BinaryMessageType: UInt8 {
|
|
||||||
case deliveryAck = 0x01
|
|
||||||
case readReceipt = 0x02
|
|
||||||
case versionHello = 0x07
|
|
||||||
case versionAck = 0x08
|
|
||||||
case noiseIdentityAnnouncement = 0x09
|
|
||||||
case noiseMessage = 0x0A
|
|
||||||
}
|
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
///
|
///
|
||||||
/// ## Compression Strategy
|
/// ## Compression Strategy
|
||||||
/// - Automatic compression for payloads > 256 bytes
|
/// - Automatic compression for payloads > 256 bytes
|
||||||
/// - LZ4 algorithm for speed over ratio
|
/// - zlib compression for broad compatibility on Apple platforms
|
||||||
/// - Original size stored for decompression
|
/// - Original size stored for decompression
|
||||||
/// - Flag bit indicates compressed payload
|
/// - Flag bit indicates compressed payload
|
||||||
///
|
///
|
||||||
@@ -117,7 +117,7 @@ struct BinaryProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Encode BitchatPacket to binary format
|
// Encode BitchatPacket to binary format
|
||||||
static func encode(_ packet: BitchatPacket) -> Data? {
|
static func encode(_ packet: BitchatPacket, padding: Bool = true) -> Data? {
|
||||||
var data = Data()
|
var data = Data()
|
||||||
|
|
||||||
|
|
||||||
@@ -139,6 +139,11 @@ struct BinaryProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
|
// Reserve capacity to reduce reallocations. Estimate base size conservatively.
|
||||||
|
// header(13) + sender(8) + opt recipient(8) + opt originalSize(2) + payload + opt signature(64) + up to 255 pad
|
||||||
|
let estimatedPayload = payload.count + (isCompressed ? 2 : 0)
|
||||||
|
let estimated = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + estimatedPayload + (packet.signature == nil ? 0 : signatureSize) + 255
|
||||||
|
data.reserveCapacity(estimated)
|
||||||
data.append(packet.version)
|
data.append(packet.version)
|
||||||
data.append(packet.type)
|
data.append(packet.type)
|
||||||
data.append(packet.ttl)
|
data.append(packet.ttl)
|
||||||
@@ -200,351 +205,126 @@ struct BinaryProtocol {
|
|||||||
|
|
||||||
|
|
||||||
// Apply padding to standard block sizes for traffic analysis resistance
|
// Apply padding to standard block sizes for traffic analysis resistance
|
||||||
let optimalSize = MessagePadding.optimalBlockSize(for: data.count)
|
if padding {
|
||||||
let paddedData = MessagePadding.pad(data, toSize: optimalSize)
|
let optimalSize = MessagePadding.optimalBlockSize(for: data.count)
|
||||||
|
let paddedData = MessagePadding.pad(data, toSize: optimalSize)
|
||||||
|
return paddedData
|
||||||
return paddedData
|
} else {
|
||||||
|
// Caller explicitly requested no padding (e.g., BLE write path)
|
||||||
|
return data
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decode binary data to BitchatPacket
|
// Decode binary data to BitchatPacket
|
||||||
static func decode(_ data: Data) -> BitchatPacket? {
|
static func decode(_ data: Data) -> BitchatPacket? {
|
||||||
// Remove padding first
|
// Try decode as-is first (robust when padding wasn't applied)
|
||||||
let unpaddedData = MessagePadding.unpad(data)
|
if let pkt = decodeCore(data) { return pkt }
|
||||||
|
// If that fails, try after removing padding
|
||||||
|
let unpadded = MessagePadding.unpad(data)
|
||||||
guard unpaddedData.count >= headerSize + senderIDSize else {
|
if unpadded as NSData === data as NSData { return nil }
|
||||||
return nil
|
return decodeCore(unpadded)
|
||||||
}
|
|
||||||
|
|
||||||
var offset = 0
|
|
||||||
|
|
||||||
// Header
|
|
||||||
let version = unpaddedData[offset]; offset += 1
|
|
||||||
// Check if version is supported
|
|
||||||
guard ProtocolVersion.isSupported(version) else {
|
|
||||||
// Log unsupported version for debugging
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let type = unpaddedData[offset]; offset += 1
|
|
||||||
let ttl = unpaddedData[offset]; offset += 1
|
|
||||||
|
|
||||||
// Timestamp
|
|
||||||
let timestampData = unpaddedData[offset..<offset+8]
|
|
||||||
let timestamp = timestampData.reduce(0) { result, byte in
|
|
||||||
(result << 8) | UInt64(byte)
|
|
||||||
}
|
|
||||||
offset += 8
|
|
||||||
|
|
||||||
// Flags
|
|
||||||
let flags = unpaddedData[offset]; offset += 1
|
|
||||||
let hasRecipient = (flags & Flags.hasRecipient) != 0
|
|
||||||
let hasSignature = (flags & Flags.hasSignature) != 0
|
|
||||||
let isCompressed = (flags & Flags.isCompressed) != 0
|
|
||||||
|
|
||||||
// Payload length
|
|
||||||
let payloadLengthData = unpaddedData[offset..<offset+2]
|
|
||||||
let payloadLength = payloadLengthData.reduce(0) { result, byte in
|
|
||||||
(result << 8) | UInt16(byte)
|
|
||||||
}
|
|
||||||
offset += 2
|
|
||||||
|
|
||||||
// Calculate expected total size
|
|
||||||
var expectedSize = headerSize + senderIDSize + Int(payloadLength)
|
|
||||||
if hasRecipient {
|
|
||||||
expectedSize += recipientIDSize
|
|
||||||
}
|
|
||||||
if hasSignature {
|
|
||||||
expectedSize += signatureSize
|
|
||||||
}
|
|
||||||
|
|
||||||
guard unpaddedData.count >= expectedSize else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SenderID
|
|
||||||
let senderID = unpaddedData[offset..<offset+senderIDSize]
|
|
||||||
offset += senderIDSize
|
|
||||||
|
|
||||||
// RecipientID
|
|
||||||
var recipientID: Data?
|
|
||||||
if hasRecipient {
|
|
||||||
recipientID = unpaddedData[offset..<offset+recipientIDSize]
|
|
||||||
offset += recipientIDSize
|
|
||||||
}
|
|
||||||
|
|
||||||
// Payload
|
|
||||||
let payload: Data
|
|
||||||
if isCompressed {
|
|
||||||
// First 2 bytes are original size
|
|
||||||
guard Int(payloadLength) >= 2 else { return nil }
|
|
||||||
let originalSizeData = unpaddedData[offset..<offset+2]
|
|
||||||
let originalSize = Int(originalSizeData.reduce(0) { result, byte in
|
|
||||||
(result << 8) | UInt16(byte)
|
|
||||||
})
|
|
||||||
offset += 2
|
|
||||||
|
|
||||||
// Compressed payload
|
|
||||||
let compressedPayload = unpaddedData[offset..<offset+Int(payloadLength)-2]
|
|
||||||
offset += Int(payloadLength) - 2
|
|
||||||
|
|
||||||
// Decompress
|
|
||||||
guard let decompressedPayload = CompressionUtil.decompress(compressedPayload, originalSize: originalSize) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
payload = decompressedPayload
|
|
||||||
} else {
|
|
||||||
payload = unpaddedData[offset..<offset+Int(payloadLength)]
|
|
||||||
offset += Int(payloadLength)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Signature
|
|
||||||
var signature: Data?
|
|
||||||
if hasSignature {
|
|
||||||
signature = unpaddedData[offset..<offset+signatureSize]
|
|
||||||
}
|
|
||||||
|
|
||||||
return BitchatPacket(
|
|
||||||
type: type,
|
|
||||||
senderID: senderID,
|
|
||||||
recipientID: recipientID,
|
|
||||||
timestamp: timestamp,
|
|
||||||
payload: payload,
|
|
||||||
signature: signature,
|
|
||||||
ttl: ttl
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Binary encoding for BitchatMessage
|
// Core decoding implementation used by decode(_:) with and without padding removal
|
||||||
extension BitchatMessage {
|
private static func decodeCore(_ raw: Data) -> BitchatPacket? {
|
||||||
func toBinaryPayload() -> Data? {
|
// Minimum size: header + senderID
|
||||||
var data = Data()
|
guard raw.count >= headerSize + senderIDSize else { return nil }
|
||||||
|
|
||||||
// Message format:
|
return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in
|
||||||
// - Flags: 1 byte (bit 0: isRelay, bit 1: isPrivate, bit 2: hasOriginalSender, bit 3: hasRecipientNickname, bit 4: hasSenderPeerID, bit 5: hasMentions)
|
guard let base = buf.baseAddress else { return nil }
|
||||||
// - Timestamp: 8 bytes (seconds since epoch)
|
var offset = 0
|
||||||
// - ID length: 1 byte
|
func require(_ n: Int) -> Bool { offset + n <= buf.count }
|
||||||
// - ID: variable
|
// Read single byte
|
||||||
// - Sender length: 1 byte
|
func read8() -> UInt8? {
|
||||||
// - Sender: variable
|
guard require(1) else { return nil }
|
||||||
// - Content length: 2 bytes
|
let v = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self).pointee
|
||||||
// - Content: variable
|
offset += 1
|
||||||
// Optional fields based on flags:
|
return v
|
||||||
// - 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
// Read big-endian 16-bit
|
||||||
|
func read16() -> UInt16? {
|
||||||
|
guard require(2) else { return nil }
|
||||||
return data
|
let p = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
|
||||||
}
|
let v = (UInt16(p[0]) << 8) | UInt16(p[1])
|
||||||
|
offset += 2
|
||||||
static func fromBinaryPayload(_ data: Data) -> BitchatMessage? {
|
return v
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
}
|
// Copy N bytes into Data
|
||||||
|
func readData(_ n: Int) -> Data? {
|
||||||
var recipientNickname: String?
|
guard require(n) else { return nil }
|
||||||
if hasRecipientNickname && offset < dataCopy.count {
|
let ptr = base.advanced(by: offset)
|
||||||
let length = Int(dataCopy[offset]); offset += 1
|
let d = Data(bytes: ptr, count: n)
|
||||||
if offset + length <= dataCopy.count {
|
offset += n
|
||||||
recipientNickname = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
|
return d
|
||||||
offset += length
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// Version
|
||||||
var senderPeerID: String?
|
guard let version = read8(), version == 1 else { return nil }
|
||||||
if hasSenderPeerID && offset < dataCopy.count {
|
guard let type = read8() else { return nil }
|
||||||
let length = Int(dataCopy[offset]); offset += 1
|
guard let ttl = read8() else { return nil }
|
||||||
if offset + length <= dataCopy.count {
|
|
||||||
senderPeerID = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
|
// Timestamp 8 bytes BE
|
||||||
offset += length
|
guard require(8) else { return nil }
|
||||||
|
var ts: UInt64 = 0
|
||||||
|
for _ in 0..<8 {
|
||||||
|
guard let b = read8() else { return nil }
|
||||||
|
ts = (ts << 8) | UInt64(b)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// Flags
|
||||||
// Mentions array
|
guard let flags = read8() else { return nil }
|
||||||
var mentions: [String]?
|
let hasRecipient = (flags & Flags.hasRecipient) != 0
|
||||||
if hasMentions && offset < dataCopy.count {
|
let hasSignature = (flags & Flags.hasSignature) != 0
|
||||||
let mentionCount = Int(dataCopy[offset]); offset += 1
|
let isCompressed = (flags & Flags.isCompressed) != 0
|
||||||
if mentionCount > 0 {
|
|
||||||
mentions = []
|
// Payload length
|
||||||
for _ in 0..<mentionCount {
|
guard let payloadLen = read16(), payloadLen <= 65535 else { return nil }
|
||||||
if offset < dataCopy.count {
|
|
||||||
let length = Int(dataCopy[offset]); offset += 1
|
// SenderID
|
||||||
if offset + length <= dataCopy.count {
|
guard let senderID = readData(senderIDSize) else { return nil }
|
||||||
if let mention = String(data: dataCopy[offset..<offset+length], encoding: .utf8) {
|
|
||||||
mentions?.append(mention)
|
// Recipient
|
||||||
}
|
var recipientID: Data? = nil
|
||||||
offset += length
|
if hasRecipient {
|
||||||
}
|
recipientID = readData(recipientIDSize)
|
||||||
}
|
if recipientID == nil { return nil }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Payload
|
||||||
|
let payload: Data
|
||||||
|
if isCompressed {
|
||||||
|
// Need original size (2 bytes)
|
||||||
|
guard let origSize16 = read16() else { return nil }
|
||||||
|
let originalSize = Int(origSize16)
|
||||||
|
guard originalSize >= 0 && originalSize <= 1_048_576 else { return nil }
|
||||||
|
let compSize = Int(payloadLen) - 2
|
||||||
|
guard compSize >= 0, let compressed = readData(compSize) else { return nil }
|
||||||
|
guard let decompressed = CompressionUtil.decompress(compressed, originalSize: originalSize),
|
||||||
|
decompressed.count == originalSize else { return nil }
|
||||||
|
payload = decompressed
|
||||||
|
} else {
|
||||||
|
guard let p = readData(Int(payloadLen)) else { return nil }
|
||||||
|
payload = p
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signature
|
||||||
|
var signature: Data? = nil
|
||||||
|
if hasSignature {
|
||||||
|
signature = readData(signatureSize)
|
||||||
|
if signature == nil { return nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
guard offset <= buf.count else { return nil }
|
||||||
|
|
||||||
|
return BitchatPacket(
|
||||||
|
type: type,
|
||||||
|
senderID: senderID,
|
||||||
|
recipientID: recipientID,
|
||||||
|
timestamp: ts,
|
||||||
|
payload: payload,
|
||||||
|
signature: signature,
|
||||||
|
ttl: ttl
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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,122 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Lightweight Geohash encoder used for Location Channels.
|
||||||
|
/// Encodes latitude/longitude to base32 geohash with a fixed precision.
|
||||||
|
enum Geohash {
|
||||||
|
private static let base32Chars = Array("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||||
|
private static let base32Map: [Character: Int] = {
|
||||||
|
var map: [Character: Int] = [:]
|
||||||
|
for (i, c) in base32Chars.enumerated() { map[c] = i }
|
||||||
|
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)
|
||||||
|
/// - longitude: Longitude in degrees (-180...180)
|
||||||
|
/// - precision: Number of geohash characters (2-12 typical). Values <= 0 return an empty string.
|
||||||
|
/// - Returns: Base32 geohash string of length `precision`.
|
||||||
|
static func encode(latitude: Double, longitude: Double, precision: Int) -> String {
|
||||||
|
guard precision > 0 else { return "" }
|
||||||
|
|
||||||
|
var latInterval: (Double, Double) = (-90.0, 90.0)
|
||||||
|
var lonInterval: (Double, Double) = (-180.0, 180.0)
|
||||||
|
|
||||||
|
var isEven = true
|
||||||
|
var bit = 0
|
||||||
|
var ch = 0
|
||||||
|
var geohash: [Character] = []
|
||||||
|
|
||||||
|
let lat = max(-90.0, min(90.0, latitude))
|
||||||
|
let lon = max(-180.0, min(180.0, longitude))
|
||||||
|
|
||||||
|
while geohash.count < precision {
|
||||||
|
if isEven {
|
||||||
|
let mid = (lonInterval.0 + lonInterval.1) / 2
|
||||||
|
if lon >= mid {
|
||||||
|
ch |= (1 << (4 - bit))
|
||||||
|
lonInterval.0 = mid
|
||||||
|
} else {
|
||||||
|
lonInterval.1 = mid
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let mid = (latInterval.0 + latInterval.1) / 2
|
||||||
|
if lat >= mid {
|
||||||
|
ch |= (1 << (4 - bit))
|
||||||
|
latInterval.0 = mid
|
||||||
|
} else {
|
||||||
|
latInterval.1 = mid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isEven.toggle()
|
||||||
|
if bit < 4 {
|
||||||
|
bit += 1
|
||||||
|
} else {
|
||||||
|
geohash.append(base32Chars[ch])
|
||||||
|
bit = 0
|
||||||
|
ch = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(geohash)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes a geohash into the center latitude/longitude of its bounding box.
|
||||||
|
/// - Parameter geohash: Base32 geohash string.
|
||||||
|
/// - Returns: (lat, lon) center coordinate.
|
||||||
|
static func decodeCenter(_ geohash: String) -> (lat: Double, lon: 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let lat = (latInterval.0 + latInterval.1) / 2
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Levels of location channels mapped to geohash precisions.
|
||||||
|
enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
||||||
|
case building
|
||||||
|
case block
|
||||||
|
case neighborhood
|
||||||
|
case city
|
||||||
|
case province // previously .region
|
||||||
|
case region // previously .country
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
case .province: return 4
|
||||||
|
case .region: return 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
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
|
||||||
|
case "region": self = .province // old "region" maps to new .province
|
||||||
|
case "country": self = .region // old "country" maps to new .region
|
||||||
|
case "province": self = .province
|
||||||
|
default:
|
||||||
|
self = .block
|
||||||
|
}
|
||||||
|
} 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
|
||||||
|
case 4: self = .province
|
||||||
|
case 0...3: self = .region
|
||||||
|
default: self = .block
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self = .block
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
case .province: try container.encode("province")
|
||||||
|
case .region: try container.encode("region")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A computed geohash channel option.
|
||||||
|
struct GeohashChannel: Codable, Equatable, Hashable, Identifiable {
|
||||||
|
let level: GeohashChannelLevel
|
||||||
|
let geohash: String
|
||||||
|
|
||||||
|
var id: String { "\(level)-\(geohash)" }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
"\(level.displayName) • \(geohash)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Identifier for current public chat channel (mesh or a location geohash).
|
||||||
|
enum ChannelID: Equatable, Codable {
|
||||||
|
case mesh
|
||||||
|
case location(GeohashChannel)
|
||||||
|
|
||||||
|
/// Human readable name for UI.
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .mesh:
|
||||||
|
return "Mesh"
|
||||||
|
case .location(let ch):
|
||||||
|
return ch.displayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nostr tag value for scoping (geohash), if applicable.
|
||||||
|
var nostrGeohashTag: String? {
|
||||||
|
switch self {
|
||||||
|
case .mesh: return nil
|
||||||
|
case .location(let ch): return ch.geohash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Protocol TLV Packets
|
||||||
|
|
||||||
|
struct AnnouncementPacket {
|
||||||
|
let nickname: String
|
||||||
|
let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement)
|
||||||
|
let signingPublicKey: Data // Ed25519 public key for signing
|
||||||
|
|
||||||
|
private enum TLVType: UInt8 {
|
||||||
|
case nickname = 0x01
|
||||||
|
case noisePublicKey = 0x02
|
||||||
|
case signingPublicKey = 0x03
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
data.append(TLVType.nickname.rawValue)
|
||||||
|
data.append(UInt8(nicknameData.count))
|
||||||
|
data.append(nicknameData)
|
||||||
|
|
||||||
|
// TLV for noise public key
|
||||||
|
guard noisePublicKey.count <= 255 else { return nil }
|
||||||
|
data.append(TLVType.noisePublicKey.rawValue)
|
||||||
|
data.append(UInt8(noisePublicKey.count))
|
||||||
|
data.append(noisePublicKey)
|
||||||
|
|
||||||
|
// TLV for signing public key
|
||||||
|
guard signingPublicKey.count <= 255 else { return nil }
|
||||||
|
data.append(TLVType.signingPublicKey.rawValue)
|
||||||
|
data.append(UInt8(signingPublicKey.count))
|
||||||
|
data.append(signingPublicKey)
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(from data: Data) -> AnnouncementPacket? {
|
||||||
|
var offset = 0
|
||||||
|
var nickname: String?
|
||||||
|
var noisePublicKey: Data?
|
||||||
|
var signingPublicKey: Data?
|
||||||
|
|
||||||
|
while offset + 2 <= data.count {
|
||||||
|
let typeRaw = data[offset]
|
||||||
|
offset += 1
|
||||||
|
let length = Int(data[offset])
|
||||||
|
offset += 1
|
||||||
|
|
||||||
|
guard offset + length <= data.count else { return nil }
|
||||||
|
let value = data[offset..<offset + length]
|
||||||
|
offset += length
|
||||||
|
|
||||||
|
if let type = TLVType(rawValue: typeRaw) {
|
||||||
|
switch type {
|
||||||
|
case .nickname:
|
||||||
|
nickname = String(data: value, encoding: .utf8)
|
||||||
|
case .noisePublicKey:
|
||||||
|
noisePublicKey = Data(value)
|
||||||
|
case .signingPublicKey:
|
||||||
|
signingPublicKey = Data(value)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Unknown TLV; skip (tolerant decoder for forward compatibility)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let nickname = nickname, let noisePublicKey = noisePublicKey, let signingPublicKey = signingPublicKey else { return nil }
|
||||||
|
return AnnouncementPacket(
|
||||||
|
nickname: nickname,
|
||||||
|
noisePublicKey: noisePublicKey,
|
||||||
|
signingPublicKey: signingPublicKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PrivateMessagePacket {
|
||||||
|
let messageID: String
|
||||||
|
let content: String
|
||||||
|
|
||||||
|
private enum TLVType: UInt8 {
|
||||||
|
case messageID = 0x00
|
||||||
|
case content = 0x01
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
data.append(TLVType.messageID.rawValue)
|
||||||
|
data.append(UInt8(messageIDData.count))
|
||||||
|
data.append(messageIDData)
|
||||||
|
|
||||||
|
// TLV for content
|
||||||
|
guard let contentData = content.data(using: .utf8), contentData.count <= 255 else { return nil }
|
||||||
|
data.append(TLVType.content.rawValue)
|
||||||
|
data.append(UInt8(contentData.count))
|
||||||
|
data.append(contentData)
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(from data: Data) -> PrivateMessagePacket? {
|
||||||
|
var offset = 0
|
||||||
|
var messageID: String?
|
||||||
|
var content: String?
|
||||||
|
|
||||||
|
while offset + 2 <= data.count {
|
||||||
|
guard let type = TLVType(rawValue: data[offset]) else { return nil }
|
||||||
|
offset += 1
|
||||||
|
|
||||||
|
let length = Int(data[offset])
|
||||||
|
offset += 1
|
||||||
|
|
||||||
|
guard offset + length <= data.count else { return nil }
|
||||||
|
let value = data[offset..<offset + length]
|
||||||
|
offset += length
|
||||||
|
|
||||||
|
switch type {
|
||||||
|
case .messageID:
|
||||||
|
messageID = String(data: value, encoding: .utf8)
|
||||||
|
case .content:
|
||||||
|
content = String(data: value, encoding: .utf8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let messageID = messageID, let content = content else { return nil }
|
||||||
|
return PrivateMessagePacket(messageID: messageID, content: content)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
//
|
||||||
|
// AutocompleteService.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// Handles autocomplete suggestions for mentions and commands
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Manages autocomplete functionality for chat
|
||||||
|
final class AutocompleteService {
|
||||||
|
private let mentionRegex = try? NSRegularExpression(pattern: "@([\\p{L}0-9_]*)$", options: [])
|
||||||
|
private let commandRegex = try? NSRegularExpression(pattern: "^/([a-z]*)$", options: [])
|
||||||
|
|
||||||
|
private let commands = [
|
||||||
|
"/msg", "/who", "/clear",
|
||||||
|
"/hug", "/slap", "/fav", "/unfav",
|
||||||
|
"/block", "/unblock"
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Get autocomplete suggestions for current text
|
||||||
|
func getSuggestions(for text: String, peers: [String], cursorPosition: Int) -> (suggestions: [String], range: NSRange?) {
|
||||||
|
let textToPosition = String(text.prefix(cursorPosition))
|
||||||
|
|
||||||
|
// Check for mention autocomplete
|
||||||
|
if let (mentionSuggestions, mentionRange) = getMentionSuggestions(textToPosition, peers: peers) {
|
||||||
|
return (mentionSuggestions, mentionRange)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't handle command autocomplete here - ContentView handles it with better UI
|
||||||
|
// if let (commandSuggestions, commandRange) = getCommandSuggestions(textToPosition) {
|
||||||
|
// return (commandSuggestions, commandRange)
|
||||||
|
// }
|
||||||
|
|
||||||
|
return ([], nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply selected suggestion to text
|
||||||
|
func applySuggestion(_ suggestion: String, to text: String, range: NSRange) -> String {
|
||||||
|
guard let textRange = Range(range, in: text) else { return text }
|
||||||
|
|
||||||
|
var replacement = suggestion
|
||||||
|
|
||||||
|
// Add space after command if it takes arguments
|
||||||
|
if suggestion.hasPrefix("/") && needsArgument(command: suggestion) {
|
||||||
|
replacement += " "
|
||||||
|
}
|
||||||
|
|
||||||
|
return text.replacingCharacters(in: textRange, with: replacement)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Methods
|
||||||
|
|
||||||
|
private func getMentionSuggestions(_ text: String, peers: [String]) -> ([String], NSRange)? {
|
||||||
|
guard let regex = mentionRegex else { return nil }
|
||||||
|
|
||||||
|
let nsText = text as NSString
|
||||||
|
let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: nsText.length))
|
||||||
|
|
||||||
|
guard let match = matches.last else { return nil }
|
||||||
|
|
||||||
|
let fullRange = match.range(at: 0)
|
||||||
|
let captureRange = match.range(at: 1)
|
||||||
|
let prefix = nsText.substring(with: captureRange).lowercased()
|
||||||
|
|
||||||
|
let suggestions = peers
|
||||||
|
.filter { $0.lowercased().hasPrefix(prefix) }
|
||||||
|
.sorted()
|
||||||
|
.prefix(5)
|
||||||
|
.map { "@\($0)" }
|
||||||
|
|
||||||
|
return suggestions.isEmpty ? nil : (Array(suggestions), fullRange)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getCommandSuggestions(_ text: String) -> ([String], NSRange)? {
|
||||||
|
guard let regex = commandRegex else { return nil }
|
||||||
|
|
||||||
|
let nsText = text as NSString
|
||||||
|
let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: nsText.length))
|
||||||
|
|
||||||
|
guard let match = matches.last else { return nil }
|
||||||
|
|
||||||
|
let fullRange = match.range(at: 0)
|
||||||
|
let captureRange = match.range(at: 1)
|
||||||
|
let prefix = nsText.substring(with: captureRange).lowercased()
|
||||||
|
|
||||||
|
let suggestions = commands
|
||||||
|
.filter { $0.hasPrefix("/\(prefix)") }
|
||||||
|
.sorted()
|
||||||
|
.prefix(5)
|
||||||
|
|
||||||
|
return suggestions.isEmpty ? nil : (Array(suggestions), fullRange)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func needsArgument(command: String) -> Bool {
|
||||||
|
switch command {
|
||||||
|
case "/who", "/clear":
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
//
|
||||||
|
// ColorPaletteService.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// Manages consistent color assignment for peers using minimal-distance algorithm
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Service that assigns consistent, visually distinct colors to peers
|
||||||
|
/// Uses a minimal-distance hue assignment algorithm to maximize color separation
|
||||||
|
final class ColorPaletteService {
|
||||||
|
|
||||||
|
// MARK: - Palette State
|
||||||
|
|
||||||
|
private var peerPaletteLight: [String: (slot: Int, ring: Int, hue: Double)] = [:]
|
||||||
|
private var peerPaletteDark: [String: (slot: Int, ring: Int, hue: Double)] = [:]
|
||||||
|
private var peerPaletteSeeds: [String: String] = [:] // peerID -> seed used
|
||||||
|
|
||||||
|
private var nostrPaletteLight: [String: (slot: Int, ring: Int, hue: Double)] = [:]
|
||||||
|
private var nostrPaletteDark: [String: (slot: Int, ring: Int, hue: Double)] = [:]
|
||||||
|
private var nostrPaletteSeeds: [String: String] = [:] // pubkey -> seed used
|
||||||
|
|
||||||
|
// MARK: - Configuration
|
||||||
|
|
||||||
|
private let slotCount: Int
|
||||||
|
private let avoidCenter: Double // Hue to avoid (typically orange for self)
|
||||||
|
private let avoidDelta: Double
|
||||||
|
private let saturationDark: Double
|
||||||
|
private let saturationLight: Double
|
||||||
|
private let baseBrightnessDark: Double
|
||||||
|
private let baseBrightnessLight: Double
|
||||||
|
private let ringDeltaDark: Double
|
||||||
|
private let ringDeltaLight: Double
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
init(
|
||||||
|
slotCount: Int = max(8, TransportConfig.uiPeerPaletteSlots),
|
||||||
|
avoidCenter: Double = 30.0 / 360.0, // Orange hue
|
||||||
|
avoidDelta: Double = TransportConfig.uiColorHueAvoidanceDelta,
|
||||||
|
saturationDark: Double = 0.80,
|
||||||
|
saturationLight: Double = 0.70,
|
||||||
|
baseBrightnessDark: Double = 0.75,
|
||||||
|
baseBrightnessLight: Double = 0.45,
|
||||||
|
ringDeltaDark: Double = TransportConfig.uiPeerPaletteRingBrightnessDeltaDark,
|
||||||
|
ringDeltaLight: Double = TransportConfig.uiPeerPaletteRingBrightnessDeltaLight
|
||||||
|
) {
|
||||||
|
self.slotCount = slotCount
|
||||||
|
self.avoidCenter = avoidCenter
|
||||||
|
self.avoidDelta = avoidDelta
|
||||||
|
self.saturationDark = saturationDark
|
||||||
|
self.saturationLight = saturationLight
|
||||||
|
self.baseBrightnessDark = baseBrightnessDark
|
||||||
|
self.baseBrightnessLight = baseBrightnessLight
|
||||||
|
self.ringDeltaDark = ringDeltaDark
|
||||||
|
self.ringDeltaLight = ringDeltaLight
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
|
||||||
|
/// Get color for a mesh peer
|
||||||
|
func colorForMeshPeer(
|
||||||
|
peerID: String,
|
||||||
|
isDark: Bool,
|
||||||
|
myPeerID: String,
|
||||||
|
allPeers: [BitchatPeer],
|
||||||
|
getNoiseKeyForShortID: (String) -> String?
|
||||||
|
) -> Color {
|
||||||
|
// Ensure palette is up to date
|
||||||
|
rebuildPeerPaletteIfNeeded(
|
||||||
|
myPeerID: myPeerID,
|
||||||
|
allPeers: allPeers,
|
||||||
|
getNoiseKeyForShortID: getNoiseKeyForShortID
|
||||||
|
)
|
||||||
|
|
||||||
|
let entry = (isDark ? peerPaletteDark[peerID] : peerPaletteLight[peerID])
|
||||||
|
let orange = Color.orange
|
||||||
|
if peerID == myPeerID { return orange }
|
||||||
|
|
||||||
|
let saturation: Double = isDark ? saturationDark : saturationLight
|
||||||
|
let baseBrightness: Double = isDark ? baseBrightnessDark : baseBrightnessLight
|
||||||
|
let ringDelta = isDark ? ringDeltaDark : ringDeltaLight
|
||||||
|
|
||||||
|
if let e = entry {
|
||||||
|
let brightness = min(1.0, max(0.0, baseBrightness + ringDelta * Double(e.ring)))
|
||||||
|
return Color(hue: e.hue, saturation: saturation, brightness: brightness)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to seed color if not in palette
|
||||||
|
let seed = meshSeed(for: peerID, getNoiseKeyForShortID: getNoiseKeyForShortID)
|
||||||
|
return Color(peerSeed: seed, isDark: isDark)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get color for a Nostr participant
|
||||||
|
func colorForNostrPubkey(
|
||||||
|
pubkeyHexLowercased: String,
|
||||||
|
isDark: Bool,
|
||||||
|
myNostrPubkey: String?,
|
||||||
|
geohashPeople: [(id: String, seed: String)]
|
||||||
|
) -> Color {
|
||||||
|
rebuildNostrPaletteIfNeeded(
|
||||||
|
myNostrPubkey: myNostrPubkey,
|
||||||
|
geohashPeople: geohashPeople
|
||||||
|
)
|
||||||
|
|
||||||
|
let entry = (isDark ? nostrPaletteDark[pubkeyHexLowercased] : nostrPaletteLight[pubkeyHexLowercased])
|
||||||
|
if let me = myNostrPubkey, pubkeyHexLowercased == me { return .orange }
|
||||||
|
|
||||||
|
let saturation: Double = isDark ? saturationDark : saturationLight
|
||||||
|
let baseBrightness: Double = isDark ? baseBrightnessDark : baseBrightnessLight
|
||||||
|
let ringDelta = isDark ? ringDeltaDark : ringDeltaLight
|
||||||
|
|
||||||
|
if let e = entry {
|
||||||
|
let brightness = min(1.0, max(0.0, baseBrightness + ringDelta * Double(e.ring)))
|
||||||
|
return Color(hue: e.hue, saturation: saturation, brightness: brightness)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to seed color
|
||||||
|
return Color(peerSeed: "nostr:" + pubkeyHexLowercased, isDark: isDark)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get color for a message sender (auto-detects type)
|
||||||
|
func peerColor(
|
||||||
|
for message: BitchatMessage,
|
||||||
|
isDark: Bool,
|
||||||
|
myPeerID: String,
|
||||||
|
myNostrPubkey: String?,
|
||||||
|
nostrKeyMapping: [String: String],
|
||||||
|
allPeers: [BitchatPeer],
|
||||||
|
geohashPeople: [(id: String, seed: String)],
|
||||||
|
getNoiseKeyForShortID: (String) -> String?
|
||||||
|
) -> Color {
|
||||||
|
if let spid = message.senderPeerID?.id {
|
||||||
|
if spid.hasPrefix("nostr:") || spid.hasPrefix("nostr_") {
|
||||||
|
let bare: String = {
|
||||||
|
if spid.hasPrefix("nostr:") { return String(spid.dropFirst(6)) }
|
||||||
|
if spid.hasPrefix("nostr_") { return String(spid.dropFirst(6)) }
|
||||||
|
return spid
|
||||||
|
}()
|
||||||
|
let full = nostrKeyMapping[spid]?.lowercased() ?? bare.lowercased()
|
||||||
|
return colorForNostrPubkey(
|
||||||
|
pubkeyHexLowercased: full,
|
||||||
|
isDark: isDark,
|
||||||
|
myNostrPubkey: myNostrPubkey,
|
||||||
|
geohashPeople: geohashPeople
|
||||||
|
)
|
||||||
|
} else if spid.count == 16 {
|
||||||
|
return colorForMeshPeer(
|
||||||
|
peerID: spid,
|
||||||
|
isDark: isDark,
|
||||||
|
myPeerID: myPeerID,
|
||||||
|
allPeers: allPeers,
|
||||||
|
getNoiseKeyForShortID: getNoiseKeyForShortID
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return colorForMeshPeer(
|
||||||
|
peerID: spid.lowercased(),
|
||||||
|
isDark: isDark,
|
||||||
|
myPeerID: myPeerID,
|
||||||
|
allPeers: allPeers,
|
||||||
|
getNoiseKeyForShortID: getNoiseKeyForShortID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback when we only have a display name
|
||||||
|
return Color(peerSeed: message.sender.lowercased(), isDark: isDark)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset all palette state (useful for testing)
|
||||||
|
func reset() {
|
||||||
|
peerPaletteLight.removeAll()
|
||||||
|
peerPaletteDark.removeAll()
|
||||||
|
peerPaletteSeeds.removeAll()
|
||||||
|
nostrPaletteLight.removeAll()
|
||||||
|
nostrPaletteDark.removeAll()
|
||||||
|
nostrPaletteSeeds.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
|
private func meshSeed(for peerID: String, getNoiseKeyForShortID: (String) -> String?) -> String {
|
||||||
|
if let full = getNoiseKeyForShortID(peerID)?.lowercased() {
|
||||||
|
return "noise:" + full
|
||||||
|
}
|
||||||
|
return peerID.lowercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rebuildPeerPaletteIfNeeded(
|
||||||
|
myPeerID: String,
|
||||||
|
allPeers: [BitchatPeer],
|
||||||
|
getNoiseKeyForShortID: (String) -> String?
|
||||||
|
) {
|
||||||
|
// Build current peer->seed map (excluding self)
|
||||||
|
var currentSeeds: [String: String] = [:]
|
||||||
|
for p in allPeers where p.peerID.id != myPeerID {
|
||||||
|
currentSeeds[p.peerID.id] = meshSeed(for: p.peerID.id, getNoiseKeyForShortID: getNoiseKeyForShortID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If seeds unchanged and palette exists for both themes, skip
|
||||||
|
if currentSeeds == peerPaletteSeeds,
|
||||||
|
peerPaletteLight.keys.count == currentSeeds.count,
|
||||||
|
peerPaletteDark.keys.count == currentSeeds.count {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
peerPaletteSeeds = currentSeeds
|
||||||
|
|
||||||
|
// Generate palette
|
||||||
|
let mapping = assignColorsMinimalDistance(seeds: currentSeeds, previousMapping: peerPaletteLight)
|
||||||
|
peerPaletteLight = mapping
|
||||||
|
peerPaletteDark = mapping
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rebuildNostrPaletteIfNeeded(
|
||||||
|
myNostrPubkey: String?,
|
||||||
|
geohashPeople: [(id: String, seed: String)]
|
||||||
|
) {
|
||||||
|
// Build seeds map from currently visible geohash people (excluding self)
|
||||||
|
var currentSeeds: [String: String] = [:]
|
||||||
|
for p in geohashPeople where p.id != myNostrPubkey {
|
||||||
|
currentSeeds[p.id] = p.seed
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentSeeds == nostrPaletteSeeds,
|
||||||
|
nostrPaletteLight.keys.count == currentSeeds.count,
|
||||||
|
nostrPaletteDark.keys.count == currentSeeds.count {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nostrPaletteSeeds = currentSeeds
|
||||||
|
|
||||||
|
let mapping = assignColorsMinimalDistance(seeds: currentSeeds, previousMapping: nostrPaletteLight)
|
||||||
|
nostrPaletteLight = mapping
|
||||||
|
nostrPaletteDark = mapping
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Minimal-Distance Color Assignment Algorithm
|
||||||
|
|
||||||
|
private func assignColorsMinimalDistance(
|
||||||
|
seeds: [String: String],
|
||||||
|
previousMapping: [String: (slot: Int, ring: Int, hue: Double)]
|
||||||
|
) -> [String: (slot: Int, ring: Int, hue: Double)] {
|
||||||
|
// Generate evenly spaced hue slots avoiding self-orange range
|
||||||
|
var slots: [Double] = []
|
||||||
|
for i in 0..<slotCount {
|
||||||
|
let hue = Double(i) / Double(slotCount)
|
||||||
|
if abs(hue - avoidCenter) < avoidDelta { continue }
|
||||||
|
slots.append(hue)
|
||||||
|
}
|
||||||
|
if slots.isEmpty {
|
||||||
|
// Safety: if avoidance consumed all (shouldn't happen), fall back to full slots
|
||||||
|
for i in 0..<slotCount { slots.append(Double(i) / Double(slotCount)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to compute circular distance
|
||||||
|
func circDist(_ a: Double, _ b: Double) -> Double {
|
||||||
|
let d = abs(a - b)
|
||||||
|
return d > 0.5 ? 1.0 - d : d
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign slots to peers to maximize minimal distance, deterministically
|
||||||
|
let peers = seeds.keys.sorted() // stable order
|
||||||
|
|
||||||
|
// Preferred slot index by seed (wrapping to available slots)
|
||||||
|
let prefIndex: [String: Int] = Dictionary(uniqueKeysWithValues: peers.map { id in
|
||||||
|
let h = (seeds[id] ?? id).djb2()
|
||||||
|
let idx = Int(h % UInt64(slots.count))
|
||||||
|
return (id, idx)
|
||||||
|
})
|
||||||
|
|
||||||
|
var mapping: [String: (slot: Int, ring: Int, hue: Double)] = [:]
|
||||||
|
var usedSlots = Set<Int>()
|
||||||
|
var usedHues: [Double] = []
|
||||||
|
|
||||||
|
// Keep previous assignments if still valid to minimize churn
|
||||||
|
for (id, entry) in previousMapping {
|
||||||
|
if seeds.keys.contains(id), entry.slot < slots.count { // slot index still valid
|
||||||
|
mapping[id] = (entry.slot, entry.ring, slots[entry.slot])
|
||||||
|
usedSlots.insert(entry.slot)
|
||||||
|
usedHues.append(slots[entry.slot])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// First ring assignment using free slots
|
||||||
|
let unassigned = peers.filter { mapping[$0] == nil }
|
||||||
|
for id in unassigned {
|
||||||
|
// If a preferred slot free, take it
|
||||||
|
let preferred = prefIndex[id] ?? 0
|
||||||
|
if !usedSlots.contains(preferred) && preferred < slots.count {
|
||||||
|
mapping[id] = (preferred, 0, slots[preferred])
|
||||||
|
usedSlots.insert(preferred)
|
||||||
|
usedHues.append(slots[preferred])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Choose free slot maximizing minimal distance to used hues
|
||||||
|
var bestSlot: Int? = nil
|
||||||
|
var bestScore: Double = -1
|
||||||
|
for sIdx in 0..<slots.count where !usedSlots.contains(sIdx) {
|
||||||
|
let hue = slots[sIdx]
|
||||||
|
let minDist = usedHues.isEmpty ? 1.0 : usedHues.map { circDist(hue, $0) }.min() ?? 1.0
|
||||||
|
// Bias toward preferred index for stability
|
||||||
|
let bias = 1.0 - (Double((abs(sIdx - (prefIndex[id] ?? 0)) % slots.count)) / Double(slots.count))
|
||||||
|
let score = minDist + 0.05 * bias
|
||||||
|
if score > bestScore { bestScore = score; bestSlot = sIdx }
|
||||||
|
}
|
||||||
|
if let s = bestSlot {
|
||||||
|
mapping[id] = (s, 0, slots[s])
|
||||||
|
usedSlots.insert(s)
|
||||||
|
usedHues.append(slots[s])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overflow peers: assign additional rings by reusing slots with stable preference
|
||||||
|
let stillUnassigned = peers.filter { mapping[$0] == nil }
|
||||||
|
if !stillUnassigned.isEmpty {
|
||||||
|
for (idx, id) in stillUnassigned.enumerated() {
|
||||||
|
let preferred = prefIndex[id] ?? 0
|
||||||
|
// Spread over slots by rotating from preferred with a golden-step
|
||||||
|
let goldenStep = 7 // small prime step for dispersion
|
||||||
|
let s = (preferred + idx * goldenStep) % slots.count
|
||||||
|
mapping[id] = (s, 1, slots[s])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapping
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
//
|
||||||
|
// CommandProcessor.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// Handles command parsing and execution for BitChat
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Result of command processing
|
||||||
|
enum CommandResult {
|
||||||
|
case success(message: String?)
|
||||||
|
case error(message: String)
|
||||||
|
case handled // Command handled, no message needed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Processes chat commands in a focused, efficient way
|
||||||
|
@MainActor
|
||||||
|
final class CommandProcessor {
|
||||||
|
weak var chatViewModel: ChatViewModel?
|
||||||
|
weak var meshService: Transport?
|
||||||
|
private let identityManager: SecureIdentityStateManagerProtocol
|
||||||
|
|
||||||
|
init(chatViewModel: ChatViewModel? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
|
||||||
|
self.chatViewModel = chatViewModel
|
||||||
|
self.meshService = meshService
|
||||||
|
self.identityManager = identityManager
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process a command string
|
||||||
|
@MainActor
|
||||||
|
func process(_ command: String) -> CommandResult {
|
||||||
|
let parts = command.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: false)
|
||||||
|
guard let cmd = parts.first else { return .error(message: "Invalid command") }
|
||||||
|
let args = parts.count > 1 ? String(parts[1]) : ""
|
||||||
|
|
||||||
|
// Geohash context: disable favoriting in public geohash or GeoDM
|
||||||
|
let inGeoPublic: Bool = {
|
||||||
|
switch LocationChannelManager.shared.selectedChannel {
|
||||||
|
case .mesh: return false
|
||||||
|
case .location: return true
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
let inGeoDM = (chatViewModel?.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
|
||||||
|
|
||||||
|
switch cmd {
|
||||||
|
case "/m", "/msg":
|
||||||
|
return handleMessage(args)
|
||||||
|
case "/w", "/who":
|
||||||
|
return handleWho()
|
||||||
|
case "/clear":
|
||||||
|
return handleClear()
|
||||||
|
case "/hug":
|
||||||
|
return handleEmote(args, command: "hug", action: "hugs", emoji: "🫂")
|
||||||
|
case "/slap":
|
||||||
|
return handleEmote(args, command: "slap", action: "slaps", emoji: "🐟", suffix: " around a bit with a large trout")
|
||||||
|
case "/block":
|
||||||
|
return handleBlock(args)
|
||||||
|
case "/unblock":
|
||||||
|
return handleUnblock(args)
|
||||||
|
case "/fav":
|
||||||
|
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
||||||
|
return handleFavorite(args, add: true)
|
||||||
|
case "/unfav":
|
||||||
|
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
||||||
|
return handleFavorite(args, add: false)
|
||||||
|
//
|
||||||
|
case "/help", "/h":
|
||||||
|
return .error(message: "unknown command: \(cmd)")
|
||||||
|
default:
|
||||||
|
return .error(message: "unknown command: \(cmd)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Command Handlers
|
||||||
|
|
||||||
|
private func handleMessage(_ args: String) -> CommandResult {
|
||||||
|
let parts = args.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: false)
|
||||||
|
guard !parts.isEmpty else {
|
||||||
|
return .error(message: "usage: /msg @nickname [message]")
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetName = String(parts[0])
|
||||||
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||||
|
|
||||||
|
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname) else {
|
||||||
|
return .error(message: "'\(nickname)' not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
chatViewModel?.startPrivateChat(with: peerID)
|
||||||
|
|
||||||
|
if parts.count > 1 {
|
||||||
|
let message = String(parts[1])
|
||||||
|
chatViewModel?.sendPrivateMessage(message, to: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return .success(message: "started private chat with \(nickname)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleWho() -> CommandResult {
|
||||||
|
// Show geohash participants when in a geohash channel; otherwise mesh peers
|
||||||
|
switch LocationChannelManager.shared.selectedChannel {
|
||||||
|
case .location(let ch):
|
||||||
|
// Geohash context: show visible geohash participants (exclude self)
|
||||||
|
guard let vm = chatViewModel else { return .success(message: "nobody around") }
|
||||||
|
let myHex = (try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
|
||||||
|
let people = vm.visibleGeohashPeople().filter { person in
|
||||||
|
if let me = myHex { return person.id.lowercased() != me }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
let names = people.map { $0.displayName }
|
||||||
|
if names.isEmpty { return .success(message: "no one else is online right now") }
|
||||||
|
return .success(message: "online: " + names.sorted().joined(separator: ", "))
|
||||||
|
case .mesh:
|
||||||
|
// Mesh context: show connected peer nicknames
|
||||||
|
guard let peers = meshService?.getPeerNicknames(), !peers.isEmpty else {
|
||||||
|
return .success(message: "no one else is online right now")
|
||||||
|
}
|
||||||
|
let onlineList = peers.values.sorted().joined(separator: ", ")
|
||||||
|
return .success(message: "online: \(onlineList)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleClear() -> CommandResult {
|
||||||
|
if let peerID = chatViewModel?.selectedPrivateChatPeer {
|
||||||
|
chatViewModel?.privateChats[peerID]?.removeAll()
|
||||||
|
} else {
|
||||||
|
chatViewModel?.clearCurrentPublicTimeline()
|
||||||
|
}
|
||||||
|
return .handled
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleEmote(_ args: String, command: String, action: String, emoji: String, suffix: String = "") -> CommandResult {
|
||||||
|
let targetName = args.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !targetName.isEmpty else {
|
||||||
|
return .error(message: "usage: /\(command) <nickname>")
|
||||||
|
}
|
||||||
|
|
||||||
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||||
|
|
||||||
|
guard let targetPeerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||||
|
let myNickname = chatViewModel?.nickname else {
|
||||||
|
return .error(message: "cannot \(command) \(nickname): not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
let emoteContent = "* \(emoji) \(myNickname) \(action) \(nickname)\(suffix) *"
|
||||||
|
|
||||||
|
if chatViewModel?.selectedPrivateChatPeer != nil {
|
||||||
|
// In private chat
|
||||||
|
if let peerNickname = meshService?.peerNickname(peerID: PeerID(str: targetPeerID)) {
|
||||||
|
let personalMessage = "* \(emoji) \(myNickname) \(action) you\(suffix) *"
|
||||||
|
meshService?.sendPrivateMessage(personalMessage, to: PeerID(str: targetPeerID),
|
||||||
|
recipientNickname: peerNickname,
|
||||||
|
messageID: UUID().uuidString)
|
||||||
|
// Also add a local system message so the sender sees a natural-language confirmation
|
||||||
|
let pastAction: String = {
|
||||||
|
switch action {
|
||||||
|
case "hugs": return "hugged"
|
||||||
|
case "slaps": return "slapped"
|
||||||
|
default: return action.hasSuffix("e") ? action + "d" : action + "ed"
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
let localText = "\(emoji) you \(pastAction) \(nickname)\(suffix)"
|
||||||
|
chatViewModel?.addLocalPrivateSystemMessage(localText, to: targetPeerID)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// In public chat: send to active public channel (mesh or geohash)
|
||||||
|
chatViewModel?.sendPublicRaw(emoteContent)
|
||||||
|
let publicEcho = "\(emoji) \(myNickname) \(action) \(nickname)\(suffix)"
|
||||||
|
chatViewModel?.addPublicSystemMessage(publicEcho)
|
||||||
|
}
|
||||||
|
|
||||||
|
return .handled
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleBlock(_ args: String) -> CommandResult {
|
||||||
|
let targetName = args.trimmingCharacters(in: .whitespaces)
|
||||||
|
|
||||||
|
if targetName.isEmpty {
|
||||||
|
// List blocked users (mesh) and geohash (Nostr) blocks
|
||||||
|
let meshBlocked = chatViewModel?.blockedUsers ?? []
|
||||||
|
var blockedNicknames: [String] = []
|
||||||
|
if let peers = meshService?.getPeerNicknames() {
|
||||||
|
for (peerID, nickname) in peers {
|
||||||
|
if let fingerprint = meshService?.getFingerprint(for: peerID),
|
||||||
|
meshBlocked.contains(fingerprint) {
|
||||||
|
blockedNicknames.append(nickname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Geohash blocked names (prefer visible display names; fallback to #suffix)
|
||||||
|
let geoBlocked = Array(identityManager.getBlockedNostrPubkeys())
|
||||||
|
var geoNames: [String] = []
|
||||||
|
if let vm = chatViewModel {
|
||||||
|
let visible = vm.visibleGeohashPeople()
|
||||||
|
let visibleIndex = Dictionary(uniqueKeysWithValues: visible.map { ($0.id.lowercased(), $0.displayName) })
|
||||||
|
for pk in geoBlocked {
|
||||||
|
if let name = visibleIndex[pk.lowercased()] {
|
||||||
|
geoNames.append(name)
|
||||||
|
} else {
|
||||||
|
let suffix = String(pk.suffix(4))
|
||||||
|
geoNames.append("anon#\(suffix)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let meshList = blockedNicknames.isEmpty ? "none" : blockedNicknames.sorted().joined(separator: ", ")
|
||||||
|
let geoList = geoNames.isEmpty ? "none" : geoNames.sorted().joined(separator: ", ")
|
||||||
|
return .success(message: "blocked peers: \(meshList) | geohash blocks: \(geoList)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||||
|
|
||||||
|
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||||
|
let fingerprint = meshService?.getFingerprint(for: PeerID(str: peerID)) {
|
||||||
|
if identityManager.isBlocked(fingerprint: fingerprint) {
|
||||||
|
return .success(message: "\(nickname) is already blocked")
|
||||||
|
}
|
||||||
|
// Block the user (mesh/noise identity)
|
||||||
|
if var identity = identityManager.getSocialIdentity(for: fingerprint) {
|
||||||
|
identity.isBlocked = true
|
||||||
|
identity.isFavorite = false
|
||||||
|
identityManager.updateSocialIdentity(identity)
|
||||||
|
} else {
|
||||||
|
let blockedIdentity = SocialIdentity(
|
||||||
|
fingerprint: fingerprint,
|
||||||
|
localPetname: nil,
|
||||||
|
claimedNickname: nickname,
|
||||||
|
trustLevel: .unknown,
|
||||||
|
isFavorite: false,
|
||||||
|
isBlocked: true,
|
||||||
|
notes: nil
|
||||||
|
)
|
||||||
|
identityManager.updateSocialIdentity(blockedIdentity)
|
||||||
|
}
|
||||||
|
return .success(message: "blocked \(nickname). you will no longer receive messages from them")
|
||||||
|
}
|
||||||
|
// Mesh lookup failed; try geohash (Nostr) participant by display name
|
||||||
|
if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) {
|
||||||
|
if identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {
|
||||||
|
return .success(message: "\(nickname) is already blocked")
|
||||||
|
}
|
||||||
|
identityManager.setNostrBlocked(pub, isBlocked: true)
|
||||||
|
return .success(message: "blocked \(nickname) in geohash chats")
|
||||||
|
}
|
||||||
|
|
||||||
|
return .error(message: "cannot block \(nickname): not found or unable to verify identity")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleUnblock(_ args: String) -> CommandResult {
|
||||||
|
let targetName = args.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !targetName.isEmpty else {
|
||||||
|
return .error(message: "usage: /unblock <nickname>")
|
||||||
|
}
|
||||||
|
|
||||||
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||||
|
|
||||||
|
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||||
|
let fingerprint = meshService?.getFingerprint(for: PeerID(str: peerID)) {
|
||||||
|
if !identityManager.isBlocked(fingerprint: fingerprint) {
|
||||||
|
return .success(message: "\(nickname) is not blocked")
|
||||||
|
}
|
||||||
|
identityManager.setBlocked(fingerprint, isBlocked: false)
|
||||||
|
return .success(message: "unblocked \(nickname)")
|
||||||
|
}
|
||||||
|
// Try geohash unblock
|
||||||
|
if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) {
|
||||||
|
if !identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {
|
||||||
|
return .success(message: "\(nickname) is not blocked")
|
||||||
|
}
|
||||||
|
identityManager.setNostrBlocked(pub, isBlocked: false)
|
||||||
|
return .success(message: "unblocked \(nickname) in geohash chats")
|
||||||
|
}
|
||||||
|
return .error(message: "cannot unblock \(nickname): not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
|
||||||
|
let targetName = args.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !targetName.isEmpty else {
|
||||||
|
return .error(message: "usage: /\(add ? "fav" : "unfav") <nickname>")
|
||||||
|
}
|
||||||
|
|
||||||
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||||
|
|
||||||
|
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||||
|
let noisePublicKey = Data(hexString: peerID) else {
|
||||||
|
return .error(message: "can't find peer: \(nickname)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if add {
|
||||||
|
let existingFavorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey)
|
||||||
|
FavoritesPersistenceService.shared.addFavorite(
|
||||||
|
peerNoisePublicKey: noisePublicKey,
|
||||||
|
peerNostrPublicKey: existingFavorite?.peerNostrPublicKey,
|
||||||
|
peerNickname: nickname
|
||||||
|
)
|
||||||
|
|
||||||
|
chatViewModel?.toggleFavorite(peerID: peerID)
|
||||||
|
chatViewModel?.sendFavoriteNotification(to: peerID, isFavorite: true)
|
||||||
|
|
||||||
|
return .success(message: "added \(nickname) to favorites")
|
||||||
|
} else {
|
||||||
|
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey)
|
||||||
|
|
||||||
|
chatViewModel?.toggleFavorite(peerID: peerID)
|
||||||
|
chatViewModel?.sendFavoriteNotification(to: peerID, isFavorite: false)
|
||||||
|
|
||||||
|
return .success(message: "removed \(nickname) from favorites")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleHelp() -> CommandResult {
|
||||||
|
let helpText = """
|
||||||
|
commands:
|
||||||
|
/msg @name - start private chat
|
||||||
|
/who - list who's online
|
||||||
|
/clear - clear messages
|
||||||
|
/hug @name - send a hug
|
||||||
|
/slap @name - slap with a trout
|
||||||
|
/fav @name - add to favorites
|
||||||
|
/unfav @name - remove from favorites
|
||||||
|
/block @name - block
|
||||||
|
/unblock @name - unblock
|
||||||
|
"""
|
||||||
|
return .success(message: helpText)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
//
|
|
||||||
// DeliveryTracker.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import Combine
|
|
||||||
|
|
||||||
class DeliveryTracker {
|
|
||||||
static let shared = DeliveryTracker()
|
|
||||||
|
|
||||||
// Track pending deliveries
|
|
||||||
private var pendingDeliveries: [String: PendingDelivery] = [:]
|
|
||||||
private let pendingLock = NSLock()
|
|
||||||
|
|
||||||
// Track received ACKs to prevent duplicates
|
|
||||||
private var receivedAckIDs = Set<String>()
|
|
||||||
private var sentAckIDs = Set<String>()
|
|
||||||
|
|
||||||
// Timeout configuration
|
|
||||||
private let privateMessageTimeout: TimeInterval = 120 // 2 minutes
|
|
||||||
private let roomMessageTimeout: TimeInterval = 180 // 3 minutes
|
|
||||||
private let favoriteTimeout: TimeInterval = 600 // 10 minutes for favorites
|
|
||||||
|
|
||||||
// Retry configuration
|
|
||||||
private let maxRetries = 3
|
|
||||||
private let retryDelay: TimeInterval = 5 // Base retry delay
|
|
||||||
|
|
||||||
// Publishers for UI updates
|
|
||||||
let deliveryStatusUpdated = PassthroughSubject<(messageID: String, status: DeliveryStatus), Never>()
|
|
||||||
|
|
||||||
// Cleanup timer
|
|
||||||
private var cleanupTimer: Timer?
|
|
||||||
|
|
||||||
struct PendingDelivery {
|
|
||||||
let messageID: String
|
|
||||||
let sentAt: Date
|
|
||||||
let recipientID: String
|
|
||||||
let recipientNickname: String
|
|
||||||
let retryCount: Int
|
|
||||||
let isFavorite: Bool
|
|
||||||
var timeoutTimer: Timer?
|
|
||||||
|
|
||||||
var isTimedOut: Bool {
|
|
||||||
let timeout: TimeInterval = isFavorite ? 300 : 30
|
|
||||||
return Date().timeIntervalSince(sentAt) > timeout
|
|
||||||
}
|
|
||||||
|
|
||||||
var shouldRetry: Bool {
|
|
||||||
return retryCount < 3 && isFavorite
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private init() {
|
|
||||||
startCleanupTimer()
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
cleanupTimer?.invalidate()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public Methods
|
|
||||||
|
|
||||||
func trackMessage(_ message: BitchatMessage, recipientID: String, recipientNickname: String, isFavorite: Bool = false, expectedRecipients: Int = 1) {
|
|
||||||
// Only track private messages
|
|
||||||
guard message.isPrivate else { return }
|
|
||||||
|
|
||||||
SecureLogger.log("Tracking message \(message.id) - private: \(message.isPrivate), recipient: \(recipientNickname)", category: SecureLogger.session, level: .info)
|
|
||||||
|
|
||||||
|
|
||||||
let delivery = PendingDelivery(
|
|
||||||
messageID: message.id,
|
|
||||||
sentAt: Date(),
|
|
||||||
recipientID: recipientID,
|
|
||||||
recipientNickname: recipientNickname,
|
|
||||||
retryCount: 0,
|
|
||||||
isFavorite: isFavorite,
|
|
||||||
timeoutTimer: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
// Store the delivery with lock
|
|
||||||
pendingLock.lock()
|
|
||||||
pendingDeliveries[message.id] = delivery
|
|
||||||
pendingLock.unlock()
|
|
||||||
|
|
||||||
// Update status to sent (only if not already delivered)
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
|
|
||||||
self.pendingLock.lock()
|
|
||||||
let stillPending = self.pendingDeliveries[message.id] != nil
|
|
||||||
self.pendingLock.unlock()
|
|
||||||
|
|
||||||
// Only update to sent if still pending (not already delivered)
|
|
||||||
if stillPending {
|
|
||||||
SecureLogger.log("Updating message \(message.id) to sent status (still pending)", category: SecureLogger.session, level: .debug)
|
|
||||||
self.updateDeliveryStatus(message.id, status: .sent)
|
|
||||||
} else {
|
|
||||||
SecureLogger.log("Skipping sent status update for \(message.id) - already delivered", category: SecureLogger.session, level: .debug)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schedule timeout (outside of lock)
|
|
||||||
scheduleTimeout(for: message.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func processDeliveryAck(_ ack: DeliveryAck) {
|
|
||||||
pendingLock.lock()
|
|
||||||
defer { pendingLock.unlock() }
|
|
||||||
|
|
||||||
SecureLogger.log("Processing delivery ACK for message \(ack.originalMessageID) from \(ack.recipientNickname)", category: SecureLogger.session, level: .info)
|
|
||||||
|
|
||||||
// Prevent duplicate ACK processing
|
|
||||||
guard !receivedAckIDs.contains(ack.ackID) else {
|
|
||||||
SecureLogger.log("Duplicate ACK \(ack.ackID) - ignoring", category: SecureLogger.session, level: .warning)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
receivedAckIDs.insert(ack.ackID)
|
|
||||||
|
|
||||||
// Find the pending delivery
|
|
||||||
guard let delivery = pendingDeliveries[ack.originalMessageID] else {
|
|
||||||
// Message might have already been delivered or timed out
|
|
||||||
SecureLogger.log("No pending delivery found for message \(ack.originalMessageID)", category: SecureLogger.session, level: .warning)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel timeout timer
|
|
||||||
delivery.timeoutTimer?.invalidate()
|
|
||||||
|
|
||||||
// Direct message - mark as delivered
|
|
||||||
SecureLogger.log("Marking private message \(ack.originalMessageID) as delivered to \(ack.recipientNickname)", category: SecureLogger.session, level: .info)
|
|
||||||
updateDeliveryStatus(ack.originalMessageID, status: .delivered(to: ack.recipientNickname, at: Date()))
|
|
||||||
pendingDeliveries.removeValue(forKey: ack.originalMessageID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func generateAck(for message: BitchatMessage, myPeerID: String, myNickname: String, hopCount: UInt8) -> DeliveryAck? {
|
|
||||||
// Don't ACK our own messages
|
|
||||||
guard message.senderPeerID != myPeerID else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only ACK private messages
|
|
||||||
guard message.isPrivate else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't ACK if we've already sent an ACK for this message
|
|
||||||
guard !sentAckIDs.contains(message.id) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
sentAckIDs.insert(message.id)
|
|
||||||
|
|
||||||
|
|
||||||
return DeliveryAck(
|
|
||||||
originalMessageID: message.id,
|
|
||||||
recipientID: myPeerID,
|
|
||||||
recipientNickname: myNickname,
|
|
||||||
hopCount: hopCount
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func clearDeliveryStatus(for messageID: String) {
|
|
||||||
pendingLock.lock()
|
|
||||||
defer { pendingLock.unlock() }
|
|
||||||
|
|
||||||
if let delivery = pendingDeliveries[messageID] {
|
|
||||||
delivery.timeoutTimer?.invalidate()
|
|
||||||
}
|
|
||||||
pendingDeliveries.removeValue(forKey: messageID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Methods
|
|
||||||
|
|
||||||
private func updateDeliveryStatus(_ messageID: String, status: DeliveryStatus) {
|
|
||||||
SecureLogger.log("Updating delivery status for message \(messageID): \(status)", category: SecureLogger.session, level: .debug)
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
self?.deliveryStatusUpdated.send((messageID: messageID, status: status))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func scheduleTimeout(for messageID: String) {
|
|
||||||
// Get delivery info with lock
|
|
||||||
pendingLock.lock()
|
|
||||||
guard let delivery = pendingDeliveries[messageID] else {
|
|
||||||
pendingLock.unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let isFavorite = delivery.isFavorite
|
|
||||||
pendingLock.unlock()
|
|
||||||
|
|
||||||
let timeout = isFavorite ? favoriteTimeout : privateMessageTimeout
|
|
||||||
|
|
||||||
let timer = Timer.scheduledTimer(withTimeInterval: timeout, repeats: false) { [weak self] _ in
|
|
||||||
self?.handleTimeout(messageID: messageID)
|
|
||||||
}
|
|
||||||
|
|
||||||
pendingLock.lock()
|
|
||||||
if var updatedDelivery = pendingDeliveries[messageID] {
|
|
||||||
updatedDelivery.timeoutTimer = timer
|
|
||||||
pendingDeliveries[messageID] = updatedDelivery
|
|
||||||
}
|
|
||||||
pendingLock.unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleTimeout(messageID: String) {
|
|
||||||
pendingLock.lock()
|
|
||||||
guard let delivery = pendingDeliveries[messageID] else {
|
|
||||||
pendingLock.unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let shouldRetry = delivery.shouldRetry
|
|
||||||
|
|
||||||
if shouldRetry {
|
|
||||||
pendingLock.unlock()
|
|
||||||
// Retry for favorites (outside of lock)
|
|
||||||
retryDelivery(messageID: messageID)
|
|
||||||
} else {
|
|
||||||
// Mark as failed
|
|
||||||
let reason = "Message not delivered"
|
|
||||||
pendingDeliveries.removeValue(forKey: messageID)
|
|
||||||
pendingLock.unlock()
|
|
||||||
updateDeliveryStatus(messageID, status: .failed(reason: reason))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func retryDelivery(messageID: String) {
|
|
||||||
pendingLock.lock()
|
|
||||||
guard let delivery = pendingDeliveries[messageID] else {
|
|
||||||
pendingLock.unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Increment retry count
|
|
||||||
let newDelivery = PendingDelivery(
|
|
||||||
messageID: delivery.messageID,
|
|
||||||
sentAt: delivery.sentAt,
|
|
||||||
recipientID: delivery.recipientID,
|
|
||||||
recipientNickname: delivery.recipientNickname,
|
|
||||||
retryCount: delivery.retryCount + 1,
|
|
||||||
isFavorite: delivery.isFavorite,
|
|
||||||
timeoutTimer: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
pendingDeliveries[messageID] = newDelivery
|
|
||||||
let retryCount = delivery.retryCount
|
|
||||||
pendingLock.unlock()
|
|
||||||
|
|
||||||
// Exponential backoff for retry
|
|
||||||
let delay = retryDelay * pow(2, Double(retryCount))
|
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
|
||||||
// Trigger resend through delegate or notification
|
|
||||||
NotificationCenter.default.post(
|
|
||||||
name: Notification.Name("bitchat.retryMessage"),
|
|
||||||
object: nil,
|
|
||||||
userInfo: ["messageID": messageID]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Schedule new timeout
|
|
||||||
self?.scheduleTimeout(for: messageID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startCleanupTimer() {
|
|
||||||
cleanupTimer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in
|
|
||||||
self?.cleanupOldDeliveries()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func cleanupOldDeliveries() {
|
|
||||||
pendingLock.lock()
|
|
||||||
defer { pendingLock.unlock() }
|
|
||||||
|
|
||||||
let now = Date()
|
|
||||||
let maxAge: TimeInterval = 3600 // 1 hour
|
|
||||||
|
|
||||||
// Clean up old pending deliveries
|
|
||||||
pendingDeliveries = pendingDeliveries.filter { (_, delivery) in
|
|
||||||
now.timeIntervalSince(delivery.sentAt) < maxAge
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up old ACK IDs (keep last 1000)
|
|
||||||
if receivedAckIDs.count > 1000 {
|
|
||||||
receivedAckIDs.removeAll()
|
|
||||||
}
|
|
||||||
if sentAckIDs.count > 1000 {
|
|
||||||
sentAckIDs.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
//
|
||||||
|
// DeliveryTrackingService.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// Service for tracking message delivery and read status
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Service that manages delivery status updates for messages
|
||||||
|
/// Prevents status downgrades (e.g., read → delivered) and maintains consistency
|
||||||
|
final class DeliveryTrackingService {
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
|
||||||
|
/// Update delivery status for a message, preventing downgrades
|
||||||
|
/// - Parameters:
|
||||||
|
/// - messageID: The message ID to update
|
||||||
|
/// - status: The new delivery status
|
||||||
|
/// - messages: Array of public messages (inout for mutation)
|
||||||
|
/// - privateChats: Dictionary of private chats (inout for mutation)
|
||||||
|
/// - notifyChange: Closure to trigger UI update
|
||||||
|
func updateStatus(
|
||||||
|
messageID: String,
|
||||||
|
status: DeliveryStatus,
|
||||||
|
messages: inout [BitchatMessage],
|
||||||
|
privateChats: inout [String: [BitchatMessage]],
|
||||||
|
notifyChange: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
// Update in main messages
|
||||||
|
if let index = messages.firstIndex(where: { $0.id == messageID }) {
|
||||||
|
let currentStatus = messages[index].deliveryStatus
|
||||||
|
if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) {
|
||||||
|
messages[index].deliveryStatus = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update in private chats
|
||||||
|
for (peerID, chatMessages) in privateChats {
|
||||||
|
guard let index = chatMessages.firstIndex(where: { $0.id == messageID }) else { continue }
|
||||||
|
|
||||||
|
let currentStatus = chatMessages[index].deliveryStatus
|
||||||
|
guard !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) else { continue }
|
||||||
|
|
||||||
|
// Update delivery status
|
||||||
|
privateChats[peerID]?[index].deliveryStatus = status
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger UI update
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
notifyChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
|
/// Check if we should skip a status update to prevent downgrades
|
||||||
|
private func shouldSkipUpdate(currentStatus: DeliveryStatus?, newStatus: DeliveryStatus) -> Bool {
|
||||||
|
guard let current = currentStatus else { return false }
|
||||||
|
|
||||||
|
// Don't downgrade from read to delivered or sent
|
||||||
|
switch (current, newStatus) {
|
||||||
|
case (.read, .delivered):
|
||||||
|
return true
|
||||||
|
case (.read, .sent):
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
/// Manages persistent favorite relationships between peers
|
/// Manages persistent favorite relationships between peers
|
||||||
@MainActor
|
@MainActor
|
||||||
class FavoritesPersistenceService: ObservableObject {
|
final class FavoritesPersistenceService: ObservableObject {
|
||||||
|
|
||||||
struct FavoriteRelationship: Codable {
|
struct FavoriteRelationship: Codable {
|
||||||
let peerNoisePublicKey: Data
|
let peerNoisePublicKey: Data
|
||||||
@@ -13,12 +14,16 @@ class FavoritesPersistenceService: ObservableObject {
|
|||||||
let theyFavoritedUs: Bool
|
let theyFavoritedUs: Bool
|
||||||
let favoritedAt: Date
|
let favoritedAt: Date
|
||||||
let lastUpdated: Date
|
let lastUpdated: Date
|
||||||
|
// Track what we last sent as OUR npub to this peer, to avoid resending unless it changes
|
||||||
|
// Note: we do not track which npub we last sent to them; sending happens only on favorite toggle
|
||||||
|
|
||||||
var isMutual: Bool {
|
var isMutual: Bool {
|
||||||
isFavorite && theyFavoritedUs
|
isFavorite && theyFavoritedUs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// We intentionally do not track when we last sent our npub; sending happens only on favorite toggle.
|
||||||
|
|
||||||
private static let storageKey = "chat.bitchat.favorites"
|
private static let storageKey = "chat.bitchat.favorites"
|
||||||
private static let keychainService = "chat.bitchat.favorites"
|
private static let keychainService = "chat.bitchat.favorites"
|
||||||
|
|
||||||
@@ -40,15 +45,20 @@ class FavoritesPersistenceService: ObservableObject {
|
|||||||
}
|
}
|
||||||
.assign(to: &$mutualFavorites)
|
.assign(to: &$mutualFavorites)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
// Clean up Combine subscriptions
|
||||||
|
cancellables.removeAll()
|
||||||
|
SecureLogger.debug("FavoritesPersistenceService deinitialized", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
/// Add or update a favorite
|
/// Add or update a favorite
|
||||||
func addFavorite(
|
func addFavorite(
|
||||||
peerNoisePublicKey: Data,
|
peerNoisePublicKey: Data,
|
||||||
peerNostrPublicKey: String? = nil,
|
peerNostrPublicKey: String? = nil,
|
||||||
peerNickname: String
|
peerNickname: String
|
||||||
) {
|
) {
|
||||||
SecureLogger.log("⭐️ Adding favorite: \(peerNickname) (\(peerNoisePublicKey.hexEncodedString()))",
|
SecureLogger.info("⭐️ Adding favorite: \(peerNickname) (\(peerNoisePublicKey.hexEncodedString()))", category: .session)
|
||||||
category: SecureLogger.session, level: .info)
|
|
||||||
|
|
||||||
let existing = favorites[peerNoisePublicKey]
|
let existing = favorites[peerNoisePublicKey]
|
||||||
|
|
||||||
@@ -64,8 +74,7 @@ class FavoritesPersistenceService: ObservableObject {
|
|||||||
|
|
||||||
// Log if this creates a mutual favorite
|
// Log if this creates a mutual favorite
|
||||||
if relationship.isMutual {
|
if relationship.isMutual {
|
||||||
SecureLogger.log("💕 Mutual favorite relationship established with \(peerNickname)!",
|
SecureLogger.info("💕 Mutual favorite relationship established with \(peerNickname)!", category: .session)
|
||||||
category: SecureLogger.session, level: .info)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
favorites[peerNoisePublicKey] = relationship
|
favorites[peerNoisePublicKey] = relationship
|
||||||
@@ -83,8 +92,7 @@ class FavoritesPersistenceService: ObservableObject {
|
|||||||
func removeFavorite(peerNoisePublicKey: Data) {
|
func removeFavorite(peerNoisePublicKey: Data) {
|
||||||
guard let existing = favorites[peerNoisePublicKey] else { return }
|
guard let existing = favorites[peerNoisePublicKey] else { return }
|
||||||
|
|
||||||
SecureLogger.log("⭐️ Removing favorite: \(existing.peerNickname) (\(peerNoisePublicKey.hexEncodedString()))",
|
SecureLogger.info("⭐️ Removing favorite: \(existing.peerNickname) (\(peerNoisePublicKey.hexEncodedString()))", category: .session)
|
||||||
category: SecureLogger.session, level: .info)
|
|
||||||
|
|
||||||
// If they still favorite us, keep the record but mark us as not favoriting
|
// If they still favorite us, keep the record but mark us as not favoriting
|
||||||
if existing.theyFavoritedUs {
|
if existing.theyFavoritedUs {
|
||||||
@@ -125,8 +133,7 @@ class FavoritesPersistenceService: ObservableObject {
|
|||||||
let existing = favorites[peerNoisePublicKey]
|
let existing = favorites[peerNoisePublicKey]
|
||||||
let displayName = peerNickname ?? existing?.peerNickname ?? "Unknown"
|
let displayName = peerNickname ?? existing?.peerNickname ?? "Unknown"
|
||||||
|
|
||||||
SecureLogger.log("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us",
|
SecureLogger.info("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us", category: .session)
|
||||||
category: SecureLogger.session, level: .info)
|
|
||||||
|
|
||||||
let relationship = FavoriteRelationship(
|
let relationship = FavoriteRelationship(
|
||||||
peerNoisePublicKey: peerNoisePublicKey,
|
peerNoisePublicKey: peerNoisePublicKey,
|
||||||
@@ -147,8 +154,7 @@ class FavoritesPersistenceService: ObservableObject {
|
|||||||
|
|
||||||
// Check if this creates a mutual favorite
|
// Check if this creates a mutual favorite
|
||||||
if relationship.isMutual {
|
if relationship.isMutual {
|
||||||
SecureLogger.log("💕 Mutual favorite relationship established with \(displayName)!",
|
SecureLogger.info("💕 Mutual favorite relationship established with \(displayName)!", category: .session)
|
||||||
category: SecureLogger.session, level: .info)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,121 +182,21 @@ class FavoritesPersistenceService: ObservableObject {
|
|||||||
func getFavoriteStatus(for peerNoisePublicKey: Data) -> FavoriteRelationship? {
|
func getFavoriteStatus(for peerNoisePublicKey: Data) -> FavoriteRelationship? {
|
||||||
favorites[peerNoisePublicKey]
|
favorites[peerNoisePublicKey]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update Nostr public key for a peer
|
/// Resolve favorite status by short peer ID (16-hex derived from Noise pubkey)
|
||||||
func updateNostrPublicKey(for peerNoisePublicKey: Data, nostrPubkey: String) {
|
/// Falls back to scanning favorites and matching on derived peer ID.
|
||||||
guard let existing = favorites[peerNoisePublicKey] else { return }
|
func getFavoriteStatus(forPeerID peerID: PeerID) -> FavoriteRelationship? {
|
||||||
|
// Quick sanity: peerID should be 16 hex chars (8 bytes)
|
||||||
let updated = FavoriteRelationship(
|
guard peerID.isShort else { return nil }
|
||||||
peerNoisePublicKey: existing.peerNoisePublicKey,
|
for (pubkey, rel) in favorites where PeerID(publicKey: pubkey) == peerID {
|
||||||
peerNostrPublicKey: nostrPubkey,
|
return rel
|
||||||
peerNickname: existing.peerNickname,
|
|
||||||
isFavorite: existing.isFavorite,
|
|
||||||
theyFavoritedUs: existing.theyFavoritedUs,
|
|
||||||
favoritedAt: existing.favoritedAt,
|
|
||||||
lastUpdated: Date()
|
|
||||||
)
|
|
||||||
|
|
||||||
favorites[peerNoisePublicKey] = updated
|
|
||||||
saveFavorites()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update nickname for an existing favorite
|
|
||||||
func updateNickname(for peerNoisePublicKey: Data, newNickname: String) {
|
|
||||||
guard let existing = favorites[peerNoisePublicKey] else { return }
|
|
||||||
|
|
||||||
// Skip if nickname hasn't changed
|
|
||||||
if existing.peerNickname == newNickname { return }
|
|
||||||
|
|
||||||
// Updating nickname for favorite
|
|
||||||
|
|
||||||
let updated = FavoriteRelationship(
|
|
||||||
peerNoisePublicKey: existing.peerNoisePublicKey,
|
|
||||||
peerNostrPublicKey: existing.peerNostrPublicKey,
|
|
||||||
peerNickname: newNickname,
|
|
||||||
isFavorite: existing.isFavorite,
|
|
||||||
theyFavoritedUs: existing.theyFavoritedUs,
|
|
||||||
favoritedAt: existing.favoritedAt,
|
|
||||||
lastUpdated: Date()
|
|
||||||
)
|
|
||||||
|
|
||||||
favorites[peerNoisePublicKey] = updated
|
|
||||||
saveFavorites()
|
|
||||||
|
|
||||||
// Notify observers
|
|
||||||
NotificationCenter.default.post(
|
|
||||||
name: .favoriteStatusChanged,
|
|
||||||
object: nil,
|
|
||||||
userInfo: ["peerPublicKey": peerNoisePublicKey]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update noise public key when peer reconnects with new ID
|
|
||||||
func updateNoisePublicKey(from oldKey: Data, to newKey: Data, peerNickname: String) {
|
|
||||||
guard let existing = favorites[oldKey] else {
|
|
||||||
SecureLogger.log("⚠️ Cannot update noise key - no favorite found for \(oldKey.hexEncodedString())",
|
|
||||||
category: SecureLogger.session, level: .warning)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
// Check if we already have a favorite with the new key
|
|
||||||
if favorites[newKey] != nil {
|
|
||||||
SecureLogger.log("⚠️ Favorite already exists with new key \(newKey.hexEncodedString()), removing old entry",
|
|
||||||
category: SecureLogger.session, level: .warning)
|
|
||||||
favorites.removeValue(forKey: oldKey)
|
|
||||||
saveFavorites()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Updating noise public key
|
|
||||||
|
|
||||||
// Remove old entry
|
|
||||||
favorites.removeValue(forKey: oldKey)
|
|
||||||
|
|
||||||
// Add with new key
|
|
||||||
let updated = FavoriteRelationship(
|
|
||||||
peerNoisePublicKey: newKey,
|
|
||||||
peerNostrPublicKey: existing.peerNostrPublicKey,
|
|
||||||
peerNickname: peerNickname,
|
|
||||||
isFavorite: existing.isFavorite,
|
|
||||||
theyFavoritedUs: existing.theyFavoritedUs,
|
|
||||||
favoritedAt: existing.favoritedAt,
|
|
||||||
lastUpdated: Date()
|
|
||||||
)
|
|
||||||
|
|
||||||
favorites[newKey] = updated
|
|
||||||
saveFavorites()
|
|
||||||
|
|
||||||
// Notify observers with both old and new keys
|
|
||||||
NotificationCenter.default.post(
|
|
||||||
name: .favoriteStatusChanged,
|
|
||||||
object: nil,
|
|
||||||
userInfo: [
|
|
||||||
"peerPublicKey": newKey,
|
|
||||||
"oldPeerPublicKey": oldKey,
|
|
||||||
"isKeyUpdate": true
|
|
||||||
]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get all favorites (including non-mutual)
|
|
||||||
func getAllFavorites() -> [FavoriteRelationship] {
|
|
||||||
favorites.values.filter { $0.isFavorite }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get only mutual favorites
|
|
||||||
func getMutualFavorites() -> [FavoriteRelationship] {
|
|
||||||
favorites.values.filter { $0.isMutual }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get all favorite relationships (including where they favorited us)
|
|
||||||
func getAllRelationships() -> [FavoriteRelationship] {
|
|
||||||
Array(favorites.values)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear all favorites - used for panic mode
|
/// Clear all favorites - used for panic mode
|
||||||
func clearAllFavorites() {
|
func clearAllFavorites() {
|
||||||
SecureLogger.log("🧹 Clearing all favorites (panic mode)", category: SecureLogger.session, level: .warning)
|
SecureLogger.warning("🧹 Clearing all favorites (panic mode)", category: .session)
|
||||||
|
|
||||||
favorites.removeAll()
|
favorites.removeAll()
|
||||||
saveFavorites()
|
saveFavorites()
|
||||||
@@ -324,7 +230,7 @@ class FavoritesPersistenceService: ObservableObject {
|
|||||||
|
|
||||||
// Successfully saved favorites
|
// Successfully saved favorites
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.log("Failed to save favorites: \(error)", category: SecureLogger.session, level: .error)
|
SecureLogger.error("Failed to save favorites: \(error)", category: .session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,7 +241,6 @@ class FavoritesPersistenceService: ObservableObject {
|
|||||||
key: Self.storageKey,
|
key: Self.storageKey,
|
||||||
service: Self.keychainService
|
service: Self.keychainService
|
||||||
) else {
|
) else {
|
||||||
SecureLogger.log("📭 No existing favorites found in keychain", category: SecureLogger.session, level: .info)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,14 +248,12 @@ class FavoritesPersistenceService: ObservableObject {
|
|||||||
let decoder = JSONDecoder()
|
let decoder = JSONDecoder()
|
||||||
let relationships = try decoder.decode([FavoriteRelationship].self, from: data)
|
let relationships = try decoder.decode([FavoriteRelationship].self, from: data)
|
||||||
|
|
||||||
SecureLogger.log("✅ Loaded \(relationships.count) favorite relationships",
|
SecureLogger.info("✅ Loaded \(relationships.count) favorite relationships", category: .session)
|
||||||
category: SecureLogger.session, level: .info)
|
|
||||||
|
|
||||||
// Log Nostr public key info
|
// Log Nostr public key info
|
||||||
for relationship in relationships {
|
for relationship in relationships {
|
||||||
if relationship.peerNostrPublicKey == nil {
|
if relationship.peerNostrPublicKey == nil {
|
||||||
SecureLogger.log("⚠️ No Nostr public key stored for '\(relationship.peerNickname)'",
|
SecureLogger.warning("⚠️ No Nostr public key stored for '\(relationship.peerNickname)'", category: .session)
|
||||||
category: SecureLogger.session, level: .warning)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,8 +264,7 @@ class FavoritesPersistenceService: ObservableObject {
|
|||||||
for relationship in relationships {
|
for relationship in relationships {
|
||||||
// Check for duplicates by public key (the actual unique identifier)
|
// Check for duplicates by public key (the actual unique identifier)
|
||||||
if let existing = seenPublicKeys[relationship.peerNoisePublicKey] {
|
if let existing = seenPublicKeys[relationship.peerNoisePublicKey] {
|
||||||
SecureLogger.log("⚠️ Duplicate favorite found for public key \(relationship.peerNoisePublicKey.hexEncodedString()) - nicknames: '\(existing.peerNickname)' vs '\(relationship.peerNickname)'",
|
SecureLogger.warning("⚠️ Duplicate favorite found for public key \(relationship.peerNoisePublicKey.hexEncodedString()) - nicknames: '\(existing.peerNickname)' vs '\(relationship.peerNickname)'", category: .session)
|
||||||
category: SecureLogger.session, level: .warning)
|
|
||||||
|
|
||||||
// Keep the most recent or most complete relationship
|
// Keep the most recent or most complete relationship
|
||||||
if relationship.lastUpdated > existing.lastUpdated ||
|
if relationship.lastUpdated > existing.lastUpdated ||
|
||||||
@@ -403,7 +305,7 @@ class FavoritesPersistenceService: ObservableObject {
|
|||||||
// Log loaded relationships
|
// Log loaded relationships
|
||||||
// Loaded relationships successfully
|
// Loaded relationships successfully
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.log("Failed to load favorites: \(error)", category: SecureLogger.session, level: .error)
|
SecureLogger.error("Failed to load favorites: \(error)", category: .session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import CoreLocation
|
||||||
|
|
||||||
|
/// Stores a user-maintained list of bookmarked geohash channels.
|
||||||
|
/// - Persistence: UserDefaults (JSON string array)
|
||||||
|
/// - Semantics: geohashes are normalized to lowercase base32 and de-duplicated
|
||||||
|
final class GeohashBookmarksStore: ObservableObject {
|
||||||
|
static let shared = GeohashBookmarksStore()
|
||||||
|
|
||||||
|
@Published private(set) var bookmarks: [String] = []
|
||||||
|
@Published private(set) var bookmarkNames: [String: String] = [:] // geohash -> friendly name
|
||||||
|
|
||||||
|
private let storeKey = "locationChannel.bookmarks"
|
||||||
|
private let namesStoreKey = "locationChannel.bookmarkNames"
|
||||||
|
private var membership: Set<String> = []
|
||||||
|
private let geocoder = CLGeocoder()
|
||||||
|
private var resolving: Set<String> = []
|
||||||
|
|
||||||
|
private let storage: UserDefaults
|
||||||
|
|
||||||
|
init(storage: UserDefaults = .standard) {
|
||||||
|
self.storage = storage
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
// Cancel any pending geocoding operations
|
||||||
|
geocoder.cancelGeocode()
|
||||||
|
SecureLogger.debug("GeohashBookmarksStore deinitialized", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
func isBookmarked(_ geohash: String) -> Bool {
|
||||||
|
return membership.contains(Self.normalize(geohash))
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggle(_ geohash: String) {
|
||||||
|
let gh = Self.normalize(geohash)
|
||||||
|
if membership.contains(gh) {
|
||||||
|
remove(gh)
|
||||||
|
} else {
|
||||||
|
add(gh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func add(_ geohash: String) {
|
||||||
|
let gh = Self.normalize(geohash)
|
||||||
|
guard !gh.isEmpty else { return }
|
||||||
|
guard !membership.contains(gh) else { return }
|
||||||
|
bookmarks.insert(gh, at: 0)
|
||||||
|
membership.insert(gh)
|
||||||
|
persist()
|
||||||
|
// Resolve and persist a friendly name once when added
|
||||||
|
resolveNameIfNeeded(for: gh)
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove(_ geohash: String) {
|
||||||
|
let gh = Self.normalize(geohash)
|
||||||
|
guard membership.contains(gh) else { return }
|
||||||
|
if let idx = bookmarks.firstIndex(of: gh) { bookmarks.remove(at: idx) }
|
||||||
|
membership.remove(gh)
|
||||||
|
// Clean up stored name to avoid stale cache growth
|
||||||
|
if bookmarkNames.removeValue(forKey: gh) != nil {
|
||||||
|
persistNames()
|
||||||
|
}
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Persistence
|
||||||
|
private func load() {
|
||||||
|
guard let data = storage.data(forKey: storeKey) else { return }
|
||||||
|
if let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||||
|
// Sanitize, normalize, dedupe while preserving order (first occurrence wins)
|
||||||
|
var seen = Set<String>()
|
||||||
|
var list: [String] = []
|
||||||
|
for raw in arr {
|
||||||
|
let gh = Self.normalize(raw)
|
||||||
|
guard !gh.isEmpty else { continue }
|
||||||
|
if !seen.contains(gh) {
|
||||||
|
seen.insert(gh)
|
||||||
|
list.append(gh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bookmarks = list
|
||||||
|
membership = seen
|
||||||
|
}
|
||||||
|
// Load any saved names
|
||||||
|
if let namesData = storage.data(forKey: namesStoreKey),
|
||||||
|
let dict = try? JSONDecoder().decode([String: String].self, from: namesData) {
|
||||||
|
bookmarkNames = dict
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persist() {
|
||||||
|
if let data = try? JSONEncoder().encode(bookmarks) {
|
||||||
|
storage.set(data, forKey: storeKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persistNames() {
|
||||||
|
if let data = try? JSONEncoder().encode(bookmarkNames) {
|
||||||
|
storage.set(data, forKey: namesStoreKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
private static func normalize(_ s: String) -> String {
|
||||||
|
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||||
|
return s
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.lowercased()
|
||||||
|
.replacingOccurrences(of: "#", with: "")
|
||||||
|
.filter { allowed.contains($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Name Resolution
|
||||||
|
/// Attempt to resolve and persist a friendly place name for a bookmarked geohash.
|
||||||
|
func resolveNameIfNeeded(for geohash: String) {
|
||||||
|
let gh = Self.normalize(geohash)
|
||||||
|
guard !gh.isEmpty else { return }
|
||||||
|
if bookmarkNames[gh] != nil { return }
|
||||||
|
if resolving.contains(gh) { return }
|
||||||
|
resolving.insert(gh)
|
||||||
|
// For very coarse geohashes, sample multiple points to capture multiple admin areas
|
||||||
|
if gh.count <= 2 {
|
||||||
|
let b = Geohash.decodeBounds(gh)
|
||||||
|
let pts: [CLLocation] = [
|
||||||
|
CLLocation(latitude: (b.latMin + b.latMax) / 2, longitude: (b.lonMin + b.lonMax) / 2), // center
|
||||||
|
CLLocation(latitude: b.latMin, longitude: b.lonMin),
|
||||||
|
CLLocation(latitude: b.latMin, longitude: b.lonMax),
|
||||||
|
CLLocation(latitude: b.latMax, longitude: b.lonMin),
|
||||||
|
CLLocation(latitude: b.latMax, longitude: b.lonMax)
|
||||||
|
]
|
||||||
|
resolveCompositeAdminName(geohash: gh, points: pts)
|
||||||
|
} else {
|
||||||
|
let center = Geohash.decodeCenter(gh)
|
||||||
|
let loc = CLLocation(latitude: center.lat, longitude: center.lon)
|
||||||
|
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
|
||||||
|
guard let self = self else { return }
|
||||||
|
defer { self.resolving.remove(gh) }
|
||||||
|
if let pm = placemarks?.first {
|
||||||
|
let name = Self.nameForGeohashLength(gh.count, from: pm)
|
||||||
|
if let name = name, !name.isEmpty {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.bookmarkNames[gh] = name
|
||||||
|
self.persistNames()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) {
|
||||||
|
var uniqueAdmins = OrderedSet<String>()
|
||||||
|
var idx = 0
|
||||||
|
func step() {
|
||||||
|
if idx >= points.count {
|
||||||
|
// Compose up to 2 names joined by ' and '
|
||||||
|
let finalName: String? = {
|
||||||
|
let names = uniqueAdmins.array
|
||||||
|
if names.count >= 2 { return names[0] + " and " + names[1] }
|
||||||
|
return names.first
|
||||||
|
}()
|
||||||
|
if let finalName = finalName, !finalName.isEmpty {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.bookmarkNames[gh] = finalName
|
||||||
|
self.persistNames()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.resolving.remove(gh)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let loc = points[idx]
|
||||||
|
idx += 1
|
||||||
|
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
|
||||||
|
guard self != nil else { return }
|
||||||
|
if let pm = placemarks?.first {
|
||||||
|
if let admin = pm.administrativeArea, !admin.isEmpty {
|
||||||
|
uniqueAdmins.insert(admin)
|
||||||
|
} else if let country = pm.country, !country.isEmpty {
|
||||||
|
uniqueAdmins.insert(country)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Proceed to next point
|
||||||
|
step()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
step()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal ordered-set for stable joining
|
||||||
|
private struct OrderedSet<Element: Hashable> {
|
||||||
|
private var set: Set<Element> = []
|
||||||
|
private(set) var array: [Element] = []
|
||||||
|
mutating func insert(_ element: Element) {
|
||||||
|
if set.insert(element).inserted { array.append(element) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func nameForGeohashLength(_ len: Int, from pm: CLPlacemark) -> String? {
|
||||||
|
switch len {
|
||||||
|
case 0...2:
|
||||||
|
// Prefer administrative area if available at this coarse level
|
||||||
|
return pm.administrativeArea ?? pm.country
|
||||||
|
case 3...4:
|
||||||
|
return pm.administrativeArea ?? pm.subAdministrativeArea ?? pm.country
|
||||||
|
case 5:
|
||||||
|
return pm.locality ?? pm.subAdministrativeArea ?? pm.administrativeArea
|
||||||
|
case 6...7:
|
||||||
|
return pm.subLocality ?? pm.locality ?? pm.administrativeArea
|
||||||
|
default:
|
||||||
|
return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
/// Testing-only reset helper
|
||||||
|
func _resetForTesting() {
|
||||||
|
bookmarks.removeAll()
|
||||||
|
membership.removeAll()
|
||||||
|
bookmarkNames.removeAll()
|
||||||
|
persist()
|
||||||
|
persistNames()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
//
|
||||||
|
// GeohashParticipantsService.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// Manages tracking of participants in geohash-based location channels
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// Service for tracking and managing participants in geohash channels
|
||||||
|
/// Handles automatic expiration, refresh timers, and participant list management
|
||||||
|
final class GeohashParticipantsService: ObservableObject {
|
||||||
|
|
||||||
|
// MARK: - Published Properties
|
||||||
|
|
||||||
|
@Published private(set) var geohashPeople: [GeoPerson] = []
|
||||||
|
|
||||||
|
// MARK: - Private State
|
||||||
|
|
||||||
|
private var geoParticipants: [String: [String: Date]] = [:] // geohash -> [pubkeyHex -> lastSeen]
|
||||||
|
private var geoParticipantsTimer: Timer? = nil
|
||||||
|
private var currentGeohash: String? = nil
|
||||||
|
|
||||||
|
// MARK: - Dependencies
|
||||||
|
|
||||||
|
private let identityManager: SecureIdentityStateManagerProtocol
|
||||||
|
private let displayNameProvider: (String) -> String
|
||||||
|
|
||||||
|
// MARK: - Configuration
|
||||||
|
|
||||||
|
private let activityWindowSeconds: TimeInterval
|
||||||
|
private let refreshIntervalSeconds: TimeInterval
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
init(
|
||||||
|
identityManager: SecureIdentityStateManagerProtocol,
|
||||||
|
displayNameProvider: @escaping (String) -> String,
|
||||||
|
activityWindowSeconds: TimeInterval = TransportConfig.uiRecentCutoffFiveMinutesSeconds,
|
||||||
|
refreshIntervalSeconds: TimeInterval = 30.0
|
||||||
|
) {
|
||||||
|
self.identityManager = identityManager
|
||||||
|
self.displayNameProvider = displayNameProvider
|
||||||
|
self.activityWindowSeconds = activityWindowSeconds
|
||||||
|
self.refreshIntervalSeconds = refreshIntervalSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
// Note: deinit cannot call @MainActor methods
|
||||||
|
// Timer cleanup will happen automatically when service is deallocated
|
||||||
|
SecureLogger.debug("GeohashParticipantsService deinitialized", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
|
||||||
|
/// Set the current geohash being tracked (starts/stops timer accordingly)
|
||||||
|
func setCurrentGeohash(_ geohash: String?) {
|
||||||
|
if currentGeohash != geohash {
|
||||||
|
currentGeohash = geohash
|
||||||
|
refreshPeopleList()
|
||||||
|
|
||||||
|
if geohash != nil {
|
||||||
|
startTimer()
|
||||||
|
} else {
|
||||||
|
stopTimer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a participant activity in the current geohash
|
||||||
|
func recordParticipant(pubkeyHex: String) {
|
||||||
|
guard let gh = currentGeohash else { return }
|
||||||
|
recordParticipant(pubkeyHex: pubkeyHex, geohash: gh)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a participant activity in a specific geohash
|
||||||
|
func recordParticipant(pubkeyHex: String, geohash: String) {
|
||||||
|
let key = pubkeyHex.lowercased()
|
||||||
|
var map = geoParticipants[geohash] ?? [:]
|
||||||
|
map[key] = Date()
|
||||||
|
geoParticipants[geohash] = map
|
||||||
|
|
||||||
|
// Only refresh list if this geohash is currently selected
|
||||||
|
if currentGeohash == geohash {
|
||||||
|
refreshPeopleList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get visible people for the current geohash (without mutating state)
|
||||||
|
func visiblePeople() -> [GeoPerson] {
|
||||||
|
guard let gh = currentGeohash else { return [] }
|
||||||
|
return visiblePeople(for: gh)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get visible people for a specific geohash
|
||||||
|
func visiblePeople(for geohash: String) -> [GeoPerson] {
|
||||||
|
let cutoff = Date().addingTimeInterval(-activityWindowSeconds)
|
||||||
|
let map = (geoParticipants[geohash] ?? [:])
|
||||||
|
.filter { $0.value >= cutoff }
|
||||||
|
.filter { !identityManager.isNostrBlocked(pubkeyHexLowercased: $0.key) }
|
||||||
|
let people = map
|
||||||
|
.map { (pub, seen) in
|
||||||
|
GeoPerson(id: pub, displayName: displayNameProvider(pub), lastSeen: seen)
|
||||||
|
}
|
||||||
|
.sorted { $0.lastSeen > $1.lastSeen }
|
||||||
|
return people
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get participant count for a specific geohash (using activity window)
|
||||||
|
func participantCount(for geohash: String) -> Int {
|
||||||
|
let cutoff = Date().addingTimeInterval(-activityWindowSeconds)
|
||||||
|
let map = geoParticipants[geohash] ?? [:]
|
||||||
|
return map.values.filter { $0 >= cutoff }.count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a participant from all geohashes (e.g., when blocked)
|
||||||
|
func removeParticipant(pubkeyHexLowercased: String) {
|
||||||
|
let hex = pubkeyHexLowercased.lowercased()
|
||||||
|
for (gh, var map) in geoParticipants {
|
||||||
|
map.removeValue(forKey: hex)
|
||||||
|
geoParticipants[gh] = map
|
||||||
|
}
|
||||||
|
refreshPeopleList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all participant data (for testing or reset)
|
||||||
|
func reset() {
|
||||||
|
stopTimer()
|
||||||
|
geoParticipants.removeAll()
|
||||||
|
geohashPeople.removeAll()
|
||||||
|
currentGeohash = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
|
private func refreshPeopleList() {
|
||||||
|
guard let gh = currentGeohash else {
|
||||||
|
geohashPeople = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cutoff = Date().addingTimeInterval(-activityWindowSeconds)
|
||||||
|
var map = geoParticipants[gh] ?? [:]
|
||||||
|
|
||||||
|
// Prune expired entries
|
||||||
|
map = map.filter { $0.value >= cutoff }
|
||||||
|
|
||||||
|
// Remove blocked Nostr pubkeys
|
||||||
|
map = map.filter { !identityManager.isNostrBlocked(pubkeyHexLowercased: $0.key) }
|
||||||
|
|
||||||
|
// Update cleaned map
|
||||||
|
geoParticipants[gh] = map
|
||||||
|
|
||||||
|
// Build display list
|
||||||
|
let people = map
|
||||||
|
.map { (pub, seen) in
|
||||||
|
GeoPerson(id: pub, displayName: displayNameProvider(pub), lastSeen: seen)
|
||||||
|
}
|
||||||
|
.sorted { $0.lastSeen > $1.lastSeen }
|
||||||
|
|
||||||
|
geohashPeople = people
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startTimer() {
|
||||||
|
stopTimer()
|
||||||
|
geoParticipantsTimer = Timer.scheduledTimer(withTimeInterval: refreshIntervalSeconds, repeats: true) { [weak self] _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.refreshPeopleList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopTimer() {
|
||||||
|
geoParticipantsTimer?.invalidate()
|
||||||
|
geoParticipantsTimer = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,92 +6,52 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import Security
|
import Security
|
||||||
import os.log
|
|
||||||
|
|
||||||
class KeychainManager {
|
protocol KeychainManagerProtocol {
|
||||||
static let shared = KeychainManager()
|
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool
|
||||||
|
func getIdentityKey(forKey key: String) -> Data?
|
||||||
|
func deleteIdentityKey(forKey key: String) -> Bool
|
||||||
|
func deleteAllKeychainData() -> Bool
|
||||||
|
|
||||||
|
func secureClear(_ data: inout Data)
|
||||||
|
func secureClear(_ string: inout String)
|
||||||
|
|
||||||
|
func verifyIdentityKeyExists() -> Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
final class KeychainManager: KeychainManagerProtocol {
|
||||||
// Use consistent service name for all keychain items
|
// Use consistent service name for all keychain items
|
||||||
private let service = "chat.bitchat"
|
private let service = BitchatApp.bundleID
|
||||||
private let appGroup = "group.chat.bitchat"
|
private let appGroup = "group.\(BitchatApp.bundleID)"
|
||||||
|
|
||||||
private init() {
|
|
||||||
// Clean up legacy keychain items on first run
|
|
||||||
cleanupLegacyKeychainItems()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func cleanupLegacyKeychainItems() {
|
|
||||||
// Check if we've already done cleanup
|
|
||||||
let cleanupKey = "bitchat.keychain.cleanup.v2"
|
|
||||||
if UserDefaults.standard.bool(forKey: cleanupKey) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// List of old service names to migrate from
|
|
||||||
let legacyServices = [
|
|
||||||
"com.bitchat.passwords",
|
|
||||||
"com.bitchat.deviceidentity",
|
|
||||||
"com.bitchat.noise.identity",
|
|
||||||
"chat.bitchat.passwords",
|
|
||||||
"bitchat.keychain"
|
|
||||||
]
|
|
||||||
|
|
||||||
var migratedItems = 0
|
|
||||||
|
|
||||||
// Try to migrate identity keys
|
|
||||||
for oldService in legacyServices {
|
|
||||||
// Check for noise identity key
|
|
||||||
let query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: oldService,
|
|
||||||
kSecAttrAccount as String: "identity_noiseStaticKey",
|
|
||||||
kSecReturnData as String: true
|
|
||||||
]
|
|
||||||
|
|
||||||
var result: AnyObject?
|
|
||||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
||||||
|
|
||||||
if status == errSecSuccess, let data = result as? Data {
|
|
||||||
// Save to new service
|
|
||||||
if saveIdentityKey(data, forKey: "noiseStaticKey") {
|
|
||||||
migratedItems += 1
|
|
||||||
SecureLogger.logKeyOperation("migrate", keyType: "noiseStaticKey", success: true)
|
|
||||||
}
|
|
||||||
// Delete from old service
|
|
||||||
let deleteQuery: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: oldService,
|
|
||||||
kSecAttrAccount as String: "identity_noiseStaticKey"
|
|
||||||
]
|
|
||||||
SecItemDelete(deleteQuery as CFDictionary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up all other legacy items
|
|
||||||
for oldService in legacyServices {
|
|
||||||
let deleteQuery: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: oldService
|
|
||||||
]
|
|
||||||
|
|
||||||
SecItemDelete(deleteQuery as CFDictionary)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Mark cleanup as done
|
|
||||||
UserDefaults.standard.set(true, forKey: cleanupKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private func isSandboxed() -> Bool {
|
private func isSandboxed() -> Bool {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
|
// More robust sandbox detection using multiple methods
|
||||||
|
|
||||||
|
// Method 1: Check environment variable (can be spoofed)
|
||||||
let environment = ProcessInfo.processInfo.environment
|
let environment = ProcessInfo.processInfo.environment
|
||||||
return environment["APP_SANDBOX_CONTAINER_ID"] != nil
|
let hasEnvVar = environment["APP_SANDBOX_CONTAINER_ID"] != nil
|
||||||
|
|
||||||
|
// Method 2: Check if we can access a path outside sandbox
|
||||||
|
let homeDir = FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
let testPath = homeDir.appendingPathComponent("../../../tmp/bitchat_sandbox_test_\(UUID().uuidString)")
|
||||||
|
let canWriteOutsideSandbox = FileManager.default.createFile(atPath: testPath.path, contents: nil, attributes: nil)
|
||||||
|
if canWriteOutsideSandbox {
|
||||||
|
try? FileManager.default.removeItem(at: testPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 3: Check container path
|
||||||
|
let containerPath = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first?.path ?? ""
|
||||||
|
let hasContainerPath = containerPath.contains("/Containers/")
|
||||||
|
|
||||||
|
// If any method indicates sandbox, we consider it sandboxed
|
||||||
|
return hasEnvVar || !canWriteOutsideSandbox || hasContainerPath
|
||||||
#else
|
#else
|
||||||
return false
|
// iOS is always sandboxed
|
||||||
|
return true
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +60,7 @@ class KeychainManager {
|
|||||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
||||||
let fullKey = "identity_\(key)"
|
let fullKey = "identity_\(key)"
|
||||||
let result = saveData(keyData, forKey: fullKey)
|
let result = saveData(keyData, forKey: fullKey)
|
||||||
SecureLogger.logKeyOperation("save", keyType: key, success: result)
|
SecureLogger.logKeyOperation(.save, keyType: key, success: result)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +71,7 @@ class KeychainManager {
|
|||||||
|
|
||||||
func deleteIdentityKey(forKey key: String) -> Bool {
|
func deleteIdentityKey(forKey key: String) -> Bool {
|
||||||
let result = delete(forKey: "identity_\(key)")
|
let result = delete(forKey: "identity_\(key)")
|
||||||
SecureLogger.logKeyOperation("delete", keyType: key, success: result)
|
SecureLogger.logKeyOperation(.delete, keyType: key, success: result)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,39 +86,44 @@ class KeychainManager {
|
|||||||
// Delete any existing item first to ensure clean state
|
// Delete any existing item first to ensure clean state
|
||||||
_ = delete(forKey: key)
|
_ = delete(forKey: key)
|
||||||
|
|
||||||
// Build query with all necessary attributes for sandboxed apps
|
// Build base query
|
||||||
var query: [String: Any] = [
|
var base: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrAccount as String: key,
|
kSecAttrAccount as String: key,
|
||||||
kSecValueData as String: data,
|
kSecValueData as String: data,
|
||||||
kSecAttrService as String: service,
|
kSecAttrService as String: service,
|
||||||
// Important for sandboxed apps: make it accessible when unlocked
|
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked,
|
||||||
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked
|
kSecAttrLabel as String: "bitchat-\(key)"
|
||||||
]
|
]
|
||||||
|
|
||||||
// Add a label for easier debugging
|
|
||||||
query[kSecAttrLabel as String] = "bitchat-\(key)"
|
|
||||||
|
|
||||||
// For sandboxed apps, use the app group for sharing between app instances
|
|
||||||
if isSandboxed() {
|
|
||||||
query[kSecAttrAccessGroup as String] = appGroup
|
|
||||||
}
|
|
||||||
|
|
||||||
// For sandboxed macOS apps, we need to ensure the item is NOT synchronized
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
query[kSecAttrSynchronizable as String] = false
|
base[kSecAttrSynchronizable as String] = false
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
let status = SecItemAdd(query as CFDictionary, nil)
|
// Try with access group where it is expected to work (iOS app builds)
|
||||||
|
var triedWithoutGroup = false
|
||||||
if status == errSecSuccess {
|
func attempt(addAccessGroup: Bool) -> OSStatus {
|
||||||
return true
|
var query = base
|
||||||
} else if status == -34018 {
|
if addAccessGroup { query[kSecAttrAccessGroup as String] = appGroup }
|
||||||
SecureLogger.logError(NSError(domain: "Keychain", code: -34018), context: "Missing keychain entitlement", category: SecureLogger.keychain)
|
return SecItemAdd(query as CFDictionary, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
var status = attempt(addAccessGroup: true)
|
||||||
|
if status == -34018 { // Missing entitlement, retry without access group
|
||||||
|
triedWithoutGroup = true
|
||||||
|
status = attempt(addAccessGroup: false)
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
// On macOS dev/simulator default to no access group to avoid -34018
|
||||||
|
let status = attempt(addAccessGroup: false)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
if status == errSecSuccess { return true }
|
||||||
|
if status == -34018 && !triedWithoutGroup {
|
||||||
|
SecureLogger.error(NSError(domain: "Keychain", code: -34018), context: "Missing keychain entitlement", category: .keychain)
|
||||||
} else if status != errSecDuplicateItem {
|
} else if status != errSecDuplicateItem {
|
||||||
SecureLogger.logError(NSError(domain: "Keychain", code: Int(status)), context: "Error saving to keychain", category: SecureLogger.keychain)
|
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)), context: "Error saving to keychain", category: .keychain)
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,45 +133,56 @@ class KeychainManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func retrieveData(forKey key: String) -> Data? {
|
private func retrieveData(forKey key: String) -> Data? {
|
||||||
var query: [String: Any] = [
|
// Base query
|
||||||
|
let base: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrAccount as String: key,
|
kSecAttrAccount as String: key,
|
||||||
kSecAttrService as String: service,
|
kSecAttrService as String: service,
|
||||||
kSecReturnData as String: true,
|
kSecReturnData as String: true,
|
||||||
kSecMatchLimit as String: kSecMatchLimitOne
|
kSecMatchLimit as String: kSecMatchLimitOne
|
||||||
]
|
]
|
||||||
|
|
||||||
// For sandboxed apps, use the app group
|
|
||||||
if isSandboxed() {
|
|
||||||
query[kSecAttrAccessGroup as String] = appGroup
|
|
||||||
}
|
|
||||||
|
|
||||||
var result: AnyObject?
|
var result: AnyObject?
|
||||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
func attempt(withAccessGroup: Bool) -> OSStatus {
|
||||||
|
var q = base
|
||||||
if status == errSecSuccess {
|
if withAccessGroup { q[kSecAttrAccessGroup as String] = appGroup }
|
||||||
return result as? Data
|
return SecItemCopyMatching(q as CFDictionary, &result)
|
||||||
} else if status == -34018 {
|
}
|
||||||
SecureLogger.logError(NSError(domain: "Keychain", code: -34018), context: "Missing keychain entitlement", category: SecureLogger.keychain)
|
|
||||||
|
#if os(iOS)
|
||||||
|
var status = attempt(withAccessGroup: true)
|
||||||
|
if status == -34018 { status = attempt(withAccessGroup: false) }
|
||||||
|
#else
|
||||||
|
let status = attempt(withAccessGroup: false)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
if status == errSecSuccess { return result as? Data }
|
||||||
|
if status == -34018 {
|
||||||
|
SecureLogger.error(NSError(domain: "Keychain", code: -34018), context: "Missing keychain entitlement", category: .keychain)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func delete(forKey key: String) -> Bool {
|
private func delete(forKey key: String) -> Bool {
|
||||||
// Build basic query
|
// Base delete query
|
||||||
var query: [String: Any] = [
|
let base: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrAccount as String: key,
|
kSecAttrAccount as String: key,
|
||||||
kSecAttrService as String: service
|
kSecAttrService as String: service
|
||||||
]
|
]
|
||||||
|
|
||||||
// For sandboxed apps, use the app group
|
func attempt(withAccessGroup: Bool) -> OSStatus {
|
||||||
if isSandboxed() {
|
var q = base
|
||||||
query[kSecAttrAccessGroup as String] = appGroup
|
if withAccessGroup { q[kSecAttrAccessGroup as String] = appGroup }
|
||||||
|
return SecItemDelete(q as CFDictionary)
|
||||||
}
|
}
|
||||||
|
|
||||||
let status = SecItemDelete(query as CFDictionary)
|
#if os(iOS)
|
||||||
|
var status = attempt(withAccessGroup: true)
|
||||||
|
if status == -34018 { status = attempt(withAccessGroup: false) }
|
||||||
|
#else
|
||||||
|
let status = attempt(withAccessGroup: false)
|
||||||
|
#endif
|
||||||
return status == errSecSuccess || status == errSecItemNotFound
|
return status == errSecSuccess || status == errSecItemNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,15 +202,10 @@ class KeychainManager {
|
|||||||
return status == errSecSuccess || status == errSecItemNotFound
|
return status == errSecSuccess || status == errSecItemNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force cleanup to run again (for development/testing)
|
|
||||||
func resetCleanupFlag() {
|
|
||||||
UserDefaults.standard.removeObject(forKey: "bitchat.keychain.cleanup.v2")
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Delete ALL keychain data for panic mode
|
// Delete ALL keychain data for panic mode
|
||||||
func deleteAllKeychainData() -> Bool {
|
func deleteAllKeychainData() -> Bool {
|
||||||
SecureLogger.log("Panic mode - deleting all keychain data", category: SecureLogger.security, level: .warning)
|
SecureLogger.warning("Panic mode - deleting all keychain data", category: .security)
|
||||||
|
|
||||||
var totalDeleted = 0
|
var totalDeleted = 0
|
||||||
|
|
||||||
@@ -253,10 +224,25 @@ class KeychainManager {
|
|||||||
var shouldDelete = false
|
var shouldDelete = false
|
||||||
let account = item[kSecAttrAccount as String] as? String ?? ""
|
let account = item[kSecAttrAccount as String] as? String ?? ""
|
||||||
let service = item[kSecAttrService as String] as? String ?? ""
|
let service = item[kSecAttrService as String] as? String ?? ""
|
||||||
|
let accessGroup = item[kSecAttrAccessGroup as String] as? String
|
||||||
|
|
||||||
// ONLY delete if service name contains "bitchat"
|
// More precise deletion criteria:
|
||||||
// This is the safest approach - we only touch items we know are ours
|
// 1. Check for our specific app group
|
||||||
if service.lowercased().contains("bitchat") {
|
// 2. OR check for our exact service name
|
||||||
|
// 3. OR check for known legacy service names
|
||||||
|
if accessGroup == appGroup {
|
||||||
|
shouldDelete = true
|
||||||
|
} else if service == self.service {
|
||||||
|
shouldDelete = true
|
||||||
|
} else if [
|
||||||
|
"com.bitchat.passwords",
|
||||||
|
"com.bitchat.deviceidentity",
|
||||||
|
"com.bitchat.noise.identity",
|
||||||
|
"chat.bitchat.passwords",
|
||||||
|
"bitchat.keychain",
|
||||||
|
"bitchat",
|
||||||
|
"com.bitchat"
|
||||||
|
].contains(service) {
|
||||||
shouldDelete = true
|
shouldDelete = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,19 +268,21 @@ class KeychainManager {
|
|||||||
let deleteStatus = SecItemDelete(deleteQuery as CFDictionary)
|
let deleteStatus = SecItemDelete(deleteQuery as CFDictionary)
|
||||||
if deleteStatus == errSecSuccess {
|
if deleteStatus == errSecSuccess {
|
||||||
totalDeleted += 1
|
totalDeleted += 1
|
||||||
SecureLogger.log("Deleted keychain item: \(account) from \(service)", category: SecureLogger.keychain, level: .info)
|
SecureLogger.info("Deleted keychain item: \(account) from \(service)", category: .keychain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also try to delete by known service names (in case we missed any)
|
// Also try to delete by known service names and app group
|
||||||
|
// This catches any items that might have been missed above
|
||||||
let knownServices = [
|
let knownServices = [
|
||||||
"chat.bitchat",
|
self.service, // Current service name
|
||||||
"com.bitchat.passwords",
|
"com.bitchat.passwords",
|
||||||
"com.bitchat.deviceidentity",
|
"com.bitchat.deviceidentity",
|
||||||
"com.bitchat.noise.identity",
|
"com.bitchat.noise.identity",
|
||||||
"chat.bitchat.passwords",
|
"chat.bitchat.passwords",
|
||||||
|
"chat.bitchat.nostr",
|
||||||
"bitchat.keychain",
|
"bitchat.keychain",
|
||||||
"bitchat",
|
"bitchat",
|
||||||
"com.bitchat"
|
"com.bitchat"
|
||||||
@@ -312,87 +300,46 @@ class KeychainManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SecureLogger.log("Panic mode cleanup completed. Total items deleted: \(totalDeleted)", category: SecureLogger.keychain, level: .warning)
|
// Also delete by app group to ensure complete cleanup
|
||||||
|
let groupQuery: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrAccessGroup as String: appGroup
|
||||||
|
]
|
||||||
|
|
||||||
|
let groupStatus = SecItemDelete(groupQuery as CFDictionary)
|
||||||
|
if groupStatus == errSecSuccess {
|
||||||
|
totalDeleted += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
SecureLogger.warning("Panic mode cleanup completed. Total items deleted: \(totalDeleted)", category: .keychain)
|
||||||
|
|
||||||
return totalDeleted > 0
|
return totalDeleted > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Security Utilities
|
||||||
|
|
||||||
|
/// Securely clear sensitive data from memory
|
||||||
|
func secureClear(_ data: inout Data) {
|
||||||
|
_ = data.withUnsafeMutableBytes { bytes in
|
||||||
|
// Use volatile memset to prevent compiler optimization
|
||||||
|
memset_s(bytes.baseAddress, bytes.count, 0, bytes.count)
|
||||||
|
}
|
||||||
|
data = Data() // Clear the data object
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Securely clear sensitive string from memory
|
||||||
|
func secureClear(_ string: inout String) {
|
||||||
|
// Convert to mutable data and clear
|
||||||
|
if var data = string.data(using: .utf8) {
|
||||||
|
secureClear(&data)
|
||||||
|
}
|
||||||
|
string = "" // Clear the string object
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Debug
|
// MARK: - Debug
|
||||||
|
|
||||||
func verifyIdentityKeyExists() -> Bool {
|
func verifyIdentityKeyExists() -> Bool {
|
||||||
let key = "identity_noiseStaticKey"
|
let key = "identity_noiseStaticKey"
|
||||||
return retrieveData(forKey: key) != nil
|
return retrieveData(forKey: key) != nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Aggressive cleanup for legacy items - can be called manually
|
|
||||||
func aggressiveCleanupLegacyItems() -> Int {
|
|
||||||
var deletedCount = 0
|
|
||||||
|
|
||||||
// List of KNOWN bitchat service names from our development history
|
|
||||||
let knownBitchatServices = [
|
|
||||||
"com.bitchat.passwords",
|
|
||||||
"com.bitchat.deviceidentity",
|
|
||||||
"com.bitchat.noise.identity",
|
|
||||||
"chat.bitchat.passwords",
|
|
||||||
"bitchat.keychain",
|
|
||||||
"Bitchat",
|
|
||||||
"BitChat"
|
|
||||||
]
|
|
||||||
|
|
||||||
// First, delete all items from known legacy services
|
|
||||||
for legacyService in knownBitchatServices {
|
|
||||||
let deleteQuery: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: legacyService
|
|
||||||
]
|
|
||||||
|
|
||||||
let status = SecItemDelete(deleteQuery as CFDictionary)
|
|
||||||
if status == errSecSuccess {
|
|
||||||
deletedCount += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now search for items that have our specific account patterns with bitchat service names
|
|
||||||
let searchQuery: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecMatchLimit as String: kSecMatchLimitAll,
|
|
||||||
kSecReturnAttributes as String: true
|
|
||||||
]
|
|
||||||
|
|
||||||
var result: AnyObject?
|
|
||||||
let status = SecItemCopyMatching(searchQuery as CFDictionary, &result)
|
|
||||||
|
|
||||||
if status == errSecSuccess, let items = result as? [[String: Any]] {
|
|
||||||
for item in items {
|
|
||||||
let account = item[kSecAttrAccount as String] as? String ?? ""
|
|
||||||
let service = item[kSecAttrService as String] as? String ?? ""
|
|
||||||
|
|
||||||
// ONLY delete if service name contains "bitchat" somewhere
|
|
||||||
// This ensures we never touch other apps' keychain items
|
|
||||||
var shouldDelete = false
|
|
||||||
|
|
||||||
// Check if service contains "bitchat" (case insensitive) but NOT our current service
|
|
||||||
let serviceLower = service.lowercased()
|
|
||||||
if service != self.service && serviceLower.contains("bitchat") {
|
|
||||||
shouldDelete = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if shouldDelete {
|
|
||||||
// Build precise delete query
|
|
||||||
let deleteQuery: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: service,
|
|
||||||
kSecAttrAccount as String: account
|
|
||||||
]
|
|
||||||
|
|
||||||
let deleteStatus = SecItemDelete(deleteQuery as CFDictionary)
|
|
||||||
if deleteStatus == errSecSuccess {
|
|
||||||
deletedCount += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return deletedCount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
import CoreLocation
|
||||||
|
|
||||||
|
/// Manages location permissions, one-shot location retrieval, and computing geohash channels.
|
||||||
|
/// Not main-actor isolated to satisfy CLLocationManagerDelegate in Swift 6; state updates hop to MainActor.
|
||||||
|
final class LocationChannelManager: NSObject, CLLocationManagerDelegate, ObservableObject {
|
||||||
|
static let shared = LocationChannelManager()
|
||||||
|
|
||||||
|
enum PermissionState: Equatable {
|
||||||
|
case notDetermined
|
||||||
|
case denied
|
||||||
|
case restricted
|
||||||
|
case authorized
|
||||||
|
}
|
||||||
|
|
||||||
|
private let cl = CLLocationManager()
|
||||||
|
private let geocoder = CLGeocoder()
|
||||||
|
private var lastLocation: CLLocation?
|
||||||
|
private var refreshTimer: Timer?
|
||||||
|
private let userDefaultsKey = "locationChannel.selected"
|
||||||
|
private let teleportedStoreKey = "locationChannel.teleportedSet"
|
||||||
|
private var isGeocoding: Bool = false
|
||||||
|
|
||||||
|
// Published state for UI bindings
|
||||||
|
@Published private(set) var permissionState: PermissionState = .notDetermined
|
||||||
|
@Published private(set) var availableChannels: [GeohashChannel] = []
|
||||||
|
@Published private(set) var selectedChannel: ChannelID = .mesh
|
||||||
|
// True when the current location channel was selected via manual teleport
|
||||||
|
@Published var teleported: Bool = false
|
||||||
|
@Published private(set) var locationNames: [GeohashChannelLevel: String] = [:]
|
||||||
|
|
||||||
|
// Persisted set of geohashes that were selected via teleport
|
||||||
|
private var teleportedSet: Set<String> = []
|
||||||
|
|
||||||
|
private override init() {
|
||||||
|
super.init()
|
||||||
|
cl.delegate = self
|
||||||
|
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||||
|
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters // meters; we're not tracking continuously
|
||||||
|
// Load selection
|
||||||
|
if let data = UserDefaults.standard.data(forKey: userDefaultsKey),
|
||||||
|
let channel = try? JSONDecoder().decode(ChannelID.self, from: data) {
|
||||||
|
selectedChannel = channel
|
||||||
|
}
|
||||||
|
// Load persisted teleported set
|
||||||
|
if let data = UserDefaults.standard.data(forKey: teleportedStoreKey),
|
||||||
|
let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||||
|
teleportedSet = Set(arr)
|
||||||
|
}
|
||||||
|
// Do not eagerly mark teleported on startup; wait for location to compute regional set.
|
||||||
|
// This avoids showing teleported for in-region channels during cold start.
|
||||||
|
let status: CLAuthorizationStatus
|
||||||
|
if #available(iOS 14.0, macOS 11.0, *) {
|
||||||
|
status = cl.authorizationStatus
|
||||||
|
} else {
|
||||||
|
status = CLLocationManager.authorizationStatus()
|
||||||
|
}
|
||||||
|
updatePermissionState(from: status)
|
||||||
|
// If we don't have location authorization at startup, fall back to persisted teleport state
|
||||||
|
switch status {
|
||||||
|
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
||||||
|
break // will compute from location
|
||||||
|
default:
|
||||||
|
if case .location(let ch) = selectedChannel {
|
||||||
|
teleported = teleportedSet.contains(ch.geohash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
func enableLocationChannels() {
|
||||||
|
let status: CLAuthorizationStatus
|
||||||
|
if #available(iOS 14.0, macOS 11.0, *) {
|
||||||
|
status = cl.authorizationStatus
|
||||||
|
} else {
|
||||||
|
status = CLLocationManager.authorizationStatus()
|
||||||
|
}
|
||||||
|
switch status {
|
||||||
|
case .notDetermined:
|
||||||
|
cl.requestWhenInUseAuthorization()
|
||||||
|
case .restricted:
|
||||||
|
Task { @MainActor in self.permissionState = .restricted }
|
||||||
|
case .denied:
|
||||||
|
Task { @MainActor in self.permissionState = .denied }
|
||||||
|
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
||||||
|
Task { @MainActor in self.permissionState = .authorized }
|
||||||
|
requestOneShotLocation()
|
||||||
|
@unknown default:
|
||||||
|
Task { @MainActor in self.permissionState = .restricted }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshChannels() {
|
||||||
|
if permissionState == .authorized {
|
||||||
|
requestOneShotLocation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Begin continuous, distance-filtered updates while the channel sheet is visible.
|
||||||
|
/// Uses a 21m filter (configurable) to only refresh on meaningful movement.
|
||||||
|
func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) {
|
||||||
|
guard permissionState == .authorized else { return }
|
||||||
|
// Stop any previous polling timer
|
||||||
|
refreshTimer?.invalidate()
|
||||||
|
refreshTimer = nil
|
||||||
|
// Tighten accuracy and distance filter for live view
|
||||||
|
cl.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
|
||||||
|
cl.distanceFilter = TransportConfig.locationDistanceFilterLiveMeters
|
||||||
|
// Start continuous updates
|
||||||
|
cl.startUpdatingLocation()
|
||||||
|
// Request an immediate fix to populate UI without waiting for movement
|
||||||
|
requestOneShotLocation()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop continuous refreshes when selector UI is dismissed.
|
||||||
|
func endLiveRefresh() {
|
||||||
|
refreshTimer?.invalidate()
|
||||||
|
refreshTimer = nil
|
||||||
|
cl.stopUpdatingLocation()
|
||||||
|
// Restore more relaxed defaults for background/idle state
|
||||||
|
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||||
|
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
|
||||||
|
}
|
||||||
|
|
||||||
|
func select(_ channel: ChannelID) {
|
||||||
|
Task { @MainActor in
|
||||||
|
self.selectedChannel = channel
|
||||||
|
if let data = try? JSONEncoder().encode(channel) {
|
||||||
|
UserDefaults.standard.set(data, forKey: self.userDefaultsKey)
|
||||||
|
}
|
||||||
|
// Update teleported flag based on persisted state for immediate UI behavior
|
||||||
|
switch channel {
|
||||||
|
case .mesh:
|
||||||
|
self.teleported = false
|
||||||
|
case .location(let ch):
|
||||||
|
// If this geohash is in our current regional set, do NOT mark teleported.
|
||||||
|
let inRegional = self.availableChannels.contains { $0.geohash == ch.geohash }
|
||||||
|
if inRegional {
|
||||||
|
self.teleported = false
|
||||||
|
// Clear persisted teleport for this geohash to keep future selections clean
|
||||||
|
if self.teleportedSet.contains(ch.geohash) {
|
||||||
|
self.teleportedSet.remove(ch.geohash)
|
||||||
|
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
|
||||||
|
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fall back to persisted mark (set by deep link or manual teleport)
|
||||||
|
self.teleported = self.teleportedSet.contains(ch.geohash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark or unmark a geohash as teleported in persistence and update current flag if relevant
|
||||||
|
func markTeleported(for geohash: String, _ flag: Bool) {
|
||||||
|
if flag { teleportedSet.insert(geohash) } else { teleportedSet.remove(geohash) }
|
||||||
|
if let data = try? JSONEncoder().encode(Array(teleportedSet)) {
|
||||||
|
UserDefaults.standard.set(data, forKey: teleportedStoreKey)
|
||||||
|
}
|
||||||
|
if case .location(let ch) = selectedChannel, ch.geohash == geohash {
|
||||||
|
Task { @MainActor in self.teleported = flag }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CoreLocation
|
||||||
|
private func requestOneShotLocation() {
|
||||||
|
cl.requestLocation()
|
||||||
|
}
|
||||||
|
|
||||||
|
// iOS < 14
|
||||||
|
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
|
||||||
|
updatePermissionState(from: status)
|
||||||
|
if case .authorized = permissionState {
|
||||||
|
requestOneShotLocation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// iOS 14+ / macOS 11+
|
||||||
|
@available(iOS 14.0, macOS 11.0, *)
|
||||||
|
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||||
|
updatePermissionState(from: manager.authorizationStatus)
|
||||||
|
if case .authorized = permissionState {
|
||||||
|
requestOneShotLocation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||||
|
guard let loc = locations.last else { return }
|
||||||
|
lastLocation = loc
|
||||||
|
computeChannels(from: loc.coordinate)
|
||||||
|
reverseGeocodeIfNeeded(location: loc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||||
|
// Surface as denied/restricted if relevant; otherwise keep previous state
|
||||||
|
SecureLogger.error("LocationChannelManager: location error: \(error.localizedDescription)", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
private func updatePermissionState(from status: CLAuthorizationStatus) {
|
||||||
|
let newState: PermissionState
|
||||||
|
switch status {
|
||||||
|
case .notDetermined: newState = .notDetermined
|
||||||
|
case .restricted: newState = .restricted
|
||||||
|
case .denied: newState = .denied
|
||||||
|
case .authorizedAlways, .authorizedWhenInUse, .authorized: newState = .authorized
|
||||||
|
@unknown default: newState = .restricted
|
||||||
|
}
|
||||||
|
Task { @MainActor in self.permissionState = newState }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func computeChannels(from coord: CLLocationCoordinate2D) {
|
||||||
|
let levels = GeohashChannelLevel.allCases
|
||||||
|
var result: [GeohashChannel] = []
|
||||||
|
for level in levels {
|
||||||
|
let gh = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: level.precision)
|
||||||
|
result.append(GeohashChannel(level: level, geohash: gh))
|
||||||
|
}
|
||||||
|
Task { @MainActor in
|
||||||
|
self.availableChannels = result
|
||||||
|
// Recompute teleported status based on whether the selected geohash is in our regional set
|
||||||
|
switch self.selectedChannel {
|
||||||
|
case .mesh:
|
||||||
|
self.teleported = false
|
||||||
|
case .location(let ch):
|
||||||
|
// Membership check using freshly computed regional channels; avoids precision/rename drift
|
||||||
|
let inRegional = result.contains { $0.geohash == ch.geohash }
|
||||||
|
if inRegional {
|
||||||
|
self.teleported = false
|
||||||
|
// Clear persisted teleport flag if present
|
||||||
|
if self.teleportedSet.contains(ch.geohash) {
|
||||||
|
self.teleportedSet.remove(ch.geohash)
|
||||||
|
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
|
||||||
|
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.teleported = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reverseGeocodeIfNeeded(location: CLLocation) {
|
||||||
|
// Always cancel previous to keep latest fresh while user moves
|
||||||
|
geocoder.cancelGeocode()
|
||||||
|
isGeocoding = true
|
||||||
|
geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, error in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.isGeocoding = false
|
||||||
|
if let pm = placemarks?.first {
|
||||||
|
let names = self.namesByLevel(from: pm)
|
||||||
|
Task { @MainActor in self.locationNames = names }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func namesByLevel(from pm: CLPlacemark) -> [GeohashChannelLevel: String] {
|
||||||
|
var dict: [GeohashChannelLevel: String] = [:]
|
||||||
|
// Region (country)
|
||||||
|
if let country = pm.country, !country.isEmpty {
|
||||||
|
dict[.region] = country
|
||||||
|
}
|
||||||
|
// Province (state/province or county)
|
||||||
|
if let admin = pm.administrativeArea, !admin.isEmpty {
|
||||||
|
dict[.province] = admin
|
||||||
|
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
|
||||||
|
dict[.province] = subAdmin
|
||||||
|
}
|
||||||
|
// City (locality)
|
||||||
|
if let locality = pm.locality, !locality.isEmpty {
|
||||||
|
dict[.city] = locality
|
||||||
|
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
|
||||||
|
dict[.city] = subAdmin
|
||||||
|
} else if let admin = pm.administrativeArea, !admin.isEmpty {
|
||||||
|
dict[.city] = admin
|
||||||
|
}
|
||||||
|
// Neighborhood
|
||||||
|
if let subLocality = pm.subLocality, !subLocality.isEmpty {
|
||||||
|
dict[.neighborhood] = subLocality
|
||||||
|
} else if let locality = pm.locality, !locality.isEmpty {
|
||||||
|
dict[.neighborhood] = locality
|
||||||
|
}
|
||||||
|
// Block: reuse neighborhood/locality granularity
|
||||||
|
if let subLocality = pm.subLocality, !subLocality.isEmpty {
|
||||||
|
dict[.block] = subLocality
|
||||||
|
} else if let locality = pm.locality, !locality.isEmpty {
|
||||||
|
dict[.block] = locality
|
||||||
|
}
|
||||||
|
// Building: prefer place name/street/venue when available
|
||||||
|
if let name = pm.name, !name.isEmpty {
|
||||||
|
dict[.building] = name
|
||||||
|
} else if let thoroughfare = pm.thoroughfare, !thoroughfare.isEmpty {
|
||||||
|
dict[.building] = thoroughfare
|
||||||
|
}
|
||||||
|
return dict
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct LocationNotesCounterDependencies {
|
||||||
|
typealias RelayLookup = @MainActor (_ geohash: String, _ count: Int) -> [String]
|
||||||
|
typealias Subscribe = @MainActor (_ filter: NostrFilter, _ id: String, _ relays: [String], _ handler: @escaping (NostrEvent) -> Void, _ onEOSE: (() -> Void)?) -> Void
|
||||||
|
typealias Unsubscribe = @MainActor (_ id: String) -> Void
|
||||||
|
|
||||||
|
var relayLookup: RelayLookup
|
||||||
|
var subscribe: Subscribe
|
||||||
|
var unsubscribe: Unsubscribe
|
||||||
|
|
||||||
|
static let live = LocationNotesCounterDependencies(
|
||||||
|
relayLookup: { geohash, count in
|
||||||
|
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
|
||||||
|
},
|
||||||
|
subscribe: { filter, id, relays, handler, onEOSE in
|
||||||
|
NostrRelayManager.shared.subscribe(
|
||||||
|
filter: filter,
|
||||||
|
id: id,
|
||||||
|
relayUrls: relays,
|
||||||
|
handler: handler,
|
||||||
|
onEOSE: onEOSE
|
||||||
|
)
|
||||||
|
},
|
||||||
|
unsubscribe: { id in
|
||||||
|
NostrRelayManager.shared.unsubscribe(id: id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lightweight background counter for location notes (kind 1) at building-level geohash (8 chars).
|
||||||
|
@MainActor
|
||||||
|
final class LocationNotesCounter: ObservableObject {
|
||||||
|
static let shared = LocationNotesCounter()
|
||||||
|
|
||||||
|
@Published private(set) var geohash: String? = nil
|
||||||
|
@Published private(set) var count: Int? = 0
|
||||||
|
@Published private(set) var initialLoadComplete: Bool = false
|
||||||
|
@Published private(set) var relayAvailable: Bool = true
|
||||||
|
|
||||||
|
private var subscriptionID: String? = nil
|
||||||
|
private var noteIDs = Set<String>()
|
||||||
|
private let dependencies: LocationNotesCounterDependencies
|
||||||
|
|
||||||
|
private init(dependencies: LocationNotesCounterDependencies = .live) {
|
||||||
|
self.dependencies = dependencies
|
||||||
|
}
|
||||||
|
|
||||||
|
init(testDependencies: LocationNotesCounterDependencies) {
|
||||||
|
self.dependencies = testDependencies
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
// Note: deinit cannot call @MainActor functions
|
||||||
|
// Subscription cleanup will happen automatically when counter is deallocated
|
||||||
|
SecureLogger.debug("LocationNotesCounter deinitialized", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
|
func subscribe(geohash gh: String) {
|
||||||
|
let norm = gh.lowercased()
|
||||||
|
if geohash == norm, subscriptionID != nil { return }
|
||||||
|
// Validate geohash (building-level precision: 8 chars)
|
||||||
|
guard Geohash.isValidBuildingGeohash(norm) else {
|
||||||
|
SecureLogger.warning("LocationNotesCounter: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Unsubscribe previous without clearing count to avoid flicker
|
||||||
|
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
|
||||||
|
subscriptionID = nil
|
||||||
|
geohash = norm
|
||||||
|
noteIDs.removeAll()
|
||||||
|
initialLoadComplete = false
|
||||||
|
relayAvailable = true
|
||||||
|
|
||||||
|
// Subscribe only to the building geohash (precision 8)
|
||||||
|
let subID = "locnotes-count-\(norm)-\(UUID().uuidString.prefix(6))"
|
||||||
|
let relays = dependencies.relayLookup(norm, TransportConfig.nostrGeoRelayCount)
|
||||||
|
guard !relays.isEmpty else {
|
||||||
|
relayAvailable = false
|
||||||
|
initialLoadComplete = true
|
||||||
|
count = 0
|
||||||
|
SecureLogger.warning("LocationNotesCounter: no geo relays for geohash=\(norm)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriptionID = subID
|
||||||
|
let filter = NostrFilter.geohashNotes(norm, since: nil, limit: 200)
|
||||||
|
dependencies.subscribe(filter, subID, relays, { [weak self] event in
|
||||||
|
guard let self = self else { return }
|
||||||
|
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
||||||
|
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == norm }) else { return }
|
||||||
|
if !self.noteIDs.contains(event.id) {
|
||||||
|
self.noteIDs.insert(event.id)
|
||||||
|
self.count = self.noteIDs.count
|
||||||
|
}
|
||||||
|
}, { [weak self] in
|
||||||
|
self?.initialLoadComplete = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() {
|
||||||
|
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
|
||||||
|
subscriptionID = nil
|
||||||
|
geohash = nil
|
||||||
|
count = 0
|
||||||
|
noteIDs.removeAll()
|
||||||
|
relayAvailable = true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Dependencies for location notes, allowing tests to stub relay/identity behavior.
|
||||||
|
struct LocationNotesDependencies {
|
||||||
|
typealias RelayLookup = @MainActor (_ geohash: String, _ count: Int) -> [String]
|
||||||
|
typealias Subscribe = @MainActor (_ filter: NostrFilter, _ id: String, _ relays: [String], _ handler: @escaping (NostrEvent) -> Void, _ onEOSE: (() -> Void)?) -> Void
|
||||||
|
typealias Unsubscribe = @MainActor (_ id: String) -> Void
|
||||||
|
typealias SendEvent = @MainActor (_ event: NostrEvent, _ relayUrls: [String]) -> Void
|
||||||
|
|
||||||
|
var relayLookup: RelayLookup
|
||||||
|
var subscribe: Subscribe
|
||||||
|
var unsubscribe: Unsubscribe
|
||||||
|
var sendEvent: SendEvent
|
||||||
|
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
|
||||||
|
var now: () -> Date
|
||||||
|
|
||||||
|
static let live = LocationNotesDependencies(
|
||||||
|
relayLookup: { geohash, count in
|
||||||
|
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
|
||||||
|
},
|
||||||
|
subscribe: { filter, id, relays, handler, onEOSE in
|
||||||
|
NostrRelayManager.shared.subscribe(
|
||||||
|
filter: filter,
|
||||||
|
id: id,
|
||||||
|
relayUrls: relays,
|
||||||
|
handler: handler,
|
||||||
|
onEOSE: onEOSE
|
||||||
|
)
|
||||||
|
},
|
||||||
|
unsubscribe: { id in
|
||||||
|
NostrRelayManager.shared.unsubscribe(id: id)
|
||||||
|
},
|
||||||
|
sendEvent: { event, relays in
|
||||||
|
NostrRelayManager.shared.sendEvent(event, to: relays)
|
||||||
|
},
|
||||||
|
deriveIdentity: { geohash in
|
||||||
|
try NostrIdentityBridge.deriveIdentity(forGeohash: geohash)
|
||||||
|
},
|
||||||
|
now: { Date() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persistent location notes (Nostr kind 1) scoped to a building-level geohash (precision 8).
|
||||||
|
/// Subscribes to and publishes notes for a given geohash and provides a send API.
|
||||||
|
@MainActor
|
||||||
|
final class LocationNotesManager: ObservableObject {
|
||||||
|
enum State: Equatable {
|
||||||
|
case idle
|
||||||
|
case loading
|
||||||
|
case ready
|
||||||
|
case noRelays
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Note: Identifiable, Equatable {
|
||||||
|
let id: String
|
||||||
|
let pubkey: String
|
||||||
|
let content: String
|
||||||
|
let createdAt: Date
|
||||||
|
let nickname: String?
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
let suffix = String(pubkey.suffix(4))
|
||||||
|
if let nick = nickname, !nick.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
return "\(nick)#\(suffix)"
|
||||||
|
}
|
||||||
|
return "anon#\(suffix)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published private(set) var notes: [Note] = [] // reverse-chron sorted
|
||||||
|
@Published private(set) var geohash: String
|
||||||
|
@Published private(set) var initialLoadComplete: Bool = false
|
||||||
|
@Published private(set) var state: State = .loading
|
||||||
|
@Published private(set) var errorMessage: String?
|
||||||
|
private var subscriptionID: String?
|
||||||
|
private var noteIDs = Set<String>() // O(1) duplicate detection
|
||||||
|
private let dependencies: LocationNotesDependencies
|
||||||
|
private let maxNotesInMemory = 500 // Defensive cap (relay limit is 200)
|
||||||
|
|
||||||
|
private enum Strings {
|
||||||
|
static let noRelays = String(localized: "location_notes.error.no_relays", comment: "Shown when no geo relays are available near the selected location")
|
||||||
|
|
||||||
|
static func failedToSend(_ detail: String) -> String {
|
||||||
|
String(
|
||||||
|
format: String(localized: "location_notes.error.failed_to_send", comment: "Shown when a location note fails to send"),
|
||||||
|
locale: .current,
|
||||||
|
detail
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init(geohash: String, dependencies: LocationNotesDependencies = .live) {
|
||||||
|
let norm = geohash.lowercased()
|
||||||
|
self.geohash = norm
|
||||||
|
self.dependencies = dependencies
|
||||||
|
// Validate geohash (building-level precision: 8 chars)
|
||||||
|
if !Geohash.isValidBuildingGeohash(norm) {
|
||||||
|
SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
|
||||||
|
}
|
||||||
|
subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
// Note: deinit cannot call @MainActor functions
|
||||||
|
// Subscription cleanup will happen automatically when manager is deallocated
|
||||||
|
SecureLogger.debug("LocationNotesManager deinitialized", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setGeohash(_ newGeohash: String) {
|
||||||
|
let norm = newGeohash.lowercased()
|
||||||
|
guard norm != geohash else { return }
|
||||||
|
// Validate geohash (building-level precision: 8 chars)
|
||||||
|
guard Geohash.isValidBuildingGeohash(norm) else {
|
||||||
|
SecureLogger.warning("LocationNotesManager: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let sub = subscriptionID {
|
||||||
|
dependencies.unsubscribe(sub)
|
||||||
|
subscriptionID = nil
|
||||||
|
}
|
||||||
|
// Set loading state before clearing to prevent empty state flicker
|
||||||
|
state = .loading
|
||||||
|
initialLoadComplete = false
|
||||||
|
errorMessage = nil
|
||||||
|
geohash = norm
|
||||||
|
notes.removeAll()
|
||||||
|
noteIDs.removeAll()
|
||||||
|
subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh() {
|
||||||
|
if let sub = subscriptionID {
|
||||||
|
dependencies.unsubscribe(sub)
|
||||||
|
subscriptionID = nil
|
||||||
|
}
|
||||||
|
// Set loading state before clearing to prevent empty state flicker
|
||||||
|
state = .loading
|
||||||
|
initialLoadComplete = false
|
||||||
|
errorMessage = nil
|
||||||
|
notes.removeAll()
|
||||||
|
noteIDs.removeAll()
|
||||||
|
subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearError() {
|
||||||
|
errorMessage = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func subscribe() {
|
||||||
|
state = .loading
|
||||||
|
errorMessage = nil
|
||||||
|
if let sub = subscriptionID {
|
||||||
|
dependencies.unsubscribe(sub)
|
||||||
|
subscriptionID = nil
|
||||||
|
}
|
||||||
|
let subID = "locnotes-\(geohash)-\(UUID().uuidString.prefix(8))"
|
||||||
|
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
||||||
|
guard !relays.isEmpty else {
|
||||||
|
subscriptionID = nil
|
||||||
|
initialLoadComplete = true
|
||||||
|
state = .noRelays
|
||||||
|
errorMessage = Strings.noRelays
|
||||||
|
SecureLogger.warning("LocationNotesManager: no geo relays for geohash=\(geohash)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriptionID = subID
|
||||||
|
initialLoadComplete = false
|
||||||
|
// For persistent notes, allow relays to return recent history without an aggressive time cutoff
|
||||||
|
let filter = NostrFilter.geohashNotes(geohash, since: nil, limit: 200)
|
||||||
|
|
||||||
|
dependencies.subscribe(filter, subID, relays, { [weak self] event in
|
||||||
|
guard let self = self else { return }
|
||||||
|
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
||||||
|
// Ensure matching tag
|
||||||
|
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == self.geohash }) else { return }
|
||||||
|
guard !self.noteIDs.contains(event.id) else { return }
|
||||||
|
self.noteIDs.insert(event.id)
|
||||||
|
let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first
|
||||||
|
let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||||
|
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick)
|
||||||
|
self.notes.append(note)
|
||||||
|
self.notes.sort { $0.createdAt > $1.createdAt }
|
||||||
|
self.enforceMemoryCap()
|
||||||
|
self.state = .ready
|
||||||
|
}, { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.initialLoadComplete = true
|
||||||
|
if self.state != .noRelays {
|
||||||
|
self.state = .ready
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a location note for the current geohash using the per-geohash identity.
|
||||||
|
func send(content: String, nickname: String) {
|
||||||
|
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return }
|
||||||
|
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
||||||
|
guard !relays.isEmpty else {
|
||||||
|
state = .noRelays
|
||||||
|
errorMessage = Strings.noRelays
|
||||||
|
SecureLogger.warning("LocationNotesManager: send blocked, no geo relays for geohash=\(geohash)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let id = try dependencies.deriveIdentity(geohash)
|
||||||
|
let event = try NostrProtocol.createGeohashTextNote(
|
||||||
|
content: trimmed,
|
||||||
|
geohash: geohash,
|
||||||
|
senderIdentity: id,
|
||||||
|
nickname: nickname
|
||||||
|
)
|
||||||
|
dependencies.sendEvent(event, relays)
|
||||||
|
// Optimistic local-echo
|
||||||
|
let echo = Note(
|
||||||
|
id: event.id,
|
||||||
|
pubkey: id.publicKeyHex,
|
||||||
|
content: trimmed,
|
||||||
|
createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)),
|
||||||
|
nickname: nickname
|
||||||
|
)
|
||||||
|
self.noteIDs.insert(event.id)
|
||||||
|
self.notes.insert(echo, at: 0)
|
||||||
|
self.enforceMemoryCap()
|
||||||
|
self.state = .ready
|
||||||
|
self.errorMessage = nil
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("LocationNotesManager: failed to send note: \(error)", category: .session)
|
||||||
|
errorMessage = Strings.failedToSend(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enforces defensive memory cap on notes array (keeps newest).
|
||||||
|
private func enforceMemoryCap() {
|
||||||
|
if notes.count > maxNotesInMemory {
|
||||||
|
let removed = notes.count - maxNotesInMemory
|
||||||
|
notes = Array(notes.prefix(maxNotesInMemory))
|
||||||
|
SecureLogger.debug("LocationNotesManager: trimmed \(removed) old notes (cap: \(maxNotesInMemory))", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicitly cancel subscription and release resources.
|
||||||
|
func cancel() {
|
||||||
|
if let sub = subscriptionID {
|
||||||
|
dependencies.unsubscribe(sub)
|
||||||
|
subscriptionID = nil
|
||||||
|
}
|
||||||
|
state = .idle
|
||||||
|
errorMessage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,618 @@
|
|||||||
|
//
|
||||||
|
// MessageFormattingService.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// Service for formatting chat messages with syntax highlighting
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Service that formats BitchatMessages into styled AttributedStrings
|
||||||
|
/// Handles hashtags, mentions, links, payment tokens, and more
|
||||||
|
final class MessageFormattingService {
|
||||||
|
|
||||||
|
// MARK: - Precompiled Regexes
|
||||||
|
|
||||||
|
private enum Regexes {
|
||||||
|
static let hashtag: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "#([a-zA-Z0-9_]+)", options: [])
|
||||||
|
}()
|
||||||
|
static let mention: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)", options: [])
|
||||||
|
}()
|
||||||
|
static let cashu: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
|
||||||
|
}()
|
||||||
|
static let bolt11: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: [])
|
||||||
|
}()
|
||||||
|
static let lnurl: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: [])
|
||||||
|
}()
|
||||||
|
static let lightningScheme: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: [])
|
||||||
|
}()
|
||||||
|
static let linkDetector: NSDataDetector? = {
|
||||||
|
try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||||
|
}()
|
||||||
|
static let quickCashuPresence: NSRegularExpression = {
|
||||||
|
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Dependencies
|
||||||
|
|
||||||
|
private let colorPalette: ColorPaletteService
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
init(colorPalette: ColorPaletteService) {
|
||||||
|
self.colorPalette = colorPalette
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
|
||||||
|
/// Format a message with full syntax highlighting (hashtags, mentions, links, payments)
|
||||||
|
/// This is the primary formatter used in the main chat view
|
||||||
|
func formatMessageAsText(
|
||||||
|
_ message: BitchatMessage,
|
||||||
|
colorScheme: ColorScheme,
|
||||||
|
nickname: String,
|
||||||
|
myPeerID: String,
|
||||||
|
myNostrPubkey: String?,
|
||||||
|
activeChannel: ChannelID,
|
||||||
|
nostrKeyMapping: [String: String],
|
||||||
|
allPeers: [BitchatPeer],
|
||||||
|
geohashPeople: [GeoPerson],
|
||||||
|
getNoiseKeyForShortID: @escaping (String) -> String?
|
||||||
|
) -> AttributedString {
|
||||||
|
// Determine if this message was sent by self
|
||||||
|
let isSelf = isSelfMessage(
|
||||||
|
message,
|
||||||
|
nickname: nickname,
|
||||||
|
myPeerID: myPeerID,
|
||||||
|
myNostrPubkey: myNostrPubkey,
|
||||||
|
activeChannel: activeChannel
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
let isDark = colorScheme == .dark
|
||||||
|
if let cachedText = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf) {
|
||||||
|
return cachedText
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not cached, format the message
|
||||||
|
var result = AttributedString()
|
||||||
|
|
||||||
|
let baseColor: Color = isSelf ? .orange : colorPalette.peerColor(
|
||||||
|
for: message,
|
||||||
|
isDark: isDark,
|
||||||
|
myPeerID: myPeerID,
|
||||||
|
myNostrPubkey: myNostrPubkey,
|
||||||
|
nostrKeyMapping: nostrKeyMapping,
|
||||||
|
allPeers: allPeers,
|
||||||
|
geohashPeople: geohashPeople.map { (id: $0.id, seed: "nostr:" + $0.id) },
|
||||||
|
getNoiseKeyForShortID: getNoiseKeyForShortID
|
||||||
|
)
|
||||||
|
|
||||||
|
if message.sender != "system" {
|
||||||
|
// Sender (at the beginning) with light-gray suffix styling if present
|
||||||
|
let (baseName, suffix) = message.sender.splitSuffix()
|
||||||
|
var senderStyle = AttributeContainer()
|
||||||
|
senderStyle.foregroundColor = baseColor
|
||||||
|
let fontWeight: Font.Weight = isSelf ? .bold : .medium
|
||||||
|
senderStyle.font = .bitchatSystem(size: 14, weight: fontWeight, design: .monospaced)
|
||||||
|
|
||||||
|
// Make sender clickable: encode senderPeerID into a custom URL
|
||||||
|
if let spid = message.senderPeerID?.id,
|
||||||
|
let url = URL(string: "bitchat://user/\(spid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid)") {
|
||||||
|
senderStyle.link = url
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format: <@name#suffix>
|
||||||
|
result.append(AttributedString("<@").mergingAttributes(senderStyle))
|
||||||
|
result.append(AttributedString(baseName).mergingAttributes(senderStyle))
|
||||||
|
if !suffix.isEmpty {
|
||||||
|
var suffixStyle = senderStyle
|
||||||
|
suffixStyle.foregroundColor = baseColor.opacity(0.6)
|
||||||
|
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
|
||||||
|
}
|
||||||
|
result.append(AttributedString("> ").mergingAttributes(senderStyle))
|
||||||
|
|
||||||
|
// Process content with syntax highlighting
|
||||||
|
let content = message.content
|
||||||
|
let nsContent = content as NSString
|
||||||
|
let nsLen = nsContent.length
|
||||||
|
|
||||||
|
// Check for Cashu presence early to decide rendering strategy
|
||||||
|
let containsCashuEarly = Regexes.quickCashuPresence.numberOfMatches(
|
||||||
|
in: content,
|
||||||
|
options: [],
|
||||||
|
range: NSRange(location: 0, length: nsLen)
|
||||||
|
) > 0
|
||||||
|
|
||||||
|
// For extremely long content, render as plain text (unless has Cashu)
|
||||||
|
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly {
|
||||||
|
var plainStyle = AttributeContainer()
|
||||||
|
plainStyle.foregroundColor = baseColor
|
||||||
|
plainStyle.font = isSelf
|
||||||
|
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||||||
|
: .bitchatSystem(size: 14, design: .monospaced)
|
||||||
|
result.append(AttributedString(content).mergingAttributes(plainStyle))
|
||||||
|
} else {
|
||||||
|
// Full syntax highlighting
|
||||||
|
result.append(formatContent(
|
||||||
|
content,
|
||||||
|
nsContent: nsContent,
|
||||||
|
nsLen: nsLen,
|
||||||
|
message: message,
|
||||||
|
baseColor: baseColor,
|
||||||
|
isSelf: isSelf,
|
||||||
|
isDark: isDark,
|
||||||
|
nickname: nickname,
|
||||||
|
myPeerID: myPeerID,
|
||||||
|
myNostrPubkey: myNostrPubkey,
|
||||||
|
activeChannel: activeChannel
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add timestamp
|
||||||
|
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
|
||||||
|
var timestampStyle = AttributeContainer()
|
||||||
|
timestampStyle.foregroundColor = Color.gray.opacity(0.7)
|
||||||
|
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
|
||||||
|
result.append(timestamp.mergingAttributes(timestampStyle))
|
||||||
|
} else {
|
||||||
|
// System message
|
||||||
|
var contentStyle = AttributeContainer()
|
||||||
|
contentStyle.foregroundColor = Color.gray
|
||||||
|
let content = AttributedString("* \(message.content) *")
|
||||||
|
contentStyle.font = .bitchatSystem(size: 12, design: .monospaced).italic()
|
||||||
|
result.append(content.mergingAttributes(contentStyle))
|
||||||
|
|
||||||
|
// Add timestamp
|
||||||
|
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
|
||||||
|
var timestampStyle = AttributeContainer()
|
||||||
|
timestampStyle.foregroundColor = Color.gray.opacity(0.5)
|
||||||
|
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
|
||||||
|
result.append(timestamp.mergingAttributes(timestampStyle))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the formatted text
|
||||||
|
message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simpler message formatter (used in legacy contexts)
|
||||||
|
func formatMessage(
|
||||||
|
_ message: BitchatMessage,
|
||||||
|
colorScheme: ColorScheme,
|
||||||
|
nickname: String
|
||||||
|
) -> AttributedString {
|
||||||
|
var result = AttributedString()
|
||||||
|
|
||||||
|
let isDark = colorScheme == .dark
|
||||||
|
let primaryColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||||
|
|
||||||
|
if message.sender == "system" {
|
||||||
|
let content = AttributedString("* \(message.content) *")
|
||||||
|
var contentStyle = AttributeContainer()
|
||||||
|
contentStyle.foregroundColor = Color.gray
|
||||||
|
contentStyle.font = .bitchatSystem(size: 12, design: .monospaced).italic()
|
||||||
|
result.append(content.mergingAttributes(contentStyle))
|
||||||
|
|
||||||
|
// Add timestamp
|
||||||
|
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
|
||||||
|
var timestampStyle = AttributeContainer()
|
||||||
|
timestampStyle.foregroundColor = Color.gray.opacity(0.5)
|
||||||
|
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
|
||||||
|
result.append(timestamp.mergingAttributes(timestampStyle))
|
||||||
|
} else {
|
||||||
|
let sender = AttributedString("<@\(message.sender)> ")
|
||||||
|
var senderStyle = AttributeContainer()
|
||||||
|
senderStyle.foregroundColor = primaryColor
|
||||||
|
let fontWeight: Font.Weight = message.sender == nickname ? .bold : .medium
|
||||||
|
senderStyle.font = .bitchatSystem(size: 12, weight: fontWeight, design: .monospaced)
|
||||||
|
result.append(sender.mergingAttributes(senderStyle))
|
||||||
|
|
||||||
|
// Process content to highlight mentions
|
||||||
|
let contentText = message.content
|
||||||
|
let pattern = "@([\\p{L}0-9_]+)"
|
||||||
|
let regex = try? NSRegularExpression(pattern: pattern, options: [])
|
||||||
|
let nsContent = contentText as NSString
|
||||||
|
let nsLen = nsContent.length
|
||||||
|
let matches = regex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
|
||||||
|
|
||||||
|
var processedContent = AttributedString()
|
||||||
|
var lastEndIndex = contentText.startIndex
|
||||||
|
|
||||||
|
for match in matches {
|
||||||
|
if let range = Range(match.range(at: 0), in: contentText) {
|
||||||
|
// Add text before mention
|
||||||
|
if lastEndIndex < range.lowerBound {
|
||||||
|
let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
|
||||||
|
if !beforeText.isEmpty {
|
||||||
|
var normalStyle = AttributeContainer()
|
||||||
|
normalStyle.font = .bitchatSystem(size: 14, design: .monospaced)
|
||||||
|
normalStyle.foregroundColor = isDark ? Color.white : Color.black
|
||||||
|
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the mention with highlight
|
||||||
|
let mentionText = String(contentText[range])
|
||||||
|
var mentionStyle = AttributeContainer()
|
||||||
|
mentionStyle.font = .bitchatSystem(size: 14, weight: .semibold, design: .monospaced)
|
||||||
|
mentionStyle.foregroundColor = Color.orange
|
||||||
|
processedContent.append(AttributedString(mentionText).mergingAttributes(mentionStyle))
|
||||||
|
|
||||||
|
if lastEndIndex < range.upperBound { lastEndIndex = range.upperBound }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add remaining text
|
||||||
|
if lastEndIndex < contentText.endIndex {
|
||||||
|
let remainingText = String(contentText[lastEndIndex...])
|
||||||
|
var normalStyle = AttributeContainer()
|
||||||
|
normalStyle.font = .bitchatSystem(size: 14, design: .monospaced)
|
||||||
|
normalStyle.foregroundColor = isDark ? Color.white : Color.black
|
||||||
|
processedContent.append(AttributedString(remainingText).mergingAttributes(normalStyle))
|
||||||
|
}
|
||||||
|
|
||||||
|
result.append(processedContent)
|
||||||
|
|
||||||
|
if message.isRelay, let originalSender = message.originalSender {
|
||||||
|
let relay = AttributedString(" (via \(originalSender))")
|
||||||
|
var relayStyle = AttributeContainer()
|
||||||
|
relayStyle.foregroundColor = primaryColor.opacity(0.7)
|
||||||
|
relayStyle.font = .bitchatSystem(size: 11, design: .monospaced)
|
||||||
|
result.append(relay.mergingAttributes(relayStyle))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add timestamp
|
||||||
|
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
|
||||||
|
var timestampStyle = AttributeContainer()
|
||||||
|
timestampStyle.foregroundColor = Color.gray.opacity(0.7)
|
||||||
|
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
|
||||||
|
result.append(timestamp.mergingAttributes(timestampStyle))
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
|
private func isSelfMessage(
|
||||||
|
_ message: BitchatMessage,
|
||||||
|
nickname: String,
|
||||||
|
myPeerID: String,
|
||||||
|
myNostrPubkey: String?,
|
||||||
|
activeChannel: ChannelID
|
||||||
|
) -> Bool {
|
||||||
|
if let spid = message.senderPeerID?.id {
|
||||||
|
// In geohash channels, compare against our per-geohash nostr short ID
|
||||||
|
if case .location = activeChannel, spid.hasPrefix("nostr:"),
|
||||||
|
let myGeo = myNostrPubkey {
|
||||||
|
return spid == "nostr:\(myGeo.prefix(TransportConfig.nostrShortKeyDisplayLength))"
|
||||||
|
}
|
||||||
|
return spid == myPeerID
|
||||||
|
}
|
||||||
|
// Fallback by nickname
|
||||||
|
if message.sender == nickname { return true }
|
||||||
|
if message.sender.hasPrefix(nickname + "#") { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatContent(
|
||||||
|
_ content: String,
|
||||||
|
nsContent: NSString,
|
||||||
|
nsLen: Int,
|
||||||
|
message: BitchatMessage,
|
||||||
|
baseColor: Color,
|
||||||
|
isSelf: Bool,
|
||||||
|
isDark: Bool,
|
||||||
|
nickname: String,
|
||||||
|
myPeerID: String,
|
||||||
|
myNostrPubkey: String?,
|
||||||
|
activeChannel: ChannelID
|
||||||
|
) -> AttributedString {
|
||||||
|
// Extract all matches
|
||||||
|
let hasMentionsHint = content.contains("@")
|
||||||
|
let hasHashtagsHint = content.contains("#")
|
||||||
|
let hasURLHint = content.contains("://") || content.contains("www.") || content.contains("http")
|
||||||
|
let hasLightningHint = content.lowercased().contains("ln") || content.lowercased().contains("lightning:")
|
||||||
|
let hasCashuHint = content.lowercased().contains("cashu")
|
||||||
|
|
||||||
|
let hashtagMatches = hasHashtagsHint ? Regexes.hashtag.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
||||||
|
let mentionMatches = hasMentionsHint ? Regexes.mention.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
||||||
|
let urlMatches = hasURLHint ? (Regexes.linkDetector?.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) ?? []) : []
|
||||||
|
let cashuMatches = hasCashuHint ? Regexes.cashu.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
||||||
|
let lightningMatches = hasLightningHint ? Regexes.lightningScheme.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
||||||
|
let bolt11Matches = hasLightningHint ? Regexes.bolt11.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
||||||
|
let lnurlMatches = hasLightningHint ? Regexes.lnurl.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
|
||||||
|
|
||||||
|
// Combine and sort matches, excluding hashtags/URLs overlapping mentions
|
||||||
|
let mentionRanges = mentionMatches.map { $0.range(at: 0) }
|
||||||
|
|
||||||
|
func overlapsMention(_ r: NSRange) -> Bool {
|
||||||
|
for mr in mentionRanges {
|
||||||
|
if NSIntersectionRange(r, mr).length > 0 { return true }
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func attachedToMention(_ r: NSRange) -> Bool {
|
||||||
|
if let nsRange = Range(r, in: content), nsRange.lowerBound > content.startIndex {
|
||||||
|
var i = content.index(before: nsRange.lowerBound)
|
||||||
|
while true {
|
||||||
|
let ch = content[i]
|
||||||
|
if ch.isWhitespace || ch.isNewline { break }
|
||||||
|
if ch == "@" { return true }
|
||||||
|
if i == content.startIndex { break }
|
||||||
|
i = content.index(before: i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isStandaloneHashtag(_ r: NSRange) -> Bool {
|
||||||
|
guard let nsRange = Range(r, in: content) else { return false }
|
||||||
|
if nsRange.lowerBound == content.startIndex { return true }
|
||||||
|
let prev = content.index(before: nsRange.lowerBound)
|
||||||
|
return content[prev].isWhitespace || content[prev].isNewline
|
||||||
|
}
|
||||||
|
|
||||||
|
var allMatches: [(range: NSRange, type: String)] = []
|
||||||
|
for match in hashtagMatches where !overlapsMention(match.range(at: 0)) && !attachedToMention(match.range(at: 0)) && isStandaloneHashtag(match.range(at: 0)) {
|
||||||
|
allMatches.append((match.range(at: 0), "hashtag"))
|
||||||
|
}
|
||||||
|
for match in mentionMatches {
|
||||||
|
allMatches.append((match.range(at: 0), "mention"))
|
||||||
|
}
|
||||||
|
for match in urlMatches where !overlapsMention(match.range) {
|
||||||
|
allMatches.append((match.range, "url"))
|
||||||
|
}
|
||||||
|
for match in cashuMatches where !overlapsMention(match.range(at: 0)) {
|
||||||
|
allMatches.append((match.range(at: 0), "cashu"))
|
||||||
|
}
|
||||||
|
for match in lightningMatches where !overlapsMention(match.range(at: 0)) {
|
||||||
|
allMatches.append((match.range(at: 0), "lightning"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exclude overlaps with lightning/url for bolt11/lnurl
|
||||||
|
let occupied: [NSRange] = urlMatches.map { $0.range } + lightningMatches.map { $0.range(at: 0) }
|
||||||
|
func overlapsOccupied(_ r: NSRange) -> Bool {
|
||||||
|
for or in occupied {
|
||||||
|
if NSIntersectionRange(r, or).length > 0 { return true }
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for match in bolt11Matches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
|
||||||
|
allMatches.append((match.range(at: 0), "bolt11"))
|
||||||
|
}
|
||||||
|
for match in lnurlMatches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
|
||||||
|
allMatches.append((match.range(at: 0), "lnurl"))
|
||||||
|
}
|
||||||
|
allMatches.sort { $0.range.location < $1.range.location }
|
||||||
|
|
||||||
|
// Build content with styling
|
||||||
|
var processedContent = AttributedString()
|
||||||
|
var lastEnd = content.startIndex
|
||||||
|
let isMentioned = message.mentions?.contains(nickname) ?? false
|
||||||
|
|
||||||
|
for (range, type) in allMatches {
|
||||||
|
if let nsRange = Range(range, in: content) {
|
||||||
|
// Add text before match
|
||||||
|
if lastEnd < nsRange.lowerBound {
|
||||||
|
let beforeText = String(content[lastEnd..<nsRange.lowerBound])
|
||||||
|
if !beforeText.isEmpty {
|
||||||
|
var beforeStyle = AttributeContainer()
|
||||||
|
beforeStyle.foregroundColor = baseColor
|
||||||
|
beforeStyle.font = isSelf
|
||||||
|
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||||||
|
: .bitchatSystem(size: 14, design: .monospaced)
|
||||||
|
if isMentioned {
|
||||||
|
beforeStyle.font = beforeStyle.font?.bold()
|
||||||
|
}
|
||||||
|
processedContent.append(AttributedString(beforeText).mergingAttributes(beforeStyle))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add styled match
|
||||||
|
let matchText = String(content[nsRange])
|
||||||
|
processedContent.append(formatMatch(
|
||||||
|
matchText,
|
||||||
|
type: type,
|
||||||
|
baseColor: baseColor,
|
||||||
|
isSelf: isSelf,
|
||||||
|
isDark: isDark,
|
||||||
|
nickname: nickname,
|
||||||
|
myPeerID: myPeerID,
|
||||||
|
myNostrPubkey: myNostrPubkey,
|
||||||
|
activeChannel: activeChannel
|
||||||
|
))
|
||||||
|
|
||||||
|
lastEnd = nsRange.upperBound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add remaining text after last match
|
||||||
|
if lastEnd < content.endIndex {
|
||||||
|
let remainingText = String(content[lastEnd...])
|
||||||
|
var remainingStyle = AttributeContainer()
|
||||||
|
remainingStyle.foregroundColor = baseColor
|
||||||
|
remainingStyle.font = isSelf
|
||||||
|
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||||||
|
: .bitchatSystem(size: 14, design: .monospaced)
|
||||||
|
if isMentioned {
|
||||||
|
remainingStyle.font = remainingStyle.font?.bold()
|
||||||
|
}
|
||||||
|
processedContent.append(AttributedString(remainingText).mergingAttributes(remainingStyle))
|
||||||
|
}
|
||||||
|
|
||||||
|
return processedContent
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatMatch(
|
||||||
|
_ matchText: String,
|
||||||
|
type: String,
|
||||||
|
baseColor: Color,
|
||||||
|
isSelf: Bool,
|
||||||
|
isDark: Bool,
|
||||||
|
nickname: String,
|
||||||
|
myPeerID: String,
|
||||||
|
myNostrPubkey: String?,
|
||||||
|
activeChannel: ChannelID
|
||||||
|
) -> AttributedString {
|
||||||
|
switch type {
|
||||||
|
case "mention":
|
||||||
|
return formatMention(
|
||||||
|
matchText,
|
||||||
|
baseColor: baseColor,
|
||||||
|
isSelf: isSelf,
|
||||||
|
nickname: nickname,
|
||||||
|
myPeerID: myPeerID,
|
||||||
|
myNostrPubkey: myNostrPubkey,
|
||||||
|
activeChannel: activeChannel
|
||||||
|
)
|
||||||
|
case "hashtag":
|
||||||
|
return formatHashtag(matchText, isDark: isDark, baseColor: baseColor, activeChannel: activeChannel)
|
||||||
|
case "url":
|
||||||
|
return formatURL(matchText, baseColor: baseColor, isSelf: isSelf)
|
||||||
|
case "cashu", "bolt11", "lnurl", "lightning":
|
||||||
|
return formatPayment(matchText, type: type, baseColor: baseColor, isSelf: isSelf)
|
||||||
|
default:
|
||||||
|
return AttributedString(matchText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatMention(
|
||||||
|
_ matchText: String,
|
||||||
|
baseColor: Color,
|
||||||
|
isSelf: Bool,
|
||||||
|
nickname: String,
|
||||||
|
myPeerID: String,
|
||||||
|
myNostrPubkey: String?,
|
||||||
|
activeChannel: ChannelID
|
||||||
|
) -> AttributedString {
|
||||||
|
// Split optional '#abcd' suffix and color suffix light grey
|
||||||
|
let (mBase, mSuffix) = matchText.splitSuffix()
|
||||||
|
|
||||||
|
// Determine if this mention targets me
|
||||||
|
let mySuffix: String? = {
|
||||||
|
if case .location = activeChannel, let myGeo = myNostrPubkey {
|
||||||
|
return String(myGeo.suffix(4))
|
||||||
|
}
|
||||||
|
return String(myPeerID.prefix(4))
|
||||||
|
}()
|
||||||
|
|
||||||
|
let isMentionToMe: Bool = {
|
||||||
|
if mBase == nickname {
|
||||||
|
if let suf = mySuffix, !mSuffix.isEmpty {
|
||||||
|
return mSuffix == "#\(suf)"
|
||||||
|
}
|
||||||
|
return mSuffix.isEmpty
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}()
|
||||||
|
|
||||||
|
var mentionStyle = AttributeContainer()
|
||||||
|
mentionStyle.font = .bitchatSystem(size: 14, weight: .semibold, design: .monospaced)
|
||||||
|
mentionStyle.foregroundColor = isMentionToMe ? .orange : baseColor
|
||||||
|
|
||||||
|
var result = AttributedString()
|
||||||
|
result.append(AttributedString(mBase).mergingAttributes(mentionStyle))
|
||||||
|
|
||||||
|
if !mSuffix.isEmpty {
|
||||||
|
var suffixStyle = mentionStyle
|
||||||
|
suffixStyle.foregroundColor = (isMentionToMe ? Color.orange : baseColor).opacity(0.5)
|
||||||
|
result.append(AttributedString(mSuffix).mergingAttributes(suffixStyle))
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatHashtag(
|
||||||
|
_ matchText: String,
|
||||||
|
isDark: Bool,
|
||||||
|
baseColor: Color,
|
||||||
|
activeChannel: ChannelID
|
||||||
|
) -> AttributedString {
|
||||||
|
var hashtagStyle = AttributeContainer()
|
||||||
|
hashtagStyle.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
|
||||||
|
|
||||||
|
// Determine if this hashtag represents the active channel
|
||||||
|
let isActiveChannel: Bool = {
|
||||||
|
if matchText.count > 1 {
|
||||||
|
let tag = String(matchText.dropFirst()) // Remove '#'
|
||||||
|
switch activeChannel {
|
||||||
|
case .mesh:
|
||||||
|
return tag.lowercased() == "mesh"
|
||||||
|
case .location(let ch):
|
||||||
|
return tag.lowercased() == ch.geohash.lowercased()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}()
|
||||||
|
|
||||||
|
if isActiveChannel {
|
||||||
|
// Highlight active channel hashtag in green
|
||||||
|
hashtagStyle.foregroundColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||||
|
hashtagStyle.font = .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||||||
|
} else {
|
||||||
|
// Link to geohash if valid
|
||||||
|
if matchText.count > 1 {
|
||||||
|
let tag = String(matchText.dropFirst())
|
||||||
|
if tag.count >= 2, tag.count <= 12,
|
||||||
|
tag.allSatisfy({ "0123456789bcdefghjkmnpqrstuvwxyz".contains($0) }) {
|
||||||
|
if let url = URL(string: "bitchat://geohash/\(tag)") {
|
||||||
|
hashtagStyle.link = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hashtagStyle.foregroundColor = baseColor.opacity(0.8)
|
||||||
|
}
|
||||||
|
|
||||||
|
return AttributedString(matchText).mergingAttributes(hashtagStyle)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatURL(_ matchText: String, baseColor: Color, isSelf: Bool) -> AttributedString {
|
||||||
|
var urlStyle = AttributeContainer()
|
||||||
|
if let url = URL(string: matchText) {
|
||||||
|
urlStyle.link = url
|
||||||
|
}
|
||||||
|
urlStyle.foregroundColor = baseColor
|
||||||
|
urlStyle.font = isSelf
|
||||||
|
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||||||
|
: .bitchatSystem(size: 14, design: .monospaced)
|
||||||
|
urlStyle.underlineStyle = .single
|
||||||
|
return AttributedString(matchText).mergingAttributes(urlStyle)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatPayment(
|
||||||
|
_ matchText: String,
|
||||||
|
type: String,
|
||||||
|
baseColor: Color,
|
||||||
|
isSelf: Bool
|
||||||
|
) -> AttributedString {
|
||||||
|
var paymentStyle = AttributeContainer()
|
||||||
|
paymentStyle.foregroundColor = baseColor
|
||||||
|
paymentStyle.font = isSelf
|
||||||
|
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||||||
|
: .bitchatSystem(size: 14, design: .monospaced)
|
||||||
|
|
||||||
|
// Make payment tokens tappable
|
||||||
|
if type == "cashu", let url = URL(string: "cashu:\(matchText)") {
|
||||||
|
paymentStyle.link = url
|
||||||
|
} else if type == "lightning" || type == "bolt11" || type == "lnurl" {
|
||||||
|
if let url = URL(string: matchText.lowercased().hasPrefix("lightning:") ? matchText : "lightning:\(matchText)") {
|
||||||
|
paymentStyle.link = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return AttributedString(matchText).mergingAttributes(paymentStyle)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
//
|
|
||||||
// MessageRetryService.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import Combine
|
|
||||||
import CryptoKit
|
|
||||||
|
|
||||||
struct RetryableMessage {
|
|
||||||
let id: String
|
|
||||||
let originalMessageID: String?
|
|
||||||
let originalTimestamp: Date?
|
|
||||||
let content: String
|
|
||||||
let mentions: [String]?
|
|
||||||
let isPrivate: Bool
|
|
||||||
let recipientPeerID: String?
|
|
||||||
let recipientNickname: String?
|
|
||||||
let retryCount: Int
|
|
||||||
let maxRetries: Int = 3
|
|
||||||
let nextRetryTime: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
class MessageRetryService {
|
|
||||||
static let shared = MessageRetryService()
|
|
||||||
|
|
||||||
private var retryQueue: [RetryableMessage] = []
|
|
||||||
private var retryTimer: Timer?
|
|
||||||
private let retryInterval: TimeInterval = 2.0 // Retry every 2 seconds for faster sync
|
|
||||||
private let maxQueueSize = 50
|
|
||||||
|
|
||||||
weak var meshService: BluetoothMeshService?
|
|
||||||
|
|
||||||
private init() {
|
|
||||||
startRetryTimer()
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
retryTimer?.invalidate()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startRetryTimer() {
|
|
||||||
retryTimer = Timer.scheduledTimer(withTimeInterval: retryInterval, repeats: true) { [weak self] _ in
|
|
||||||
self?.processRetryQueue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func addMessageForRetry(
|
|
||||||
content: String,
|
|
||||||
mentions: [String]? = nil,
|
|
||||||
isPrivate: Bool = false,
|
|
||||||
recipientPeerID: String? = nil,
|
|
||||||
recipientNickname: String? = nil,
|
|
||||||
originalMessageID: String? = nil,
|
|
||||||
originalTimestamp: Date? = nil
|
|
||||||
) {
|
|
||||||
// Don't queue empty or whitespace-only messages
|
|
||||||
guard !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't queue if we're at capacity
|
|
||||||
guard retryQueue.count < maxQueueSize else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this message is already in the queue
|
|
||||||
if let messageID = originalMessageID {
|
|
||||||
let alreadyQueued = retryQueue.contains { msg in
|
|
||||||
msg.originalMessageID == messageID
|
|
||||||
}
|
|
||||||
if alreadyQueued {
|
|
||||||
return // Don't add duplicate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let retryMessage = RetryableMessage(
|
|
||||||
id: UUID().uuidString,
|
|
||||||
originalMessageID: originalMessageID,
|
|
||||||
originalTimestamp: originalTimestamp,
|
|
||||||
content: content,
|
|
||||||
mentions: mentions,
|
|
||||||
isPrivate: isPrivate,
|
|
||||||
recipientPeerID: recipientPeerID,
|
|
||||||
recipientNickname: recipientNickname,
|
|
||||||
retryCount: 0,
|
|
||||||
nextRetryTime: Date().addingTimeInterval(retryInterval)
|
|
||||||
)
|
|
||||||
|
|
||||||
retryQueue.append(retryMessage)
|
|
||||||
|
|
||||||
// Sort the queue by original timestamp to maintain message order
|
|
||||||
retryQueue.sort { (msg1, msg2) in
|
|
||||||
let time1 = msg1.originalTimestamp ?? Date.distantPast
|
|
||||||
let time2 = msg2.originalTimestamp ?? Date.distantPast
|
|
||||||
return time1 < time2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func processRetryQueue() {
|
|
||||||
guard meshService != nil else { return }
|
|
||||||
|
|
||||||
let now = Date()
|
|
||||||
var messagesToRetry: [RetryableMessage] = []
|
|
||||||
var updatedQueue: [RetryableMessage] = []
|
|
||||||
|
|
||||||
for message in retryQueue {
|
|
||||||
if message.nextRetryTime <= now {
|
|
||||||
messagesToRetry.append(message)
|
|
||||||
} else {
|
|
||||||
updatedQueue.append(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
retryQueue = updatedQueue
|
|
||||||
|
|
||||||
// Sort messages by original timestamp to maintain order
|
|
||||||
messagesToRetry.sort { (msg1, msg2) in
|
|
||||||
let time1 = msg1.originalTimestamp ?? Date.distantPast
|
|
||||||
let time2 = msg2.originalTimestamp ?? Date.distantPast
|
|
||||||
return time1 < time2
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send messages with delay to maintain order
|
|
||||||
for (index, message) in messagesToRetry.enumerated() {
|
|
||||||
// Check if we should still retry
|
|
||||||
if message.retryCount >= message.maxRetries {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add delay between messages to ensure proper ordering
|
|
||||||
let delay = Double(index) * 0.05 // 50ms between messages
|
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
|
||||||
guard let self = self,
|
|
||||||
let meshService = self.meshService else { return }
|
|
||||||
|
|
||||||
// Check connectivity before retrying
|
|
||||||
let viewModel = meshService.delegate as? ChatViewModel
|
|
||||||
let connectedPeers = viewModel?.connectedPeers ?? []
|
|
||||||
|
|
||||||
if message.isPrivate {
|
|
||||||
// For private messages, check if recipient is connected
|
|
||||||
if let recipientID = message.recipientPeerID,
|
|
||||||
connectedPeers.contains(recipientID) {
|
|
||||||
// Retry private message
|
|
||||||
meshService.sendPrivateMessage(
|
|
||||||
message.content,
|
|
||||||
to: recipientID,
|
|
||||||
recipientNickname: message.recipientNickname ?? "unknown",
|
|
||||||
messageID: message.originalMessageID
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// Recipient not connected, keep in queue with updated retry time
|
|
||||||
var updatedMessage = message
|
|
||||||
updatedMessage = RetryableMessage(
|
|
||||||
id: message.id,
|
|
||||||
originalMessageID: message.originalMessageID,
|
|
||||||
originalTimestamp: message.originalTimestamp,
|
|
||||||
content: message.content,
|
|
||||||
mentions: message.mentions,
|
|
||||||
isPrivate: message.isPrivate,
|
|
||||||
recipientPeerID: message.recipientPeerID,
|
|
||||||
recipientNickname: message.recipientNickname,
|
|
||||||
retryCount: message.retryCount + 1,
|
|
||||||
nextRetryTime: Date().addingTimeInterval(self.retryInterval * Double(message.retryCount + 2))
|
|
||||||
)
|
|
||||||
self.retryQueue.append(updatedMessage)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Regular message
|
|
||||||
if !connectedPeers.isEmpty {
|
|
||||||
meshService.sendMessage(
|
|
||||||
message.content,
|
|
||||||
mentions: message.mentions ?? [],
|
|
||||||
to: nil,
|
|
||||||
messageID: message.originalMessageID,
|
|
||||||
timestamp: message.originalTimestamp
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// No peers connected, keep in queue
|
|
||||||
var updatedMessage = message
|
|
||||||
updatedMessage = RetryableMessage(
|
|
||||||
id: message.id,
|
|
||||||
originalMessageID: message.originalMessageID,
|
|
||||||
originalTimestamp: message.originalTimestamp,
|
|
||||||
content: message.content,
|
|
||||||
mentions: message.mentions,
|
|
||||||
isPrivate: message.isPrivate,
|
|
||||||
recipientPeerID: message.recipientPeerID,
|
|
||||||
recipientNickname: message.recipientNickname,
|
|
||||||
retryCount: message.retryCount + 1,
|
|
||||||
nextRetryTime: Date().addingTimeInterval(self.retryInterval * Double(message.retryCount + 2))
|
|
||||||
)
|
|
||||||
self.retryQueue.append(updatedMessage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func clearRetryQueue() {
|
|
||||||
retryQueue.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
func getRetryQueueCount() -> Int {
|
|
||||||
return retryQueue.count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,671 +1,133 @@
|
|||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
|
||||||
|
|
||||||
/// Routes messages through the appropriate transport (Bluetooth mesh or Nostr)
|
/// Routes messages between BLE and Nostr transports
|
||||||
@MainActor
|
@MainActor
|
||||||
class MessageRouter: ObservableObject {
|
final class MessageRouter {
|
||||||
|
private let mesh: Transport
|
||||||
enum Transport {
|
private let nostr: NostrTransport
|
||||||
case bluetoothMesh
|
private var outbox: [PeerID: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
|
||||||
case nostr
|
|
||||||
}
|
init(mesh: Transport, nostr: NostrTransport) {
|
||||||
|
self.mesh = mesh
|
||||||
enum DeliveryStatus {
|
self.nostr = nostr
|
||||||
case pending
|
self.nostr.senderPeerID = mesh.myPeerID
|
||||||
case sent
|
|
||||||
case delivered
|
// Observe favorites changes to learn Nostr mapping and flush queued messages
|
||||||
case failed(Error)
|
NotificationCenter.default.addObserver(
|
||||||
}
|
forName: .favoriteStatusChanged,
|
||||||
|
object: nil,
|
||||||
struct RoutedMessage {
|
queue: .main
|
||||||
let id: String
|
) { [weak self] note in
|
||||||
let content: String
|
guard let self = self else { return }
|
||||||
let recipientNoisePublicKey: Data
|
if let data = note.userInfo?["peerPublicKey"] as? Data {
|
||||||
let transport: Transport
|
let peerID = PeerID(publicKey: data)
|
||||||
let timestamp: Date
|
Task { @MainActor in
|
||||||
var status: DeliveryStatus
|
self.flushOutbox(for: peerID)
|
||||||
}
|
|
||||||
|
|
||||||
@Published private(set) var pendingMessages: [String: RoutedMessage] = [:]
|
|
||||||
|
|
||||||
private let meshService: BluetoothMeshService
|
|
||||||
private let nostrRelay: NostrRelayManager
|
|
||||||
private let favoritesService: FavoritesPersistenceService
|
|
||||||
private let processedMessagesService = ProcessedMessagesService.shared
|
|
||||||
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
|
||||||
private let messageDeduplication = LRUCache<String, Date>(maxSize: 1000)
|
|
||||||
|
|
||||||
init(
|
|
||||||
meshService: BluetoothMeshService,
|
|
||||||
nostrRelay: NostrRelayManager
|
|
||||||
) {
|
|
||||||
self.meshService = meshService
|
|
||||||
self.nostrRelay = nostrRelay
|
|
||||||
self.favoritesService = FavoritesPersistenceService.shared
|
|
||||||
|
|
||||||
setupBindings()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a message to a peer, automatically selecting the best transport
|
|
||||||
func sendMessage(
|
|
||||||
_ content: String,
|
|
||||||
to recipientNoisePublicKey: Data,
|
|
||||||
preferredTransport: Transport? = nil,
|
|
||||||
messageId: String? = nil
|
|
||||||
) async throws {
|
|
||||||
|
|
||||||
let finalMessageId = messageId ?? UUID().uuidString
|
|
||||||
|
|
||||||
// Check if peer is available on mesh (actually connected, not just known)
|
|
||||||
let recipientHexID = recipientNoisePublicKey.hexEncodedString()
|
|
||||||
let peerAvailableOnMesh = meshService.isPeerConnected(recipientHexID)
|
|
||||||
|
|
||||||
// Check if this is a mutual favorite
|
|
||||||
let isMutualFavorite = favoritesService.isMutualFavorite(recipientNoisePublicKey)
|
|
||||||
|
|
||||||
// Determine transport
|
|
||||||
let transport: Transport
|
|
||||||
if let preferred = preferredTransport {
|
|
||||||
transport = preferred
|
|
||||||
} else if peerAvailableOnMesh {
|
|
||||||
// Always prefer mesh when available
|
|
||||||
transport = .bluetoothMesh
|
|
||||||
} else if isMutualFavorite {
|
|
||||||
// Use Nostr for mutual favorites when not on mesh
|
|
||||||
transport = .nostr
|
|
||||||
} else {
|
|
||||||
throw MessageRouterError.peerNotReachable
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create routed message
|
|
||||||
let routedMessage = RoutedMessage(
|
|
||||||
id: finalMessageId,
|
|
||||||
content: content,
|
|
||||||
recipientNoisePublicKey: recipientNoisePublicKey,
|
|
||||||
transport: transport,
|
|
||||||
timestamp: Date(),
|
|
||||||
status: .pending
|
|
||||||
)
|
|
||||||
|
|
||||||
pendingMessages[finalMessageId] = routedMessage
|
|
||||||
|
|
||||||
// Route based on transport
|
|
||||||
switch transport {
|
|
||||||
case .bluetoothMesh:
|
|
||||||
try await sendViaMesh(routedMessage)
|
|
||||||
|
|
||||||
case .nostr:
|
|
||||||
try await sendViaNostr(routedMessage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a favorite/unfavorite notification
|
|
||||||
func sendFavoriteNotification(
|
|
||||||
to recipientNoisePublicKey: Data,
|
|
||||||
isFavorite: Bool
|
|
||||||
) async throws {
|
|
||||||
|
|
||||||
// messageType is used for logging below
|
|
||||||
// let messageType: MessageType = isFavorite ? .favorited : .unfavorited
|
|
||||||
let recipientHexID = recipientNoisePublicKey.hexEncodedString()
|
|
||||||
let action = isFavorite ? "favorite" : "unfavorite"
|
|
||||||
|
|
||||||
SecureLogger.log("📤 Sending \(action) notification to \(recipientHexID)",
|
|
||||||
category: SecureLogger.session, level: .info)
|
|
||||||
|
|
||||||
// Try mesh first
|
|
||||||
if meshService.getPeerNicknames()[recipientHexID] != nil {
|
|
||||||
SecureLogger.log("📡 Sending \(action) notification via Bluetooth mesh",
|
|
||||||
category: SecureLogger.session, level: .info)
|
|
||||||
|
|
||||||
// Send via mesh as a system message
|
|
||||||
meshService.sendFavoriteNotification(to: recipientHexID, isFavorite: isFavorite)
|
|
||||||
|
|
||||||
} else if let favoriteStatus = favoritesService.getFavoriteStatus(for: recipientNoisePublicKey),
|
|
||||||
let recipientNostrPubkey = favoriteStatus.peerNostrPublicKey {
|
|
||||||
|
|
||||||
SecureLogger.log("🌐 Sending \(action) notification via Nostr to \(favoriteStatus.peerNickname)",
|
|
||||||
category: SecureLogger.session, level: .info)
|
|
||||||
|
|
||||||
// Send via Nostr as a special message
|
|
||||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else {
|
|
||||||
throw MessageRouterError.noNostrIdentity
|
|
||||||
}
|
|
||||||
|
|
||||||
// Include our npub in the content
|
|
||||||
let content = isFavorite ? "FAVORITED:\(senderIdentity.npub)" : "UNFAVORITED:\(senderIdentity.npub)"
|
|
||||||
let event = try NostrProtocol.createPrivateMessage(
|
|
||||||
content: content,
|
|
||||||
recipientPubkey: recipientNostrPubkey,
|
|
||||||
senderIdentity: senderIdentity
|
|
||||||
)
|
|
||||||
|
|
||||||
nostrRelay.sendEvent(event)
|
|
||||||
} else {
|
|
||||||
SecureLogger.log("⚠️ Cannot send \(action) notification - peer not reachable via mesh or Nostr",
|
|
||||||
category: SecureLogger.session, level: .warning)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Methods
|
|
||||||
|
|
||||||
private func sendViaMesh(_ message: RoutedMessage) async throws {
|
|
||||||
// Send the message through mesh - using sendPrivateMessage for now
|
|
||||||
let recipientHexID = message.recipientNoisePublicKey.hexEncodedString()
|
|
||||||
if let recipientNickname = meshService.getPeerNicknames()[recipientHexID] {
|
|
||||||
meshService.sendPrivateMessage(message.content, to: recipientHexID, recipientNickname: recipientNickname, messageID: message.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update status
|
|
||||||
pendingMessages[message.id]?.status = .sent
|
|
||||||
}
|
|
||||||
|
|
||||||
private func sendViaNostr(_ message: RoutedMessage) async throws {
|
|
||||||
// Get recipient's Nostr public key
|
|
||||||
let favoriteStatus = favoritesService.getFavoriteStatus(for: message.recipientNoisePublicKey)
|
|
||||||
|
|
||||||
// Looking up Nostr key for recipient
|
|
||||||
|
|
||||||
if favoriteStatus != nil {
|
|
||||||
// Found favorite relationship
|
|
||||||
} else {
|
|
||||||
SecureLogger.log("❌ No favorite relationship found",
|
|
||||||
category: SecureLogger.session, level: .error)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let favoriteStatus = favoriteStatus,
|
|
||||||
let recipientNostrPubkey = favoriteStatus.peerNostrPublicKey else {
|
|
||||||
throw MessageRouterError.noNostrPublicKey
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get sender's Nostr identity
|
|
||||||
guard let senderIdentity = try NostrIdentityBridge.getCurrentNostrIdentity() else {
|
|
||||||
throw MessageRouterError.noNostrIdentity
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create NIP-17 encrypted message with structured content
|
|
||||||
let structuredContent = "MSG:\(message.id):\(message.content)"
|
|
||||||
let event = try NostrProtocol.createPrivateMessage(
|
|
||||||
content: structuredContent,
|
|
||||||
recipientPubkey: recipientNostrPubkey,
|
|
||||||
senderIdentity: senderIdentity
|
|
||||||
)
|
|
||||||
|
|
||||||
// Created gift wrap event
|
|
||||||
|
|
||||||
// Send via relay
|
|
||||||
nostrRelay.sendEvent(event)
|
|
||||||
|
|
||||||
// Update status
|
|
||||||
pendingMessages[message.id]?.status = .sent
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupBindings() {
|
|
||||||
// Monitor Nostr messages
|
|
||||||
setupNostrMessageHandling()
|
|
||||||
|
|
||||||
// Clean up old pending messages periodically
|
|
||||||
Timer.publish(every: 60, on: .main, in: .common)
|
|
||||||
.autoconnect()
|
|
||||||
.sink { [weak self] _ in
|
|
||||||
self?.cleanupOldMessages()
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
// Listen for app becoming active to check for messages
|
|
||||||
NotificationCenter.default.publisher(for: .appDidBecomeActive)
|
|
||||||
.sink { [weak self] _ in
|
|
||||||
self?.checkForNostrMessages()
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupNostrMessageHandling() {
|
|
||||||
guard let currentIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else {
|
|
||||||
SecureLogger.log("⚠️ No Nostr identity available for initial setup", category: SecureLogger.session, level: .warning)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
SecureLogger.log("🚀 Setting up Nostr message handling for \(currentIdentity.npub)",
|
|
||||||
category: SecureLogger.session, level: .info)
|
|
||||||
|
|
||||||
// Connect to relays if not already connected
|
|
||||||
if !nostrRelay.isConnected {
|
|
||||||
nostrRelay.connect()
|
|
||||||
|
|
||||||
// Wait for connections to establish before subscribing
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
|
|
||||||
self?.subscribeToNostrMessages()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Already connected, subscribe immediately
|
|
||||||
subscribeToNostrMessages()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check for Nostr messages when app becomes active
|
|
||||||
func checkForNostrMessages() {
|
|
||||||
// Checking for Nostr messages
|
|
||||||
|
|
||||||
guard (try? NostrIdentityBridge.getCurrentNostrIdentity()) != nil else {
|
|
||||||
SecureLogger.log("⚠️ No Nostr identity available for message check", category: SecureLogger.session, level: .warning)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure we're connected to relays first
|
|
||||||
if !nostrRelay.isConnected {
|
|
||||||
// Connecting to Nostr relays
|
|
||||||
nostrRelay.connect()
|
|
||||||
|
|
||||||
// Wait a bit for connections to establish before subscribing
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
|
||||||
self?.subscribeToNostrMessages()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Already connected, subscribe immediately
|
|
||||||
subscribeToNostrMessages()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func subscribeToNostrMessages() {
|
|
||||||
guard let currentIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
|
||||||
|
|
||||||
// Subscribing to Nostr messages
|
|
||||||
// Full pubkey recorded
|
|
||||||
// Pubkey length verified
|
|
||||||
|
|
||||||
// Unsubscribe existing subscription to refresh
|
|
||||||
nostrRelay.unsubscribe(id: "router-messages")
|
|
||||||
|
|
||||||
// Create a new subscription for recent messages
|
|
||||||
let sinceDate = processedMessagesService.getSubscriptionSinceDate()
|
|
||||||
let filter = NostrFilter.giftWrapsFor(
|
|
||||||
pubkey: currentIdentity.publicKeyHex,
|
|
||||||
since: sinceDate
|
|
||||||
)
|
|
||||||
|
|
||||||
// Subscribing to messages since date
|
|
||||||
|
|
||||||
// Subscribing to gift wraps
|
|
||||||
|
|
||||||
nostrRelay.subscribe(filter: filter, id: "router-messages") { [weak self] event in
|
|
||||||
// Received Nostr event
|
|
||||||
self?.handleNostrMessage(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleNostrMessage(_ giftWrap: NostrEvent) {
|
|
||||||
// Check if we've already processed this event
|
|
||||||
if processedMessagesService.isMessageProcessed(giftWrap.id) {
|
|
||||||
// Skipping already processed event
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attempting to decrypt gift wrap
|
|
||||||
// Full event ID recorded
|
|
||||||
// Event timestamp recorded
|
|
||||||
// Event tags recorded
|
|
||||||
|
|
||||||
// Decrypt the message
|
|
||||||
guard let currentIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else {
|
|
||||||
SecureLogger.log("❌ No current Nostr identity available",
|
|
||||||
category: SecureLogger.session, level: .error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this event is actually tagged for us
|
|
||||||
let ourPubkey = currentIdentity.publicKeyHex
|
|
||||||
let isTaggedForUs = giftWrap.tags.contains { tag in
|
|
||||||
tag.count >= 2 && tag[0] == "p" && tag[1] == ourPubkey
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isTaggedForUs {
|
|
||||||
SecureLogger.log("⚠️ Gift wrap not tagged for us! Our pubkey: \(ourPubkey.prefix(8))..., Event tags: \(giftWrap.tags)",
|
|
||||||
category: SecureLogger.session, level: .warning)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
let (content, senderPubkey) = try NostrProtocol.decryptPrivateMessage(
|
|
||||||
giftWrap: giftWrap,
|
|
||||||
recipientIdentity: currentIdentity
|
|
||||||
)
|
|
||||||
|
|
||||||
SecureLogger.log("✅ Successfully decrypted message from \(senderPubkey.prefix(8))...: \(content)",
|
|
||||||
category: SecureLogger.session, level: .info)
|
|
||||||
|
|
||||||
// Mark this event as processed to avoid duplicates on app restart
|
|
||||||
let eventTimestamp = Date(timeIntervalSince1970: TimeInterval(giftWrap.created_at))
|
|
||||||
processedMessagesService.markMessageAsProcessed(giftWrap.id, timestamp: eventTimestamp)
|
|
||||||
|
|
||||||
// Check for deduplication within current session
|
|
||||||
let messageHash = "\(senderPubkey)-\(content)-\(giftWrap.created_at)"
|
|
||||||
if messageDeduplication.get(messageHash) != nil {
|
|
||||||
return // Already processed in this session
|
|
||||||
}
|
|
||||||
messageDeduplication.set(messageHash, value: Date())
|
|
||||||
|
|
||||||
// Handle special messages
|
|
||||||
if content.hasPrefix("FAVORITED") || content.hasPrefix("UNFAVORITED") {
|
|
||||||
let parts = content.split(separator: ":")
|
|
||||||
let isFavorite = parts.first == "FAVORITED"
|
|
||||||
let nostrNpub = parts.count > 1 ? String(parts[1]) : nil
|
|
||||||
handleFavoriteNotification(from: senderPubkey, isFavorite: isFavorite, nostrNpub: nostrNpub)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle delivery acknowledgments
|
|
||||||
if content.hasPrefix("DELIVERED:") {
|
|
||||||
let parts = content.split(separator: ":")
|
|
||||||
if parts.count > 1 {
|
|
||||||
let messageId = String(parts[1])
|
|
||||||
handleDeliveryAcknowledgment(messageId: messageId, from: senderPubkey)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle read receipts
|
|
||||||
if content.hasPrefix("READ:") {
|
|
||||||
let parts = content.split(separator: ":", maxSplits: 1)
|
|
||||||
if parts.count > 1 {
|
|
||||||
let receiptDataString = String(parts[1])
|
|
||||||
if let receiptData = Data(base64Encoded: receiptDataString),
|
|
||||||
let receipt = ReadReceipt.fromBinaryData(receiptData) {
|
|
||||||
handleReadReceipt(receipt, from: senderPubkey)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
// Handle key updates
|
||||||
}
|
if let newKey = note.userInfo?["peerPublicKey"] as? Data,
|
||||||
|
let _ = note.userInfo?["isKeyUpdate"] as? Bool {
|
||||||
// Find the sender's Noise public key
|
let peerID = PeerID(publicKey: newKey)
|
||||||
guard let senderNoiseKey = findNoisePublicKey(for: senderPubkey) else { return }
|
Task { @MainActor in
|
||||||
|
self.flushOutbox(for: peerID)
|
||||||
// Parse structured message content
|
|
||||||
var messageId = UUID().uuidString
|
|
||||||
var messageContent = content
|
|
||||||
|
|
||||||
if content.hasPrefix("MSG:") {
|
|
||||||
let parts = content.split(separator: ":", maxSplits: 2)
|
|
||||||
if parts.count >= 3 {
|
|
||||||
messageId = String(parts[1])
|
|
||||||
messageContent = String(parts[2])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a BitchatMessage and inject into the stream
|
|
||||||
let chatMessage = BitchatMessage(
|
|
||||||
id: messageId,
|
|
||||||
sender: favoritesService.getFavoriteStatus(for: senderNoiseKey)?.peerNickname ?? "Unknown",
|
|
||||||
content: messageContent,
|
|
||||||
timestamp: Date(timeIntervalSince1970: TimeInterval(giftWrap.created_at)),
|
|
||||||
isRelay: false,
|
|
||||||
originalSender: nil,
|
|
||||||
isPrivate: true,
|
|
||||||
recipientNickname: nil,
|
|
||||||
senderPeerID: senderNoiseKey.hexEncodedString(),
|
|
||||||
mentions: nil,
|
|
||||||
deliveryStatus: .delivered(to: "nostr", at: Date())
|
|
||||||
)
|
|
||||||
|
|
||||||
// Post notification for ChatViewModel to handle
|
|
||||||
NotificationCenter.default.post(
|
|
||||||
name: .nostrMessageReceived,
|
|
||||||
object: nil,
|
|
||||||
userInfo: ["message": chatMessage]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Send delivery acknowledgment back to sender
|
|
||||||
sendDeliveryAcknowledgment(for: chatMessage.id, to: senderPubkey)
|
|
||||||
|
|
||||||
} catch {
|
|
||||||
SecureLogger.log("❌ Failed to decrypt gift wrap: \(error)",
|
|
||||||
category: SecureLogger.session, level: .error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleFavoriteNotification(from nostrPubkey: String, isFavorite: Bool, nostrNpub: String? = nil) {
|
|
||||||
// Find the sender's Noise public key
|
|
||||||
guard let senderNoiseKey = findNoisePublicKey(for: nostrPubkey) else { return }
|
|
||||||
|
|
||||||
// Update favorites service - nostrPubkey is already the hex public key
|
|
||||||
favoritesService.updatePeerFavoritedUs(
|
|
||||||
peerNoisePublicKey: senderNoiseKey,
|
|
||||||
favorited: isFavorite,
|
|
||||||
peerNostrPublicKey: nostrPubkey
|
|
||||||
)
|
|
||||||
|
|
||||||
// Post notification for UI update
|
|
||||||
NotificationCenter.default.post(
|
|
||||||
name: .favoriteStatusChanged,
|
|
||||||
object: nil,
|
|
||||||
userInfo: [
|
|
||||||
"peerPublicKey": senderNoiseKey,
|
|
||||||
"isFavorite": isFavorite
|
|
||||||
]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func findNoisePublicKey(for nostrPubkey: String) -> Data? {
|
|
||||||
// Search through favorites for matching Nostr pubkey
|
|
||||||
for (noiseKey, relationship) in favoritesService.favorites {
|
|
||||||
if relationship.peerNostrPublicKey == nostrPubkey {
|
|
||||||
return noiseKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleDeliveryAcknowledgment(messageId: String, from senderPubkey: String) {
|
|
||||||
SecureLogger.log("✅ Received delivery acknowledgment for message \(messageId) from \(senderPubkey)",
|
|
||||||
category: SecureLogger.session, level: .info)
|
|
||||||
|
|
||||||
// Find the sender's Noise public key
|
|
||||||
guard let senderNoiseKey = findNoisePublicKey(for: senderPubkey) else { return }
|
|
||||||
|
|
||||||
// Post notification for ChatViewModel to update delivery status
|
|
||||||
NotificationCenter.default.post(
|
|
||||||
name: .messageDeliveryAcknowledged,
|
|
||||||
object: nil,
|
|
||||||
userInfo: [
|
|
||||||
"messageId": messageId,
|
|
||||||
"senderNoiseKey": senderNoiseKey
|
|
||||||
]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleReadReceipt(_ receipt: ReadReceipt, from senderPubkey: String) {
|
|
||||||
SecureLogger.log("📖 Received read receipt for message \(receipt.originalMessageID) from \(senderPubkey)",
|
|
||||||
category: SecureLogger.session, level: .info)
|
|
||||||
|
|
||||||
// Find the sender's Noise public key
|
|
||||||
guard let senderNoiseKey = findNoisePublicKey(for: senderPubkey) else { return }
|
|
||||||
let senderHexID = senderNoiseKey.hexEncodedString()
|
|
||||||
|
|
||||||
// Update the receipt with the correct sender ID
|
|
||||||
var updatedReceipt = receipt
|
|
||||||
updatedReceipt.readerID = senderHexID
|
|
||||||
|
|
||||||
// Post notification for ChatViewModel to process
|
|
||||||
NotificationCenter.default.post(
|
|
||||||
name: .readReceiptReceived,
|
|
||||||
object: nil,
|
|
||||||
userInfo: ["receipt": updatedReceipt]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendReadReceipt(
|
|
||||||
for originalMessageID: String,
|
|
||||||
to recipientNoisePublicKey: Data,
|
|
||||||
preferredTransport: Transport? = nil
|
|
||||||
) async throws {
|
|
||||||
SecureLogger.log("📖 Sending read receipt for message \(originalMessageID)",
|
|
||||||
category: SecureLogger.session, level: .info)
|
|
||||||
|
|
||||||
// Get nickname from delegate or use default
|
|
||||||
let nickname = (meshService.delegate as? ChatViewModel)?.nickname ?? "Anonymous"
|
|
||||||
|
|
||||||
// Create read receipt
|
|
||||||
let receipt = ReadReceipt(
|
|
||||||
originalMessageID: originalMessageID,
|
|
||||||
readerID: meshService.myPeerID,
|
|
||||||
readerNickname: nickname
|
|
||||||
)
|
|
||||||
|
|
||||||
// Encode receipt
|
|
||||||
let receiptData = receipt.toBinaryData()
|
|
||||||
let content = "READ:\(receiptData.base64EncodedString())"
|
|
||||||
|
|
||||||
// Check if peer is connected via mesh (mesh takes precedence)
|
|
||||||
let recipientHexID = recipientNoisePublicKey.hexEncodedString()
|
|
||||||
|
|
||||||
// First check if the peer is currently connected with the given ID
|
|
||||||
var actualRecipientHexID = recipientHexID
|
|
||||||
var actualRecipientNoiseKey = recipientNoisePublicKey
|
|
||||||
|
|
||||||
// Always check if they reconnected with a new ID, even if preferredTransport is specified
|
|
||||||
if let favoriteStatus = favoritesService.getFavoriteStatus(for: recipientNoisePublicKey) {
|
|
||||||
let peerNickname = favoriteStatus.peerNickname
|
|
||||||
|
|
||||||
// Search through all current peers to find one with the same nickname
|
|
||||||
for (currentPeerID, currentNickname) in meshService.getPeerNicknames() {
|
|
||||||
if currentNickname == peerNickname,
|
|
||||||
currentPeerID != recipientHexID,
|
|
||||||
let currentNoiseKey = Data(hexString: currentPeerID) {
|
|
||||||
SecureLogger.log("🔄 Found updated peer ID for \(peerNickname): \(recipientHexID) -> \(currentPeerID)",
|
|
||||||
category: SecureLogger.session, level: .info)
|
|
||||||
actualRecipientHexID = currentPeerID
|
|
||||||
actualRecipientNoiseKey = currentNoiseKey
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If still not found in connected peers, check all favorites for the current key
|
|
||||||
if meshService.getPeerNicknames()[actualRecipientHexID] == nil {
|
|
||||||
// Search through all favorites to find the current noise key for this nickname
|
|
||||||
for (noiseKey, relationship) in favoritesService.favorites {
|
|
||||||
if relationship.peerNickname == peerNickname && relationship.peerNostrPublicKey != nil {
|
|
||||||
SecureLogger.log("🔄 Using current favorite key for \(peerNickname): \(recipientHexID) -> \(noiseKey.hexEncodedString())",
|
|
||||||
category: SecureLogger.session, level: .info)
|
|
||||||
actualRecipientHexID = noiseKey.hexEncodedString()
|
|
||||||
actualRecipientNoiseKey = noiseKey
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
let isConnectedOnMesh = meshService.isPeerConnected(actualRecipientHexID)
|
|
||||||
|
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||||
if isConnectedOnMesh && preferredTransport != .nostr {
|
let reachableMesh = mesh.isPeerReachable(peerID)
|
||||||
// Send via mesh
|
if reachableMesh {
|
||||||
SecureLogger.log("📡 Sending read receipt via mesh to \(actualRecipientHexID)",
|
SecureLogger.debug("Routing PM via mesh (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
category: SecureLogger.session, level: .debug)
|
// BLEService will initiate a handshake if needed and queue the message
|
||||||
meshService.sendReadReceipt(receipt, to: actualRecipientHexID)
|
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||||
|
} else if canSendViaNostr(peerID: peerID) {
|
||||||
|
SecureLogger.debug("Routing PM via Nostr to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
|
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||||
} else {
|
} else {
|
||||||
// Send via Nostr
|
// Queue for later (when mesh connects or Nostr mapping appears)
|
||||||
SecureLogger.log("🌐 Sending read receipt via Nostr to \(actualRecipientHexID)",
|
if outbox[peerID] == nil { outbox[peerID] = [] }
|
||||||
category: SecureLogger.session, level: .debug)
|
outbox[peerID]?.append((content, recipientNickname, messageID))
|
||||||
|
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))…", category: .session)
|
||||||
// Get recipient's Nostr public key using the actual current noise key
|
|
||||||
let favoriteStatus = favoritesService.getFavoriteStatus(for: actualRecipientNoiseKey)
|
|
||||||
guard let recipientNostrPubkey = favoriteStatus?.peerNostrPublicKey else {
|
|
||||||
SecureLogger.log("❌ Cannot send read receipt - no Nostr key for recipient",
|
|
||||||
category: SecureLogger.session, level: .error)
|
|
||||||
throw MessageRouterError.noNostrKey
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else {
|
|
||||||
SecureLogger.log("⚠️ No Nostr identity available for read receipt",
|
|
||||||
category: SecureLogger.session, level: .warning)
|
|
||||||
throw MessageRouterError.noIdentity
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create read receipt message
|
|
||||||
guard let event = try? NostrProtocol.createPrivateMessage(
|
|
||||||
content: content,
|
|
||||||
recipientPubkey: recipientNostrPubkey,
|
|
||||||
senderIdentity: senderIdentity
|
|
||||||
) else {
|
|
||||||
SecureLogger.log("❌ Failed to create read receipt",
|
|
||||||
category: SecureLogger.session, level: .error)
|
|
||||||
throw MessageRouterError.encryptionFailed
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send via relay
|
|
||||||
nostrRelay.sendEvent(event)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sendDeliveryAcknowledgment(for messageId: String, to recipientNostrPubkey: String) {
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||||
SecureLogger.log("📤 Sending delivery acknowledgment for message \(messageId)",
|
// Prefer mesh for reachable peers; BLE will queue if handshake is needed
|
||||||
category: SecureLogger.session, level: .debug)
|
if mesh.isPeerReachable(peerID) {
|
||||||
|
SecureLogger.debug("Routing READ ack via mesh (reachable) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else {
|
mesh.sendReadReceipt(receipt, to: peerID)
|
||||||
SecureLogger.log("⚠️ No Nostr identity available for acknowledgment",
|
} else {
|
||||||
category: SecureLogger.session, level: .warning)
|
SecureLogger.debug("Routing READ ack via Nostr to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||||
return
|
nostr.sendReadReceipt(receipt, to: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create acknowledgment message
|
|
||||||
let content = "DELIVERED:\(messageId)"
|
|
||||||
guard let event = try? NostrProtocol.createPrivateMessage(
|
|
||||||
content: content,
|
|
||||||
recipientPubkey: recipientNostrPubkey,
|
|
||||||
senderIdentity: senderIdentity
|
|
||||||
) else {
|
|
||||||
SecureLogger.log("❌ Failed to create delivery acknowledgment",
|
|
||||||
category: SecureLogger.session, level: .error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send via relay
|
|
||||||
nostrRelay.sendEvent(event)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func cleanupOldMessages() {
|
func sendDeliveryAck(_ messageID: String, to peerID: PeerID) {
|
||||||
let cutoff = Date().addingTimeInterval(-300) // 5 minutes
|
if mesh.isPeerReachable(peerID) {
|
||||||
pendingMessages = pendingMessages.filter { $0.value.timestamp > cutoff }
|
SecureLogger.debug("Routing DELIVERED ack via mesh (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
|
mesh.sendDeliveryAck(for: messageID, to: peerID)
|
||||||
|
} else {
|
||||||
|
nostr.sendDeliveryAck(for: messageID, to: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||||
|
// Route via mesh when connected; else use Nostr
|
||||||
|
if mesh.isPeerConnected(peerID) {
|
||||||
|
mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||||
|
} else {
|
||||||
|
nostr.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Outbox Management
|
||||||
|
private func canSendViaNostr(peerID: PeerID) -> Bool {
|
||||||
|
// Two forms are supported:
|
||||||
|
// - 64-hex Noise public key (32 bytes)
|
||||||
|
// - 16-hex short peer ID (derived from Noise pubkey)
|
||||||
|
if let noiseKey = peerID.noiseKey {
|
||||||
|
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||||
|
fav.peerNostrPublicKey != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} else if peerID.isShort {
|
||||||
|
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
||||||
|
fav.peerNostrPublicKey != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func flushOutbox(for peerID: PeerID) {
|
||||||
|
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
||||||
|
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
|
||||||
|
var remaining: [(content: String, nickname: String, messageID: String)] = []
|
||||||
|
// Prefer mesh if connected; else try Nostr if mapping exists
|
||||||
|
for (content, nickname, messageID) in queued {
|
||||||
|
if mesh.isPeerReachable(peerID) {
|
||||||
|
SecureLogger.debug("Outbox -> mesh for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
|
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||||
|
} else if canSendViaNostr(peerID: peerID) {
|
||||||
|
SecureLogger.debug("Outbox -> Nostr for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
|
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||||
|
} else {
|
||||||
|
// Keep unsent items queued
|
||||||
|
remaining.append((content, nickname, messageID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Persist only items we could not send
|
||||||
|
if remaining.isEmpty {
|
||||||
|
outbox.removeValue(forKey: peerID)
|
||||||
|
} else {
|
||||||
|
outbox[peerID] = remaining
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func flushAllOutbox() {
|
||||||
|
for key in Array(outbox.keys) { flushOutbox(for: key) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Errors
|
|
||||||
|
|
||||||
enum MessageRouterError: LocalizedError {
|
|
||||||
case peerNotReachable
|
|
||||||
case noNostrPublicKey
|
|
||||||
case noNostrIdentity
|
|
||||||
case transportFailed
|
|
||||||
case noNostrKey
|
|
||||||
case noIdentity
|
|
||||||
case encryptionFailed
|
|
||||||
|
|
||||||
var errorDescription: String? {
|
|
||||||
switch self {
|
|
||||||
case .peerNotReachable:
|
|
||||||
return "Peer is not reachable via mesh or Nostr"
|
|
||||||
case .noNostrPublicKey:
|
|
||||||
return "Peer's Nostr public key is unknown"
|
|
||||||
case .noNostrIdentity:
|
|
||||||
return "No Nostr identity available"
|
|
||||||
case .transportFailed:
|
|
||||||
return "Failed to send message"
|
|
||||||
case .noNostrKey:
|
|
||||||
return "No Nostr key available for recipient"
|
|
||||||
case .noIdentity:
|
|
||||||
return "No identity available"
|
|
||||||
case .encryptionFailed:
|
|
||||||
return "Failed to encrypt message"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Notification Names
|
|
||||||
|
|
||||||
extension Notification.Name {
|
|
||||||
static let nostrMessageReceived = Notification.Name("NostrMessageReceived")
|
|
||||||
static let messageDeliveryAcknowledged = Notification.Name("MessageDeliveryAcknowledged")
|
|
||||||
static let readReceiptReceived = Notification.Name("ReadReceiptReceived")
|
|
||||||
static let appDidBecomeActive = Notification.Name("AppDidBecomeActive")
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import Foundation
|
||||||
|
import BitLogger
|
||||||
|
import Combine
|
||||||
|
import Tor
|
||||||
|
|
||||||
|
/// Coordinates when the app is allowed to start Tor and connect to Nostr relays.
|
||||||
|
/// Policy: permit start when either location permissions are authorized OR
|
||||||
|
/// there exists at least one mutual favorite. Otherwise, do not start.
|
||||||
|
@MainActor
|
||||||
|
final class NetworkActivationService: ObservableObject {
|
||||||
|
static let shared = NetworkActivationService()
|
||||||
|
|
||||||
|
@Published private(set) var activationAllowed: Bool = false
|
||||||
|
@Published private(set) var userTorEnabled: Bool = true
|
||||||
|
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
private var started = false
|
||||||
|
private let torPreferenceKey = "networkActivationService.userTorEnabled"
|
||||||
|
private var torAutoStartDesired: Bool = false
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
// Clean up Combine subscriptions
|
||||||
|
cancellables.removeAll()
|
||||||
|
SecureLogger.debug("NetworkActivationService deinitialized", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard !started else { return }
|
||||||
|
started = true
|
||||||
|
|
||||||
|
if let stored = UserDefaults.standard.object(forKey: torPreferenceKey) as? Bool {
|
||||||
|
userTorEnabled = stored
|
||||||
|
} else {
|
||||||
|
userTorEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial compute
|
||||||
|
let allowed = basePolicyAllowed()
|
||||||
|
activationAllowed = allowed
|
||||||
|
torAutoStartDesired = allowed && userTorEnabled
|
||||||
|
TorManager.shared.setAutoStartAllowed(torAutoStartDesired)
|
||||||
|
applyTorState(torDesired: torAutoStartDesired)
|
||||||
|
if allowed {
|
||||||
|
NostrRelayManager.shared.connect()
|
||||||
|
} else {
|
||||||
|
NostrRelayManager.shared.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// React to location permission changes
|
||||||
|
LocationChannelManager.shared.$permissionState
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.reevaluate()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
// React to mutual favorites changes
|
||||||
|
FavoritesPersistenceService.shared.$mutualFavorites
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.reevaluate()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setUserTorEnabled(_ enabled: Bool) {
|
||||||
|
guard enabled != userTorEnabled else { return }
|
||||||
|
userTorEnabled = enabled
|
||||||
|
UserDefaults.standard.set(enabled, forKey: torPreferenceKey)
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .TorUserPreferenceChanged,
|
||||||
|
object: nil,
|
||||||
|
userInfo: ["enabled": enabled]
|
||||||
|
)
|
||||||
|
reevaluate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reevaluate() {
|
||||||
|
let allowed = basePolicyAllowed()
|
||||||
|
let torDesired = allowed && userTorEnabled
|
||||||
|
let statusChanged = allowed != activationAllowed
|
||||||
|
let torChanged = torDesired != torAutoStartDesired
|
||||||
|
if statusChanged {
|
||||||
|
SecureLogger.info("NetworkActivationService: activationAllowed -> \(allowed)", category: .session)
|
||||||
|
activationAllowed = allowed
|
||||||
|
}
|
||||||
|
if statusChanged || torChanged {
|
||||||
|
torAutoStartDesired = torDesired
|
||||||
|
TorManager.shared.setAutoStartAllowed(torDesired)
|
||||||
|
applyTorState(torDesired: torDesired)
|
||||||
|
}
|
||||||
|
|
||||||
|
if allowed {
|
||||||
|
if torChanged {
|
||||||
|
// Reset relay sockets when switching transport path (Tor ↔︎ direct)
|
||||||
|
NostrRelayManager.shared.disconnect()
|
||||||
|
}
|
||||||
|
NostrRelayManager.shared.connect()
|
||||||
|
} else if statusChanged {
|
||||||
|
NostrRelayManager.shared.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func basePolicyAllowed() -> Bool {
|
||||||
|
let permOK = LocationChannelManager.shared.permissionState == .authorized
|
||||||
|
let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
|
||||||
|
return permOK || hasMutual
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyTorState(torDesired: Bool) {
|
||||||
|
TorURLSession.shared.setProxyMode(useTor: torDesired)
|
||||||
|
if torDesired {
|
||||||
|
TorManager.shared.startIfNeeded()
|
||||||
|
} else {
|
||||||
|
TorManager.shared.shutdownCompletely()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
///
|
///
|
||||||
/// High-level encryption service that manages Noise Protocol sessions for secure
|
/// High-level encryption service that manages Noise Protocol sessions for secure
|
||||||
/// peer-to-peer communication in BitChat. Acts as the bridge between the transport
|
/// peer-to-peer communication in BitChat. Acts as the bridge between the transport
|
||||||
/// layer (BluetoothMeshService) and the cryptographic layer (NoiseProtocol).
|
/// layer (BLEService) and the cryptographic layer (NoiseProtocol).
|
||||||
///
|
///
|
||||||
/// ## Overview
|
/// ## Overview
|
||||||
/// This service provides a simplified API for establishing and managing encrypted
|
/// This service provides a simplified API for establishing and managing encrypted
|
||||||
@@ -60,9 +60,8 @@
|
|||||||
/// ```
|
/// ```
|
||||||
///
|
///
|
||||||
/// ## Integration Points
|
/// ## Integration Points
|
||||||
/// - **BluetoothMeshService**: Calls this service for all private messages
|
/// - **BLEService**: Calls this service for all private messages
|
||||||
/// - **ChatViewModel**: Monitors encryption status for UI indicators
|
/// - **ChatViewModel**: Monitors encryption status for UI indicators
|
||||||
/// - **NoiseHandshakeCoordinator**: Prevents handshake race conditions
|
|
||||||
/// - **KeychainManager**: Secure storage for identity keys
|
/// - **KeychainManager**: Secure storage for identity keys
|
||||||
///
|
///
|
||||||
/// ## Thread Safety
|
/// ## Thread Safety
|
||||||
@@ -83,9 +82,9 @@
|
|||||||
/// - Background queue for CPU-intensive operations
|
/// - Background queue for CPU-intensive operations
|
||||||
///
|
///
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import os.log
|
|
||||||
|
|
||||||
// MARK: - Encryption Status
|
// MARK: - Encryption Status
|
||||||
|
|
||||||
@@ -116,15 +115,30 @@ enum EncryptionStatus: Equatable {
|
|||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .none:
|
case .none:
|
||||||
return "Encryption failed"
|
return String(localized: "encryption.status.failed", comment: "Status text when encryption failed")
|
||||||
case .noHandshake:
|
case .noHandshake:
|
||||||
return "Not encrypted"
|
return String(localized: "encryption.status.not_encrypted", comment: "Status text when no encryption handshake happened")
|
||||||
case .noiseHandshaking:
|
case .noiseHandshaking:
|
||||||
return "Establishing encryption..."
|
return String(localized: "encryption.status.establishing", comment: "Status text when encryption is being established")
|
||||||
case .noiseSecured:
|
case .noiseSecured:
|
||||||
return "Encrypted"
|
return String(localized: "encryption.status.secured", comment: "Status text when encryption is secured but not verified")
|
||||||
case .noiseVerified:
|
case .noiseVerified:
|
||||||
return "Encrypted & Verified"
|
return String(localized: "encryption.status.verified", comment: "Status text when encryption is verified")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var accessibilityDescription: String {
|
||||||
|
switch self {
|
||||||
|
case .none:
|
||||||
|
return String(localized: "encryption.accessibility.failed", comment: "Accessibility text when encryption failed")
|
||||||
|
case .noHandshake:
|
||||||
|
return String(localized: "encryption.accessibility.not_encrypted", comment: "Accessibility text when encryption is not established")
|
||||||
|
case .noiseHandshaking:
|
||||||
|
return String(localized: "encryption.accessibility.establishing", comment: "Accessibility text when encryption is being established")
|
||||||
|
case .noiseSecured:
|
||||||
|
return String(localized: "encryption.accessibility.secured", comment: "Accessibility text when encryption is secured")
|
||||||
|
case .noiseVerified:
|
||||||
|
return String(localized: "encryption.accessibility.verified", comment: "Accessibility text when encryption is verified")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,7 +149,7 @@ enum EncryptionStatus: Equatable {
|
|||||||
/// Provides a high-level API for establishing secure channels between peers,
|
/// Provides a high-level API for establishing secure channels between peers,
|
||||||
/// handling all cryptographic operations transparently.
|
/// handling all cryptographic operations transparently.
|
||||||
/// - Important: This service maintains the device's cryptographic identity
|
/// - Important: This service maintains the device's cryptographic identity
|
||||||
class NoiseEncryptionService {
|
final class NoiseEncryptionService {
|
||||||
// Static identity key (persistent across sessions)
|
// Static identity key (persistent across sessions)
|
||||||
private let staticIdentityKey: Curve25519.KeyAgreement.PrivateKey
|
private let staticIdentityKey: Curve25519.KeyAgreement.PrivateKey
|
||||||
public let staticIdentityPublicKey: Curve25519.KeyAgreement.PublicKey
|
public let staticIdentityPublicKey: Curve25519.KeyAgreement.PublicKey
|
||||||
@@ -148,32 +162,52 @@ class NoiseEncryptionService {
|
|||||||
private let sessionManager: NoiseSessionManager
|
private let sessionManager: NoiseSessionManager
|
||||||
|
|
||||||
// Peer fingerprints (SHA256 hash of static public key)
|
// Peer fingerprints (SHA256 hash of static public key)
|
||||||
private var peerFingerprints: [String: String] = [:] // peerID -> fingerprint
|
private var peerFingerprints: [PeerID: String] = [:]
|
||||||
private var fingerprintToPeerID: [String: String] = [:] // fingerprint -> peerID
|
private var fingerprintToPeerID: [String: PeerID] = [:]
|
||||||
|
|
||||||
// Thread safety
|
// Thread safety
|
||||||
private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent)
|
private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent)
|
||||||
|
|
||||||
// Security components
|
// Security components
|
||||||
private let rateLimiter = NoiseRateLimiter()
|
private let rateLimiter = NoiseRateLimiter()
|
||||||
|
private let keychain: KeychainManagerProtocol
|
||||||
|
|
||||||
// Session maintenance
|
// Session maintenance
|
||||||
private var rekeyTimer: Timer?
|
private var rekeyTimer: Timer?
|
||||||
private let rekeyCheckInterval: TimeInterval = 60.0 // Check every minute
|
private let rekeyCheckInterval: TimeInterval = 60.0 // Check every minute
|
||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
var onPeerAuthenticated: ((String, String) -> Void)? // peerID, fingerprint
|
private var onPeerAuthenticatedHandlers: [((String, String) -> Void)] = [] // Array of handlers for peer authentication
|
||||||
var onHandshakeRequired: ((String) -> Void)? // peerID needs handshake
|
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
|
||||||
|
|
||||||
init() {
|
// Add a handler for peer authentication
|
||||||
|
func addOnPeerAuthenticatedHandler(_ handler: @escaping (String, String) -> Void) {
|
||||||
|
serviceQueue.async(flags: .barrier) { [weak self] in
|
||||||
|
self?.onPeerAuthenticatedHandlers.append(handler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy support - setting this will add to the handlers array
|
||||||
|
var onPeerAuthenticated: ((String, String) -> Void)? {
|
||||||
|
get { nil } // Always return nil for backward compatibility
|
||||||
|
set {
|
||||||
|
if let handler = newValue {
|
||||||
|
addOnPeerAuthenticatedHandler(handler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init(keychain: KeychainManagerProtocol) {
|
||||||
|
self.keychain = keychain
|
||||||
|
|
||||||
// Load or create static identity key (ONLY from keychain)
|
// Load or create static identity key (ONLY from keychain)
|
||||||
let loadedKey: Curve25519.KeyAgreement.PrivateKey
|
let loadedKey: Curve25519.KeyAgreement.PrivateKey
|
||||||
|
|
||||||
// Try to load from keychain
|
// Try to load from keychain
|
||||||
if let identityData = KeychainManager.shared.getIdentityKey(forKey: "noiseStaticKey"),
|
if let identityData = keychain.getIdentityKey(forKey: "noiseStaticKey"),
|
||||||
let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) {
|
let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) {
|
||||||
loadedKey = key
|
loadedKey = key
|
||||||
SecureLogger.logKeyOperation("load", keyType: "noiseStaticKey", success: true)
|
SecureLogger.logKeyOperation(.load, keyType: "noiseStaticKey", success: true)
|
||||||
}
|
}
|
||||||
// If no identity exists, create new one
|
// If no identity exists, create new one
|
||||||
else {
|
else {
|
||||||
@@ -181,8 +215,8 @@ class NoiseEncryptionService {
|
|||||||
let keyData = loadedKey.rawRepresentation
|
let keyData = loadedKey.rawRepresentation
|
||||||
|
|
||||||
// Save to keychain
|
// Save to keychain
|
||||||
let saved = KeychainManager.shared.saveIdentityKey(keyData, forKey: "noiseStaticKey")
|
let saved = keychain.saveIdentityKey(keyData, forKey: "noiseStaticKey")
|
||||||
SecureLogger.logKeyOperation("create", keyType: "noiseStaticKey", success: saved)
|
SecureLogger.logKeyOperation(.create, keyType: "noiseStaticKey", success: saved)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now assign the final value
|
// Now assign the final value
|
||||||
@@ -193,10 +227,10 @@ class NoiseEncryptionService {
|
|||||||
let loadedSigningKey: Curve25519.Signing.PrivateKey
|
let loadedSigningKey: Curve25519.Signing.PrivateKey
|
||||||
|
|
||||||
// Try to load from keychain
|
// Try to load from keychain
|
||||||
if let signingData = KeychainManager.shared.getIdentityKey(forKey: "ed25519SigningKey"),
|
if let signingData = keychain.getIdentityKey(forKey: "ed25519SigningKey"),
|
||||||
let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) {
|
let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) {
|
||||||
loadedSigningKey = key
|
loadedSigningKey = key
|
||||||
SecureLogger.logKeyOperation("load", keyType: "ed25519SigningKey", success: true)
|
SecureLogger.logKeyOperation(.load, keyType: "ed25519SigningKey", success: true)
|
||||||
}
|
}
|
||||||
// If no signing key exists, create new one
|
// If no signing key exists, create new one
|
||||||
else {
|
else {
|
||||||
@@ -204,8 +238,8 @@ class NoiseEncryptionService {
|
|||||||
let keyData = loadedSigningKey.rawRepresentation
|
let keyData = loadedSigningKey.rawRepresentation
|
||||||
|
|
||||||
// Save to keychain
|
// Save to keychain
|
||||||
let saved = KeychainManager.shared.saveIdentityKey(keyData, forKey: "ed25519SigningKey")
|
let saved = keychain.saveIdentityKey(keyData, forKey: "ed25519SigningKey")
|
||||||
SecureLogger.logKeyOperation("create", keyType: "ed25519SigningKey", success: saved)
|
SecureLogger.logKeyOperation(.create, keyType: "ed25519SigningKey", success: saved)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now assign the signing keys
|
// Now assign the signing keys
|
||||||
@@ -213,7 +247,7 @@ class NoiseEncryptionService {
|
|||||||
self.signingPublicKey = signingKey.publicKey
|
self.signingPublicKey = signingKey.publicKey
|
||||||
|
|
||||||
// Initialize session manager
|
// Initialize session manager
|
||||||
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey)
|
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain)
|
||||||
|
|
||||||
// Set up session callbacks
|
// Set up session callbacks
|
||||||
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in
|
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in
|
||||||
@@ -238,22 +272,21 @@ class NoiseEncryptionService {
|
|||||||
|
|
||||||
/// Get our identity fingerprint
|
/// Get our identity fingerprint
|
||||||
func getIdentityFingerprint() -> String {
|
func getIdentityFingerprint() -> String {
|
||||||
let hash = SHA256.hash(data: staticIdentityPublicKey.rawRepresentation)
|
staticIdentityPublicKey.rawRepresentation.sha256Fingerprint()
|
||||||
return hash.map { String(format: "%02x", $0) }.joined()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get peer's public key data
|
/// Get peer's public key data
|
||||||
func getPeerPublicKeyData(_ peerID: String) -> Data? {
|
func getPeerPublicKeyData(_ peerID: PeerID) -> Data? {
|
||||||
return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation
|
return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear persistent identity (for panic mode)
|
/// Clear persistent identity (for panic mode)
|
||||||
func clearPersistentIdentity() {
|
func clearPersistentIdentity() {
|
||||||
// Clear from keychain
|
// Clear from keychain
|
||||||
let deletedStatic = KeychainManager.shared.deleteIdentityKey(forKey: "noiseStaticKey")
|
let deletedStatic = keychain.deleteIdentityKey(forKey: "noiseStaticKey")
|
||||||
let deletedSigning = KeychainManager.shared.deleteIdentityKey(forKey: "ed25519SigningKey")
|
let deletedSigning = keychain.deleteIdentityKey(forKey: "ed25519SigningKey")
|
||||||
SecureLogger.logKeyOperation("delete", keyType: "identity keys", success: deletedStatic && deletedSigning)
|
SecureLogger.logKeyOperation(.delete, keyType: "identity keys", success: deletedStatic && deletedSigning)
|
||||||
SecureLogger.log("Panic mode activated - identity cleared", category: SecureLogger.security, level: .warning)
|
SecureLogger.warning("Panic mode activated - identity cleared", category: .security)
|
||||||
// Stop rekey timer
|
// Stop rekey timer
|
||||||
stopRekeyTimer()
|
stopRekeyTimer()
|
||||||
}
|
}
|
||||||
@@ -264,7 +297,7 @@ class NoiseEncryptionService {
|
|||||||
let signature = try signingKey.signature(for: data)
|
let signature = try signingKey.signature(for: data)
|
||||||
return signature
|
return signature
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.logError(error, context: "Failed to sign data", category: SecureLogger.noise)
|
SecureLogger.error(error, context: "Failed to sign data")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,29 +308,117 @@ class NoiseEncryptionService {
|
|||||||
let signingPublicKey = try Curve25519.Signing.PublicKey(rawRepresentation: publicKey)
|
let signingPublicKey = try Curve25519.Signing.PublicKey(rawRepresentation: publicKey)
|
||||||
return signingPublicKey.isValidSignature(signature, for: data)
|
return signingPublicKey.isValidSignature(signature, for: data)
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.logError(error, context: "Failed to verify signature", category: SecureLogger.noise)
|
SecureLogger.error(error, context: "Failed to verify signature")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Announce Signature Helpers
|
||||||
|
|
||||||
|
/// Build the canonical announce binding message bytes and sign with our Ed25519 key
|
||||||
|
/// - Parameters:
|
||||||
|
/// - peerID: 8-byte routing ID (as in packet header)
|
||||||
|
/// - noiseKey: 32-byte Curve25519.KeyAgreement public key
|
||||||
|
/// - ed25519Key: 32-byte Ed25519 public key (self)
|
||||||
|
/// - nickname: UTF-8 nickname (<=255 bytes)
|
||||||
|
/// - timestampMs: UInt64 milliseconds since epoch
|
||||||
|
/// - Returns: Ed25519 signature over the canonical bytes, or nil on failure
|
||||||
|
func buildAnnounceSignature(peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64) -> Data? {
|
||||||
|
let message = canonicalAnnounceBytes(peerID: peerID, noiseKey: noiseKey, ed25519Key: ed25519Key, nickname: nickname, timestampMs: timestampMs)
|
||||||
|
return signData(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify an announce signature
|
||||||
|
func verifyAnnounceSignature(signature: Data, peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64, publicKey: Data) -> Bool {
|
||||||
|
let message = canonicalAnnounceBytes(peerID: peerID, noiseKey: noiseKey, ed25519Key: ed25519Key, nickname: nickname, timestampMs: timestampMs)
|
||||||
|
return verifySignature(signature, for: message, publicKey: publicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build canonical bytes for announce signing.
|
||||||
|
private func canonicalAnnounceBytes(peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64) -> Data {
|
||||||
|
var out = Data()
|
||||||
|
// context
|
||||||
|
let context = "bitchat-announce-v1".data(using: .utf8) ?? Data()
|
||||||
|
out.append(UInt8(min(context.count, 255)))
|
||||||
|
out.append(context.prefix(255))
|
||||||
|
// peerID (expect 8 bytes; pad/truncate to 8 for canonicalization)
|
||||||
|
let peerID8 = peerID.prefix(8)
|
||||||
|
out.append(peerID8)
|
||||||
|
if peerID8.count < 8 { out.append(Data(repeating: 0, count: 8 - peerID8.count)) }
|
||||||
|
// noise static key (expect 32)
|
||||||
|
let noise32 = noiseKey.prefix(32)
|
||||||
|
out.append(noise32)
|
||||||
|
if noise32.count < 32 { out.append(Data(repeating: 0, count: 32 - noise32.count)) }
|
||||||
|
// ed25519 public key (expect 32)
|
||||||
|
let ed32 = ed25519Key.prefix(32)
|
||||||
|
out.append(ed32)
|
||||||
|
if ed32.count < 32 { out.append(Data(repeating: 0, count: 32 - ed32.count)) }
|
||||||
|
// nickname length + bytes
|
||||||
|
let nickData = nickname.data(using: .utf8) ?? Data()
|
||||||
|
out.append(UInt8(min(nickData.count, 255)))
|
||||||
|
out.append(nickData.prefix(255))
|
||||||
|
// timestamp
|
||||||
|
var ts = timestampMs.bigEndian
|
||||||
|
withUnsafeBytes(of: &ts) { raw in out.append(contentsOf: raw) }
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Packet Signing/Verification
|
||||||
|
|
||||||
|
/// Sign a BitchatPacket using the noise private key
|
||||||
|
func signPacket(_ packet: BitchatPacket) -> BitchatPacket? {
|
||||||
|
// Create canonical packet bytes for signing
|
||||||
|
guard let packetData = packet.toBinaryDataForSigning() else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign with the noise private key (converted to Ed25519 for signing)
|
||||||
|
guard let signature = signData(packetData) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return new packet with signature
|
||||||
|
var signedPacket = packet
|
||||||
|
signedPacket.signature = signature
|
||||||
|
return signedPacket
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a BitchatPacket signature using the provided public key
|
||||||
|
func verifyPacketSignature(_ packet: BitchatPacket, publicKey: Data) -> Bool {
|
||||||
|
guard let signature = packet.signature else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create canonical packet bytes for verification (without signature)
|
||||||
|
|
||||||
|
guard let packetData = packet.toBinaryDataForSigning() else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// For noise public keys, we need to derive the Ed25519 key for verification
|
||||||
|
// This assumes the noise key can be used for Ed25519 signing
|
||||||
|
return verifySignature(signature, for: packetData, publicKey: publicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Handshake Management
|
// MARK: - Handshake Management
|
||||||
|
|
||||||
/// Initiate a Noise handshake with a peer
|
/// Initiate a Noise handshake with a peer
|
||||||
func initiateHandshake(with peerID: String) throws -> Data {
|
func initiateHandshake(with peerID: PeerID) throws -> Data {
|
||||||
|
|
||||||
// Validate peer ID
|
// Validate peer ID
|
||||||
guard NoiseSecurityValidator.validatePeerID(peerID) else {
|
guard peerID.isValid else {
|
||||||
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: peerID), level: .warning)
|
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
|
||||||
throw NoiseSecurityError.invalidPeerID
|
throw NoiseSecurityError.invalidPeerID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check rate limit
|
// Check rate limit
|
||||||
guard rateLimiter.allowHandshake(from: peerID) else {
|
guard rateLimiter.allowHandshake(from: peerID) else {
|
||||||
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: "Rate limited: \(peerID)"), level: .warning)
|
SecureLogger.warning(.authenticationFailed(peerID: "Rate limited: \(peerID)"))
|
||||||
throw NoiseSecurityError.rateLimitExceeded
|
throw NoiseSecurityError.rateLimitExceeded
|
||||||
}
|
}
|
||||||
|
|
||||||
SecureLogger.logSecurityEvent(.handshakeStarted(peerID: peerID))
|
SecureLogger.info(.handshakeStarted(peerID: peerID.id))
|
||||||
|
|
||||||
// Return raw handshake data without wrapper
|
// Return raw handshake data without wrapper
|
||||||
// The Noise protocol handles its own message format
|
// The Noise protocol handles its own message format
|
||||||
@@ -306,23 +427,23 @@ class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Process an incoming handshake message
|
/// Process an incoming handshake message
|
||||||
func processHandshakeMessage(from peerID: String, message: Data) throws -> Data? {
|
func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? {
|
||||||
|
|
||||||
// Validate peer ID
|
// Validate peer ID
|
||||||
guard NoiseSecurityValidator.validatePeerID(peerID) else {
|
guard peerID.isValid else {
|
||||||
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: peerID), level: .warning)
|
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
|
||||||
throw NoiseSecurityError.invalidPeerID
|
throw NoiseSecurityError.invalidPeerID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate message size
|
// Validate message size
|
||||||
guard NoiseSecurityValidator.validateHandshakeMessageSize(message) else {
|
guard NoiseSecurityValidator.validateHandshakeMessageSize(message) else {
|
||||||
SecureLogger.logSecurityEvent(.handshakeFailed(peerID: peerID, error: "Message too large"), level: .warning)
|
SecureLogger.warning(.handshakeFailed(peerID: peerID.id, error: "Message too large"))
|
||||||
throw NoiseSecurityError.messageTooLarge
|
throw NoiseSecurityError.messageTooLarge
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check rate limit
|
// Check rate limit
|
||||||
guard rateLimiter.allowHandshake(from: peerID) else {
|
guard rateLimiter.allowHandshake(from: peerID) else {
|
||||||
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: "Rate limited: \(peerID)"), level: .warning)
|
SecureLogger.warning(.authenticationFailed(peerID: "Rate limited: \(peerID)"))
|
||||||
throw NoiseSecurityError.rateLimitExceeded
|
throw NoiseSecurityError.rateLimitExceeded
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,19 +457,19 @@ class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check if we have an established session with a peer
|
/// Check if we have an established session with a peer
|
||||||
func hasEstablishedSession(with peerID: String) -> Bool {
|
func hasEstablishedSession(with peerID: PeerID) -> Bool {
|
||||||
return sessionManager.getSession(for: peerID)?.isEstablished() ?? false
|
return sessionManager.getSession(for: peerID)?.isEstablished() ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if we have a session (established or handshaking) with a peer
|
/// Check if we have a session (established or handshaking) with a peer
|
||||||
func hasSession(with peerID: String) -> Bool {
|
func hasSession(with peerID: PeerID) -> Bool {
|
||||||
return sessionManager.getSession(for: peerID) != nil
|
return sessionManager.getSession(for: peerID) != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Encryption/Decryption
|
// MARK: - Encryption/Decryption
|
||||||
|
|
||||||
/// Encrypt data for a specific peer
|
/// Encrypt data for a specific peer
|
||||||
func encrypt(_ data: Data, for peerID: String) throws -> Data {
|
func encrypt(_ data: Data, for peerID: PeerID) throws -> Data {
|
||||||
// Validate message size
|
// Validate message size
|
||||||
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
||||||
throw NoiseSecurityError.messageTooLarge
|
throw NoiseSecurityError.messageTooLarge
|
||||||
@@ -370,7 +491,7 @@ class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypt data from a specific peer
|
/// Decrypt data from a specific peer
|
||||||
func decrypt(_ data: Data, from peerID: String) throws -> Data {
|
func decrypt(_ data: Data, from peerID: PeerID) throws -> Data {
|
||||||
// Validate message size
|
// Validate message size
|
||||||
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
||||||
throw NoiseSecurityError.messageTooLarge
|
throw NoiseSecurityError.messageTooLarge
|
||||||
@@ -392,56 +513,26 @@ class NoiseEncryptionService {
|
|||||||
// MARK: - Peer Management
|
// MARK: - Peer Management
|
||||||
|
|
||||||
/// Get fingerprint for a peer
|
/// Get fingerprint for a peer
|
||||||
func getPeerFingerprint(_ peerID: String) -> String? {
|
func getPeerFingerprint(_ peerID: PeerID) -> String? {
|
||||||
return serviceQueue.sync {
|
return serviceQueue.sync {
|
||||||
return peerFingerprints[peerID]
|
return peerFingerprints[peerID]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get peer ID for a fingerprint
|
func clearEphemeralStateForPanic() {
|
||||||
func getPeerID(for fingerprint: String) -> String? {
|
sessionManager.removeAllSessions()
|
||||||
return serviceQueue.sync {
|
|
||||||
return fingerprintToPeerID[fingerprint]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove a peer session
|
|
||||||
func removePeer(_ peerID: String) {
|
|
||||||
sessionManager.removeSession(for: peerID)
|
|
||||||
|
|
||||||
serviceQueue.sync(flags: .barrier) {
|
serviceQueue.sync(flags: .barrier) {
|
||||||
if let fingerprint = peerFingerprints[peerID] {
|
peerFingerprints.removeAll()
|
||||||
fingerprintToPeerID.removeValue(forKey: fingerprint)
|
fingerprintToPeerID.removeAll()
|
||||||
}
|
|
||||||
peerFingerprints.removeValue(forKey: peerID)
|
|
||||||
}
|
}
|
||||||
|
rateLimiter.resetAll()
|
||||||
SecureLogger.logSecurityEvent(.sessionExpired(peerID: peerID))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Migrate session when peer ID changes
|
|
||||||
func migratePeerSession(from oldPeerID: String, to newPeerID: String, fingerprint: String) {
|
|
||||||
// First update the fingerprint mappings
|
|
||||||
serviceQueue.sync(flags: .barrier) {
|
|
||||||
// Remove old mapping
|
|
||||||
if let oldFingerprint = peerFingerprints[oldPeerID], oldFingerprint == fingerprint {
|
|
||||||
peerFingerprints.removeValue(forKey: oldPeerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add new mapping
|
|
||||||
peerFingerprints[newPeerID] = fingerprint
|
|
||||||
fingerprintToPeerID[fingerprint] = newPeerID
|
|
||||||
}
|
|
||||||
|
|
||||||
// Migrate the session in session manager
|
|
||||||
sessionManager.migrateSession(from: oldPeerID, to: newPeerID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private Helpers
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
private func handleSessionEstablished(peerID: String, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
|
private func handleSessionEstablished(peerID: PeerID, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
|
||||||
// Calculate fingerprint
|
// Calculate fingerprint
|
||||||
let fingerprint = calculateFingerprint(for: remoteStaticKey)
|
let fingerprint = remoteStaticKey.rawRepresentation.sha256Fingerprint()
|
||||||
|
|
||||||
// Store fingerprint mapping
|
// Store fingerprint mapping
|
||||||
serviceQueue.sync(flags: .barrier) {
|
serviceQueue.sync(flags: .barrier) {
|
||||||
@@ -450,15 +541,14 @@ class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Log security event
|
// Log security event
|
||||||
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID))
|
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||||
|
|
||||||
// Notify about authentication
|
// Notify all handlers about authentication
|
||||||
onPeerAuthenticated?(peerID, fingerprint)
|
serviceQueue.async { [weak self] in
|
||||||
}
|
self?.onPeerAuthenticatedHandlers.forEach { handler in
|
||||||
|
handler(peerID.id, fingerprint)
|
||||||
private func calculateFingerprint(for publicKey: Curve25519.KeyAgreement.PublicKey) -> String {
|
}
|
||||||
let hash = SHA256.hash(data: publicKey.rawRepresentation)
|
}
|
||||||
return hash.map { String(format: "%02x", $0) }.joined()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Session Maintenance
|
// MARK: - Session Maintenance
|
||||||
@@ -482,12 +572,12 @@ class NoiseEncryptionService {
|
|||||||
// Attempt to rekey the session
|
// Attempt to rekey the session
|
||||||
do {
|
do {
|
||||||
try sessionManager.initiateRekey(for: peerID)
|
try sessionManager.initiateRekey(for: peerID)
|
||||||
SecureLogger.log("Key rotation initiated for peer: \(peerID)", category: SecureLogger.security, level: .info)
|
SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security)
|
||||||
|
|
||||||
// Signal that handshake is needed
|
// Signal that handshake is needed
|
||||||
onHandshakeRequired?(peerID)
|
onHandshakeRequired?(peerID)
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.logError(error, context: "Failed to initiate rekey for peer: \(peerID)", category: SecureLogger.session)
|
SecureLogger.error(error, context: "Failed to initiate rekey for peer: \(peerID)", category: .session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||