Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed105f311a | ||
|
|
8ebb7411a7 | ||
|
|
fb60c929eb | ||
|
|
678795adcf | ||
|
|
37e973bf9f | ||
|
|
8c002b8942 | ||
|
|
6533629362 | ||
|
|
dd4e2f6e86 | ||
|
|
55187ac5c6 |
@@ -1,20 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
name: Fetch GeoRelays Data
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 6 * * 0'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
update-relay-data:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Fetch GeoRelays
|
|
||||||
run: |
|
|
||||||
wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
|
||||||
mv nostr_relays.csv ./relays/online_relays_gps.csv
|
|
||||||
|
|
||||||
- name: Check for changes
|
|
||||||
id: git-check
|
|
||||||
run: |
|
|
||||||
git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Commit and push changes
|
|
||||||
if: steps.git-check.outputs.changes == 'true'
|
|
||||||
run: |
|
|
||||||
git config --local user.email "action@github.com"
|
|
||||||
git config --local user.name "GitHub Action"
|
|
||||||
git add relays/online_relays_gps.csv
|
|
||||||
git commit -m "Automated update of relay data - $(date -u)"
|
|
||||||
git push
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
name: Build & Test
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
name: Run Swift Tests (${{ matrix.name }})
|
|
||||||
runs-on: macos-latest
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
fail-fast: false # Don't cancel other matrix jobs when one fails
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- name: app
|
|
||||||
path: .
|
|
||||||
- name: BitLogger
|
|
||||||
path: localPackages/BitLogger
|
|
||||||
- name: BitFoundation
|
|
||||||
path: localPackages/BitFoundation
|
|
||||||
- name: Noise
|
|
||||||
path: localPackages/Noise
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v5
|
|
||||||
|
|
||||||
- name: Set up Swift
|
|
||||||
uses: swift-actions/setup-swift@v2
|
|
||||||
|
|
||||||
- name: Cache build artifacts
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ${{ matrix.path }}/.build
|
|
||||||
key: ${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }}
|
|
||||||
${{ runner.os }}-${{ matrix.name }}-
|
|
||||||
|
|
||||||
- name: Run Tests
|
|
||||||
run: swift test --parallel --quiet --package-path ${{ matrix.path }}
|
|
||||||
@@ -8,7 +8,9 @@ plans/
|
|||||||
## AI
|
## AI
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
.claude/
|
|
||||||
|
## User settings
|
||||||
|
xcuserdata/
|
||||||
|
|
||||||
## 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
|
||||||
@@ -55,8 +57,7 @@ iOSInjectionProject/
|
|||||||
|
|
||||||
## Xcode project
|
## Xcode project
|
||||||
*.xcodeproj/project.xcworkspace/
|
*.xcodeproj/project.xcworkspace/
|
||||||
## Xcode User settings
|
*.xcodeproj/xcshareddata/
|
||||||
xcuserdata/
|
|
||||||
|
|
||||||
## Python
|
## Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
@@ -67,9 +68,6 @@ __pycache__/
|
|||||||
*.tmp
|
*.tmp
|
||||||
*.temp
|
*.temp
|
||||||
|
|
||||||
## Cache
|
|
||||||
.cache/
|
|
||||||
|
|
||||||
# Local build results
|
# Local build results
|
||||||
.Result*/
|
.Result*/
|
||||||
.Result*.xcresult/
|
.Result*.xcresult/
|
||||||
@@ -77,6 +75,3 @@ TestResult.xcresult/
|
|||||||
*.xcresult/
|
*.xcresult/
|
||||||
build.log
|
build.log
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# Local configs
|
|
||||||
Local.xcconfig
|
|
||||||
|
|||||||
@@ -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
|
||||||
final class NoiseEncryptionService {
|
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 @@ final class NoiseEncryptionService {
|
|||||||
#### NoiseSession
|
#### NoiseSession
|
||||||
Individual session state for each peer:
|
Individual session state for each peer:
|
||||||
```swift
|
```swift
|
||||||
final class NoiseSession {
|
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 @@ final class NoiseSession {
|
|||||||
#### NoiseSessionManager
|
#### NoiseSessionManager
|
||||||
Thread-safe session management:
|
Thread-safe session management:
|
||||||
```swift
|
```swift
|
||||||
final class NoiseSessionManager {
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
#include "Release.xcconfig"
|
|
||||||
|
|
||||||
// Optional include of local configs
|
|
||||||
#include? "Local.xcconfig"
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
// 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)
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
MARKETING_VERSION = 1.5.1
|
|
||||||
CURRENT_PROJECT_VERSION = 1
|
|
||||||
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0
|
|
||||||
SWIFT_VERSION = 5.0
|
|
||||||
|
|
||||||
DEVELOPMENT_TEAM = L3N5LHJD5Y
|
|
||||||
CODE_SIGN_STYLE = Automatic
|
|
||||||
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat
|
|
||||||
APP_GROUP_ID = group.chat.bitchat
|
|
||||||
@@ -14,16 +14,16 @@ default:
|
|||||||
# Check prerequisites
|
# Check prerequisites
|
||||||
check:
|
check:
|
||||||
@echo "Checking prerequisites..."
|
@echo "Checking prerequisites..."
|
||||||
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install Xcode from App Store" && exit 1)
|
@command -v xcodegen >/dev/null 2>&1 || (echo "❌ XcodeGen not found. Install with: brew install xcodegen" && exit 1)
|
||||||
@xcode-select -p | grep -q "Xcode.app" || (echo "❌ Full Xcode required, not just command line tools. Install from App Store and run:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
|
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ Xcode not found. Install Xcode from App Store" && exit 1)
|
||||||
@test -d "/Applications/Xcode.app" || (echo "❌ Xcode.app not found in Applications folder. Install 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)
|
||||||
@xcodebuild -version >/dev/null 2>&1 || (echo "❌ Xcode not properly configured. Try:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
|
|
||||||
@security find-identity -v -p codesigning | grep -q "Apple Development\|Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
|
|
||||||
@echo "✅ All prerequisites met"
|
@echo "✅ All prerequisites met"
|
||||||
|
|
||||||
# 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,8 +44,13 @@ 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
|
||||||
|
|
||||||
@@ -70,7 +75,9 @@ 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..."
|
||||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
@if [ ! -f project.yml.backup ]; then just patch-for-macos; fi
|
||||||
|
@xcodegen generate
|
||||||
|
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
||||||
@find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}"
|
@find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}"
|
||||||
|
|
||||||
# Show app info
|
# Show app info
|
||||||
@@ -99,9 +106,11 @@ 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 bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Not a git repo or no changes to restore"
|
@git checkout -- project.yml 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"
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import PackageDescription
|
|||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "bitchat",
|
name: "bitchat",
|
||||||
defaultLocalization: "en",
|
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v16),
|
.iOS(.v16),
|
||||||
.macOS(.v13)
|
.macOS(.v13)
|
||||||
@@ -16,50 +15,22 @@ let package = Package(
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
dependencies:[
|
dependencies:[
|
||||||
.package(path: "localPackages/Arti"),
|
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1"),
|
||||||
.package(path: "localPackages/Noise"),
|
|
||||||
.package(path: "localPackages/BitFoundation"),
|
|
||||||
.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: [
|
dependencies: [
|
||||||
.product(name: "P256K", package: "swift-secp256k1"),
|
.product(name: "P256K", package: "swift-secp256k1")
|
||||||
.product(name: "BitFoundation", package: "BitFoundation"),
|
|
||||||
.product(name: "BitLogger", package: "BitLogger"),
|
|
||||||
.product(name: "Noise", package: "Noise"),
|
|
||||||
.product(name: "Tor", package: "Arti")
|
|
||||||
],
|
],
|
||||||
path: "bitchat",
|
path: "bitchat",
|
||||||
exclude: [
|
exclude: [
|
||||||
"Info.plist",
|
"Info.plist",
|
||||||
"Assets.xcassets",
|
"Assets.xcassets",
|
||||||
"_PreviewHelpers/PreviewAssets.xcassets",
|
|
||||||
"bitchat.entitlements",
|
"bitchat.entitlements",
|
||||||
"bitchat-macOS.entitlements",
|
"bitchat-macOS.entitlements",
|
||||||
"LaunchScreen.storyboard",
|
"LaunchScreen.storyboard"
|
||||||
"ViewModels/Extensions/README.md"
|
|
||||||
],
|
|
||||||
resources: [
|
|
||||||
.process("Localizable.xcstrings")
|
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
.testTarget(
|
|
||||||
name: "bitchatTests",
|
|
||||||
dependencies: [
|
|
||||||
"bitchat",
|
|
||||||
.product(name: "BitFoundation", package: "BitFoundation")
|
|
||||||
],
|
|
||||||
path: "bitchatTests",
|
|
||||||
exclude: [
|
|
||||||
"Info.plist",
|
|
||||||
"README.md"
|
|
||||||
],
|
|
||||||
resources: [
|
|
||||||
.process("Localization")
|
|
||||||
]
|
|
||||||
)
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ A decentralized peer-to-peer messaging app with dual transport architecture: loc
|
|||||||
|
|
||||||
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
|
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> Private messages have not received external security review and may contain vulnerabilities. Do not use for sensitive use cases, and do not rely on its security until it has been reviewed. Now uses the [Noise Protocol](http://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
|
||||||
|
|
||||||
## License
|
## 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.
|
||||||
@@ -19,7 +22,7 @@ This project is released into the public domain. See the [LICENSE](LICENSE) file
|
|||||||
- **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)
|
- **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
|
||||||
- **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) for mesh, NIP-17 for Nostr
|
||||||
- **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
|
||||||
@@ -91,32 +94,45 @@ For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.m
|
|||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
### Option 1: Using Xcode
|
### Option 1: Using XcodeGen (Recommended)
|
||||||
|
|
||||||
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
To run on a device there're a few steps to prepare the code:
|
### Option 2: Using Swift Package Manager
|
||||||
- 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
|
||||||
brew install just
|
cd bitchat
|
||||||
|
open Package.swift
|
||||||
```
|
```
|
||||||
|
|
||||||
|
2. Select your target device and run
|
||||||
|
|
||||||
|
### Option 3: Manual Xcode Project
|
||||||
|
|
||||||
|
1. Open Xcode and create a new iOS/macOS App
|
||||||
|
2. Copy all Swift files from the `bitchat` directory into your project
|
||||||
|
3. Update Info.plist with Bluetooth permissions
|
||||||
|
4. Set deployment target to iOS 16.0 / macOS 13.0
|
||||||
|
|
||||||
|
### Option 4: just
|
||||||
|
|
||||||
Want to try this on macos: `just run` will set it up and run from source.
|
Want to try this on macos: `just run` will set it up and run from source.
|
||||||
Run `just clean` afterwards to restore things to original state for mobile app building and development.
|
Run `just clean` afterwards to restore things to original state for mobile app building and development.
|
||||||
|
|
||||||
## Localization
|
|
||||||
|
|
||||||
- Base app resources live under `bitchat/Localization/Base.lproj/`. Add new copy to `Localizable.strings` and plural rules to `Localizable.stringsdict`.
|
|
||||||
- Share extension strings are separate in `bitchatShareExtension/Localization/Base.lproj/Localizable.strings`.
|
|
||||||
- Prefer keys that describe intent (`app_info.features.offline.title`) and reuse existing ones where possible.
|
|
||||||
- Run `xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGNING_ALLOWED=NO build` to compile-check any localization updates.
|
|
||||||
|
|||||||
@@ -184,28 +184,6 @@ 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.
|
||||||
@@ -220,25 +198,6 @@ 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
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
<?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 = "BITCHAT_LOG_LEVEL"
|
|
||||||
value = "debug"
|
|
||||||
isEnabled = "YES">
|
|
||||||
</EnvironmentVariable>
|
|
||||||
</EnvironmentVariables>
|
|
||||||
</LaunchAction>
|
|
||||||
<ProfileAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
savedToolIdentifier = ""
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
debugDocumentVersioning = "YES">
|
|
||||||
<BuildableProductRunnable
|
|
||||||
runnableDebuggingMode = "0">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
|
||||||
BuildableName = "bitchat.app"
|
|
||||||
BlueprintName = "bitchat_iOS"
|
|
||||||
ReferencedContainer = "container:bitchat.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildableProductRunnable>
|
|
||||||
</ProfileAction>
|
|
||||||
<AnalyzeAction
|
|
||||||
buildConfiguration = "Debug">
|
|
||||||
</AnalyzeAction>
|
|
||||||
<ArchiveAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
revealArchiveInOrganizer = "YES">
|
|
||||||
</ArchiveAction>
|
|
||||||
</Scheme>
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
<?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,9 +1,111 @@
|
|||||||
{
|
{
|
||||||
"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" : "universal",
|
"idiom" : "ios-marketing",
|
||||||
"platform" : "ios",
|
"scale" : "1x",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
|
After Width: | Height: | Size: 378 B |
|
After Width: | Height: | Size: 497 B |
|
After Width: | Height: | Size: 570 B |
|
After Width: | Height: | Size: 401 B |
|
After Width: | Height: | Size: 564 B |
|
After Width: | Height: | Size: 668 B |
|
After Width: | Height: | Size: 497 B |
|
After Width: | Height: | Size: 641 B |
|
After Width: | Height: | Size: 765 B |
|
After Width: | Height: | Size: 765 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 628 B |
|
After Width: | Height: | Size: 930 B |
|
After Width: | Height: | Size: 976 B |
@@ -1,36 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "image-1024.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"platform" : "ios",
|
|
||||||
"size" : "1024x1024"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"appearances" : [
|
|
||||||
{
|
|
||||||
"appearance" : "luminosity",
|
|
||||||
"value" : "dark"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"idiom" : "universal",
|
|
||||||
"platform" : "ios",
|
|
||||||
"size" : "1024x1024"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"appearances" : [
|
|
||||||
{
|
|
||||||
"appearance" : "luminosity",
|
|
||||||
"value" : "tinted"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"idiom" : "universal",
|
|
||||||
"platform" : "ios",
|
|
||||||
"size" : "1024x1024"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 11 KiB |
@@ -3,4 +3,4 @@
|
|||||||
"author" : "xcode",
|
"author" : "xcode",
|
||||||
"version" : 1
|
"version" : 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,40 +6,20 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import Tor
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import BitFoundation
|
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct BitchatApp: App {
|
struct BitchatApp: App {
|
||||||
static let bundleID = Bundle.main.bundleIdentifier ?? "chat.bitchat"
|
@StateObject private var chatViewModel = ChatViewModel()
|
||||||
static let groupID = "group.\(bundleID)"
|
|
||||||
|
|
||||||
@StateObject private var chatViewModel: ChatViewModel
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@Environment(\.scenePhase) var scenePhase
|
@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
|
||||||
|
|
||||||
private let idBridge = NostrIdentityBridge()
|
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let keychain = KeychainManager()
|
|
||||||
let idBridge = self.idBridge
|
|
||||||
_chatViewModel = StateObject(
|
|
||||||
wrappedValue: ChatViewModel(
|
|
||||||
keychain: keychain,
|
|
||||||
idBridge: idBridge,
|
|
||||||
identityManager: SecureIdentityStateManager(keychain)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
|
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
|
||||||
// Warm up georelay directory and refresh if stale (once/day)
|
// Warm up georelay directory and refresh if stale (once/day)
|
||||||
GeoRelayDirectory.shared.prefetchIfNeeded()
|
GeoRelayDirectory.shared.prefetchIfNeeded()
|
||||||
@@ -54,20 +34,15 @@ struct BitchatApp: App {
|
|||||||
// Inject live Noise service into VerificationService to avoid creating new BLE instances
|
// Inject live Noise service into VerificationService to avoid creating new BLE instances
|
||||||
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
||||||
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
||||||
let nickname = chatViewModel.nickname
|
|
||||||
DispatchQueue.global(qos: .utility).async {
|
DispatchQueue.global(qos: .utility).async {
|
||||||
let npub = try? idBridge.getCurrentNostrIdentity()?.npub
|
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
|
||||||
_ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub)
|
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
|
||||||
}
|
}
|
||||||
|
#if os(iOS)
|
||||||
appDelegate.chatViewModel = chatViewModel
|
appDelegate.chatViewModel = chatViewModel
|
||||||
|
#elseif os(macOS)
|
||||||
// Initialize network activation policy; will start Tor/Nostr only when allowed
|
appDelegate.chatViewModel = chatViewModel
|
||||||
NetworkActivationService.shared.start()
|
#endif
|
||||||
|
|
||||||
// Start presence service (will wait for Tor readiness)
|
|
||||||
GeohashPresenceService.shared.start()
|
|
||||||
|
|
||||||
// Check for shared content
|
// Check for shared content
|
||||||
checkForSharedContent()
|
checkForSharedContent()
|
||||||
}
|
}
|
||||||
@@ -79,41 +54,10 @@ struct BitchatApp: App {
|
|||||||
switch newPhase {
|
switch newPhase {
|
||||||
case .background:
|
case .background:
|
||||||
// Keep BLE mesh running in background; BLEService adapts scanning automatically
|
// Keep BLE mesh running in background; BLEService adapts scanning automatically
|
||||||
// Always send Tor to dormant on background for a clean restart later.
|
break
|
||||||
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:
|
case .active:
|
||||||
// Restart services when becoming active
|
// Restart services when becoming active
|
||||||
chatViewModel.meshService.startServices()
|
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()
|
checkForSharedContent()
|
||||||
case .inactive:
|
case .inactive:
|
||||||
break
|
break
|
||||||
@@ -146,7 +90,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: BitchatApp.groupID) else {
|
guard let userDefaults = UserDefaults(suiteName: "group.chat.bitchat") else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,23 +131,19 @@ struct BitchatApp: App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
final class AppDelegate: NSObject, UIApplicationDelegate {
|
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 {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationWillTerminate(_ application: UIApplication) {
|
|
||||||
chatViewModel?.applicationWillTerminate()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
import AppKit
|
import AppKit
|
||||||
|
|
||||||
final class MacAppDelegate: NSObject, NSApplicationDelegate {
|
class MacAppDelegate: NSObject, NSApplicationDelegate {
|
||||||
weak var chatViewModel: ChatViewModel?
|
weak var chatViewModel: ChatViewModel?
|
||||||
|
|
||||||
func applicationWillTerminate(_ notification: Notification) {
|
func applicationWillTerminate(_ notification: Notification) {
|
||||||
@@ -216,7 +156,7 @@ final class MacAppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||||
static let shared = NotificationDelegate()
|
static let shared = NotificationDelegate()
|
||||||
weak var chatViewModel: ChatViewModel?
|
weak var chatViewModel: ChatViewModel?
|
||||||
|
|
||||||
@@ -229,7 +169,7 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
// Get peer ID from userInfo
|
// Get peer ID from userInfo
|
||||||
if let peerID = userInfo["peerID"] as? String {
|
if let peerID = userInfo["peerID"] as? String {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.chatViewModel?.startPrivateChat(with: PeerID(str: peerID))
|
self.chatViewModel?.startPrivateChat(with: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -254,15 +194,10 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
// Get peer ID from userInfo
|
// Get peer ID from userInfo
|
||||||
if let peerID = userInfo["peerID"] as? String {
|
if let peerID = userInfo["peerID"] as? String {
|
||||||
// Don't show notification if the private chat is already open
|
// Don't show notification if the private chat is already open
|
||||||
// Access main-actor-isolated property via Task
|
if chatViewModel?.selectedPrivateChatPeer == peerID {
|
||||||
Task { @MainActor in
|
completionHandler([])
|
||||||
if self.chatViewModel?.selectedPrivateChatPeer == PeerID(str: peerID) {
|
return
|
||||||
completionHandler([])
|
|
||||||
} else {
|
|
||||||
completionHandler([.banner, .sound])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Suppress geohash activity notification if we're already in that geohash channel
|
// Suppress geohash activity notification if we're already in that geohash channel
|
||||||
@@ -280,3 +215,8 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
var nilIfEmpty: String? {
|
||||||
|
self.isEmpty ? nil : self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,203 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import ImageIO
|
|
||||||
import UniformTypeIdentifiers
|
|
||||||
#if os(iOS)
|
|
||||||
import UIKit
|
|
||||||
#else
|
|
||||||
import AppKit
|
|
||||||
#endif
|
|
||||||
|
|
||||||
enum ImageUtilsError: Error {
|
|
||||||
case invalidImage
|
|
||||||
case encodingFailed
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ImageUtils {
|
|
||||||
private static let compressionQuality: CGFloat = 0.82
|
|
||||||
private static let targetImageBytes: Int = 45_000
|
|
||||||
|
|
||||||
static func processImage(at url: URL, maxDimension: CGFloat = 448) throws -> URL {
|
|
||||||
// Security H1: Check file size BEFORE reading into memory
|
|
||||||
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
|
|
||||||
guard let fileSize = attrs[.size] as? Int else {
|
|
||||||
throw ImageUtilsError.invalidImage
|
|
||||||
}
|
|
||||||
// Allow up to 10MB source images (will be scaled down)
|
|
||||||
guard fileSize <= 10 * 1024 * 1024 else {
|
|
||||||
throw ImageUtilsError.invalidImage
|
|
||||||
}
|
|
||||||
|
|
||||||
let data = try Data(contentsOf: url)
|
|
||||||
#if os(iOS)
|
|
||||||
guard let image = UIImage(data: data) else { throw ImageUtilsError.invalidImage }
|
|
||||||
return try processImage(image, maxDimension: maxDimension)
|
|
||||||
#else
|
|
||||||
guard let image = NSImage(data: data) else { throw ImageUtilsError.invalidImage }
|
|
||||||
return try processImage(image, maxDimension: maxDimension)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448) throws -> URL {
|
|
||||||
return try autoreleasepool {
|
|
||||||
// Scale the image first
|
|
||||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
|
||||||
|
|
||||||
// Get CGImage from UIImage - this is the key to stripping metadata
|
|
||||||
guard let cgImage = scaled.cgImage else {
|
|
||||||
throw ImageUtilsError.encodingFailed
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use CGImageDestination to encode without metadata (same as macOS)
|
|
||||||
var quality = compressionQuality
|
|
||||||
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
|
||||||
throw ImageUtilsError.encodingFailed
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compress to target size
|
|
||||||
while jpegData.count > targetImageBytes && quality > 0.3 {
|
|
||||||
quality -= 0.1
|
|
||||||
autoreleasepool {
|
|
||||||
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
|
||||||
jpegData = next
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let outputURL = try makeOutputURL()
|
|
||||||
try jpegData.write(to: outputURL, options: .atomic)
|
|
||||||
return outputURL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func scaledImage(_ image: UIImage, maxDimension: CGFloat) -> UIImage {
|
|
||||||
let size = image.size
|
|
||||||
let maxSide = max(size.width, size.height)
|
|
||||||
guard maxSide > maxDimension else { return image }
|
|
||||||
let scale = maxDimension / maxSide
|
|
||||||
let newSize = CGSize(width: size.width * scale, height: size.height * scale)
|
|
||||||
|
|
||||||
// Draw into a new context to get a clean CGImage without metadata
|
|
||||||
UIGraphicsBeginImageContextWithOptions(newSize, true, 1.0)
|
|
||||||
image.draw(in: CGRect(origin: .zero, size: newSize))
|
|
||||||
let rendered = UIGraphicsGetImageFromCurrentImageContext()
|
|
||||||
UIGraphicsEndImageContext()
|
|
||||||
return rendered ?? image
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
|
|
||||||
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
|
||||||
guard let data = CFDataCreateMutable(nil, 0) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
|
||||||
// By only specifying compression quality and no metadata keys,
|
|
||||||
// we ensure a clean JPEG with no privacy-leaking information
|
|
||||||
let options: [CFString: Any] = [
|
|
||||||
kCGImageDestinationLossyCompressionQuality: quality
|
|
||||||
]
|
|
||||||
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
|
|
||||||
guard CGImageDestinationFinalize(destination) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return data as Data
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448) throws -> URL {
|
|
||||||
return try autoreleasepool {
|
|
||||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
|
||||||
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
|
||||||
throw ImageUtilsError.encodingFailed
|
|
||||||
}
|
|
||||||
let width = inputCG.width
|
|
||||||
let height = inputCG.height
|
|
||||||
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
|
|
||||||
guard let context = CGContext(
|
|
||||||
data: nil,
|
|
||||||
width: width,
|
|
||||||
height: height,
|
|
||||||
bitsPerComponent: 8,
|
|
||||||
bytesPerRow: 0,
|
|
||||||
space: colorSpace,
|
|
||||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
|
||||||
) else {
|
|
||||||
throw ImageUtilsError.encodingFailed
|
|
||||||
}
|
|
||||||
context.draw(inputCG, in: CGRect(x: 0, y: 0, width: width, height: height))
|
|
||||||
guard let cgImage = context.makeImage() else {
|
|
||||||
throw ImageUtilsError.encodingFailed
|
|
||||||
}
|
|
||||||
var quality = compressionQuality
|
|
||||||
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
|
||||||
throw ImageUtilsError.encodingFailed
|
|
||||||
}
|
|
||||||
while jpegData.count > targetImageBytes && quality > 0.3 {
|
|
||||||
quality -= 0.1
|
|
||||||
autoreleasepool {
|
|
||||||
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
|
||||||
jpegData = next
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let outputURL = try makeOutputURL()
|
|
||||||
try jpegData.write(to: outputURL, options: .atomic)
|
|
||||||
return outputURL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func scaledImage(_ image: NSImage, maxDimension: CGFloat) -> NSImage {
|
|
||||||
let size = image.size
|
|
||||||
let maxSide = max(size.width, size.height)
|
|
||||||
guard maxSide > maxDimension else { return image }
|
|
||||||
let scale = maxDimension / maxSide
|
|
||||||
let newSize = NSSize(width: size.width * scale, height: size.height * scale)
|
|
||||||
let scaledImage = NSImage(size: newSize)
|
|
||||||
scaledImage.lockFocus()
|
|
||||||
image.draw(in: NSRect(origin: .zero, size: newSize),
|
|
||||||
from: NSRect(origin: .zero, size: size),
|
|
||||||
operation: .copy,
|
|
||||||
fraction: 1.0)
|
|
||||||
scaledImage.unlockFocus()
|
|
||||||
return scaledImage
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
|
|
||||||
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
|
||||||
guard let data = CFDataCreateMutable(nil, 0) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
|
||||||
// By only specifying compression quality and no metadata keys,
|
|
||||||
// we ensure a clean JPEG with no privacy-leaking information
|
|
||||||
let options: [CFString: Any] = [
|
|
||||||
kCGImageDestinationLossyCompressionQuality: quality
|
|
||||||
]
|
|
||||||
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
|
|
||||||
guard CGImageDestinationFinalize(destination) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return data as Data
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
private static func makeOutputURL() throws -> URL {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
|
||||||
let fileName = "img_\(formatter.string(from: Date())).jpg"
|
|
||||||
|
|
||||||
let directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true)
|
|
||||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
|
||||||
return directory.appendingPathComponent(fileName)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func applicationFilesDirectory() throws -> URL {
|
|
||||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
|
||||||
return base.appendingPathComponent("files", isDirectory: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import AVFoundation
|
|
||||||
import BitLogger
|
|
||||||
|
|
||||||
/// Controls playback for a single voice note and coordinates exclusive playback across the app.
|
|
||||||
final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlayerDelegate {
|
|
||||||
@Published private(set) var isPlaying: Bool = false
|
|
||||||
@Published private(set) var currentTime: TimeInterval = 0
|
|
||||||
@Published private(set) var duration: TimeInterval = 0
|
|
||||||
@Published private(set) var progress: Double = 0
|
|
||||||
|
|
||||||
/// rounded so 4.9s shows "00:05"
|
|
||||||
var roundedDuration: Int {
|
|
||||||
guard duration.isFinite else { return 0 }
|
|
||||||
return Int(duration.rounded())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// ceil so "00:01" stays visible until playback ends, capped to rounded duration
|
|
||||||
var remainingSeconds: Int {
|
|
||||||
let remaining = max(0, duration - currentTime)
|
|
||||||
return min(roundedDuration, Int(ceil(remaining)))
|
|
||||||
}
|
|
||||||
|
|
||||||
private var player: AVAudioPlayer?
|
|
||||||
private var timer: Timer?
|
|
||||||
private var url: URL
|
|
||||||
|
|
||||||
init(url: URL) {
|
|
||||||
self.url = url
|
|
||||||
super.init()
|
|
||||||
// Don't load anything eagerly - wait until user interaction or view is fully displayed
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadDuration() {
|
|
||||||
guard duration == 0 else { return }
|
|
||||||
|
|
||||||
DispatchQueue.global(qos: .utility).async { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
do {
|
|
||||||
let player = try AVAudioPlayer(contentsOf: self.url)
|
|
||||||
let loadedDuration = player.duration
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
guard let self = self, self.duration == 0 else { return }
|
|
||||||
self.duration = loadedDuration
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("Failed to load audio duration: \(error)", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
timer?.invalidate()
|
|
||||||
}
|
|
||||||
|
|
||||||
func replaceURL(_ url: URL) {
|
|
||||||
guard url != self.url else { return }
|
|
||||||
stop()
|
|
||||||
self.url = url
|
|
||||||
player = nil
|
|
||||||
duration = 0
|
|
||||||
// Duration will be loaded on demand when needed
|
|
||||||
}
|
|
||||||
|
|
||||||
func togglePlayback() {
|
|
||||||
isPlaying ? pause() : play()
|
|
||||||
}
|
|
||||||
|
|
||||||
func play() {
|
|
||||||
guard ensurePlayerReady() else { return }
|
|
||||||
VoiceNotePlaybackCoordinator.shared.activate(self)
|
|
||||||
player?.play()
|
|
||||||
startTimer()
|
|
||||||
updateProgress()
|
|
||||||
isPlaying = true
|
|
||||||
}
|
|
||||||
|
|
||||||
func pause() {
|
|
||||||
player?.pause()
|
|
||||||
stopTimer()
|
|
||||||
updateProgress()
|
|
||||||
isPlaying = false
|
|
||||||
}
|
|
||||||
|
|
||||||
func stop() {
|
|
||||||
player?.stop()
|
|
||||||
player?.currentTime = 0
|
|
||||||
stopTimer()
|
|
||||||
updateProgress()
|
|
||||||
isPlaying = false
|
|
||||||
VoiceNotePlaybackCoordinator.shared.deactivate(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
func seek(to fraction: Double) {
|
|
||||||
guard ensurePlayerReady() else { return }
|
|
||||||
let clamped = max(0, min(1, fraction))
|
|
||||||
if let player = player {
|
|
||||||
player.currentTime = clamped * player.duration
|
|
||||||
if isPlaying {
|
|
||||||
player.play()
|
|
||||||
}
|
|
||||||
updateProgress()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - AVAudioPlayerDelegate
|
|
||||||
|
|
||||||
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
|
|
||||||
// Delegate callback may be on background thread - ensure main thread for UI updates
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
self.stopTimer()
|
|
||||||
self.updateProgress()
|
|
||||||
self.isPlaying = false
|
|
||||||
VoiceNotePlaybackCoordinator.shared.deactivate(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Helpers
|
|
||||||
|
|
||||||
private func preparePlayer(for url: URL) {
|
|
||||||
// Prepare player synchronously (only called when playback is requested)
|
|
||||||
do {
|
|
||||||
let player = try AVAudioPlayer(contentsOf: url)
|
|
||||||
player.delegate = self
|
|
||||||
player.prepareToPlay()
|
|
||||||
self.player = player
|
|
||||||
duration = player.duration
|
|
||||||
currentTime = player.currentTime
|
|
||||||
progress = duration > 0 ? currentTime / duration : 0
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("Voice note playback failed for \(url.lastPathComponent): \(error)", category: .session)
|
|
||||||
player = nil
|
|
||||||
duration = 0
|
|
||||||
currentTime = 0
|
|
||||||
progress = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func ensurePlayerReady() -> Bool {
|
|
||||||
if player == nil {
|
|
||||||
preparePlayer(for: url)
|
|
||||||
}
|
|
||||||
#if os(iOS)
|
|
||||||
let session = AVAudioSession.sharedInstance()
|
|
||||||
do {
|
|
||||||
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
|
|
||||||
try session.setActive(true, options: [])
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("Failed to activate audio session: \(error)", category: .session)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
return player != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startTimer() {
|
|
||||||
if timer != nil { return }
|
|
||||||
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
|
|
||||||
self?.updateProgress()
|
|
||||||
}
|
|
||||||
if let timer = timer {
|
|
||||||
RunLoop.main.add(timer, forMode: .common)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func stopTimer() {
|
|
||||||
timer?.invalidate()
|
|
||||||
timer = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateProgress() {
|
|
||||||
guard let player = player else {
|
|
||||||
currentTime = 0
|
|
||||||
duration = 0
|
|
||||||
progress = 0
|
|
||||||
return
|
|
||||||
}
|
|
||||||
currentTime = player.currentTime
|
|
||||||
duration = player.duration
|
|
||||||
progress = duration > 0 ? currentTime / duration : 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ensures only one voice note plays at a time.
|
|
||||||
final class VoiceNotePlaybackCoordinator {
|
|
||||||
static let shared = VoiceNotePlaybackCoordinator()
|
|
||||||
|
|
||||||
private weak var activeController: VoiceNotePlaybackController?
|
|
||||||
|
|
||||||
private init() {}
|
|
||||||
|
|
||||||
func activate(_ controller: VoiceNotePlaybackController) {
|
|
||||||
if activeController === controller {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
activeController?.pause()
|
|
||||||
activeController = controller
|
|
||||||
}
|
|
||||||
|
|
||||||
func deactivate(_ controller: VoiceNotePlaybackController) {
|
|
||||||
if activeController === controller {
|
|
||||||
activeController = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import AVFoundation
|
|
||||||
|
|
||||||
/// Manages audio capture for mesh voice notes with predictable encoding settings.
|
|
||||||
actor VoiceRecorder {
|
|
||||||
enum RecorderError: Error {
|
|
||||||
case microphoneAccessDenied
|
|
||||||
case recorderInitializationFailed
|
|
||||||
case recordingInProgress
|
|
||||||
}
|
|
||||||
|
|
||||||
static let shared = VoiceRecorder()
|
|
||||||
|
|
||||||
private let paddingInterval: TimeInterval = 0.5
|
|
||||||
private let maxRecordingDuration: TimeInterval = 120
|
|
||||||
static let minRecordingDuration: TimeInterval = 1
|
|
||||||
|
|
||||||
private var recorder: AVAudioRecorder?
|
|
||||||
private var currentURL: URL?
|
|
||||||
|
|
||||||
// MARK: - Permissions
|
|
||||||
|
|
||||||
nonisolated
|
|
||||||
func requestPermission() async -> Bool {
|
|
||||||
#if os(iOS)
|
|
||||||
return await withCheckedContinuation { continuation in
|
|
||||||
AVAudioSession.sharedInstance().requestRecordPermission { granted in
|
|
||||||
continuation.resume(returning: granted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#elseif os(macOS)
|
|
||||||
return await withCheckedContinuation { continuation in
|
|
||||||
AVCaptureDevice.requestAccess(for: .audio) { granted in
|
|
||||||
continuation.resume(returning: granted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
return true
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Recording Lifecycle
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
func startRecording() throws -> URL {
|
|
||||||
if recorder?.isRecording == true {
|
|
||||||
throw RecorderError.recordingInProgress
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
let session = AVAudioSession.sharedInstance()
|
|
||||||
guard session.recordPermission == .granted else {
|
|
||||||
throw RecorderError.microphoneAccessDenied
|
|
||||||
}
|
|
||||||
#if targetEnvironment(simulator)
|
|
||||||
// allowBluetoothHFP is not available on iOS Simulator
|
|
||||||
try session.setCategory(
|
|
||||||
.playAndRecord,
|
|
||||||
mode: .default,
|
|
||||||
options: [.defaultToSpeaker, .allowBluetoothA2DP]
|
|
||||||
)
|
|
||||||
#else
|
|
||||||
try session.setCategory(
|
|
||||||
.playAndRecord,
|
|
||||||
mode: .default,
|
|
||||||
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
|
|
||||||
)
|
|
||||||
#endif
|
|
||||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
|
||||||
#endif
|
|
||||||
#if os(macOS)
|
|
||||||
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
|
|
||||||
throw RecorderError.microphoneAccessDenied
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
let outputURL = try makeOutputURL()
|
|
||||||
let settings: [String: Any] = [
|
|
||||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
|
||||||
AVSampleRateKey: 16_000,
|
|
||||||
AVNumberOfChannelsKey: 1,
|
|
||||||
AVEncoderBitRateKey: 16_000
|
|
||||||
]
|
|
||||||
|
|
||||||
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
|
||||||
audioRecorder.isMeteringEnabled = true
|
|
||||||
audioRecorder.prepareToRecord()
|
|
||||||
audioRecorder.record(forDuration: maxRecordingDuration)
|
|
||||||
|
|
||||||
recorder = audioRecorder
|
|
||||||
currentURL = outputURL
|
|
||||||
return outputURL
|
|
||||||
}
|
|
||||||
|
|
||||||
func stopRecording() async -> URL? {
|
|
||||||
guard let recorder, recorder.isRecording else {
|
|
||||||
return currentURL
|
|
||||||
}
|
|
||||||
|
|
||||||
let sessionURL = currentURL
|
|
||||||
|
|
||||||
try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000))
|
|
||||||
|
|
||||||
recorder.stop()
|
|
||||||
|
|
||||||
// A new session may have started during the sleep — don't touch its state
|
|
||||||
if self.recorder === recorder {
|
|
||||||
cleanupSession()
|
|
||||||
self.recorder = nil
|
|
||||||
currentURL = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return sessionURL
|
|
||||||
}
|
|
||||||
|
|
||||||
func cancelRecording() {
|
|
||||||
if let recorder, recorder.isRecording {
|
|
||||||
recorder.stop()
|
|
||||||
}
|
|
||||||
cleanupSession()
|
|
||||||
if let currentURL {
|
|
||||||
try? FileManager.default.removeItem(at: currentURL)
|
|
||||||
}
|
|
||||||
recorder = nil
|
|
||||||
currentURL = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helpers
|
|
||||||
|
|
||||||
private func makeOutputURL() throws -> URL {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
|
||||||
let fileName = "voice_\(formatter.string(from: Date())).m4a"
|
|
||||||
|
|
||||||
let baseDirectory = try applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true)
|
|
||||||
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: nil)
|
|
||||||
return baseDirectory.appendingPathComponent(fileName)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applicationFilesDirectory() throws -> URL {
|
|
||||||
#if os(iOS)
|
|
||||||
return try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
|
||||||
.appendingPathComponent("files", isDirectory: true)
|
|
||||||
#else
|
|
||||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
|
||||||
return base.appendingPathComponent("files", isDirectory: true)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private func cleanupSession() {
|
|
||||||
#if os(iOS)
|
|
||||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import AVFoundation
|
|
||||||
import Foundation
|
|
||||||
import BitLogger
|
|
||||||
|
|
||||||
/// Generates and caches downsampled waveforms for audio files so UI rendering is cheap.
|
|
||||||
final class WaveformCache {
|
|
||||||
static let shared = WaveformCache()
|
|
||||||
|
|
||||||
private let queue = DispatchQueue(label: "com.bitchat.waveform-cache", attributes: .concurrent)
|
|
||||||
private var cache: [URL: (waveform: [Float], lastAccess: Date)] = [:]
|
|
||||||
private let maxCacheSize = 20 // Limit cache to prevent unbounded memory growth
|
|
||||||
|
|
||||||
private init() {}
|
|
||||||
|
|
||||||
func cachedWaveform(for url: URL) -> [Float]? {
|
|
||||||
queue.sync {
|
|
||||||
guard let entry = cache[url] else { return nil }
|
|
||||||
return entry.waveform
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func waveform(for url: URL, bins: Int = 120, completion: @escaping ([Float]) -> Void) {
|
|
||||||
queue.async { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
|
|
||||||
// Check cache (read-only, no update needed on cache hit for performance)
|
|
||||||
if let entry = self.cache[url] {
|
|
||||||
DispatchQueue.main.async { completion(entry.waveform) }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let computed = self.computeWaveform(url: url, bins: bins) else {
|
|
||||||
DispatchQueue.main.async { completion([]) }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
self.queue.async(flags: .barrier) { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
|
|
||||||
// Evict oldest entry if cache is full
|
|
||||||
if self.cache.count >= self.maxCacheSize {
|
|
||||||
if let oldest = self.cache.min(by: { $0.value.lastAccess < $1.value.lastAccess }) {
|
|
||||||
self.cache.removeValue(forKey: oldest.key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.cache[url] = (computed, Date())
|
|
||||||
}
|
|
||||||
DispatchQueue.main.async { completion(computed) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func purge(url: URL) {
|
|
||||||
queue.async(flags: .barrier) { [weak self] in
|
|
||||||
self?.cache.removeValue(forKey: url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func purgeAll() {
|
|
||||||
queue.async(flags: .barrier) { [weak self] in
|
|
||||||
self?.cache.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func computeWaveform(url: URL, bins: Int) -> [Float]? {
|
|
||||||
guard bins > 0 else { return nil }
|
|
||||||
// Use autoreleasepool to manage memory from audio buffer allocations
|
|
||||||
return autoreleasepool {
|
|
||||||
do {
|
|
||||||
let audioFile = try AVAudioFile(forReading: url)
|
|
||||||
let length = Int(audioFile.length)
|
|
||||||
guard length > 0 else { return nil }
|
|
||||||
|
|
||||||
guard let buffer = AVAudioPCMBuffer(pcmFormat: audioFile.processingFormat, frameCapacity: AVAudioFrameCount(length)) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
try audioFile.read(into: buffer, frameCount: AVAudioFrameCount(length))
|
|
||||||
guard let channelData = buffer.floatChannelData else { return nil }
|
|
||||||
|
|
||||||
let channelCount = Int(audioFile.processingFormat.channelCount)
|
|
||||||
let frameLength = Int(buffer.frameLength)
|
|
||||||
let samplesPerBin = max(1, frameLength / bins)
|
|
||||||
|
|
||||||
var magnitudes: [Float] = Array(repeating: 0, count: bins)
|
|
||||||
for bin in 0..<bins {
|
|
||||||
let start = bin * samplesPerBin
|
|
||||||
let end = min(frameLength, start + samplesPerBin)
|
|
||||||
if start >= end { break }
|
|
||||||
|
|
||||||
var sum: Float = 0
|
|
||||||
var sampleCount = 0
|
|
||||||
for frame in start..<end {
|
|
||||||
var sampleValue: Float = 0
|
|
||||||
for channel in 0..<channelCount {
|
|
||||||
sampleValue += fabsf(channelData[channel][frame])
|
|
||||||
}
|
|
||||||
sum += sampleValue / Float(channelCount)
|
|
||||||
sampleCount += 1
|
|
||||||
}
|
|
||||||
magnitudes[bin] = sampleCount > 0 ? sum / Float(sampleCount) : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if let maxMagnitude = magnitudes.max(), maxMagnitude > 0 {
|
|
||||||
magnitudes = magnitudes.map { min($0 / maxMagnitude, 1.0) }
|
|
||||||
}
|
|
||||||
return magnitudes
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("Waveform extraction failed for \(url.lastPathComponent): \(error)", category: .session)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -81,14 +81,13 @@
|
|||||||
///
|
///
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import BitFoundation
|
|
||||||
|
|
||||||
// MARK: - Three-Layer Identity Model
|
// MARK: - Three-Layer Identity Model
|
||||||
|
|
||||||
/// 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: PeerID // 8 random bytes
|
let peerID: String // 8 random bytes
|
||||||
let sessionStart: Date
|
let sessionStart: Date
|
||||||
var handshakeState: HandshakeState
|
var handshakeState: HandshakeState
|
||||||
}
|
}
|
||||||
@@ -159,6 +158,23 @@ struct IdentityCache: Codable {
|
|||||||
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: - Migration Support
|
// MARK: - Migration Support
|
||||||
|
|||||||
@@ -90,64 +90,27 @@
|
|||||||
/// - Advanced conflict resolution
|
/// - Advanced conflict resolution
|
||||||
///
|
///
|
||||||
|
|
||||||
import BitLogger
|
|
||||||
import BitFoundation
|
|
||||||
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.
|
||||||
final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
class SecureIdentityStateManager {
|
||||||
private let keychain: KeychainManagerProtocol
|
static let shared = SecureIdentityStateManager()
|
||||||
|
|
||||||
|
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: [PeerID: EphemeralIdentity] = [:]
|
private var ephemeralSessions: [String: 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)
|
||||||
|
|
||||||
@@ -159,16 +122,14 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
// Encryption key
|
// Encryption key
|
||||||
private let encryptionKey: SymmetricKey
|
private let encryptionKey: SymmetricKey
|
||||||
|
|
||||||
init(_ keychain: KeychainManagerProtocol) {
|
private init() {
|
||||||
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 {
|
||||||
@@ -176,7 +137,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
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
|
||||||
@@ -185,13 +146,9 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
loadIdentityCache()
|
loadIdentityCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
|
||||||
forceSave()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Secure Loading/Saving
|
// MARK: - Secure Loading/Saving
|
||||||
|
|
||||||
private func loadIdentityCache() {
|
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
|
||||||
@@ -203,11 +160,16 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
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.error(error, context: "Failed to load identity cache", category: .security)
|
SecureLogger.logError(error, context: "Failed to load identity cache", category: SecureLogger.security)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveIdentityCache() {
|
deinit {
|
||||||
|
// Force save any pending changes
|
||||||
|
forceSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveIdentityCache() {
|
||||||
// Mark that we need to save
|
// Mark that we need to save
|
||||||
pendingSave = true
|
pendingSave = true
|
||||||
|
|
||||||
@@ -229,17 +191,35 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
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.debug("Identity cache saved to keychain", category: .security)
|
SecureLogger.log("Identity cache saved to keychain", category: SecureLogger.security, level: .debug)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error(error, context: "Failed to save identity cache", category: .security)
|
SecureLogger.logError(error, context: "Failed to save identity cache", category: SecureLogger.security)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force immediate save (for app termination)
|
// Force immediate save (for app termination)
|
||||||
func forceSave() {
|
func forceSave() {
|
||||||
saveTimer?.invalidate()
|
saveTimer?.invalidate()
|
||||||
performSave()
|
if pendingSave {
|
||||||
|
performSave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Identity Resolution
|
||||||
|
|
||||||
|
func resolveIdentity(peerID: String, claimedNickname: String) -> IdentityHint {
|
||||||
|
queue.sync {
|
||||||
|
// Check if we have candidates based on nickname
|
||||||
|
if let fingerprints = cache.nicknameIndex[claimedNickname] {
|
||||||
|
if fingerprints.count == 1 {
|
||||||
|
return .likelyKnown(fingerprint: fingerprints.first!)
|
||||||
|
} else {
|
||||||
|
return .ambiguous(candidates: fingerprints)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return .unknown
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Social Identity Management
|
// MARK: - Social Identity Management
|
||||||
@@ -321,26 +301,38 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Retrieve cryptographic identity by fingerprint
|
||||||
|
func getCryptographicIdentity(for fingerprint: String) -> CryptographicIdentity? {
|
||||||
|
queue.sync { cryptographicIdentities[fingerprint] }
|
||||||
|
}
|
||||||
|
|
||||||
/// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID
|
/// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID
|
||||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity] {
|
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] {
|
||||||
queue.sync {
|
queue.sync {
|
||||||
// Defensive: ensure hex and correct length
|
// Defensive: ensure hex and correct length
|
||||||
guard peerID.isShort else { return [] }
|
guard PeerIDResolver.isShortID(peerID) else { return [] }
|
||||||
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
|
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAllSocialIdentities() -> [SocialIdentity] {
|
||||||
|
queue.sync {
|
||||||
|
return Array(cache.socialIdentities.values)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateSocialIdentity(_ identity: SocialIdentity) {
|
func updateSocialIdentity(_ identity: SocialIdentity) {
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname
|
|
||||||
self.cache.socialIdentities[identity.fingerprint] = identity
|
self.cache.socialIdentities[identity.fingerprint] = identity
|
||||||
|
|
||||||
// Update nickname index
|
// Update nickname index
|
||||||
if let previousClaimedNickname,
|
if let existingIdentity = self.cache.socialIdentities[identity.fingerprint] {
|
||||||
previousClaimedNickname != identity.claimedNickname {
|
// Remove old nickname from index if changed
|
||||||
self.cache.nicknameIndex[previousClaimedNickname]?.remove(identity.fingerprint)
|
if existingIdentity.claimedNickname != identity.claimedNickname {
|
||||||
if self.cache.nicknameIndex[previousClaimedNickname]?.isEmpty == true {
|
self.cache.nicknameIndex[existingIdentity.claimedNickname]?.remove(identity.fingerprint)
|
||||||
self.cache.nicknameIndex.removeValue(forKey: previousClaimedNickname)
|
if self.cache.nicknameIndex[existingIdentity.claimedNickname]?.isEmpty == true {
|
||||||
|
self.cache.nicknameIndex.removeValue(forKey: existingIdentity.claimedNickname)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,7 +395,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func setBlocked(_ fingerprint: String, isBlocked: Bool) {
|
func setBlocked(_ fingerprint: String, isBlocked: Bool) {
|
||||||
SecureLogger.info("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: .security)
|
SecureLogger.log("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: SecureLogger.security, level: .info)
|
||||||
|
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
if var identity = self.cache.socialIdentities[fingerprint] {
|
if var identity = self.cache.socialIdentities[fingerprint] {
|
||||||
@@ -455,7 +447,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
|
|
||||||
// MARK: - Ephemeral Session Management
|
// MARK: - Ephemeral Session Management
|
||||||
|
|
||||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {
|
func registerEphemeralSession(peerID: String, handshakeState: HandshakeState = .none) {
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
self.ephemeralSessions[peerID] = EphemeralIdentity(
|
self.ephemeralSessions[peerID] = EphemeralIdentity(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
@@ -465,7 +457,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateHandshakeState(peerID: PeerID, state: HandshakeState) {
|
func updateHandshakeState(peerID: String, state: HandshakeState) {
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
self.ephemeralSessions[peerID]?.handshakeState = state
|
self.ephemeralSessions[peerID]?.handshakeState = state
|
||||||
|
|
||||||
@@ -477,32 +469,81 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.warning("Clearing all identity data", category: .security)
|
SecureLogger.log("Clearing all identity data", category: SecureLogger.security, level: .warning)
|
||||||
|
|
||||||
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: PeerID) {
|
func removeEphemeralSession(peerID: String) {
|
||||||
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.info("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: .security)
|
SecureLogger.log("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: SecureLogger.security, level: .info)
|
||||||
|
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
if verified {
|
if verified {
|
||||||
@@ -532,16 +573,4 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
return cache.verifiedFingerprints
|
return cache.verifiedFingerprints
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var debugNicknameIndex: [String: Set<String>] {
|
|
||||||
queue.sync { cache.nicknameIndex }
|
|
||||||
}
|
|
||||||
|
|
||||||
func debugEphemeralSession(for peerID: PeerID) -> EphemeralIdentity? {
|
|
||||||
queue.sync { ephemeralSessions[peerID] }
|
|
||||||
}
|
|
||||||
|
|
||||||
func debugLastInteraction(for fingerprint: String) -> Date? {
|
|
||||||
queue.sync { cache.lastInteractions[fingerprint] }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>AppGroupID</key>
|
|
||||||
<string>$(APP_GROUP_ID)</string>
|
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
@@ -37,14 +35,10 @@
|
|||||||
<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>
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
|
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
|
||||||
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
|
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>bluetooth-central</string>
|
<string>bluetooth-central</string>
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
//
|
|
||||||
// BitchatMessage+Media.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import BitFoundation
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
extension BitchatMessage {
|
|
||||||
enum Media {
|
|
||||||
case voice(URL)
|
|
||||||
case image(URL)
|
|
||||||
|
|
||||||
var url: URL {
|
|
||||||
switch self {
|
|
||||||
case .voice(let url), .image(let url):
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
|
|
||||||
private struct Cache {
|
|
||||||
let filesDir: URL?
|
|
||||||
|
|
||||||
static let shared = Cache()
|
|
||||||
private init() {
|
|
||||||
do {
|
|
||||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
|
||||||
let filesDir = base.appendingPathComponent("files", isDirectory: true)
|
|
||||||
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
|
||||||
self.filesDir = filesDir
|
|
||||||
} catch {
|
|
||||||
filesDir = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mediaAttachment(for nickname: String) -> Media? {
|
|
||||||
guard let baseDirectory = Cache.shared.filesDir else { return nil }
|
|
||||||
|
|
||||||
func url(for category: MimeType.Category) -> URL? {
|
|
||||||
guard content.hasPrefix(category.messagePrefix),
|
|
||||||
let filename = String(content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty
|
|
||||||
else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check outgoing first for sent messages, incoming for received
|
|
||||||
let subdir = sender == nickname ? "\(category.mediaDir)/outgoing" : "\(category.mediaDir)/incoming"
|
|
||||||
|
|
||||||
// Construct URL directly without fileExists check (avoids blocking disk I/O in view body)
|
|
||||||
// Files are checked during playback/display, so missing files fail gracefully
|
|
||||||
let directory = baseDirectory.appendingPathComponent(subdir, isDirectory: true)
|
|
||||||
return directory.appendingPathComponent(filename)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let url = url(for: .audio) {
|
|
||||||
return .voice(url)
|
|
||||||
}
|
|
||||||
if let url = url(for: .image) {
|
|
||||||
return .image(url)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CoreBluetooth
|
import CoreBluetooth
|
||||||
import BitFoundation
|
|
||||||
|
|
||||||
/// Represents a peer in the BitChat network with all associated metadata
|
/// Represents a peer in the BitChat network with all associated metadata
|
||||||
struct BitchatPeer: Equatable {
|
struct BitchatPeer: Identifiable, Equatable {
|
||||||
let peerID: PeerID // Hex-encoded peer ID
|
let id: String // Hex-encoded peer ID
|
||||||
let noisePublicKey: Data
|
let noisePublicKey: Data
|
||||||
let nickname: String
|
let nickname: String
|
||||||
let lastSeen: Date
|
let lastSeen: Date
|
||||||
@@ -52,7 +51,7 @@ struct BitchatPeer: Equatable {
|
|||||||
|
|
||||||
// Display helpers
|
// Display helpers
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
nickname.isEmpty ? String(peerID.id.prefix(8)) : nickname
|
nickname.isEmpty ? String(id.prefix(8)) : nickname
|
||||||
}
|
}
|
||||||
|
|
||||||
var statusIcon: String {
|
var statusIcon: String {
|
||||||
@@ -74,14 +73,14 @@ struct BitchatPeer: Equatable {
|
|||||||
|
|
||||||
// Initialize from mesh service data
|
// Initialize from mesh service data
|
||||||
init(
|
init(
|
||||||
peerID: PeerID,
|
id: String,
|
||||||
noisePublicKey: Data,
|
noisePublicKey: Data,
|
||||||
nickname: String,
|
nickname: String,
|
||||||
lastSeen: Date = Date(),
|
lastSeen: Date = Date(),
|
||||||
isConnected: Bool = false,
|
isConnected: Bool = false,
|
||||||
isReachable: Bool = false
|
isReachable: Bool = false
|
||||||
) {
|
) {
|
||||||
self.peerID = peerID
|
self.id = id
|
||||||
self.noisePublicKey = noisePublicKey
|
self.noisePublicKey = noisePublicKey
|
||||||
self.nickname = nickname
|
self.nickname = nickname
|
||||||
self.lastSeen = lastSeen
|
self.lastSeen = lastSeen
|
||||||
@@ -94,6 +93,8 @@ struct BitchatPeer: Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func == (lhs: BitchatPeer, rhs: BitchatPeer) -> Bool {
|
static func == (lhs: BitchatPeer, rhs: BitchatPeer) -> Bool {
|
||||||
lhs.peerID == rhs.peerID
|
lhs.id == rhs.id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
//
|
|
||||||
// CommandsInfo.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
// MARK: - CommandInfo Enum
|
|
||||||
|
|
||||||
enum CommandInfo: String, Identifiable {
|
|
||||||
case block
|
|
||||||
case clear
|
|
||||||
case hug
|
|
||||||
case message = "dm"
|
|
||||||
case slap
|
|
||||||
case unblock
|
|
||||||
case who
|
|
||||||
case favorite
|
|
||||||
case unfavorite
|
|
||||||
|
|
||||||
var id: String { rawValue }
|
|
||||||
|
|
||||||
var alias: String { "/" + rawValue }
|
|
||||||
|
|
||||||
var placeholder: String? {
|
|
||||||
switch self {
|
|
||||||
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
|
|
||||||
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
|
|
||||||
case .clear, .who:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var description: String {
|
|
||||||
switch self {
|
|
||||||
case .block: String(localized: "content.commands.block")
|
|
||||||
case .clear: String(localized: "content.commands.clear")
|
|
||||||
case .hug: String(localized: "content.commands.hug")
|
|
||||||
case .message: String(localized: "content.commands.message")
|
|
||||||
case .slap: String(localized: "content.commands.slap")
|
|
||||||
case .unblock: String(localized: "content.commands.unblock")
|
|
||||||
case .who: String(localized: "content.commands.who")
|
|
||||||
case .favorite: String(localized: "content.commands.favorite")
|
|
||||||
case .unfavorite: String(localized: "content.commands.unfavorite")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
|
|
||||||
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .hug, .message, .slap, .who]
|
|
||||||
if isGeoPublic || isGeoDM {
|
|
||||||
return baseCommands + [.favorite, .unfavorite]
|
|
||||||
}
|
|
||||||
return baseCommands
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
//
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
//
|
|
||||||
// ReadReceipt.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import BitFoundation
|
|
||||||
|
|
||||||
struct ReadReceipt: Codable {
|
|
||||||
let originalMessageID: String
|
|
||||||
let receiptID: String
|
|
||||||
var readerID: PeerID // Who read it
|
|
||||||
let readerNickname: String
|
|
||||||
let timestamp: Date
|
|
||||||
|
|
||||||
init(originalMessageID: String, readerID: PeerID, readerNickname: String) {
|
|
||||||
self.originalMessageID = originalMessageID
|
|
||||||
self.receiptID = UUID().uuidString
|
|
||||||
self.readerID = readerID
|
|
||||||
self.readerNickname = readerNickname
|
|
||||||
self.timestamp = Date()
|
|
||||||
}
|
|
||||||
|
|
||||||
// For binary decoding
|
|
||||||
private init(originalMessageID: String, receiptID: String, readerID: PeerID, readerNickname: String, timestamp: Date) {
|
|
||||||
self.originalMessageID = originalMessageID
|
|
||||||
self.receiptID = receiptID
|
|
||||||
self.readerID = readerID
|
|
||||||
self.readerNickname = readerNickname
|
|
||||||
self.timestamp = timestamp
|
|
||||||
}
|
|
||||||
|
|
||||||
func encode() -> Data? {
|
|
||||||
try? JSONEncoder().encode(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func decode(from data: Data) -> ReadReceipt? {
|
|
||||||
try? JSONDecoder().decode(ReadReceipt.self, from: data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Binary Encoding
|
|
||||||
|
|
||||||
func toBinaryData() -> Data {
|
|
||||||
var data = Data()
|
|
||||||
data.appendUUID(originalMessageID)
|
|
||||||
data.appendUUID(receiptID)
|
|
||||||
// ReaderID as 8-byte hex string
|
|
||||||
var readerData = Data()
|
|
||||||
var tempID = readerID.id
|
|
||||||
while tempID.count >= 2 && readerData.count < 8 {
|
|
||||||
let hexByte = String(tempID.prefix(2))
|
|
||||||
if let byte = UInt8(hexByte, radix: 16) {
|
|
||||||
readerData.append(byte)
|
|
||||||
}
|
|
||||||
tempID = String(tempID.dropFirst(2))
|
|
||||||
}
|
|
||||||
while readerData.count < 8 {
|
|
||||||
readerData.append(0)
|
|
||||||
}
|
|
||||||
data.append(readerData)
|
|
||||||
data.appendDate(timestamp)
|
|
||||||
data.appendString(readerNickname)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
static func fromBinaryData(_ data: Data) -> ReadReceipt? {
|
|
||||||
// Create defensive copy
|
|
||||||
let dataCopy = Data(data)
|
|
||||||
|
|
||||||
// Minimum size: 2 UUIDs (32) + readerID (8) + timestamp (8) + min nickname
|
|
||||||
guard dataCopy.count >= 49 else { return nil }
|
|
||||||
|
|
||||||
var offset = 0
|
|
||||||
|
|
||||||
guard let originalMessageID = dataCopy.readUUID(at: &offset),
|
|
||||||
let receiptID = dataCopy.readUUID(at: &offset) else { return nil }
|
|
||||||
|
|
||||||
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
|
||||||
let readerID = PeerID(hexData: readerIDData)
|
|
||||||
guard readerID.isValid else { return nil }
|
|
||||||
|
|
||||||
guard let timestamp = dataCopy.readDate(at: &offset),
|
|
||||||
InputValidator.validateTimestamp(timestamp),
|
|
||||||
let readerNicknameRaw = dataCopy.readString(at: &offset),
|
|
||||||
let readerNickname = InputValidator.validateNickname(readerNicknameRaw) else { return nil }
|
|
||||||
|
|
||||||
return ReadReceipt(originalMessageID: originalMessageID,
|
|
||||||
receiptID: receiptID,
|
|
||||||
readerID: readerID,
|
|
||||||
readerNickname: readerNickname,
|
|
||||||
timestamp: timestamp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
// REQUEST_SYNC payload TLV (type, length16, value)
|
|
||||||
// - 0x01: P (uint8) — Golomb-Rice parameter
|
|
||||||
// - 0x02: M (uint32, big-endian) — hash range (N * 2^P)
|
|
||||||
// - 0x03: data (opaque) — GR bitstream bytes (MSB-first)
|
|
||||||
struct RequestSyncPacket {
|
|
||||||
let p: Int
|
|
||||||
let m: UInt32
|
|
||||||
let data: Data
|
|
||||||
let types: SyncTypeFlags?
|
|
||||||
let sinceTimestamp: UInt64?
|
|
||||||
let fragmentIdFilter: String?
|
|
||||||
|
|
||||||
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil, sinceTimestamp: UInt64? = nil, fragmentIdFilter: String? = nil) {
|
|
||||||
self.p = p
|
|
||||||
self.m = m
|
|
||||||
self.data = data
|
|
||||||
self.types = types
|
|
||||||
self.sinceTimestamp = sinceTimestamp
|
|
||||||
self.fragmentIdFilter = fragmentIdFilter
|
|
||||||
}
|
|
||||||
|
|
||||||
func encode() -> Data {
|
|
||||||
var out = Data()
|
|
||||||
func putTLV(_ t: UInt8, _ v: Data) {
|
|
||||||
out.append(t)
|
|
||||||
let len = UInt16(v.count)
|
|
||||||
out.append(UInt8((len >> 8) & 0xFF))
|
|
||||||
out.append(UInt8(len & 0xFF))
|
|
||||||
out.append(v)
|
|
||||||
}
|
|
||||||
// P
|
|
||||||
putTLV(0x01, Data([UInt8(p & 0xFF)]))
|
|
||||||
// M (uint32)
|
|
||||||
var mBE = m.bigEndian
|
|
||||||
putTLV(0x02, withUnsafeBytes(of: &mBE) { Data($0) })
|
|
||||||
// data
|
|
||||||
putTLV(0x03, data)
|
|
||||||
if let typesData = types?.toData() {
|
|
||||||
putTLV(0x04, typesData)
|
|
||||||
}
|
|
||||||
if let ts = sinceTimestamp {
|
|
||||||
var tsBE = ts.bigEndian
|
|
||||||
putTLV(0x05, withUnsafeBytes(of: &tsBE) { Data($0) })
|
|
||||||
}
|
|
||||||
if let fid = fragmentIdFilter, let fidData = fid.data(using: .utf8) {
|
|
||||||
putTLV(0x06, fidData)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
static func decode(from data: Data, maxAcceptBytes: Int = 1024) -> RequestSyncPacket? {
|
|
||||||
var off = 0
|
|
||||||
var p: Int? = nil
|
|
||||||
var m: UInt32? = nil
|
|
||||||
var payload: Data? = nil
|
|
||||||
var types: SyncTypeFlags? = nil
|
|
||||||
var sinceTimestamp: UInt64? = nil
|
|
||||||
var fragmentIdFilter: String? = nil
|
|
||||||
|
|
||||||
while off + 3 <= data.count {
|
|
||||||
let t = Int(data[off]); off += 1
|
|
||||||
guard off + 2 <= data.count else { return nil }
|
|
||||||
let len = (Int(data[off]) << 8) | Int(data[off+1]); off += 2
|
|
||||||
guard off + len <= data.count else { return nil }
|
|
||||||
let v = data.subdata(in: off..<(off+len)); off += len
|
|
||||||
switch t {
|
|
||||||
case 0x01:
|
|
||||||
if v.count == 1 { p = Int(v[0]) }
|
|
||||||
case 0x02:
|
|
||||||
if v.count == 4 {
|
|
||||||
var mm: UInt32 = 0
|
|
||||||
for b in v { mm = (mm << 8) | UInt32(b) }
|
|
||||||
m = mm
|
|
||||||
}
|
|
||||||
case 0x03:
|
|
||||||
if v.count > maxAcceptBytes { return nil }
|
|
||||||
payload = v
|
|
||||||
case 0x04:
|
|
||||||
if let decoded = SyncTypeFlags.decode(v) {
|
|
||||||
types = decoded
|
|
||||||
}
|
|
||||||
case 0x05:
|
|
||||||
if v.count == 8 {
|
|
||||||
var ts: UInt64 = 0
|
|
||||||
for b in v { ts = (ts << 8) | UInt64(b) }
|
|
||||||
sinceTimestamp = ts
|
|
||||||
}
|
|
||||||
case 0x06:
|
|
||||||
if let fid = String(data: v, encoding: .utf8) {
|
|
||||||
fragmentIdFilter = fid
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break // forward compatible; ignore unknown TLVs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil }
|
|
||||||
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types, sinceTimestamp: sinceTimestamp, fragmentIdFilter: fragmentIdFilter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
//
|
||||||
|
// NoiseHandshakeCoordinator.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Coordinates Noise handshakes to prevent race conditions and ensure reliable encryption establishment
|
||||||
|
class NoiseHandshakeCoordinator {
|
||||||
|
|
||||||
|
// MARK: - Handshake State
|
||||||
|
|
||||||
|
enum HandshakeState: Equatable {
|
||||||
|
case idle
|
||||||
|
case waitingToInitiate(since: Date)
|
||||||
|
case initiating(attempt: Int, lastAttempt: Date)
|
||||||
|
case responding(since: Date)
|
||||||
|
case waitingForResponse(messagesSent: [Data], timeout: Date)
|
||||||
|
case established(since: Date)
|
||||||
|
case failed(reason: String, canRetry: Bool, lastAttempt: Date)
|
||||||
|
|
||||||
|
var isActive: Bool {
|
||||||
|
switch self {
|
||||||
|
case .idle, .established, .failed:
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
|
private var handshakeStates: [String: HandshakeState] = [:]
|
||||||
|
private var handshakeQueue = DispatchQueue(label: "chat.bitchat.noise.handshake", attributes: .concurrent)
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
private let maxHandshakeAttempts = 3
|
||||||
|
private let handshakeTimeout: TimeInterval = 10.0
|
||||||
|
private let retryDelay: TimeInterval = 2.0
|
||||||
|
private let minTimeBetweenHandshakes: TimeInterval = 1.0 // Reduced from 5.0 for faster recovery
|
||||||
|
private let establishedSessionTTL: TimeInterval = 300.0 // 5 minutes - sessions older than this can be cleaned up
|
||||||
|
private let maxEstablishedSessions = 50 // Limit total established sessions
|
||||||
|
|
||||||
|
// Track handshake messages to detect duplicates
|
||||||
|
private var processedHandshakeMessages: Set<Data> = []
|
||||||
|
private let messageHistoryLimit = 100
|
||||||
|
|
||||||
|
// MARK: - Role Determination
|
||||||
|
|
||||||
|
/// Deterministically determine who should initiate the handshake
|
||||||
|
/// Lower peer ID becomes the initiator to prevent simultaneous attempts
|
||||||
|
func determineHandshakeRole(myPeerID: String, remotePeerID: String) -> NoiseRole {
|
||||||
|
// Use simple string comparison for deterministic ordering
|
||||||
|
return myPeerID < remotePeerID ? .initiator : .responder
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if we should initiate handshake with a peer
|
||||||
|
func shouldInitiateHandshake(myPeerID: String, remotePeerID: String, forceIfStale: Bool = false) -> Bool {
|
||||||
|
return handshakeQueue.sync {
|
||||||
|
// Check if we're already in an active handshake
|
||||||
|
if let state = handshakeStates[remotePeerID], state.isActive {
|
||||||
|
// Check if the handshake is stale and we should force a new one
|
||||||
|
if forceIfStale {
|
||||||
|
switch state {
|
||||||
|
case .initiating(_, let lastAttempt):
|
||||||
|
if Date().timeIntervalSince(lastAttempt) > handshakeTimeout {
|
||||||
|
SecureLogger.log("Forcing new handshake with \(remotePeerID) - previous stuck in initiating",
|
||||||
|
category: SecureLogger.handshake, level: .warning)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SecureLogger.log("Already in active handshake with \(remotePeerID), state: \(state)",
|
||||||
|
category: SecureLogger.handshake, level: .debug)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check role
|
||||||
|
let role = determineHandshakeRole(myPeerID: myPeerID, remotePeerID: remotePeerID)
|
||||||
|
if role != .initiator {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we've failed recently and can't retry yet
|
||||||
|
if case .failed(_, let canRetry, let lastAttempt) = handshakeStates[remotePeerID] {
|
||||||
|
if !canRetry {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if Date().timeIntervalSince(lastAttempt) < retryDelay {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record that we're initiating a handshake
|
||||||
|
func recordHandshakeInitiation(peerID: String) {
|
||||||
|
handshakeQueue.async(flags: .barrier) {
|
||||||
|
let attempt = self.getCurrentAttempt(for: peerID) + 1
|
||||||
|
self.handshakeStates[peerID] = .initiating(attempt: attempt, lastAttempt: Date())
|
||||||
|
SecureLogger.log("Recording handshake initiation with \(peerID), attempt \(attempt)",
|
||||||
|
category: SecureLogger.handshake, level: .info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record that we're responding to a handshake
|
||||||
|
func recordHandshakeResponse(peerID: String) {
|
||||||
|
handshakeQueue.async(flags: .barrier) {
|
||||||
|
self.handshakeStates[peerID] = .responding(since: Date())
|
||||||
|
SecureLogger.log("Recording handshake response to \(peerID)",
|
||||||
|
category: SecureLogger.handshake, level: .info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record successful handshake completion
|
||||||
|
func recordHandshakeSuccess(peerID: String) {
|
||||||
|
handshakeQueue.async(flags: .barrier) {
|
||||||
|
self.handshakeStates[peerID] = .established(since: Date())
|
||||||
|
SecureLogger.log("Handshake successfully established with \(peerID)",
|
||||||
|
category: SecureLogger.handshake, level: .info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record handshake failure
|
||||||
|
func recordHandshakeFailure(peerID: String, reason: String) {
|
||||||
|
handshakeQueue.async(flags: .barrier) {
|
||||||
|
let attempts = self.getCurrentAttempt(for: peerID)
|
||||||
|
let canRetry = attempts < self.maxHandshakeAttempts
|
||||||
|
self.handshakeStates[peerID] = .failed(reason: reason, canRetry: canRetry, lastAttempt: Date())
|
||||||
|
SecureLogger.log("Handshake failed with \(peerID): \(reason), canRetry: \(canRetry)",
|
||||||
|
category: SecureLogger.handshake, level: .warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if we should accept an incoming handshake initiation
|
||||||
|
func shouldAcceptHandshakeInitiation(myPeerID: String, remotePeerID: String) -> Bool {
|
||||||
|
return handshakeQueue.sync {
|
||||||
|
// If we're already established, reject new handshakes
|
||||||
|
if case .established = handshakeStates[remotePeerID] {
|
||||||
|
SecureLogger.log("Rejecting handshake from \(remotePeerID) - already established",
|
||||||
|
category: SecureLogger.handshake, level: .debug)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let role = determineHandshakeRole(myPeerID: myPeerID, remotePeerID: remotePeerID)
|
||||||
|
|
||||||
|
// If we're the initiator and already initiating, this is a race condition
|
||||||
|
if role == .initiator {
|
||||||
|
if case .initiating = handshakeStates[remotePeerID] {
|
||||||
|
// They shouldn't be initiating, but accept it to recover from race condition
|
||||||
|
SecureLogger.log("Accepting handshake from \(remotePeerID) despite being initiator (race condition recovery)",
|
||||||
|
category: SecureLogger.handshake, level: .warning)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're the responder, we should accept
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if this is a duplicate handshake message
|
||||||
|
func isDuplicateHandshakeMessage(_ data: Data) -> Bool {
|
||||||
|
return handshakeQueue.sync {
|
||||||
|
if processedHandshakeMessages.contains(data) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to processed messages with size limit
|
||||||
|
if processedHandshakeMessages.count >= messageHistoryLimit {
|
||||||
|
processedHandshakeMessages.removeAll()
|
||||||
|
}
|
||||||
|
processedHandshakeMessages.insert(data)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get time to wait before next handshake attempt
|
||||||
|
func getRetryDelay(for peerID: String) -> TimeInterval? {
|
||||||
|
return handshakeQueue.sync {
|
||||||
|
guard let state = handshakeStates[peerID] else { return nil }
|
||||||
|
|
||||||
|
switch state {
|
||||||
|
case .failed(_, let canRetry, let lastAttempt):
|
||||||
|
if !canRetry { return nil }
|
||||||
|
let timeSinceFailure = Date().timeIntervalSince(lastAttempt)
|
||||||
|
if timeSinceFailure >= retryDelay {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return retryDelay - timeSinceFailure
|
||||||
|
|
||||||
|
case .initiating(_, let lastAttempt):
|
||||||
|
let timeSinceAttempt = Date().timeIntervalSince(lastAttempt)
|
||||||
|
if timeSinceAttempt >= minTimeBetweenHandshakes {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return minTimeBetweenHandshakes - timeSinceAttempt
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset handshake state for a peer
|
||||||
|
func resetHandshakeState(for peerID: String) {
|
||||||
|
handshakeQueue.async(flags: .barrier) {
|
||||||
|
self.handshakeStates.removeValue(forKey: peerID)
|
||||||
|
SecureLogger.log("Reset handshake state for \(peerID)",
|
||||||
|
category: SecureLogger.handshake, level: .debug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean up stale handshake states and old established sessions
|
||||||
|
func cleanupStaleHandshakes(staleTimeout: TimeInterval = 30.0) -> [String] {
|
||||||
|
return handshakeQueue.sync {
|
||||||
|
let now = Date()
|
||||||
|
var stalePeerIDs: [String] = []
|
||||||
|
var establishedSessions: [(peerID: String, since: Date)] = []
|
||||||
|
|
||||||
|
for (peerID, state) in handshakeStates {
|
||||||
|
var isStale = false
|
||||||
|
|
||||||
|
switch state {
|
||||||
|
case .initiating(_, let lastAttempt):
|
||||||
|
if now.timeIntervalSince(lastAttempt) > staleTimeout {
|
||||||
|
isStale = true
|
||||||
|
}
|
||||||
|
case .responding(let since):
|
||||||
|
if now.timeIntervalSince(since) > staleTimeout {
|
||||||
|
isStale = true
|
||||||
|
}
|
||||||
|
case .waitingForResponse(_, let timeout):
|
||||||
|
if now > timeout {
|
||||||
|
isStale = true
|
||||||
|
}
|
||||||
|
case .established(let since):
|
||||||
|
// Track established sessions for potential cleanup
|
||||||
|
establishedSessions.append((peerID, since))
|
||||||
|
// Clean up very old established sessions
|
||||||
|
if now.timeIntervalSince(since) > establishedSessionTTL {
|
||||||
|
isStale = true
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if isStale {
|
||||||
|
stalePeerIDs.append(peerID)
|
||||||
|
SecureLogger.log("Found stale handshake state for \(peerID): \(state)",
|
||||||
|
category: SecureLogger.handshake, level: .warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have too many established sessions, clean up the oldest ones
|
||||||
|
if establishedSessions.count > maxEstablishedSessions {
|
||||||
|
// Sort by age (oldest first)
|
||||||
|
let sortedSessions = establishedSessions.sorted { $0.since < $1.since }
|
||||||
|
let sessionsToRemove = sortedSessions.count - maxEstablishedSessions
|
||||||
|
|
||||||
|
for i in 0..<sessionsToRemove {
|
||||||
|
let peerID = sortedSessions[i].peerID
|
||||||
|
stalePeerIDs.append(peerID)
|
||||||
|
SecureLogger.log("Removing old established session for \(peerID) to maintain session limit",
|
||||||
|
category: SecureLogger.handshake, level: .info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up stale states
|
||||||
|
for peerID in stalePeerIDs {
|
||||||
|
handshakeStates.removeValue(forKey: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !stalePeerIDs.isEmpty {
|
||||||
|
SecureLogger.log("Cleaned up \(stalePeerIDs.count) stale handshake states",
|
||||||
|
category: SecureLogger.handshake, level: .info)
|
||||||
|
}
|
||||||
|
|
||||||
|
return stalePeerIDs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current handshake state
|
||||||
|
func getHandshakeState(for peerID: String) -> HandshakeState {
|
||||||
|
return handshakeQueue.sync {
|
||||||
|
return handshakeStates[peerID] ?? .idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current retry count for a peer
|
||||||
|
func getRetryCount(for peerID: String) -> Int {
|
||||||
|
return handshakeQueue.sync {
|
||||||
|
switch handshakeStates[peerID] {
|
||||||
|
case .initiating(let attempt, _):
|
||||||
|
return attempt - 1 // Attempts start at 1, retries start at 0
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Increment retry count for a peer
|
||||||
|
func incrementRetryCount(for peerID: String) {
|
||||||
|
handshakeQueue.async(flags: .barrier) {
|
||||||
|
let currentAttempt = self.getCurrentAttempt(for: peerID)
|
||||||
|
self.handshakeStates[peerID] = .initiating(attempt: currentAttempt + 1, lastAttempt: Date())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
|
private func getCurrentAttempt(for peerID: String) -> Int {
|
||||||
|
switch handshakeStates[peerID] {
|
||||||
|
case .initiating(let attempt, _):
|
||||||
|
return attempt
|
||||||
|
case .failed(_, _, _):
|
||||||
|
// Count previous attempts
|
||||||
|
return 1 // Simplified for now
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log current handshake states for debugging
|
||||||
|
func logHandshakeStates() {
|
||||||
|
handshakeQueue.sync {
|
||||||
|
SecureLogger.log("=== Handshake States ===", category: SecureLogger.handshake, level: .debug)
|
||||||
|
for (peerID, state) in handshakeStates {
|
||||||
|
let stateDesc: String
|
||||||
|
switch state {
|
||||||
|
case .idle:
|
||||||
|
stateDesc = "idle"
|
||||||
|
case .waitingToInitiate(let since):
|
||||||
|
stateDesc = "waiting to initiate (since \(since))"
|
||||||
|
case .initiating(let attempt, let lastAttempt):
|
||||||
|
stateDesc = "initiating (attempt \(attempt), last: \(lastAttempt))"
|
||||||
|
case .responding(let since):
|
||||||
|
stateDesc = "responding (since: \(since))"
|
||||||
|
case .waitingForResponse(let messages, let timeout):
|
||||||
|
stateDesc = "waiting for response (\(messages.count) messages, timeout: \(timeout))"
|
||||||
|
case .established(let since):
|
||||||
|
stateDesc = "established (since \(since))"
|
||||||
|
case .failed(let reason, let canRetry, let lastAttempt):
|
||||||
|
stateDesc = "failed: \(reason) (canRetry: \(canRetry), last: \(lastAttempt))"
|
||||||
|
}
|
||||||
|
SecureLogger.log(" \(peerID): \(stateDesc)", category: SecureLogger.handshake, level: .debug)
|
||||||
|
}
|
||||||
|
SecureLogger.log("========================", category: SecureLogger.handshake, level: .debug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all handshake states - used during panic mode
|
||||||
|
func clearAllHandshakeStates() {
|
||||||
|
handshakeQueue.async(flags: .barrier) {
|
||||||
|
SecureLogger.log("Clearing all handshake states for panic mode", category: SecureLogger.handshake, level: .warning)
|
||||||
|
self.handshakeStates.removeAll()
|
||||||
|
self.processedHandshakeMessages.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,10 +77,9 @@
|
|||||||
/// - Noise Specification: http://www.noiseprotocol.org/noise.html
|
/// - Noise Specification: http://www.noiseprotocol.org/noise.html
|
||||||
///
|
///
|
||||||
|
|
||||||
import BitLogger
|
|
||||||
import BitFoundation
|
|
||||||
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
|
||||||
@@ -128,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
|
||||||
final class NoiseCipherState {
|
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
|
||||||
@@ -166,23 +165,19 @@ final class NoiseCipherState {
|
|||||||
// MARK: - Sliding Window Replay Protection
|
// MARK: - Sliding Window Replay Protection
|
||||||
|
|
||||||
/// Check if nonce is valid for replay protection
|
/// Check if nonce is valid for replay protection
|
||||||
/// BCH-01-010: Use safe arithmetic to prevent integer overflow
|
|
||||||
private func isValidNonce(_ receivedNonce: UInt64) -> Bool {
|
private func isValidNonce(_ receivedNonce: UInt64) -> Bool {
|
||||||
// Safe overflow check: instead of (receivedNonce + WINDOW_SIZE <= highest)
|
if receivedNonce + UInt64(Self.REPLAY_WINDOW_SIZE) <= highestReceivedNonce {
|
||||||
// use (highest >= WINDOW_SIZE && receivedNonce <= highest - WINDOW_SIZE)
|
|
||||||
let windowSize = UInt64(Self.REPLAY_WINDOW_SIZE)
|
|
||||||
if highestReceivedNonce >= windowSize && receivedNonce <= highestReceivedNonce - windowSize {
|
|
||||||
return false // Too old, outside window
|
return false // Too old, outside window
|
||||||
}
|
}
|
||||||
|
|
||||||
if receivedNonce > highestReceivedNonce {
|
if receivedNonce > highestReceivedNonce {
|
||||||
return true // Always accept newer nonces
|
return true // Always accept newer nonces
|
||||||
}
|
}
|
||||||
|
|
||||||
let offset = Int(highestReceivedNonce - receivedNonce)
|
let offset = Int(highestReceivedNonce - receivedNonce)
|
||||||
let byteIndex = offset / 8
|
let byteIndex = offset / 8
|
||||||
let bitIndex = offset % 8
|
let bitIndex = offset % 8
|
||||||
|
|
||||||
return (replayWindow[byteIndex] & (1 << bitIndex)) == 0 // Not yet seen
|
return (replayWindow[byteIndex] & (1 << bitIndex)) == 0 // Not yet seen
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,7 +222,7 @@ final class NoiseCipherState {
|
|||||||
guard combinedPayload.count >= Self.NONCE_SIZE_BYTES else {
|
guard combinedPayload.count >= Self.NONCE_SIZE_BYTES else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract 4-byte nonce (big-endian)
|
// Extract 4-byte nonce (big-endian)
|
||||||
let nonceData = combinedPayload.prefix(Self.NONCE_SIZE_BYTES)
|
let nonceData = combinedPayload.prefix(Self.NONCE_SIZE_BYTES)
|
||||||
let extractedNonce = nonceData.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) -> UInt64 in
|
let extractedNonce = nonceData.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) -> UInt64 in
|
||||||
@@ -238,18 +233,18 @@ final class NoiseCipherState {
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract ciphertext (remaining bytes)
|
// Extract ciphertext (remaining bytes)
|
||||||
let ciphertext = combinedPayload.dropFirst(Self.NONCE_SIZE_BYTES)
|
let ciphertext = combinedPayload.dropFirst(Self.NONCE_SIZE_BYTES)
|
||||||
|
|
||||||
return (nonce: extractedNonce, ciphertext: Data(ciphertext))
|
return (nonce: extractedNonce, ciphertext: Data(ciphertext))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert nonce to 4-byte array (big-endian)
|
/// Convert nonce to 4-byte array (big-endian)
|
||||||
private func nonceToBytes(_ nonce: UInt64) -> Data {
|
private func nonceToBytes(_ nonce: UInt64) -> Data {
|
||||||
var bytes = Data(count: Self.NONCE_SIZE_BYTES)
|
var bytes = Data(count: Self.NONCE_SIZE_BYTES)
|
||||||
withUnsafeBytes(of: nonce.bigEndian) { ptr in
|
withUnsafeBytes(of: nonce.bigEndian) { ptr in
|
||||||
// Copy only the last 4 bytes from the 8-byte UInt64
|
// Copy only the last 4 bytes from the 8-byte UInt64
|
||||||
let sourceBytes = ptr.bindMemory(to: UInt8.self)
|
let sourceBytes = ptr.bindMemory(to: UInt8.self)
|
||||||
bytes.replaceSubrange(0..<Self.NONCE_SIZE_BYTES, with: sourceBytes.suffix(Self.NONCE_SIZE_BYTES))
|
bytes.replaceSubrange(0..<Self.NONCE_SIZE_BYTES, with: sourceBytes.suffix(Self.NONCE_SIZE_BYTES))
|
||||||
}
|
}
|
||||||
@@ -278,7 +273,7 @@ final class NoiseCipherState {
|
|||||||
let sealedBox = try ChaChaPoly.seal(plaintext, using: key, nonce: ChaChaPoly.Nonce(data: nonceData), authenticating: associatedData)
|
let sealedBox = try ChaChaPoly.seal(plaintext, using: key, nonce: ChaChaPoly.Nonce(data: nonceData), authenticating: associatedData)
|
||||||
// increment local nonce
|
// increment local nonce
|
||||||
nonce += 1
|
nonce += 1
|
||||||
|
|
||||||
// Create combined payload: <nonce><ciphertext>
|
// Create combined payload: <nonce><ciphertext>
|
||||||
let combinedPayload: Data
|
let combinedPayload: Data
|
||||||
if (useExtractedNonce) {
|
if (useExtractedNonce) {
|
||||||
@@ -290,9 +285,9 @@ final 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.warning("High nonce value detected: \(currentNonce) - consider rekeying", category: .encryption)
|
SecureLogger.log("High nonce value detected: \(currentNonce) - consider rekeying", category: SecureLogger.encryption, level: .warning)
|
||||||
}
|
}
|
||||||
|
|
||||||
return combinedPayload
|
return combinedPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,16 +307,16 @@ final 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.debug("Decrypt failed: Could not extract nonce from payload")
|
SecureLogger.log("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.debug("Replay attack detected: nonce \(extractedNonce) rejected")
|
SecureLogger.log("Replay attack detected: nonce \(extractedNonce) rejected")
|
||||||
throw NoiseError.replayDetected
|
throw NoiseError.replayDetected
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split ciphertext and tag
|
// Split ciphertext and tag
|
||||||
encryptedData = actualCiphertext.prefix(actualCiphertext.count - 16)
|
encryptedData = actualCiphertext.prefix(actualCiphertext.count - 16)
|
||||||
tag = actualCiphertext.suffix(16)
|
tag = actualCiphertext.suffix(16)
|
||||||
@@ -347,26 +342,22 @@ final 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.warning("High nonce value detected: \(decryptionNonce) - consider rekeying", category: .encryption)
|
SecureLogger.log("High nonce value detected: \(decryptionNonce) - consider rekeying", category: SecureLogger.encryption, level: .warning)
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let plaintext = try ChaChaPoly.open(sealedBox, using: key, authenticating: associatedData)
|
let plaintext = try ChaChaPoly.open(sealedBox, using: key, authenticating: associatedData)
|
||||||
|
|
||||||
// BCH-01-010: Atomic nonce state update
|
|
||||||
// Both replay window marking and nonce increment must complete together
|
|
||||||
// to prevent state desynchronization. We perform both after successful
|
|
||||||
// decryption only, ensuring state consistency on any failure path.
|
|
||||||
if useExtractedNonce {
|
if useExtractedNonce {
|
||||||
|
// Mark nonce as seen after successful decryption
|
||||||
markNonceAsSeen(decryptionNonce)
|
markNonceAsSeen(decryptionNonce)
|
||||||
}
|
}
|
||||||
nonce += 1
|
nonce += 1
|
||||||
|
|
||||||
return plaintext
|
return plaintext
|
||||||
} catch {
|
} catch {
|
||||||
// Decryption failed - nonce state remains unchanged (atomic rollback)
|
SecureLogger.log("Decrypt failed: \(error) for nonce \(decryptionNonce)")
|
||||||
SecureLogger.debug("Decrypt failed: \(error) for nonce \(decryptionNonce)")
|
// Log authentication failures with nonce info
|
||||||
SecureLogger.error("Decryption failed at nonce \(decryptionNonce)", category: .encryption)
|
SecureLogger.log("Decryption failed at nonce \(decryptionNonce)", category: SecureLogger.encryption, level: .error)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -385,16 +376,6 @@ final class NoiseCipherState {
|
|||||||
replayWindow[i] = 0
|
replayWindow[i] = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
func setNonceForTesting(_ nonce: UInt64) {
|
|
||||||
self.nonce = nonce
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractNonceFromCiphertextPayloadForTesting(_ combinedPayload: Data) throws -> (nonce: UInt64, ciphertext: Data)? {
|
|
||||||
try extractNonceFromCiphertextPayload(combinedPayload)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Symmetric State
|
// MARK: - Symmetric State
|
||||||
@@ -403,7 +384,7 @@ final 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
|
||||||
final class NoiseSymmetricState {
|
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
|
||||||
@@ -416,7 +397,7 @@ final 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 = nameData.sha256Hash()
|
self.hash = Data(SHA256.hash(data: nameData))
|
||||||
}
|
}
|
||||||
self.chainingKey = self.hash
|
self.chainingKey = self.hash
|
||||||
}
|
}
|
||||||
@@ -429,7 +410,7 @@ final class NoiseSymmetricState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func mixHash(_ data: Data) {
|
func mixHash(_ data: Data) {
|
||||||
hash = (hash + data).sha256Hash()
|
hash = Data(SHA256.hash(data: hash + data))
|
||||||
}
|
}
|
||||||
|
|
||||||
func mixKeyAndHash(_ inputKeyMaterial: Data) {
|
func mixKeyAndHash(_ inputKeyMaterial: Data) {
|
||||||
@@ -470,40 +451,17 @@ final class NoiseSymmetricState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func split(useExtractedNonce: Bool) -> (NoiseCipherState, NoiseCipherState) {
|
func split() -> (NoiseCipherState, NoiseCipherState) {
|
||||||
let output = hkdf(chainingKey: chainingKey, inputKeyMaterial: Data(), numOutputs: 2)
|
let output = hkdf(chainingKey: chainingKey, inputKeyMaterial: Data(), numOutputs: 2)
|
||||||
let tempKey1 = SymmetricKey(data: output[0])
|
let tempKey1 = SymmetricKey(data: output[0])
|
||||||
let tempKey2 = SymmetricKey(data: output[1])
|
let tempKey2 = SymmetricKey(data: output[1])
|
||||||
|
|
||||||
let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: useExtractedNonce)
|
let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: true)
|
||||||
let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: useExtractedNonce)
|
let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: true)
|
||||||
|
|
||||||
// BCH-01-010: Clear symmetric state after split per Noise spec
|
|
||||||
// The chaining key and hash should not be retained after handshake completes
|
|
||||||
clearSensitiveData()
|
|
||||||
|
|
||||||
return (c1, c2)
|
return (c1, c2)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// BCH-01-010: Securely clear sensitive cryptographic state
|
|
||||||
/// Called after split() to clear chaining key and hash per Noise spec
|
|
||||||
func clearSensitiveData() {
|
|
||||||
// Clear chaining key by overwriting with zeros
|
|
||||||
let chainingKeyCount = chainingKey.count
|
|
||||||
chainingKey = Data(repeating: 0, count: chainingKeyCount)
|
|
||||||
|
|
||||||
// Clear hash by overwriting with zeros
|
|
||||||
let hashCount = hash.count
|
|
||||||
hash = Data(repeating: 0, count: hashCount)
|
|
||||||
|
|
||||||
// Clear the internal cipher state
|
|
||||||
cipherState.clearSensitiveData()
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
clearSensitiveData()
|
|
||||||
}
|
|
||||||
|
|
||||||
// HKDF implementation
|
// HKDF implementation
|
||||||
private func hkdf(chainingKey: Data, inputKeyMaterial: Data, numOutputs: Int) -> [Data] {
|
private func hkdf(chainingKey: Data, inputKeyMaterial: Data, numOutputs: Int) -> [Data] {
|
||||||
let tempKey = HMAC<SHA256>.authenticationCode(for: inputKeyMaterial, using: SymmetricKey(data: chainingKey))
|
let tempKey = HMAC<SHA256>.authenticationCode(for: inputKeyMaterial, using: SymmetricKey(data: chainingKey))
|
||||||
@@ -530,10 +488,9 @@ final 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
|
||||||
final class NoiseHandshakeState {
|
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
|
||||||
@@ -549,24 +506,9 @@ final class NoiseHandshakeState {
|
|||||||
private var messagePatterns: [[NoiseMessagePattern]] = []
|
private var messagePatterns: [[NoiseMessagePattern]] = []
|
||||||
private var currentPattern = 0
|
private var currentPattern = 0
|
||||||
|
|
||||||
// Test support: predetermined ephemeral keys for test vectors
|
init(role: NoiseRole, pattern: NoisePattern, localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil, remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil) {
|
||||||
private var predeterminedEphemeralKey: Curve25519.KeyAgreement.PrivateKey?
|
|
||||||
private var prologueData: Data
|
|
||||||
|
|
||||||
init(
|
|
||||||
role: NoiseRole,
|
|
||||||
pattern: NoisePattern,
|
|
||||||
keychain: KeychainManagerProtocol,
|
|
||||||
localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil,
|
|
||||||
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil,
|
|
||||||
prologue: Data = Data(),
|
|
||||||
predeterminedEphemeralKey: Curve25519.KeyAgreement.PrivateKey? = nil
|
|
||||||
) {
|
|
||||||
self.role = role
|
self.role = role
|
||||||
self.pattern = pattern
|
self.pattern = pattern
|
||||||
self.keychain = keychain
|
|
||||||
self.prologueData = prologue
|
|
||||||
self.predeterminedEphemeralKey = predeterminedEphemeralKey
|
|
||||||
|
|
||||||
// Initialize static keys
|
// Initialize static keys
|
||||||
if let localKey = localStaticKey {
|
if let localKey = localStaticKey {
|
||||||
@@ -587,8 +529,8 @@ final class NoiseHandshakeState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func mixPreMessageKeys() {
|
private func mixPreMessageKeys() {
|
||||||
// Mix prologue
|
// Mix prologue (empty for XX pattern normally)
|
||||||
symmetricState.mixHash(self.prologueData)
|
symmetricState.mixHash(Data()) // Empty prologue for XX pattern
|
||||||
// For XX pattern, no pre-message keys
|
// For XX pattern, no pre-message keys
|
||||||
// For IK/NK patterns, we'd mix the responder's static key here
|
// For IK/NK patterns, we'd mix the responder's static key here
|
||||||
switch pattern {
|
switch pattern {
|
||||||
@@ -596,9 +538,8 @@ final class NoiseHandshakeState {
|
|||||||
break // No pre-message keys
|
break // No pre-message keys
|
||||||
case .IK, .NK:
|
case .IK, .NK:
|
||||||
if role == .initiator, let remoteStatic = remoteStaticPublic {
|
if role == .initiator, let remoteStatic = remoteStaticPublic {
|
||||||
|
_ = symmetricState.getHandshakeHash()
|
||||||
symmetricState.mixHash(remoteStatic.rawRepresentation)
|
symmetricState.mixHash(remoteStatic.rawRepresentation)
|
||||||
} else if role == .responder, let localStatic = localStaticPublic {
|
|
||||||
symmetricState.mixHash(localStatic.rawRepresentation)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -607,20 +548,15 @@ final class NoiseHandshakeState {
|
|||||||
guard currentPattern < messagePatterns.count else {
|
guard currentPattern < messagePatterns.count else {
|
||||||
throw NoiseError.handshakeComplete
|
throw NoiseError.handshakeComplete
|
||||||
}
|
}
|
||||||
|
|
||||||
var messageBuffer = Data()
|
var messageBuffer = Data()
|
||||||
let patterns = messagePatterns[currentPattern]
|
let patterns = messagePatterns[currentPattern]
|
||||||
|
|
||||||
for pattern in patterns {
|
for pattern in patterns {
|
||||||
switch pattern {
|
switch pattern {
|
||||||
case .e:
|
case .e:
|
||||||
// Generate ephemeral key (or use predetermined key for tests)
|
// Generate ephemeral key
|
||||||
if let predetermined = predeterminedEphemeralKey {
|
localEphemeralPrivate = Curve25519.KeyAgreement.PrivateKey()
|
||||||
localEphemeralPrivate = predetermined
|
|
||||||
predeterminedEphemeralKey = nil
|
|
||||||
} else {
|
|
||||||
localEphemeralPrivate = Curve25519.KeyAgreement.PrivateKey()
|
|
||||||
}
|
|
||||||
localEphemeralPublic = localEphemeralPrivate!.publicKey
|
localEphemeralPublic = localEphemeralPrivate!.publicKey
|
||||||
messageBuffer.append(localEphemeralPublic!.rawRepresentation)
|
messageBuffer.append(localEphemeralPublic!.rawRepresentation)
|
||||||
symmetricState.mixHash(localEphemeralPublic!.rawRepresentation)
|
symmetricState.mixHash(localEphemeralPublic!.rawRepresentation)
|
||||||
@@ -643,7 +579,7 @@ final class NoiseHandshakeState {
|
|||||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||||
symmetricState.mixKey(sharedData)
|
symmetricState.mixKey(sharedData)
|
||||||
// Clear sensitive shared secret
|
// Clear sensitive shared secret
|
||||||
keychain.secureClear(&sharedData)
|
KeychainManager.secureClear(&sharedData)
|
||||||
|
|
||||||
case .es:
|
case .es:
|
||||||
// DH(ephemeral, static) - direction depends on role
|
// DH(ephemeral, static) - direction depends on role
|
||||||
@@ -653,20 +589,14 @@ final class NoiseHandshakeState {
|
|||||||
throw NoiseError.missingKeys
|
throw NoiseError.missingKeys
|
||||||
}
|
}
|
||||||
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic)
|
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic)
|
||||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
symmetricState.mixKey(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)
|
||||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||||
symmetricState.mixKey(sharedData)
|
|
||||||
// Clear sensitive shared secret
|
|
||||||
keychain.secureClear(&sharedData)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case .se:
|
case .se:
|
||||||
@@ -677,20 +607,14 @@ final class NoiseHandshakeState {
|
|||||||
throw NoiseError.missingKeys
|
throw NoiseError.missingKeys
|
||||||
}
|
}
|
||||||
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral)
|
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral)
|
||||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
symmetricState.mixKey(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)
|
||||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||||
symmetricState.mixKey(sharedData)
|
|
||||||
// Clear sensitive shared secret
|
|
||||||
keychain.secureClear(&sharedData)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case .ss:
|
case .ss:
|
||||||
@@ -703,7 +627,7 @@ final class NoiseHandshakeState {
|
|||||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||||
symmetricState.mixKey(sharedData)
|
symmetricState.mixKey(sharedData)
|
||||||
// Clear sensitive shared secret
|
// Clear sensitive shared secret
|
||||||
keychain.secureClear(&sharedData)
|
KeychainManager.secureClear(&sharedData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -720,7 +644,7 @@ final class NoiseHandshakeState {
|
|||||||
guard currentPattern < messagePatterns.count else {
|
guard currentPattern < messagePatterns.count else {
|
||||||
throw NoiseError.handshakeComplete
|
throw NoiseError.handshakeComplete
|
||||||
}
|
}
|
||||||
|
|
||||||
var buffer = message
|
var buffer = message
|
||||||
let patterns = messagePatterns[currentPattern]
|
let patterns = messagePatterns[currentPattern]
|
||||||
|
|
||||||
@@ -737,7 +661,7 @@ final class NoiseHandshakeState {
|
|||||||
do {
|
do {
|
||||||
remoteEphemeralPublic = try NoiseHandshakeState.validatePublicKey(ephemeralData)
|
remoteEphemeralPublic = try NoiseHandshakeState.validatePublicKey(ephemeralData)
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.warning("Invalid ephemeral public key received", category: .security)
|
SecureLogger.log("Invalid ephemeral public key received", category: SecureLogger.security, level: .warning)
|
||||||
throw NoiseError.invalidMessage
|
throw NoiseError.invalidMessage
|
||||||
}
|
}
|
||||||
symmetricState.mixHash(ephemeralData)
|
symmetricState.mixHash(ephemeralData)
|
||||||
@@ -754,7 +678,7 @@ final 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.error(.authenticationFailed(peerID: "Unknown - handshake"))
|
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: "Unknown - handshake"), level: .error)
|
||||||
throw NoiseError.authenticationFailure
|
throw NoiseError.authenticationFailure
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -779,11 +703,8 @@ final class NoiseHandshakeState {
|
|||||||
throw NoiseError.missingKeys
|
throw NoiseError.missingKeys
|
||||||
}
|
}
|
||||||
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteEphemeral)
|
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteEphemeral)
|
||||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||||
symmetricState.mixKey(sharedData)
|
|
||||||
// Clear sensitive shared secret
|
|
||||||
keychain.secureClear(&sharedData)
|
|
||||||
|
|
||||||
case .es:
|
case .es:
|
||||||
if role == .initiator {
|
if role == .initiator {
|
||||||
guard let localEphemeral = localEphemeralPrivate,
|
guard let localEphemeral = localEphemeralPrivate,
|
||||||
@@ -794,7 +715,7 @@ final class NoiseHandshakeState {
|
|||||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||||
symmetricState.mixKey(sharedData)
|
symmetricState.mixKey(sharedData)
|
||||||
// Clear sensitive shared secret
|
// Clear sensitive shared secret
|
||||||
keychain.secureClear(&sharedData)
|
KeychainManager.secureClear(&sharedData)
|
||||||
} else {
|
} else {
|
||||||
guard let localStatic = localStaticPrivate,
|
guard let localStatic = localStaticPrivate,
|
||||||
let remoteEphemeral = remoteEphemeralPublic else {
|
let remoteEphemeral = remoteEphemeralPublic else {
|
||||||
@@ -804,7 +725,7 @@ final class NoiseHandshakeState {
|
|||||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||||
symmetricState.mixKey(sharedData)
|
symmetricState.mixKey(sharedData)
|
||||||
// Clear sensitive shared secret
|
// Clear sensitive shared secret
|
||||||
keychain.secureClear(&sharedData)
|
KeychainManager.secureClear(&sharedData)
|
||||||
}
|
}
|
||||||
|
|
||||||
case .se:
|
case .se:
|
||||||
@@ -817,7 +738,7 @@ final class NoiseHandshakeState {
|
|||||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||||
symmetricState.mixKey(sharedData)
|
symmetricState.mixKey(sharedData)
|
||||||
// Clear sensitive shared secret
|
// Clear sensitive shared secret
|
||||||
keychain.secureClear(&sharedData)
|
KeychainManager.secureClear(&sharedData)
|
||||||
} else {
|
} else {
|
||||||
guard let localEphemeral = localEphemeralPrivate,
|
guard let localEphemeral = localEphemeralPrivate,
|
||||||
let remoteStatic = remoteStaticPublic else {
|
let remoteStatic = remoteStaticPublic else {
|
||||||
@@ -827,7 +748,7 @@ final class NoiseHandshakeState {
|
|||||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||||
symmetricState.mixKey(sharedData)
|
symmetricState.mixKey(sharedData)
|
||||||
// Clear sensitive shared secret
|
// Clear sensitive shared secret
|
||||||
keychain.secureClear(&sharedData)
|
KeychainManager.secureClear(&sharedData)
|
||||||
}
|
}
|
||||||
|
|
||||||
case .ss:
|
case .ss:
|
||||||
@@ -836,12 +757,9 @@ final class NoiseHandshakeState {
|
|||||||
throw NoiseError.missingKeys
|
throw NoiseError.missingKeys
|
||||||
}
|
}
|
||||||
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteStatic)
|
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteStatic)
|
||||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||||
symmetricState.mixKey(sharedData)
|
|
||||||
// Clear sensitive shared secret
|
default:
|
||||||
keychain.secureClear(&sharedData)
|
|
||||||
|
|
||||||
case .e, .s:
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -850,20 +768,16 @@ final class NoiseHandshakeState {
|
|||||||
return currentPattern >= messagePatterns.count
|
return currentPattern >= messagePatterns.count
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTransportCiphers(useExtractedNonce: Bool) throws -> (send: NoiseCipherState, receive: NoiseCipherState, handshakeHash: Data) {
|
func getTransportCiphers() throws -> (send: NoiseCipherState, receive: NoiseCipherState) {
|
||||||
guard isHandshakeComplete() else {
|
guard isHandshakeComplete() else {
|
||||||
throw NoiseError.handshakeNotComplete
|
throw NoiseError.handshakeNotComplete
|
||||||
}
|
}
|
||||||
|
|
||||||
// BCH-01-010: Capture handshake hash BEFORE split() clears symmetric state
|
let (c1, c2) = symmetricState.split()
|
||||||
let finalHandshakeHash = symmetricState.getHandshakeHash()
|
|
||||||
|
|
||||||
let (c1, c2) = symmetricState.split(useExtractedNonce: useExtractedNonce)
|
|
||||||
|
|
||||||
// Initiator uses c1 for sending, c2 for receiving
|
// Initiator uses c1 for sending, c2 for receiving
|
||||||
// Responder uses c2 for sending, c1 for receiving
|
// Responder uses c2 for sending, c1 for receiving
|
||||||
let ciphers = role == .initiator ? (c1, c2) : (c2, c1)
|
return role == .initiator ? (c1, c2) : (c2, c1)
|
||||||
return (send: ciphers.0, receive: ciphers.1, handshakeHash: finalHandshakeHash)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getRemoteStaticPublicKey() -> Curve25519.KeyAgreement.PublicKey? {
|
func getRemoteStaticPublicKey() -> Curve25519.KeyAgreement.PublicKey? {
|
||||||
@@ -873,20 +787,6 @@ final class NoiseHandshakeState {
|
|||||||
func getHandshakeHash() -> Data {
|
func getHandshakeHash() -> Data {
|
||||||
return symmetricState.getHandshakeHash()
|
return symmetricState.getHandshakeHash()
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
func performDHOperationForTesting(_ pattern: NoiseMessagePattern) throws {
|
|
||||||
try performDHOperation(pattern)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setCurrentPatternForTesting(_ currentPattern: Int) {
|
|
||||||
self.currentPattern = currentPattern
|
|
||||||
}
|
|
||||||
|
|
||||||
func setRemoteEphemeralPublicKeyForTesting(_ key: Curve25519.KeyAgreement.PublicKey?) {
|
|
||||||
self.remoteEphemeralPublic = key
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Pattern Extensions
|
// MARK: - Pattern Extensions
|
||||||
@@ -924,7 +824,7 @@ extension NoisePattern {
|
|||||||
|
|
||||||
// MARK: - Errors
|
// MARK: - Errors
|
||||||
|
|
||||||
public enum NoiseError: Error {
|
enum NoiseError: Error {
|
||||||
case uninitializedCipher
|
case uninitializedCipher
|
||||||
case invalidCiphertext
|
case invalidCiphertext
|
||||||
case handshakeComplete
|
case handshakeComplete
|
||||||
@@ -938,47 +838,22 @@ public enum NoiseError: Error {
|
|||||||
case nonceExceeded
|
case nonceExceeded
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Constant-Time Operations
|
|
||||||
|
|
||||||
/// BCH-01-010: Constant-time comparison to prevent timing side-channel attacks
|
|
||||||
/// This function compares two Data objects in constant time, preventing
|
|
||||||
/// information leakage via timing analysis.
|
|
||||||
private func constantTimeCompare(_ a: Data, _ b: Data) -> Bool {
|
|
||||||
guard a.count == b.count else { return false }
|
|
||||||
|
|
||||||
var result: UInt8 = 0
|
|
||||||
for i in 0..<a.count {
|
|
||||||
result |= a[a.startIndex.advanced(by: i)] ^ b[b.startIndex.advanced(by: i)]
|
|
||||||
}
|
|
||||||
return result == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// BCH-01-010: Constant-time check if all bytes are zero
|
|
||||||
private func constantTimeIsZero(_ data: Data) -> Bool {
|
|
||||||
var result: UInt8 = 0
|
|
||||||
for byte in data {
|
|
||||||
result |= byte
|
|
||||||
}
|
|
||||||
return result == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Key Validation
|
// MARK: - Key Validation
|
||||||
|
|
||||||
extension NoiseHandshakeState {
|
extension NoiseHandshakeState {
|
||||||
/// Validate a Curve25519 public key
|
/// Validate a Curve25519 public key
|
||||||
/// Checks for weak/invalid keys that could compromise security
|
/// Checks for weak/invalid keys that could compromise security
|
||||||
/// BCH-01-010: Uses constant-time operations to prevent timing side-channels
|
|
||||||
static func validatePublicKey(_ keyData: Data) throws -> Curve25519.KeyAgreement.PublicKey {
|
static func validatePublicKey(_ keyData: Data) throws -> Curve25519.KeyAgreement.PublicKey {
|
||||||
// Check key length
|
// Check key length
|
||||||
guard keyData.count == 32 else {
|
guard keyData.count == 32 else {
|
||||||
throw NoiseError.invalidPublicKey
|
throw NoiseError.invalidPublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// BCH-01-010: Constant-time check for all-zero key (point at infinity)
|
// Check for all-zero key (point at infinity)
|
||||||
if constantTimeIsZero(keyData) {
|
if keyData.allSatisfy({ $0 == 0 }) {
|
||||||
throw NoiseError.invalidPublicKey
|
throw NoiseError.invalidPublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for low-order points that could enable small subgroup attacks
|
// Check for low-order points that could enable small subgroup attacks
|
||||||
// These are the known bad points for Curve25519
|
// These are the known bad points for Curve25519
|
||||||
let lowOrderPoints: [Data] = [
|
let lowOrderPoints: [Data] = [
|
||||||
@@ -999,28 +874,20 @@ extension NoiseHandshakeState {
|
|||||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]) // Another bad point
|
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]) // Another bad point
|
||||||
]
|
]
|
||||||
|
|
||||||
// BCH-01-010: Constant-time check against known bad points
|
// Check against known bad points
|
||||||
// We check all points and accumulate matches to avoid early exit timing leaks
|
if lowOrderPoints.contains(keyData) {
|
||||||
var foundBadPoint = false
|
SecureLogger.log("Low-order point detected", category: SecureLogger.security, level: .warning)
|
||||||
for badPoint in lowOrderPoints {
|
|
||||||
if constantTimeCompare(keyData, badPoint) {
|
|
||||||
foundBadPoint = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if foundBadPoint {
|
|
||||||
SecureLogger.warning("Low-order point detected", category: .security)
|
|
||||||
throw NoiseError.invalidPublicKey
|
throw NoiseError.invalidPublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to create the key - CryptoKit will validate curve points internally
|
// Try to create the key - CryptoKit will validate curve points internally
|
||||||
do {
|
do {
|
||||||
let publicKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: keyData)
|
let publicKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: keyData)
|
||||||
return publicKey
|
return publicKey
|
||||||
} catch {
|
} catch {
|
||||||
// If CryptoKit rejects it, it's invalid
|
// If CryptoKit rejects it, it's invalid
|
||||||
SecureLogger.warning("CryptoKit validation failed", category: .security)
|
SecureLogger.log("CryptoKit validation failed", category: SecureLogger.security, level: .warning)
|
||||||
throw NoiseError.invalidPublicKey
|
throw NoiseError.invalidPublicKey
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
//
|
||||||
|
// NoiseSecurityConsiderations.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
// MARK: - Security Constants
|
||||||
|
|
||||||
|
enum NoiseSecurityConstants {
|
||||||
|
// Maximum message size to prevent memory exhaustion
|
||||||
|
static let maxMessageSize = 65535 // 64KB as per Noise spec
|
||||||
|
|
||||||
|
// Maximum handshake message size
|
||||||
|
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
|
||||||
|
|
||||||
|
// Session timeout - sessions older than this should be renegotiated
|
||||||
|
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
||||||
|
|
||||||
|
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
|
||||||
|
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
|
||||||
|
|
||||||
|
// Handshake timeout - abandon incomplete handshakes
|
||||||
|
static let handshakeTimeout: TimeInterval = 60 // 1 minute
|
||||||
|
|
||||||
|
// Maximum concurrent sessions per peer
|
||||||
|
static let maxSessionsPerPeer = 3
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
static let maxHandshakesPerMinute = 10
|
||||||
|
static let maxMessagesPerSecond = 100
|
||||||
|
|
||||||
|
// Global rate limiting (across all peers)
|
||||||
|
static let maxGlobalHandshakesPerMinute = 30
|
||||||
|
static let maxGlobalMessagesPerSecond = 500
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Security Validations
|
||||||
|
|
||||||
|
struct NoiseSecurityValidator {
|
||||||
|
|
||||||
|
/// Validate message size
|
||||||
|
static func validateMessageSize(_ data: Data) -> Bool {
|
||||||
|
return data.count <= NoiseSecurityConstants.maxMessageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate handshake message size
|
||||||
|
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
||||||
|
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate peer ID format using unified validator
|
||||||
|
static func validatePeerID(_ peerID: String) -> Bool {
|
||||||
|
return InputValidator.validatePeerID(peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Enhanced Noise Session with Security
|
||||||
|
|
||||||
|
class SecureNoiseSession: NoiseSession {
|
||||||
|
private(set) var messageCount: UInt64 = 0
|
||||||
|
private let sessionStartTime = Date()
|
||||||
|
private(set) var lastActivityTime = Date()
|
||||||
|
|
||||||
|
override func encrypt(_ plaintext: Data) throws -> Data {
|
||||||
|
// Check session age
|
||||||
|
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
|
||||||
|
throw NoiseSecurityError.sessionExpired
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check message count
|
||||||
|
if messageCount >= NoiseSecurityConstants.maxMessagesPerSession {
|
||||||
|
throw NoiseSecurityError.sessionExhausted
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate message size
|
||||||
|
guard NoiseSecurityValidator.validateMessageSize(plaintext) else {
|
||||||
|
throw NoiseSecurityError.messageTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
let encrypted = try super.encrypt(plaintext)
|
||||||
|
messageCount += 1
|
||||||
|
lastActivityTime = Date()
|
||||||
|
|
||||||
|
return encrypted
|
||||||
|
}
|
||||||
|
|
||||||
|
override func decrypt(_ ciphertext: Data) throws -> Data {
|
||||||
|
// Check session age
|
||||||
|
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
|
||||||
|
throw NoiseSecurityError.sessionExpired
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate message size
|
||||||
|
guard NoiseSecurityValidator.validateMessageSize(ciphertext) else {
|
||||||
|
throw NoiseSecurityError.messageTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
let decrypted = try super.decrypt(ciphertext)
|
||||||
|
lastActivityTime = Date()
|
||||||
|
|
||||||
|
return decrypted
|
||||||
|
}
|
||||||
|
|
||||||
|
func needsRenegotiation() -> Bool {
|
||||||
|
// Check if we've used more than 90% of message limit
|
||||||
|
let messageThreshold = UInt64(Double(NoiseSecurityConstants.maxMessagesPerSession) * 0.9)
|
||||||
|
if messageCount >= messageThreshold {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if last activity was more than 30 minutes ago
|
||||||
|
if Date().timeIntervalSince(lastActivityTime) > NoiseSecurityConstants.sessionTimeout {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Testing Support
|
||||||
|
#if DEBUG
|
||||||
|
func setLastActivityTimeForTesting(_ date: Date) {
|
||||||
|
lastActivityTime = date
|
||||||
|
}
|
||||||
|
|
||||||
|
func setMessageCountForTesting(_ count: UInt64) {
|
||||||
|
messageCount = count
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rate Limiter
|
||||||
|
|
||||||
|
class NoiseRateLimiter {
|
||||||
|
private var handshakeTimestamps: [String: [Date]] = [:] // peerID -> timestamps
|
||||||
|
private var messageTimestamps: [String: [Date]] = [:] // peerID -> timestamps
|
||||||
|
|
||||||
|
// Global rate limiting
|
||||||
|
private var globalHandshakeTimestamps: [Date] = []
|
||||||
|
private var globalMessageTimestamps: [Date] = []
|
||||||
|
|
||||||
|
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
|
||||||
|
|
||||||
|
func allowHandshake(from peerID: String) -> Bool {
|
||||||
|
return queue.sync(flags: .barrier) {
|
||||||
|
let now = Date()
|
||||||
|
let oneMinuteAgo = now.addingTimeInterval(-60)
|
||||||
|
|
||||||
|
// Check global rate limit first
|
||||||
|
globalHandshakeTimestamps = globalHandshakeTimestamps.filter { $0 > oneMinuteAgo }
|
||||||
|
if globalHandshakeTimestamps.count >= NoiseSecurityConstants.maxGlobalHandshakesPerMinute {
|
||||||
|
SecureLogger.log("Global handshake rate limit exceeded: \(globalHandshakeTimestamps.count)/\(NoiseSecurityConstants.maxGlobalHandshakesPerMinute) per minute", category: SecureLogger.security, level: .warning)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check per-peer rate limit
|
||||||
|
var timestamps = handshakeTimestamps[peerID] ?? []
|
||||||
|
timestamps = timestamps.filter { $0 > oneMinuteAgo }
|
||||||
|
|
||||||
|
if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute {
|
||||||
|
SecureLogger.log("Per-peer handshake rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: SecureLogger.security, level: .warning)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record new handshake
|
||||||
|
timestamps.append(now)
|
||||||
|
handshakeTimestamps[peerID] = timestamps
|
||||||
|
globalHandshakeTimestamps.append(now)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func allowMessage(from peerID: String) -> Bool {
|
||||||
|
return queue.sync(flags: .barrier) {
|
||||||
|
let now = Date()
|
||||||
|
let oneSecondAgo = now.addingTimeInterval(-1)
|
||||||
|
|
||||||
|
// Check global rate limit first
|
||||||
|
globalMessageTimestamps = globalMessageTimestamps.filter { $0 > oneSecondAgo }
|
||||||
|
if globalMessageTimestamps.count >= NoiseSecurityConstants.maxGlobalMessagesPerSecond {
|
||||||
|
SecureLogger.log("Global message rate limit exceeded: \(globalMessageTimestamps.count)/\(NoiseSecurityConstants.maxGlobalMessagesPerSecond) per second", category: SecureLogger.security, level: .warning)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check per-peer rate limit
|
||||||
|
var timestamps = messageTimestamps[peerID] ?? []
|
||||||
|
timestamps = timestamps.filter { $0 > oneSecondAgo }
|
||||||
|
|
||||||
|
if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond {
|
||||||
|
SecureLogger.log("Per-peer message rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: SecureLogger.security, level: .warning)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record new message
|
||||||
|
timestamps.append(now)
|
||||||
|
messageTimestamps[peerID] = timestamps
|
||||||
|
globalMessageTimestamps.append(now)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reset(for peerID: String) {
|
||||||
|
queue.async(flags: .barrier) {
|
||||||
|
self.handshakeTimestamps.removeValue(forKey: peerID)
|
||||||
|
self.messageTimestamps.removeValue(forKey: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Security Errors
|
||||||
|
|
||||||
|
enum NoiseSecurityError: Error {
|
||||||
|
case sessionExpired
|
||||||
|
case sessionExhausted
|
||||||
|
case messageTooLarge
|
||||||
|
case invalidPeerID
|
||||||
|
case rateLimitExceeded
|
||||||
|
case handshakeTimeout
|
||||||
|
}
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
//
|
||||||
|
// NoiseSession.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
import os.log
|
||||||
|
|
||||||
|
// MARK: - Noise Session State
|
||||||
|
|
||||||
|
enum NoiseSessionState: Equatable {
|
||||||
|
case uninitialized
|
||||||
|
case handshaking
|
||||||
|
case established
|
||||||
|
case failed(Error)
|
||||||
|
|
||||||
|
static func == (lhs: NoiseSessionState, rhs: NoiseSessionState) -> Bool {
|
||||||
|
switch (lhs, rhs) {
|
||||||
|
case (.uninitialized, .uninitialized),
|
||||||
|
(.handshaking, .handshaking),
|
||||||
|
(.established, .established):
|
||||||
|
return true
|
||||||
|
case (.failed, .failed):
|
||||||
|
return true // We don't compare the errors
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Noise Session
|
||||||
|
|
||||||
|
class NoiseSession {
|
||||||
|
let peerID: String
|
||||||
|
let role: NoiseRole
|
||||||
|
private var state: NoiseSessionState = .uninitialized
|
||||||
|
private var handshakeState: NoiseHandshakeState?
|
||||||
|
private var sendCipher: NoiseCipherState?
|
||||||
|
private var receiveCipher: NoiseCipherState?
|
||||||
|
|
||||||
|
// Keys
|
||||||
|
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
|
||||||
|
private var remoteStaticPublicKey: Curve25519.KeyAgreement.PublicKey?
|
||||||
|
|
||||||
|
// Handshake messages for retransmission
|
||||||
|
private var sentHandshakeMessages: [Data] = []
|
||||||
|
private var handshakeHash: Data?
|
||||||
|
|
||||||
|
// Thread safety
|
||||||
|
private let sessionQueue = DispatchQueue(label: "chat.bitchat.noise.session", attributes: .concurrent)
|
||||||
|
|
||||||
|
init(peerID: String, role: NoiseRole, localStaticKey: Curve25519.KeyAgreement.PrivateKey, remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil) {
|
||||||
|
self.peerID = peerID
|
||||||
|
self.role = role
|
||||||
|
self.localStaticKey = localStaticKey
|
||||||
|
self.remoteStaticPublicKey = remoteStaticKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Handshake
|
||||||
|
|
||||||
|
func startHandshake() throws -> Data {
|
||||||
|
return try sessionQueue.sync(flags: .barrier) {
|
||||||
|
guard case .uninitialized = state else {
|
||||||
|
throw NoiseSessionError.invalidState
|
||||||
|
}
|
||||||
|
|
||||||
|
// For XX pattern, we don't need remote static key upfront
|
||||||
|
handshakeState = NoiseHandshakeState(
|
||||||
|
role: role,
|
||||||
|
pattern: .XX,
|
||||||
|
localStaticKey: localStaticKey,
|
||||||
|
remoteStaticKey: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
state = .handshaking
|
||||||
|
|
||||||
|
// Only initiator writes the first message
|
||||||
|
if role == .initiator {
|
||||||
|
let message = try handshakeState!.writeMessage()
|
||||||
|
sentHandshakeMessages.append(message)
|
||||||
|
return message
|
||||||
|
} else {
|
||||||
|
// Responder doesn't send first message in XX pattern
|
||||||
|
return Data()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func processHandshakeMessage(_ message: Data) throws -> Data? {
|
||||||
|
return try sessionQueue.sync(flags: .barrier) {
|
||||||
|
SecureLogger.log("NoiseSession[\(peerID)]: Processing handshake message, current state: \(state), role: \(role)", category: SecureLogger.noise, level: .debug)
|
||||||
|
|
||||||
|
// Initialize handshake state if needed (for responders)
|
||||||
|
if state == .uninitialized && role == .responder {
|
||||||
|
handshakeState = NoiseHandshakeState(
|
||||||
|
role: role,
|
||||||
|
pattern: .XX,
|
||||||
|
localStaticKey: localStaticKey,
|
||||||
|
remoteStaticKey: nil
|
||||||
|
)
|
||||||
|
state = .handshaking
|
||||||
|
SecureLogger.log("NoiseSession[\(peerID)]: Initialized handshake state for responder", category: SecureLogger.noise, level: .debug)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard case .handshaking = state, let handshake = handshakeState else {
|
||||||
|
throw NoiseSessionError.invalidState
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process incoming message
|
||||||
|
_ = try handshake.readMessage(message)
|
||||||
|
SecureLogger.log("NoiseSession[\(peerID)]: Read handshake message, checking if complete", category: SecureLogger.noise, level: .debug)
|
||||||
|
|
||||||
|
// Check if handshake is complete
|
||||||
|
if handshake.isHandshakeComplete() {
|
||||||
|
// Get transport ciphers
|
||||||
|
let (send, receive) = try handshake.getTransportCiphers()
|
||||||
|
sendCipher = send
|
||||||
|
receiveCipher = receive
|
||||||
|
|
||||||
|
// Store remote static key
|
||||||
|
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
|
||||||
|
|
||||||
|
// Store handshake hash for channel binding
|
||||||
|
handshakeHash = handshake.getHandshakeHash()
|
||||||
|
|
||||||
|
state = .established
|
||||||
|
handshakeState = nil // Clear handshake state
|
||||||
|
|
||||||
|
SecureLogger.log("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established", category: SecureLogger.noise, level: .debug)
|
||||||
|
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
} else {
|
||||||
|
// Generate response
|
||||||
|
let response = try handshake.writeMessage()
|
||||||
|
sentHandshakeMessages.append(response)
|
||||||
|
SecureLogger.log("NoiseSession[\(peerID)]: Generated handshake response of size \(response.count)", category: SecureLogger.noise, level: .debug)
|
||||||
|
|
||||||
|
// Check if handshake is complete after writing
|
||||||
|
if handshake.isHandshakeComplete() {
|
||||||
|
// Get transport ciphers
|
||||||
|
let (send, receive) = try handshake.getTransportCiphers()
|
||||||
|
sendCipher = send
|
||||||
|
receiveCipher = receive
|
||||||
|
|
||||||
|
// Store remote static key
|
||||||
|
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
|
||||||
|
|
||||||
|
// Store handshake hash for channel binding
|
||||||
|
handshakeHash = handshake.getHandshakeHash()
|
||||||
|
|
||||||
|
state = .established
|
||||||
|
handshakeState = nil // Clear handshake state
|
||||||
|
|
||||||
|
SecureLogger.log("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established", category: SecureLogger.noise, level: .debug)
|
||||||
|
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID))
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Transport
|
||||||
|
|
||||||
|
func encrypt(_ plaintext: Data) throws -> Data {
|
||||||
|
return try sessionQueue.sync(flags: .barrier) {
|
||||||
|
guard case .established = state, let cipher = sendCipher else {
|
||||||
|
throw NoiseSessionError.notEstablished
|
||||||
|
}
|
||||||
|
|
||||||
|
return try cipher.encrypt(plaintext: plaintext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decrypt(_ ciphertext: Data) throws -> Data {
|
||||||
|
return try sessionQueue.sync(flags: .barrier) {
|
||||||
|
guard case .established = state, let cipher = receiveCipher else {
|
||||||
|
throw NoiseSessionError.notEstablished
|
||||||
|
}
|
||||||
|
|
||||||
|
return try cipher.decrypt(ciphertext: ciphertext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - State Management
|
||||||
|
|
||||||
|
func getState() -> NoiseSessionState {
|
||||||
|
return sessionQueue.sync {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isEstablished() -> Bool {
|
||||||
|
return sessionQueue.sync {
|
||||||
|
if case .established = state {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRemoteStaticPublicKey() -> Curve25519.KeyAgreement.PublicKey? {
|
||||||
|
return sessionQueue.sync {
|
||||||
|
return remoteStaticPublicKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getHandshakeHash() -> Data? {
|
||||||
|
return sessionQueue.sync {
|
||||||
|
return handshakeHash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reset() {
|
||||||
|
sessionQueue.sync(flags: .barrier) {
|
||||||
|
let wasEstablished = state == .established
|
||||||
|
state = .uninitialized
|
||||||
|
handshakeState = nil
|
||||||
|
|
||||||
|
// Clear sensitive cipher states
|
||||||
|
sendCipher?.clearSensitiveData()
|
||||||
|
receiveCipher?.clearSensitiveData()
|
||||||
|
sendCipher = nil
|
||||||
|
receiveCipher = nil
|
||||||
|
|
||||||
|
// Clear sent handshake messages
|
||||||
|
for i in 0..<sentHandshakeMessages.count {
|
||||||
|
var message = sentHandshakeMessages[i]
|
||||||
|
KeychainManager.secureClear(&message)
|
||||||
|
}
|
||||||
|
sentHandshakeMessages.removeAll()
|
||||||
|
|
||||||
|
// Clear handshake hash
|
||||||
|
if var hash = handshakeHash {
|
||||||
|
KeychainManager.secureClear(&hash)
|
||||||
|
}
|
||||||
|
handshakeHash = nil
|
||||||
|
|
||||||
|
if wasEstablished {
|
||||||
|
SecureLogger.logSecurityEvent(.sessionExpired(peerID: peerID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session Manager
|
||||||
|
|
||||||
|
class NoiseSessionManager {
|
||||||
|
private var sessions: [String: NoiseSession] = [:]
|
||||||
|
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
|
||||||
|
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
var onSessionEstablished: ((String, Curve25519.KeyAgreement.PublicKey) -> Void)?
|
||||||
|
var onSessionFailed: ((String, Error) -> Void)?
|
||||||
|
|
||||||
|
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey) {
|
||||||
|
self.localStaticKey = localStaticKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session Management
|
||||||
|
|
||||||
|
func createSession(for peerID: String, role: NoiseRole) -> NoiseSession {
|
||||||
|
return managerQueue.sync(flags: .barrier) {
|
||||||
|
let session = SecureNoiseSession(
|
||||||
|
peerID: peerID,
|
||||||
|
role: role,
|
||||||
|
localStaticKey: localStaticKey
|
||||||
|
)
|
||||||
|
sessions[peerID] = session
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSession(for peerID: String) -> NoiseSession? {
|
||||||
|
return managerQueue.sync {
|
||||||
|
return sessions[peerID]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeSession(for peerID: String) {
|
||||||
|
managerQueue.sync(flags: .barrier) {
|
||||||
|
if let session = sessions[peerID] {
|
||||||
|
if session.isEstablished() {
|
||||||
|
SecureLogger.logSecurityEvent(.sessionExpired(peerID: peerID))
|
||||||
|
}
|
||||||
|
// Clear sensitive data before removing
|
||||||
|
session.reset()
|
||||||
|
}
|
||||||
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEstablishedSessions() -> [String: NoiseSession] {
|
||||||
|
return managerQueue.sync {
|
||||||
|
return sessions.filter { $0.value.isEstablished() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Handshake Helpers
|
||||||
|
|
||||||
|
func initiateHandshake(with peerID: String) throws -> Data {
|
||||||
|
return try managerQueue.sync(flags: .barrier) {
|
||||||
|
// Check if we already have an established session
|
||||||
|
if let existingSession = sessions[peerID], existingSession.isEstablished() {
|
||||||
|
// Session already established, don't recreate
|
||||||
|
throw NoiseSessionError.alreadyEstablished
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any existing non-established session
|
||||||
|
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
|
||||||
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new initiator session
|
||||||
|
let session = SecureNoiseSession(
|
||||||
|
peerID: peerID,
|
||||||
|
role: .initiator,
|
||||||
|
localStaticKey: localStaticKey
|
||||||
|
)
|
||||||
|
sessions[peerID] = session
|
||||||
|
|
||||||
|
do {
|
||||||
|
let handshakeData = try session.startHandshake()
|
||||||
|
return handshakeData
|
||||||
|
} catch {
|
||||||
|
// Clean up failed session
|
||||||
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
SecureLogger.logSecurityEvent(.handshakeFailed(peerID: peerID, error: error.localizedDescription), level: .error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleIncomingHandshake(from peerID: String, message: Data) throws -> Data? {
|
||||||
|
// Process everything within the synchronized block to prevent race conditions
|
||||||
|
return try managerQueue.sync(flags: .barrier) {
|
||||||
|
var shouldCreateNew = false
|
||||||
|
var existingSession: NoiseSession? = nil
|
||||||
|
|
||||||
|
if let existing = sessions[peerID] {
|
||||||
|
// If we have an established session, the peer must have cleared their session
|
||||||
|
// for a good reason (e.g., decryption failure, restart, etc.)
|
||||||
|
// We should accept the new handshake to re-establish encryption
|
||||||
|
if existing.isEstablished() {
|
||||||
|
SecureLogger.log("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session",
|
||||||
|
category: SecureLogger.session, level: .info)
|
||||||
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
shouldCreateNew = true
|
||||||
|
} else {
|
||||||
|
// If we're in the middle of a handshake and receive a new initiation,
|
||||||
|
// reset and start fresh (the other side may have restarted)
|
||||||
|
if existing.getState() == .handshaking && message.count == 32 {
|
||||||
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
shouldCreateNew = true
|
||||||
|
} else {
|
||||||
|
existingSession = existing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
shouldCreateNew = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create session
|
||||||
|
let session: NoiseSession
|
||||||
|
if shouldCreateNew {
|
||||||
|
let newSession = SecureNoiseSession(
|
||||||
|
peerID: peerID,
|
||||||
|
role: .responder,
|
||||||
|
localStaticKey: localStaticKey
|
||||||
|
)
|
||||||
|
sessions[peerID] = newSession
|
||||||
|
session = newSession
|
||||||
|
} else {
|
||||||
|
session = existingSession!
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the handshake message within the synchronized block
|
||||||
|
do {
|
||||||
|
let response = try session.processHandshakeMessage(message)
|
||||||
|
|
||||||
|
// Check if session is established after processing
|
||||||
|
if session.isEstablished() {
|
||||||
|
if let remoteKey = session.getRemoteStaticPublicKey() {
|
||||||
|
// Schedule callback outside the synchronized block to prevent deadlock
|
||||||
|
DispatchQueue.global().async { [weak self] in
|
||||||
|
self?.onSessionEstablished?(peerID, remoteKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch {
|
||||||
|
// Reset the session on handshake failure so next attempt can start fresh
|
||||||
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
|
||||||
|
// Schedule callback outside the synchronized block to prevent deadlock
|
||||||
|
DispatchQueue.global().async { [weak self] in
|
||||||
|
self?.onSessionFailed?(peerID, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
SecureLogger.logSecurityEvent(.handshakeFailed(peerID: peerID, error: error.localizedDescription), level: .error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Encryption/Decryption
|
||||||
|
|
||||||
|
func encrypt(_ plaintext: Data, for peerID: String) throws -> Data {
|
||||||
|
guard let session = getSession(for: peerID) else {
|
||||||
|
throw NoiseSessionError.sessionNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return try session.encrypt(plaintext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decrypt(_ ciphertext: Data, from peerID: String) throws -> Data {
|
||||||
|
guard let session = getSession(for: peerID) else {
|
||||||
|
throw NoiseSessionError.sessionNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return try session.decrypt(ciphertext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Key Management
|
||||||
|
|
||||||
|
func getRemoteStaticKey(for peerID: String) -> Curve25519.KeyAgreement.PublicKey? {
|
||||||
|
return getSession(for: peerID)?.getRemoteStaticPublicKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
func getHandshakeHash(for peerID: String) -> Data? {
|
||||||
|
return getSession(for: peerID)?.getHandshakeHash()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session Rekeying
|
||||||
|
|
||||||
|
func getSessionsNeedingRekey() -> [(peerID: String, needsRekey: Bool)] {
|
||||||
|
return managerQueue.sync {
|
||||||
|
var needingRekey: [(peerID: String, needsRekey: Bool)] = []
|
||||||
|
|
||||||
|
for (peerID, session) in sessions {
|
||||||
|
if let secureSession = session as? SecureNoiseSession,
|
||||||
|
secureSession.isEstablished(),
|
||||||
|
secureSession.needsRenegotiation() {
|
||||||
|
needingRekey.append((peerID: peerID, needsRekey: true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return needingRekey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func initiateRekey(for peerID: String) throws {
|
||||||
|
// Remove old session
|
||||||
|
removeSession(for: peerID)
|
||||||
|
|
||||||
|
// Initiate new handshake
|
||||||
|
_ = try initiateHandshake(with: peerID)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Errors
|
||||||
|
|
||||||
|
enum NoiseSessionError: Error {
|
||||||
|
case invalidState
|
||||||
|
case notEstablished
|
||||||
|
case sessionNotFound
|
||||||
|
case handshakeFailed(Error)
|
||||||
|
case alreadyEstablished
|
||||||
|
}
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// Bech32 encoding for Nostr (minimal implementation)
|
|
||||||
enum Bech32 {
|
|
||||||
private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
|
||||||
private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
|
|
||||||
|
|
||||||
static func encode(hrp: String, data: Data) throws -> String {
|
|
||||||
let values = convertBits(from: 8, to: 5, pad: true, data: Array(data))
|
|
||||||
let checksum = createChecksum(hrp: hrp, values: values)
|
|
||||||
let combined = values + checksum
|
|
||||||
|
|
||||||
return hrp + "1" + combined.map {
|
|
||||||
let index = charset.index(charset.startIndex, offsetBy: Int($0))
|
|
||||||
return String(charset[index])
|
|
||||||
}.joined()
|
|
||||||
}
|
|
||||||
|
|
||||||
static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {
|
|
||||||
// Find the last occurrence of '1'
|
|
||||||
guard let separatorIndex = bech32String.lastIndex(of: "1") else {
|
|
||||||
throw Bech32Error.invalidFormat
|
|
||||||
}
|
|
||||||
|
|
||||||
let hrp = String(bech32String[..<separatorIndex])
|
|
||||||
|
|
||||||
// Validate HRP contains only ASCII characters
|
|
||||||
for char in hrp {
|
|
||||||
guard char.asciiValue != nil else {
|
|
||||||
throw Bech32Error.invalidCharacter
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])
|
|
||||||
|
|
||||||
// Convert characters to values
|
|
||||||
var values = [UInt8]()
|
|
||||||
for char in dataString {
|
|
||||||
guard let index = charset.firstIndex(of: char) else {
|
|
||||||
throw Bech32Error.invalidCharacter
|
|
||||||
}
|
|
||||||
values.append(UInt8(charset.distance(from: charset.startIndex, to: index)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify checksum
|
|
||||||
guard values.count >= 6 else {
|
|
||||||
throw Bech32Error.invalidChecksum
|
|
||||||
}
|
|
||||||
|
|
||||||
let payloadValues = Array(values.dropLast(6))
|
|
||||||
let checksum = Array(values.suffix(6))
|
|
||||||
let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues)
|
|
||||||
|
|
||||||
guard checksum == expectedChecksum else {
|
|
||||||
throw Bech32Error.invalidChecksum
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert back to bytes
|
|
||||||
let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues)
|
|
||||||
return (hrp: hrp, data: Data(bytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Bech32Error: Error {
|
|
||||||
case invalidFormat
|
|
||||||
case invalidCharacter
|
|
||||||
case invalidChecksum
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] {
|
|
||||||
var acc = 0
|
|
||||||
var bits = 0
|
|
||||||
var result = [UInt8]()
|
|
||||||
let maxv = (1 << to) - 1
|
|
||||||
|
|
||||||
for value in data {
|
|
||||||
acc = (acc << from) | Int(value)
|
|
||||||
bits += from
|
|
||||||
|
|
||||||
while bits >= to {
|
|
||||||
bits -= to
|
|
||||||
result.append(UInt8((acc >> bits) & maxv))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if pad && bits > 0 {
|
|
||||||
result.append(UInt8((acc << (to - bits)) & maxv))
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] {
|
|
||||||
let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0]
|
|
||||||
let polymod = polymod(checksumValues) ^ 1
|
|
||||||
var checksum = [UInt8]()
|
|
||||||
|
|
||||||
for i in 0..<6 {
|
|
||||||
checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31))
|
|
||||||
}
|
|
||||||
|
|
||||||
return checksum
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func hrpExpand(_ hrp: String) -> [UInt8] {
|
|
||||||
var result = [UInt8]()
|
|
||||||
for c in hrp {
|
|
||||||
guard let asciiValue = c.asciiValue else {
|
|
||||||
return [] // Return empty array for invalid input
|
|
||||||
}
|
|
||||||
result.append(UInt8(asciiValue >> 5))
|
|
||||||
}
|
|
||||||
result.append(0)
|
|
||||||
for c in hrp {
|
|
||||||
guard let asciiValue = c.asciiValue else {
|
|
||||||
return [] // Return empty array for invalid input
|
|
||||||
}
|
|
||||||
result.append(UInt8(asciiValue & 31))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func polymod(_ values: [UInt8]) -> Int {
|
|
||||||
var chk = 1
|
|
||||||
for value in values {
|
|
||||||
let b = chk >> 25
|
|
||||||
chk = (chk & 0x1ffffff) << 5 ^ Int(value)
|
|
||||||
for i in 0..<5 {
|
|
||||||
if (b >> i) & 1 == 1 {
|
|
||||||
chk ^= generator[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return chk
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,161 +1,26 @@
|
|||||||
import BitLogger
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Tor
|
|
||||||
#if os(iOS)
|
|
||||||
import UIKit
|
|
||||||
#elseif os(macOS)
|
|
||||||
import AppKit
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
||||||
struct GeoRelayDirectoryDependencies {
|
|
||||||
var userDefaults: UserDefaults
|
|
||||||
var notificationCenter: NotificationCenter
|
|
||||||
var now: () -> Date
|
|
||||||
var remoteURL: URL
|
|
||||||
var fetchInterval: TimeInterval
|
|
||||||
var refreshCheckInterval: TimeInterval
|
|
||||||
var retryInitialSeconds: TimeInterval
|
|
||||||
var retryMaxSeconds: TimeInterval
|
|
||||||
var awaitTorReady: @Sendable () async -> Bool
|
|
||||||
var makeFetchData: @MainActor @Sendable () -> (@Sendable (URLRequest) async throws -> Data)
|
|
||||||
var readData: (URL) -> Data?
|
|
||||||
var writeData: (Data, URL) throws -> Void
|
|
||||||
var cacheURL: () -> URL?
|
|
||||||
var bundledCSVURLs: () -> [URL]
|
|
||||||
var currentDirectoryPath: () -> String?
|
|
||||||
var retrySleep: (TimeInterval) async -> Void
|
|
||||||
var activeNotificationName: Notification.Name?
|
|
||||||
var autoStart: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension GeoRelayDirectoryDependencies {
|
|
||||||
@MainActor
|
|
||||||
static func live() -> Self {
|
|
||||||
#if os(iOS)
|
|
||||||
let activeNotificationName: Notification.Name? = UIApplication.didBecomeActiveNotification
|
|
||||||
#elseif os(macOS)
|
|
||||||
let activeNotificationName: Notification.Name? = NSApplication.didBecomeActiveNotification
|
|
||||||
#else
|
|
||||||
let activeNotificationName: Notification.Name? = nil
|
|
||||||
#endif
|
|
||||||
|
|
||||||
return Self(
|
|
||||||
userDefaults: .standard,
|
|
||||||
notificationCenter: .default,
|
|
||||||
now: Date.init,
|
|
||||||
remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!,
|
|
||||||
fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds,
|
|
||||||
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
|
|
||||||
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
|
|
||||||
retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds,
|
|
||||||
awaitTorReady: { await TorManager.shared.awaitReady() },
|
|
||||||
makeFetchData: {
|
|
||||||
let session = TorURLSession.shared.session
|
|
||||||
return { request in
|
|
||||||
let (data, _) = try await session.data(for: request)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
},
|
|
||||||
readData: { try? Data(contentsOf: $0) },
|
|
||||||
writeData: { data, url in
|
|
||||||
try data.write(to: url, options: .atomic)
|
|
||||||
},
|
|
||||||
cacheURL: {
|
|
||||||
do {
|
|
||||||
let base = try FileManager.default.url(
|
|
||||||
for: .applicationSupportDirectory,
|
|
||||||
in: .userDomainMask,
|
|
||||||
appropriateFor: nil,
|
|
||||||
create: true
|
|
||||||
)
|
|
||||||
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
|
||||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
||||||
return dir.appendingPathComponent("georelays_cache.csv")
|
|
||||||
} catch {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
},
|
|
||||||
bundledCSVURLs: {
|
|
||||||
[
|
|
||||||
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
|
|
||||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
|
|
||||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
|
|
||||||
].compactMap { $0 }
|
|
||||||
},
|
|
||||||
currentDirectoryPath: { FileManager.default.currentDirectoryPath },
|
|
||||||
retrySleep: { delay in
|
|
||||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
|
||||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
|
||||||
},
|
|
||||||
activeNotificationName: activeNotificationName,
|
|
||||||
autoStart: true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class GeoRelayDirectory {
|
final class GeoRelayDirectory {
|
||||||
private final class CleanupState {
|
struct Entry: Hashable {
|
||||||
let notificationCenter: NotificationCenter
|
|
||||||
var observers: [NSObjectProtocol] = []
|
|
||||||
var refreshTimer: Timer?
|
|
||||||
var retryTask: Task<Void, Never>?
|
|
||||||
|
|
||||||
init(notificationCenter: NotificationCenter) {
|
|
||||||
self.notificationCenter = notificationCenter
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
observers.forEach { notificationCenter.removeObserver($0) }
|
|
||||||
refreshTimer?.invalidate()
|
|
||||||
retryTask?.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Entry: Hashable, Sendable {
|
|
||||||
let host: String
|
let host: String
|
||||||
let lat: Double
|
let lat: Double
|
||||||
let lon: Double
|
let lon: Double
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum DetachedFetchOutcome: Sendable {
|
|
||||||
case success(entries: [Entry], csv: String)
|
|
||||||
case torNotReady
|
|
||||||
case invalidData
|
|
||||||
case network(String)
|
|
||||||
}
|
|
||||||
|
|
||||||
static let shared = GeoRelayDirectory()
|
static let shared = GeoRelayDirectory()
|
||||||
|
|
||||||
private(set) var entries: [Entry] = []
|
private(set) var entries: [Entry] = []
|
||||||
|
private let cacheFileName = "georelays_cache.csv"
|
||||||
private let lastFetchKey = "georelay.lastFetchAt"
|
private let lastFetchKey = "georelay.lastFetchAt"
|
||||||
private let dependencies: GeoRelayDirectoryDependencies
|
private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
|
||||||
private let cleanupState: CleanupState
|
private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds // 24h
|
||||||
|
|
||||||
private var retryAttempt: Int = 0
|
|
||||||
private var isFetching: Bool = false
|
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
self.dependencies = .live()
|
// Load cached or bundled data synchronously
|
||||||
self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter)
|
self.entries = self.loadLocalEntries()
|
||||||
entries = loadLocalEntries()
|
// Fire-and-forget remote refresh if stale
|
||||||
if dependencies.autoStart {
|
prefetchIfNeeded()
|
||||||
registerObservers()
|
|
||||||
startRefreshTimer()
|
|
||||||
prefetchIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal init(dependencies: GeoRelayDirectoryDependencies) {
|
|
||||||
self.dependencies = dependencies
|
|
||||||
self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter)
|
|
||||||
entries = loadLocalEntries()
|
|
||||||
if dependencies.autoStart {
|
|
||||||
registerObservers()
|
|
||||||
startRefreshTimer()
|
|
||||||
prefetchIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
|
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
|
||||||
@@ -166,220 +31,93 @@ final class GeoRelayDirectory {
|
|||||||
|
|
||||||
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
|
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
|
||||||
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
|
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
|
||||||
guard !entries.isEmpty, count > 0 else { return [] }
|
guard !entries.isEmpty else { return [] }
|
||||||
|
let sorted = entries
|
||||||
if entries.count <= count {
|
.sorted { a, b in
|
||||||
return entries
|
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
|
||||||
.sorted { a, b in
|
|
||||||
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
|
|
||||||
}
|
|
||||||
.map { "wss://\($0.host)" }
|
|
||||||
}
|
|
||||||
|
|
||||||
var best: [(entry: Entry, distance: Double)] = []
|
|
||||||
best.reserveCapacity(count)
|
|
||||||
|
|
||||||
for entry in entries {
|
|
||||||
let distance = haversineKm(lat, lon, entry.lat, entry.lon)
|
|
||||||
if best.count < count {
|
|
||||||
let idx = best.firstIndex { $0.distance > distance } ?? best.count
|
|
||||||
best.insert((entry, distance), at: idx)
|
|
||||||
} else if let worstDistance = best.last?.distance, distance < worstDistance {
|
|
||||||
let idx = best.firstIndex { $0.distance > distance } ?? best.count
|
|
||||||
best.insert((entry, distance), at: idx)
|
|
||||||
best.removeLast()
|
|
||||||
}
|
}
|
||||||
}
|
.prefix(count)
|
||||||
|
return sorted.map { "wss://\($0.host)" }
|
||||||
return best.map { "wss://\($0.entry.host)" }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Remote Fetch
|
// MARK: - Remote Fetch
|
||||||
func prefetchIfNeeded(force: Bool = false) {
|
func prefetchIfNeeded() {
|
||||||
guard !isFetching else { return }
|
let now = Date()
|
||||||
|
let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
||||||
let now = dependencies.now()
|
guard now.timeIntervalSince(last) >= fetchInterval else { return }
|
||||||
let last = dependencies.userDefaults.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
|
||||||
|
|
||||||
if !force {
|
|
||||||
guard now.timeIntervalSince(last) >= dependencies.fetchInterval else { return }
|
|
||||||
} else if last != .distantPast,
|
|
||||||
now.timeIntervalSince(last) < dependencies.retryInitialSeconds {
|
|
||||||
// Skip forced fetches if we just refreshed moments ago.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelRetry()
|
|
||||||
fetchRemote()
|
fetchRemote()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchRemote() {
|
private func fetchRemote() {
|
||||||
guard !isFetching else { return }
|
let req = URLRequest(url: remoteURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
|
||||||
isFetching = true
|
let task = URLSession.shared.dataTask(with: req) { [weak self] data, _, error in
|
||||||
|
guard let self = self else { return }
|
||||||
let request = URLRequest(
|
if let data = data, error == nil, let text = String(data: data, encoding: .utf8) {
|
||||||
url: dependencies.remoteURL,
|
let parsed = GeoRelayDirectory.parseCSV(text)
|
||||||
cachePolicy: .reloadIgnoringLocalCacheData,
|
if !parsed.isEmpty {
|
||||||
timeoutInterval: 15
|
Task { @MainActor in
|
||||||
)
|
self.entries = parsed
|
||||||
let awaitTorReady = dependencies.awaitTorReady
|
self.persistCache(text)
|
||||||
let fetchData = dependencies.makeFetchData()
|
UserDefaults.standard.set(Date(), forKey: self.lastFetchKey)
|
||||||
|
SecureLogger.log("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: SecureLogger.session, level: .info)
|
||||||
Task { [weak self] in
|
}
|
||||||
guard let self else { return }
|
return
|
||||||
|
|
||||||
let outcome = await Self.fetchRemoteOutcome(
|
|
||||||
request: request,
|
|
||||||
awaitTorReady: awaitTorReady,
|
|
||||||
fetchData: fetchData
|
|
||||||
)
|
|
||||||
|
|
||||||
switch outcome {
|
|
||||||
case .success(let parsed, let csv):
|
|
||||||
self.handleFetchSuccess(entries: parsed, csv: csv)
|
|
||||||
case .torNotReady:
|
|
||||||
self.handleFetchFailure(.torNotReady)
|
|
||||||
case .invalidData:
|
|
||||||
self.handleFetchFailure(.invalidData)
|
|
||||||
case .network(let description):
|
|
||||||
self.handleFetchFailure(.network(description))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nonisolated private static func fetchRemoteOutcome(
|
|
||||||
request: URLRequest,
|
|
||||||
awaitTorReady: @escaping @Sendable () async -> Bool,
|
|
||||||
fetchData: @escaping @Sendable (URLRequest) async throws -> Data
|
|
||||||
) async -> DetachedFetchOutcome {
|
|
||||||
await Task.detached(priority: .utility) {
|
|
||||||
let ready = await awaitTorReady()
|
|
||||||
guard ready else { return .torNotReady }
|
|
||||||
|
|
||||||
do {
|
|
||||||
let data = try await fetchData(request)
|
|
||||||
guard let text = String(data: data, encoding: .utf8) else {
|
|
||||||
return .invalidData
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let parsed = Self.parseCSV(text)
|
|
||||||
guard !parsed.isEmpty else {
|
|
||||||
return .invalidData
|
|
||||||
}
|
|
||||||
|
|
||||||
return .success(entries: parsed, csv: text)
|
|
||||||
} catch {
|
|
||||||
return .network(error.localizedDescription)
|
|
||||||
}
|
}
|
||||||
}.value
|
SecureLogger.log("GeoRelayDirectory: remote fetch failed; keeping local entries", category: SecureLogger.session, level: .warning)
|
||||||
}
|
|
||||||
|
|
||||||
private enum FetchFailure {
|
|
||||||
case torNotReady
|
|
||||||
case invalidData
|
|
||||||
case network(String)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
|
|
||||||
entries = parsed
|
|
||||||
persistCache(csv)
|
|
||||||
dependencies.userDefaults.set(dependencies.now(), forKey: lastFetchKey)
|
|
||||||
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
|
|
||||||
isFetching = false
|
|
||||||
retryAttempt = 0
|
|
||||||
cancelRetry()
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func handleFetchFailure(_ reason: FetchFailure) {
|
|
||||||
switch reason {
|
|
||||||
case .torNotReady:
|
|
||||||
SecureLogger.warning("GeoRelayDirectory: Tor not ready; scheduling retry", category: .session)
|
|
||||||
case .invalidData:
|
|
||||||
SecureLogger.warning("GeoRelayDirectory: remote fetch returned invalid data; scheduling retry", category: .session)
|
|
||||||
case .network(let errorDescription):
|
|
||||||
SecureLogger.warning("GeoRelayDirectory: remote fetch failed with error: \(errorDescription)", category: .session)
|
|
||||||
}
|
}
|
||||||
isFetching = false
|
task.resume()
|
||||||
scheduleRetry()
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func scheduleRetry() {
|
|
||||||
retryAttempt = min(retryAttempt + 1, 10)
|
|
||||||
let base = dependencies.retryInitialSeconds
|
|
||||||
let maxDelay = dependencies.retryMaxSeconds
|
|
||||||
let multiplier = pow(2.0, Double(max(retryAttempt - 1, 0)))
|
|
||||||
let calculated = base * multiplier
|
|
||||||
let delay = min(maxDelay, max(base, calculated))
|
|
||||||
|
|
||||||
cancelRetry()
|
|
||||||
cleanupState.retryTask = Task { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
await self.dependencies.retrySleep(delay)
|
|
||||||
guard !Task.isCancelled else { return }
|
|
||||||
await MainActor.run {
|
|
||||||
self.prefetchIfNeeded(force: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func cancelRetry() {
|
|
||||||
cleanupState.retryTask?.cancel()
|
|
||||||
cleanupState.retryTask = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func persistCache(_ text: String) {
|
private func persistCache(_ text: String) {
|
||||||
guard let url = dependencies.cacheURL() else { return }
|
guard let url = cacheURL() else { return }
|
||||||
guard let data = text.data(using: .utf8) else { return }
|
|
||||||
do {
|
do {
|
||||||
try dependencies.writeData(data, url)
|
try text.data(using: .utf8)?.write(to: url, options: .atomic)
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.warning("GeoRelayDirectory: failed to write cache: \(error)", category: .session)
|
SecureLogger.log("GeoRelayDirectory: failed to write cache: \(error)", category: SecureLogger.session, level: .warning)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Loading
|
// MARK: - Loading
|
||||||
private func loadLocalEntries() -> [Entry] {
|
private func loadLocalEntries() -> [Entry] {
|
||||||
// Prefer cached file if present
|
// Prefer cached file if present
|
||||||
if let cache = dependencies.cacheURL(),
|
if let cache = self.cacheURL(),
|
||||||
let data = dependencies.readData(cache),
|
let data = try? Data(contentsOf: cache),
|
||||||
let text = String(data: data, encoding: .utf8) {
|
let text = String(data: data, encoding: .utf8) {
|
||||||
let arr = Self.parseCSV(text)
|
let arr = Self.parseCSV(text)
|
||||||
if !arr.isEmpty { return arr }
|
if !arr.isEmpty { return arr }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try bundled resource(s)
|
// Try bundled resource(s)
|
||||||
let bundleCandidates = dependencies.bundledCSVURLs()
|
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 {
|
for url in bundleCandidates {
|
||||||
if let data = dependencies.readData(url),
|
if let data = try? Data(contentsOf: url), let text = String(data: data, encoding: .utf8) {
|
||||||
let text = String(data: data, encoding: .utf8) {
|
|
||||||
let arr = Self.parseCSV(text)
|
let arr = Self.parseCSV(text)
|
||||||
if !arr.isEmpty { return arr }
|
if !arr.isEmpty { return arr }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try filesystem path (development/test)
|
// Try filesystem path (development/test)
|
||||||
if let cwd = dependencies.currentDirectoryPath(),
|
if let cwd = FileManager.default.currentDirectoryPath as String?,
|
||||||
let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
||||||
let text = String(data: data, encoding: .utf8) {
|
let text = String(data: data, encoding: .utf8) {
|
||||||
return Self.parseCSV(text)
|
return Self.parseCSV(text)
|
||||||
}
|
}
|
||||||
|
SecureLogger.log("GeoRelayDirectory: no local CSV found; entries empty", category: SecureLogger.session, level: .warning)
|
||||||
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
|
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated static func parseCSV(_ text: String) -> [Entry] {
|
nonisolated static func parseCSV(_ text: String) -> [Entry] {
|
||||||
var result: Set<Entry> = []
|
var result: Set<Entry> = []
|
||||||
let lines = text.split(whereSeparator: { $0.isNewline })
|
let lines = text.split(whereSeparator: { $0.isNewline })
|
||||||
|
// Skip header if present
|
||||||
for (idx, raw) in lines.enumerated() {
|
for (idx, raw) in lines.enumerated() {
|
||||||
guard let line = raw.trimmedOrNilIfEmpty else { continue }
|
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if line.isEmpty { continue }
|
||||||
if idx == 0 && line.lowercased().contains("relay url") { continue }
|
if idx == 0 && line.lowercased().contains("relay url") { continue }
|
||||||
let parts = line.split(separator: ",").map { $0.trimmed }
|
let parts = line.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) }
|
||||||
guard parts.count >= 3 else { continue }
|
guard parts.count >= 3 else { continue }
|
||||||
var host = parts[0]
|
var host = parts[0]
|
||||||
host = host.replacingOccurrences(of: "https://", with: "")
|
host = host.replacingOccurrences(of: "https://", with: "")
|
||||||
@@ -393,55 +131,14 @@ final class GeoRelayDirectory {
|
|||||||
return Array(result)
|
return Array(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Observers & Timers
|
private func cacheURL() -> URL? {
|
||||||
private func registerObservers() {
|
do {
|
||||||
let center = dependencies.notificationCenter
|
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||||
|
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
||||||
let torReady = center.addObserver(
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
forName: .TorDidBecomeReady,
|
return dir.appendingPathComponent(cacheFileName)
|
||||||
object: nil,
|
} catch { return nil }
|
||||||
queue: .main
|
|
||||||
) { [weak self] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
Task { @MainActor in
|
|
||||||
self.prefetchIfNeeded(force: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cleanupState.observers.append(torReady)
|
|
||||||
|
|
||||||
if let activeNotificationName = dependencies.activeNotificationName {
|
|
||||||
let didBecomeActive = center.addObserver(
|
|
||||||
forName: activeNotificationName,
|
|
||||||
object: nil,
|
|
||||||
queue: .main
|
|
||||||
) { [weak self] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
Task { @MainActor in
|
|
||||||
self.prefetchIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cleanupState.observers.append(didBecomeActive)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startRefreshTimer() {
|
|
||||||
cleanupState.refreshTimer?.invalidate()
|
|
||||||
let interval = dependencies.refreshCheckInterval
|
|
||||||
guard interval > 0 else { return }
|
|
||||||
|
|
||||||
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
Task { @MainActor in
|
|
||||||
self.prefetchIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cleanupState.refreshTimer = timer
|
|
||||||
RunLoop.main.add(timer, forMode: .common)
|
|
||||||
}
|
|
||||||
|
|
||||||
var debugRetryAttempt: Int { retryAttempt }
|
|
||||||
var debugHasRetryTask: Bool { cleanupState.retryTask != nil }
|
|
||||||
var debugObserverCount: Int { cleanupState.observers.count }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Distance
|
// MARK: - Distance
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import BitFoundation
|
|
||||||
|
|
||||||
// MARK: - BitChat-over-Nostr Adapter
|
// MARK: - BitChat-over-Nostr Adapter
|
||||||
|
|
||||||
struct NostrEmbeddedBitChat {
|
struct NostrEmbeddedBitChat {
|
||||||
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a private message for Nostr DMs.
|
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a private message for Nostr DMs.
|
||||||
static func encodePMForNostr(content: String, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> String? {
|
static func encodePMForNostr(content: String, messageID: String, recipientPeerID: String, senderPeerID: String) -> String? {
|
||||||
// TLV-encode the private message
|
// TLV-encode the private message
|
||||||
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
||||||
guard let tlv = pm.encode() else { return nil }
|
guard let tlv = pm.encode() else { return nil }
|
||||||
@@ -15,12 +14,12 @@ struct NostrEmbeddedBitChat {
|
|||||||
payload.append(tlv)
|
payload.append(tlv)
|
||||||
|
|
||||||
// Determine 8-byte recipient ID to embed
|
// Determine 8-byte recipient ID to embed
|
||||||
let recipientID = normalizeRecipientPeerID(recipientPeerID)
|
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
|
||||||
|
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||||
recipientID: Data(hexString: recipientID.id),
|
recipientID: Data(hexString: recipientIDHex),
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
signature: nil,
|
signature: nil,
|
||||||
@@ -32,18 +31,18 @@ struct NostrEmbeddedBitChat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a delivery/read ack for Nostr DMs.
|
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a delivery/read ack for Nostr DMs.
|
||||||
static func encodeAckForNostr(type: NoisePayloadType, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> String? {
|
static func encodeAckForNostr(type: NoisePayloadType, messageID: String, recipientPeerID: String, senderPeerID: String) -> String? {
|
||||||
guard type == .delivered || type == .readReceipt else { return nil }
|
guard type == .delivered || type == .readReceipt else { return nil }
|
||||||
|
|
||||||
var payload = Data([type.rawValue])
|
var payload = Data([type.rawValue])
|
||||||
payload.append(Data(messageID.utf8))
|
payload.append(Data(messageID.utf8))
|
||||||
|
|
||||||
let recipientID = normalizeRecipientPeerID(recipientPeerID)
|
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
|
||||||
|
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||||
recipientID: Data(hexString: recipientID.id),
|
recipientID: Data(hexString: recipientIDHex),
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
signature: nil,
|
signature: nil,
|
||||||
@@ -55,7 +54,7 @@ struct NostrEmbeddedBitChat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build a `bitchat1:` ACK (delivered/read) without an embedded recipient peer ID (geohash DMs).
|
/// Build a `bitchat1:` ACK (delivered/read) without an embedded recipient peer ID (geohash DMs).
|
||||||
static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: PeerID) -> String? {
|
static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: String) -> String? {
|
||||||
guard type == .delivered || type == .readReceipt else { return nil }
|
guard type == .delivered || type == .readReceipt else { return nil }
|
||||||
|
|
||||||
var payload = Data([type.rawValue])
|
var payload = Data([type.rawValue])
|
||||||
@@ -63,7 +62,7 @@ struct NostrEmbeddedBitChat {
|
|||||||
|
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||||
recipientID: nil,
|
recipientID: nil,
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
@@ -76,7 +75,7 @@ struct NostrEmbeddedBitChat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build a `bitchat1:` payload without an embedded recipient peer ID (used for geohash DMs).
|
/// Build a `bitchat1:` payload without an embedded recipient peer ID (used for geohash DMs).
|
||||||
static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: PeerID) -> String? {
|
static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: String) -> String? {
|
||||||
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
||||||
guard let tlv = pm.encode() else { return nil }
|
guard let tlv = pm.encode() else { return nil }
|
||||||
|
|
||||||
@@ -85,7 +84,7 @@ struct NostrEmbeddedBitChat {
|
|||||||
|
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||||
recipientID: nil,
|
recipientID: nil,
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
@@ -97,11 +96,11 @@ struct NostrEmbeddedBitChat {
|
|||||||
return "bitchat1:" + base64URLEncode(data)
|
return "bitchat1:" + base64URLEncode(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func normalizeRecipientPeerID(_ recipientPeerID: PeerID) -> PeerID {
|
private static func normalizeRecipientPeerID(_ recipientPeerID: String) -> String {
|
||||||
if let maybeData = Data(hexString: recipientPeerID.id) {
|
if let maybeData = Data(hexString: recipientPeerID) {
|
||||||
if maybeData.count == 32 {
|
if maybeData.count == 32 {
|
||||||
// Treat as Noise static public key; derive peerID from fingerprint
|
// Treat as Noise static public key; derive peerID from fingerprint
|
||||||
return PeerID(publicKey: maybeData)
|
return PeerIDUtils.derivePeerID(fromPublicKey: maybeData)
|
||||||
} else if maybeData.count == 8 {
|
} else if maybeData.count == 8 {
|
||||||
// Already an 8-byte peer ID
|
// Already an 8-byte peer ID
|
||||||
return recipientPeerID
|
return recipientPeerID
|
||||||
|
|||||||
@@ -1,5 +1,50 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
import P256K
|
import P256K
|
||||||
|
import Security
|
||||||
|
|
||||||
|
// Keychain helper for secure storage
|
||||||
|
struct KeychainHelper {
|
||||||
|
static func save(key: String, data: Data, service: String, accessible: CFString? = nil) {
|
||||||
|
var query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
kSecValueData as String: data
|
||||||
|
]
|
||||||
|
if let accessible = accessible {
|
||||||
|
query[kSecAttrAccessible as String] = accessible
|
||||||
|
}
|
||||||
|
|
||||||
|
SecItemDelete(query as CFDictionary)
|
||||||
|
SecItemAdd(query as CFDictionary, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func load(key: String, service: String) -> Data? {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
kSecReturnData as String: true
|
||||||
|
]
|
||||||
|
|
||||||
|
var result: AnyObject?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
|
|
||||||
|
guard status == errSecSuccess else { return nil }
|
||||||
|
return result as? Data
|
||||||
|
}
|
||||||
|
|
||||||
|
static func delete(key: String, service: String) {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key
|
||||||
|
]
|
||||||
|
|
||||||
|
SecItemDelete(query as CFDictionary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
|
/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
|
||||||
struct NostrIdentity: Codable {
|
struct NostrIdentity: Codable {
|
||||||
@@ -58,3 +103,251 @@ struct NostrIdentity: Codable {
|
|||||||
return publicKey.hexEncodedString()
|
return publicKey.hexEncodedString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bridge between Noise and Nostr identities
|
||||||
|
struct NostrIdentityBridge {
|
||||||
|
private static let keychainService = "chat.bitchat.nostr"
|
||||||
|
private static let currentIdentityKey = "nostr-current-identity"
|
||||||
|
private static let deviceSeedKey = "nostr-device-seed"
|
||||||
|
// In-memory cache to avoid transient keychain access issues
|
||||||
|
private static var deviceSeedCache: Data?
|
||||||
|
|
||||||
|
/// Get or create the current Nostr identity
|
||||||
|
static func getCurrentNostrIdentity() throws -> NostrIdentity? {
|
||||||
|
// Check if we already have a Nostr identity
|
||||||
|
if let existingData = KeychainHelper.load(key: currentIdentityKey, service: keychainService),
|
||||||
|
let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) {
|
||||||
|
return identity
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new Nostr identity
|
||||||
|
let nostrIdentity = try NostrIdentity.generate()
|
||||||
|
|
||||||
|
// Store it
|
||||||
|
let data = try JSONEncoder().encode(nostrIdentity)
|
||||||
|
KeychainHelper.save(key: currentIdentityKey, data: data, service: keychainService)
|
||||||
|
|
||||||
|
return nostrIdentity
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Associate a Nostr identity with a Noise public key (for favorites)
|
||||||
|
static func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
|
||||||
|
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||||
|
if let data = nostrPubkey.data(using: .utf8) {
|
||||||
|
KeychainHelper.save(key: key, data: data, service: keychainService)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get Nostr public key associated with a Noise public key
|
||||||
|
static func getNostrPublicKey(for noisePublicKey: Data) -> String? {
|
||||||
|
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||||
|
guard let data = KeychainHelper.load(key: key, service: keychainService),
|
||||||
|
let pubkey = String(data: data, encoding: .utf8) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return pubkey
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all Nostr identity associations and current identity
|
||||||
|
static func clearAllAssociations() {
|
||||||
|
// Delete current Nostr identity
|
||||||
|
KeychainHelper.delete(key: currentIdentityKey, service: keychainService)
|
||||||
|
KeychainHelper.delete(key: deviceSeedKey, service: keychainService)
|
||||||
|
|
||||||
|
// Note: We can't efficiently delete all noise-nostr associations
|
||||||
|
// without tracking them, but they'll be orphaned and eventually cleaned up
|
||||||
|
// The important part is deleting the current identity so a new one is generated
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Per-Geohash Identities (Location Channels)
|
||||||
|
|
||||||
|
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
|
||||||
|
/// Stored only on device keychain.
|
||||||
|
private static func getOrCreateDeviceSeed() -> Data {
|
||||||
|
if let cached = deviceSeedCache { return cached }
|
||||||
|
if let existing = KeychainHelper.load(key: deviceSeedKey, service: keychainService) {
|
||||||
|
// Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock
|
||||||
|
KeychainHelper.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||||
|
deviceSeedCache = existing
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
var seed = Data(count: 32)
|
||||||
|
_ = seed.withUnsafeMutableBytes { ptr in
|
||||||
|
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
||||||
|
}
|
||||||
|
// Ensure availability after first unlock to prevent unintended rotation when locked
|
||||||
|
KeychainHelper.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||||
|
deviceSeedCache = seed
|
||||||
|
return seed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
|
||||||
|
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
||||||
|
/// if the candidate is not a valid secp256k1 private key.
|
||||||
|
static func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
||||||
|
let seed = getOrCreateDeviceSeed()
|
||||||
|
guard let msg = geohash.data(using: .utf8) else {
|
||||||
|
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func candidateKey(iteration: UInt32) -> Data {
|
||||||
|
var input = Data(msg)
|
||||||
|
var iterBE = iteration.bigEndian
|
||||||
|
withUnsafeBytes(of: &iterBE) { bytes in
|
||||||
|
input.append(contentsOf: bytes)
|
||||||
|
}
|
||||||
|
let code = CryptoKit.HMAC<CryptoKit.SHA256>.authenticationCode(for: input, using: SymmetricKey(data: seed))
|
||||||
|
return Data(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try a few iterations to ensure a valid key can be formed
|
||||||
|
for i in 0..<10 {
|
||||||
|
let keyData = candidateKey(iteration: UInt32(i))
|
||||||
|
if let identity = try? NostrIdentity(privateKeyData: keyData) {
|
||||||
|
return identity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// As a final fallback, hash the seed+msg and try again
|
||||||
|
var combined = Data()
|
||||||
|
combined.append(seed)
|
||||||
|
combined.append(msg)
|
||||||
|
let fallback = Data(CryptoKit.SHA256.hash(data: combined))
|
||||||
|
return try NostrIdentity(privateKeyData: fallback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bech32 encoding for Nostr (minimal implementation)
|
||||||
|
enum Bech32 {
|
||||||
|
private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||||
|
private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
|
||||||
|
|
||||||
|
static func encode(hrp: String, data: Data) throws -> String {
|
||||||
|
let values = convertBits(from: 8, to: 5, pad: true, data: Array(data))
|
||||||
|
let checksum = createChecksum(hrp: hrp, values: values)
|
||||||
|
let combined = values + checksum
|
||||||
|
|
||||||
|
return hrp + "1" + combined.map {
|
||||||
|
let index = charset.index(charset.startIndex, offsetBy: Int($0))
|
||||||
|
return String(charset[index])
|
||||||
|
}.joined()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {
|
||||||
|
// Find the last occurrence of '1'
|
||||||
|
guard let separatorIndex = bech32String.lastIndex(of: "1") else {
|
||||||
|
throw Bech32Error.invalidFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
let hrp = String(bech32String[..<separatorIndex])
|
||||||
|
|
||||||
|
// Validate HRP contains only ASCII characters
|
||||||
|
for char in hrp {
|
||||||
|
guard char.asciiValue != nil else {
|
||||||
|
throw Bech32Error.invalidCharacter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])
|
||||||
|
|
||||||
|
// Convert characters to values
|
||||||
|
var values = [UInt8]()
|
||||||
|
for char in dataString {
|
||||||
|
guard let index = charset.firstIndex(of: char) else {
|
||||||
|
throw Bech32Error.invalidCharacter
|
||||||
|
}
|
||||||
|
values.append(UInt8(charset.distance(from: charset.startIndex, to: index)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify checksum
|
||||||
|
guard values.count >= 6 else {
|
||||||
|
throw Bech32Error.invalidChecksum
|
||||||
|
}
|
||||||
|
|
||||||
|
let payloadValues = Array(values.dropLast(6))
|
||||||
|
let checksum = Array(values.suffix(6))
|
||||||
|
let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues)
|
||||||
|
|
||||||
|
guard checksum == expectedChecksum else {
|
||||||
|
throw Bech32Error.invalidChecksum
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert back to bytes
|
||||||
|
let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues)
|
||||||
|
return (hrp: hrp, data: Data(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Bech32Error: Error {
|
||||||
|
case invalidFormat
|
||||||
|
case invalidCharacter
|
||||||
|
case invalidChecksum
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] {
|
||||||
|
var acc = 0
|
||||||
|
var bits = 0
|
||||||
|
var result = [UInt8]()
|
||||||
|
let maxv = (1 << to) - 1
|
||||||
|
|
||||||
|
for value in data {
|
||||||
|
acc = (acc << from) | Int(value)
|
||||||
|
bits += from
|
||||||
|
|
||||||
|
while bits >= to {
|
||||||
|
bits -= to
|
||||||
|
result.append(UInt8((acc >> bits) & maxv))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if pad && bits > 0 {
|
||||||
|
result.append(UInt8((acc << (to - bits)) & maxv))
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] {
|
||||||
|
let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0]
|
||||||
|
let polymod = polymod(checksumValues) ^ 1
|
||||||
|
var checksum = [UInt8]()
|
||||||
|
|
||||||
|
for i in 0..<6 {
|
||||||
|
checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31))
|
||||||
|
}
|
||||||
|
|
||||||
|
return checksum
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func hrpExpand(_ hrp: String) -> [UInt8] {
|
||||||
|
var result = [UInt8]()
|
||||||
|
for c in hrp {
|
||||||
|
guard let asciiValue = c.asciiValue else {
|
||||||
|
return [] // Return empty array for invalid input
|
||||||
|
}
|
||||||
|
result.append(UInt8(asciiValue >> 5))
|
||||||
|
}
|
||||||
|
result.append(0)
|
||||||
|
for c in hrp {
|
||||||
|
guard let asciiValue = c.asciiValue else {
|
||||||
|
return [] // Return empty array for invalid input
|
||||||
|
}
|
||||||
|
result.append(UInt8(asciiValue & 31))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func polymod(_ values: [UInt8]) -> Int {
|
||||||
|
var chk = 1
|
||||||
|
for value in values {
|
||||||
|
let b = chk >> 25
|
||||||
|
chk = (chk & 0x1ffffff) << 5 ^ Int(value)
|
||||||
|
for i in 0..<5 {
|
||||||
|
if (b >> i) & 1 == 1 {
|
||||||
|
chk ^= generator[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chk
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data hex encoding extension moved to BinaryEncodingUtils.swift to avoid duplication
|
||||||
|
|||||||
@@ -1,158 +0,0 @@
|
|||||||
import BitFoundation
|
|
||||||
import Foundation
|
|
||||||
import CryptoKit
|
|
||||||
|
|
||||||
/// Bridge between Noise and Nostr identities
|
|
||||||
final class NostrIdentityBridge {
|
|
||||||
private let keychainService = "chat.bitchat.nostr"
|
|
||||||
private let currentIdentityKey = "nostr-current-identity"
|
|
||||||
private let deviceSeedKey = "nostr-device-seed"
|
|
||||||
// In-memory cache to avoid transient keychain access issues
|
|
||||||
private var deviceSeedCache: Data?
|
|
||||||
// Cache derived identities to avoid repeated crypto during view rendering
|
|
||||||
private var derivedIdentityCache: [String: NostrIdentity] = [:]
|
|
||||||
private let cacheLock = NSLock()
|
|
||||||
|
|
||||||
private let keychain: KeychainManagerProtocol
|
|
||||||
|
|
||||||
init(keychain: KeychainManagerProtocol = KeychainManager()) {
|
|
||||||
self.keychain = keychain
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get or create the current Nostr identity
|
|
||||||
func getCurrentNostrIdentity() throws -> NostrIdentity? {
|
|
||||||
// Check if we already have a Nostr identity
|
|
||||||
if let existingData = keychain.load(key: currentIdentityKey, service: keychainService),
|
|
||||||
let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) {
|
|
||||||
return identity
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate new Nostr identity
|
|
||||||
let nostrIdentity = try NostrIdentity.generate()
|
|
||||||
|
|
||||||
// Store it
|
|
||||||
let data = try JSONEncoder().encode(nostrIdentity)
|
|
||||||
keychain.save(key: currentIdentityKey, data: data, service: keychainService, accessible: nil)
|
|
||||||
|
|
||||||
return nostrIdentity
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Associate a Nostr identity with a Noise public key (for favorites)
|
|
||||||
func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
|
|
||||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
|
||||||
if let data = nostrPubkey.data(using: .utf8) {
|
|
||||||
keychain.save(key: key, data: data, service: keychainService, accessible: nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get Nostr public key associated with a Noise public key
|
|
||||||
func getNostrPublicKey(for noisePublicKey: Data) -> String? {
|
|
||||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
|
||||||
guard let data = keychain.load(key: key, service: keychainService),
|
|
||||||
let pubkey = String(data: data, encoding: .utf8) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return pubkey
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear all Nostr identity associations and current identity
|
|
||||||
func clearAllAssociations() {
|
|
||||||
let query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: keychainService,
|
|
||||||
kSecMatchLimit as String: kSecMatchLimitAll,
|
|
||||||
kSecReturnAttributes as String: true
|
|
||||||
]
|
|
||||||
|
|
||||||
var result: AnyObject?
|
|
||||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
||||||
if status == errSecSuccess, let items = result as? [[String: Any]] {
|
|
||||||
for item in items {
|
|
||||||
var deleteQuery: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: keychainService
|
|
||||||
]
|
|
||||||
if let account = item[kSecAttrAccount as String] as? String {
|
|
||||||
deleteQuery[kSecAttrAccount as String] = account
|
|
||||||
}
|
|
||||||
SecItemDelete(deleteQuery as CFDictionary)
|
|
||||||
}
|
|
||||||
} else if status == errSecItemNotFound {
|
|
||||||
// nothing persisted; no action needed
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceSeedCache = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Per-Geohash Identities (Location Channels)
|
|
||||||
|
|
||||||
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
|
|
||||||
/// Stored only on device keychain.
|
|
||||||
private func getOrCreateDeviceSeed() -> Data {
|
|
||||||
if let cached = deviceSeedCache { return cached }
|
|
||||||
if let existing = keychain.load(key: deviceSeedKey, service: keychainService) {
|
|
||||||
// Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock
|
|
||||||
keychain.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
|
||||||
deviceSeedCache = existing
|
|
||||||
return existing
|
|
||||||
}
|
|
||||||
var seed = Data(count: 32)
|
|
||||||
_ = seed.withUnsafeMutableBytes { ptr in
|
|
||||||
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
|
||||||
}
|
|
||||||
// Ensure availability after first unlock to prevent unintended rotation when locked
|
|
||||||
keychain.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
|
||||||
deviceSeedCache = seed
|
|
||||||
return seed
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
|
|
||||||
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
|
||||||
/// if the candidate is not a valid secp256k1 private key.
|
|
||||||
func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
|
||||||
// Check cache first to avoid repeated crypto + keychain I/O during view rendering
|
|
||||||
cacheLock.lock()
|
|
||||||
if let cached = derivedIdentityCache[geohash] {
|
|
||||||
cacheLock.unlock()
|
|
||||||
return cached
|
|
||||||
}
|
|
||||||
cacheLock.unlock()
|
|
||||||
|
|
||||||
let seed = getOrCreateDeviceSeed()
|
|
||||||
guard let msg = geohash.data(using: .utf8) else {
|
|
||||||
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func candidateKey(iteration: UInt32) -> Data {
|
|
||||||
var input = Data(msg)
|
|
||||||
var iterBE = iteration.bigEndian
|
|
||||||
withUnsafeBytes(of: &iterBE) { bytes in
|
|
||||||
input.append(contentsOf: bytes)
|
|
||||||
}
|
|
||||||
let code = HMAC<SHA256>.authenticationCode(for: input, using: SymmetricKey(data: seed))
|
|
||||||
return Data(code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try a few iterations to ensure a valid key can be formed
|
|
||||||
for i in 0..<10 {
|
|
||||||
let keyData = candidateKey(iteration: UInt32(i))
|
|
||||||
if let identity = try? NostrIdentity(privateKeyData: keyData) {
|
|
||||||
// Cache the result
|
|
||||||
cacheLock.lock()
|
|
||||||
derivedIdentityCache[geohash] = identity
|
|
||||||
cacheLock.unlock()
|
|
||||||
return identity
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// As a final fallback, hash the seed+msg and try again
|
|
||||||
let fallback = (seed + msg).sha256Hash()
|
|
||||||
let identity = try NostrIdentity(privateKeyData: fallback)
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
cacheLock.lock()
|
|
||||||
derivedIdentityCache[geohash] = identity
|
|
||||||
cacheLock.unlock()
|
|
||||||
|
|
||||||
return identity
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import BitLogger
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import P256K
|
import P256K
|
||||||
@@ -18,7 +17,6 @@ struct NostrProtocol {
|
|||||||
case seal = 13 // NIP-17 sealed event
|
case seal = 13 // NIP-17 sealed event
|
||||||
case giftWrap = 1059 // NIP-59 gift wrap
|
case giftWrap = 1059 // NIP-59 gift wrap
|
||||||
case ephemeralEvent = 20000
|
case ephemeralEvent = 20000
|
||||||
case geohashPresence = 20001
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a NIP-17 private message
|
/// Create a NIP-17 private message
|
||||||
@@ -80,7 +78,8 @@ struct NostrProtocol {
|
|||||||
)
|
)
|
||||||
// Successfully unwrapped gift wrap
|
// Successfully unwrapped gift wrap
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("❌ Failed to unwrap gift wrap: \(error)", category: .session)
|
SecureLogger.log("❌ Failed to unwrap gift wrap: \(error)",
|
||||||
|
category: SecureLogger.session, level: .error)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +92,8 @@ struct NostrProtocol {
|
|||||||
)
|
)
|
||||||
// Successfully opened seal
|
// Successfully opened seal
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("❌ Failed to open seal: \(error)", category: .session)
|
SecureLogger.log("❌ Failed to open seal: \(error)",
|
||||||
|
category: SecureLogger.session, level: .error)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ struct NostrProtocol {
|
|||||||
teleported: Bool = false
|
teleported: Bool = false
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
var tags = [["g", geohash]]
|
var tags = [["g", geohash]]
|
||||||
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
if let nickname = nickname, !nickname.isEmpty {
|
||||||
tags.append(["n", nickname])
|
tags.append(["n", nickname])
|
||||||
}
|
}
|
||||||
if teleported {
|
if teleported {
|
||||||
@@ -125,46 +125,6 @@ struct NostrProtocol {
|
|||||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||||
return try event.sign(with: schnorrKey)
|
return try event.sign(with: schnorrKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a geohash presence heartbeat (kind 20001)
|
|
||||||
/// Must contain empty content and NO nickname tag
|
|
||||||
static func createGeohashPresenceEvent(
|
|
||||||
geohash: String,
|
|
||||||
senderIdentity: NostrIdentity
|
|
||||||
) throws -> NostrEvent {
|
|
||||||
let tags = [["g", geohash]]
|
|
||||||
let event = NostrEvent(
|
|
||||||
pubkey: senderIdentity.publicKeyHex,
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .geohashPresence,
|
|
||||||
tags: tags,
|
|
||||||
content: ""
|
|
||||||
)
|
|
||||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
|
||||||
return try event.sign(with: schnorrKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
|
|
||||||
static func createGeohashTextNote(
|
|
||||||
content: String,
|
|
||||||
geohash: String,
|
|
||||||
senderIdentity: NostrIdentity,
|
|
||||||
nickname: String? = nil
|
|
||||||
) throws -> NostrEvent {
|
|
||||||
var tags = [["g", geohash]]
|
|
||||||
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
|
||||||
tags.append(["n", nickname])
|
|
||||||
}
|
|
||||||
let event = NostrEvent(
|
|
||||||
pubkey: senderIdentity.publicKeyHex,
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .textNote,
|
|
||||||
tags: tags,
|
|
||||||
content: content
|
|
||||||
)
|
|
||||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
|
||||||
return try event.sign(with: schnorrKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
|
|
||||||
@@ -528,26 +488,6 @@ struct NostrEvent: Codable {
|
|||||||
signed.sig = signatureHex
|
signed.sig = signatureHex
|
||||||
return signed
|
return signed
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate that the event ID and Schnorr signature match the content and pubkey.
|
|
||||||
/// Returns false when the signature is missing, malformed, or does not verify.
|
|
||||||
func isValidSignature() -> Bool {
|
|
||||||
guard let sig = sig,
|
|
||||||
let sigData = Data(hexString: sig),
|
|
||||||
let pubData = Data(hexString: pubkey),
|
|
||||||
sigData.count == 64,
|
|
||||||
pubData.count == 32,
|
|
||||||
let signature = try? P256K.Schnorr.SchnorrSignature(dataRepresentation: sigData),
|
|
||||||
let (expectedId, eventHash) = try? calculateEventId(),
|
|
||||||
expectedId == id
|
|
||||||
else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
var messageBytes = [UInt8](eventHash)
|
|
||||||
let xonly = P256K.Schnorr.XonlyKey(dataRepresentation: pubData)
|
|
||||||
return xonly.isValid(signature, for: &messageBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func calculateEventId() throws -> (String, Data) {
|
private func calculateEventId() throws -> (String, Data) {
|
||||||
let serialized = [
|
let serialized = [
|
||||||
@@ -560,7 +500,10 @@ 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])
|
||||||
return (data.sha256Fingerprint(), data.sha256Hash())
|
let hash = CryptoKit.SHA256.hash(data: data)
|
||||||
|
let hashData = Data(hash)
|
||||||
|
let hashHex = hash.compactMap { String(format: "%02x", $0) }.joined()
|
||||||
|
return (hashHex, hashData)
|
||||||
}
|
}
|
||||||
|
|
||||||
func jsonString() throws -> String {
|
func jsonString() throws -> String {
|
||||||
|
|||||||
@@ -5,27 +5,16 @@ import CryptoKit
|
|||||||
/// Implements HChaCha20 to derive a subkey and reduces the 24-byte nonce to a 12-byte nonce
|
/// Implements HChaCha20 to derive a subkey and reduces the 24-byte nonce to a 12-byte nonce
|
||||||
/// as per XChaCha20 construction.
|
/// as per XChaCha20 construction.
|
||||||
enum XChaCha20Poly1305Compat {
|
enum XChaCha20Poly1305Compat {
|
||||||
|
|
||||||
/// Errors that can occur during XChaCha20-Poly1305 operations
|
|
||||||
enum Error: Swift.Error {
|
|
||||||
case invalidKeyLength(expected: Int, got: Int)
|
|
||||||
case invalidNonceLength(expected: Int, got: Int)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SealBox {
|
struct SealBox {
|
||||||
let ciphertext: Data
|
let ciphertext: Data
|
||||||
let tag: Data
|
let tag: Data
|
||||||
}
|
}
|
||||||
|
|
||||||
static func seal(plaintext: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> SealBox {
|
static func seal(plaintext: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> SealBox {
|
||||||
guard key.count == 32 else {
|
precondition(key.count == 32, "XChaCha20 key must be 32 bytes")
|
||||||
throw Error.invalidKeyLength(expected: 32, got: key.count)
|
precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes")
|
||||||
}
|
|
||||||
guard nonce24.count == 24 else {
|
|
||||||
throw Error.invalidNonceLength(expected: 24, got: nonce24.count)
|
|
||||||
}
|
|
||||||
|
|
||||||
let subkey = try hchacha20(key: key, nonce16: Data(nonce24.prefix(16)))
|
let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16))
|
||||||
let nonce12 = derive12ByteNonce(from24: nonce24)
|
let nonce12 = derive12ByteNonce(from24: nonce24)
|
||||||
let chachaKey = SymmetricKey(data: subkey)
|
let chachaKey = SymmetricKey(data: subkey)
|
||||||
let nonce = try ChaChaPoly.Nonce(data: nonce12)
|
let nonce = try ChaChaPoly.Nonce(data: nonce12)
|
||||||
@@ -34,14 +23,10 @@ enum XChaCha20Poly1305Compat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func open(ciphertext: Data, tag: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> Data {
|
static func open(ciphertext: Data, tag: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> Data {
|
||||||
guard key.count == 32 else {
|
precondition(key.count == 32, "XChaCha20 key must be 32 bytes")
|
||||||
throw Error.invalidKeyLength(expected: 32, got: key.count)
|
precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes")
|
||||||
}
|
|
||||||
guard nonce24.count == 24 else {
|
|
||||||
throw Error.invalidNonceLength(expected: 24, got: nonce24.count)
|
|
||||||
}
|
|
||||||
|
|
||||||
let subkey = try hchacha20(key: key, nonce16: Data(nonce24.prefix(16)))
|
let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16))
|
||||||
let nonce12 = derive12ByteNonce(from24: nonce24)
|
let nonce12 = derive12ByteNonce(from24: nonce24)
|
||||||
let chachaKey = SymmetricKey(data: subkey)
|
let chachaKey = SymmetricKey(data: subkey)
|
||||||
let box = try ChaChaPoly.SealedBox(nonce: ChaChaPoly.Nonce(data: nonce12), ciphertext: ciphertext, tag: tag)
|
let box = try ChaChaPoly.SealedBox(nonce: ChaChaPoly.Nonce(data: nonce12), ciphertext: ciphertext, tag: tag)
|
||||||
@@ -58,14 +43,10 @@ enum XChaCha20Poly1305Compat {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func hchacha20(key: Data, nonce16: Data) throws -> Data {
|
private static func hchacha20(key: Data, nonce16: Data) -> Data {
|
||||||
// HChaCha20 based on the original ChaCha20 core with a 16-byte nonce.
|
// HChaCha20 based on the original ChaCha20 core with a 16-byte nonce.
|
||||||
guard key.count == 32 else {
|
precondition(key.count == 32)
|
||||||
throw Error.invalidKeyLength(expected: 32, got: key.count)
|
precondition(nonce16.count == 16)
|
||||||
}
|
|
||||||
guard nonce16.count == 16 else {
|
|
||||||
throw Error.invalidNonceLength(expected: 16, got: nonce16.count)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Constants "expand 32-byte k"
|
// Constants "expand 32-byte k"
|
||||||
var state: [UInt32] = [
|
var state: [UInt32] = [
|
||||||
|
|||||||
@@ -5,37 +5,75 @@
|
|||||||
// Binary encoding utilities for efficient protocol messages
|
// Binary encoding utilities for efficient protocol messages
|
||||||
//
|
//
|
||||||
|
|
||||||
import struct Foundation.Data
|
import Foundation
|
||||||
import struct Foundation.Date
|
import CryptoKit
|
||||||
|
|
||||||
|
// MARK: - Hex Encoding/Decoding
|
||||||
|
|
||||||
|
extension Data {
|
||||||
|
func hexEncodedString() -> String {
|
||||||
|
if self.isEmpty {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return self.map { String(format: "%02x", $0) }.joined()
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(hexString: String) {
|
||||||
|
let len = hexString.count / 2
|
||||||
|
var data = Data(capacity: len)
|
||||||
|
var index = hexString.startIndex
|
||||||
|
|
||||||
|
for _ in 0..<len {
|
||||||
|
let nextIndex = hexString.index(index, offsetBy: 2)
|
||||||
|
guard let byte = UInt8(String(hexString[index..<nextIndex]), radix: 16) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data.append(byte)
|
||||||
|
index = nextIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
self = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Fingerprint Helpers
|
||||||
|
|
||||||
|
extension Data {
|
||||||
|
/// SHA-256 over data, hex-encoded (lowercase)
|
||||||
|
func sha256Fingerprint() -> String {
|
||||||
|
let hash = SHA256.hash(data: self)
|
||||||
|
return hash.map { String(format: "%02x", $0) }.joined()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Binary Encoding Utilities
|
// MARK: - Binary Encoding Utilities
|
||||||
|
|
||||||
extension Data {
|
extension Data {
|
||||||
// MARK: Writing
|
// MARK: Writing
|
||||||
|
|
||||||
@inlinable public mutating func appendUInt8(_ value: UInt8) {
|
mutating func appendUInt8(_ value: UInt8) {
|
||||||
self.append(value)
|
self.append(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@inlinable mutating func appendUInt16(_ value: UInt16) {
|
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
@inlinable mutating func appendUInt32(_ value: UInt32) {
|
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
@inlinable mutating func appendUInt64(_ value: UInt64) {
|
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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public mutating func appendString(_ string: String, maxLength: Int = 255) {
|
mutating func appendString(_ string: String, maxLength: Int = 255) {
|
||||||
guard let data = string.data(using: .utf8) else { return }
|
guard let data = string.data(using: .utf8) else { return }
|
||||||
let length = Swift.min(data.count, maxLength)
|
let length = Swift.min(data.count, maxLength)
|
||||||
|
|
||||||
@@ -48,7 +86,7 @@ extension Data {
|
|||||||
self.append(data.prefix(length))
|
self.append(data.prefix(length))
|
||||||
}
|
}
|
||||||
|
|
||||||
public mutating func appendData(_ data: Data, maxLength: Int = 65535) {
|
mutating func appendData(_ data: Data, maxLength: Int = 65535) {
|
||||||
let length = Swift.min(data.count, maxLength)
|
let length = Swift.min(data.count, maxLength)
|
||||||
|
|
||||||
if maxLength <= 255 {
|
if maxLength <= 255 {
|
||||||
@@ -60,12 +98,12 @@ extension Data {
|
|||||||
self.append(data.prefix(length))
|
self.append(data.prefix(length))
|
||||||
}
|
}
|
||||||
|
|
||||||
public mutating func appendDate(_ date: Date) {
|
mutating func appendDate(_ date: Date) {
|
||||||
let timestamp = UInt64(date.timeIntervalSince1970 * 1000) // milliseconds
|
let timestamp = UInt64(date.timeIntervalSince1970 * 1000) // milliseconds
|
||||||
self.appendUInt64(timestamp)
|
self.appendUInt64(timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
public mutating func appendUUID(_ uuid: String) {
|
mutating func appendUUID(_ uuid: String) {
|
||||||
// Convert UUID string to 16 bytes
|
// Convert UUID string to 16 bytes
|
||||||
var uuidData = Data(count: 16)
|
var uuidData = Data(count: 16)
|
||||||
|
|
||||||
@@ -86,21 +124,21 @@ extension Data {
|
|||||||
|
|
||||||
// MARK: Reading
|
// MARK: Reading
|
||||||
|
|
||||||
@inlinable public func readUInt8(at offset: inout Int) -> UInt8? {
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@inlinable func readUInt16(at offset: inout Int) -> UInt16? {
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@inlinable func readUInt32(at offset: inout Int) -> UInt32? {
|
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 |
|
||||||
@@ -110,7 +148,7 @@ extension Data {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
@inlinable func readUInt64(at offset: inout Int) -> UInt64? {
|
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 {
|
||||||
@@ -120,7 +158,7 @@ extension Data {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
public func readString(at offset: inout Int, maxLength: Int = 255) -> String? {
|
func readString(at offset: inout Int, maxLength: Int = 255) -> String? {
|
||||||
let length: Int
|
let length: Int
|
||||||
|
|
||||||
if maxLength <= 255 {
|
if maxLength <= 255 {
|
||||||
@@ -139,7 +177,7 @@ extension Data {
|
|||||||
return String(data: stringData, encoding: .utf8)
|
return String(data: stringData, encoding: .utf8)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func readData(at offset: inout Int, maxLength: Int = 65535) -> Data? {
|
func readData(at offset: inout Int, maxLength: Int = 65535) -> Data? {
|
||||||
let length: Int
|
let length: Int
|
||||||
|
|
||||||
if maxLength <= 255 {
|
if maxLength <= 255 {
|
||||||
@@ -158,19 +196,19 @@ extension Data {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
public func readDate(at offset: inout Int) -> Date? {
|
func readDate(at offset: inout Int) -> Date? {
|
||||||
guard let timestamp = readUInt64(at: &offset) else { return nil }
|
guard let timestamp = readUInt64(at: &offset) else { return nil }
|
||||||
return Date(timeIntervalSince1970: Double(timestamp) / 1000.0)
|
return Date(timeIntervalSince1970: Double(timestamp) / 1000.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func readUUID(at offset: inout Int) -> String? {
|
func readUUID(at offset: inout Int) -> String? {
|
||||||
guard offset + 16 <= self.count else { return nil }
|
guard offset + 16 <= self.count else { return nil }
|
||||||
|
|
||||||
let uuidData = self[offset..<offset + 16]
|
let uuidData = self[offset..<offset + 16]
|
||||||
offset += 16
|
offset += 16
|
||||||
|
|
||||||
// Convert 16 bytes to UUID string format
|
// Convert 16 bytes to UUID string format
|
||||||
let uuid = uuidData.hexEncodedString()
|
let uuid = uuidData.map { String(format: "%02x", $0) }.joined()
|
||||||
|
|
||||||
// Insert hyphens at proper positions: 8-4-4-4-12
|
// Insert hyphens at proper positions: 8-4-4-4-12
|
||||||
var result = ""
|
var result = ""
|
||||||
@@ -184,7 +222,7 @@ extension Data {
|
|||||||
return result.uppercased()
|
return result.uppercased()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func readFixedBytes(at offset: inout Int, count: Int) -> Data? {
|
func readFixedBytes(at offset: inout Int, count: Int) -> Data? {
|
||||||
guard offset + count <= self.count else { return nil }
|
guard offset + count <= self.count else { return nil }
|
||||||
|
|
||||||
let data = self[offset..<offset + count]
|
let data = self[offset..<offset + count]
|
||||||
@@ -0,0 +1,588 @@
|
|||||||
|
//
|
||||||
|
// BinaryProtocol.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
///
|
||||||
|
/// # BinaryProtocol
|
||||||
|
///
|
||||||
|
/// Low-level binary encoding and decoding for BitChat protocol messages.
|
||||||
|
/// Optimized for Bluetooth LE's limited bandwidth and MTU constraints.
|
||||||
|
///
|
||||||
|
/// ## Overview
|
||||||
|
/// BinaryProtocol implements an efficient binary wire format that minimizes
|
||||||
|
/// overhead while maintaining extensibility. It handles:
|
||||||
|
/// - Compact binary encoding with fixed headers
|
||||||
|
/// - Optional field support via flags
|
||||||
|
/// - Automatic compression for large payloads
|
||||||
|
/// - Endianness handling for cross-platform compatibility
|
||||||
|
///
|
||||||
|
/// ## Wire Format
|
||||||
|
/// ```
|
||||||
|
/// Header (Fixed 13 bytes):
|
||||||
|
/// +--------+------+-----+-----------+-------+----------------+
|
||||||
|
/// |Version | Type | TTL | Timestamp | Flags | PayloadLength |
|
||||||
|
/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 bytes |
|
||||||
|
/// +--------+------+-----+-----------+-------+----------------+
|
||||||
|
///
|
||||||
|
/// Variable sections:
|
||||||
|
/// +----------+-------------+---------+------------+
|
||||||
|
/// | SenderID | RecipientID | Payload | Signature |
|
||||||
|
/// | 8 bytes | 8 bytes* | Variable| 64 bytes* |
|
||||||
|
/// +----------+-------------+---------+------------+
|
||||||
|
/// * Optional fields based on flags
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// ## Design Rationale
|
||||||
|
/// The protocol is designed for:
|
||||||
|
/// - **Efficiency**: Minimal overhead for small messages
|
||||||
|
/// - **Flexibility**: Optional fields via flag bits
|
||||||
|
/// - **Compatibility**: Network byte order (big-endian)
|
||||||
|
/// - **Performance**: Zero-copy where possible
|
||||||
|
///
|
||||||
|
/// ## Compression Strategy
|
||||||
|
/// - Automatic compression for payloads > 256 bytes
|
||||||
|
/// - zlib compression for broad compatibility on Apple platforms
|
||||||
|
/// - Original size stored for decompression
|
||||||
|
/// - Flag bit indicates compressed payload
|
||||||
|
///
|
||||||
|
/// ## Flag Bits
|
||||||
|
/// - Bit 0: Has recipient ID (directed message)
|
||||||
|
/// - Bit 1: Has signature (authenticated message)
|
||||||
|
/// - Bit 2: Is compressed (LZ4 compression applied)
|
||||||
|
/// - Bits 3-7: Reserved for future use
|
||||||
|
///
|
||||||
|
/// ## Size Constraints
|
||||||
|
/// - Maximum packet size: 65,535 bytes (16-bit length field)
|
||||||
|
/// - Typical packet size: < 512 bytes (BLE MTU)
|
||||||
|
/// - Minimum packet size: 21 bytes (header + sender ID)
|
||||||
|
///
|
||||||
|
/// ## Encoding Process
|
||||||
|
/// 1. Construct header with fixed fields
|
||||||
|
/// 2. Set appropriate flags
|
||||||
|
/// 3. Compress payload if beneficial
|
||||||
|
/// 4. Append variable-length fields
|
||||||
|
/// 5. Calculate and append signature if needed
|
||||||
|
///
|
||||||
|
/// ## Decoding Process
|
||||||
|
/// 1. Validate minimum packet size
|
||||||
|
/// 2. Parse fixed header
|
||||||
|
/// 3. Extract flags and determine field presence
|
||||||
|
/// 4. Parse variable fields based on flags
|
||||||
|
/// 5. Decompress payload if compressed
|
||||||
|
/// 6. Verify signature if present
|
||||||
|
///
|
||||||
|
/// ## Error Handling
|
||||||
|
/// - Graceful handling of malformed packets
|
||||||
|
/// - Clear error messages for debugging
|
||||||
|
/// - No crashes on invalid input
|
||||||
|
/// - Logging of protocol violations
|
||||||
|
///
|
||||||
|
/// ## Performance Notes
|
||||||
|
/// - Allocation-free for small messages
|
||||||
|
/// - Streaming support for large payloads
|
||||||
|
/// - Efficient bit manipulation
|
||||||
|
/// - Platform-optimized byte swapping
|
||||||
|
///
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension Data {
|
||||||
|
func trimmingNullBytes() -> Data {
|
||||||
|
// Find the first null byte
|
||||||
|
if let nullIndex = self.firstIndex(of: 0) {
|
||||||
|
return self.prefix(nullIndex)
|
||||||
|
}
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implements binary encoding and decoding for BitChat protocol messages.
|
||||||
|
/// Provides static methods for converting between BitchatPacket objects and
|
||||||
|
/// their binary wire format representation.
|
||||||
|
/// - Note: All multi-byte values use network byte order (big-endian)
|
||||||
|
struct BinaryProtocol {
|
||||||
|
static let headerSize = 13
|
||||||
|
static let senderIDSize = 8
|
||||||
|
static let recipientIDSize = 8
|
||||||
|
static let signatureSize = 64
|
||||||
|
|
||||||
|
struct Flags {
|
||||||
|
static let hasRecipient: UInt8 = 0x01
|
||||||
|
static let hasSignature: UInt8 = 0x02
|
||||||
|
static let isCompressed: UInt8 = 0x04
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode BitchatPacket to binary format
|
||||||
|
static func encode(_ packet: BitchatPacket, padding: Bool = true) -> Data? {
|
||||||
|
var data = Data()
|
||||||
|
|
||||||
|
|
||||||
|
// Try to compress payload if beneficial
|
||||||
|
var payload = packet.payload
|
||||||
|
var originalPayloadSize: UInt16? = nil
|
||||||
|
var isCompressed = false
|
||||||
|
|
||||||
|
if CompressionUtil.shouldCompress(payload) {
|
||||||
|
if let compressedPayload = CompressionUtil.compress(payload) {
|
||||||
|
// Store original size for decompression (2 bytes after payload)
|
||||||
|
originalPayloadSize = UInt16(payload.count)
|
||||||
|
payload = compressedPayload
|
||||||
|
isCompressed = true
|
||||||
|
|
||||||
|
} else {
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header
|
||||||
|
data.append(packet.version)
|
||||||
|
data.append(packet.type)
|
||||||
|
data.append(packet.ttl)
|
||||||
|
|
||||||
|
// Timestamp (8 bytes, big-endian)
|
||||||
|
for i in (0..<8).reversed() {
|
||||||
|
data.append(UInt8((packet.timestamp >> (i * 8)) & 0xFF))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flags
|
||||||
|
var flags: UInt8 = 0
|
||||||
|
if packet.recipientID != nil {
|
||||||
|
flags |= Flags.hasRecipient
|
||||||
|
}
|
||||||
|
if packet.signature != nil {
|
||||||
|
flags |= Flags.hasSignature
|
||||||
|
}
|
||||||
|
if isCompressed {
|
||||||
|
flags |= Flags.isCompressed
|
||||||
|
}
|
||||||
|
data.append(flags)
|
||||||
|
|
||||||
|
// Payload length (2 bytes, big-endian) - includes original size if compressed
|
||||||
|
let payloadDataSize = payload.count + (isCompressed ? 2 : 0)
|
||||||
|
let payloadLength = UInt16(payloadDataSize)
|
||||||
|
|
||||||
|
|
||||||
|
data.append(UInt8((payloadLength >> 8) & 0xFF))
|
||||||
|
data.append(UInt8(payloadLength & 0xFF))
|
||||||
|
|
||||||
|
// SenderID (exactly 8 bytes)
|
||||||
|
let senderBytes = packet.senderID.prefix(senderIDSize)
|
||||||
|
data.append(senderBytes)
|
||||||
|
if senderBytes.count < senderIDSize {
|
||||||
|
data.append(Data(repeating: 0, count: senderIDSize - senderBytes.count))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecipientID (if present)
|
||||||
|
if let recipientID = packet.recipientID {
|
||||||
|
let recipientBytes = recipientID.prefix(recipientIDSize)
|
||||||
|
data.append(recipientBytes)
|
||||||
|
if recipientBytes.count < recipientIDSize {
|
||||||
|
data.append(Data(repeating: 0, count: recipientIDSize - recipientBytes.count))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payload (with original size prepended if compressed)
|
||||||
|
if isCompressed, let originalSize = originalPayloadSize {
|
||||||
|
// Prepend original size (2 bytes, big-endian)
|
||||||
|
data.append(UInt8((originalSize >> 8) & 0xFF))
|
||||||
|
data.append(UInt8(originalSize & 0xFF))
|
||||||
|
}
|
||||||
|
data.append(payload)
|
||||||
|
|
||||||
|
// Signature (if present)
|
||||||
|
if let signature = packet.signature {
|
||||||
|
data.append(signature.prefix(signatureSize))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Apply padding to standard block sizes for traffic analysis resistance
|
||||||
|
if padding {
|
||||||
|
let optimalSize = MessagePadding.optimalBlockSize(for: data.count)
|
||||||
|
let paddedData = MessagePadding.pad(data, toSize: optimalSize)
|
||||||
|
return paddedData
|
||||||
|
} else {
|
||||||
|
// Caller explicitly requested no padding (e.g., BLE write path)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode binary data to BitchatPacket
|
||||||
|
static func decode(_ data: Data) -> BitchatPacket? {
|
||||||
|
// Try decode as-is first (robust when padding wasn't applied)
|
||||||
|
if let pkt = decodeCore(data) { return pkt }
|
||||||
|
// If that fails, try after removing padding
|
||||||
|
let unpadded = MessagePadding.unpad(data)
|
||||||
|
if unpadded as NSData === data as NSData { return nil }
|
||||||
|
return decodeCore(unpadded)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Core decoding implementation used by decode(_:) with and without padding removal
|
||||||
|
private static func decodeCore(_ raw: Data) -> BitchatPacket? {
|
||||||
|
// Minimum size check: header + senderID
|
||||||
|
guard raw.count >= headerSize + senderIDSize else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to array for safer indexed access
|
||||||
|
let dataArray = Array(raw)
|
||||||
|
var offset = 0
|
||||||
|
|
||||||
|
// Header parsing with bounds checks
|
||||||
|
guard offset < dataArray.count else { return nil }
|
||||||
|
let version = dataArray[offset]; offset += 1
|
||||||
|
|
||||||
|
// Check if version is 1 (only supported version)
|
||||||
|
guard version == 1 else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard offset < dataArray.count else { return nil }
|
||||||
|
let type = dataArray[offset]; offset += 1
|
||||||
|
|
||||||
|
guard offset < dataArray.count else { return nil }
|
||||||
|
let ttl = dataArray[offset]; offset += 1
|
||||||
|
|
||||||
|
// Timestamp - need 8 bytes
|
||||||
|
guard offset + 8 <= dataArray.count else { return nil }
|
||||||
|
let timestampData = Data(dataArray[offset..<offset+8])
|
||||||
|
let timestamp = timestampData.reduce(0) { result, byte in
|
||||||
|
(result << 8) | UInt64(byte)
|
||||||
|
}
|
||||||
|
offset += 8
|
||||||
|
|
||||||
|
// Flags
|
||||||
|
guard offset < dataArray.count else { return nil }
|
||||||
|
let flags = dataArray[offset]; offset += 1
|
||||||
|
let hasRecipient = (flags & Flags.hasRecipient) != 0
|
||||||
|
let hasSignature = (flags & Flags.hasSignature) != 0
|
||||||
|
let isCompressed = (flags & Flags.isCompressed) != 0
|
||||||
|
|
||||||
|
// Payload length - need 2 bytes
|
||||||
|
guard offset + 2 <= dataArray.count else { return nil }
|
||||||
|
let payloadLengthData = Data(dataArray[offset..<offset+2])
|
||||||
|
let payloadLength = payloadLengthData.reduce(0) { result, byte in
|
||||||
|
(result << 8) | UInt16(byte)
|
||||||
|
}
|
||||||
|
offset += 2
|
||||||
|
|
||||||
|
// Validate payloadLength is reasonable (prevent integer overflow)
|
||||||
|
guard payloadLength <= 65535 else { return nil }
|
||||||
|
|
||||||
|
// SenderID - need 8 bytes
|
||||||
|
guard offset + senderIDSize <= dataArray.count else { return nil }
|
||||||
|
let senderID = Data(dataArray[offset..<offset+senderIDSize])
|
||||||
|
offset += senderIDSize
|
||||||
|
|
||||||
|
// RecipientID if present
|
||||||
|
var recipientID: Data?
|
||||||
|
if hasRecipient {
|
||||||
|
guard offset + recipientIDSize <= dataArray.count else { return nil }
|
||||||
|
recipientID = Data(dataArray[offset..<offset+recipientIDSize])
|
||||||
|
offset += recipientIDSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payload handling with comprehensive bounds checking
|
||||||
|
let payload: Data
|
||||||
|
if isCompressed {
|
||||||
|
// Compressed payload needs at least 2 bytes for original size
|
||||||
|
guard Int(payloadLength) >= 2 else { return nil }
|
||||||
|
|
||||||
|
// Check we have enough data for the original size prefix
|
||||||
|
guard offset + 2 <= dataArray.count else { return nil }
|
||||||
|
let originalSizeData = Data(dataArray[offset..<offset+2])
|
||||||
|
let originalSize = Int(originalSizeData.reduce(0) { result, byte in
|
||||||
|
(result << 8) | UInt16(byte)
|
||||||
|
})
|
||||||
|
offset += 2
|
||||||
|
|
||||||
|
// Validate original size is reasonable
|
||||||
|
guard originalSize >= 0 && originalSize <= 1048576 else { return nil } // Max 1MB
|
||||||
|
|
||||||
|
// Check we have enough data for the compressed payload
|
||||||
|
let compressedPayloadSize = Int(payloadLength) - 2
|
||||||
|
guard compressedPayloadSize >= 0 && offset + compressedPayloadSize <= dataArray.count else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let compressedPayload = Data(dataArray[offset..<offset+compressedPayloadSize])
|
||||||
|
offset += compressedPayloadSize
|
||||||
|
|
||||||
|
// Decompress with error handling
|
||||||
|
guard let decompressedPayload = CompressionUtil.decompress(compressedPayload, originalSize: originalSize) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify decompressed size matches expected
|
||||||
|
guard decompressedPayload.count == originalSize else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = decompressedPayload
|
||||||
|
} else {
|
||||||
|
// Uncompressed payload
|
||||||
|
guard Int(payloadLength) >= 0 && offset + Int(payloadLength) <= dataArray.count else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
payload = Data(dataArray[offset..<offset+Int(payloadLength)])
|
||||||
|
offset += Int(payloadLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signature if present
|
||||||
|
var signature: Data?
|
||||||
|
if hasSignature {
|
||||||
|
guard offset + signatureSize <= dataArray.count else { return nil }
|
||||||
|
signature = Data(dataArray[offset..<offset+signatureSize])
|
||||||
|
offset += signatureSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final validation: ensure we haven't gone past the end
|
||||||
|
guard offset <= dataArray.count else { return nil }
|
||||||
|
|
||||||
|
return BitchatPacket(
|
||||||
|
type: type,
|
||||||
|
senderID: senderID,
|
||||||
|
recipientID: recipientID,
|
||||||
|
timestamp: timestamp,
|
||||||
|
payload: payload,
|
||||||
|
signature: signature,
|
||||||
|
ttl: ttl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Binary encoding for BitchatMessage
|
||||||
|
extension BitchatMessage {
|
||||||
|
func toBinaryPayload() -> Data? {
|
||||||
|
var data = Data()
|
||||||
|
|
||||||
|
// Message format:
|
||||||
|
// - Flags: 1 byte (bit 0: isRelay, bit 1: isPrivate, bit 2: hasOriginalSender, bit 3: hasRecipientNickname, bit 4: hasSenderPeerID, bit 5: hasMentions)
|
||||||
|
// - Timestamp: 8 bytes (seconds since epoch)
|
||||||
|
// - ID length: 1 byte
|
||||||
|
// - ID: variable
|
||||||
|
// - Sender length: 1 byte
|
||||||
|
// - Sender: variable
|
||||||
|
// - Content length: 2 bytes
|
||||||
|
// - Content: variable
|
||||||
|
// Optional fields based on flags:
|
||||||
|
// - Original sender length + data
|
||||||
|
// - Recipient nickname length + data
|
||||||
|
// - Sender peer ID length + data
|
||||||
|
// - Mentions array
|
||||||
|
|
||||||
|
var flags: UInt8 = 0
|
||||||
|
if isRelay { flags |= 0x01 }
|
||||||
|
if isPrivate { flags |= 0x02 }
|
||||||
|
if originalSender != nil { flags |= 0x04 }
|
||||||
|
if recipientNickname != nil { flags |= 0x08 }
|
||||||
|
if senderPeerID != nil { flags |= 0x10 }
|
||||||
|
if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }
|
||||||
|
|
||||||
|
data.append(flags)
|
||||||
|
|
||||||
|
// Timestamp (in milliseconds)
|
||||||
|
let timestampMillis = UInt64(timestamp.timeIntervalSince1970 * 1000)
|
||||||
|
// Encode as 8 bytes, big-endian
|
||||||
|
for i in (0..<8).reversed() {
|
||||||
|
data.append(UInt8((timestampMillis >> (i * 8)) & 0xFF))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID
|
||||||
|
if let idData = id.data(using: .utf8) {
|
||||||
|
data.append(UInt8(min(idData.count, 255)))
|
||||||
|
data.append(idData.prefix(255))
|
||||||
|
} else {
|
||||||
|
data.append(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sender
|
||||||
|
if let senderData = sender.data(using: .utf8) {
|
||||||
|
data.append(UInt8(min(senderData.count, 255)))
|
||||||
|
data.append(senderData.prefix(255))
|
||||||
|
} else {
|
||||||
|
data.append(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content
|
||||||
|
if let contentData = content.data(using: .utf8) {
|
||||||
|
let length = UInt16(min(contentData.count, 65535))
|
||||||
|
// Encode length as 2 bytes, big-endian
|
||||||
|
data.append(UInt8((length >> 8) & 0xFF))
|
||||||
|
data.append(UInt8(length & 0xFF))
|
||||||
|
data.append(contentData.prefix(Int(length)))
|
||||||
|
} else {
|
||||||
|
data.append(contentsOf: [0, 0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional fields
|
||||||
|
if let originalSender = originalSender, let origData = originalSender.data(using: .utf8) {
|
||||||
|
data.append(UInt8(min(origData.count, 255)))
|
||||||
|
data.append(origData.prefix(255))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let recipientNickname = recipientNickname, let recipData = recipientNickname.data(using: .utf8) {
|
||||||
|
data.append(UInt8(min(recipData.count, 255)))
|
||||||
|
data.append(recipData.prefix(255))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let senderPeerID = senderPeerID, let peerData = senderPeerID.data(using: .utf8) {
|
||||||
|
data.append(UInt8(min(peerData.count, 255)))
|
||||||
|
data.append(peerData.prefix(255))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mentions array
|
||||||
|
if let mentions = mentions {
|
||||||
|
data.append(UInt8(min(mentions.count, 255))) // Number of mentions
|
||||||
|
for mention in mentions.prefix(255) {
|
||||||
|
if let mentionData = mention.data(using: .utf8) {
|
||||||
|
data.append(UInt8(min(mentionData.count, 255)))
|
||||||
|
data.append(mentionData.prefix(255))
|
||||||
|
} else {
|
||||||
|
data.append(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fromBinaryPayload(_ data: Data) -> BitchatMessage? {
|
||||||
|
// Create an immutable copy to prevent threading issues
|
||||||
|
let dataCopy = Data(data)
|
||||||
|
|
||||||
|
|
||||||
|
guard dataCopy.count >= 13 else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var offset = 0
|
||||||
|
|
||||||
|
// Flags
|
||||||
|
guard offset < dataCopy.count else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let flags = dataCopy[offset]; offset += 1
|
||||||
|
let isRelay = (flags & 0x01) != 0
|
||||||
|
let isPrivate = (flags & 0x02) != 0
|
||||||
|
let hasOriginalSender = (flags & 0x04) != 0
|
||||||
|
let hasRecipientNickname = (flags & 0x08) != 0
|
||||||
|
let hasSenderPeerID = (flags & 0x10) != 0
|
||||||
|
let hasMentions = (flags & 0x20) != 0
|
||||||
|
|
||||||
|
// Timestamp
|
||||||
|
guard offset + 8 <= dataCopy.count else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let timestampData = dataCopy[offset..<offset+8]
|
||||||
|
let timestampMillis = timestampData.reduce(0) { result, byte in
|
||||||
|
(result << 8) | UInt64(byte)
|
||||||
|
}
|
||||||
|
offset += 8
|
||||||
|
let timestamp = Date(timeIntervalSince1970: TimeInterval(timestampMillis) / 1000.0)
|
||||||
|
|
||||||
|
// ID
|
||||||
|
guard offset < dataCopy.count else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let idLength = Int(dataCopy[offset]); offset += 1
|
||||||
|
guard offset + idLength <= dataCopy.count else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let id = String(data: dataCopy[offset..<offset+idLength], encoding: .utf8) ?? UUID().uuidString
|
||||||
|
offset += idLength
|
||||||
|
|
||||||
|
// Sender
|
||||||
|
guard offset < dataCopy.count else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let senderLength = Int(dataCopy[offset]); offset += 1
|
||||||
|
guard offset + senderLength <= dataCopy.count else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let sender = String(data: dataCopy[offset..<offset+senderLength], encoding: .utf8) ?? "unknown"
|
||||||
|
offset += senderLength
|
||||||
|
|
||||||
|
// Content
|
||||||
|
guard offset + 2 <= dataCopy.count else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let contentLengthData = dataCopy[offset..<offset+2]
|
||||||
|
let contentLength = Int(contentLengthData.reduce(0) { result, byte in
|
||||||
|
(result << 8) | UInt16(byte)
|
||||||
|
})
|
||||||
|
offset += 2
|
||||||
|
guard offset + contentLength <= dataCopy.count else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = String(data: dataCopy[offset..<offset+contentLength], encoding: .utf8) ?? ""
|
||||||
|
offset += contentLength
|
||||||
|
|
||||||
|
// Optional fields
|
||||||
|
var originalSender: String?
|
||||||
|
if hasOriginalSender && offset < dataCopy.count {
|
||||||
|
let length = Int(dataCopy[offset]); offset += 1
|
||||||
|
if offset + length <= dataCopy.count {
|
||||||
|
originalSender = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
|
||||||
|
offset += length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var recipientNickname: String?
|
||||||
|
if hasRecipientNickname && offset < dataCopy.count {
|
||||||
|
let length = Int(dataCopy[offset]); offset += 1
|
||||||
|
if offset + length <= dataCopy.count {
|
||||||
|
recipientNickname = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
|
||||||
|
offset += length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var senderPeerID: String?
|
||||||
|
if hasSenderPeerID && offset < dataCopy.count {
|
||||||
|
let length = Int(dataCopy[offset]); offset += 1
|
||||||
|
if offset + length <= dataCopy.count {
|
||||||
|
senderPeerID = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
|
||||||
|
offset += length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mentions array
|
||||||
|
var mentions: [String]?
|
||||||
|
if hasMentions && offset < dataCopy.count {
|
||||||
|
let mentionCount = Int(dataCopy[offset]); offset += 1
|
||||||
|
if mentionCount > 0 {
|
||||||
|
mentions = []
|
||||||
|
for _ in 0..<mentionCount {
|
||||||
|
if offset < dataCopy.count {
|
||||||
|
let length = Int(dataCopy[offset]); offset += 1
|
||||||
|
if offset + length <= dataCopy.count {
|
||||||
|
if let mention = String(data: dataCopy[offset..<offset+length], encoding: .utf8) {
|
||||||
|
mentions?.append(mention)
|
||||||
|
}
|
||||||
|
offset += length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = BitchatMessage(
|
||||||
|
id: id,
|
||||||
|
sender: sender,
|
||||||
|
content: content,
|
||||||
|
timestamp: timestamp,
|
||||||
|
isRelay: isRelay,
|
||||||
|
originalSender: originalSender,
|
||||||
|
isPrivate: isPrivate,
|
||||||
|
recipientNickname: recipientNickname,
|
||||||
|
senderPeerID: senderPeerID,
|
||||||
|
mentions: mentions
|
||||||
|
)
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
//
|
|
||||||
// BitchatFilePacket.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import BitFoundation
|
|
||||||
import BitLogger
|
|
||||||
|
|
||||||
/// TLV payload for Bluetooth mesh file transfers (voice notes, images, generic files).
|
|
||||||
/// Mirrors the Android client specification to ensure cross-platform interoperability.
|
|
||||||
struct BitchatFilePacket {
|
|
||||||
var fileName: String?
|
|
||||||
var fileSize: UInt64?
|
|
||||||
var mimeType: String?
|
|
||||||
var content: Data
|
|
||||||
|
|
||||||
/// Canonical TLV tags defined by the Android implementation.
|
|
||||||
private enum TLVType: UInt8 {
|
|
||||||
case fileName = 0x01
|
|
||||||
case fileSize = 0x02
|
|
||||||
case mimeType = 0x03
|
|
||||||
case content = 0x04
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encodes the packet using v2 canonical TLVs (4-byte FILE_SIZE, 4-byte CONTENT length).
|
|
||||||
/// Returns `nil` when fields exceed protocol limits (e.g., content > UInt32.max).
|
|
||||||
func encode() -> Data? {
|
|
||||||
let resolvedSize = fileSize ?? UInt64(content.count)
|
|
||||||
guard resolvedSize <= UInt64(UInt32.max) else { return nil }
|
|
||||||
guard resolvedSize <= UInt64(FileTransferLimits.maxPayloadBytes) else { return nil }
|
|
||||||
guard content.count <= Int(UInt32.max) else { return nil }
|
|
||||||
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
|
||||||
|
|
||||||
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
|
|
||||||
var big = value.bigEndian
|
|
||||||
withUnsafeBytes(of: &big) { data.append(contentsOf: $0) }
|
|
||||||
}
|
|
||||||
|
|
||||||
var encoded = Data()
|
|
||||||
|
|
||||||
if let name = fileName, let nameData = name.data(using: .utf8), nameData.count <= Int(UInt16.max) {
|
|
||||||
encoded.append(TLVType.fileName.rawValue)
|
|
||||||
appendBE(UInt16(nameData.count), into: &encoded)
|
|
||||||
encoded.append(nameData)
|
|
||||||
}
|
|
||||||
|
|
||||||
encoded.append(TLVType.fileSize.rawValue)
|
|
||||||
appendBE(UInt16(4), into: &encoded)
|
|
||||||
appendBE(UInt32(resolvedSize), into: &encoded)
|
|
||||||
|
|
||||||
if let mime = mimeType, let mimeData = mime.data(using: .utf8), mimeData.count <= Int(UInt16.max) {
|
|
||||||
encoded.append(TLVType.mimeType.rawValue)
|
|
||||||
appendBE(UInt16(mimeData.count), into: &encoded)
|
|
||||||
encoded.append(mimeData)
|
|
||||||
}
|
|
||||||
|
|
||||||
encoded.append(TLVType.content.rawValue)
|
|
||||||
appendBE(UInt32(content.count), into: &encoded)
|
|
||||||
encoded.append(content)
|
|
||||||
|
|
||||||
return encoded
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible.
|
|
||||||
static func decode(_ data: Data) -> BitchatFilePacket? {
|
|
||||||
var cursor = data.startIndex
|
|
||||||
let end = data.endIndex
|
|
||||||
|
|
||||||
var fileName: String?
|
|
||||||
var fileSize: UInt64?
|
|
||||||
var mimeType: String?
|
|
||||||
var content = Data()
|
|
||||||
|
|
||||||
while cursor < end {
|
|
||||||
let typeRaw = data[cursor]
|
|
||||||
cursor = data.index(after: cursor)
|
|
||||||
|
|
||||||
guard cursor <= end else { return nil }
|
|
||||||
let tlvType = TLVType(rawValue: typeRaw)
|
|
||||||
|
|
||||||
func readBigEndianLength(bytes: Int) -> Int? {
|
|
||||||
guard data.distance(from: cursor, to: end) >= bytes else { return nil }
|
|
||||||
// Use UInt64 to prevent integer overflow during shift operations
|
|
||||||
var result: UInt64 = 0
|
|
||||||
for _ in 0..<bytes {
|
|
||||||
result = (result << 8) | UInt64(data[cursor])
|
|
||||||
cursor = data.index(after: cursor)
|
|
||||||
}
|
|
||||||
// Safely convert to Int with overflow check
|
|
||||||
guard result <= Int.max else { return nil }
|
|
||||||
return Int(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
let length: Int?
|
|
||||||
if tlvType == .content {
|
|
||||||
let snapshot = cursor
|
|
||||||
let canonical = readBigEndianLength(bytes: 4)
|
|
||||||
if let canonical = canonical,
|
|
||||||
canonical <= data.distance(from: cursor, to: end) {
|
|
||||||
length = canonical
|
|
||||||
} else {
|
|
||||||
cursor = snapshot
|
|
||||||
length = readBigEndianLength(bytes: 2)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
length = readBigEndianLength(bytes: 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let tlvLength = length, tlvLength >= 0 else { return nil }
|
|
||||||
guard data.distance(from: cursor, to: end) >= tlvLength else { return nil }
|
|
||||||
|
|
||||||
let valueStart = cursor
|
|
||||||
cursor = data.index(cursor, offsetBy: tlvLength)
|
|
||||||
let value = data[valueStart..<cursor]
|
|
||||||
|
|
||||||
switch tlvType {
|
|
||||||
case .fileName:
|
|
||||||
fileName = String(data: Data(value), encoding: .utf8)
|
|
||||||
case .fileSize:
|
|
||||||
if tlvLength == 4 || tlvLength == 8 {
|
|
||||||
var size: UInt64 = 0
|
|
||||||
for byte in value {
|
|
||||||
size = (size << 8) | UInt64(byte)
|
|
||||||
}
|
|
||||||
if size > UInt64(FileTransferLimits.maxPayloadBytes) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
fileSize = size
|
|
||||||
}
|
|
||||||
case .mimeType:
|
|
||||||
mimeType = String(data: Data(value), encoding: .utf8)
|
|
||||||
case .content:
|
|
||||||
let proposedSize = content.count + value.count
|
|
||||||
if proposedSize > FileTransferLimits.maxPayloadBytes {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
content.append(contentsOf: value)
|
|
||||||
case nil:
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
guard !content.isEmpty else { return nil }
|
|
||||||
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
|
||||||
return BitchatFilePacket(
|
|
||||||
fileName: fileName,
|
|
||||||
fileSize: fileSize ?? UInt64(content.count),
|
|
||||||
mimeType: mimeType,
|
|
||||||
content: content
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -59,8 +59,91 @@
|
|||||||
///
|
///
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CoreBluetooth
|
import CryptoKit
|
||||||
import BitFoundation
|
|
||||||
|
// MARK: - Message Padding
|
||||||
|
|
||||||
|
/// Provides privacy-preserving message padding to obscure actual content length.
|
||||||
|
/// Uses PKCS#7-style padding with random bytes to prevent traffic analysis.
|
||||||
|
struct MessagePadding {
|
||||||
|
// Standard block sizes for padding
|
||||||
|
static let blockSizes = [256, 512, 1024, 2048]
|
||||||
|
|
||||||
|
// Add PKCS#7-style padding to reach target size
|
||||||
|
static func pad(_ data: Data, toSize targetSize: Int) -> Data {
|
||||||
|
guard data.count < targetSize else { return data }
|
||||||
|
|
||||||
|
let paddingNeeded = targetSize - data.count
|
||||||
|
// Constrain to 255 to fit a single-byte pad length marker
|
||||||
|
guard paddingNeeded > 0 && paddingNeeded <= 255 else { return data }
|
||||||
|
|
||||||
|
var padded = data
|
||||||
|
// PKCS#7: All pad bytes are equal to the pad length
|
||||||
|
padded.append(contentsOf: Array(repeating: UInt8(paddingNeeded), count: paddingNeeded))
|
||||||
|
return padded
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove padding from data
|
||||||
|
static func unpad(_ data: Data) -> Data {
|
||||||
|
guard !data.isEmpty else { return data }
|
||||||
|
let last = data.last!
|
||||||
|
let paddingLength = Int(last)
|
||||||
|
// Must have at least 1 pad byte and not exceed data length
|
||||||
|
guard paddingLength > 0 && paddingLength <= data.count else { return data }
|
||||||
|
// Verify PKCS#7: all last N bytes equal to pad length
|
||||||
|
let start = data.count - paddingLength
|
||||||
|
let tail = data[start...]
|
||||||
|
for b in tail { if b != last { return data } }
|
||||||
|
return Data(data[..<start])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find optimal block size for data
|
||||||
|
static func optimalBlockSize(for dataSize: Int) -> Int {
|
||||||
|
// Account for encryption overhead (~16 bytes for AES-GCM tag)
|
||||||
|
let totalSize = dataSize + 16
|
||||||
|
|
||||||
|
// Find smallest block that fits
|
||||||
|
for blockSize in blockSizes {
|
||||||
|
if totalSize <= blockSize {
|
||||||
|
return blockSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For very large messages, just use the original size
|
||||||
|
// (will be fragmented anyway)
|
||||||
|
return dataSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Message Types
|
||||||
|
|
||||||
|
/// Simplified BitChat protocol message types.
|
||||||
|
/// Reduced from 24 types to just 6 essential ones.
|
||||||
|
/// All private communication metadata (receipts, status) is embedded in noiseEncrypted payloads.
|
||||||
|
enum MessageType: UInt8 {
|
||||||
|
// Public messages (unencrypted)
|
||||||
|
case announce = 0x01 // "I'm here" with nickname
|
||||||
|
case message = 0x02 // Public chat message
|
||||||
|
case leave = 0x03 // "I'm leaving"
|
||||||
|
|
||||||
|
// Noise encryption
|
||||||
|
case noiseHandshake = 0x10 // Handshake (init or response determined by payload)
|
||||||
|
case noiseEncrypted = 0x11 // All encrypted payloads (messages, receipts, etc.)
|
||||||
|
|
||||||
|
// Fragmentation (simplified)
|
||||||
|
case fragment = 0x20 // Single fragment type for large messages
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .announce: return "announce"
|
||||||
|
case .message: return "message"
|
||||||
|
case .leave: return "leave"
|
||||||
|
case .noiseHandshake: return "noiseHandshake"
|
||||||
|
case .noiseEncrypted: return "noiseEncrypted"
|
||||||
|
case .fragment: return "fragment"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Noise Payload Types
|
// MARK: - Noise Payload Types
|
||||||
|
|
||||||
@@ -98,25 +181,300 @@ enum LazyHandshakeState {
|
|||||||
case failed(Error) // Handshake failed
|
case failed(Error) // Handshake failed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
// MARK: - Core Protocol Structures
|
||||||
|
|
||||||
|
/// The core packet structure for all BitChat protocol messages.
|
||||||
|
/// Encapsulates all data needed for routing through the mesh network,
|
||||||
|
/// including TTL for hop limiting and optional encryption.
|
||||||
|
/// - Note: Packets larger than BLE MTU (512 bytes) are automatically fragmented
|
||||||
|
struct BitchatPacket: Codable {
|
||||||
|
let version: UInt8
|
||||||
|
let type: UInt8
|
||||||
|
let senderID: Data
|
||||||
|
let recipientID: Data?
|
||||||
|
let timestamp: UInt64
|
||||||
|
let payload: Data
|
||||||
|
var signature: Data?
|
||||||
|
var ttl: UInt8
|
||||||
|
|
||||||
|
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) {
|
||||||
|
self.version = 1
|
||||||
|
self.type = type
|
||||||
|
self.senderID = senderID
|
||||||
|
self.recipientID = recipientID
|
||||||
|
self.timestamp = timestamp
|
||||||
|
self.payload = payload
|
||||||
|
self.signature = signature
|
||||||
|
self.ttl = ttl
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience initializer for new binary format
|
||||||
|
init(type: UInt8, ttl: UInt8, senderID: String, payload: Data) {
|
||||||
|
self.version = 1
|
||||||
|
self.type = type
|
||||||
|
// Convert hex string peer ID to binary data (8 bytes)
|
||||||
|
var senderData = Data()
|
||||||
|
var tempID = senderID
|
||||||
|
while tempID.count >= 2 {
|
||||||
|
let hexByte = String(tempID.prefix(2))
|
||||||
|
if let byte = UInt8(hexByte, radix: 16) {
|
||||||
|
senderData.append(byte)
|
||||||
|
}
|
||||||
|
tempID = String(tempID.dropFirst(2))
|
||||||
|
}
|
||||||
|
self.senderID = senderData
|
||||||
|
self.recipientID = nil
|
||||||
|
self.timestamp = UInt64(Date().timeIntervalSince1970 * 1000) // milliseconds
|
||||||
|
self.payload = payload
|
||||||
|
self.signature = nil
|
||||||
|
self.ttl = ttl
|
||||||
|
}
|
||||||
|
|
||||||
|
var data: Data? {
|
||||||
|
BinaryProtocol.encode(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toBinaryData(padding: Bool = true) -> Data? {
|
||||||
|
BinaryProtocol.encode(self, padding: padding)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward-compatible helper (defaults to padded encoding)
|
||||||
|
func toBinaryData() -> Data? {
|
||||||
|
toBinaryData(padding: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create binary representation for signing (without signature and TTL fields)
|
||||||
|
/// TTL is excluded because it changes during packet relay operations
|
||||||
|
func toBinaryDataForSigning() -> Data? {
|
||||||
|
// Create a copy without signature and with fixed TTL for signing
|
||||||
|
// TTL must be excluded because it changes during relay
|
||||||
|
let unsignedPacket = BitchatPacket(
|
||||||
|
type: type,
|
||||||
|
senderID: senderID,
|
||||||
|
recipientID: recipientID,
|
||||||
|
timestamp: timestamp,
|
||||||
|
payload: payload,
|
||||||
|
signature: nil, // Remove signature for signing
|
||||||
|
ttl: 0 // Use fixed TTL=0 for signing to ensure relay compatibility
|
||||||
|
)
|
||||||
|
return BinaryProtocol.encode(unsignedPacket)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func from(_ data: Data) -> BitchatPacket? {
|
||||||
|
BinaryProtocol.decode(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
// MARK: - Read Receipts
|
||||||
|
|
||||||
|
// Read receipt structure
|
||||||
|
struct ReadReceipt: Codable {
|
||||||
|
let originalMessageID: String
|
||||||
|
let receiptID: String
|
||||||
|
var readerID: String // Who read it
|
||||||
|
let readerNickname: String
|
||||||
|
let timestamp: Date
|
||||||
|
|
||||||
|
init(originalMessageID: String, readerID: String, readerNickname: String) {
|
||||||
|
self.originalMessageID = originalMessageID
|
||||||
|
self.receiptID = UUID().uuidString
|
||||||
|
self.readerID = readerID
|
||||||
|
self.readerNickname = readerNickname
|
||||||
|
self.timestamp = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
// For binary decoding
|
||||||
|
private init(originalMessageID: String, receiptID: String, readerID: String, readerNickname: String, timestamp: Date) {
|
||||||
|
self.originalMessageID = originalMessageID
|
||||||
|
self.receiptID = receiptID
|
||||||
|
self.readerID = readerID
|
||||||
|
self.readerNickname = readerNickname
|
||||||
|
self.timestamp = timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode() -> Data? {
|
||||||
|
try? JSONEncoder().encode(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(from data: Data) -> ReadReceipt? {
|
||||||
|
try? JSONDecoder().decode(ReadReceipt.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Binary Encoding
|
||||||
|
|
||||||
|
func toBinaryData() -> Data {
|
||||||
|
var data = Data()
|
||||||
|
data.appendUUID(originalMessageID)
|
||||||
|
data.appendUUID(receiptID)
|
||||||
|
// ReaderID as 8-byte hex string
|
||||||
|
var readerData = Data()
|
||||||
|
var tempID = readerID
|
||||||
|
while tempID.count >= 2 && readerData.count < 8 {
|
||||||
|
let hexByte = String(tempID.prefix(2))
|
||||||
|
if let byte = UInt8(hexByte, radix: 16) {
|
||||||
|
readerData.append(byte)
|
||||||
|
}
|
||||||
|
tempID = String(tempID.dropFirst(2))
|
||||||
|
}
|
||||||
|
while readerData.count < 8 {
|
||||||
|
readerData.append(0)
|
||||||
|
}
|
||||||
|
data.append(readerData)
|
||||||
|
data.appendDate(timestamp)
|
||||||
|
data.appendString(readerNickname)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fromBinaryData(_ data: Data) -> ReadReceipt? {
|
||||||
|
// Create defensive copy
|
||||||
|
let dataCopy = Data(data)
|
||||||
|
|
||||||
|
// Minimum size: 2 UUIDs (32) + readerID (8) + timestamp (8) + min nickname
|
||||||
|
guard dataCopy.count >= 49 else { return nil }
|
||||||
|
|
||||||
|
var offset = 0
|
||||||
|
|
||||||
|
guard let originalMessageID = dataCopy.readUUID(at: &offset),
|
||||||
|
let receiptID = dataCopy.readUUID(at: &offset) else { return nil }
|
||||||
|
|
||||||
|
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
||||||
|
let readerID = readerIDData.hexEncodedString()
|
||||||
|
guard InputValidator.validatePeerID(readerID) else { return nil }
|
||||||
|
|
||||||
|
guard let timestamp = dataCopy.readDate(at: &offset),
|
||||||
|
InputValidator.validateTimestamp(timestamp),
|
||||||
|
let readerNicknameRaw = dataCopy.readString(at: &offset),
|
||||||
|
let readerNickname = InputValidator.validateNickname(readerNicknameRaw) else { return nil }
|
||||||
|
|
||||||
|
return ReadReceipt(originalMessageID: originalMessageID,
|
||||||
|
receiptID: receiptID,
|
||||||
|
readerID: readerID,
|
||||||
|
readerNickname: readerNickname,
|
||||||
|
timestamp: timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Delivery Status
|
||||||
|
|
||||||
|
// Delivery status for messages
|
||||||
|
enum DeliveryStatus: Codable, Equatable {
|
||||||
|
case sending
|
||||||
|
case sent // Left our device
|
||||||
|
case delivered(to: String, at: Date) // Confirmed by recipient
|
||||||
|
case read(by: String, at: Date) // Seen by recipient
|
||||||
|
case failed(reason: String)
|
||||||
|
case partiallyDelivered(reached: Int, total: Int) // For rooms
|
||||||
|
|
||||||
|
var displayText: String {
|
||||||
|
switch self {
|
||||||
|
case .sending:
|
||||||
|
return "Sending..."
|
||||||
|
case .sent:
|
||||||
|
return "Sent"
|
||||||
|
case .delivered(let nickname, _):
|
||||||
|
return "Delivered to \(nickname)"
|
||||||
|
case .read(let nickname, _):
|
||||||
|
return "Read by \(nickname)"
|
||||||
|
case .failed(let reason):
|
||||||
|
return "Failed: \(reason)"
|
||||||
|
case .partiallyDelivered(let reached, let total):
|
||||||
|
return "Delivered to \(reached)/\(total)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Message Model
|
||||||
|
|
||||||
|
/// Represents a user-visible message in the BitChat system.
|
||||||
|
/// Handles both broadcast messages and private encrypted messages,
|
||||||
|
/// with support for mentions, replies, and delivery tracking.
|
||||||
|
/// - Note: This is the primary data model for chat messages
|
||||||
|
class BitchatMessage: Codable {
|
||||||
|
let id: String
|
||||||
|
let sender: String
|
||||||
|
let content: String
|
||||||
|
let timestamp: Date
|
||||||
|
let isRelay: Bool
|
||||||
|
let originalSender: String?
|
||||||
|
let isPrivate: Bool
|
||||||
|
let recipientNickname: String?
|
||||||
|
let senderPeerID: String?
|
||||||
|
let mentions: [String]? // Array of mentioned nicknames
|
||||||
|
var deliveryStatus: DeliveryStatus? // Delivery tracking
|
||||||
|
|
||||||
|
// Cached formatted text (not included in Codable)
|
||||||
|
private var _cachedFormattedText: [String: AttributedString] = [:]
|
||||||
|
|
||||||
|
func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? {
|
||||||
|
return _cachedFormattedText["\(isDark)-\(isSelf)"]
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) {
|
||||||
|
_cachedFormattedText["\(isDark)-\(isSelf)"] = text
|
||||||
|
}
|
||||||
|
|
||||||
|
// Codable implementation
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, sender, content, timestamp, isRelay, originalSender
|
||||||
|
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, deliveryStatus: DeliveryStatus? = nil) {
|
||||||
|
self.id = id ?? UUID().uuidString
|
||||||
|
self.sender = sender
|
||||||
|
self.content = content
|
||||||
|
self.timestamp = timestamp
|
||||||
|
self.isRelay = isRelay
|
||||||
|
self.originalSender = originalSender
|
||||||
|
self.isPrivate = isPrivate
|
||||||
|
self.recipientNickname = recipientNickname
|
||||||
|
self.senderPeerID = senderPeerID
|
||||||
|
self.mentions = mentions
|
||||||
|
self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equatable conformance for BitchatMessage
|
||||||
|
extension BitchatMessage: Equatable {
|
||||||
|
static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool {
|
||||||
|
return lhs.id == rhs.id &&
|
||||||
|
lhs.sender == rhs.sender &&
|
||||||
|
lhs.content == rhs.content &&
|
||||||
|
lhs.timestamp == rhs.timestamp &&
|
||||||
|
lhs.isRelay == rhs.isRelay &&
|
||||||
|
lhs.originalSender == rhs.originalSender &&
|
||||||
|
lhs.isPrivate == rhs.isPrivate &&
|
||||||
|
lhs.recipientNickname == rhs.recipientNickname &&
|
||||||
|
lhs.senderPeerID == rhs.senderPeerID &&
|
||||||
|
lhs.mentions == rhs.mentions &&
|
||||||
|
lhs.deliveryStatus == rhs.deliveryStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Delegate Protocol
|
// MARK: - Delegate Protocol
|
||||||
|
|
||||||
protocol BitchatDelegate: AnyObject {
|
protocol BitchatDelegate: AnyObject {
|
||||||
func didReceiveMessage(_ message: BitchatMessage)
|
func didReceiveMessage(_ message: BitchatMessage)
|
||||||
func didConnectToPeer(_ peerID: PeerID)
|
func didConnectToPeer(_ peerID: String)
|
||||||
func didDisconnectFromPeer(_ peerID: PeerID)
|
func didDisconnectFromPeer(_ peerID: String)
|
||||||
func didUpdatePeerList(_ peers: [PeerID])
|
func didUpdatePeerList(_ peers: [String])
|
||||||
|
|
||||||
// Optional method to check if a fingerprint belongs to a favorite peer
|
// Optional method to check if a fingerprint belongs to a favorite peer
|
||||||
func isFavorite(fingerprint: String) -> Bool
|
func isFavorite(fingerprint: String) -> Bool
|
||||||
|
|
||||||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus)
|
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus)
|
||||||
|
|
||||||
// Low-level events for better separation of concerns
|
// Low-level events for better separation of concerns
|
||||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
|
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date)
|
||||||
|
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date)
|
||||||
// Bluetooth state updates for user notifications
|
|
||||||
func didUpdateBluetoothState(_ state: CBManagerState)
|
|
||||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provide default implementation to make it effectively optional
|
// Provide default implementation to make it effectively optional
|
||||||
@@ -129,11 +487,45 @@ extension BitchatDelegate {
|
|||||||
// Default empty implementation
|
// Default empty implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {
|
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date) {
|
||||||
// Default empty implementation
|
// Default empty implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {
|
||||||
// Default empty implementation
|
// Default empty implementation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Noise Payload Helpers
|
||||||
|
|
||||||
|
/// Helper to create typed Noise payloads
|
||||||
|
struct NoisePayload {
|
||||||
|
let type: NoisePayloadType
|
||||||
|
let data: Data
|
||||||
|
|
||||||
|
/// Encode payload with type prefix
|
||||||
|
func encode() -> Data {
|
||||||
|
var encoded = Data()
|
||||||
|
encoded.append(type.rawValue)
|
||||||
|
encoded.append(data)
|
||||||
|
return encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode payload from data
|
||||||
|
static func decode(_ data: Data) -> NoisePayload? {
|
||||||
|
// Ensure we have at least 1 byte for the type
|
||||||
|
guard !data.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safely get the first byte
|
||||||
|
let firstByte = data[data.startIndex]
|
||||||
|
guard let type = NoisePayloadType(rawValue: firstByte) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a proper Data copy (not a subsequence) for thread safety
|
||||||
|
let payloadData = data.count > 1 ? Data(data.dropFirst()) : Data()
|
||||||
|
return NoisePayload(type: type, data: payloadData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,14 +10,6 @@ enum Geohash {
|
|||||||
return map
|
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.
|
/// Encodes the provided coordinates into a geohash string.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - latitude: Latitude in degrees (-90...90)
|
/// - latitude: Latitude in degrees (-90...90)
|
||||||
@@ -95,81 +87,4 @@ enum Geohash {
|
|||||||
let lon = (lonInterval.0 + lonInterval.1) / 2
|
let lon = (lonInterval.0 + lonInterval.1) / 2
|
||||||
return (lat, lon)
|
return (lat, lon)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decodes a geohash into its latitude and longitude bounds.
|
|
||||||
/// - Parameter geohash: Base32 geohash string.
|
|
||||||
/// - Returns: (latMin, latMax, lonMin, lonMax)
|
|
||||||
static func decodeBounds(_ geohash: String) -> (latMin: Double, latMax: Double, lonMin: Double, lonMax: Double) {
|
|
||||||
var latInterval: (Double, Double) = (-90.0, 90.0)
|
|
||||||
var lonInterval: (Double, Double) = (-180.0, 180.0)
|
|
||||||
|
|
||||||
var isEven = true
|
|
||||||
for ch in geohash.lowercased() {
|
|
||||||
guard let cd = base32Map[ch] else { continue }
|
|
||||||
for mask in [16, 8, 4, 2, 1] {
|
|
||||||
if isEven {
|
|
||||||
let mid = (lonInterval.0 + lonInterval.1) / 2
|
|
||||||
if (cd & mask) != 0 { lonInterval.0 = mid } else { lonInterval.1 = mid }
|
|
||||||
} else {
|
|
||||||
let mid = (latInterval.0 + latInterval.1) / 2
|
|
||||||
if (cd & mask) != 0 { latInterval.0 = mid } else { latInterval.1 = mid }
|
|
||||||
}
|
|
||||||
isEven.toggle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (latInterval.0, latInterval.1, lonInterval.0, lonInterval.1)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns all 8 neighboring geohash cells at the same precision.
|
|
||||||
/// - Parameter geohash: Base32 geohash string.
|
|
||||||
/// - Returns: Array of 8 neighboring geohashes (N, NE, E, SE, S, SW, W, NW order).
|
|
||||||
static func neighbors(of geohash: String) -> [String] {
|
|
||||||
guard !geohash.isEmpty else { return [] }
|
|
||||||
|
|
||||||
let precision = geohash.count
|
|
||||||
let bounds = decodeBounds(geohash)
|
|
||||||
let center = decodeCenter(geohash)
|
|
||||||
|
|
||||||
// Calculate cell dimensions
|
|
||||||
let latHeight = bounds.latMax - bounds.latMin
|
|
||||||
let lonWidth = bounds.lonMax - bounds.lonMin
|
|
||||||
|
|
||||||
// Helper to wrap longitude around ±180
|
|
||||||
func wrapLongitude(_ lon: Double) -> Double {
|
|
||||||
var wrapped = lon
|
|
||||||
while wrapped > 180.0 { wrapped -= 360.0 }
|
|
||||||
while wrapped < -180.0 { wrapped += 360.0 }
|
|
||||||
return wrapped
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to clamp latitude to ±90
|
|
||||||
func clampLatitude(_ lat: Double) -> Double {
|
|
||||||
return max(-90.0, min(90.0, lat))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate 8 neighbor centers
|
|
||||||
let neighbors: [(lat: Double, lon: Double)] = [
|
|
||||||
(center.lat + latHeight, center.lon), // N
|
|
||||||
(center.lat + latHeight, center.lon + lonWidth), // NE
|
|
||||||
(center.lat, center.lon + lonWidth), // E
|
|
||||||
(center.lat - latHeight, center.lon + lonWidth), // SE
|
|
||||||
(center.lat - latHeight, center.lon), // S
|
|
||||||
(center.lat - latHeight, center.lon - lonWidth), // SW
|
|
||||||
(center.lat, center.lon - lonWidth), // W
|
|
||||||
(center.lat + latHeight, center.lon - lonWidth) // NW
|
|
||||||
]
|
|
||||||
|
|
||||||
// Encode each neighbor, handling boundary conditions
|
|
||||||
return neighbors.compactMap { neighbor in
|
|
||||||
let lat = clampLatitude(neighbor.lat)
|
|
||||||
let lon = wrapLongitude(neighbor.lon)
|
|
||||||
|
|
||||||
// Skip if we've crossed a pole (latitude clamped to boundary)
|
|
||||||
if (neighbor.lat > 90.0 || neighbor.lat < -90.0) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return encode(latitude: lat, longitude: lon, precision: precision)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import Foundation
|
|||||||
|
|
||||||
/// Levels of location channels mapped to geohash precisions.
|
/// Levels of location channels mapped to geohash precisions.
|
||||||
enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
||||||
case building
|
|
||||||
case block
|
case block
|
||||||
case neighborhood
|
case neighborhood
|
||||||
case city
|
case city
|
||||||
@@ -12,7 +11,6 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
|||||||
/// Geohash length used for this level.
|
/// Geohash length used for this level.
|
||||||
var precision: Int {
|
var precision: Int {
|
||||||
switch self {
|
switch self {
|
||||||
case .building: return 8
|
|
||||||
case .block: return 7
|
case .block: return 7
|
||||||
case .neighborhood: return 6
|
case .neighborhood: return 6
|
||||||
case .city: return 5
|
case .city: return 5
|
||||||
@@ -23,28 +21,20 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
|||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .building:
|
case .block: return "Block"
|
||||||
return String(localized: "location_levels.building", comment: "Name for building-level location channel")
|
case .neighborhood: return "Neighborhood"
|
||||||
case .block:
|
case .city: return "City"
|
||||||
return String(localized: "location_levels.block", comment: "Name for block-level location channel")
|
case .province: return "Province"
|
||||||
case .neighborhood:
|
case .region: return "Region"
|
||||||
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
|
// Backward-compatible Codable for renamed cases
|
||||||
extension GeohashChannelLevel {
|
extension GeohashChannelLevel {
|
||||||
init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.singleValueContainer()
|
let container = try decoder.singleValueContainer()
|
||||||
if let raw = try? container.decode(String.self) {
|
if let raw = try? container.decode(String.self) {
|
||||||
switch raw {
|
switch raw {
|
||||||
case "building": self = .building
|
|
||||||
case "block": self = .block
|
case "block": self = .block
|
||||||
case "neighborhood": self = .neighborhood
|
case "neighborhood": self = .neighborhood
|
||||||
case "city": self = .city
|
case "city": self = .city
|
||||||
@@ -56,7 +46,6 @@ extension GeohashChannelLevel {
|
|||||||
}
|
}
|
||||||
} else if let precision = try? container.decode(Int.self) {
|
} else if let precision = try? container.decode(Int.self) {
|
||||||
switch precision {
|
switch precision {
|
||||||
case 8: self = .building
|
|
||||||
case 7: self = .block
|
case 7: self = .block
|
||||||
case 6: self = .neighborhood
|
case 6: self = .neighborhood
|
||||||
case 5: self = .city
|
case 5: self = .city
|
||||||
@@ -72,7 +61,6 @@ extension GeohashChannelLevel {
|
|||||||
func encode(to encoder: Encoder) throws {
|
func encode(to encoder: Encoder) throws {
|
||||||
var container = encoder.singleValueContainer()
|
var container = encoder.singleValueContainer()
|
||||||
switch self {
|
switch self {
|
||||||
case .building: try container.encode("building")
|
|
||||||
case .block: try container.encode("block")
|
case .block: try container.encode("block")
|
||||||
case .neighborhood: try container.encode("neighborhood")
|
case .neighborhood: try container.encode("neighborhood")
|
||||||
case .city: try container.encode("city")
|
case .city: try container.encode("city")
|
||||||
@@ -116,18 +104,4 @@ enum ChannelID: Equatable, Codable {
|
|||||||
case .location(let ch): return ch.geohash
|
case .location(let ch): return ch.geohash
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var isMesh: Bool {
|
|
||||||
switch self {
|
|
||||||
case .mesh: true
|
|
||||||
case .location: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var isLocation: Bool {
|
|
||||||
switch self {
|
|
||||||
case .mesh: false
|
|
||||||
case .location: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,19 +6,15 @@ struct AnnouncementPacket {
|
|||||||
let nickname: String
|
let nickname: String
|
||||||
let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement)
|
let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement)
|
||||||
let signingPublicKey: Data // Ed25519 public key for signing
|
let signingPublicKey: Data // Ed25519 public key for signing
|
||||||
let directNeighbors: [Data]? // 8-byte peer IDs
|
|
||||||
|
|
||||||
private enum TLVType: UInt8 {
|
private enum TLVType: UInt8 {
|
||||||
case nickname = 0x01
|
case nickname = 0x01
|
||||||
case noisePublicKey = 0x02
|
case noisePublicKey = 0x02
|
||||||
case signingPublicKey = 0x03
|
case signingPublicKey = 0x03
|
||||||
case directNeighbors = 0x04
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func encode() -> Data? {
|
func encode() -> Data? {
|
||||||
var data = 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
|
// TLV for nickname
|
||||||
guard let nicknameData = nickname.data(using: .utf8), nicknameData.count <= 255 else { return nil }
|
guard let nicknameData = nickname.data(using: .utf8), nicknameData.count <= 255 else { return nil }
|
||||||
@@ -37,16 +33,6 @@ struct AnnouncementPacket {
|
|||||||
data.append(TLVType.signingPublicKey.rawValue)
|
data.append(TLVType.signingPublicKey.rawValue)
|
||||||
data.append(UInt8(signingPublicKey.count))
|
data.append(UInt8(signingPublicKey.count))
|
||||||
data.append(signingPublicKey)
|
data.append(signingPublicKey)
|
||||||
|
|
||||||
// TLV for direct neighbors (optional)
|
|
||||||
if let neighbors = directNeighbors, !neighbors.isEmpty {
|
|
||||||
let neighborsData = neighbors.prefix(10).reduce(Data()) { $0 + $1 }
|
|
||||||
if !neighborsData.isEmpty && neighborsData.count % 8 == 0 {
|
|
||||||
data.append(TLVType.directNeighbors.rawValue)
|
|
||||||
data.append(UInt8(neighborsData.count))
|
|
||||||
data.append(neighborsData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
@@ -56,7 +42,6 @@ struct AnnouncementPacket {
|
|||||||
var nickname: String?
|
var nickname: String?
|
||||||
var noisePublicKey: Data?
|
var noisePublicKey: Data?
|
||||||
var signingPublicKey: Data?
|
var signingPublicKey: Data?
|
||||||
var directNeighbors: [Data]?
|
|
||||||
|
|
||||||
while offset + 2 <= data.count {
|
while offset + 2 <= data.count {
|
||||||
let typeRaw = data[offset]
|
let typeRaw = data[offset]
|
||||||
@@ -76,17 +61,6 @@ struct AnnouncementPacket {
|
|||||||
noisePublicKey = Data(value)
|
noisePublicKey = Data(value)
|
||||||
case .signingPublicKey:
|
case .signingPublicKey:
|
||||||
signingPublicKey = Data(value)
|
signingPublicKey = Data(value)
|
||||||
case .directNeighbors:
|
|
||||||
if length > 0 && length % 8 == 0 {
|
|
||||||
var neighbors = [Data]()
|
|
||||||
let count = length / 8
|
|
||||||
for i in 0..<count {
|
|
||||||
let start = value.startIndex + i * 8
|
|
||||||
let end = start + 8
|
|
||||||
neighbors.append(Data(value[start..<end]))
|
|
||||||
}
|
|
||||||
directNeighbors = neighbors
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Unknown TLV; skip (tolerant decoder for forward compatibility)
|
// Unknown TLV; skip (tolerant decoder for forward compatibility)
|
||||||
@@ -98,8 +72,7 @@ struct AnnouncementPacket {
|
|||||||
return AnnouncementPacket(
|
return AnnouncementPacket(
|
||||||
nickname: nickname,
|
nickname: nickname,
|
||||||
noisePublicKey: noisePublicKey,
|
noisePublicKey: noisePublicKey,
|
||||||
signingPublicKey: signingPublicKey,
|
signingPublicKey: signingPublicKey
|
||||||
directNeighbors: directNeighbors
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,7 +88,6 @@ struct PrivateMessagePacket {
|
|||||||
|
|
||||||
func encode() -> Data? {
|
func encode() -> Data? {
|
||||||
var data = Data()
|
var data = Data()
|
||||||
data.reserveCapacity(2 + min(messageID.count, 255) + 2 + min(content.count, 255))
|
|
||||||
|
|
||||||
// TLV for messageID
|
// TLV for messageID
|
||||||
guard let messageIDData = messageID.data(using: .utf8), messageIDData.count <= 255 else { return nil }
|
guard let messageIDData = messageID.data(using: .utf8), messageIDData.count <= 255 else { return nil }
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
// MARK: - Peer ID Utilities
|
||||||
|
|
||||||
|
struct PeerIDUtils {
|
||||||
|
/// Derive the stable 16-hex peer ID from a Noise static public key
|
||||||
|
static func derivePeerID(fromPublicKey publicKey: Data) -> String {
|
||||||
|
let digest = SHA256.hash(data: publicKey)
|
||||||
|
let hex = digest.map { String(format: "%02x", $0) }.joined()
|
||||||
|
return String(hex.prefix(16))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Manages autocomplete functionality for chat
|
/// Manages autocomplete functionality for chat
|
||||||
final class AutocompleteService {
|
class AutocompleteService {
|
||||||
private let mentionRegex = try? NSRegularExpression(pattern: "@([\\p{L}0-9_]*)$", options: [])
|
private let mentionRegex = try? NSRegularExpression(pattern: "@([\\p{L}0-9_]*)$", options: [])
|
||||||
private let commandRegex = try? NSRegularExpression(pattern: "^/([a-z]*)$", options: [])
|
private let commandRegex = try? NSRegularExpression(pattern: "^/([a-z]*)$", options: [])
|
||||||
|
|
||||||
|
|||||||
@@ -1,212 +0,0 @@
|
|||||||
//
|
|
||||||
// MimeType.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import UniformTypeIdentifiers
|
|
||||||
|
|
||||||
// MARK: - Extensions for missing UTTypes
|
|
||||||
|
|
||||||
extension UTType {
|
|
||||||
static let webP = UTType(importedAs: "image/webp")
|
|
||||||
static let aac = UTType(importedAs: "audio/aac")
|
|
||||||
static let m4a = UTType(importedAs: "audio/m4a")
|
|
||||||
static let ogg = UTType(importedAs: "audio/ogg")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - MimeType Enum
|
|
||||||
|
|
||||||
enum MimeType: CaseIterable, Hashable {
|
|
||||||
case jpeg
|
|
||||||
case jpg
|
|
||||||
case png
|
|
||||||
case gif
|
|
||||||
case webp
|
|
||||||
case mp4Audio
|
|
||||||
case m4a
|
|
||||||
case aac
|
|
||||||
case mpeg
|
|
||||||
case mp3
|
|
||||||
case wav
|
|
||||||
case xWav
|
|
||||||
case ogg
|
|
||||||
case pdf
|
|
||||||
case octetStream
|
|
||||||
|
|
||||||
var utType: UTType {
|
|
||||||
switch self {
|
|
||||||
case .jpeg, .jpg: .jpeg
|
|
||||||
case .png: .png
|
|
||||||
case .gif: .gif
|
|
||||||
case .webp: .webP
|
|
||||||
case .aac: .aac
|
|
||||||
case .m4a: .m4a
|
|
||||||
case .mp4Audio: .mpeg4Audio
|
|
||||||
case .mp3, .mpeg: .mp3
|
|
||||||
case .wav, .xWav: .wav
|
|
||||||
case .ogg: .ogg
|
|
||||||
case .pdf: .pdf
|
|
||||||
case .octetStream: .data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var category: Category {
|
|
||||||
switch self {
|
|
||||||
case .jpeg, .jpg, .png, .gif, .webp:
|
|
||||||
return .image
|
|
||||||
case .aac, .m4a, .mp4Audio, .mpeg, .mp3, .wav, .xWav, .ogg:
|
|
||||||
return .audio
|
|
||||||
case .pdf, .octetStream:
|
|
||||||
return .file
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
var mimeString: String {
|
|
||||||
switch self {
|
|
||||||
case .jpeg, .jpg: "image/jpeg"
|
|
||||||
case .png: "image/png"
|
|
||||||
case .gif: "image/gif"
|
|
||||||
case .webp: "image/webp"
|
|
||||||
case .mp4Audio: "audio/mp4"
|
|
||||||
case .m4a: "audio/m4a"
|
|
||||||
case .aac: "audio/aac"
|
|
||||||
case .mpeg: "audio/mpeg"
|
|
||||||
case .mp3: "audio/mp3"
|
|
||||||
case .wav: "audio/wav"
|
|
||||||
case .xWav: "audio/x-wav"
|
|
||||||
case .ogg: "audio/ogg"
|
|
||||||
case .pdf: "application/pdf"
|
|
||||||
case .octetStream: "application/octet-stream"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var defaultExtension: String {
|
|
||||||
switch self {
|
|
||||||
case .jpeg, .jpg: "jpg"
|
|
||||||
case .png: "png"
|
|
||||||
case .webp: "webp"
|
|
||||||
case .gif: "gif"
|
|
||||||
case .mp4Audio, .m4a, .aac: "m4a"
|
|
||||||
case .mpeg, .mp3: "mp3"
|
|
||||||
case .wav, .xWav: "wav"
|
|
||||||
case .ogg: "ogg"
|
|
||||||
case .pdf: "pdf"
|
|
||||||
case .octetStream: "bin"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static var allowed: Set<MimeType> = [
|
|
||||||
.jpeg, .jpg, .png, .gif, .webp,
|
|
||||||
.mp4Audio, .m4a, .aac, .mpeg, .mp3,
|
|
||||||
.wav, .xWav, .ogg,
|
|
||||||
.pdf, .octetStream
|
|
||||||
]
|
|
||||||
|
|
||||||
var isAllowed: Bool {
|
|
||||||
Self.allowed.contains(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Byte signature validation
|
|
||||||
func matches(data: Data) -> Bool {
|
|
||||||
guard !data.isEmpty else { return false }
|
|
||||||
|
|
||||||
// Generic type → skip validation
|
|
||||||
if self == .octetStream { return true }
|
|
||||||
|
|
||||||
switch self {
|
|
||||||
case .jpeg, .jpg:
|
|
||||||
return data.count >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF
|
|
||||||
|
|
||||||
case .png:
|
|
||||||
return data.count >= 8 &&
|
|
||||||
data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 &&
|
|
||||||
data[4] == 0x0D && data[5] == 0x0A && data[6] == 0x1A && data[7] == 0x0A
|
|
||||||
|
|
||||||
case .gif:
|
|
||||||
return data.count >= 6 && data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 &&
|
|
||||||
data[3] == 0x38 && (data[4] == 0x37 || data[4] == 0x39) && data[5] == 0x61
|
|
||||||
|
|
||||||
case .webp:
|
|
||||||
return data.count >= 12 &&
|
|
||||||
data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 &&
|
|
||||||
data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50
|
|
||||||
|
|
||||||
case .m4a, .mp4Audio, .aac:
|
|
||||||
// AVAudioRecorder output varies by platform - be lenient
|
|
||||||
// Security: size already capped + sandboxed execution
|
|
||||||
return data.count > 100
|
|
||||||
|
|
||||||
case .mpeg, .mp3:
|
|
||||||
if data.count >= 3 && data[0] == 0x49 && data[1] == 0x44 && data[2] == 0x33 {
|
|
||||||
return true // ID3 header
|
|
||||||
}
|
|
||||||
return data.count >= 2 && data[0] == 0xFF && (data[1] & 0xE0) == 0xE0
|
|
||||||
|
|
||||||
case .wav, .xWav:
|
|
||||||
return data.count >= 12 &&
|
|
||||||
data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 &&
|
|
||||||
data[8] == 0x57 && data[9] == 0x41 && data[10] == 0x56 && data[11] == 0x45
|
|
||||||
|
|
||||||
case .ogg:
|
|
||||||
return data.count >= 4 &&
|
|
||||||
data[0] == 0x4F && data[1] == 0x67 && data[2] == 0x67 && data[3] == 0x53
|
|
||||||
|
|
||||||
case .pdf:
|
|
||||||
return data.count >= 4 &&
|
|
||||||
data[0] == 0x25 && data[1] == 0x50 && data[2] == 0x44 && data[3] == 0x46
|
|
||||||
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Convenience Initializers
|
|
||||||
|
|
||||||
init?(_ mimeString: String?) {
|
|
||||||
guard let mimeString else { return nil }
|
|
||||||
|
|
||||||
let normalized = mimeString.lowercased()
|
|
||||||
|
|
||||||
// Direct match with our canonical list
|
|
||||||
if let match = MimeType.allCases.first(where: { $0.mimeString == normalized }) {
|
|
||||||
self = match
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Let UTType normalize aliases like "image/jpg", "audio/x-wav", etc.
|
|
||||||
if let type = UTType(mimeType: normalized),
|
|
||||||
let match = MimeType.allCases.first(where: { type.conforms(to: $0.utType) }) {
|
|
||||||
self = match
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension MimeType {
|
|
||||||
enum Category: String {
|
|
||||||
case audio, image, file
|
|
||||||
|
|
||||||
/// Ends with a space
|
|
||||||
var messagePrefix: String {
|
|
||||||
switch self {
|
|
||||||
case .audio: "[voice] "
|
|
||||||
case .image: "[image] "
|
|
||||||
case .file: "[file] "
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var mediaDir: String {
|
|
||||||
switch self {
|
|
||||||
case .audio: "voicenotes"
|
|
||||||
case .image: "images"
|
|
||||||
case .file: "files"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import BitFoundation
|
|
||||||
|
|
||||||
/// Result of command processing
|
/// Result of command processing
|
||||||
enum CommandResult {
|
enum CommandResult {
|
||||||
@@ -16,54 +15,15 @@ enum CommandResult {
|
|||||||
case handled // Command handled, no message needed
|
case handled // Command handled, no message needed
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simple struct for geo participant info used by CommandProcessor
|
|
||||||
struct CommandGeoParticipant {
|
|
||||||
let id: String // pubkey hex (lowercased)
|
|
||||||
let displayName: String
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Protocol defining what CommandProcessor needs from its context.
|
|
||||||
/// This breaks the circular dependency between CommandProcessor and ChatViewModel.
|
|
||||||
@MainActor
|
|
||||||
protocol CommandContextProvider: AnyObject {
|
|
||||||
// MARK: - State Properties
|
|
||||||
var nickname: String { get }
|
|
||||||
var selectedPrivateChatPeer: PeerID? { get }
|
|
||||||
var blockedUsers: Set<String> { get }
|
|
||||||
var privateChats: [PeerID: [BitchatMessage]] { get set }
|
|
||||||
var idBridge: NostrIdentityBridge { get }
|
|
||||||
|
|
||||||
// MARK: - Peer Lookup
|
|
||||||
func getPeerIDForNickname(_ nickname: String) -> PeerID?
|
|
||||||
func getVisibleGeoParticipants() -> [CommandGeoParticipant]
|
|
||||||
func nostrPubkeyForDisplayName(_ displayName: String) -> String?
|
|
||||||
|
|
||||||
// MARK: - Chat Actions
|
|
||||||
func startPrivateChat(with peerID: PeerID)
|
|
||||||
func sendPrivateMessage(_ content: String, to peerID: PeerID)
|
|
||||||
func clearCurrentPublicTimeline()
|
|
||||||
func sendPublicRaw(_ content: String)
|
|
||||||
|
|
||||||
// MARK: - System Messages
|
|
||||||
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID)
|
|
||||||
func addPublicSystemMessage(_ content: String)
|
|
||||||
|
|
||||||
// MARK: - Favorites
|
|
||||||
func toggleFavorite(peerID: PeerID)
|
|
||||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Processes chat commands in a focused, efficient way
|
/// Processes chat commands in a focused, efficient way
|
||||||
@MainActor
|
@MainActor
|
||||||
final class CommandProcessor {
|
class CommandProcessor {
|
||||||
weak var contextProvider: CommandContextProvider?
|
weak var chatViewModel: ChatViewModel?
|
||||||
weak var meshService: Transport?
|
weak var meshService: Transport?
|
||||||
private let identityManager: SecureIdentityStateManagerProtocol
|
|
||||||
|
init(chatViewModel: ChatViewModel? = nil, meshService: Transport? = nil) {
|
||||||
init(contextProvider: CommandContextProvider? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
|
self.chatViewModel = chatViewModel
|
||||||
self.contextProvider = contextProvider
|
|
||||||
self.meshService = meshService
|
self.meshService = meshService
|
||||||
self.identityManager = identityManager
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Process a command string
|
/// Process a command string
|
||||||
@@ -80,7 +40,7 @@ final class CommandProcessor {
|
|||||||
case .location: return true
|
case .location: return true
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
let inGeoDM = contextProvider?.selectedPrivateChatPeer?.isGeoDM == true
|
let inGeoDM = (chatViewModel?.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
|
||||||
|
|
||||||
switch cmd {
|
switch cmd {
|
||||||
case "/m", "/msg":
|
case "/m", "/msg":
|
||||||
@@ -90,9 +50,9 @@ final class CommandProcessor {
|
|||||||
case "/clear":
|
case "/clear":
|
||||||
return handleClear()
|
return handleClear()
|
||||||
case "/hug":
|
case "/hug":
|
||||||
return handleEmote(args, command: "hug", action: "hugs", emoji: "🫂")
|
return handleEmote(args, action: "hugs", emoji: "🫂")
|
||||||
case "/slap":
|
case "/slap":
|
||||||
return handleEmote(args, command: "slap", action: "slaps", emoji: "🐟", suffix: " around a bit with a large trout")
|
return handleEmote(args, action: "slaps", emoji: "🐟", suffix: " around a bit with a large trout")
|
||||||
case "/block":
|
case "/block":
|
||||||
return handleBlock(args)
|
return handleBlock(args)
|
||||||
case "/unblock":
|
case "/unblock":
|
||||||
@@ -103,11 +63,14 @@ final class CommandProcessor {
|
|||||||
case "/unfav":
|
case "/unfav":
|
||||||
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
||||||
return handleFavorite(args, add: false)
|
return handleFavorite(args, add: false)
|
||||||
|
//
|
||||||
|
case "/help", "/h":
|
||||||
|
return .error(message: "unknown command: \(cmd)")
|
||||||
default:
|
default:
|
||||||
return .error(message: "unknown command: \(cmd)")
|
return .error(message: "unknown command: \(cmd)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Command Handlers
|
// MARK: - Command Handlers
|
||||||
|
|
||||||
private func handleMessage(_ args: String) -> CommandResult {
|
private func handleMessage(_ args: String) -> CommandResult {
|
||||||
@@ -119,15 +82,15 @@ final class CommandProcessor {
|
|||||||
let targetName = String(parts[0])
|
let targetName = String(parts[0])
|
||||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||||
|
|
||||||
guard let peerID = contextProvider?.getPeerIDForNickname(nickname) else {
|
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname) else {
|
||||||
return .error(message: "'\(nickname)' not found")
|
return .error(message: "'\(nickname)' not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
contextProvider?.startPrivateChat(with: peerID)
|
chatViewModel?.startPrivateChat(with: peerID)
|
||||||
|
|
||||||
if parts.count > 1 {
|
if parts.count > 1 {
|
||||||
let message = String(parts[1])
|
let message = String(parts[1])
|
||||||
contextProvider?.sendPrivateMessage(message, to: peerID)
|
chatViewModel?.sendPrivateMessage(message, to: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return .success(message: "started private chat with \(nickname)")
|
return .success(message: "started private chat with \(nickname)")
|
||||||
@@ -138,9 +101,9 @@ final class CommandProcessor {
|
|||||||
switch LocationChannelManager.shared.selectedChannel {
|
switch LocationChannelManager.shared.selectedChannel {
|
||||||
case .location(let ch):
|
case .location(let ch):
|
||||||
// Geohash context: show visible geohash participants (exclude self)
|
// Geohash context: show visible geohash participants (exclude self)
|
||||||
guard let vm = contextProvider else { return .success(message: "nobody around") }
|
guard let vm = chatViewModel else { return .success(message: "nobody around") }
|
||||||
let myHex = (try? vm.idBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
|
let myHex = (try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
|
||||||
let people = vm.getVisibleGeoParticipants().filter { person in
|
let people = vm.visibleGeohashPeople().filter { person in
|
||||||
if let me = myHex { return person.id.lowercased() != me }
|
if let me = myHex { return person.id.lowercased() != me }
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -158,35 +121,35 @@ final class CommandProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleClear() -> CommandResult {
|
private func handleClear() -> CommandResult {
|
||||||
if let peerID = contextProvider?.selectedPrivateChatPeer {
|
if let peerID = chatViewModel?.selectedPrivateChatPeer {
|
||||||
contextProvider?.privateChats[peerID]?.removeAll()
|
chatViewModel?.privateChats[peerID]?.removeAll()
|
||||||
} else {
|
} else {
|
||||||
contextProvider?.clearCurrentPublicTimeline()
|
chatViewModel?.clearCurrentPublicTimeline()
|
||||||
}
|
}
|
||||||
return .handled
|
return .handled
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleEmote(_ args: String, command: String, action: String, emoji: String, suffix: String = "") -> CommandResult {
|
private func handleEmote(_ args: String, action: String, emoji: String, suffix: String = "") -> CommandResult {
|
||||||
let targetName = args.trimmed
|
let targetName = args.trimmingCharacters(in: .whitespaces)
|
||||||
guard !targetName.isEmpty else {
|
guard !targetName.isEmpty else {
|
||||||
return .error(message: "usage: /\(command) <nickname>")
|
return .error(message: "usage: /\(action) <nickname>")
|
||||||
}
|
}
|
||||||
|
|
||||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||||
|
|
||||||
guard let targetPeerID = contextProvider?.getPeerIDForNickname(nickname),
|
guard let targetPeerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||||
let myNickname = contextProvider?.nickname else {
|
let myNickname = chatViewModel?.nickname else {
|
||||||
return .error(message: "cannot \(command) \(nickname): not found")
|
return .error(message: "cannot \(action) \(nickname): not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
let emoteContent = "* \(emoji) \(myNickname) \(action) \(nickname)\(suffix) *"
|
let emoteContent = "* \(emoji) \(myNickname) \(action) \(nickname)\(suffix) *"
|
||||||
|
|
||||||
if contextProvider?.selectedPrivateChatPeer != nil {
|
if chatViewModel?.selectedPrivateChatPeer != nil {
|
||||||
// In private chat
|
// In private chat
|
||||||
if let peerNickname = meshService?.peerNickname(peerID: targetPeerID) {
|
if let peerNickname = meshService?.peerNickname(peerID: targetPeerID) {
|
||||||
let personalMessage = "* \(emoji) \(myNickname) \(action) you\(suffix) *"
|
let personalMessage = "* \(emoji) \(myNickname) \(action) you\(suffix) *"
|
||||||
meshService?.sendPrivateMessage(personalMessage, to: targetPeerID,
|
meshService?.sendPrivateMessage(personalMessage, to: targetPeerID,
|
||||||
recipientNickname: peerNickname,
|
recipientNickname: peerNickname,
|
||||||
messageID: UUID().uuidString)
|
messageID: UUID().uuidString)
|
||||||
// Also add a local system message so the sender sees a natural-language confirmation
|
// Also add a local system message so the sender sees a natural-language confirmation
|
||||||
let pastAction: String = {
|
let pastAction: String = {
|
||||||
@@ -197,24 +160,24 @@ final class CommandProcessor {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
let localText = "\(emoji) you \(pastAction) \(nickname)\(suffix)"
|
let localText = "\(emoji) you \(pastAction) \(nickname)\(suffix)"
|
||||||
contextProvider?.addLocalPrivateSystemMessage(localText, to: targetPeerID)
|
chatViewModel?.addLocalPrivateSystemMessage(localText, to: targetPeerID)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// In public chat: send to active public channel (mesh or geohash)
|
// In public chat: send to active public channel (mesh or geohash)
|
||||||
contextProvider?.sendPublicRaw(emoteContent)
|
chatViewModel?.sendPublicRaw(emoteContent)
|
||||||
let publicEcho = "\(emoji) \(myNickname) \(action) \(nickname)\(suffix)"
|
let publicEcho = "\(emoji) \(myNickname) \(action) \(nickname)\(suffix)"
|
||||||
contextProvider?.addPublicSystemMessage(publicEcho)
|
chatViewModel?.addPublicSystemMessage(publicEcho)
|
||||||
}
|
}
|
||||||
|
|
||||||
return .handled
|
return .handled
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleBlock(_ args: String) -> CommandResult {
|
private func handleBlock(_ args: String) -> CommandResult {
|
||||||
let targetName = args.trimmed
|
let targetName = args.trimmingCharacters(in: .whitespaces)
|
||||||
|
|
||||||
if targetName.isEmpty {
|
if targetName.isEmpty {
|
||||||
// List blocked users (mesh) and geohash (Nostr) blocks
|
// List blocked users (mesh) and geohash (Nostr) blocks
|
||||||
let meshBlocked = contextProvider?.blockedUsers ?? []
|
let meshBlocked = chatViewModel?.blockedUsers ?? []
|
||||||
var blockedNicknames: [String] = []
|
var blockedNicknames: [String] = []
|
||||||
if let peers = meshService?.getPeerNicknames() {
|
if let peers = meshService?.getPeerNicknames() {
|
||||||
for (peerID, nickname) in peers {
|
for (peerID, nickname) in peers {
|
||||||
@@ -226,10 +189,10 @@ final class CommandProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Geohash blocked names (prefer visible display names; fallback to #suffix)
|
// Geohash blocked names (prefer visible display names; fallback to #suffix)
|
||||||
let geoBlocked = Array(identityManager.getBlockedNostrPubkeys())
|
let geoBlocked = Array(SecureIdentityStateManager.shared.getBlockedNostrPubkeys())
|
||||||
var geoNames: [String] = []
|
var geoNames: [String] = []
|
||||||
if let vm = contextProvider {
|
if let vm = chatViewModel {
|
||||||
let visible = vm.getVisibleGeoParticipants()
|
let visible = vm.visibleGeohashPeople()
|
||||||
let visibleIndex = Dictionary(uniqueKeysWithValues: visible.map { ($0.id.lowercased(), $0.displayName) })
|
let visibleIndex = Dictionary(uniqueKeysWithValues: visible.map { ($0.id.lowercased(), $0.displayName) })
|
||||||
for pk in geoBlocked {
|
for pk in geoBlocked {
|
||||||
if let name = visibleIndex[pk.lowercased()] {
|
if let name = visibleIndex[pk.lowercased()] {
|
||||||
@@ -248,16 +211,16 @@ final class CommandProcessor {
|
|||||||
|
|
||||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||||
|
|
||||||
if let peerID = contextProvider?.getPeerIDForNickname(nickname),
|
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||||
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||||
if identityManager.isBlocked(fingerprint: fingerprint) {
|
if SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) {
|
||||||
return .success(message: "\(nickname) is already blocked")
|
return .success(message: "\(nickname) is already blocked")
|
||||||
}
|
}
|
||||||
// Block the user (mesh/noise identity)
|
// Block the user (mesh/noise identity)
|
||||||
if var identity = identityManager.getSocialIdentity(for: fingerprint) {
|
if var identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint) {
|
||||||
identity.isBlocked = true
|
identity.isBlocked = true
|
||||||
identity.isFavorite = false
|
identity.isFavorite = false
|
||||||
identityManager.updateSocialIdentity(identity)
|
SecureIdentityStateManager.shared.updateSocialIdentity(identity)
|
||||||
} else {
|
} else {
|
||||||
let blockedIdentity = SocialIdentity(
|
let blockedIdentity = SocialIdentity(
|
||||||
fingerprint: fingerprint,
|
fingerprint: fingerprint,
|
||||||
@@ -268,16 +231,16 @@ final class CommandProcessor {
|
|||||||
isBlocked: true,
|
isBlocked: true,
|
||||||
notes: nil
|
notes: nil
|
||||||
)
|
)
|
||||||
identityManager.updateSocialIdentity(blockedIdentity)
|
SecureIdentityStateManager.shared.updateSocialIdentity(blockedIdentity)
|
||||||
}
|
}
|
||||||
return .success(message: "blocked \(nickname). you will no longer receive messages from them")
|
return .success(message: "blocked \(nickname). you will no longer receive messages from them")
|
||||||
}
|
}
|
||||||
// Mesh lookup failed; try geohash (Nostr) participant by display name
|
// Mesh lookup failed; try geohash (Nostr) participant by display name
|
||||||
if let pub = contextProvider?.nostrPubkeyForDisplayName(nickname) {
|
if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) {
|
||||||
if identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {
|
if SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: pub) {
|
||||||
return .success(message: "\(nickname) is already blocked")
|
return .success(message: "\(nickname) is already blocked")
|
||||||
}
|
}
|
||||||
identityManager.setNostrBlocked(pub, isBlocked: true)
|
SecureIdentityStateManager.shared.setNostrBlocked(pub, isBlocked: true)
|
||||||
return .success(message: "blocked \(nickname) in geohash chats")
|
return .success(message: "blocked \(nickname) in geohash chats")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,42 +248,42 @@ final class CommandProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleUnblock(_ args: String) -> CommandResult {
|
private func handleUnblock(_ args: String) -> CommandResult {
|
||||||
let targetName = args.trimmed
|
let targetName = args.trimmingCharacters(in: .whitespaces)
|
||||||
guard !targetName.isEmpty else {
|
guard !targetName.isEmpty else {
|
||||||
return .error(message: "usage: /unblock <nickname>")
|
return .error(message: "usage: /unblock <nickname>")
|
||||||
}
|
}
|
||||||
|
|
||||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||||
|
|
||||||
if let peerID = contextProvider?.getPeerIDForNickname(nickname),
|
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||||
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||||
if !identityManager.isBlocked(fingerprint: fingerprint) {
|
if !SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) {
|
||||||
return .success(message: "\(nickname) is not blocked")
|
return .success(message: "\(nickname) is not blocked")
|
||||||
}
|
}
|
||||||
identityManager.setBlocked(fingerprint, isBlocked: false)
|
SecureIdentityStateManager.shared.setBlocked(fingerprint, isBlocked: false)
|
||||||
return .success(message: "unblocked \(nickname)")
|
return .success(message: "unblocked \(nickname)")
|
||||||
}
|
}
|
||||||
// Try geohash unblock
|
// Try geohash unblock
|
||||||
if let pub = contextProvider?.nostrPubkeyForDisplayName(nickname) {
|
if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) {
|
||||||
if !identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {
|
if !SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: pub) {
|
||||||
return .success(message: "\(nickname) is not blocked")
|
return .success(message: "\(nickname) is not blocked")
|
||||||
}
|
}
|
||||||
identityManager.setNostrBlocked(pub, isBlocked: false)
|
SecureIdentityStateManager.shared.setNostrBlocked(pub, isBlocked: false)
|
||||||
return .success(message: "unblocked \(nickname) in geohash chats")
|
return .success(message: "unblocked \(nickname) in geohash chats")
|
||||||
}
|
}
|
||||||
return .error(message: "cannot unblock \(nickname): not found")
|
return .error(message: "cannot unblock \(nickname): not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
|
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
|
||||||
let targetName = args.trimmed
|
let targetName = args.trimmingCharacters(in: .whitespaces)
|
||||||
guard !targetName.isEmpty else {
|
guard !targetName.isEmpty else {
|
||||||
return .error(message: "usage: /\(add ? "fav" : "unfav") <nickname>")
|
return .error(message: "usage: /\(add ? "fav" : "unfav") <nickname>")
|
||||||
}
|
}
|
||||||
|
|
||||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||||
|
|
||||||
guard let peerID = contextProvider?.getPeerIDForNickname(nickname),
|
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||||
let noisePublicKey = Data(hexString: peerID.id) else {
|
let noisePublicKey = Data(hexString: peerID) else {
|
||||||
return .error(message: "can't find peer: \(nickname)")
|
return .error(message: "can't find peer: \(nickname)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,18 +295,33 @@ final class CommandProcessor {
|
|||||||
peerNickname: nickname
|
peerNickname: nickname
|
||||||
)
|
)
|
||||||
|
|
||||||
contextProvider?.toggleFavorite(peerID: peerID)
|
chatViewModel?.toggleFavorite(peerID: peerID)
|
||||||
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: true)
|
chatViewModel?.sendFavoriteNotification(to: peerID, isFavorite: true)
|
||||||
|
|
||||||
return .success(message: "added \(nickname) to favorites")
|
return .success(message: "added \(nickname) to favorites")
|
||||||
} else {
|
} else {
|
||||||
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey)
|
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey)
|
||||||
|
|
||||||
contextProvider?.toggleFavorite(peerID: peerID)
|
chatViewModel?.toggleFavorite(peerID: peerID)
|
||||||
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: false)
|
chatViewModel?.sendFavoriteNotification(to: peerID, isFavorite: false)
|
||||||
|
|
||||||
return .success(message: "removed \(nickname) from favorites")
|
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,11 +1,9 @@
|
|||||||
import BitLogger
|
|
||||||
import BitFoundation
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
/// Manages persistent favorite relationships between peers
|
/// Manages persistent favorite relationships between peers
|
||||||
@MainActor
|
@MainActor
|
||||||
final class FavoritesPersistenceService: ObservableObject {
|
class FavoritesPersistenceService: ObservableObject {
|
||||||
|
|
||||||
struct FavoriteRelationship: Codable {
|
struct FavoriteRelationship: Codable {
|
||||||
let peerNoisePublicKey: Data
|
let peerNoisePublicKey: Data
|
||||||
@@ -15,27 +13,24 @@ final 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"
|
||||||
private let keychain: KeychainManagerProtocol
|
|
||||||
|
|
||||||
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
|
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
|
||||||
@Published private(set) var mutualFavorites: Set<Data> = []
|
@Published private(set) var mutualFavorites: Set<Data> = []
|
||||||
|
|
||||||
|
private let userDefaults = UserDefaults.standard
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
static let shared = FavoritesPersistenceService()
|
static let shared = FavoritesPersistenceService()
|
||||||
|
|
||||||
init(keychain: KeychainManagerProtocol = KeychainManager()) {
|
private init() {
|
||||||
self.keychain = keychain
|
|
||||||
loadFavorites()
|
loadFavorites()
|
||||||
|
|
||||||
// Update mutual favorites when favorites change
|
// Update mutual favorites when favorites change
|
||||||
@@ -52,7 +47,8 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
peerNostrPublicKey: String? = nil,
|
peerNostrPublicKey: String? = nil,
|
||||||
peerNickname: String
|
peerNickname: String
|
||||||
) {
|
) {
|
||||||
SecureLogger.info("⭐️ Adding favorite: \(peerNickname) (\(peerNoisePublicKey.hexEncodedString()))", category: .session)
|
SecureLogger.log("⭐️ Adding favorite: \(peerNickname) (\(peerNoisePublicKey.hexEncodedString()))",
|
||||||
|
category: SecureLogger.session, level: .info)
|
||||||
|
|
||||||
let existing = favorites[peerNoisePublicKey]
|
let existing = favorites[peerNoisePublicKey]
|
||||||
|
|
||||||
@@ -68,7 +64,8 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
|
|
||||||
// Log if this creates a mutual favorite
|
// Log if this creates a mutual favorite
|
||||||
if relationship.isMutual {
|
if relationship.isMutual {
|
||||||
SecureLogger.info("💕 Mutual favorite relationship established with \(peerNickname)!", category: .session)
|
SecureLogger.log("💕 Mutual favorite relationship established with \(peerNickname)!",
|
||||||
|
category: SecureLogger.session, level: .info)
|
||||||
}
|
}
|
||||||
|
|
||||||
favorites[peerNoisePublicKey] = relationship
|
favorites[peerNoisePublicKey] = relationship
|
||||||
@@ -86,7 +83,8 @@ final 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.info("⭐️ Removing favorite: \(existing.peerNickname) (\(peerNoisePublicKey.hexEncodedString()))", category: .session)
|
SecureLogger.log("⭐️ Removing favorite: \(existing.peerNickname) (\(peerNoisePublicKey.hexEncodedString()))",
|
||||||
|
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 {
|
||||||
@@ -127,7 +125,8 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
let existing = favorites[peerNoisePublicKey]
|
let existing = favorites[peerNoisePublicKey]
|
||||||
let displayName = peerNickname ?? existing?.peerNickname ?? "Unknown"
|
let displayName = peerNickname ?? existing?.peerNickname ?? "Unknown"
|
||||||
|
|
||||||
SecureLogger.info("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us", category: .session)
|
SecureLogger.log("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us",
|
||||||
|
category: SecureLogger.session, level: .info)
|
||||||
|
|
||||||
let relationship = FavoriteRelationship(
|
let relationship = FavoriteRelationship(
|
||||||
peerNoisePublicKey: peerNoisePublicKey,
|
peerNoisePublicKey: peerNoisePublicKey,
|
||||||
@@ -148,7 +147,8 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
|
|
||||||
// Check if this creates a mutual favorite
|
// Check if this creates a mutual favorite
|
||||||
if relationship.isMutual {
|
if relationship.isMutual {
|
||||||
SecureLogger.info("💕 Mutual favorite relationship established with \(displayName)!", category: .session)
|
SecureLogger.log("💕 Mutual favorite relationship established with \(displayName)!",
|
||||||
|
category: SecureLogger.session, level: .info)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,24 +179,136 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
|
|
||||||
/// Resolve favorite status by short peer ID (16-hex derived from Noise pubkey)
|
/// Resolve favorite status by short peer ID (16-hex derived from Noise pubkey)
|
||||||
/// Falls back to scanning favorites and matching on derived peer ID.
|
/// Falls back to scanning favorites and matching on derived peer ID.
|
||||||
func getFavoriteStatus(forPeerID peerID: PeerID) -> FavoriteRelationship? {
|
func getFavoriteStatus(forPeerID peerID: String) -> FavoriteRelationship? {
|
||||||
// Quick sanity: peerID should be 16 hex chars (8 bytes)
|
// Quick sanity: peerID should be 16 hex chars (8 bytes)
|
||||||
guard peerID.isShort else { return nil }
|
guard PeerIDResolver.isShortID(peerID) else { return nil }
|
||||||
for (pubkey, rel) in favorites where PeerID(publicKey: pubkey) == peerID {
|
for (pubkey, rel) in favorites {
|
||||||
return rel
|
let derived = PeerIDUtils.derivePeerID(fromPublicKey: pubkey)
|
||||||
|
if derived == peerID { return rel }
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update Nostr public key for a peer
|
||||||
|
func updateNostrPublicKey(for peerNoisePublicKey: Data, nostrPubkey: String) {
|
||||||
|
guard let existing = favorites[peerNoisePublicKey] else { return }
|
||||||
|
|
||||||
|
let updated = FavoriteRelationship(
|
||||||
|
peerNoisePublicKey: existing.peerNoisePublicKey,
|
||||||
|
peerNostrPublicKey: nostrPubkey,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.warning("🧹 Clearing all favorites (panic mode)", category: .session)
|
SecureLogger.log("🧹 Clearing all favorites (panic mode)", category: SecureLogger.session, level: .warning)
|
||||||
|
|
||||||
favorites.removeAll()
|
favorites.removeAll()
|
||||||
saveFavorites()
|
saveFavorites()
|
||||||
|
|
||||||
// Delete from keychain directly
|
// Delete from keychain directly
|
||||||
keychain.delete(
|
KeychainHelper.delete(
|
||||||
key: Self.storageKey,
|
key: Self.storageKey,
|
||||||
service: Self.keychainService
|
service: Self.keychainService
|
||||||
)
|
)
|
||||||
@@ -216,23 +328,22 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
let data = try encoder.encode(relationships)
|
let data = try encoder.encode(relationships)
|
||||||
|
|
||||||
// Store in keychain for security
|
// Store in keychain for security
|
||||||
keychain.save(
|
KeychainHelper.save(
|
||||||
key: Self.storageKey,
|
key: Self.storageKey,
|
||||||
data: data,
|
data: data,
|
||||||
service: Self.keychainService,
|
service: Self.keychainService
|
||||||
accessible: nil
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Successfully saved favorites
|
// Successfully saved favorites
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("Failed to save favorites: \(error)", category: .session)
|
SecureLogger.log("Failed to save favorites: \(error)", category: SecureLogger.session, level: .error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadFavorites() {
|
private func loadFavorites() {
|
||||||
// Loading favorites from keychain
|
// Loading favorites from keychain
|
||||||
|
|
||||||
guard let data = keychain.load(
|
guard let data = KeychainHelper.load(
|
||||||
key: Self.storageKey,
|
key: Self.storageKey,
|
||||||
service: Self.keychainService
|
service: Self.keychainService
|
||||||
) else {
|
) else {
|
||||||
@@ -243,12 +354,14 @@ final 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.info("✅ Loaded \(relationships.count) favorite relationships", category: .session)
|
SecureLogger.log("✅ Loaded \(relationships.count) favorite relationships",
|
||||||
|
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.warning("⚠️ No Nostr public key stored for '\(relationship.peerNickname)'", category: .session)
|
SecureLogger.log("⚠️ No Nostr public key stored for '\(relationship.peerNickname)'",
|
||||||
|
category: SecureLogger.session, level: .warning)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,7 +372,8 @@ final 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.warning("⚠️ Duplicate favorite found for public key \(relationship.peerNoisePublicKey.hexEncodedString()) - nicknames: '\(existing.peerNickname)' vs '\(relationship.peerNickname)'", category: .session)
|
SecureLogger.log("⚠️ Duplicate favorite found for public key \(relationship.peerNoisePublicKey.hexEncodedString()) - nicknames: '\(existing.peerNickname)' vs '\(relationship.peerNickname)'",
|
||||||
|
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 ||
|
||||||
@@ -300,7 +414,7 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
// Log loaded relationships
|
// Log loaded relationships
|
||||||
// Loaded relationships successfully
|
// Loaded relationships successfully
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("Failed to load favorites: \(error)", category: .session)
|
SecureLogger.log("Failed to load favorites: \(error)", category: SecureLogger.session, level: .error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
//
|
|
||||||
// GeohashParticipantTracker.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Tracks participants in geohash-based location channels.
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Represents a participant in a geohash channel
|
|
||||||
public struct GeoPerson: Identifiable, Equatable, Sendable {
|
|
||||||
public let id: String // pubkey hex (lowercased)
|
|
||||||
public let displayName: String
|
|
||||||
public let lastSeen: Date
|
|
||||||
|
|
||||||
public init(id: String, displayName: String, lastSeen: Date) {
|
|
||||||
self.id = id
|
|
||||||
self.displayName = displayName
|
|
||||||
self.lastSeen = lastSeen
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Protocol for resolving display names and checking block status
|
|
||||||
@MainActor
|
|
||||||
public protocol GeohashParticipantContext: AnyObject {
|
|
||||||
/// Returns display name for a Nostr pubkey (e.g., "alice#a1b2" or "anon#c3d4")
|
|
||||||
func displayNameForPubkey(_ pubkeyHex: String) -> String
|
|
||||||
/// Returns true if the pubkey is blocked
|
|
||||||
func isBlocked(_ pubkeyHexLowercased: String) -> Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tracks participants across multiple geohash channels
|
|
||||||
@MainActor
|
|
||||||
public final class GeohashParticipantTracker: ObservableObject {
|
|
||||||
|
|
||||||
/// Activity cutoff duration (defaults to 5 minutes)
|
|
||||||
public let activityCutoff: TimeInterval
|
|
||||||
|
|
||||||
/// Per-geohash participant map: [geohash: [pubkeyHex: lastSeen]]
|
|
||||||
private var participants: [String: [String: Date]] = [:]
|
|
||||||
|
|
||||||
/// Currently visible people for the active geohash
|
|
||||||
@Published public private(set) var visiblePeople: [GeoPerson] = []
|
|
||||||
|
|
||||||
/// The currently active geohash (if any)
|
|
||||||
private var activeGeohash: String?
|
|
||||||
|
|
||||||
/// Context for display name resolution and block checking
|
|
||||||
private weak var context: GeohashParticipantContext?
|
|
||||||
|
|
||||||
/// Timer for periodic refresh
|
|
||||||
private var refreshTimer: Timer?
|
|
||||||
|
|
||||||
public init(activityCutoff: TimeInterval = -300) { // default 5 minutes
|
|
||||||
self.activityCutoff = activityCutoff
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Configure with a context provider
|
|
||||||
public func configure(context: GeohashParticipantContext) {
|
|
||||||
self.context = context
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the currently active geohash
|
|
||||||
public func setActiveGeohash(_ geohash: String?) {
|
|
||||||
activeGeohash = geohash
|
|
||||||
if geohash == nil {
|
|
||||||
visiblePeople = []
|
|
||||||
} else {
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record activity from a participant in the current active geohash
|
|
||||||
public func recordParticipant(pubkeyHex: String) {
|
|
||||||
guard let gh = activeGeohash else { return }
|
|
||||||
recordParticipant(pubkeyHex: pubkeyHex, geohash: gh)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record activity from a participant in a specific geohash
|
|
||||||
public func recordParticipant(pubkeyHex: String, geohash: String) {
|
|
||||||
let key = pubkeyHex.lowercased()
|
|
||||||
var map = participants[geohash] ?? [:]
|
|
||||||
map[key] = Date()
|
|
||||||
participants[geohash] = map
|
|
||||||
|
|
||||||
// Always notify observers that state has changed so counts in UI update
|
|
||||||
objectWillChange.send()
|
|
||||||
|
|
||||||
// Only refresh visible list if this geohash is currently active
|
|
||||||
if activeGeohash == geohash {
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove a participant from all geohashes (used when blocking)
|
|
||||||
public func removeParticipant(pubkeyHex: String) {
|
|
||||||
let key = pubkeyHex.lowercased()
|
|
||||||
for (gh, var map) in participants {
|
|
||||||
map.removeValue(forKey: key)
|
|
||||||
participants[gh] = map
|
|
||||||
}
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get participant count for a specific geohash
|
|
||||||
public func participantCount(for geohash: String) -> Int {
|
|
||||||
let cutoff = Date().addingTimeInterval(activityCutoff)
|
|
||||||
let map = participants[geohash] ?? [:]
|
|
||||||
return map.values.filter { $0 >= cutoff }.count
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the visible people list for the active geohash (read-only query)
|
|
||||||
public func getVisiblePeople() -> [GeoPerson] {
|
|
||||||
guard let gh = activeGeohash, let context = context else { return [] }
|
|
||||||
let cutoff = Date().addingTimeInterval(activityCutoff)
|
|
||||||
let map = (participants[gh] ?? [:])
|
|
||||||
.filter { $0.value >= cutoff }
|
|
||||||
.filter { !context.isBlocked($0.key) }
|
|
||||||
|
|
||||||
return map
|
|
||||||
.map { (pub, seen) in
|
|
||||||
GeoPerson(id: pub, displayName: context.displayNameForPubkey(pub), lastSeen: seen)
|
|
||||||
}
|
|
||||||
.sorted { $0.lastSeen > $1.lastSeen }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Refresh the visible people list
|
|
||||||
public func refresh() {
|
|
||||||
visiblePeople = getVisiblePeople()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start the periodic refresh timer
|
|
||||||
public func startRefreshTimer(interval: TimeInterval = 30.0) {
|
|
||||||
stopRefreshTimer()
|
|
||||||
refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
|
||||||
Task { @MainActor in
|
|
||||||
self?.refresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop the periodic refresh timer
|
|
||||||
public func stopRefreshTimer() {
|
|
||||||
refreshTimer?.invalidate()
|
|
||||||
refreshTimer = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear all participant data
|
|
||||||
public func clear() {
|
|
||||||
participants.removeAll()
|
|
||||||
visiblePeople = []
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear participant data for a specific geohash
|
|
||||||
public func clear(geohash: String) {
|
|
||||||
participants.removeValue(forKey: geohash)
|
|
||||||
if activeGeohash == geohash {
|
|
||||||
visiblePeople = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
//
|
|
||||||
// GeohashPresenceService.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Manages the broadcasting of ephemeral presence heartbeats (Kind 20001)
|
|
||||||
// to geohash location channels.
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import Combine
|
|
||||||
import BitLogger
|
|
||||||
import Tor
|
|
||||||
|
|
||||||
protocol GeohashPresenceTimerProtocol: AnyObject {
|
|
||||||
var isValid: Bool { get }
|
|
||||||
func invalidate()
|
|
||||||
}
|
|
||||||
|
|
||||||
private final class GeohashPresenceTimerAdapter: GeohashPresenceTimerProtocol {
|
|
||||||
private let base: Timer
|
|
||||||
|
|
||||||
init(base: Timer) {
|
|
||||||
self.base = base
|
|
||||||
}
|
|
||||||
|
|
||||||
var isValid: Bool { base.isValid }
|
|
||||||
|
|
||||||
func invalidate() {
|
|
||||||
base.invalidate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Service that coordinates the broadcasting of presence heartbeats.
|
|
||||||
///
|
|
||||||
/// Behavior:
|
|
||||||
/// - Monitors location changes via LocationStateManager
|
|
||||||
/// - Broadcasts Kind 20001 events to low-precision geohash channels
|
|
||||||
/// - Uses randomized timing (40-80s loop) and decorrelated bursts
|
|
||||||
/// - Respects privacy by NOT broadcasting to Neighborhood/Block/Building levels
|
|
||||||
@MainActor
|
|
||||||
final class GeohashPresenceService: ObservableObject {
|
|
||||||
static let shared = GeohashPresenceService()
|
|
||||||
|
|
||||||
private var subscriptions = Set<AnyCancellable>()
|
|
||||||
private var heartbeatTimer: GeohashPresenceTimerProtocol?
|
|
||||||
private let availableChannelsProvider: () -> [GeohashChannel]
|
|
||||||
private let locationChanges: AnyPublisher<[GeohashChannel], Never>
|
|
||||||
private let torReadyPublisher: AnyPublisher<Void, Never>
|
|
||||||
private let torIsReady: () -> Bool
|
|
||||||
private let torIsForeground: () -> Bool
|
|
||||||
private let deriveIdentity: (String) throws -> NostrIdentity
|
|
||||||
private let relayLookup: (String, Int) -> [String]
|
|
||||||
private let relaySender: (NostrEvent, [String]) -> Void
|
|
||||||
private let sleeper: (UInt64) async -> Void
|
|
||||||
private let scheduleTimer: (TimeInterval, @escaping () -> Void) -> GeohashPresenceTimerProtocol
|
|
||||||
|
|
||||||
// MARK: - Constants
|
|
||||||
|
|
||||||
// Loop interval range in seconds
|
|
||||||
private let loopMinInterval: TimeInterval
|
|
||||||
private let loopMaxInterval: TimeInterval
|
|
||||||
|
|
||||||
// Per-broadcast decorrelation delay range in seconds
|
|
||||||
private let burstMinDelay: TimeInterval
|
|
||||||
private let burstMaxDelay: TimeInterval
|
|
||||||
|
|
||||||
// Privacy: Only broadcast to these levels
|
|
||||||
private let allowedPrecisions: Set<Int> = [
|
|
||||||
GeohashChannelLevel.region.precision, // 2
|
|
||||||
GeohashChannelLevel.province.precision, // 4
|
|
||||||
GeohashChannelLevel.city.precision // 5
|
|
||||||
]
|
|
||||||
|
|
||||||
private init() {
|
|
||||||
let idBridge = NostrIdentityBridge()
|
|
||||||
self.availableChannelsProvider = { LocationStateManager.shared.availableChannels }
|
|
||||||
self.locationChanges = LocationStateManager.shared.$availableChannels.eraseToAnyPublisher()
|
|
||||||
self.torReadyPublisher = NotificationCenter.default.publisher(for: .TorDidBecomeReady)
|
|
||||||
.map { _ in () }
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
self.torIsReady = { TorManager.shared.isReady }
|
|
||||||
self.torIsForeground = { TorManager.shared.isForeground() }
|
|
||||||
self.deriveIdentity = { try idBridge.deriveIdentity(forGeohash: $0) }
|
|
||||||
self.relayLookup = { geohash, count in
|
|
||||||
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
|
|
||||||
}
|
|
||||||
self.relaySender = { event, relays in
|
|
||||||
NostrRelayManager.shared.sendEvent(event, to: relays)
|
|
||||||
}
|
|
||||||
self.sleeper = { nanoseconds in
|
|
||||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
|
||||||
}
|
|
||||||
self.scheduleTimer = { interval, action in
|
|
||||||
GeohashPresenceTimerAdapter(
|
|
||||||
base: Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in
|
|
||||||
action()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
self.loopMinInterval = 40.0
|
|
||||||
self.loopMaxInterval = 80.0
|
|
||||||
self.burstMinDelay = 2.0
|
|
||||||
self.burstMaxDelay = 5.0
|
|
||||||
setupObservers()
|
|
||||||
}
|
|
||||||
|
|
||||||
internal init(
|
|
||||||
availableChannelsProvider: @escaping () -> [GeohashChannel],
|
|
||||||
locationChanges: AnyPublisher<[GeohashChannel], Never>,
|
|
||||||
torReadyPublisher: AnyPublisher<Void, Never>,
|
|
||||||
torIsReady: @escaping () -> Bool,
|
|
||||||
torIsForeground: @escaping () -> Bool,
|
|
||||||
deriveIdentity: @escaping (String) throws -> NostrIdentity,
|
|
||||||
relayLookup: @escaping (String, Int) -> [String],
|
|
||||||
relaySender: @escaping (NostrEvent, [String]) -> Void,
|
|
||||||
sleeper: @escaping (UInt64) async -> Void = { nanoseconds in try? await Task.sleep(nanoseconds: nanoseconds) },
|
|
||||||
scheduleTimer: @escaping (TimeInterval, @escaping () -> Void) -> GeohashPresenceTimerProtocol = { interval, action in
|
|
||||||
GeohashPresenceTimerAdapter(
|
|
||||||
base: Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in
|
|
||||||
action()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
loopMinInterval: TimeInterval = 40.0,
|
|
||||||
loopMaxInterval: TimeInterval = 80.0,
|
|
||||||
burstMinDelay: TimeInterval = 2.0,
|
|
||||||
burstMaxDelay: TimeInterval = 5.0
|
|
||||||
) {
|
|
||||||
self.availableChannelsProvider = availableChannelsProvider
|
|
||||||
self.locationChanges = locationChanges
|
|
||||||
self.torReadyPublisher = torReadyPublisher
|
|
||||||
self.torIsReady = torIsReady
|
|
||||||
self.torIsForeground = torIsForeground
|
|
||||||
self.deriveIdentity = deriveIdentity
|
|
||||||
self.relayLookup = relayLookup
|
|
||||||
self.relaySender = relaySender
|
|
||||||
self.sleeper = sleeper
|
|
||||||
self.scheduleTimer = scheduleTimer
|
|
||||||
self.loopMinInterval = loopMinInterval
|
|
||||||
self.loopMaxInterval = loopMaxInterval
|
|
||||||
self.burstMinDelay = burstMinDelay
|
|
||||||
self.burstMaxDelay = burstMaxDelay
|
|
||||||
setupObservers()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start the service (safe to call multiple times)
|
|
||||||
func start() {
|
|
||||||
SecureLogger.info("Presence: service starting...", category: .session)
|
|
||||||
scheduleNextHeartbeat()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupObservers() {
|
|
||||||
// Monitor location channel changes
|
|
||||||
locationChanges
|
|
||||||
.dropFirst()
|
|
||||||
.sink { [weak self] _ in
|
|
||||||
self?.handleLocationChange()
|
|
||||||
}
|
|
||||||
.store(in: &subscriptions)
|
|
||||||
|
|
||||||
// Monitor Tor readiness to kick off heartbeat if it was stalled
|
|
||||||
torReadyPublisher
|
|
||||||
.sink { [weak self] _ in
|
|
||||||
self?.handleConnectivityChange()
|
|
||||||
}
|
|
||||||
.store(in: &subscriptions)
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleLocationChange() {
|
|
||||||
// When location changes, we trigger an immediate (but slightly delayed) heartbeat
|
|
||||||
// to announce presence in the new zone, then reset the loop.
|
|
||||||
SecureLogger.debug("Presence: location changed, scheduling update", category: .session)
|
|
||||||
heartbeatTimer?.invalidate()
|
|
||||||
|
|
||||||
// Small delay to allow location state to settle
|
|
||||||
heartbeatTimer = scheduleTimer(5.0) { [weak self] in
|
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
self?.performHeartbeat()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleConnectivityChange() {
|
|
||||||
SecureLogger.debug("Presence: connectivity restored, triggering heartbeat", category: .session)
|
|
||||||
// If we were waiting for network, do it now
|
|
||||||
if heartbeatTimer == nil || !heartbeatTimer!.isValid {
|
|
||||||
scheduleNextHeartbeat()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func scheduleNextHeartbeat() {
|
|
||||||
heartbeatTimer?.invalidate()
|
|
||||||
let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval)
|
|
||||||
heartbeatTimer = scheduleTimer(interval) { [weak self] in
|
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
self?.performHeartbeat()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func performHeartbeat() {
|
|
||||||
// Always schedule next loop first ensures continuity even if this one fails/skips
|
|
||||||
defer { scheduleNextHeartbeat() }
|
|
||||||
|
|
||||||
// 1. Check preconditions
|
|
||||||
guard torIsReady() else {
|
|
||||||
SecureLogger.debug("Presence: skipping heartbeat (Tor not ready)", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// App must be active (or at least we shouldn't broadcast if in background, usually)
|
|
||||||
if !torIsForeground() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Get channels
|
|
||||||
let channels = availableChannelsProvider()
|
|
||||||
guard !channels.isEmpty else { return }
|
|
||||||
|
|
||||||
// 3. Filter and broadcast
|
|
||||||
// We use Task + sleep for decorrelation to allow the main runloop to proceed
|
|
||||||
for channel in channels {
|
|
||||||
// Check privacy restriction
|
|
||||||
if !self.allowedPrecisions.contains(channel.geohash.count) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Launch independent task for each channel's delay
|
|
||||||
Task { @MainActor in
|
|
||||||
// Random delay for decorrelation
|
|
||||||
let delay = TimeInterval.random(in: self.burstMinDelay...self.burstMaxDelay)
|
|
||||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
|
||||||
await self.sleeper(nanoseconds)
|
|
||||||
|
|
||||||
self.broadcastPresence(for: channel.geohash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func broadcastPresence(for geohash: String) {
|
|
||||||
do {
|
|
||||||
guard let identity = try? deriveIdentity(geohash) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let event = try NostrProtocol.createGeohashPresenceEvent(
|
|
||||||
geohash: geohash,
|
|
||||||
senderIdentity: identity
|
|
||||||
)
|
|
||||||
|
|
||||||
// Send via RelayManager
|
|
||||||
let targetRelays = relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
|
||||||
|
|
||||||
if !targetRelays.isEmpty {
|
|
||||||
relaySender(event, targetRelays)
|
|
||||||
SecureLogger.debug("Presence: sent heartbeat for \(geohash) (pub=\(identity.publicKeyHex.prefix(6))...)", category: .session)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("Presence: failed to create event for \(geohash): \(error)", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,22 +6,54 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import BitLogger
|
|
||||||
import BitFoundation
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Security
|
import Security
|
||||||
|
import os.log
|
||||||
|
|
||||||
final class KeychainManager: KeychainManagerProtocol {
|
class KeychainManager {
|
||||||
|
static let shared = KeychainManager()
|
||||||
|
|
||||||
// Use consistent service name for all keychain items
|
// Use consistent service name for all keychain items
|
||||||
private let service = BitchatApp.bundleID
|
private let service = "chat.bitchat"
|
||||||
private let appGroup = "group.\(BitchatApp.bundleID)"
|
private let appGroup = "group.chat.bitchat"
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
|
||||||
|
private func isSandboxed() -> Bool {
|
||||||
|
#if os(macOS)
|
||||||
|
// More robust sandbox detection using multiple methods
|
||||||
|
|
||||||
|
// Method 1: Check environment variable (can be spoofed)
|
||||||
|
let environment = ProcessInfo.processInfo.environment
|
||||||
|
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
|
||||||
|
// iOS is always sandboxed
|
||||||
|
return true
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Identity Keys
|
// MARK: - Identity Keys
|
||||||
|
|
||||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,184 +64,10 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - BCH-01-009: Methods with Proper Error Classification
|
|
||||||
|
|
||||||
/// Get identity key with detailed result for proper error handling
|
|
||||||
/// Distinguishes between missing keys (expected) and critical failures
|
|
||||||
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
|
|
||||||
let fullKey = "identity_\(key)"
|
|
||||||
return retrieveDataWithResult(forKey: fullKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Save identity key with detailed result and retry logic for transient errors
|
|
||||||
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
|
|
||||||
let fullKey = "identity_\(key)"
|
|
||||||
return saveDataWithResult(keyData, forKey: fullKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Internal method to save data with detailed result and retry for transient errors
|
|
||||||
private func saveDataWithResult(_ data: Data, forKey key: String, retryCount: Int = 2) -> KeychainSaveResult {
|
|
||||||
// Delete any existing item first to ensure clean state
|
|
||||||
_ = delete(forKey: key)
|
|
||||||
|
|
||||||
// Build base query
|
|
||||||
var base: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrAccount as String: key,
|
|
||||||
kSecValueData as String: data,
|
|
||||||
kSecAttrService as String: service,
|
|
||||||
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked,
|
|
||||||
kSecAttrLabel as String: "bitchat-\(key)"
|
|
||||||
]
|
|
||||||
#if os(macOS)
|
|
||||||
base[kSecAttrSynchronizable as String] = false
|
|
||||||
#endif
|
|
||||||
|
|
||||||
func attempt(addAccessGroup: Bool) -> OSStatus {
|
|
||||||
var query = base
|
|
||||||
if addAccessGroup { query[kSecAttrAccessGroup as String] = appGroup }
|
|
||||||
return SecItemAdd(query as CFDictionary, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
var status = attempt(addAccessGroup: true)
|
|
||||||
if status == -34018 { // Missing entitlement, retry without access group
|
|
||||||
status = attempt(addAccessGroup: false)
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
let status = attempt(addAccessGroup: false)
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Classify the result
|
|
||||||
let result = classifySaveStatus(status)
|
|
||||||
|
|
||||||
// Log all outcomes consistently
|
|
||||||
switch result {
|
|
||||||
case .success:
|
|
||||||
SecureLogger.debug("Keychain save succeeded for key: \(key)", category: .keychain)
|
|
||||||
case .duplicateItem:
|
|
||||||
SecureLogger.warning("Keychain save found duplicate for key: \(key)", category: .keychain)
|
|
||||||
case .accessDenied:
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
|
|
||||||
context: "Keychain access denied for key: \(key)", category: .keychain)
|
|
||||||
case .deviceLocked:
|
|
||||||
SecureLogger.warning("Device locked during keychain save for key: \(key)", category: .keychain)
|
|
||||||
case .storageFull:
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
|
|
||||||
context: "Keychain storage full for key: \(key)", category: .keychain)
|
|
||||||
case .otherError(let code):
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(code)),
|
|
||||||
context: "Keychain save failed for key: \(key)", category: .keychain)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retry transient errors with exponential backoff
|
|
||||||
if result.isRecoverableError && retryCount > 0 {
|
|
||||||
let delayMs = UInt32((3 - retryCount) * 100) // 100ms, 200ms backoff
|
|
||||||
usleep(delayMs * 1000)
|
|
||||||
SecureLogger.debug("Retrying keychain save for key: \(key), attempts remaining: \(retryCount)", category: .keychain)
|
|
||||||
return saveDataWithResult(data, forKey: key, retryCount: retryCount - 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Internal method to retrieve data with detailed result
|
|
||||||
private func retrieveDataWithResult(forKey key: String) -> KeychainReadResult {
|
|
||||||
let base: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrAccount as String: key,
|
|
||||||
kSecAttrService as String: service,
|
|
||||||
kSecReturnData as String: true,
|
|
||||||
kSecMatchLimit as String: kSecMatchLimitOne
|
|
||||||
]
|
|
||||||
|
|
||||||
var result: AnyObject?
|
|
||||||
func attempt(withAccessGroup: Bool) -> OSStatus {
|
|
||||||
var q = base
|
|
||||||
if withAccessGroup { q[kSecAttrAccessGroup as String] = appGroup }
|
|
||||||
return SecItemCopyMatching(q as CFDictionary, &result)
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
var status = attempt(withAccessGroup: true)
|
|
||||||
if status == -34018 { status = attempt(withAccessGroup: false) }
|
|
||||||
#else
|
|
||||||
let status = attempt(withAccessGroup: false)
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Classify the result
|
|
||||||
let readResult = classifyReadStatus(status, data: result as? Data)
|
|
||||||
|
|
||||||
// Log all outcomes consistently
|
|
||||||
switch readResult {
|
|
||||||
case .success:
|
|
||||||
SecureLogger.debug("Keychain read succeeded for key: \(key)", category: .keychain)
|
|
||||||
case .itemNotFound:
|
|
||||||
// Expected case - no logging needed for missing keys
|
|
||||||
break
|
|
||||||
case .accessDenied:
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
|
|
||||||
context: "Keychain access denied for key: \(key)", category: .keychain)
|
|
||||||
case .deviceLocked:
|
|
||||||
SecureLogger.warning("Device locked during keychain read for key: \(key)", category: .keychain)
|
|
||||||
case .authenticationFailed:
|
|
||||||
SecureLogger.warning("Authentication failed for keychain read of key: \(key)", category: .keychain)
|
|
||||||
case .otherError(let code):
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(code)),
|
|
||||||
context: "Keychain read failed for key: \(key)", category: .keychain)
|
|
||||||
}
|
|
||||||
|
|
||||||
return readResult
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Classify keychain read status into meaningful categories
|
|
||||||
private func classifyReadStatus(_ status: OSStatus, data: Data?) -> KeychainReadResult {
|
|
||||||
switch status {
|
|
||||||
case errSecSuccess:
|
|
||||||
if let data = data {
|
|
||||||
return .success(data)
|
|
||||||
}
|
|
||||||
return .otherError(status)
|
|
||||||
case errSecItemNotFound:
|
|
||||||
return .itemNotFound
|
|
||||||
case errSecInteractionNotAllowed:
|
|
||||||
// Device is locked or in a state that doesn't allow keychain access
|
|
||||||
return .deviceLocked
|
|
||||||
case errSecAuthFailed:
|
|
||||||
return .authenticationFailed
|
|
||||||
case -34018: // errSecMissingEntitlement
|
|
||||||
return .accessDenied
|
|
||||||
case errSecNotAvailable:
|
|
||||||
return .accessDenied
|
|
||||||
default:
|
|
||||||
return .otherError(status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Classify keychain save status into meaningful categories
|
|
||||||
private func classifySaveStatus(_ status: OSStatus) -> KeychainSaveResult {
|
|
||||||
switch status {
|
|
||||||
case errSecSuccess:
|
|
||||||
return .success
|
|
||||||
case errSecDuplicateItem:
|
|
||||||
return .duplicateItem
|
|
||||||
case errSecInteractionNotAllowed:
|
|
||||||
return .deviceLocked
|
|
||||||
case -34018: // errSecMissingEntitlement
|
|
||||||
return .accessDenied
|
|
||||||
case errSecNotAvailable:
|
|
||||||
return .accessDenied
|
|
||||||
case errSecDiskFull:
|
|
||||||
return .storageFull
|
|
||||||
default:
|
|
||||||
return .otherError(status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Generic Operations
|
// MARK: - Generic Operations
|
||||||
|
|
||||||
private func save(_ value: String, forKey key: String) -> Bool {
|
private func save(_ value: String, forKey key: String) -> Bool {
|
||||||
@@ -255,9 +113,9 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
|
|
||||||
if status == errSecSuccess { return true }
|
if status == errSecSuccess { return true }
|
||||||
if status == -34018 && !triedWithoutGroup {
|
if status == -34018 && !triedWithoutGroup {
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: -34018), context: "Missing keychain entitlement", category: .keychain)
|
SecureLogger.logError(NSError(domain: "Keychain", code: -34018), context: "Missing keychain entitlement", category: SecureLogger.keychain)
|
||||||
} else if status != errSecDuplicateItem {
|
} else if status != errSecDuplicateItem {
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)), context: "Error saving to keychain", category: .keychain)
|
SecureLogger.logError(NSError(domain: "Keychain", code: Int(status)), context: "Error saving to keychain", category: SecureLogger.keychain)
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -293,7 +151,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
|
|
||||||
if status == errSecSuccess { return result as? Data }
|
if status == errSecSuccess { return result as? Data }
|
||||||
if status == -34018 {
|
if status == -34018 {
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: -34018), context: "Missing keychain entitlement", category: .keychain)
|
SecureLogger.logError(NSError(domain: "Keychain", code: -34018), context: "Missing keychain entitlement", category: SecureLogger.keychain)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -340,7 +198,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
|
|
||||||
// Delete ALL keychain data for panic mode
|
// Delete ALL keychain data for panic mode
|
||||||
func deleteAllKeychainData() -> Bool {
|
func deleteAllKeychainData() -> Bool {
|
||||||
SecureLogger.warning("Panic mode - deleting all keychain data", category: .security)
|
SecureLogger.log("Panic mode - deleting all keychain data", category: SecureLogger.security, level: .warning)
|
||||||
|
|
||||||
var totalDeleted = 0
|
var totalDeleted = 0
|
||||||
|
|
||||||
@@ -403,7 +261,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
let deleteStatus = SecItemDelete(deleteQuery as CFDictionary)
|
let deleteStatus = SecItemDelete(deleteQuery as CFDictionary)
|
||||||
if deleteStatus == errSecSuccess {
|
if deleteStatus == errSecSuccess {
|
||||||
totalDeleted += 1
|
totalDeleted += 1
|
||||||
SecureLogger.info("Deleted keychain item: \(account) from \(service)", category: .keychain)
|
SecureLogger.log("Deleted keychain item: \(account) from \(service)", category: SecureLogger.keychain, level: .info)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -417,7 +275,6 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
"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"
|
||||||
@@ -446,7 +303,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
totalDeleted += 1
|
totalDeleted += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
SecureLogger.warning("Panic mode cleanup completed. Total items deleted: \(totalDeleted)", category: .keychain)
|
SecureLogger.log("Panic mode cleanup completed. Total items deleted: \(totalDeleted)", category: SecureLogger.keychain, level: .warning)
|
||||||
|
|
||||||
return totalDeleted > 0
|
return totalDeleted > 0
|
||||||
}
|
}
|
||||||
@@ -454,7 +311,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
// MARK: - Security Utilities
|
// MARK: - Security Utilities
|
||||||
|
|
||||||
/// Securely clear sensitive data from memory
|
/// Securely clear sensitive data from memory
|
||||||
func secureClear(_ data: inout Data) {
|
static func secureClear(_ data: inout Data) {
|
||||||
_ = data.withUnsafeMutableBytes { bytes in
|
_ = data.withUnsafeMutableBytes { bytes in
|
||||||
// Use volatile memset to prevent compiler optimization
|
// Use volatile memset to prevent compiler optimization
|
||||||
memset_s(bytes.baseAddress, bytes.count, 0, bytes.count)
|
memset_s(bytes.baseAddress, bytes.count, 0, bytes.count)
|
||||||
@@ -463,7 +320,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Securely clear sensitive string from memory
|
/// Securely clear sensitive string from memory
|
||||||
func secureClear(_ string: inout String) {
|
static func secureClear(_ string: inout String) {
|
||||||
// Convert to mutable data and clear
|
// Convert to mutable data and clear
|
||||||
if var data = string.data(using: .utf8) {
|
if var data = string.data(using: .utf8) {
|
||||||
secureClear(&data)
|
secureClear(&data)
|
||||||
@@ -472,54 +329,9 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
|
|
||||||
|
|
||||||
/// Save data with a custom service name
|
|
||||||
func save(key: String, data: Data, service customService: String, accessible: CFString?) {
|
|
||||||
var query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: customService,
|
|
||||||
kSecAttrAccount as String: key,
|
|
||||||
kSecValueData as String: data
|
|
||||||
]
|
|
||||||
if let accessible = accessible {
|
|
||||||
query[kSecAttrAccessible as String] = accessible
|
|
||||||
}
|
|
||||||
|
|
||||||
SecItemDelete(query as CFDictionary)
|
|
||||||
SecItemAdd(query as CFDictionary, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load data from a custom service
|
|
||||||
func load(key: String, service customService: String) -> Data? {
|
|
||||||
let query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: customService,
|
|
||||||
kSecAttrAccount as String: key,
|
|
||||||
kSecReturnData as String: true
|
|
||||||
]
|
|
||||||
|
|
||||||
var result: AnyObject?
|
|
||||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
||||||
|
|
||||||
guard status == errSecSuccess else { return nil }
|
|
||||||
return result as? Data
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete data from a custom service
|
|
||||||
func delete(key: String, service customService: String) {
|
|
||||||
let query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: customService,
|
|
||||||
kSecAttrAccount as String: key
|
|
||||||
]
|
|
||||||
|
|
||||||
SecItemDelete(query as CFDictionary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
// Initialize teleported flag from persisted state if a location channel is selected
|
||||||
|
if case .location(let ch) = selectedChannel {
|
||||||
|
teleported = teleportedSet.contains(ch.geohash)
|
||||||
|
}
|
||||||
|
let status: CLAuthorizationStatus
|
||||||
|
if #available(iOS 14.0, macOS 11.0, *) {
|
||||||
|
status = cl.authorizationStatus
|
||||||
|
} else {
|
||||||
|
status = CLLocationManager.authorizationStatus()
|
||||||
|
}
|
||||||
|
updatePermissionState(from: status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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):
|
||||||
|
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.log("LocationChannelManager: location error: \(error.localizedDescription)",
|
||||||
|
category: SecureLogger.session, level: .error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 persisted state OR current location vs selected channel
|
||||||
|
switch self.selectedChannel {
|
||||||
|
case .mesh:
|
||||||
|
self.teleported = false
|
||||||
|
case .location(let ch):
|
||||||
|
let persisted = self.teleportedSet.contains(ch.geohash)
|
||||||
|
let currentGH = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: ch.level.precision)
|
||||||
|
self.teleported = persisted || (currentGH != ch.geohash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 without exposing street level
|
||||||
|
if let subLocality = pm.subLocality, !subLocality.isEmpty {
|
||||||
|
dict[.block] = subLocality
|
||||||
|
} else if let locality = pm.locality, !locality.isEmpty {
|
||||||
|
dict[.block] = locality
|
||||||
|
}
|
||||||
|
return dict
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
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
|
|
||||||
|
|
||||||
private static let idBridge = NostrIdentityBridge()
|
|
||||||
|
|
||||||
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 idBridge.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?.trimmedOrNilIfEmpty {
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
// Subscribe to center + 8 neighbors (± 1 grid)
|
|
||||||
let neighbors = Geohash.neighbors(of: geohash)
|
|
||||||
let allGeohashes = [geohash] + neighbors
|
|
||||||
let filter = NostrFilter.geohashNotes(allGeohashes, since: nil, limit: 200)
|
|
||||||
|
|
||||||
// Build a set of valid geohashes for tag matching (includes all 9 cells)
|
|
||||||
let validGeohashes = Set(allGeohashes.map { $0.lowercased() })
|
|
||||||
|
|
||||||
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 - accept any of our 9 geohashes
|
|
||||||
guard event.tags.contains(where: { tag in
|
|
||||||
tag.count >= 2 && tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased())
|
|
||||||
}) 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) {
|
|
||||||
guard let trimmed = content.trimmedOrNilIfEmpty 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,630 +0,0 @@
|
|||||||
import BitLogger
|
|
||||||
import Foundation
|
|
||||||
import Combine
|
|
||||||
|
|
||||||
#if os(iOS) || os(macOS)
|
|
||||||
import CoreLocation
|
|
||||||
|
|
||||||
protocol LocationStateManaging: AnyObject {
|
|
||||||
var delegate: CLLocationManagerDelegate? { get set }
|
|
||||||
var desiredAccuracy: CLLocationAccuracy { get set }
|
|
||||||
var distanceFilter: CLLocationDistance { get set }
|
|
||||||
var authorizationStatus: CLAuthorizationStatus { get }
|
|
||||||
func requestWhenInUseAuthorization()
|
|
||||||
func requestLocation()
|
|
||||||
func startUpdatingLocation()
|
|
||||||
func stopUpdatingLocation()
|
|
||||||
}
|
|
||||||
|
|
||||||
protocol LocationStateGeocoding: AnyObject {
|
|
||||||
func cancelGeocode()
|
|
||||||
func reverseGeocodeLocation(
|
|
||||||
_ location: CLLocation,
|
|
||||||
completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private final class CLLocationManagerAdapter: NSObject, LocationStateManaging {
|
|
||||||
private let base = CLLocationManager()
|
|
||||||
|
|
||||||
var delegate: CLLocationManagerDelegate? {
|
|
||||||
get { base.delegate }
|
|
||||||
set { base.delegate = newValue }
|
|
||||||
}
|
|
||||||
|
|
||||||
var desiredAccuracy: CLLocationAccuracy {
|
|
||||||
get { base.desiredAccuracy }
|
|
||||||
set { base.desiredAccuracy = newValue }
|
|
||||||
}
|
|
||||||
|
|
||||||
var distanceFilter: CLLocationDistance {
|
|
||||||
get { base.distanceFilter }
|
|
||||||
set { base.distanceFilter = newValue }
|
|
||||||
}
|
|
||||||
|
|
||||||
var authorizationStatus: CLAuthorizationStatus {
|
|
||||||
base.authorizationStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
func requestWhenInUseAuthorization() {
|
|
||||||
base.requestWhenInUseAuthorization()
|
|
||||||
}
|
|
||||||
|
|
||||||
func requestLocation() {
|
|
||||||
base.requestLocation()
|
|
||||||
}
|
|
||||||
|
|
||||||
func startUpdatingLocation() {
|
|
||||||
base.startUpdatingLocation()
|
|
||||||
}
|
|
||||||
|
|
||||||
func stopUpdatingLocation() {
|
|
||||||
base.stopUpdatingLocation()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private final class CLGeocoderAdapter: LocationStateGeocoding {
|
|
||||||
private let base = CLGeocoder()
|
|
||||||
|
|
||||||
func cancelGeocode() {
|
|
||||||
base.cancelGeocode()
|
|
||||||
}
|
|
||||||
|
|
||||||
func reverseGeocodeLocation(
|
|
||||||
_ location: CLLocation,
|
|
||||||
completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void
|
|
||||||
) {
|
|
||||||
base.reverseGeocodeLocation(location, completionHandler: completionHandler)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unified manager for location-based channel state including:
|
|
||||||
/// - CoreLocation permissions and one-shot location retrieval
|
|
||||||
/// - Geohash channel computation from coordinates
|
|
||||||
/// - Channel selection and teleport state
|
|
||||||
/// - Bookmark persistence and friendly name resolution
|
|
||||||
///
|
|
||||||
/// Consolidates LocationChannelManager + GeohashBookmarksStore into a single source of truth.
|
|
||||||
final class LocationStateManager: NSObject, CLLocationManagerDelegate, ObservableObject {
|
|
||||||
static let shared = LocationStateManager()
|
|
||||||
|
|
||||||
// MARK: - Permission State
|
|
||||||
|
|
||||||
enum PermissionState: Equatable {
|
|
||||||
case notDetermined
|
|
||||||
case denied
|
|
||||||
case restricted
|
|
||||||
case authorized
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Properties (CoreLocation)
|
|
||||||
|
|
||||||
private let cl: LocationStateManaging
|
|
||||||
private let geocoder: LocationStateGeocoding
|
|
||||||
private var lastLocation: CLLocation?
|
|
||||||
private var refreshTimer: Timer?
|
|
||||||
private var isGeocoding: Bool = false
|
|
||||||
|
|
||||||
// MARK: - Persistence Keys
|
|
||||||
|
|
||||||
private let selectedChannelKey = "locationChannel.selected"
|
|
||||||
private let teleportedStoreKey = "locationChannel.teleportedSet"
|
|
||||||
private let bookmarksKey = "locationChannel.bookmarks"
|
|
||||||
private let bookmarkNamesKey = "locationChannel.bookmarkNames"
|
|
||||||
|
|
||||||
// MARK: - Published State (Channel)
|
|
||||||
|
|
||||||
@Published private(set) var permissionState: PermissionState = .notDetermined
|
|
||||||
@Published private(set) var availableChannels: [GeohashChannel] = []
|
|
||||||
@Published private(set) var selectedChannel: ChannelID = .mesh
|
|
||||||
@Published var teleported: Bool = false
|
|
||||||
@Published private(set) var locationNames: [GeohashChannelLevel: String] = [:]
|
|
||||||
|
|
||||||
// MARK: - Published State (Bookmarks)
|
|
||||||
|
|
||||||
@Published private(set) var bookmarks: [String] = []
|
|
||||||
@Published private(set) var bookmarkNames: [String: String] = [:]
|
|
||||||
|
|
||||||
// MARK: - Private State
|
|
||||||
|
|
||||||
private var teleportedSet: Set<String> = []
|
|
||||||
private var bookmarkMembership: Set<String> = []
|
|
||||||
private var resolvingNames: Set<String> = []
|
|
||||||
private let storage: UserDefaults
|
|
||||||
|
|
||||||
/// Returns true if running in test environment
|
|
||||||
private static var isRunningTests: Bool {
|
|
||||||
let env = ProcessInfo.processInfo.environment
|
|
||||||
return NSClassFromString("XCTestCase") != nil ||
|
|
||||||
env["XCTestConfigurationFilePath"] != nil ||
|
|
||||||
env["XCTestBundlePath"] != nil ||
|
|
||||||
env["GITHUB_ACTIONS"] != nil ||
|
|
||||||
env["CI"] != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Initialization
|
|
||||||
|
|
||||||
private override init() {
|
|
||||||
self.storage = .standard
|
|
||||||
self.cl = CLLocationManagerAdapter()
|
|
||||||
self.geocoder = CLGeocoderAdapter()
|
|
||||||
super.init()
|
|
||||||
|
|
||||||
// Skip CoreLocation setup in test environments
|
|
||||||
guard !Self.isRunningTests else {
|
|
||||||
loadPersistedState()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cl.delegate = self
|
|
||||||
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
|
||||||
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
|
|
||||||
|
|
||||||
loadPersistedState()
|
|
||||||
initializePermissionState()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Internal initializer for testing with custom storage
|
|
||||||
init(storage: UserDefaults) {
|
|
||||||
self.storage = storage
|
|
||||||
self.cl = CLLocationManagerAdapter()
|
|
||||||
self.geocoder = CLGeocoderAdapter()
|
|
||||||
super.init()
|
|
||||||
loadPersistedState()
|
|
||||||
}
|
|
||||||
|
|
||||||
internal init(
|
|
||||||
storage: UserDefaults,
|
|
||||||
locationManager: LocationStateManaging,
|
|
||||||
geocoder: LocationStateGeocoding,
|
|
||||||
shouldInitializeCoreLocation: Bool
|
|
||||||
) {
|
|
||||||
self.storage = storage
|
|
||||||
self.cl = locationManager
|
|
||||||
self.geocoder = geocoder
|
|
||||||
super.init()
|
|
||||||
loadPersistedState()
|
|
||||||
guard shouldInitializeCoreLocation else { return }
|
|
||||||
cl.delegate = self
|
|
||||||
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
|
||||||
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
|
|
||||||
initializePermissionState()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadPersistedState() {
|
|
||||||
// Load selected channel
|
|
||||||
if let data = storage.data(forKey: selectedChannelKey),
|
|
||||||
let channel = try? JSONDecoder().decode(ChannelID.self, from: data) {
|
|
||||||
selectedChannel = channel
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load teleported set
|
|
||||||
if let data = storage.data(forKey: teleportedStoreKey),
|
|
||||||
let arr = try? JSONDecoder().decode([String].self, from: data) {
|
|
||||||
teleportedSet = Set(arr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load bookmarks
|
|
||||||
if let data = storage.data(forKey: bookmarksKey),
|
|
||||||
let arr = try? JSONDecoder().decode([String].self, from: data) {
|
|
||||||
var seen = Set<String>()
|
|
||||||
var list: [String] = []
|
|
||||||
for raw in arr {
|
|
||||||
let gh = Self.normalizeGeohash(raw)
|
|
||||||
guard !gh.isEmpty, !seen.contains(gh) else { continue }
|
|
||||||
seen.insert(gh)
|
|
||||||
list.append(gh)
|
|
||||||
}
|
|
||||||
bookmarks = list
|
|
||||||
bookmarkMembership = seen
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load bookmark names
|
|
||||||
if let data = storage.data(forKey: bookmarkNamesKey),
|
|
||||||
let dict = try? JSONDecoder().decode([String: String].self, from: data) {
|
|
||||||
bookmarkNames = dict
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func initializePermissionState() {
|
|
||||||
let status = cl.authorizationStatus
|
|
||||||
updatePermissionState(from: status)
|
|
||||||
|
|
||||||
// Fall back to persisted teleport state if no location authorization
|
|
||||||
switch status {
|
|
||||||
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
|
||||||
break
|
|
||||||
case .notDetermined, .restricted, .denied:
|
|
||||||
fallthrough
|
|
||||||
@unknown default:
|
|
||||||
if case .location(let ch) = selectedChannel {
|
|
||||||
teleported = teleportedSet.contains(ch.geohash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public API (Permissions & Location)
|
|
||||||
|
|
||||||
func enableLocationChannels() {
|
|
||||||
let status = cl.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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) {
|
|
||||||
guard permissionState == .authorized else { return }
|
|
||||||
refreshTimer?.invalidate()
|
|
||||||
refreshTimer = nil
|
|
||||||
cl.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
|
|
||||||
cl.distanceFilter = TransportConfig.locationDistanceFilterLiveMeters
|
|
||||||
cl.startUpdatingLocation()
|
|
||||||
requestOneShotLocation()
|
|
||||||
}
|
|
||||||
|
|
||||||
func endLiveRefresh() {
|
|
||||||
refreshTimer?.invalidate()
|
|
||||||
refreshTimer = nil
|
|
||||||
cl.stopUpdatingLocation()
|
|
||||||
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
|
||||||
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public API (Channel Selection)
|
|
||||||
|
|
||||||
func select(_ channel: ChannelID) {
|
|
||||||
Task { @MainActor in
|
|
||||||
self.selectedChannel = channel
|
|
||||||
if let data = try? JSONEncoder().encode(channel) {
|
|
||||||
self.storage.set(data, forKey: self.selectedChannelKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch channel {
|
|
||||||
case .mesh:
|
|
||||||
self.teleported = false
|
|
||||||
case .location(let ch):
|
|
||||||
let inRegional = self.availableChannels.contains { $0.geohash == ch.geohash }
|
|
||||||
if inRegional {
|
|
||||||
self.teleported = false
|
|
||||||
if self.teleportedSet.contains(ch.geohash) {
|
|
||||||
self.teleportedSet.remove(ch.geohash)
|
|
||||||
self.persistTeleportedSet()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.teleported = self.teleportedSet.contains(ch.geohash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func markTeleported(for geohash: String, _ flag: Bool) {
|
|
||||||
if flag {
|
|
||||||
teleportedSet.insert(geohash)
|
|
||||||
} else {
|
|
||||||
teleportedSet.remove(geohash)
|
|
||||||
}
|
|
||||||
persistTeleportedSet()
|
|
||||||
if case .location(let ch) = selectedChannel, ch.geohash == geohash {
|
|
||||||
Task { @MainActor in self.teleported = flag }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public API (Bookmarks)
|
|
||||||
|
|
||||||
func isBookmarked(_ geohash: String) -> Bool {
|
|
||||||
bookmarkMembership.contains(Self.normalizeGeohash(geohash))
|
|
||||||
}
|
|
||||||
|
|
||||||
func toggleBookmark(_ geohash: String) {
|
|
||||||
let gh = Self.normalizeGeohash(geohash)
|
|
||||||
if bookmarkMembership.contains(gh) {
|
|
||||||
removeBookmark(gh)
|
|
||||||
} else {
|
|
||||||
addBookmark(gh)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func addBookmark(_ geohash: String) {
|
|
||||||
let gh = Self.normalizeGeohash(geohash)
|
|
||||||
guard !gh.isEmpty, !bookmarkMembership.contains(gh) else { return }
|
|
||||||
bookmarks.insert(gh, at: 0)
|
|
||||||
bookmarkMembership.insert(gh)
|
|
||||||
persistBookmarks()
|
|
||||||
resolveBookmarkNameIfNeeded(for: gh)
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeBookmark(_ geohash: String) {
|
|
||||||
let gh = Self.normalizeGeohash(geohash)
|
|
||||||
guard bookmarkMembership.contains(gh) else { return }
|
|
||||||
if let idx = bookmarks.firstIndex(of: gh) {
|
|
||||||
bookmarks.remove(at: idx)
|
|
||||||
}
|
|
||||||
bookmarkMembership.remove(gh)
|
|
||||||
if bookmarkNames.removeValue(forKey: gh) != nil {
|
|
||||||
persistBookmarkNames()
|
|
||||||
}
|
|
||||||
persistBookmarks()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - CLLocationManagerDelegate
|
|
||||||
|
|
||||||
private func requestOneShotLocation() {
|
|
||||||
cl.requestLocation()
|
|
||||||
}
|
|
||||||
|
|
||||||
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
|
|
||||||
updatePermissionState(from: status)
|
|
||||||
if case .authorized = permissionState {
|
|
||||||
requestOneShotLocation()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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)
|
|
||||||
reverseGeocodeLocation(loc)
|
|
||||||
}
|
|
||||||
|
|
||||||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
|
||||||
SecureLogger.error("LocationStateManager: location error: \(error.localizedDescription)", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Helpers (Permission)
|
|
||||||
|
|
||||||
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 }
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Helpers (Channel Computation)
|
|
||||||
|
|
||||||
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
|
|
||||||
switch self.selectedChannel {
|
|
||||||
case .mesh:
|
|
||||||
self.teleported = false
|
|
||||||
case .location(let ch):
|
|
||||||
let inRegional = result.contains { $0.geohash == ch.geohash }
|
|
||||||
if inRegional {
|
|
||||||
self.teleported = false
|
|
||||||
if self.teleportedSet.contains(ch.geohash) {
|
|
||||||
self.teleportedSet.remove(ch.geohash)
|
|
||||||
self.persistTeleportedSet()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.teleported = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Helpers (Geocoding)
|
|
||||||
|
|
||||||
private func reverseGeocodeLocation(_ location: CLLocation) {
|
|
||||||
geocoder.cancelGeocode()
|
|
||||||
isGeocoding = true
|
|
||||||
geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, _ in
|
|
||||||
guard let self = self else { return }
|
|
||||||
self.isGeocoding = false
|
|
||||||
if let pm = placemarks?.first {
|
|
||||||
let names = self.locationNamesByLevel(from: pm)
|
|
||||||
Task { @MainActor in self.locationNames = names }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func locationNamesByLevel(from pm: CLPlacemark) -> [GeohashChannelLevel: String] {
|
|
||||||
var dict: [GeohashChannelLevel: String] = [:]
|
|
||||||
if let country = pm.country, !country.isEmpty {
|
|
||||||
dict[.region] = country
|
|
||||||
}
|
|
||||||
if let admin = pm.administrativeArea, !admin.isEmpty {
|
|
||||||
dict[.province] = admin
|
|
||||||
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
|
|
||||||
dict[.province] = subAdmin
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
if let subLocality = pm.subLocality, !subLocality.isEmpty {
|
|
||||||
dict[.neighborhood] = subLocality
|
|
||||||
} else if let locality = pm.locality, !locality.isEmpty {
|
|
||||||
dict[.neighborhood] = locality
|
|
||||||
}
|
|
||||||
if let subLocality = pm.subLocality, !subLocality.isEmpty {
|
|
||||||
dict[.block] = subLocality
|
|
||||||
} else if let locality = pm.locality, !locality.isEmpty {
|
|
||||||
dict[.block] = locality
|
|
||||||
}
|
|
||||||
if let name = pm.name, !name.isEmpty {
|
|
||||||
dict[.building] = name
|
|
||||||
} else if let thoroughfare = pm.thoroughfare, !thoroughfare.isEmpty {
|
|
||||||
dict[.building] = thoroughfare
|
|
||||||
}
|
|
||||||
return dict
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveBookmarkNameIfNeeded(for geohash: String) {
|
|
||||||
let gh = Self.normalizeGeohash(geohash)
|
|
||||||
guard !gh.isEmpty, bookmarkNames[gh] == nil, !resolvingNames.contains(gh) else { return }
|
|
||||||
resolvingNames.insert(gh)
|
|
||||||
|
|
||||||
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),
|
|
||||||
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.resolvingNames.remove(gh) }
|
|
||||||
if let pm = placemarks?.first,
|
|
||||||
let name = Self.nameForGeohashLength(gh.count, from: pm),
|
|
||||||
!name.isEmpty {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.bookmarkNames[gh] = name
|
|
||||||
self.persistBookmarkNames()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) {
|
|
||||||
var uniqueAdmins: [String] = []
|
|
||||||
var seenAdmins = Set<String>()
|
|
||||||
var idx = 0
|
|
||||||
|
|
||||||
func step() {
|
|
||||||
if idx >= points.count {
|
|
||||||
let finalName: String? = {
|
|
||||||
if uniqueAdmins.count >= 2 { return uniqueAdmins[0] + " and " + uniqueAdmins[1] }
|
|
||||||
return uniqueAdmins.first
|
|
||||||
}()
|
|
||||||
if let finalName = finalName, !finalName.isEmpty {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.bookmarkNames[gh] = finalName
|
|
||||||
self.persistBookmarkNames()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.resolvingNames.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, !seenAdmins.contains(admin) {
|
|
||||||
seenAdmins.insert(admin)
|
|
||||||
uniqueAdmins.append(admin)
|
|
||||||
} else if let country = pm.country, !country.isEmpty, !seenAdmins.contains(country) {
|
|
||||||
seenAdmins.insert(country)
|
|
||||||
uniqueAdmins.append(country)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
step()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
step()
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func nameForGeohashLength(_ len: Int, from pm: CLPlacemark) -> String? {
|
|
||||||
switch len {
|
|
||||||
case 0...2:
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Helpers (Persistence)
|
|
||||||
|
|
||||||
private func persistTeleportedSet() {
|
|
||||||
if let data = try? JSONEncoder().encode(Array(teleportedSet)) {
|
|
||||||
storage.set(data, forKey: teleportedStoreKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func persistBookmarks() {
|
|
||||||
if let data = try? JSONEncoder().encode(bookmarks) {
|
|
||||||
storage.set(data, forKey: bookmarksKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func persistBookmarkNames() {
|
|
||||||
if let data = try? JSONEncoder().encode(bookmarkNames) {
|
|
||||||
storage.set(data, forKey: bookmarkNamesKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func normalizeGeohash(_ s: String) -> String {
|
|
||||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
|
||||||
return s
|
|
||||||
.trimmed
|
|
||||||
.lowercased()
|
|
||||||
.replacingOccurrences(of: "#", with: "")
|
|
||||||
.filter { allowed.contains($0) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Backward Compatibility Typealiases
|
|
||||||
|
|
||||||
typealias LocationChannelManager = LocationStateManager
|
|
||||||
typealias GeohashBookmarksStore = LocationStateManager
|
|
||||||
|
|
||||||
// MARK: - Backward Compatibility Extensions
|
|
||||||
|
|
||||||
extension LocationStateManager {
|
|
||||||
/// Backward compatibility: toggle bookmark (was GeohashBookmarksStore.toggle)
|
|
||||||
func toggle(_ geohash: String) {
|
|
||||||
toggleBookmark(geohash)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Backward compatibility: add bookmark (was GeohashBookmarksStore.add)
|
|
||||||
func add(_ geohash: String) {
|
|
||||||
addBookmark(geohash)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Backward compatibility: remove bookmark (was GeohashBookmarksStore.remove)
|
|
||||||
func remove(_ geohash: String) {
|
|
||||||
removeBookmark(geohash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// Tracks observed mesh topology and computes hop-by-hop routes.
|
|
||||||
final class MeshTopologyTracker {
|
|
||||||
private typealias RoutingID = Data
|
|
||||||
|
|
||||||
private let queue = DispatchQueue(label: "mesh.topology", attributes: .concurrent)
|
|
||||||
private let hopSize = 8
|
|
||||||
// Directed claims: Key claims to see Value (neighbors)
|
|
||||||
private var claims: [RoutingID: Set<RoutingID>] = [:]
|
|
||||||
// Last time we received an update from a node
|
|
||||||
private var lastSeen: [RoutingID: Date] = [:]
|
|
||||||
|
|
||||||
// Maximum age for topology claims to be considered fresh for routing
|
|
||||||
// Routes computed using stale topology can fail when the network has changed
|
|
||||||
private static let routeFreshnessThreshold: TimeInterval = 60 // 60 seconds
|
|
||||||
|
|
||||||
func reset() {
|
|
||||||
queue.sync(flags: .barrier) {
|
|
||||||
self.claims.removeAll()
|
|
||||||
self.lastSeen.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update the topology with a node's self-reported neighbor list
|
|
||||||
func updateNeighbors(for sourceData: Data?, neighbors: [Data]) {
|
|
||||||
guard let source = sanitize(sourceData) else { return }
|
|
||||||
// Sanitize neighbors and exclude self-loops
|
|
||||||
let validNeighbors = Set(neighbors.compactMap { sanitize($0) }).subtracting([source])
|
|
||||||
|
|
||||||
queue.sync(flags: .barrier) {
|
|
||||||
self.claims[source] = validNeighbors
|
|
||||||
self.lastSeen[source] = Date()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func removePeer(_ data: Data?) {
|
|
||||||
guard let peer = sanitize(data) else { return }
|
|
||||||
queue.sync(flags: .barrier) {
|
|
||||||
self.claims.removeValue(forKey: peer)
|
|
||||||
self.lastSeen.removeValue(forKey: peer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Prune nodes that haven't updated their topology in `age` seconds
|
|
||||||
func prune(olderThan age: TimeInterval) {
|
|
||||||
let deadline = Date().addingTimeInterval(-age)
|
|
||||||
queue.sync(flags: .barrier) {
|
|
||||||
let stale = self.lastSeen.filter { $0.value < deadline }
|
|
||||||
for (peer, _) in stale {
|
|
||||||
self.claims.removeValue(forKey: peer)
|
|
||||||
self.lastSeen.removeValue(forKey: peer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 10) -> [Data]? {
|
|
||||||
guard let source = sanitize(start), let target = sanitize(goal) else { return nil }
|
|
||||||
if source == target { return [] } // Direct connection, no intermediate hops
|
|
||||||
|
|
||||||
return queue.sync {
|
|
||||||
let now = Date()
|
|
||||||
let freshnessDeadline = now.addingTimeInterval(-Self.routeFreshnessThreshold)
|
|
||||||
|
|
||||||
// BFS
|
|
||||||
var visited: Set<RoutingID> = [source]
|
|
||||||
// Queue stores paths: [Start, Hop1, Hop2, ..., Current]
|
|
||||||
var queuePaths: [[RoutingID]] = [[source]]
|
|
||||||
|
|
||||||
while !queuePaths.isEmpty {
|
|
||||||
let path = queuePaths.removeFirst()
|
|
||||||
// Limit path length (path contains source + maxHops + target) -> maxHops intermediate
|
|
||||||
// If maxHops = 10, max edges = 11, max nodes = 12.
|
|
||||||
if path.count > maxHops + 1 { continue }
|
|
||||||
|
|
||||||
guard let last = path.last else { continue }
|
|
||||||
|
|
||||||
// Get neighbors that 'last' claims to see
|
|
||||||
guard let neighbors = claims[last] else { continue }
|
|
||||||
|
|
||||||
// Check if 'last' node's topology info is fresh
|
|
||||||
guard let lastSeenTime = lastSeen[last], lastSeenTime > freshnessDeadline else {
|
|
||||||
continue // Skip stale nodes
|
|
||||||
}
|
|
||||||
|
|
||||||
for neighbor in neighbors {
|
|
||||||
if visited.contains(neighbor) { continue }
|
|
||||||
|
|
||||||
// CONFIRMED EDGE CHECK:
|
|
||||||
// 'last' claims 'neighbor' (checked above)
|
|
||||||
// Does 'neighbor' claim 'last'?
|
|
||||||
guard let neighborClaims = claims[neighbor],
|
|
||||||
neighborClaims.contains(last) else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if 'neighbor' node's topology info is fresh
|
|
||||||
guard let neighborSeenTime = lastSeen[neighbor], neighborSeenTime > freshnessDeadline else {
|
|
||||||
continue // Skip edges to stale nodes
|
|
||||||
}
|
|
||||||
|
|
||||||
var nextPath = path
|
|
||||||
nextPath.append(neighbor)
|
|
||||||
|
|
||||||
if neighbor == target {
|
|
||||||
// Return only intermediate hops
|
|
||||||
// Path: [Source, I1, I2, Target] -> [I1, I2]
|
|
||||||
return Array(nextPath.dropFirst().dropLast())
|
|
||||||
}
|
|
||||||
|
|
||||||
visited.insert(neighbor)
|
|
||||||
queuePaths.append(nextPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helpers
|
|
||||||
|
|
||||||
private func sanitize(_ data: Data?) -> Data? {
|
|
||||||
guard var value = data, !value.isEmpty else { return nil }
|
|
||||||
if value.count > hopSize {
|
|
||||||
value = Data(value.prefix(hopSize))
|
|
||||||
} else if value.count < hopSize {
|
|
||||||
value.append(Data(repeating: 0, count: hopSize - value.count))
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
//
|
|
||||||
// MessageDeduplicationService.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Handles message deduplication using LRU caches.
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
// MARK: - LRU Deduplication Cache
|
|
||||||
|
|
||||||
/// Generic LRU (Least Recently Used) cache for deduplication.
|
|
||||||
/// Uses an efficient O(1) lookup with periodic compaction.
|
|
||||||
/// Thread-safe via @MainActor - all callers are already on main actor.
|
|
||||||
@MainActor
|
|
||||||
final class LRUDeduplicationCache<Value> {
|
|
||||||
private var map: [String: Value] = [:]
|
|
||||||
private var order: [String] = []
|
|
||||||
private var head: Int = 0
|
|
||||||
private let capacity: Int
|
|
||||||
|
|
||||||
/// Creates a new LRU cache with the specified capacity.
|
|
||||||
/// - Parameter capacity: Maximum number of entries before eviction
|
|
||||||
init(capacity: Int) {
|
|
||||||
precondition(capacity > 0, "LRU cache capacity must be positive")
|
|
||||||
self.capacity = capacity
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of active entries in the cache
|
|
||||||
var count: Int {
|
|
||||||
order.count - head
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks if a key exists in the cache
|
|
||||||
func contains(_ key: String) -> Bool {
|
|
||||||
map[key] != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets the value for a key, or nil if not present
|
|
||||||
func value(for key: String) -> Value? {
|
|
||||||
map[key]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Records a key-value pair, updating if exists or inserting if new
|
|
||||||
func record(_ key: String, value: Value) {
|
|
||||||
if map[key] == nil {
|
|
||||||
order.append(key)
|
|
||||||
}
|
|
||||||
map[key] = value
|
|
||||||
trimIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Removes a specific key from the cache
|
|
||||||
func remove(_ key: String) {
|
|
||||||
map.removeValue(forKey: key)
|
|
||||||
// Note: key remains in order array but will be skipped during eviction
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clears all entries from the cache
|
|
||||||
func clear() {
|
|
||||||
map.removeAll()
|
|
||||||
order.removeAll()
|
|
||||||
head = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private
|
|
||||||
|
|
||||||
private func trimIfNeeded() {
|
|
||||||
let activeCount = order.count - head
|
|
||||||
guard activeCount > capacity else { return }
|
|
||||||
|
|
||||||
let overflow = activeCount - capacity
|
|
||||||
for _ in 0..<overflow {
|
|
||||||
guard let victim = popOldest() else { break }
|
|
||||||
map.removeValue(forKey: victim)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func popOldest() -> String? {
|
|
||||||
// Skip keys that were already removed from map
|
|
||||||
while head < order.count {
|
|
||||||
let key = order[head]
|
|
||||||
head += 1
|
|
||||||
|
|
||||||
// Periodically compact the backing storage
|
|
||||||
if head >= 32 && head * 2 >= order.count {
|
|
||||||
order.removeFirst(head)
|
|
||||||
head = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only return if key is still in map
|
|
||||||
if map[key] != nil {
|
|
||||||
return key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Content Normalizer
|
|
||||||
|
|
||||||
/// Normalizes message content for near-duplicate detection.
|
|
||||||
enum ContentNormalizer {
|
|
||||||
|
|
||||||
/// Regex to simplify HTTP URLs by stripping query strings and fragments
|
|
||||||
private static let simplifyHTTPURL: NSRegularExpression = {
|
|
||||||
try! NSRegularExpression(
|
|
||||||
pattern: "https?://[^\\s?#]+(?:[?#][^\\s]*)?",
|
|
||||||
options: [.caseInsensitive]
|
|
||||||
)
|
|
||||||
}()
|
|
||||||
|
|
||||||
/// Normalizes content for deduplication comparison.
|
|
||||||
/// - Parameters:
|
|
||||||
/// - content: The raw message content
|
|
||||||
/// - prefixLength: Maximum characters to consider (default from TransportConfig)
|
|
||||||
/// - Returns: A hash-based key for comparison
|
|
||||||
static func normalizedKey(
|
|
||||||
_ content: String,
|
|
||||||
prefixLength: Int = TransportConfig.contentKeyPrefixLength
|
|
||||||
) -> String {
|
|
||||||
// Lowercase for case-insensitive comparison
|
|
||||||
let lowered = content.lowercased()
|
|
||||||
let ns = lowered as NSString
|
|
||||||
let range = NSRange(location: 0, length: ns.length)
|
|
||||||
|
|
||||||
// Simplify URLs by stripping query/fragment
|
|
||||||
var simplified = ""
|
|
||||||
var last = 0
|
|
||||||
for match in simplifyHTTPURL.matches(in: lowered, options: [], range: range) {
|
|
||||||
if match.range.location > last {
|
|
||||||
simplified += ns.substring(with: NSRange(location: last, length: match.range.location - last))
|
|
||||||
}
|
|
||||||
let url = ns.substring(with: match.range)
|
|
||||||
if let queryIndex = url.firstIndex(where: { $0 == "?" || $0 == "#" }) {
|
|
||||||
simplified += String(url[..<queryIndex])
|
|
||||||
} else {
|
|
||||||
simplified += url
|
|
||||||
}
|
|
||||||
last = match.range.location + match.range.length
|
|
||||||
}
|
|
||||||
if last < ns.length {
|
|
||||||
simplified += ns.substring(with: NSRange(location: last, length: ns.length - last))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trim and collapse whitespace
|
|
||||||
let trimmed = simplified.trimmed
|
|
||||||
let collapsed = trimmed.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
|
||||||
|
|
||||||
// Take prefix and hash
|
|
||||||
let prefix = String(collapsed.prefix(prefixLength))
|
|
||||||
let hash = prefix.djb2()
|
|
||||||
return String(format: "h:%016llx", hash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Message Deduplication Service
|
|
||||||
|
|
||||||
/// Service that manages message deduplication using LRU caches.
|
|
||||||
/// Provides separate caches for content-based dedup and Nostr event ID dedup.
|
|
||||||
/// Thread-safe via @MainActor - all callers are already on main actor.
|
|
||||||
@MainActor
|
|
||||||
final class MessageDeduplicationService {
|
|
||||||
|
|
||||||
/// Cache for content-based near-duplicate detection
|
|
||||||
private let contentCache: LRUDeduplicationCache<Date>
|
|
||||||
|
|
||||||
/// Cache for Nostr event ID deduplication
|
|
||||||
private let nostrEventCache: LRUDeduplicationCache<Bool>
|
|
||||||
|
|
||||||
/// Cache for Nostr ACK deduplication (messageId:ackType:senderPubkey format)
|
|
||||||
private let nostrAckCache: LRUDeduplicationCache<Bool>
|
|
||||||
|
|
||||||
/// Creates a new deduplication service with specified capacities.
|
|
||||||
/// - Parameters:
|
|
||||||
/// - contentCapacity: Max entries for content cache
|
|
||||||
/// - nostrEventCapacity: Max entries for Nostr event cache
|
|
||||||
init(
|
|
||||||
contentCapacity: Int = TransportConfig.contentLRUCap,
|
|
||||||
nostrEventCapacity: Int = TransportConfig.uiProcessedNostrEventsCap
|
|
||||||
) {
|
|
||||||
self.contentCache = LRUDeduplicationCache(capacity: contentCapacity)
|
|
||||||
self.nostrEventCache = LRUDeduplicationCache(capacity: nostrEventCapacity)
|
|
||||||
self.nostrAckCache = LRUDeduplicationCache(capacity: nostrEventCapacity)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Content Deduplication
|
|
||||||
|
|
||||||
/// Records content with its timestamp for near-duplicate detection.
|
|
||||||
/// - Parameters:
|
|
||||||
/// - content: The message content
|
|
||||||
/// - timestamp: When the content was received
|
|
||||||
func recordContent(_ content: String, timestamp: Date) {
|
|
||||||
let key = ContentNormalizer.normalizedKey(content)
|
|
||||||
contentCache.record(key, value: timestamp)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Records a pre-normalized content key with its timestamp.
|
|
||||||
/// - Parameters:
|
|
||||||
/// - key: The normalized content key
|
|
||||||
/// - timestamp: When the content was received
|
|
||||||
func recordContentKey(_ key: String, timestamp: Date) {
|
|
||||||
contentCache.record(key, value: timestamp)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets the timestamp for previously seen content.
|
|
||||||
/// - Parameter content: The message content
|
|
||||||
/// - Returns: The timestamp when first seen, or nil if not seen
|
|
||||||
func contentTimestamp(for content: String) -> Date? {
|
|
||||||
let key = ContentNormalizer.normalizedKey(content)
|
|
||||||
return contentCache.value(for: key)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets the timestamp for a pre-normalized content key.
|
|
||||||
/// - Parameter key: The normalized content key
|
|
||||||
/// - Returns: The timestamp when first seen, or nil if not seen
|
|
||||||
func contentTimestamp(forKey key: String) -> Date? {
|
|
||||||
contentCache.value(for: key)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Normalizes content to a deduplication key.
|
|
||||||
/// - Parameter content: The raw content
|
|
||||||
/// - Returns: A normalized hash key
|
|
||||||
func normalizedContentKey(_ content: String) -> String {
|
|
||||||
ContentNormalizer.normalizedKey(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Nostr Event Deduplication
|
|
||||||
|
|
||||||
/// Checks if a Nostr event has already been processed.
|
|
||||||
/// - Parameter eventId: The event ID
|
|
||||||
/// - Returns: true if already processed
|
|
||||||
func hasProcessedNostrEvent(_ eventId: String) -> Bool {
|
|
||||||
nostrEventCache.contains(eventId)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Records a Nostr event as processed.
|
|
||||||
/// - Parameter eventId: The event ID
|
|
||||||
func recordNostrEvent(_ eventId: String) {
|
|
||||||
nostrEventCache.record(eventId, value: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Nostr ACK Deduplication
|
|
||||||
|
|
||||||
/// Checks if a Nostr ACK has already been processed.
|
|
||||||
/// - Parameter ackKey: The ACK key in format "messageId:ackType:senderPubkey"
|
|
||||||
/// - Returns: true if already processed
|
|
||||||
func hasProcessedNostrAck(_ ackKey: String) -> Bool {
|
|
||||||
nostrAckCache.contains(ackKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Records a Nostr ACK as processed.
|
|
||||||
/// - Parameter ackKey: The ACK key in format "messageId:ackType:senderPubkey"
|
|
||||||
func recordNostrAck(_ ackKey: String) {
|
|
||||||
nostrAckCache.record(ackKey, value: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates an ACK key from components.
|
|
||||||
static func ackKey(messageId: String, ackType: String, senderPubkey: String) -> String {
|
|
||||||
"\(messageId):\(ackType):\(senderPubkey)"
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Clear
|
|
||||||
|
|
||||||
/// Clears all caches
|
|
||||||
func clearAll() {
|
|
||||||
contentCache.clear()
|
|
||||||
nostrEventCache.clear()
|
|
||||||
nostrAckCache.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clears only the Nostr caches (events and ACKs)
|
|
||||||
func clearNostrCaches() {
|
|
||||||
nostrEventCache.clear()
|
|
||||||
nostrAckCache.clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,472 +0,0 @@
|
|||||||
//
|
|
||||||
// MessageFormattingEngine.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Handles message text formatting, including mentions, hashtags, URLs, and tokens.
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
//
|
|
||||||
|
|
||||||
import BitFoundation
|
|
||||||
import Foundation
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
// MARK: - Formatting Context Protocol
|
|
||||||
|
|
||||||
/// Protocol defining the context needed for message formatting.
|
|
||||||
/// Implemented by ChatViewModel to provide runtime state.
|
|
||||||
@MainActor
|
|
||||||
protocol MessageFormattingContext: AnyObject {
|
|
||||||
/// The user's current nickname
|
|
||||||
var nickname: String { get }
|
|
||||||
|
|
||||||
/// Determines if a message was sent by the current user
|
|
||||||
func isSelfMessage(_ message: BitchatMessage) -> Bool
|
|
||||||
|
|
||||||
/// Gets the color for a message's sender
|
|
||||||
func senderColor(for message: BitchatMessage, isDark: Bool) -> Color
|
|
||||||
|
|
||||||
/// Resolves a peer ID to a clickable URL
|
|
||||||
func peerURL(for peerID: PeerID) -> URL?
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Formatting Engine
|
|
||||||
|
|
||||||
/// Handles rich text formatting for chat messages.
|
|
||||||
/// Extracts mentions, hashtags, URLs, Lightning invoices, and Cashu tokens.
|
|
||||||
final class MessageFormattingEngine {
|
|
||||||
|
|
||||||
// MARK: - Precompiled Regexes
|
|
||||||
|
|
||||||
/// Precompiled regex patterns for message content parsing
|
|
||||||
enum Patterns {
|
|
||||||
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: [])
|
|
||||||
}()
|
|
||||||
|
|
||||||
static let simplifyHTTPURL: NSRegularExpression = {
|
|
||||||
try! NSRegularExpression(pattern: "https?://[^\\s?#]+(?:[?#][^\\s]*)?", options: [.caseInsensitive])
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Match Types
|
|
||||||
|
|
||||||
/// Types of matches found in message content
|
|
||||||
enum MatchType: String {
|
|
||||||
case hashtag
|
|
||||||
case mention
|
|
||||||
case url
|
|
||||||
case cashu
|
|
||||||
case lightning
|
|
||||||
case bolt11
|
|
||||||
case lnurl
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A match found in message content
|
|
||||||
struct ContentMatch {
|
|
||||||
let range: NSRange
|
|
||||||
let type: MatchType
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public API
|
|
||||||
|
|
||||||
/// Formats a message with rich text styling
|
|
||||||
@MainActor
|
|
||||||
static func formatMessage(
|
|
||||||
_ message: BitchatMessage,
|
|
||||||
context: MessageFormattingContext,
|
|
||||||
colorScheme: ColorScheme
|
|
||||||
) -> AttributedString {
|
|
||||||
let isDark = colorScheme == .dark
|
|
||||||
let isSelf = context.isSelfMessage(message)
|
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
if let cached = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf) {
|
|
||||||
return cached
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = AttributedString()
|
|
||||||
let baseColor: Color = isSelf ? .orange : context.senderColor(for: message, isDark: isDark)
|
|
||||||
|
|
||||||
// Format system messages differently
|
|
||||||
if message.sender == "system" {
|
|
||||||
result = formatSystemMessage(message, isDark: isDark)
|
|
||||||
} else {
|
|
||||||
// Format sender header
|
|
||||||
result = formatSenderHeader(
|
|
||||||
message: message,
|
|
||||||
baseColor: baseColor,
|
|
||||||
isSelf: isSelf,
|
|
||||||
context: context
|
|
||||||
)
|
|
||||||
|
|
||||||
// Format content
|
|
||||||
let contentResult = formatContent(
|
|
||||||
message.content,
|
|
||||||
baseColor: baseColor,
|
|
||||||
isSelf: isSelf,
|
|
||||||
isMentioned: message.mentions?.contains(context.nickname) ?? false
|
|
||||||
)
|
|
||||||
result.append(contentResult)
|
|
||||||
|
|
||||||
// Add timestamp
|
|
||||||
result.append(formatTimestamp(message.formattedTimestamp))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf)
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Formats just the message header (sender portion)
|
|
||||||
@MainActor
|
|
||||||
static func formatHeader(
|
|
||||||
_ message: BitchatMessage,
|
|
||||||
context: MessageFormattingContext,
|
|
||||||
colorScheme: ColorScheme
|
|
||||||
) -> AttributedString {
|
|
||||||
let isDark = colorScheme == .dark
|
|
||||||
let isSelf = context.isSelfMessage(message)
|
|
||||||
let baseColor: Color = isSelf ? .orange : context.senderColor(for: message, isDark: isDark)
|
|
||||||
|
|
||||||
if message.sender == "system" {
|
|
||||||
var style = AttributeContainer()
|
|
||||||
style.foregroundColor = baseColor
|
|
||||||
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
|
|
||||||
return AttributedString(message.sender).mergingAttributes(style)
|
|
||||||
}
|
|
||||||
|
|
||||||
return formatSenderHeader(
|
|
||||||
message: message,
|
|
||||||
baseColor: baseColor,
|
|
||||||
isSelf: isSelf,
|
|
||||||
context: context
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extracts mentions from message content
|
|
||||||
static func extractMentions(from content: String) -> [String] {
|
|
||||||
let nsContent = content as NSString
|
|
||||||
let range = NSRange(location: 0, length: nsContent.length)
|
|
||||||
let matches = Patterns.mention.matches(in: content, options: [], range: range)
|
|
||||||
|
|
||||||
return matches.compactMap { match -> String? in
|
|
||||||
guard match.numberOfRanges > 1 else { return nil }
|
|
||||||
let captureRange = match.range(at: 1)
|
|
||||||
guard let swiftRange = Range(captureRange, in: content) else { return nil }
|
|
||||||
return String(content[swiftRange])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks if content contains a Cashu token
|
|
||||||
static func containsCashuToken(_ content: String) -> Bool {
|
|
||||||
let nsContent = content as NSString
|
|
||||||
let range = NSRange(location: 0, length: nsContent.length)
|
|
||||||
return Patterns.quickCashuPresence.numberOfMatches(in: content, options: [], range: range) > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Helpers
|
|
||||||
|
|
||||||
private static func formatSystemMessage(_ message: BitchatMessage, isDark: Bool) -> AttributedString {
|
|
||||||
var result = AttributedString()
|
|
||||||
|
|
||||||
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))
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private static func formatSenderHeader(
|
|
||||||
message: BitchatMessage,
|
|
||||||
baseColor: Color,
|
|
||||||
isSelf: Bool,
|
|
||||||
context: MessageFormattingContext
|
|
||||||
) -> AttributedString {
|
|
||||||
var result = AttributedString()
|
|
||||||
|
|
||||||
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
|
|
||||||
if let spid = message.senderPeerID, let url = context.peerURL(for: spid) {
|
|
||||||
senderStyle.link = url
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build: "<@baseName#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))
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func formatContent(
|
|
||||||
_ content: String,
|
|
||||||
baseColor: Color,
|
|
||||||
isSelf: Bool,
|
|
||||||
isMentioned: Bool
|
|
||||||
) -> AttributedString {
|
|
||||||
// For very long content without special tokens, use plain formatting
|
|
||||||
let containsCashu = containsCashuToken(content)
|
|
||||||
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashu {
|
|
||||||
return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find all matches
|
|
||||||
let matches = findAllMatches(in: content)
|
|
||||||
|
|
||||||
// Build formatted content
|
|
||||||
var result = AttributedString()
|
|
||||||
var lastEnd = content.startIndex
|
|
||||||
|
|
||||||
for match in matches {
|
|
||||||
guard let swiftRange = Range(match.range, in: content) else { continue }
|
|
||||||
|
|
||||||
// Add text before match
|
|
||||||
if lastEnd < swiftRange.lowerBound {
|
|
||||||
let beforeText = String(content[lastEnd..<swiftRange.lowerBound])
|
|
||||||
result.append(formatPlainText(beforeText, baseColor: baseColor, isSelf: isSelf, isMentioned: isMentioned))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add styled match
|
|
||||||
let matchText = String(content[swiftRange])
|
|
||||||
result.append(formatMatch(matchText, type: match.type, baseColor: baseColor, isSelf: isSelf))
|
|
||||||
|
|
||||||
lastEnd = swiftRange.upperBound
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add remaining text
|
|
||||||
if lastEnd < content.endIndex {
|
|
||||||
let remainingText = String(content[lastEnd...])
|
|
||||||
result.append(formatPlainText(remainingText, baseColor: baseColor, isSelf: isSelf, isMentioned: isMentioned))
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func findAllMatches(in content: String) -> [ContentMatch] {
|
|
||||||
let nsContent = content as NSString
|
|
||||||
let nsLen = nsContent.length
|
|
||||||
let fullRange = NSRange(location: 0, length: nsLen)
|
|
||||||
|
|
||||||
// Quick hints to avoid unnecessary regex work
|
|
||||||
let hasMentions = content.contains("@")
|
|
||||||
let hasHashtags = content.contains("#")
|
|
||||||
let hasURLs = content.contains("://") || content.contains("www.") || content.contains("http")
|
|
||||||
let hasLightning = content.lowercased().contains("ln") || content.lowercased().contains("lightning:")
|
|
||||||
let hasCashu = content.lowercased().contains("cashu")
|
|
||||||
|
|
||||||
// Collect matches
|
|
||||||
let mentionMatches = hasMentions ? Patterns.mention.matches(in: content, options: [], range: fullRange) : []
|
|
||||||
let hashtagMatches = hasHashtags ? Patterns.hashtag.matches(in: content, options: [], range: fullRange) : []
|
|
||||||
let urlMatches = hasURLs ? (Patterns.linkDetector?.matches(in: content, options: [], range: fullRange) ?? []) : []
|
|
||||||
let cashuMatches = hasCashu ? Patterns.cashu.matches(in: content, options: [], range: fullRange) : []
|
|
||||||
let lightningMatches = hasLightning ? Patterns.lightningScheme.matches(in: content, options: [], range: fullRange) : []
|
|
||||||
let bolt11Matches = hasLightning ? Patterns.bolt11.matches(in: content, options: [], range: fullRange) : []
|
|
||||||
let lnurlMatches = hasLightning ? Patterns.lnurl.matches(in: content, options: [], range: fullRange) : []
|
|
||||||
|
|
||||||
// Build mention ranges for overlap checking
|
|
||||||
let mentionRanges = mentionMatches.map { $0.range(at: 0) }
|
|
||||||
|
|
||||||
func overlapsMention(_ r: NSRange) -> Bool {
|
|
||||||
mentionRanges.contains { NSIntersectionRange(r, $0).length > 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
func isStandaloneHashtag(_ r: NSRange) -> Bool {
|
|
||||||
guard let swiftRange = Range(r, in: content) else { return false }
|
|
||||||
if swiftRange.lowerBound == content.startIndex { return true }
|
|
||||||
let prev = content.index(before: swiftRange.lowerBound)
|
|
||||||
return content[prev].isWhitespace || content[prev].isNewline
|
|
||||||
}
|
|
||||||
|
|
||||||
func attachedToMention(_ r: NSRange) -> Bool {
|
|
||||||
guard let swiftRange = Range(r, in: content), swiftRange.lowerBound > content.startIndex else { return false }
|
|
||||||
var i = content.index(before: swiftRange.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
|
|
||||||
}
|
|
||||||
|
|
||||||
var allMatches: [ContentMatch] = []
|
|
||||||
|
|
||||||
// Add hashtags (excluding those attached to mentions)
|
|
||||||
for match in hashtagMatches {
|
|
||||||
let range = match.range(at: 0)
|
|
||||||
if !overlapsMention(range) && !attachedToMention(range) && isStandaloneHashtag(range) {
|
|
||||||
allMatches.append(ContentMatch(range: range, type: .hashtag))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add mentions
|
|
||||||
for match in mentionMatches {
|
|
||||||
allMatches.append(ContentMatch(range: match.range(at: 0), type: .mention))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add URLs
|
|
||||||
for match in urlMatches where !overlapsMention(match.range) {
|
|
||||||
allMatches.append(ContentMatch(range: match.range, type: .url))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add Cashu tokens
|
|
||||||
for match in cashuMatches where !overlapsMention(match.range(at: 0)) {
|
|
||||||
allMatches.append(ContentMatch(range: match.range(at: 0), type: .cashu))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add Lightning scheme URLs
|
|
||||||
for match in lightningMatches where !overlapsMention(match.range(at: 0)) {
|
|
||||||
allMatches.append(ContentMatch(range: match.range(at: 0), type: .lightning))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add bolt11/lnurl (avoiding overlaps with lightning scheme and URLs)
|
|
||||||
let occupied = urlMatches.map { $0.range } + lightningMatches.map { $0.range(at: 0) }
|
|
||||||
func overlapsOccupied(_ r: NSRange) -> Bool {
|
|
||||||
occupied.contains { NSIntersectionRange(r, $0).length > 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
for match in bolt11Matches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
|
|
||||||
allMatches.append(ContentMatch(range: match.range(at: 0), type: .bolt11))
|
|
||||||
}
|
|
||||||
|
|
||||||
for match in lnurlMatches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
|
|
||||||
allMatches.append(ContentMatch(range: match.range(at: 0), type: .lnurl))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by position
|
|
||||||
return allMatches.sorted { $0.range.location < $1.range.location }
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func formatPlainContent(_ content: String, baseColor: Color, isSelf: Bool) -> AttributedString {
|
|
||||||
var style = AttributeContainer()
|
|
||||||
style.foregroundColor = baseColor
|
|
||||||
style.font = isSelf
|
|
||||||
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
|
||||||
: .bitchatSystem(size: 14, design: .monospaced)
|
|
||||||
return AttributedString(content).mergingAttributes(style)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func formatPlainText(_ text: String, baseColor: Color, isSelf: Bool, isMentioned: Bool) -> AttributedString {
|
|
||||||
guard !text.isEmpty else { return AttributedString() }
|
|
||||||
|
|
||||||
var style = AttributeContainer()
|
|
||||||
style.foregroundColor = baseColor
|
|
||||||
style.font = isSelf
|
|
||||||
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
|
||||||
: .bitchatSystem(size: 14, design: .monospaced)
|
|
||||||
|
|
||||||
if isMentioned {
|
|
||||||
style.font = style.font?.bold()
|
|
||||||
}
|
|
||||||
|
|
||||||
return AttributedString(text).mergingAttributes(style)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func formatMatch(_ text: String, type: MatchType, baseColor: Color, isSelf: Bool) -> AttributedString {
|
|
||||||
var style = AttributeContainer()
|
|
||||||
|
|
||||||
switch type {
|
|
||||||
case .mention:
|
|
||||||
// Split optional '#abcd' suffix
|
|
||||||
let (baseName, suffix) = text.splitSuffix()
|
|
||||||
var result = AttributedString()
|
|
||||||
|
|
||||||
var mentionStyle = AttributeContainer()
|
|
||||||
mentionStyle.foregroundColor = .blue
|
|
||||||
mentionStyle.font = .bitchatSystem(size: 14, weight: .semibold, design: .monospaced)
|
|
||||||
result.append(AttributedString(baseName).mergingAttributes(mentionStyle))
|
|
||||||
|
|
||||||
if !suffix.isEmpty {
|
|
||||||
var suffixStyle = mentionStyle
|
|
||||||
suffixStyle.foregroundColor = Color.gray.opacity(0.7)
|
|
||||||
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
case .hashtag:
|
|
||||||
style.foregroundColor = .purple
|
|
||||||
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
|
|
||||||
|
|
||||||
case .url:
|
|
||||||
style.foregroundColor = .blue
|
|
||||||
style.font = .bitchatSystem(size: 14, design: .monospaced)
|
|
||||||
style.underlineStyle = .single
|
|
||||||
if let url = URL(string: text) {
|
|
||||||
style.link = url
|
|
||||||
}
|
|
||||||
|
|
||||||
case .cashu:
|
|
||||||
style.foregroundColor = .green
|
|
||||||
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
|
|
||||||
style.backgroundColor = Color.green.opacity(0.1)
|
|
||||||
|
|
||||||
case .lightning, .bolt11, .lnurl:
|
|
||||||
style.foregroundColor = .yellow
|
|
||||||
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
|
|
||||||
style.backgroundColor = Color.yellow.opacity(0.1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return AttributedString(text).mergingAttributes(style)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func formatTimestamp(_ timestamp: String) -> AttributedString {
|
|
||||||
let text = AttributedString(" [\(timestamp)]")
|
|
||||||
var style = AttributeContainer()
|
|
||||||
style.foregroundColor = Color.gray.opacity(0.5)
|
|
||||||
style.font = .bitchatSystem(size: 10, design: .monospaced)
|
|
||||||
return text.mergingAttributes(style)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +1,15 @@
|
|||||||
import BitLogger
|
|
||||||
import BitFoundation
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Routes messages using available transports (Mesh, Nostr, etc.)
|
/// Routes messages between BLE and Nostr transports
|
||||||
@MainActor
|
@MainActor
|
||||||
final class MessageRouter {
|
final class MessageRouter {
|
||||||
private let transports: [Transport]
|
private let mesh: Transport
|
||||||
|
private let nostr: NostrOutboxService
|
||||||
|
private var outbox: [String: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
|
||||||
|
|
||||||
// Outbox entry with timestamp for TTL-based eviction
|
init(mesh: Transport, nostr: NostrOutboxService) {
|
||||||
private struct QueuedMessage {
|
self.mesh = mesh
|
||||||
let content: String
|
self.nostr = nostr
|
||||||
let nickname: String
|
|
||||||
let messageID: String
|
|
||||||
let timestamp: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
private var outbox: [PeerID: [QueuedMessage]] = [:]
|
|
||||||
|
|
||||||
// Outbox limits to prevent unbounded memory growth
|
|
||||||
private static let maxMessagesPerPeer = 100
|
|
||||||
private static let messageTTLSeconds: TimeInterval = 24 * 60 * 60 // 24 hours
|
|
||||||
|
|
||||||
init(transports: [Transport]) {
|
|
||||||
self.transports = transports
|
|
||||||
|
|
||||||
// Observe favorites changes to learn Nostr mapping and flush queued messages
|
// Observe favorites changes to learn Nostr mapping and flush queued messages
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
@@ -32,7 +19,7 @@ final class MessageRouter {
|
|||||||
) { [weak self] note in
|
) { [weak self] note in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
if let data = note.userInfo?["peerPublicKey"] as? Data {
|
if let data = note.userInfo?["peerPublicKey"] as? Data {
|
||||||
let peerID = PeerID(publicKey: data)
|
let peerID = PeerIDUtils.derivePeerID(fromPublicKey: data)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.flushOutbox(for: peerID)
|
self.flushOutbox(for: peerID)
|
||||||
}
|
}
|
||||||
@@ -40,7 +27,7 @@ final class MessageRouter {
|
|||||||
// Handle key updates
|
// Handle key updates
|
||||||
if let newKey = note.userInfo?["peerPublicKey"] as? Data,
|
if let newKey = note.userInfo?["peerPublicKey"] as? Data,
|
||||||
let _ = note.userInfo?["isKeyUpdate"] as? Bool {
|
let _ = note.userInfo?["isKeyUpdate"] as? Bool {
|
||||||
let peerID = PeerID(publicKey: newKey)
|
let peerID = PeerIDUtils.derivePeerID(fromPublicKey: newKey)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.flushOutbox(for: peerID)
|
self.flushOutbox(for: peerID)
|
||||||
}
|
}
|
||||||
@@ -48,106 +35,90 @@ final class MessageRouter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Transport Selection
|
func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
|
||||||
|
let reachableMesh = mesh.isPeerReachable(peerID)
|
||||||
private func reachableTransport(for peerID: PeerID) -> Transport? {
|
if reachableMesh {
|
||||||
transports.first { $0.isPeerReachable(peerID) }
|
SecureLogger.log("Routing PM via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||||
}
|
category: SecureLogger.session, level: .debug)
|
||||||
|
// BLEService will initiate a handshake if needed and queue the message
|
||||||
private func connectedTransport(for peerID: PeerID) -> Transport? {
|
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||||
transports.first { $0.isPeerConnected(peerID) }
|
} else if canSendViaNostr(peerID: peerID) {
|
||||||
}
|
SecureLogger.log("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||||
|
category: SecureLogger.session, level: .debug)
|
||||||
// MARK: - Message Sending
|
nostr.sendPrivate(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||||
|
|
||||||
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
|
||||||
if let transport = reachableTransport(for: peerID) {
|
|
||||||
SecureLogger.debug("Routing PM via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
|
||||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
|
||||||
} else {
|
} else {
|
||||||
// Queue for later with timestamp for TTL tracking
|
// Queue for later (when mesh connects or Nostr mapping appears)
|
||||||
if outbox[peerID] == nil { outbox[peerID] = [] }
|
if outbox[peerID] == nil { outbox[peerID] = [] }
|
||||||
|
outbox[peerID]?.append((content, recipientNickname, messageID))
|
||||||
let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: Date())
|
SecureLogger.log("Queued PM for \(peerID.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))…",
|
||||||
outbox[peerID]?.append(message)
|
category: SecureLogger.session, level: .debug)
|
||||||
|
|
||||||
// Enforce per-peer size limit with FIFO eviction
|
|
||||||
if let count = outbox[peerID]?.count, count > Self.maxMessagesPerPeer {
|
|
||||||
let evicted = outbox[peerID]?.removeFirst()
|
|
||||||
SecureLogger.warning("📤 Outbox overflow for \(peerID.id.prefix(8))… - evicted oldest message: \(evicted?.messageID.prefix(8) ?? "?")…", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
|
||||||
if let transport = reachableTransport(for: peerID) {
|
// Prefer mesh for reachable peers; BLE will queue if handshake is needed
|
||||||
SecureLogger.debug("Routing READ ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
if mesh.isPeerReachable(peerID) {
|
||||||
transport.sendReadReceipt(receipt, to: peerID)
|
SecureLogger.log("Routing READ ack via mesh (reachable) to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…",
|
||||||
} else if !transports.isEmpty {
|
category: SecureLogger.session, level: .debug)
|
||||||
SecureLogger.debug("No reachable transport for READ ack to \(peerID.id.prefix(8))…", category: .session)
|
mesh.sendReadReceipt(receipt, to: peerID)
|
||||||
|
} else {
|
||||||
|
SecureLogger.log("Routing READ ack via Nostr to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…",
|
||||||
|
category: SecureLogger.session, level: .debug)
|
||||||
|
nostr.sendReadReceipt(receipt, to: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendDeliveryAck(_ messageID: String, to peerID: PeerID) {
|
func sendDeliveryAck(_ messageID: String, to peerID: String) {
|
||||||
if let transport = reachableTransport(for: peerID) {
|
if mesh.isPeerReachable(peerID) {
|
||||||
SecureLogger.debug("Routing DELIVERED ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.log("Routing DELIVERED ack via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||||
transport.sendDeliveryAck(for: messageID, to: peerID)
|
category: SecureLogger.session, level: .debug)
|
||||||
|
mesh.sendDeliveryAck(for: messageID, to: peerID)
|
||||||
|
} else {
|
||||||
|
nostr.sendDeliveryAck(for: messageID, to: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
|
||||||
if let transport = connectedTransport(for: peerID) {
|
if mesh.isPeerConnected(peerID) {
|
||||||
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||||
} else if let transport = reachableTransport(for: peerID) {
|
} else {
|
||||||
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
nostr.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Outbox Management
|
// MARK: - Outbox Management
|
||||||
|
private func canSendViaNostr(peerID: String) -> Bool {
|
||||||
|
guard let noiseKey = Data(hexString: peerID) else { return false }
|
||||||
|
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||||
|
fav.peerNostrPublicKey != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func flushOutbox(for peerID: PeerID) {
|
func flushOutbox(for peerID: String) {
|
||||||
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
||||||
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
|
SecureLogger.log("Flushing outbox for \(peerID.prefix(8))… count=\(queued.count)",
|
||||||
|
category: SecureLogger.session, level: .debug)
|
||||||
let now = Date()
|
// Prefer mesh if connected; else try Nostr if mapping exists
|
||||||
var remaining: [QueuedMessage] = []
|
for (content, nickname, messageID) in queued {
|
||||||
|
if mesh.isPeerReachable(peerID) {
|
||||||
for message in queued {
|
SecureLogger.log("Outbox -> mesh for \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||||
// Skip expired messages (TTL exceeded)
|
category: SecureLogger.session, level: .debug)
|
||||||
if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds {
|
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||||
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… (age: \(Int(now.timeIntervalSince(message.timestamp)))s)", category: .session)
|
} else if canSendViaNostr(peerID: peerID) {
|
||||||
|
SecureLogger.log("Outbox -> Nostr for \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||||
|
category: SecureLogger.session, level: .debug)
|
||||||
|
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||||
|
} else {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if let transport = reachableTransport(for: peerID) {
|
|
||||||
SecureLogger.debug("Outbox -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
|
||||||
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
|
||||||
} else {
|
|
||||||
remaining.append(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if remaining.isEmpty {
|
|
||||||
outbox.removeValue(forKey: peerID)
|
|
||||||
} else {
|
|
||||||
outbox[peerID] = remaining
|
|
||||||
}
|
}
|
||||||
|
// Remove all flushed items (remaining ones, if any, will be re-queued on next call)
|
||||||
|
outbox[peerID]?.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
func flushAllOutbox() {
|
func flushAllOutbox() {
|
||||||
for key in Array(outbox.keys) { flushOutbox(for: key) }
|
for key in outbox.keys { flushOutbox(for: key) }
|
||||||
}
|
|
||||||
|
|
||||||
/// Periodically clean up expired messages from all outboxes
|
|
||||||
func cleanupExpiredMessages() {
|
|
||||||
let now = Date()
|
|
||||||
for peerID in Array(outbox.keys) {
|
|
||||||
outbox[peerID]?.removeAll { now.timeIntervalSince($0.timestamp) > Self.messageTTLSeconds }
|
|
||||||
if outbox[peerID]?.isEmpty == true {
|
|
||||||
outbox.removeValue(forKey: peerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,176 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import BitLogger
|
|
||||||
import Combine
|
|
||||||
import Tor
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
protocol NetworkActivationTorControlling: AnyObject {
|
|
||||||
func setAutoStartAllowed(_ allowed: Bool)
|
|
||||||
func startIfNeeded()
|
|
||||||
func shutdownCompletely()
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
protocol NetworkActivationRelayControlling: AnyObject {
|
|
||||||
func connect()
|
|
||||||
func disconnect()
|
|
||||||
}
|
|
||||||
|
|
||||||
protocol NetworkActivationProxyControlling: AnyObject {
|
|
||||||
func setProxyMode(useTor: Bool)
|
|
||||||
}
|
|
||||||
|
|
||||||
extension TorManager: NetworkActivationTorControlling {}
|
|
||||||
extension NostrRelayManager: NetworkActivationRelayControlling {}
|
|
||||||
extension TorURLSession: NetworkActivationProxyControlling {}
|
|
||||||
|
|
||||||
/// 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 let storage: UserDefaults
|
|
||||||
private let locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>
|
|
||||||
private let mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>
|
|
||||||
private let permissionProvider: () -> LocationChannelManager.PermissionState
|
|
||||||
private let mutualFavoritesProvider: () -> Set<Data>
|
|
||||||
private let torController: NetworkActivationTorControlling
|
|
||||||
private let relayController: NetworkActivationRelayControlling
|
|
||||||
private let proxyController: NetworkActivationProxyControlling
|
|
||||||
private let notificationCenter: NotificationCenter
|
|
||||||
|
|
||||||
private init() {
|
|
||||||
storage = .standard
|
|
||||||
locationPermissionPublisher = LocationChannelManager.shared.$permissionState.eraseToAnyPublisher()
|
|
||||||
mutualFavoritesPublisher = FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher()
|
|
||||||
permissionProvider = { LocationChannelManager.shared.permissionState }
|
|
||||||
mutualFavoritesProvider = { FavoritesPersistenceService.shared.mutualFavorites }
|
|
||||||
torController = TorManager.shared
|
|
||||||
relayController = NostrRelayManager.shared
|
|
||||||
proxyController = TorURLSession.shared
|
|
||||||
notificationCenter = .default
|
|
||||||
}
|
|
||||||
|
|
||||||
internal init(
|
|
||||||
storage: UserDefaults,
|
|
||||||
locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>,
|
|
||||||
mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>,
|
|
||||||
permissionProvider: @escaping () -> LocationChannelManager.PermissionState,
|
|
||||||
mutualFavoritesProvider: @escaping () -> Set<Data>,
|
|
||||||
torController: NetworkActivationTorControlling,
|
|
||||||
relayController: NetworkActivationRelayControlling,
|
|
||||||
proxyController: NetworkActivationProxyControlling,
|
|
||||||
notificationCenter: NotificationCenter = .default
|
|
||||||
) {
|
|
||||||
self.storage = storage
|
|
||||||
self.locationPermissionPublisher = locationPermissionPublisher
|
|
||||||
self.mutualFavoritesPublisher = mutualFavoritesPublisher
|
|
||||||
self.permissionProvider = permissionProvider
|
|
||||||
self.mutualFavoritesProvider = mutualFavoritesProvider
|
|
||||||
self.torController = torController
|
|
||||||
self.relayController = relayController
|
|
||||||
self.proxyController = proxyController
|
|
||||||
self.notificationCenter = notificationCenter
|
|
||||||
}
|
|
||||||
|
|
||||||
func start() {
|
|
||||||
guard !started else { return }
|
|
||||||
started = true
|
|
||||||
|
|
||||||
if let stored = storage.object(forKey: torPreferenceKey) as? Bool {
|
|
||||||
userTorEnabled = stored
|
|
||||||
} else {
|
|
||||||
userTorEnabled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initial compute
|
|
||||||
let allowed = basePolicyAllowed()
|
|
||||||
activationAllowed = allowed
|
|
||||||
torAutoStartDesired = allowed && userTorEnabled
|
|
||||||
torController.setAutoStartAllowed(torAutoStartDesired)
|
|
||||||
applyTorState(torDesired: torAutoStartDesired)
|
|
||||||
if allowed {
|
|
||||||
relayController.connect()
|
|
||||||
} else {
|
|
||||||
relayController.disconnect()
|
|
||||||
}
|
|
||||||
|
|
||||||
// React to location permission changes
|
|
||||||
locationPermissionPublisher
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] _ in
|
|
||||||
self?.reevaluate()
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
// React to mutual favorites changes
|
|
||||||
mutualFavoritesPublisher
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] _ in
|
|
||||||
self?.reevaluate()
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setUserTorEnabled(_ enabled: Bool) {
|
|
||||||
guard enabled != userTorEnabled else { return }
|
|
||||||
userTorEnabled = enabled
|
|
||||||
storage.set(enabled, forKey: torPreferenceKey)
|
|
||||||
notificationCenter.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
|
|
||||||
torController.setAutoStartAllowed(torDesired)
|
|
||||||
applyTorState(torDesired: torDesired)
|
|
||||||
}
|
|
||||||
|
|
||||||
if allowed {
|
|
||||||
if torChanged {
|
|
||||||
// Reset relay sockets when switching transport path (Tor ↔︎ direct)
|
|
||||||
relayController.disconnect()
|
|
||||||
}
|
|
||||||
relayController.connect()
|
|
||||||
} else if statusChanged {
|
|
||||||
relayController.disconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func basePolicyAllowed() -> Bool {
|
|
||||||
let permOK = permissionProvider() == .authorized
|
|
||||||
let hasMutual = !mutualFavoritesProvider().isEmpty
|
|
||||||
return permOK || hasMutual
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applyTorState(torDesired: Bool) {
|
|
||||||
proxyController.setProxyMode(useTor: torDesired)
|
|
||||||
if torDesired {
|
|
||||||
torController.startIfNeeded()
|
|
||||||
} else {
|
|
||||||
torController.shutdownCompletely()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -62,6 +62,7 @@
|
|||||||
/// ## Integration Points
|
/// ## Integration Points
|
||||||
/// - **BLEService**: 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
|
||||||
@@ -82,11 +83,9 @@
|
|||||||
/// - Background queue for CPU-intensive operations
|
/// - Background queue for CPU-intensive operations
|
||||||
///
|
///
|
||||||
|
|
||||||
import BitLogger
|
|
||||||
import BitFoundation
|
|
||||||
import Noise
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
import os.log
|
||||||
|
|
||||||
// MARK: - Encryption Status
|
// MARK: - Encryption Status
|
||||||
|
|
||||||
@@ -117,30 +116,15 @@ enum EncryptionStatus: Equatable {
|
|||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .none:
|
case .none:
|
||||||
return String(localized: "encryption.status.failed", comment: "Status text when encryption failed")
|
return "Encryption failed"
|
||||||
case .noHandshake:
|
case .noHandshake:
|
||||||
return String(localized: "encryption.status.not_encrypted", comment: "Status text when no encryption handshake happened")
|
return "Not encrypted"
|
||||||
case .noiseHandshaking:
|
case .noiseHandshaking:
|
||||||
return String(localized: "encryption.status.establishing", comment: "Status text when encryption is being established")
|
return "Establishing encryption..."
|
||||||
case .noiseSecured:
|
case .noiseSecured:
|
||||||
return String(localized: "encryption.status.secured", comment: "Status text when encryption is secured but not verified")
|
return "Encrypted"
|
||||||
case .noiseVerified:
|
case .noiseVerified:
|
||||||
return String(localized: "encryption.status.verified", comment: "Status text when encryption is verified")
|
return "Encrypted & 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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,7 +135,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
|
||||||
final class NoiseEncryptionService {
|
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
|
||||||
@@ -164,33 +148,32 @@ final 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: [PeerID: String] = [:]
|
private var peerFingerprints: [String: String] = [:] // peerID -> fingerprint
|
||||||
private var fingerprintToPeerID: [String: PeerID] = [:]
|
private var fingerprintToPeerID: [String: String] = [:] // fingerprint -> 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
|
||||||
private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication
|
private var onPeerAuthenticatedHandlers: [((String, String) -> Void)] = [] // Array of handlers for peer authentication
|
||||||
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
|
var onHandshakeRequired: ((String) -> Void)? // peerID needs handshake
|
||||||
|
|
||||||
// Add a handler for peer authentication
|
// Add a handler for peer authentication
|
||||||
func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) {
|
func addOnPeerAuthenticatedHandler(_ handler: @escaping (String, String) -> Void) {
|
||||||
serviceQueue.async(flags: .barrier) { [weak self] in
|
serviceQueue.async(flags: .barrier) { [weak self] in
|
||||||
self?.onPeerAuthenticatedHandlers.append(handler)
|
self?.onPeerAuthenticatedHandlers.append(handler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy support - setting this will add to the handlers array
|
// Legacy support - setting this will add to the handlers array
|
||||||
var onPeerAuthenticated: ((PeerID, String) -> Void)? {
|
var onPeerAuthenticated: ((String, String) -> Void)? {
|
||||||
get { nil } // Always return nil for backward compatibility
|
get { nil } // Always return nil for backward compatibility
|
||||||
set {
|
set {
|
||||||
if let handler = newValue {
|
if let handler = newValue {
|
||||||
@@ -199,155 +182,64 @@ final class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init(keychain: KeychainManagerProtocol) {
|
init() {
|
||||||
self.keychain = keychain
|
// Load or create static identity key (ONLY from keychain)
|
||||||
|
|
||||||
// BCH-01-009: Load or create static identity key with proper error handling
|
|
||||||
let loadedKey: Curve25519.KeyAgreement.PrivateKey
|
let loadedKey: Curve25519.KeyAgreement.PrivateKey
|
||||||
|
|
||||||
// Try to load from keychain with proper error classification
|
// Try to load from keychain
|
||||||
let noiseKeyResult = keychain.getIdentityKeyWithResult(forKey: "noiseStaticKey")
|
if let identityData = KeychainManager.shared.getIdentityKey(forKey: "noiseStaticKey"),
|
||||||
|
let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) {
|
||||||
switch noiseKeyResult {
|
loadedKey = key
|
||||||
case .success(let identityData):
|
SecureLogger.logKeyOperation("load", keyType: "noiseStaticKey", success: true)
|
||||||
if let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) {
|
|
||||||
loadedKey = key
|
|
||||||
SecureLogger.logKeyOperation(.load, keyType: "noiseStaticKey", success: true)
|
|
||||||
} else {
|
|
||||||
// Data corrupted, regenerate
|
|
||||||
SecureLogger.warning("Noise static key data corrupted, regenerating", category: .keychain)
|
|
||||||
loadedKey = Self.generateAndSaveNoiseKey(keychain: keychain)
|
|
||||||
}
|
|
||||||
|
|
||||||
case .itemNotFound:
|
|
||||||
// Expected case: no key exists yet, create new one
|
|
||||||
loadedKey = Self.generateAndSaveNoiseKey(keychain: keychain)
|
|
||||||
|
|
||||||
case .accessDenied:
|
|
||||||
// Critical error - log but proceed with ephemeral key (will be lost on restart)
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: -1),
|
|
||||||
context: "Keychain access denied - using ephemeral identity", category: .keychain)
|
|
||||||
loadedKey = Curve25519.KeyAgreement.PrivateKey()
|
|
||||||
|
|
||||||
case .deviceLocked, .authenticationFailed:
|
|
||||||
// Recoverable error - use ephemeral key and warn
|
|
||||||
SecureLogger.warning("Device locked or auth failed - using ephemeral identity until unlocked", category: .keychain)
|
|
||||||
loadedKey = Curve25519.KeyAgreement.PrivateKey()
|
|
||||||
|
|
||||||
case .otherError(let status):
|
|
||||||
// Unexpected error - log and use ephemeral key
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
|
|
||||||
context: "Unexpected keychain error - using ephemeral identity", category: .keychain)
|
|
||||||
loadedKey = Curve25519.KeyAgreement.PrivateKey()
|
|
||||||
}
|
}
|
||||||
|
// If no identity exists, create new one
|
||||||
|
else {
|
||||||
|
loadedKey = Curve25519.KeyAgreement.PrivateKey()
|
||||||
|
let keyData = loadedKey.rawRepresentation
|
||||||
|
|
||||||
|
// Save to keychain
|
||||||
|
let saved = KeychainManager.shared.saveIdentityKey(keyData, forKey: "noiseStaticKey")
|
||||||
|
SecureLogger.logKeyOperation("create", keyType: "noiseStaticKey", success: saved)
|
||||||
|
}
|
||||||
|
|
||||||
// Now assign the final value
|
// Now assign the final value
|
||||||
self.staticIdentityKey = loadedKey
|
self.staticIdentityKey = loadedKey
|
||||||
self.staticIdentityPublicKey = staticIdentityKey.publicKey
|
self.staticIdentityPublicKey = staticIdentityKey.publicKey
|
||||||
|
|
||||||
// BCH-01-009: Load or create signing key pair with proper error handling
|
// Load or create signing key pair
|
||||||
let loadedSigningKey: Curve25519.Signing.PrivateKey
|
let loadedSigningKey: Curve25519.Signing.PrivateKey
|
||||||
|
|
||||||
let signingKeyResult = keychain.getIdentityKeyWithResult(forKey: "ed25519SigningKey")
|
// Try to load from keychain
|
||||||
|
if let signingData = KeychainManager.shared.getIdentityKey(forKey: "ed25519SigningKey"),
|
||||||
switch signingKeyResult {
|
let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) {
|
||||||
case .success(let signingData):
|
loadedSigningKey = key
|
||||||
if let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) {
|
SecureLogger.logKeyOperation("load", keyType: "ed25519SigningKey", success: true)
|
||||||
loadedSigningKey = key
|
|
||||||
SecureLogger.logKeyOperation(.load, keyType: "ed25519SigningKey", success: true)
|
|
||||||
} else {
|
|
||||||
// Data corrupted, regenerate
|
|
||||||
SecureLogger.warning("Ed25519 signing key data corrupted, regenerating", category: .keychain)
|
|
||||||
loadedSigningKey = Self.generateAndSaveSigningKey(keychain: keychain)
|
|
||||||
}
|
|
||||||
|
|
||||||
case .itemNotFound:
|
|
||||||
// Expected case: no key exists yet, create new one
|
|
||||||
loadedSigningKey = Self.generateAndSaveSigningKey(keychain: keychain)
|
|
||||||
|
|
||||||
case .accessDenied:
|
|
||||||
// Critical error - log but proceed with ephemeral key
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: -1),
|
|
||||||
context: "Keychain access denied - using ephemeral signing key", category: .keychain)
|
|
||||||
loadedSigningKey = Curve25519.Signing.PrivateKey()
|
|
||||||
|
|
||||||
case .deviceLocked, .authenticationFailed:
|
|
||||||
// Recoverable error - use ephemeral key and warn
|
|
||||||
SecureLogger.warning("Device locked or auth failed - using ephemeral signing key until unlocked", category: .keychain)
|
|
||||||
loadedSigningKey = Curve25519.Signing.PrivateKey()
|
|
||||||
|
|
||||||
case .otherError(let status):
|
|
||||||
// Unexpected error - log and use ephemeral key
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
|
|
||||||
context: "Unexpected keychain error - using ephemeral signing key", category: .keychain)
|
|
||||||
loadedSigningKey = Curve25519.Signing.PrivateKey()
|
|
||||||
}
|
}
|
||||||
|
// If no signing key exists, create new one
|
||||||
|
else {
|
||||||
|
loadedSigningKey = Curve25519.Signing.PrivateKey()
|
||||||
|
let keyData = loadedSigningKey.rawRepresentation
|
||||||
|
|
||||||
|
// Save to keychain
|
||||||
|
let saved = KeychainManager.shared.saveIdentityKey(keyData, forKey: "ed25519SigningKey")
|
||||||
|
SecureLogger.logKeyOperation("create", keyType: "ed25519SigningKey", success: saved)
|
||||||
|
}
|
||||||
|
|
||||||
// Now assign the signing keys
|
// Now assign the signing keys
|
||||||
self.signingKey = loadedSigningKey
|
self.signingKey = loadedSigningKey
|
||||||
self.signingPublicKey = signingKey.publicKey
|
self.signingPublicKey = signingKey.publicKey
|
||||||
|
|
||||||
// Initialize session manager
|
// Initialize session manager
|
||||||
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain)
|
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey)
|
||||||
|
|
||||||
// Set up session callbacks
|
// Set up session callbacks
|
||||||
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in
|
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in
|
||||||
self?.handleSessionEstablished(peerID: peerID, remoteStaticKey: remoteStaticKey)
|
self?.handleSessionEstablished(peerID: peerID, remoteStaticKey: remoteStaticKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start session maintenance timer
|
// Start session maintenance timer
|
||||||
startRekeyTimer()
|
startRekeyTimer()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - BCH-01-009: Key Generation Helpers with Save Verification
|
|
||||||
|
|
||||||
/// Generate and save a new Noise static key, verifying the save succeeds
|
|
||||||
private static func generateAndSaveNoiseKey(keychain: KeychainManagerProtocol) -> Curve25519.KeyAgreement.PrivateKey {
|
|
||||||
let newKey = Curve25519.KeyAgreement.PrivateKey()
|
|
||||||
let keyData = newKey.rawRepresentation
|
|
||||||
|
|
||||||
// Save to keychain and verify success
|
|
||||||
let saveResult = keychain.saveIdentityKeyWithResult(keyData, forKey: "noiseStaticKey")
|
|
||||||
|
|
||||||
switch saveResult {
|
|
||||||
case .success:
|
|
||||||
SecureLogger.logKeyOperation(.create, keyType: "noiseStaticKey", success: true)
|
|
||||||
case .duplicateItem:
|
|
||||||
// This shouldn't happen since we just tried to load, but handle it
|
|
||||||
SecureLogger.warning("Noise key already exists (race condition?)", category: .keychain)
|
|
||||||
default:
|
|
||||||
// Save failed - log but continue with the key (it will be ephemeral)
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: -1),
|
|
||||||
context: "Failed to persist noise static key - identity will be lost on restart",
|
|
||||||
category: .keychain)
|
|
||||||
}
|
|
||||||
|
|
||||||
return newKey
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate and save a new Ed25519 signing key, verifying the save succeeds
|
|
||||||
private static func generateAndSaveSigningKey(keychain: KeychainManagerProtocol) -> Curve25519.Signing.PrivateKey {
|
|
||||||
let newKey = Curve25519.Signing.PrivateKey()
|
|
||||||
let keyData = newKey.rawRepresentation
|
|
||||||
|
|
||||||
// Save to keychain and verify success
|
|
||||||
let saveResult = keychain.saveIdentityKeyWithResult(keyData, forKey: "ed25519SigningKey")
|
|
||||||
|
|
||||||
switch saveResult {
|
|
||||||
case .success:
|
|
||||||
SecureLogger.logKeyOperation(.create, keyType: "ed25519SigningKey", success: true)
|
|
||||||
case .duplicateItem:
|
|
||||||
// This shouldn't happen since we just tried to load, but handle it
|
|
||||||
SecureLogger.warning("Signing key already exists (race condition?)", category: .keychain)
|
|
||||||
default:
|
|
||||||
// Save failed - log but continue with the key (it will be ephemeral)
|
|
||||||
SecureLogger.error(NSError(domain: "Keychain", code: -1),
|
|
||||||
context: "Failed to persist signing key - identity will be lost on restart",
|
|
||||||
category: .keychain)
|
|
||||||
}
|
|
||||||
|
|
||||||
return newKey
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public Interface
|
// MARK: - Public Interface
|
||||||
|
|
||||||
@@ -363,21 +255,22 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
/// Get our identity fingerprint
|
/// Get our identity fingerprint
|
||||||
func getIdentityFingerprint() -> String {
|
func getIdentityFingerprint() -> String {
|
||||||
staticIdentityPublicKey.rawRepresentation.sha256Fingerprint()
|
let hash = SHA256.hash(data: staticIdentityPublicKey.rawRepresentation)
|
||||||
|
return hash.map { String(format: "%02x", $0) }.joined()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get peer's public key data
|
/// Get peer's public key data
|
||||||
func getPeerPublicKeyData(_ peerID: PeerID) -> Data? {
|
func getPeerPublicKeyData(_ peerID: String) -> 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 = keychain.deleteIdentityKey(forKey: "noiseStaticKey")
|
let deletedStatic = KeychainManager.shared.deleteIdentityKey(forKey: "noiseStaticKey")
|
||||||
let deletedSigning = keychain.deleteIdentityKey(forKey: "ed25519SigningKey")
|
let deletedSigning = KeychainManager.shared.deleteIdentityKey(forKey: "ed25519SigningKey")
|
||||||
SecureLogger.logKeyOperation(.delete, keyType: "identity keys", success: deletedStatic && deletedSigning)
|
SecureLogger.logKeyOperation("delete", keyType: "identity keys", success: deletedStatic && deletedSigning)
|
||||||
SecureLogger.warning("Panic mode activated - identity cleared", category: .security)
|
SecureLogger.log("Panic mode activated - identity cleared", category: SecureLogger.security, level: .warning)
|
||||||
// Stop rekey timer
|
// Stop rekey timer
|
||||||
stopRekeyTimer()
|
stopRekeyTimer()
|
||||||
}
|
}
|
||||||
@@ -388,7 +281,7 @@ final class NoiseEncryptionService {
|
|||||||
let signature = try signingKey.signature(for: data)
|
let signature = try signingKey.signature(for: data)
|
||||||
return signature
|
return signature
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error(error, context: "Failed to sign data")
|
SecureLogger.logError(error, context: "Failed to sign data", category: SecureLogger.noise)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -399,7 +292,7 @@ final 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.error(error, context: "Failed to verify signature")
|
SecureLogger.logError(error, context: "Failed to verify signature", category: SecureLogger.noise)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -495,21 +388,21 @@ final class NoiseEncryptionService {
|
|||||||
// MARK: - Handshake Management
|
// MARK: - Handshake Management
|
||||||
|
|
||||||
/// Initiate a Noise handshake with a peer
|
/// Initiate a Noise handshake with a peer
|
||||||
func initiateHandshake(with peerID: PeerID) throws -> Data {
|
func initiateHandshake(with peerID: String) throws -> Data {
|
||||||
|
|
||||||
// Validate peer ID
|
// Validate peer ID
|
||||||
guard peerID.isValid else {
|
guard NoiseSecurityValidator.validatePeerID(peerID) else {
|
||||||
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
|
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: peerID), level: .warning)
|
||||||
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.warning(.authenticationFailed(peerID: "Rate limited: \(peerID)"))
|
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: "Rate limited: \(peerID)"), level: .warning)
|
||||||
throw NoiseSecurityError.rateLimitExceeded
|
throw NoiseSecurityError.rateLimitExceeded
|
||||||
}
|
}
|
||||||
|
|
||||||
SecureLogger.info(.handshakeStarted(peerID: peerID.id))
|
SecureLogger.logSecurityEvent(.handshakeStarted(peerID: peerID))
|
||||||
|
|
||||||
// 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
|
||||||
@@ -518,23 +411,23 @@ final class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Process an incoming handshake message
|
/// Process an incoming handshake message
|
||||||
func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? {
|
func processHandshakeMessage(from peerID: String, message: Data) throws -> Data? {
|
||||||
|
|
||||||
// Validate peer ID
|
// Validate peer ID
|
||||||
guard peerID.isValid else {
|
guard NoiseSecurityValidator.validatePeerID(peerID) else {
|
||||||
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
|
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: peerID), level: .warning)
|
||||||
throw NoiseSecurityError.invalidPeerID
|
throw NoiseSecurityError.invalidPeerID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate message size
|
// Validate message size
|
||||||
guard NoiseSecurityValidator.validateHandshakeMessageSize(message) else {
|
guard NoiseSecurityValidator.validateHandshakeMessageSize(message) else {
|
||||||
SecureLogger.warning(.handshakeFailed(peerID: peerID.id, error: "Message too large"))
|
SecureLogger.logSecurityEvent(.handshakeFailed(peerID: peerID, error: "Message too large"), level: .warning)
|
||||||
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.warning(.authenticationFailed(peerID: "Rate limited: \(peerID)"))
|
SecureLogger.logSecurityEvent(.authenticationFailed(peerID: "Rate limited: \(peerID)"), level: .warning)
|
||||||
throw NoiseSecurityError.rateLimitExceeded
|
throw NoiseSecurityError.rateLimitExceeded
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -548,19 +441,19 @@ final 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: PeerID) -> Bool {
|
func hasEstablishedSession(with peerID: String) -> 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: PeerID) -> Bool {
|
func hasSession(with peerID: String) -> 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: PeerID) throws -> Data {
|
func encrypt(_ data: Data, for peerID: String) throws -> Data {
|
||||||
// Validate message size
|
// Validate message size
|
||||||
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
||||||
throw NoiseSecurityError.messageTooLarge
|
throw NoiseSecurityError.messageTooLarge
|
||||||
@@ -582,7 +475,7 @@ final class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypt data from a specific peer
|
/// Decrypt data from a specific peer
|
||||||
func decrypt(_ data: Data, from peerID: PeerID) throws -> Data {
|
func decrypt(_ data: Data, from peerID: String) throws -> Data {
|
||||||
// Validate message size
|
// Validate message size
|
||||||
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
||||||
throw NoiseSecurityError.messageTooLarge
|
throw NoiseSecurityError.messageTooLarge
|
||||||
@@ -604,37 +497,38 @@ final class NoiseEncryptionService {
|
|||||||
// MARK: - Peer Management
|
// MARK: - Peer Management
|
||||||
|
|
||||||
/// Get fingerprint for a peer
|
/// Get fingerprint for a peer
|
||||||
func getPeerFingerprint(_ peerID: PeerID) -> String? {
|
func getPeerFingerprint(_ peerID: String) -> String? {
|
||||||
return serviceQueue.sync {
|
return serviceQueue.sync {
|
||||||
return peerFingerprints[peerID]
|
return peerFingerprints[peerID]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearEphemeralStateForPanic() {
|
/// Get peer ID for a fingerprint
|
||||||
sessionManager.removeAllSessions()
|
func getPeerID(for fingerprint: String) -> String? {
|
||||||
serviceQueue.sync(flags: .barrier) {
|
return serviceQueue.sync {
|
||||||
peerFingerprints.removeAll()
|
return fingerprintToPeerID[fingerprint]
|
||||||
fingerprintToPeerID.removeAll()
|
|
||||||
}
|
}
|
||||||
rateLimiter.resetAll()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear session for a specific peer (e.g., on decryption failure to allow re-handshake)
|
/// Remove a peer session
|
||||||
func clearSession(for peerID: PeerID) {
|
func removePeer(_ peerID: String) {
|
||||||
sessionManager.removeSession(for: peerID)
|
sessionManager.removeSession(for: peerID)
|
||||||
|
|
||||||
serviceQueue.sync(flags: .barrier) {
|
serviceQueue.sync(flags: .barrier) {
|
||||||
if let fingerprint = peerFingerprints.removeValue(forKey: peerID) {
|
if let fingerprint = peerFingerprints[peerID] {
|
||||||
fingerprintToPeerID.removeValue(forKey: fingerprint)
|
fingerprintToPeerID.removeValue(forKey: fingerprint)
|
||||||
}
|
}
|
||||||
|
peerFingerprints.removeValue(forKey: peerID)
|
||||||
}
|
}
|
||||||
SecureLogger.debug("🔓 Cleared Noise session for \(peerID)", category: .session)
|
|
||||||
|
SecureLogger.logSecurityEvent(.sessionExpired(peerID: peerID))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private Helpers
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
private func handleSessionEstablished(peerID: PeerID, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
|
private func handleSessionEstablished(peerID: String, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
|
||||||
// Calculate fingerprint
|
// Calculate fingerprint
|
||||||
let fingerprint = remoteStaticKey.rawRepresentation.sha256Fingerprint()
|
let fingerprint = calculateFingerprint(for: remoteStaticKey)
|
||||||
|
|
||||||
// Store fingerprint mapping
|
// Store fingerprint mapping
|
||||||
serviceQueue.sync(flags: .barrier) {
|
serviceQueue.sync(flags: .barrier) {
|
||||||
@@ -643,7 +537,7 @@ final class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Log security event
|
// Log security event
|
||||||
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID))
|
||||||
|
|
||||||
// Notify all handlers about authentication
|
// Notify all handlers about authentication
|
||||||
serviceQueue.async { [weak self] in
|
serviceQueue.async { [weak self] in
|
||||||
@@ -652,6 +546,10 @@ final class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func calculateFingerprint(for publicKey: Curve25519.KeyAgreement.PublicKey) -> String {
|
||||||
|
return publicKey.rawRepresentation.sha256Fingerprint()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Session Maintenance
|
// MARK: - Session Maintenance
|
||||||
|
|
||||||
@@ -674,12 +572,12 @@ final 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.debug("Key rotation initiated for peer: \(peerID)", category: .security)
|
SecureLogger.log("Key rotation initiated for peer: \(peerID)", category: SecureLogger.security, level: .debug)
|
||||||
|
|
||||||
// Signal that handshake is needed
|
// Signal that handshake is needed
|
||||||
onHandshakeRequired?(peerID)
|
onHandshakeRequired?(peerID)
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error(error, context: "Failed to initiate rekey for peer: \(peerID)", category: .session)
|
SecureLogger.logError(error, context: "Failed to initiate rekey for peer: \(peerID)", category: SecureLogger.session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
//
|
||||||
|
// NostrInboxService.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// Centralizes Nostr subscribe/unsubscribe and event de-duplication for inbox flows.
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class NostrInboxService {
|
||||||
|
private var processedIDs: Set<String> = []
|
||||||
|
private var order: [String] = []
|
||||||
|
private let cap: Int
|
||||||
|
|
||||||
|
init(capacity: Int = TransportConfig.uiProcessedNostrEventsCap) {
|
||||||
|
self.cap = capacity
|
||||||
|
}
|
||||||
|
|
||||||
|
private func track(_ id: String) -> Bool {
|
||||||
|
if processedIDs.contains(id) { return false }
|
||||||
|
processedIDs.insert(id)
|
||||||
|
order.append(id)
|
||||||
|
if order.count > cap {
|
||||||
|
let overflow = order.count - cap
|
||||||
|
for _ in 0..<overflow {
|
||||||
|
if let oldest = order.first {
|
||||||
|
order.removeFirst()
|
||||||
|
processedIDs.remove(oldest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func reset() {
|
||||||
|
processedIDs.removeAll()
|
||||||
|
order.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to public geohash ephemeral events (kind 20000)
|
||||||
|
@discardableResult
|
||||||
|
func subscribeGeohashEphemeral(
|
||||||
|
geohash: String,
|
||||||
|
lookbackSeconds: TimeInterval,
|
||||||
|
limit: Int,
|
||||||
|
relayCount: Int,
|
||||||
|
subID: String? = nil,
|
||||||
|
handler: @escaping (NostrEvent) -> Void
|
||||||
|
) -> String {
|
||||||
|
let id = subID ?? "geo-\(geohash)"
|
||||||
|
let filter = NostrFilter.geohashEphemeral(
|
||||||
|
geohash,
|
||||||
|
since: Date().addingTimeInterval(-lookbackSeconds),
|
||||||
|
limit: limit
|
||||||
|
)
|
||||||
|
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: relayCount)
|
||||||
|
NostrRelayManager.shared.subscribe(filter: filter, id: id, relayUrls: relays) { [weak self] event in
|
||||||
|
guard let self = self else { return }
|
||||||
|
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
|
||||||
|
guard self.track(event.id) else { return }
|
||||||
|
handler(event)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to gift wraps (DMs) for a given pubkey
|
||||||
|
@discardableResult
|
||||||
|
func subscribeGiftWrapsFor(
|
||||||
|
pubkeyHex: String,
|
||||||
|
lookbackSeconds: TimeInterval,
|
||||||
|
subID: String? = nil,
|
||||||
|
handler: @escaping (NostrEvent) -> Void
|
||||||
|
) -> String {
|
||||||
|
let id = subID ?? "dm-\(pubkeyHex.prefix(8))"
|
||||||
|
let filter = NostrFilter.giftWrapsFor(pubkey: pubkeyHex, since: Date().addingTimeInterval(-lookbackSeconds))
|
||||||
|
NostrRelayManager.shared.subscribe(filter: filter, id: id) { [weak self] event in
|
||||||
|
guard let self = self else { return }
|
||||||
|
guard self.track(event.id) else { return }
|
||||||
|
handler(event)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func unsubscribe(id: String) {
|
||||||
|
NostrRelayManager.shared.unsubscribe(id: id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
//
|
||||||
|
// NostrOutboxService.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// Centralized Nostr outbox for sending PMs and ACKs.
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class NostrOutboxService {
|
||||||
|
private let transport: NostrTransport
|
||||||
|
|
||||||
|
init(meshService: Transport) {
|
||||||
|
let t = NostrTransport()
|
||||||
|
t.senderPeerID = meshService.myPeerID
|
||||||
|
self.transport = t
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendGeohashPM(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
|
||||||
|
transport.sendPrivateMessageGeohash(content: content, toRecipientHex: recipientHex, from: identity, messageID: messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendGeohashDeliveredAck(messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||||
|
transport.sendDeliveryAckGeohash(for: messageID, toRecipientHex: recipientHex, from: identity)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendGeohashReadAck(messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||||
|
transport.sendReadReceiptGeohash(messageID, toRecipientHex: recipientHex, from: identity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Generic outbox via favorites mapping
|
||||||
|
func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
|
||||||
|
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
|
||||||
|
transport.sendReadReceipt(receipt, to: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendDeliveryAck(for messageID: String, to peerID: String) {
|
||||||
|
transport.sendDeliveryAck(for: messageID, to: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
|
||||||
|
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,119 +1,28 @@
|
|||||||
import BitLogger
|
|
||||||
import BitFoundation
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
// Minimal Nostr transport conforming to Transport for offline sending
|
// Minimal Nostr transport conforming to Transport for offline sending
|
||||||
final class NostrTransport: Transport, @unchecked Sendable {
|
final class NostrTransport: Transport {
|
||||||
struct Dependencies {
|
|
||||||
let notificationCenter: NotificationCenter
|
|
||||||
let loadFavorites: @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship]
|
|
||||||
let favoriteStatusForNoiseKey: @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?
|
|
||||||
let favoriteStatusForPeerID: @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?
|
|
||||||
let currentIdentity: @MainActor () throws -> NostrIdentity?
|
|
||||||
let registerPendingGiftWrap: @MainActor (String) -> Void
|
|
||||||
let sendEvent: @MainActor (NostrEvent) -> Void
|
|
||||||
let scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
|
|
||||||
|
|
||||||
static func live(idBridge: NostrIdentityBridge) -> Dependencies {
|
|
||||||
Dependencies(
|
|
||||||
notificationCenter: .default,
|
|
||||||
loadFavorites: { FavoritesPersistenceService.shared.favorites },
|
|
||||||
favoriteStatusForNoiseKey: { FavoritesPersistenceService.shared.getFavoriteStatus(for: $0) },
|
|
||||||
favoriteStatusForPeerID: { FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: $0) },
|
|
||||||
currentIdentity: { try idBridge.getCurrentNostrIdentity() },
|
|
||||||
registerPendingGiftWrap: { NostrRelayManager.registerPendingGiftWrap(id: $0) },
|
|
||||||
sendEvent: { NostrRelayManager.shared.sendEvent($0) },
|
|
||||||
scheduleAfter: { delay, action in
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Provide BLE short peer ID for BitChat embedding
|
|
||||||
var senderPeerID = PeerID(str: "")
|
|
||||||
|
|
||||||
// Throttle READ receipts to avoid relay rate limits
|
|
||||||
private struct QueuedRead {
|
|
||||||
let receipt: ReadReceipt
|
|
||||||
let peerID: PeerID
|
|
||||||
}
|
|
||||||
private var readQueue: [QueuedRead] = []
|
|
||||||
private var isSendingReadAcks = false
|
|
||||||
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
|
|
||||||
private let keychain: KeychainManagerProtocol
|
|
||||||
private let idBridge: NostrIdentityBridge
|
|
||||||
private let dependencies: Dependencies
|
|
||||||
private var favoriteStatusObserver: NSObjectProtocol?
|
|
||||||
|
|
||||||
// Reachability Cache (thread-safe)
|
|
||||||
private var reachablePeers: Set<PeerID> = []
|
|
||||||
private let queue = DispatchQueue(label: "nostr.transport.state", attributes: .concurrent)
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
init(
|
|
||||||
keychain: KeychainManagerProtocol,
|
|
||||||
idBridge: NostrIdentityBridge,
|
|
||||||
dependencies: Dependencies? = nil
|
|
||||||
) {
|
|
||||||
self.keychain = keychain
|
|
||||||
self.idBridge = idBridge
|
|
||||||
self.dependencies = dependencies ?? .live(idBridge: idBridge)
|
|
||||||
|
|
||||||
setupObservers()
|
|
||||||
|
|
||||||
// Synchronously warm the cache to avoid startup race
|
|
||||||
let favorites = self.dependencies.loadFavorites()
|
|
||||||
let reachable = favorites.values
|
|
||||||
.filter { $0.peerNostrPublicKey != nil }
|
|
||||||
.map { PeerID(publicKey: $0.peerNoisePublicKey) }
|
|
||||||
|
|
||||||
queue.sync(flags: .barrier) {
|
|
||||||
self.reachablePeers = Set(reachable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
if let favoriteStatusObserver {
|
|
||||||
dependencies.notificationCenter.removeObserver(favoriteStatusObserver)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupObservers() {
|
|
||||||
favoriteStatusObserver = dependencies.notificationCenter.addObserver(
|
|
||||||
forName: .favoriteStatusChanged,
|
|
||||||
object: nil,
|
|
||||||
queue: nil
|
|
||||||
) { [weak self] _ in
|
|
||||||
self?.refreshReachablePeers()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func refreshReachablePeers() {
|
|
||||||
Task { @MainActor in
|
|
||||||
let favorites = dependencies.loadFavorites()
|
|
||||||
let reachable = favorites.values
|
|
||||||
.filter { $0.peerNostrPublicKey != nil }
|
|
||||||
.map { PeerID(publicKey: $0.peerNoisePublicKey) }
|
|
||||||
|
|
||||||
self.queue.async(flags: .barrier) { [weak self] in
|
|
||||||
self?.reachablePeers = Set(reachable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Transport Protocol Conformance
|
|
||||||
|
|
||||||
weak var delegate: BitchatDelegate?
|
weak var delegate: BitchatDelegate?
|
||||||
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
||||||
|
|
||||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
||||||
Just([]).eraseToAnyPublisher()
|
Just([]).eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
func currentPeerSnapshots() -> [TransportPeerSnapshot] { [] }
|
func currentPeerSnapshots() -> [TransportPeerSnapshot] { [] }
|
||||||
|
|
||||||
var myPeerID: PeerID { senderPeerID }
|
// Provide BLE short peer ID for BitChat embedding
|
||||||
|
var senderPeerID: String = ""
|
||||||
|
|
||||||
|
// Throttle READ receipts to avoid relay rate limits
|
||||||
|
private struct QueuedRead {
|
||||||
|
let receipt: ReadReceipt
|
||||||
|
let peerID: String
|
||||||
|
}
|
||||||
|
private var readQueue: [QueuedRead] = []
|
||||||
|
private var isSendingReadAcks = false
|
||||||
|
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
|
||||||
|
|
||||||
|
var myPeerID: String { senderPeerID }
|
||||||
var myNickname: String { "" }
|
var myNickname: String { "" }
|
||||||
func setNickname(_ nickname: String) { /* not used for Nostr */ }
|
func setNickname(_ nickname: String) { /* not used for Nostr */ }
|
||||||
|
|
||||||
@@ -121,205 +30,220 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
func stopServices() { /* no-op */ }
|
func stopServices() { /* no-op */ }
|
||||||
func emergencyDisconnectAll() { /* no-op */ }
|
func emergencyDisconnectAll() { /* no-op */ }
|
||||||
|
|
||||||
func isPeerConnected(_ peerID: PeerID) -> Bool { false }
|
func isPeerConnected(_ peerID: String) -> Bool { false }
|
||||||
|
func isPeerReachable(_ peerID: String) -> Bool { false }
|
||||||
func isPeerReachable(_ peerID: PeerID) -> Bool {
|
func peerNickname(peerID: String) -> String? { nil }
|
||||||
queue.sync {
|
func getPeerNicknames() -> [String : String] { [:] }
|
||||||
// Check if exact match
|
|
||||||
if reachablePeers.contains(peerID) { return true }
|
|
||||||
// Check for short ID match
|
|
||||||
if peerID.isShort {
|
|
||||||
return reachablePeers.contains(where: { $0.toShort() == peerID })
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func peerNickname(peerID: PeerID) -> String? { nil }
|
|
||||||
func getPeerNicknames() -> [PeerID : String] { [:] }
|
|
||||||
|
|
||||||
func getFingerprint(for peerID: PeerID) -> String? { nil }
|
func getFingerprint(for peerID: String) -> String? { nil }
|
||||||
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
|
func getNoiseSessionState(for peerID: String) -> LazyHandshakeState { .none }
|
||||||
func triggerHandshake(with peerID: PeerID) { /* no-op */ }
|
func triggerHandshake(with peerID: String) { /* no-op */ }
|
||||||
|
|
||||||
// Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation
|
// Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation
|
||||||
private static var cachedNoiseService: NoiseEncryptionService?
|
private static var cachedNoiseService: NoiseEncryptionService = {
|
||||||
func getNoiseService() -> NoiseEncryptionService {
|
NoiseEncryptionService()
|
||||||
if let noiseService = Self.cachedNoiseService {
|
}()
|
||||||
return noiseService
|
func getNoiseService() -> NoiseEncryptionService { Self.cachedNoiseService }
|
||||||
}
|
|
||||||
let noiseService = NoiseEncryptionService(keychain: keychain)
|
|
||||||
Self.cachedNoiseService = noiseService
|
|
||||||
return noiseService
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public broadcast not supported over Nostr here
|
// Public broadcast not supported over Nostr here
|
||||||
func sendMessage(_ content: String, mentions: [String]) { /* no-op */ }
|
func sendMessage(_ content: String, mentions: [String]) { /* no-op */ }
|
||||||
|
|
||||||
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||||
let recipientHex = npubToHex(recipientNpub),
|
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||||
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
SecureLogger.log("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||||
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… id=\(messageID.prefix(8))…", category: .session)
|
category: SecureLogger.session, level: .debug)
|
||||||
|
// Convert recipient npub -> hex (x-only)
|
||||||
|
let recipientHex: String
|
||||||
|
do {
|
||||||
|
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||||
|
guard hrp == "npub" else {
|
||||||
|
SecureLogger.log("NostrTransport: recipient key not npub (hrp=\(hrp))", category: SecureLogger.session, level: .error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recipientHex = data.hexEncodedString()
|
||||||
|
} catch {
|
||||||
|
SecureLogger.log("NostrTransport: failed to decode npub -> hex: \(error)", category: SecureLogger.session, level: .error)
|
||||||
|
return
|
||||||
|
}
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||||
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
SecureLogger.log("NostrTransport: failed to embed PM packet", category: SecureLogger.session, level: .error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||||
|
SecureLogger.log("NostrTransport: failed to build Nostr event for PM", category: SecureLogger.session, level: .error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
SecureLogger.log("NostrTransport: sending PM giftWrap id=\(event.id.prefix(16))…",
|
||||||
|
category: SecureLogger.session, level: .debug)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
|
||||||
// Enqueue and process with throttling to avoid relay rate limits
|
// Enqueue and process with throttling to avoid relay rate limits
|
||||||
// Use barrier to synchronize access to readQueue
|
readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
|
||||||
queue.async(flags: .barrier) { [weak self] in
|
processReadQueueIfNeeded()
|
||||||
self?.readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
|
|
||||||
self?.processReadQueueIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
|
||||||
Task { @MainActor in
|
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
|
||||||
let recipientHex = npubToHex(recipientNpub),
|
|
||||||
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
|
||||||
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
|
||||||
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session)
|
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
|
||||||
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
|
|
||||||
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
|
|
||||||
Task { @MainActor in
|
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
|
||||||
let recipientHex = npubToHex(recipientNpub),
|
|
||||||
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
|
||||||
SecureLogger.debug("NostrTransport: preparing DELIVERED ack id=\(messageID.prefix(8))…", category: .session)
|
|
||||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
|
||||||
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Geohash Helpers
|
|
||||||
|
|
||||||
extension NostrTransport {
|
|
||||||
|
|
||||||
// MARK: Geohash ACK helpers
|
|
||||||
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
|
||||||
Task { @MainActor in
|
|
||||||
SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))…", category: .session)
|
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
|
||||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
|
||||||
Task { @MainActor in
|
|
||||||
SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))…", category: .session)
|
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
|
||||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Geohash DMs (per-geohash identity)
|
|
||||||
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
|
|
||||||
Task { @MainActor in
|
|
||||||
guard !recipientHex.isEmpty else { return }
|
|
||||||
SecureLogger.debug("GeoDM: send PM mid=\(messageID.prefix(8))…", category: .session)
|
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {
|
|
||||||
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Helpers
|
|
||||||
|
|
||||||
extension NostrTransport {
|
|
||||||
/// Converts npub bech32 string to hex pubkey
|
|
||||||
@MainActor
|
|
||||||
private func npubToHex(_ npub: String) -> String? {
|
|
||||||
do {
|
|
||||||
let (hrp, data) = try Bech32.decode(npub)
|
|
||||||
guard hrp == "npub" else { return nil }
|
|
||||||
return data.hexEncodedString()
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates and sends a gift-wrapped private message event
|
|
||||||
@MainActor
|
|
||||||
private func sendWrappedMessage(content: String, recipientHex: String, senderIdentity: NostrIdentity, registerPending: Bool = false) {
|
|
||||||
guard let event = try? NostrProtocol.createPrivateMessage(content: content, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
|
||||||
SecureLogger.error("NostrTransport: failed to build Nostr event", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if registerPending {
|
|
||||||
dependencies.registerPendingGiftWrap(event.id)
|
|
||||||
}
|
|
||||||
dependencies.sendEvent(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Must be called within a barrier on `queue`
|
|
||||||
private func processReadQueueIfNeeded() {
|
private func processReadQueueIfNeeded() {
|
||||||
guard !isSendingReadAcks else { return }
|
guard !isSendingReadAcks else { return }
|
||||||
guard !readQueue.isEmpty else { return }
|
guard !readQueue.isEmpty else { return }
|
||||||
isSendingReadAcks = true
|
isSendingReadAcks = true
|
||||||
let item = readQueue.removeFirst()
|
sendNextReadAck()
|
||||||
sendReadAckItem(item)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sends a single read ack item (called after extraction from queue within barrier)
|
private func sendNextReadAck() {
|
||||||
private func sendReadAckItem(_ item: QueuedRead) {
|
guard !readQueue.isEmpty else { isSendingReadAcks = false; return }
|
||||||
|
let item = readQueue.removeFirst()
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
defer { scheduleNextReadAck() }
|
guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: item.peerID),
|
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
|
||||||
let recipientHex = npubToHex(recipientNpub),
|
SecureLogger.log("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))…",
|
||||||
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
category: SecureLogger.session, level: .debug)
|
||||||
SecureLogger.debug("NostrTransport: preparing READ ack id=\(item.receipt.originalMessageID.prefix(8))…", category: .session)
|
// Convert recipient npub -> hex
|
||||||
|
let recipientHex: String
|
||||||
|
do {
|
||||||
|
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||||
|
guard hrp == "npub" else { scheduleNextReadAck(); return }
|
||||||
|
recipientHex = data.hexEncodedString()
|
||||||
|
} catch { scheduleNextReadAck(); return }
|
||||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else {
|
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else {
|
||||||
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
SecureLogger.log("NostrTransport: failed to embed READ ack", category: SecureLogger.session, level: .error)
|
||||||
return
|
scheduleNextReadAck(); return
|
||||||
}
|
}
|
||||||
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
|
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||||
|
SecureLogger.log("NostrTransport: failed to build Nostr event for READ ack", category: SecureLogger.session, level: .error)
|
||||||
|
scheduleNextReadAck(); return
|
||||||
|
}
|
||||||
|
SecureLogger.log("NostrTransport: sending READ ack giftWrap id=\(event.id.prefix(16))…",
|
||||||
|
category: SecureLogger.session, level: .debug)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
|
scheduleNextReadAck()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scheduleNextReadAck() {
|
private func scheduleNextReadAck() {
|
||||||
dependencies.scheduleAfter(readAckInterval) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + readAckInterval) { [weak self] in
|
||||||
self?.queue.async(flags: .barrier) { [weak self] in
|
guard let self = self else { return }
|
||||||
self?.isSendingReadAcks = false
|
self.isSendingReadAcks = false
|
||||||
self?.processReadQueueIfNeeded()
|
self.processReadQueueIfNeeded()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||||
|
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||||
|
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
||||||
|
SecureLogger.log("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…",
|
||||||
|
category: SecureLogger.session, level: .debug)
|
||||||
|
// Convert recipient npub -> hex
|
||||||
|
let recipientHex: String
|
||||||
|
do {
|
||||||
|
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||||
|
guard hrp == "npub" else { return }
|
||||||
|
recipientHex = data.hexEncodedString()
|
||||||
|
} catch { return }
|
||||||
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||||
|
SecureLogger.log("NostrTransport: failed to embed favorite notification", category: SecureLogger.session, level: .error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||||
|
SecureLogger.log("NostrTransport: failed to build Nostr event for favorite notification", category: SecureLogger.session, level: .error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
SecureLogger.log("NostrTransport: sending favorite giftWrap id=\(event.id.prefix(16))…",
|
||||||
|
category: SecureLogger.session, level: .debug)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
@MainActor
|
@MainActor
|
||||||
private func resolveRecipientNpub(for peerID: PeerID) -> String? {
|
private func resolveRecipientNpub(for peerID: String) -> String? {
|
||||||
if let noiseKey = Data(hexString: peerID.id),
|
if let noiseKey = Data(hexString: peerID),
|
||||||
let fav = dependencies.favoriteStatusForNoiseKey(noiseKey),
|
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||||
let npub = fav.peerNostrPublicKey {
|
let npub = fav.peerNostrPublicKey {
|
||||||
return npub
|
return npub
|
||||||
}
|
}
|
||||||
if peerID.id.count == 16,
|
if PeerIDResolver.isShortID(peerID),
|
||||||
let fav = dependencies.favoriteStatusForPeerID(peerID),
|
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
||||||
let npub = fav.peerNostrPublicKey {
|
let npub = fav.peerNostrPublicKey {
|
||||||
return npub
|
return npub
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
|
||||||
|
func sendDeliveryAck(for messageID: String, to peerID: String) {
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||||
|
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||||
|
SecureLogger.log("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))…",
|
||||||
|
category: SecureLogger.session, level: .debug)
|
||||||
|
let recipientHex: String
|
||||||
|
do {
|
||||||
|
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||||
|
guard hrp == "npub" else { return }
|
||||||
|
recipientHex = data.hexEncodedString()
|
||||||
|
} catch { return }
|
||||||
|
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||||
|
SecureLogger.log("NostrTransport: failed to embed DELIVERED ack", category: SecureLogger.session, level: .error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||||
|
SecureLogger.log("NostrTransport: failed to build Nostr event for DELIVERED ack", category: SecureLogger.session, level: .error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
SecureLogger.log("NostrTransport: sending DELIVERED ack giftWrap id=\(event.id.prefix(16))…",
|
||||||
|
category: SecureLogger.session, level: .debug)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Geohash ACK helpers
|
||||||
|
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||||
|
Task { @MainActor in
|
||||||
|
SecureLogger.log("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…",
|
||||||
|
category: SecureLogger.session, level: .debug)
|
||||||
|
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||||
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||||
|
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||||
|
Task { @MainActor in
|
||||||
|
SecureLogger.log("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…",
|
||||||
|
category: SecureLogger.session, level: .debug)
|
||||||
|
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||||
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||||
|
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Geohash DMs (per-geohash identity)
|
||||||
|
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
|
||||||
|
Task { @MainActor in
|
||||||
|
guard !recipientHex.isEmpty else { return }
|
||||||
|
SecureLogger.log("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…",
|
||||||
|
category: SecureLogger.session, level: .debug)
|
||||||
|
// Build embedded BitChat packet without recipient peer ID
|
||||||
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {
|
||||||
|
SecureLogger.log("NostrTransport: failed to embed geohash PM packet", category: SecureLogger.session, level: .error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else {
|
||||||
|
SecureLogger.log("NostrTransport: failed to build Nostr event for geohash PM", category: SecureLogger.session, level: .error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
SecureLogger.log("NostrTransport: sending geohash PM giftWrap id=\(event.id.prefix(16))…",
|
||||||
|
category: SecureLogger.session, level: .debug)
|
||||||
|
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import BitFoundation
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -15,103 +14,13 @@ import UIKit
|
|||||||
import AppKit
|
import AppKit
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
protocol NotificationAuthorizing {
|
class NotificationService {
|
||||||
func requestAuthorization(
|
|
||||||
options: UNAuthorizationOptions,
|
|
||||||
completionHandler: @escaping (Bool, Error?) -> Void
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
protocol NotificationRequestDelivering {
|
|
||||||
func add(_ request: UNNotificationRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
private final class NotificationCenterAuthorizerAdapter: NotificationAuthorizing {
|
|
||||||
private let center: UNUserNotificationCenter
|
|
||||||
|
|
||||||
init(center: UNUserNotificationCenter) {
|
|
||||||
self.center = center
|
|
||||||
}
|
|
||||||
|
|
||||||
func requestAuthorization(
|
|
||||||
options: UNAuthorizationOptions,
|
|
||||||
completionHandler: @escaping (Bool, Error?) -> Void
|
|
||||||
) {
|
|
||||||
center.requestAuthorization(options: options, completionHandler: completionHandler)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private final class NotificationCenterRequestDelivererAdapter: NotificationRequestDelivering {
|
|
||||||
private let center: UNUserNotificationCenter
|
|
||||||
|
|
||||||
init(center: UNUserNotificationCenter) {
|
|
||||||
self.center = center
|
|
||||||
}
|
|
||||||
|
|
||||||
func add(_ request: UNNotificationRequest) {
|
|
||||||
Task {
|
|
||||||
try? await center.add(request)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct NoopNotificationAuthorizer: NotificationAuthorizing {
|
|
||||||
func requestAuthorization(
|
|
||||||
options: UNAuthorizationOptions,
|
|
||||||
completionHandler: @escaping (Bool, Error?) -> Void
|
|
||||||
) {
|
|
||||||
completionHandler(false, nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct NoopNotificationRequestDeliverer: NotificationRequestDelivering {
|
|
||||||
func add(_ request: UNNotificationRequest) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
final class NotificationService {
|
|
||||||
static let shared = NotificationService()
|
static let shared = NotificationService()
|
||||||
|
|
||||||
private let isRunningTestsProvider: () -> Bool
|
private init() {}
|
||||||
private let authorizer: NotificationAuthorizing
|
|
||||||
private let requestDeliverer: NotificationRequestDelivering
|
|
||||||
|
|
||||||
/// Returns true if running in test environment (XCTest, Swift Testing, or CI)
|
|
||||||
private var isRunningTests: Bool {
|
|
||||||
isRunningTestsProvider()
|
|
||||||
}
|
|
||||||
|
|
||||||
private init() {
|
|
||||||
self.isRunningTestsProvider = {
|
|
||||||
let env = ProcessInfo.processInfo.environment
|
|
||||||
return NSClassFromString("XCTestCase") != nil ||
|
|
||||||
env["XCTestConfigurationFilePath"] != nil ||
|
|
||||||
env["XCTestBundlePath"] != nil ||
|
|
||||||
env["GITHUB_ACTIONS"] != nil ||
|
|
||||||
env["CI"] != nil
|
|
||||||
}
|
|
||||||
if isRunningTestsProvider() {
|
|
||||||
self.authorizer = NoopNotificationAuthorizer()
|
|
||||||
self.requestDeliverer = NoopNotificationRequestDeliverer()
|
|
||||||
} else {
|
|
||||||
let center = UNUserNotificationCenter.current()
|
|
||||||
self.authorizer = NotificationCenterAuthorizerAdapter(center: center)
|
|
||||||
self.requestDeliverer = NotificationCenterRequestDelivererAdapter(center: center)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal init(
|
|
||||||
isRunningTestsProvider: @escaping () -> Bool,
|
|
||||||
authorizer: NotificationAuthorizing,
|
|
||||||
requestDeliverer: NotificationRequestDelivering
|
|
||||||
) {
|
|
||||||
self.isRunningTestsProvider = isRunningTestsProvider
|
|
||||||
self.authorizer = authorizer
|
|
||||||
self.requestDeliverer = requestDeliverer
|
|
||||||
}
|
|
||||||
|
|
||||||
func requestAuthorization() {
|
func requestAuthorization() {
|
||||||
guard !isRunningTests else { return }
|
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
||||||
authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
|
||||||
if granted {
|
if granted {
|
||||||
// Permission granted
|
// Permission granted
|
||||||
} else {
|
} else {
|
||||||
@@ -120,31 +29,28 @@ final class NotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendLocalNotification(
|
func sendLocalNotification(title: String, body: String, identifier: String, userInfo: [String: Any]? = nil) {
|
||||||
title: String,
|
// For now, skip app state check entirely to avoid thread issues
|
||||||
body: String,
|
// The NotificationDelegate will handle foreground presentation
|
||||||
identifier: String,
|
DispatchQueue.main.async {
|
||||||
userInfo: [String: Any]? = nil,
|
let content = UNMutableNotificationContent()
|
||||||
interruptionLevel: UNNotificationInterruptionLevel = .active
|
content.title = title
|
||||||
) {
|
content.body = body
|
||||||
guard !isRunningTests else { return }
|
content.sound = .default
|
||||||
let content = UNMutableNotificationContent()
|
if let userInfo = userInfo {
|
||||||
content.title = title
|
content.userInfo = userInfo
|
||||||
content.body = body
|
}
|
||||||
content.sound = .default
|
|
||||||
content.interruptionLevel = interruptionLevel
|
let request = UNNotificationRequest(
|
||||||
|
identifier: identifier,
|
||||||
if let userInfo = userInfo {
|
content: content,
|
||||||
content.userInfo = userInfo
|
trigger: nil // Deliver immediately
|
||||||
|
)
|
||||||
|
|
||||||
|
UNUserNotificationCenter.current().add(request) { _ in
|
||||||
|
// Notification added
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let request = UNNotificationRequest(
|
|
||||||
identifier: identifier,
|
|
||||||
content: content,
|
|
||||||
trigger: nil // Deliver immediately
|
|
||||||
)
|
|
||||||
|
|
||||||
requestDeliverer.add(request)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendMentionNotification(from sender: String, message: String) {
|
func sendMentionNotification(from sender: String, message: String) {
|
||||||
@@ -155,15 +61,35 @@ final class NotificationService {
|
|||||||
sendLocalNotification(title: title, body: body, identifier: identifier)
|
sendLocalNotification(title: title, body: body, identifier: identifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendPrivateMessageNotification(from sender: String, message: String, peerID: PeerID) {
|
func sendPrivateMessageNotification(from sender: String, message: String, peerID: String) {
|
||||||
let title = "🔒 DM from \(sender)"
|
let title = "🔒 private message from \(sender)"
|
||||||
let body = message
|
let body = message
|
||||||
let identifier = "private-\(UUID().uuidString)"
|
let identifier = "private-\(UUID().uuidString)"
|
||||||
let userInfo = ["peerID": peerID.id, "senderName": sender]
|
let userInfo = ["peerID": peerID, "senderName": sender]
|
||||||
|
|
||||||
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
|
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sendFavoriteOnlineNotification(nickname: String) {
|
||||||
|
// Send directly without checking app state for favorites
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "⭐ \(nickname) is online!"
|
||||||
|
content.body = "wanna get in there?"
|
||||||
|
content.sound = .default
|
||||||
|
|
||||||
|
let request = UNNotificationRequest(
|
||||||
|
identifier: "favorite-online-\(UUID().uuidString)",
|
||||||
|
content: content,
|
||||||
|
trigger: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
UNUserNotificationCenter.current().add(request) { _ in
|
||||||
|
// Notification added
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Geohash public chat notification with deep link to a specific geohash
|
// Geohash public chat notification with deep link to a specific geohash
|
||||||
func sendGeohashActivityNotification(geohash: String, titlePrefix: String = "#", bodyPreview: String) {
|
func sendGeohashActivityNotification(geohash: String, titlePrefix: String = "#", bodyPreview: String) {
|
||||||
let title = "\(titlePrefix)\(geohash)"
|
let title = "\(titlePrefix)\(geohash)"
|
||||||
@@ -176,14 +102,26 @@ final class NotificationService {
|
|||||||
func sendNetworkAvailableNotification(peerCount: Int) {
|
func sendNetworkAvailableNotification(peerCount: Int) {
|
||||||
let title = "👥 bitchatters nearby!"
|
let title = "👥 bitchatters nearby!"
|
||||||
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
|
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
|
||||||
// Fixed identifier so iOS updates the existing notification instead of creating new ones
|
let identifier = "network-available-\(Date().timeIntervalSince1970)"
|
||||||
let identifier = "network-available"
|
|
||||||
|
// For network notifications, we want to show them even in foreground
|
||||||
sendLocalNotification(
|
// No app state check - let the notification delegate handle presentation
|
||||||
title: title,
|
DispatchQueue.main.async {
|
||||||
body: body,
|
let content = UNMutableNotificationContent()
|
||||||
identifier: identifier,
|
content.title = title
|
||||||
interruptionLevel: .timeSensitive
|
content.body = body
|
||||||
)
|
content.sound = .default
|
||||||
|
content.interruptionLevel = .timeSensitive // Make it more prominent
|
||||||
|
|
||||||
|
let request = UNNotificationRequest(
|
||||||
|
identifier: identifier,
|
||||||
|
content: content,
|
||||||
|
trigger: nil // Deliver immediately
|
||||||
|
)
|
||||||
|
|
||||||
|
UNUserNotificationCenter.current().add(request) { _ in
|
||||||
|
// Notification added
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
//
|
|
||||||
// NotificationStreamAssembler.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import BitLogger
|
|
||||||
import BitFoundation
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct NotificationStreamAssembler {
|
|
||||||
private var buffer = Data()
|
|
||||||
private var pendingFrameStartedAt: DispatchTime?
|
|
||||||
private var pendingFrameExpectedLength: Int = 0
|
|
||||||
|
|
||||||
private mutating func resetState() {
|
|
||||||
buffer.removeAll(keepingCapacity: false)
|
|
||||||
pendingFrameStartedAt = nil
|
|
||||||
pendingFrameExpectedLength = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func append(_ chunk: Data) -> (frames: [Data], droppedPrefixes: [UInt8], reset: Bool) {
|
|
||||||
guard !chunk.isEmpty else { return ([], [], false) }
|
|
||||||
|
|
||||||
buffer.append(chunk)
|
|
||||||
|
|
||||||
var frames: [Data] = []
|
|
||||||
var dropped: [UInt8] = []
|
|
||||||
var didReset = false
|
|
||||||
let now = DispatchTime.now()
|
|
||||||
let maxFrameLength = TransportConfig.bleNotificationAssemblerHardCapBytes
|
|
||||||
let minimumFramePrefix = BinaryProtocol.v1HeaderSize + BinaryProtocol.senderIDSize
|
|
||||||
|
|
||||||
if buffer.count > TransportConfig.bleNotificationAssemblerHardCapBytes {
|
|
||||||
SecureLogger.error("❌ Notification assembler overflow (\(buffer.count) bytes); dropping partial frame", category: .session)
|
|
||||||
resetState()
|
|
||||||
return ([], [], true)
|
|
||||||
}
|
|
||||||
|
|
||||||
while buffer.count >= minimumFramePrefix {
|
|
||||||
guard let version = buffer.first else { break }
|
|
||||||
guard version == 1 || version == 2 else {
|
|
||||||
dropped.append(buffer.removeFirst())
|
|
||||||
pendingFrameStartedAt = nil
|
|
||||||
pendingFrameExpectedLength = 0
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let headerSize = BinaryProtocol.headerSize(for: version) else {
|
|
||||||
dropped.append(buffer.removeFirst())
|
|
||||||
pendingFrameStartedAt = nil
|
|
||||||
pendingFrameExpectedLength = 0
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
let framePrefix = headerSize + BinaryProtocol.senderIDSize
|
|
||||||
guard buffer.count >= framePrefix else { break }
|
|
||||||
|
|
||||||
let flagsIndex = buffer.startIndex + BinaryProtocol.Offsets.flags
|
|
||||||
guard flagsIndex < buffer.endIndex else { break }
|
|
||||||
let flags = buffer[flagsIndex]
|
|
||||||
let hasRecipient = (flags & BinaryProtocol.Flags.hasRecipient) != 0
|
|
||||||
let hasSignature = (flags & BinaryProtocol.Flags.hasSignature) != 0
|
|
||||||
let isCompressed = (flags & BinaryProtocol.Flags.isCompressed) != 0
|
|
||||||
let hasRoute = (version >= 2) && (flags & BinaryProtocol.Flags.hasRoute) != 0
|
|
||||||
|
|
||||||
let lengthOffset = 12
|
|
||||||
let payloadLength: Int
|
|
||||||
if version == 2 {
|
|
||||||
let lengthIndex = buffer.startIndex + lengthOffset
|
|
||||||
payloadLength =
|
|
||||||
(Int(buffer[lengthIndex]) << 24) |
|
|
||||||
(Int(buffer[lengthIndex + 1]) << 16) |
|
|
||||||
(Int(buffer[lengthIndex + 2]) << 8) |
|
|
||||||
Int(buffer[lengthIndex + 3])
|
|
||||||
} else {
|
|
||||||
let lengthIndex = buffer.startIndex + lengthOffset
|
|
||||||
payloadLength = (Int(buffer[lengthIndex]) << 8) | Int(buffer[lengthIndex + 1])
|
|
||||||
}
|
|
||||||
|
|
||||||
var frameLength = framePrefix + payloadLength
|
|
||||||
if hasRecipient { frameLength += BinaryProtocol.recipientIDSize }
|
|
||||||
if hasSignature { frameLength += BinaryProtocol.signatureSize }
|
|
||||||
|
|
||||||
if hasRoute {
|
|
||||||
let routeCountOffset = framePrefix + (hasRecipient ? BinaryProtocol.recipientIDSize : 0)
|
|
||||||
let routeCountIndex = buffer.startIndex + routeCountOffset
|
|
||||||
guard buffer.count > routeCountOffset else { break }
|
|
||||||
let routeCount = Int(buffer[routeCountIndex])
|
|
||||||
frameLength += 1 + (routeCount * BinaryProtocol.senderIDSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isCompressed {
|
|
||||||
let rawLengthFieldBytes = (version == 2) ? 4 : 2
|
|
||||||
if payloadLength < rawLengthFieldBytes {
|
|
||||||
SecureLogger.error("❌ Invalid compressed payload length (\(payloadLength))", category: .session)
|
|
||||||
resetState()
|
|
||||||
didReset = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
guard frameLength > 0, frameLength <= maxFrameLength else {
|
|
||||||
SecureLogger.error("❌ Notification frame length \(frameLength) invalid (cap=\(maxFrameLength)); resetting stream", category: .session)
|
|
||||||
resetState()
|
|
||||||
didReset = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if buffer.count < frameLength {
|
|
||||||
let remaining = frameLength - buffer.count
|
|
||||||
if pendingFrameStartedAt == nil || frameLength != pendingFrameExpectedLength {
|
|
||||||
pendingFrameStartedAt = now
|
|
||||||
pendingFrameExpectedLength = frameLength
|
|
||||||
} else if let started = pendingFrameStartedAt {
|
|
||||||
let elapsed = now.uptimeNanoseconds - started.uptimeNanoseconds
|
|
||||||
let threshold = UInt64(TransportConfig.bleAssemblerStallResetMs) * 1_000_000
|
|
||||||
if elapsed >= threshold {
|
|
||||||
SecureLogger.debug("📉 Resetting notification assembler after waiting \(remaining)B for \(TransportConfig.bleAssemblerStallResetMs)ms", category: .session)
|
|
||||||
resetState()
|
|
||||||
didReset = true
|
|
||||||
} else {
|
|
||||||
SecureLogger.debug("⌛ Waiting for remaining \(remaining)B to complete BLE frame", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
pendingFrameStartedAt = nil
|
|
||||||
pendingFrameExpectedLength = 0
|
|
||||||
|
|
||||||
let frame = Data(buffer.prefix(frameLength))
|
|
||||||
frames.append(frame)
|
|
||||||
buffer.removeFirst(frameLength)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !buffer.isEmpty, buffer.allSatisfy({ $0 == 0 }) {
|
|
||||||
resetState()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (frames, dropped, didReset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,187 +6,32 @@
|
|||||||
// This is free and unencumbered software released into the public domain.
|
// This is free and unencumbered software released into the public domain.
|
||||||
//
|
//
|
||||||
|
|
||||||
import BitLogger
|
|
||||||
import BitFoundation
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Manages all private chat functionality
|
/// Manages all private chat functionality
|
||||||
final class PrivateChatManager: ObservableObject {
|
class PrivateChatManager: ObservableObject {
|
||||||
@Published var privateChats: [PeerID: [BitchatMessage]] = [:]
|
@Published var privateChats: [String: [BitchatMessage]] = [:]
|
||||||
@Published var selectedPeer: PeerID? = nil
|
@Published var selectedPeer: String? = nil
|
||||||
@Published var unreadMessages: Set<PeerID> = []
|
@Published var unreadMessages: Set<String> = []
|
||||||
|
|
||||||
private var selectedPeerFingerprint: String? = nil
|
private var selectedPeerFingerprint: String? = nil
|
||||||
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
|
private var readReceiptTracker: ReadReceiptTracker?
|
||||||
|
|
||||||
weak var meshService: Transport?
|
weak var meshService: Transport?
|
||||||
// Route acks/receipts via MessageRouter (chooses mesh or Nostr)
|
// Route acks/receipts via MessageRouter (chooses mesh or Nostr)
|
||||||
weak var messageRouter: MessageRouter?
|
weak var messageRouter: MessageRouter?
|
||||||
// Peer service for looking up peer info during consolidation
|
|
||||||
weak var unifiedPeerService: UnifiedPeerService?
|
init(meshService: Transport? = nil, readReceiptTracker: ReadReceiptTracker? = nil) {
|
||||||
|
|
||||||
init(meshService: Transport? = nil) {
|
|
||||||
self.meshService = meshService
|
self.meshService = meshService
|
||||||
|
self.readReceiptTracker = readReceiptTracker
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cap for messages stored per private chat
|
// Cap for messages stored per private chat
|
||||||
private let privateChatCap = TransportConfig.privateChatCap
|
private let privateChatCap = TransportConfig.privateChatCap
|
||||||
|
|
||||||
// MARK: - Message Consolidation
|
|
||||||
|
|
||||||
/// Consolidates messages from different peer ID representations into a single chat.
|
|
||||||
/// This ensures messages from stable Noise keys and temporary Nostr peer IDs are merged.
|
|
||||||
/// - Parameters:
|
|
||||||
/// - peerID: The target peer ID to consolidate messages into
|
|
||||||
/// - peerNickname: The peer's display name (lowercased for matching)
|
|
||||||
/// - persistedReadReceipts: The persisted read receipts set from ChatViewModel (UserDefaults-backed)
|
|
||||||
/// - Returns: True if any unread messages were found during consolidation
|
|
||||||
@MainActor
|
|
||||||
func consolidateMessages(for peerID: PeerID, peerNickname: String, persistedReadReceipts: Set<String>) -> Bool {
|
|
||||||
guard let meshService = meshService else { return false }
|
|
||||||
var hasUnreadMessages = false
|
|
||||||
|
|
||||||
// 1. Consolidate from stable Noise key (64-char hex)
|
|
||||||
if let peer = unifiedPeerService?.getPeer(by: peerID) {
|
|
||||||
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
|
|
||||||
|
|
||||||
if noiseKeyHex != peerID, let nostrMessages = privateChats[noiseKeyHex], !nostrMessages.isEmpty {
|
|
||||||
if privateChats[peerID] == nil {
|
|
||||||
privateChats[peerID] = []
|
|
||||||
}
|
|
||||||
|
|
||||||
let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? [])
|
|
||||||
for message in nostrMessages {
|
|
||||||
if !existingMessageIds.contains(message.id) {
|
|
||||||
// Update senderPeerID for correct read receipts
|
|
||||||
let updatedMessage = BitchatMessage(
|
|
||||||
id: message.id,
|
|
||||||
sender: message.sender,
|
|
||||||
content: message.content,
|
|
||||||
timestamp: message.timestamp,
|
|
||||||
isRelay: message.isRelay,
|
|
||||||
originalSender: message.originalSender,
|
|
||||||
isPrivate: message.isPrivate,
|
|
||||||
recipientNickname: message.recipientNickname,
|
|
||||||
senderPeerID: message.senderPeerID == meshService.myPeerID ? meshService.myPeerID : peerID,
|
|
||||||
mentions: message.mentions,
|
|
||||||
deliveryStatus: message.deliveryStatus
|
|
||||||
)
|
|
||||||
privateChats[peerID]?.append(updatedMessage)
|
|
||||||
|
|
||||||
// Check for recent unread messages (< 60s, not sent by us, not already read)
|
|
||||||
// Use persistedReadReceipts to correctly identify already-read messages after app restart
|
|
||||||
if message.senderPeerID != meshService.myPeerID {
|
|
||||||
let messageAge = Date().timeIntervalSince(message.timestamp)
|
|
||||||
if messageAge < 60 && !persistedReadReceipts.contains(message.id) {
|
|
||||||
hasUnreadMessages = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
|
|
||||||
|
|
||||||
if hasUnreadMessages {
|
|
||||||
unreadMessages.insert(peerID)
|
|
||||||
} else if unreadMessages.contains(noiseKeyHex) {
|
|
||||||
unreadMessages.remove(noiseKeyHex)
|
|
||||||
}
|
|
||||||
|
|
||||||
privateChats.removeValue(forKey: noiseKeyHex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Consolidate from temporary Nostr peer IDs (nostr_* prefixed)
|
|
||||||
let normalizedNickname = peerNickname.lowercased()
|
|
||||||
var tempPeerIDsToConsolidate: [PeerID] = []
|
|
||||||
|
|
||||||
for (storedPeerID, messages) in privateChats {
|
|
||||||
if storedPeerID.isGeoDM && storedPeerID != peerID {
|
|
||||||
let nicknamesMatch = messages.allSatisfy { $0.sender.lowercased() == normalizedNickname }
|
|
||||||
if nicknamesMatch && !messages.isEmpty {
|
|
||||||
tempPeerIDsToConsolidate.append(storedPeerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !tempPeerIDsToConsolidate.isEmpty {
|
|
||||||
if privateChats[peerID] == nil {
|
|
||||||
privateChats[peerID] = []
|
|
||||||
}
|
|
||||||
|
|
||||||
let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? [])
|
|
||||||
var consolidatedCount = 0
|
|
||||||
var hadUnreadTemp = false
|
|
||||||
|
|
||||||
for tempPeerID in tempPeerIDsToConsolidate {
|
|
||||||
if unreadMessages.contains(tempPeerID) {
|
|
||||||
hadUnreadTemp = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if let tempMessages = privateChats[tempPeerID] {
|
|
||||||
for message in tempMessages {
|
|
||||||
if !existingMessageIds.contains(message.id) {
|
|
||||||
let updatedMessage = BitchatMessage(
|
|
||||||
id: message.id,
|
|
||||||
sender: message.sender,
|
|
||||||
content: message.content,
|
|
||||||
timestamp: message.timestamp,
|
|
||||||
isRelay: message.isRelay,
|
|
||||||
originalSender: message.originalSender,
|
|
||||||
isPrivate: message.isPrivate,
|
|
||||||
recipientNickname: message.recipientNickname,
|
|
||||||
senderPeerID: peerID,
|
|
||||||
mentions: message.mentions,
|
|
||||||
deliveryStatus: message.deliveryStatus
|
|
||||||
)
|
|
||||||
privateChats[peerID]?.append(updatedMessage)
|
|
||||||
consolidatedCount += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
privateChats.removeValue(forKey: tempPeerID)
|
|
||||||
unreadMessages.remove(tempPeerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if hadUnreadTemp {
|
|
||||||
unreadMessages.insert(peerID)
|
|
||||||
hasUnreadMessages = true
|
|
||||||
SecureLogger.debug("📬 Transferred unread status from temp peer IDs to \(peerID)", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
if consolidatedCount > 0 {
|
|
||||||
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
|
|
||||||
SecureLogger.info("📥 Consolidated \(consolidatedCount) Nostr messages from temporary peer IDs to \(peerNickname)", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return hasUnreadMessages
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Syncs the read receipt tracking between manager and view model for sent messages
|
|
||||||
@MainActor
|
|
||||||
func syncReadReceiptsForSentMessages(peerID: PeerID, nickname: String, externalReceipts: inout Set<String>) {
|
|
||||||
guard let messages = privateChats[peerID] else { return }
|
|
||||||
|
|
||||||
for message in messages {
|
|
||||||
if message.sender == nickname {
|
|
||||||
if let status = message.deliveryStatus {
|
|
||||||
switch status {
|
|
||||||
case .read, .delivered:
|
|
||||||
externalReceipts.insert(message.id)
|
|
||||||
sentReadReceipts.insert(message.id)
|
|
||||||
case .failed, .partiallyDelivered, .sending, .sent:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start a private chat with a peer
|
/// Start a private chat with a peer
|
||||||
func startChat(with peerID: PeerID) {
|
func startChat(with peerID: String) {
|
||||||
selectedPeer = peerID
|
selectedPeer = peerID
|
||||||
|
|
||||||
// Store fingerprint for persistence across reconnections
|
// Store fingerprint for persistence across reconnections
|
||||||
@@ -208,65 +53,184 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
selectedPeer = nil
|
selectedPeer = nil
|
||||||
selectedPeerFingerprint = nil
|
selectedPeerFingerprint = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove duplicate messages by ID and keep chronological order
|
/// Send a private message
|
||||||
func sanitizeChat(for peerID: PeerID) {
|
func sendMessage(_ content: String, to peerID: String) {
|
||||||
guard let arr = privateChats[peerID] else { return }
|
guard let meshService = meshService,
|
||||||
if arr.count <= 1 {
|
let peerNickname = meshService.peerNickname(peerID: peerID) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var indexByID: [String: Int] = [:]
|
let messageID = UUID().uuidString
|
||||||
indexByID.reserveCapacity(arr.count)
|
|
||||||
var deduped: [BitchatMessage] = []
|
// Create local message
|
||||||
deduped.reserveCapacity(arr.count)
|
let message = BitchatMessage(
|
||||||
|
id: messageID,
|
||||||
for msg in arr.sorted(by: { $0.timestamp < $1.timestamp }) {
|
sender: meshService.myNickname,
|
||||||
if let existing = indexByID[msg.id] {
|
content: content,
|
||||||
deduped[existing] = msg
|
timestamp: Date(),
|
||||||
} else {
|
isRelay: false,
|
||||||
indexByID[msg.id] = deduped.count
|
originalSender: nil,
|
||||||
deduped.append(msg)
|
isPrivate: true,
|
||||||
}
|
recipientNickname: peerNickname,
|
||||||
|
senderPeerID: meshService.myPeerID,
|
||||||
|
mentions: nil,
|
||||||
|
deliveryStatus: .sending
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add to chat
|
||||||
|
if privateChats[peerID] == nil { privateChats[peerID] = [] }
|
||||||
|
privateChats[peerID]?.append(message)
|
||||||
|
// Enforce per-chat cap on local append
|
||||||
|
if var arr = privateChats[peerID], arr.count > privateChatCap {
|
||||||
|
let remove = arr.count - privateChatCap
|
||||||
|
arr.removeFirst(remove)
|
||||||
|
privateChats[peerID] = arr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send via mesh service
|
||||||
|
meshService.sendPrivateMessage(content, to: peerID, recipientNickname: peerNickname, messageID: messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle incoming private message
|
||||||
|
func handleIncomingMessage(_ message: BitchatMessage) {
|
||||||
|
guard let senderPeerID = message.senderPeerID else { return }
|
||||||
|
|
||||||
|
// Initialize chat if needed
|
||||||
|
if privateChats[senderPeerID] == nil {
|
||||||
|
privateChats[senderPeerID] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate by ID: replace existing message if present, else append
|
||||||
|
if let idx = privateChats[senderPeerID]?.firstIndex(where: { $0.id == message.id }) {
|
||||||
|
privateChats[senderPeerID]?[idx] = message
|
||||||
|
} else {
|
||||||
|
privateChats[senderPeerID]?.append(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sanitize chat to avoid duplicate IDs and sort by timestamp
|
||||||
|
sanitizeChat(for: senderPeerID)
|
||||||
|
// Enforce cap after sanitize
|
||||||
|
if var arr = privateChats[senderPeerID], arr.count > privateChatCap {
|
||||||
|
let remove = arr.count - privateChatCap
|
||||||
|
arr.removeFirst(remove)
|
||||||
|
privateChats[senderPeerID] = arr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as unread if not in this chat
|
||||||
|
if selectedPeer != senderPeerID {
|
||||||
|
unreadMessages.insert(senderPeerID)
|
||||||
|
|
||||||
|
// Avoid notifying for messages already marked as read (dup/resubscribe cases)
|
||||||
|
if !(readReceiptTracker?.contains(message.id) ?? false) {
|
||||||
|
NotificationService.shared.sendPrivateMessageNotification(
|
||||||
|
from: message.sender,
|
||||||
|
message: message.content,
|
||||||
|
peerID: senderPeerID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Send read receipt if viewing this chat
|
||||||
|
sendReadReceipt(for: message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove duplicate messages by ID and keep chronological order
|
||||||
|
func sanitizeChat(for peerID: String) {
|
||||||
|
guard let arr = privateChats[peerID] else { return }
|
||||||
|
var seen = Set<String>()
|
||||||
|
var deduped: [BitchatMessage] = []
|
||||||
|
for msg in arr.sorted(by: { $0.timestamp < $1.timestamp }) {
|
||||||
|
if !seen.contains(msg.id) {
|
||||||
|
seen.insert(msg.id)
|
||||||
|
deduped.append(msg)
|
||||||
|
} else {
|
||||||
|
// Replace previous with the latest occurrence (which is later in sort)
|
||||||
|
if let index = deduped.firstIndex(where: { $0.id == msg.id }) {
|
||||||
|
deduped[index] = msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
privateChats[peerID] = deduped
|
privateChats[peerID] = deduped
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark messages from a peer as read
|
/// Mark messages from a peer as read
|
||||||
func markAsRead(from peerID: PeerID) {
|
func markAsRead(from peerID: String) {
|
||||||
unreadMessages.remove(peerID)
|
unreadMessages.remove(peerID)
|
||||||
|
|
||||||
// Send read receipts for unread messages that haven't been sent yet
|
// Send read receipts for unread messages that haven't been sent yet
|
||||||
if let messages = privateChats[peerID] {
|
if let messages = privateChats[peerID] {
|
||||||
for message in messages {
|
for message in messages {
|
||||||
if message.senderPeerID == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) {
|
if message.senderPeerID == peerID && !message.isRelay && !(readReceiptTracker?.contains(message.id) ?? false) {
|
||||||
sendReadReceipt(for: message)
|
sendReadReceipt(for: message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update the selected peer if fingerprint matches (for reconnections)
|
||||||
|
func updateSelectedPeer(peers: [String: String]) {
|
||||||
|
guard let fingerprint = selectedPeerFingerprint else { return }
|
||||||
|
|
||||||
|
// Find peer with matching fingerprint
|
||||||
|
for (peerID, _) in peers {
|
||||||
|
if meshService?.getFingerprint(for: peerID) == fingerprint {
|
||||||
|
selectedPeer = peerID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get chat messages for current context
|
||||||
|
func getCurrentMessages() -> [BitchatMessage] {
|
||||||
|
guard let peer = selectedPeer else { return [] }
|
||||||
|
return privateChats[peer] ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear a private chat
|
||||||
|
func clearChat(with peerID: String) {
|
||||||
|
privateChats[peerID]?.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle delivery acknowledgment
|
||||||
|
func handleDeliveryAck(messageID: String, from peerID: String) {
|
||||||
|
guard privateChats[peerID] != nil else { return }
|
||||||
|
|
||||||
|
if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||||
|
privateChats[peerID]?[index].deliveryStatus = .delivered(to: "recipient", at: Date())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle read receipt
|
||||||
|
func handleReadReceipt(messageID: String, from peerID: String) {
|
||||||
|
guard privateChats[peerID] != nil else { return }
|
||||||
|
|
||||||
|
if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||||
|
privateChats[peerID]?[index].deliveryStatus = .read(by: "recipient", at: Date())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
|
|
||||||
private func sendReadReceipt(for message: BitchatMessage) {
|
private func sendReadReceipt(for message: BitchatMessage) {
|
||||||
guard !sentReadReceipts.contains(message.id),
|
guard !(readReceiptTracker?.contains(message.id) ?? false),
|
||||||
let senderPeerID = message.senderPeerID else {
|
let senderPeerID = message.senderPeerID else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sentReadReceipts.insert(message.id)
|
readReceiptTracker?.insert(message.id)
|
||||||
|
|
||||||
// Create read receipt using the simplified method
|
// Create read receipt using the simplified method
|
||||||
let receipt = ReadReceipt(
|
let receipt = ReadReceipt(
|
||||||
originalMessageID: message.id,
|
originalMessageID: message.id,
|
||||||
readerID: meshService?.myPeerID ?? PeerID(str: ""),
|
readerID: meshService?.myPeerID ?? "",
|
||||||
readerNickname: meshService?.myNickname ?? ""
|
readerNickname: meshService?.myNickname ?? ""
|
||||||
)
|
)
|
||||||
|
|
||||||
// Route via MessageRouter to avoid handshakeRequired spam when session isn't established
|
// Route via MessageRouter to avoid handshakeRequired spam when session isn't established
|
||||||
if let router = messageRouter {
|
if let router = messageRouter {
|
||||||
SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.id.prefix(8))… via router", category: .session)
|
SecureLogger.log("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.prefix(8))… via router",
|
||||||
|
category: SecureLogger.session, level: .debug)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
router.sendReadReceipt(receipt, to: senderPeerID)
|
router.sendReadReceipt(receipt, to: senderPeerID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
//
|
||||||
|
// ReadReceiptTracker.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// Centralized tracker for sent read receipts with simple persistence.
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class ReadReceiptTracker {
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
private let key = "sentReadReceipts"
|
||||||
|
private let queue = DispatchQueue(label: "chat.bitchat.readreceipts", attributes: .concurrent)
|
||||||
|
private var set: Set<String> = []
|
||||||
|
|
||||||
|
init(defaults: UserDefaults = .standard) {
|
||||||
|
self.defaults = defaults
|
||||||
|
if let data = defaults.data(forKey: key),
|
||||||
|
let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||||
|
self.set = Set(arr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(_ id: String) -> Bool {
|
||||||
|
queue.sync { set.contains(id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func insert(_ id: String) {
|
||||||
|
queue.async(flags: .barrier) {
|
||||||
|
if self.set.insert(id).inserted { self.persist() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func insert<S: Sequence>(_ ids: S) where S.Element == String {
|
||||||
|
queue.async(flags: .barrier) {
|
||||||
|
var changed = false
|
||||||
|
for id in ids { changed = self.set.insert(id).inserted || changed }
|
||||||
|
if changed { self.persist() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove(_ id: String) {
|
||||||
|
queue.async(flags: .barrier) {
|
||||||
|
if self.set.remove(id) != nil { self.persist() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeAll() {
|
||||||
|
queue.async(flags: .barrier) {
|
||||||
|
if !self.set.isEmpty { self.set.removeAll(); self.persist() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep only IDs present in the allow-list; useful for pruning stale entries.
|
||||||
|
func prune(toAllowedIDs allowed: Set<String>) {
|
||||||
|
queue.async(flags: .barrier) {
|
||||||
|
let newSet = self.set.intersection(allowed)
|
||||||
|
if newSet.count != self.set.count { self.set = newSet; self.persist() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot current set for read-only operations (avoid long-lived copies in hot paths)
|
||||||
|
func snapshot() -> Set<String> { queue.sync { set } }
|
||||||
|
|
||||||
|
private func persist() {
|
||||||
|
let arr = Array(set)
|
||||||
|
if let data = try? JSONEncoder().encode(arr) {
|
||||||
|
defaults.set(data, forKey: key)
|
||||||
|
} else {
|
||||||
|
SecureLogger.log("❌ Failed to encode read receipts for persistence",
|
||||||
|
category: SecureLogger.session, level: .error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -13,23 +13,18 @@ struct RelayController {
|
|||||||
senderIsSelf: Bool,
|
senderIsSelf: Bool,
|
||||||
isEncrypted: Bool,
|
isEncrypted: Bool,
|
||||||
isDirectedEncrypted: Bool,
|
isDirectedEncrypted: Bool,
|
||||||
isFragment: Bool,
|
|
||||||
isDirectedFragment: Bool,
|
isDirectedFragment: Bool,
|
||||||
isHandshake: Bool,
|
isHandshake: Bool,
|
||||||
isAnnounce: Bool,
|
isAnnounce: Bool,
|
||||||
degree: Int,
|
degree: Int,
|
||||||
highDegreeThreshold: Int) -> RelayDecision {
|
highDegreeThreshold: Int) -> RelayDecision {
|
||||||
let ttlCap = min(ttl, TransportConfig.messageTTLDefault)
|
|
||||||
|
|
||||||
// Suppress obvious non-relays
|
// Suppress obvious non-relays
|
||||||
if ttlCap <= 1 || senderIsSelf {
|
if ttl <= 1 || senderIsSelf { return RelayDecision(shouldRelay: false, newTTL: ttl, delayMs: 0) }
|
||||||
return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// For session-critical or directed traffic, be deterministic and reliable
|
// For session-critical or directed traffic, be deterministic and reliable
|
||||||
if isHandshake || isDirectedFragment || isDirectedEncrypted {
|
if isHandshake || isDirectedFragment || isDirectedEncrypted {
|
||||||
// Always relay with no TTL cap for these types
|
// Always relay with no TTL cap for these types
|
||||||
let newTTL = ttlCap &- 1
|
let newTTL = (ttl &- 1)
|
||||||
// Slight jitter to desynchronize without adding too much latency
|
// Slight jitter to desynchronize without adding too much latency
|
||||||
// Tighter for faster multi-hop handshakes and directed DMs
|
// Tighter for faster multi-hop handshakes and directed DMs
|
||||||
let delayRange: ClosedRange<Int> = isHandshake ? 10...35 : 20...60
|
let delayRange: ClosedRange<Int> = isHandshake ? 10...35 : 20...60
|
||||||
@@ -37,27 +32,28 @@ struct RelayController {
|
|||||||
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
if isFragment {
|
// Degree-aware probability to reduce floods in dense graphs (broadcast/public)
|
||||||
let ttlLimit = min(ttlCap, TransportConfig.bleFragmentRelayTtlCap)
|
let baseProb: Double
|
||||||
guard ttlLimit > 1 else {
|
switch degree {
|
||||||
return RelayDecision(shouldRelay: false, newTTL: ttlLimit, delayMs: 0)
|
case 0...2: baseProb = 1.0
|
||||||
}
|
case 3...4: baseProb = 0.9
|
||||||
let newTTL = ttlLimit &- 1
|
case 5...6: baseProb = 0.7
|
||||||
let delayMs = Int.random(in: TransportConfig.bleFragmentRelayMinDelayMs...TransportConfig.bleFragmentRelayMaxDelayMs)
|
case 7...9: baseProb = 0.55
|
||||||
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
default: baseProb = 0.45
|
||||||
}
|
}
|
||||||
|
let prob = baseProb
|
||||||
|
let shouldRelay = Double.random(in: 0...1) <= prob
|
||||||
|
|
||||||
// TTL clamping for broadcast
|
// TTL clamping for broadcast
|
||||||
// - Dense graphs: keep lower but still allow multi-hop bridging
|
// - Dense graphs: keep very low to avoid floods
|
||||||
// - Announces get a bit more headroom
|
// - Sparse graphs: allow slightly longer reach for multi-hop discovery
|
||||||
let ttlLimit: UInt8 = {
|
// - Announces in sparse graphs get a bit more headroom
|
||||||
if degree >= highDegreeThreshold {
|
let ttlCap: UInt8 = {
|
||||||
return max(UInt8(2), min(ttlCap, UInt8(5)))
|
if degree >= highDegreeThreshold { return 3 }
|
||||||
}
|
return isAnnounce ? 7 : 6
|
||||||
let preferred = UInt8(isAnnounce ? 7 : 6)
|
|
||||||
return max(UInt8(2), min(ttlCap, preferred))
|
|
||||||
}()
|
}()
|
||||||
let newTTL = ttlLimit &- 1
|
let clamped = max(1, min(ttl, ttlCap))
|
||||||
|
let newTTL = clamped &- 1
|
||||||
|
|
||||||
// Wider jitter window to allow duplicate suppression to win more often
|
// Wider jitter window to allow duplicate suppression to win more often
|
||||||
// For sparse graphs (<=2), relay quickly to avoid cancellation races
|
// For sparse graphs (<=2), relay quickly to avoid cancellation races
|
||||||
@@ -68,6 +64,6 @@ struct RelayController {
|
|||||||
case 6...9: delayMs = Int.random(in: 80...180)
|
case 6...9: delayMs = Int.random(in: 80...180)
|
||||||
default: delayMs = Int.random(in: 100...220)
|
default: delayMs = Int.random(in: 100...220)
|
||||||
}
|
}
|
||||||
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
return RelayDecision(shouldRelay: shouldRelay, newTTL: newTTL, delayMs: delayMs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Combine
|
|
||||||
|
|
||||||
/// Centralized progress bus for Bluetooth file transfers.
|
|
||||||
/// Emits Combine events consumed by ChatViewModel to update UI progress indicators.
|
|
||||||
final class TransferProgressManager {
|
|
||||||
static let shared = TransferProgressManager()
|
|
||||||
|
|
||||||
enum Event {
|
|
||||||
case started(id: String, totalFragments: Int)
|
|
||||||
case updated(id: String, sentFragments: Int, totalFragments: Int)
|
|
||||||
case completed(id: String, totalFragments: Int)
|
|
||||||
case cancelled(id: String, sentFragments: Int, totalFragments: Int)
|
|
||||||
}
|
|
||||||
|
|
||||||
private let subject = PassthroughSubject<Event, Never>()
|
|
||||||
private let queue = DispatchQueue(label: "com.bitchat.transfer-progress", attributes: .concurrent)
|
|
||||||
private var states: [String: (sent: Int, total: Int)] = [:]
|
|
||||||
|
|
||||||
var publisher: AnyPublisher<Event, Never> {
|
|
||||||
subject.eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
func start(id: String, totalFragments: Int) {
|
|
||||||
queue.async(flags: .barrier) { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
self.states[id] = (sent: 0, total: totalFragments)
|
|
||||||
self.subject.send(.started(id: id, totalFragments: totalFragments))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func recordFragmentSent(id: String) {
|
|
||||||
queue.async(flags: .barrier) { [weak self] in
|
|
||||||
guard let self = self, var state = self.states[id] else { return }
|
|
||||||
state.sent = min(state.sent + 1, state.total)
|
|
||||||
self.states[id] = state
|
|
||||||
self.subject.send(.updated(id: id, sentFragments: state.sent, totalFragments: state.total))
|
|
||||||
if state.sent >= state.total {
|
|
||||||
self.states.removeValue(forKey: id)
|
|
||||||
self.subject.send(.completed(id: id, totalFragments: state.total))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func cancel(id: String) {
|
|
||||||
queue.async(flags: .barrier) { [weak self] in
|
|
||||||
guard let self = self, let state = self.states.removeValue(forKey: id) else { return }
|
|
||||||
self.subject.send(.cancelled(id: id, sentFragments: state.sent, totalFragments: state.total))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func reset(id: String) {
|
|
||||||
queue.async(flags: .barrier) { [weak self] in
|
|
||||||
self?.states.removeValue(forKey: id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func snapshot(id: String) -> (sent: Int, total: Int)? {
|
|
||||||
var result: (sent: Int, total: Int)?
|
|
||||||
queue.sync {
|
|
||||||
result = states[id]
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import BitFoundation
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
/// Abstract transport interface used by ChatViewModel and services.
|
/// Abstract transport interface used by ChatViewModel and services.
|
||||||
/// BLEService implements this protocol; a future Nostr transport can too.
|
/// BLEService implements this protocol; a future Nostr transport can too.
|
||||||
struct TransportPeerSnapshot: Equatable, Hashable {
|
struct TransportPeerSnapshot: Equatable, Hashable {
|
||||||
let peerID: PeerID
|
let id: String
|
||||||
let nickname: String
|
let nickname: String
|
||||||
let isConnected: Bool
|
let isConnected: Bool
|
||||||
let noisePublicKey: Data?
|
let noisePublicKey: Data?
|
||||||
@@ -13,17 +12,13 @@ struct TransportPeerSnapshot: Equatable, Hashable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protocol Transport: AnyObject {
|
protocol Transport: AnyObject {
|
||||||
// Event sink
|
|
||||||
var delegate: BitchatDelegate? { get set }
|
|
||||||
// Peer events (preferred over publishers for UI)
|
// Peer events (preferred over publishers for UI)
|
||||||
var peerEventsDelegate: TransportPeerEventsDelegate? { get set }
|
var peerEventsDelegate: TransportPeerEventsDelegate? { get set }
|
||||||
|
// Event sink
|
||||||
// Peer snapshots (for non-UI services)
|
var delegate: BitchatDelegate? { get set }
|
||||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get }
|
|
||||||
func currentPeerSnapshots() -> [TransportPeerSnapshot]
|
|
||||||
|
|
||||||
// Identity
|
// Identity
|
||||||
var myPeerID: PeerID { get }
|
var myPeerID: String { get }
|
||||||
var myNickname: String { get }
|
var myNickname: String { get }
|
||||||
func setNickname(_ nickname: String)
|
func setNickname(_ nickname: String)
|
||||||
|
|
||||||
@@ -33,51 +28,37 @@ protocol Transport: AnyObject {
|
|||||||
func emergencyDisconnectAll()
|
func emergencyDisconnectAll()
|
||||||
|
|
||||||
// Connectivity and peers
|
// Connectivity and peers
|
||||||
func isPeerConnected(_ peerID: PeerID) -> Bool
|
func isPeerConnected(_ peerID: String) -> Bool
|
||||||
func isPeerReachable(_ peerID: PeerID) -> Bool
|
func isPeerReachable(_ peerID: String) -> Bool
|
||||||
func peerNickname(peerID: PeerID) -> String?
|
func peerNickname(peerID: String) -> String?
|
||||||
func getPeerNicknames() -> [PeerID: String]
|
func getPeerNicknames() -> [String: String]
|
||||||
|
|
||||||
// Protocol utilities
|
// Protocol utilities
|
||||||
func getFingerprint(for peerID: PeerID) -> String?
|
func getFingerprint(for peerID: String) -> String?
|
||||||
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState
|
func getNoiseSessionState(for peerID: String) -> LazyHandshakeState
|
||||||
func triggerHandshake(with peerID: PeerID)
|
func triggerHandshake(with peerID: String)
|
||||||
func getNoiseService() -> NoiseEncryptionService
|
func getNoiseService() -> NoiseEncryptionService
|
||||||
|
|
||||||
// Messaging
|
// Messaging
|
||||||
func sendMessage(_ content: String, mentions: [String])
|
func sendMessage(_ content: String, mentions: [String])
|
||||||
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date)
|
func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String)
|
||||||
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String)
|
||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
|
func sendFavoriteNotification(to peerID: String, isFavorite: Bool)
|
||||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
|
||||||
func sendBroadcastAnnounce()
|
func sendBroadcastAnnounce()
|
||||||
func sendDeliveryAck(for messageID: String, to peerID: PeerID)
|
func sendDeliveryAck(for messageID: String, to peerID: String)
|
||||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
|
|
||||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
|
|
||||||
func cancelTransfer(_ transferId: String)
|
|
||||||
|
|
||||||
// QR verification (optional for transports)
|
// QR verification (optional for transports)
|
||||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data)
|
||||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data)
|
||||||
|
|
||||||
// Pending file management (BCH-01-002: files held in memory until user accepts)
|
// Peer snapshots (for non-UI services)
|
||||||
func acceptPendingFile(id: String) -> URL?
|
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get }
|
||||||
func declinePendingFile(id: String)
|
func currentPeerSnapshots() -> [TransportPeerSnapshot]
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Transport {
|
extension Transport {
|
||||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data) {}
|
||||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data) {}
|
||||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
|
||||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
|
||||||
func cancelTransfer(_ transferId: String) {}
|
|
||||||
|
|
||||||
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
|
|
||||||
sendMessage(content, mentions: mentions)
|
|
||||||
}
|
|
||||||
|
|
||||||
func acceptPendingFile(id: String) -> URL? { nil }
|
|
||||||
func declinePendingFile(id: String) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol TransportPeerEventsDelegate: AnyObject {
|
protocol TransportPeerEventsDelegate: AnyObject {
|
||||||
|
|||||||
@@ -8,10 +8,6 @@ enum TransportConfig {
|
|||||||
static let messageTTLDefault: UInt8 = 7 // Default TTL for mesh flooding
|
static let messageTTLDefault: UInt8 = 7 // Default TTL for mesh flooding
|
||||||
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
|
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
|
||||||
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
|
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
|
||||||
static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends
|
|
||||||
static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
|
|
||||||
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
|
|
||||||
static let bleFragmentRelayTtlCap: UInt8 = 5 // Clamp fragment TTL to contain floods
|
|
||||||
|
|
||||||
// UI / Storage Caps
|
// UI / Storage Caps
|
||||||
static let privateChatCap: Int = 1337
|
static let privateChatCap: Int = 1337
|
||||||
@@ -21,7 +17,6 @@ enum TransportConfig {
|
|||||||
|
|
||||||
// Timers
|
// Timers
|
||||||
static let networkResetGraceSeconds: TimeInterval = 600 // 10 minutes
|
static let networkResetGraceSeconds: TimeInterval = 600 // 10 minutes
|
||||||
static let networkNotificationCooldownSeconds: TimeInterval = 300 // 5 minutes
|
|
||||||
static let basePublicFlushInterval: TimeInterval = 0.08 // ~12.5 fps batching
|
static let basePublicFlushInterval: TimeInterval = 0.08 // ~12.5 fps batching
|
||||||
|
|
||||||
// BLE duty/announce/connect
|
// BLE duty/announce/connect
|
||||||
@@ -35,19 +30,13 @@ enum TransportConfig {
|
|||||||
static let bleDynamicRSSIThresholdDefault: Int = -90
|
static let bleDynamicRSSIThresholdDefault: Int = -90
|
||||||
static let bleConnectionCandidatesMax: Int = 100
|
static let bleConnectionCandidatesMax: Int = 100
|
||||||
static let blePendingWriteBufferCapBytes: Int = 1_000_000
|
static let blePendingWriteBufferCapBytes: Int = 1_000_000
|
||||||
static let bleNotificationAssemblerHardCapBytes: Int = 8 * 1024 * 1024
|
static let blePendingNotificationsCapCount: Int = 20
|
||||||
static let bleAssemblerStallResetMs: Int = 250
|
|
||||||
static let blePendingNotificationsCapCount: Int = 128
|
|
||||||
static let bleNotificationRetryDelayMs: Int = 25
|
|
||||||
static let bleNotificationRetryMaxAttempts: Int = 80
|
|
||||||
|
|
||||||
// Nostr
|
// Nostr
|
||||||
static let nostrReadAckInterval: TimeInterval = 0.35 // ~3 per second
|
static let nostrReadAckInterval: TimeInterval = 0.35 // ~3 per second
|
||||||
|
|
||||||
// UI thresholds
|
// UI thresholds
|
||||||
static let uiLateInsertThreshold: TimeInterval = 15.0
|
static let uiLateInsertThreshold: TimeInterval = 15.0
|
||||||
// Geohash public chats are more sensitive to ordering; use a tighter threshold
|
|
||||||
static let uiLateInsertThresholdGeo: TimeInterval = 0.0
|
|
||||||
static let uiProcessedNostrEventsCap: Int = 2000
|
static let uiProcessedNostrEventsCap: Int = 2000
|
||||||
static let uiChannelInactivityThresholdSeconds: TimeInterval = 9 * 60
|
static let uiChannelInactivityThresholdSeconds: TimeInterval = 9 * 60
|
||||||
|
|
||||||
@@ -71,7 +60,6 @@ enum TransportConfig {
|
|||||||
static let uiAnimationMediumSeconds: TimeInterval = 0.2
|
static let uiAnimationMediumSeconds: TimeInterval = 0.2
|
||||||
static let uiAnimationSidebarSeconds: TimeInterval = 0.25
|
static let uiAnimationSidebarSeconds: TimeInterval = 0.25
|
||||||
static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60
|
static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60
|
||||||
static let uiMeshEmptyConfirmationSeconds: TimeInterval = 30.0
|
|
||||||
|
|
||||||
// BLE maintenance & thresholds
|
// BLE maintenance & thresholds
|
||||||
static let bleMaintenanceInterval: TimeInterval = 5.0
|
static let bleMaintenanceInterval: TimeInterval = 5.0
|
||||||
@@ -97,12 +85,11 @@ enum TransportConfig {
|
|||||||
// Keep scanning fully ON when we saw traffic very recently
|
// Keep scanning fully ON when we saw traffic very recently
|
||||||
static let bleRecentTrafficForceScanSeconds: TimeInterval = 10.0
|
static let bleRecentTrafficForceScanSeconds: TimeInterval = 10.0
|
||||||
static let bleThreadSleepWriteShortDelaySeconds: TimeInterval = 0.05
|
static let bleThreadSleepWriteShortDelaySeconds: TimeInterval = 0.05
|
||||||
static let bleExpectedWritePerFragmentMs: Int = 20
|
static let bleExpectedWritePerFragmentMs: Int = 8
|
||||||
static let bleExpectedWriteMaxMs: Int = 5000
|
static let bleExpectedWriteMaxMs: Int = 2000
|
||||||
// Fragment pacing: Conservative spacing to prevent BLE buffer overflow
|
// Faster fragment pacing; use slightly tighter spacing for directed trains
|
||||||
// Aggressive pacing causes packet loss; needs 25-30ms between fragments for reliable delivery
|
static let bleFragmentSpacingMs: Int = 5
|
||||||
static let bleFragmentSpacingMs: Int = 30
|
static let bleFragmentSpacingDirectedMs: Int = 4
|
||||||
static let bleFragmentSpacingDirectedMs: Int = 25
|
|
||||||
static let bleAnnounceIntervalSeconds: TimeInterval = 4.0
|
static let bleAnnounceIntervalSeconds: TimeInterval = 4.0
|
||||||
static let bleDutyOnDurationDense: TimeInterval = 3.0
|
static let bleDutyOnDurationDense: TimeInterval = 3.0
|
||||||
static let bleDutyOffDurationDense: TimeInterval = 15.0
|
static let bleDutyOffDurationDense: TimeInterval = 15.0
|
||||||
@@ -114,7 +101,7 @@ enum TransportConfig {
|
|||||||
// Location
|
// Location
|
||||||
static let locationDistanceFilterMeters: Double = 1000
|
static let locationDistanceFilterMeters: Double = 1000
|
||||||
// Live (channel sheet open) distance threshold for meaningful updates
|
// Live (channel sheet open) distance threshold for meaningful updates
|
||||||
static let locationDistanceFilterLiveMeters: Double = 10.0
|
static let locationDistanceFilterLiveMeters: Double = 21.0
|
||||||
static let locationLiveRefreshInterval: TimeInterval = 5.0
|
static let locationLiveRefreshInterval: TimeInterval = 5.0
|
||||||
|
|
||||||
// Notifications (geohash)
|
// Notifications (geohash)
|
||||||
@@ -133,6 +120,9 @@ enum TransportConfig {
|
|||||||
static let nostrShortKeyDisplayLength: Int = 8
|
static let nostrShortKeyDisplayLength: Int = 8
|
||||||
static let nostrConvKeyPrefixLength: Int = 16
|
static let nostrConvKeyPrefixLength: Int = 16
|
||||||
|
|
||||||
|
// Compression
|
||||||
|
static let compressionThresholdBytes: Int = 100
|
||||||
|
|
||||||
// Message deduplication
|
// Message deduplication
|
||||||
static let messageDedupMaxAgeSeconds: TimeInterval = 300
|
static let messageDedupMaxAgeSeconds: TimeInterval = 300
|
||||||
static let messageDedupMaxCount: Int = 1000
|
static let messageDedupMaxCount: Int = 1000
|
||||||
@@ -149,9 +139,6 @@ enum TransportConfig {
|
|||||||
|
|
||||||
// Geo relay directory
|
// Geo relay directory
|
||||||
static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24
|
static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24
|
||||||
static let geoRelayRefreshCheckIntervalSeconds: TimeInterval = 60 * 60
|
|
||||||
static let geoRelayRetryInitialSeconds: TimeInterval = 60
|
|
||||||
static let geoRelayRetryMaxSeconds: TimeInterval = 60 * 60
|
|
||||||
|
|
||||||
// BLE operational delays
|
// BLE operational delays
|
||||||
static let bleInitialAnnounceDelaySeconds: TimeInterval = 0.6
|
static let bleInitialAnnounceDelaySeconds: TimeInterval = 0.6
|
||||||
@@ -161,14 +148,6 @@ enum TransportConfig {
|
|||||||
static let blePostAnnounceDelaySeconds: TimeInterval = 0.4
|
static let blePostAnnounceDelaySeconds: TimeInterval = 0.4
|
||||||
static let bleForceAnnounceMinIntervalSeconds: TimeInterval = 0.15
|
static let bleForceAnnounceMinIntervalSeconds: TimeInterval = 0.15
|
||||||
|
|
||||||
// BCH-01-004: Rate-limiting for subscription-triggered announces
|
|
||||||
// Prevents rapid enumeration attacks by rate-limiting announce responses
|
|
||||||
static let bleSubscriptionRateLimitMinSeconds: TimeInterval = 2.0 // Minimum interval between announces per central
|
|
||||||
static let bleSubscriptionRateLimitBackoffFactor: Double = 2.0 // Exponential backoff multiplier
|
|
||||||
static let bleSubscriptionRateLimitMaxBackoffSeconds: TimeInterval = 30.0 // Maximum backoff period
|
|
||||||
static let bleSubscriptionRateLimitWindowSeconds: TimeInterval = 60.0 // Window for tracking subscription attempts
|
|
||||||
static let bleSubscriptionRateLimitMaxAttempts: Int = 5 // Max attempts before extended cooldown
|
|
||||||
|
|
||||||
// Store-and-forward for directed packets at relays
|
// Store-and-forward for directed packets at relays
|
||||||
static let bleDirectedSpoolWindowSeconds: TimeInterval = 15.0
|
static let bleDirectedSpoolWindowSeconds: TimeInterval = 15.0
|
||||||
|
|
||||||
@@ -207,21 +186,7 @@ enum TransportConfig {
|
|||||||
static let uiWindowStepCount: Int = 200
|
static let uiWindowStepCount: Int = 200
|
||||||
|
|
||||||
// Share extension
|
// Share extension
|
||||||
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0
|
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 0.3
|
||||||
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
|
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
|
||||||
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
|
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
|
||||||
|
|
||||||
// Gossip Sync Configuration
|
|
||||||
static let syncSeenCapacity: Int = 1000
|
|
||||||
static let syncGCSMaxBytes: Int = 400
|
|
||||||
static let syncGCSTargetFpr: Double = 0.01
|
|
||||||
static let syncMaxMessageAgeSeconds: TimeInterval = 900
|
|
||||||
static let syncMaintenanceIntervalSeconds: TimeInterval = 30.0
|
|
||||||
static let syncStalePeerCleanupIntervalSeconds: TimeInterval = 60.0
|
|
||||||
static let syncStalePeerTimeoutSeconds: TimeInterval = 60.0
|
|
||||||
static let syncFragmentCapacity: Int = 600
|
|
||||||
static let syncFileTransferCapacity: Int = 200
|
|
||||||
static let syncFragmentIntervalSeconds: TimeInterval = 30.0
|
|
||||||
static let syncFileTransferIntervalSeconds: TimeInterval = 60.0
|
|
||||||
static let syncMessageIntervalSeconds: TimeInterval = 15.0
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,44 +6,34 @@
|
|||||||
// This is free and unencumbered software released into the public domain.
|
// This is free and unencumbered software released into the public domain.
|
||||||
//
|
//
|
||||||
|
|
||||||
import BitLogger
|
|
||||||
import BitFoundation
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
/// Single source of truth for peer state, combining mesh connectivity and favorites
|
/// Single source of truth for peer state, combining mesh connectivity and favorites
|
||||||
@MainActor
|
@MainActor
|
||||||
final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||||
|
|
||||||
// MARK: - Published Properties
|
// MARK: - Published Properties
|
||||||
|
|
||||||
@Published private(set) var peers: [BitchatPeer] = []
|
@Published private(set) var peers: [BitchatPeer] = []
|
||||||
@Published private(set) var connectedPeerIDs: Set<PeerID> = []
|
@Published private(set) var connectedPeerIDs: Set<String> = []
|
||||||
@Published private(set) var favorites: [BitchatPeer] = []
|
@Published private(set) var favorites: [BitchatPeer] = []
|
||||||
@Published private(set) var mutualFavorites: [BitchatPeer] = []
|
@Published private(set) var mutualFavorites: [BitchatPeer] = []
|
||||||
|
|
||||||
// MARK: - Private Properties
|
// MARK: - Private Properties
|
||||||
|
|
||||||
private var peerIndex: [PeerID: BitchatPeer] = [:]
|
private var peerIndex: [String: BitchatPeer] = [:]
|
||||||
private var fingerprintCache: [PeerID: String] = [:]
|
private var fingerprintCache: [String: String] = [:] // peerID -> fingerprint
|
||||||
private let meshService: Transport
|
private let meshService: Transport
|
||||||
private let idBridge: NostrIdentityBridge
|
|
||||||
private let identityManager: SecureIdentityStateManagerProtocol
|
|
||||||
weak var messageRouter: MessageRouter?
|
|
||||||
private let favoritesService = FavoritesPersistenceService.shared
|
private let favoritesService = FavoritesPersistenceService.shared
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
init(
|
init(meshService: Transport) {
|
||||||
meshService: Transport,
|
|
||||||
idBridge: NostrIdentityBridge,
|
|
||||||
identityManager: SecureIdentityStateManagerProtocol
|
|
||||||
) {
|
|
||||||
self.meshService = meshService
|
self.meshService = meshService
|
||||||
self.idBridge = idBridge
|
|
||||||
self.identityManager = identityManager
|
|
||||||
|
|
||||||
// Subscribe to changes from both services
|
// Subscribe to changes from both services
|
||||||
setupSubscriptions()
|
setupSubscriptions()
|
||||||
@@ -84,12 +74,12 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
let favorites = favoritesService.favorites
|
let favorites = favoritesService.favorites
|
||||||
|
|
||||||
var enrichedPeers: [BitchatPeer] = []
|
var enrichedPeers: [BitchatPeer] = []
|
||||||
var connected: Set<PeerID> = []
|
var connected: Set<String> = []
|
||||||
var addedPeerIDs: Set<PeerID> = []
|
var addedPeerIDs: Set<String> = []
|
||||||
|
|
||||||
// Phase 1: Add all mesh peers (connected and reachable)
|
// Phase 1: Add all mesh peers (connected and reachable)
|
||||||
for peerInfo in meshPeers {
|
for peerInfo in meshPeers {
|
||||||
let peerID = peerInfo.peerID
|
let peerID = peerInfo.id
|
||||||
guard peerID != meshService.myPeerID else { continue } // Never add self
|
guard peerID != meshService.myPeerID else { continue } // Never add self
|
||||||
|
|
||||||
let peer = buildPeerFromMesh(
|
let peer = buildPeerFromMesh(
|
||||||
@@ -110,7 +100,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
|
|
||||||
// Phase 2: Add offline favorites that we actively favorite
|
// Phase 2: Add offline favorites that we actively favorite
|
||||||
for (favoriteKey, favorite) in favorites where favorite.isFavorite {
|
for (favoriteKey, favorite) in favorites where favorite.isFavorite {
|
||||||
let peerID = PeerID(hexData: favoriteKey)
|
let peerID = favoriteKey.hexEncodedString()
|
||||||
|
|
||||||
// Skip if already added (connected peer)
|
// Skip if already added (connected peer)
|
||||||
if addedPeerIDs.contains(peerID) { continue }
|
if addedPeerIDs.contains(peerID) { continue }
|
||||||
@@ -144,10 +134,10 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
// Phase 4: Build subsets and indices
|
// Phase 4: Build subsets and indices
|
||||||
var favoritesList: [BitchatPeer] = []
|
var favoritesList: [BitchatPeer] = []
|
||||||
var mutualsList: [BitchatPeer] = []
|
var mutualsList: [BitchatPeer] = []
|
||||||
var newIndex: [PeerID: BitchatPeer] = [:]
|
var newIndex: [String: BitchatPeer] = [:]
|
||||||
|
|
||||||
for peer in enrichedPeers {
|
for peer in enrichedPeers {
|
||||||
newIndex[peer.peerID] = peer
|
newIndex[peer.id] = peer
|
||||||
|
|
||||||
if peer.isFavorite {
|
if peer.isFavorite {
|
||||||
favoritesList.append(peer)
|
favoritesList.append(peer)
|
||||||
@@ -183,7 +173,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
// Determine reachability based on lastSeen and identity trust
|
// Determine reachability based on lastSeen and identity trust
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let fingerprint = peerInfo.noisePublicKey?.sha256Fingerprint()
|
let fingerprint = peerInfo.noisePublicKey?.sha256Fingerprint()
|
||||||
let isVerified = fingerprint.map { identityManager.isVerified(fingerprint: $0) } ?? false
|
let isVerified = fingerprint.map { SecureIdentityStateManager.shared.isVerified(fingerprint: $0) } ?? false
|
||||||
let isFav = peerInfo.noisePublicKey.flatMap { favorites[$0]?.isFavorite } ?? false
|
let isFav = peerInfo.noisePublicKey.flatMap { favorites[$0]?.isFavorite } ?? false
|
||||||
let retention: TimeInterval = (isVerified || isFav) ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds
|
let retention: TimeInterval = (isVerified || isFav) ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds
|
||||||
// A peer is reachable if we recently saw them AND we are attached to the mesh
|
// A peer is reachable if we recently saw them AND we are attached to the mesh
|
||||||
@@ -191,7 +181,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
let isReachable = peerInfo.isConnected ? true : (withinRetention && meshAttached)
|
let isReachable = peerInfo.isConnected ? true : (withinRetention && meshAttached)
|
||||||
|
|
||||||
var peer = BitchatPeer(
|
var peer = BitchatPeer(
|
||||||
peerID: peerInfo.peerID,
|
id: peerInfo.id,
|
||||||
noisePublicKey: peerInfo.noisePublicKey ?? Data(),
|
noisePublicKey: peerInfo.noisePublicKey ?? Data(),
|
||||||
nickname: peerInfo.nickname,
|
nickname: peerInfo.nickname,
|
||||||
lastSeen: peerInfo.lastSeen,
|
lastSeen: peerInfo.lastSeen,
|
||||||
@@ -204,6 +194,31 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
let favoriteStatus = favorites[noiseKey] {
|
let favoriteStatus = favorites[noiseKey] {
|
||||||
peer.favoriteStatus = favoriteStatus
|
peer.favoriteStatus = favoriteStatus
|
||||||
peer.nostrPublicKey = favoriteStatus.peerNostrPublicKey
|
peer.nostrPublicKey = favoriteStatus.peerNostrPublicKey
|
||||||
|
} else {
|
||||||
|
// Check by nickname for reconnected peers
|
||||||
|
let favoriteByNickname = favorites.values.first {
|
||||||
|
$0.peerNickname == peerInfo.nickname
|
||||||
|
}
|
||||||
|
|
||||||
|
if let favorite = favoriteByNickname,
|
||||||
|
let noiseKey = peerInfo.noisePublicKey {
|
||||||
|
SecureLogger.log(
|
||||||
|
"🔄 Found favorite for '\(peerInfo.nickname)' by nickname, updating noise key",
|
||||||
|
category: SecureLogger.session,
|
||||||
|
level: .debug
|
||||||
|
)
|
||||||
|
|
||||||
|
// Update the favorite's key in persistence
|
||||||
|
favoritesService.updateNoisePublicKey(
|
||||||
|
from: favorite.peerNoisePublicKey,
|
||||||
|
to: noiseKey,
|
||||||
|
peerNickname: peerInfo.nickname
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get updated favorite
|
||||||
|
peer.favoriteStatus = favoritesService.getFavoriteStatus(for: noiseKey)
|
||||||
|
peer.nostrPublicKey = peer.favoriteStatus?.peerNostrPublicKey ?? favorite.peerNostrPublicKey
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return peer
|
return peer
|
||||||
@@ -211,10 +226,10 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
|
|
||||||
private func buildPeerFromFavorite(
|
private func buildPeerFromFavorite(
|
||||||
favorite: FavoritesPersistenceService.FavoriteRelationship,
|
favorite: FavoritesPersistenceService.FavoriteRelationship,
|
||||||
peerID: PeerID
|
peerID: String
|
||||||
) -> BitchatPeer {
|
) -> BitchatPeer {
|
||||||
var peer = BitchatPeer(
|
var peer = BitchatPeer(
|
||||||
peerID: peerID,
|
id: peerID,
|
||||||
noisePublicKey: favorite.peerNoisePublicKey,
|
noisePublicKey: favorite.peerNoisePublicKey,
|
||||||
nickname: favorite.peerNickname,
|
nickname: favorite.peerNickname,
|
||||||
lastSeen: favorite.lastUpdated,
|
lastSeen: favorite.lastUpdated,
|
||||||
@@ -231,27 +246,32 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
// MARK: - Public Methods
|
// MARK: - Public Methods
|
||||||
|
|
||||||
/// Get peer by ID
|
/// Get peer by ID
|
||||||
func getPeer(by peerID: PeerID) -> BitchatPeer? {
|
func getPeer(by id: String) -> BitchatPeer? {
|
||||||
return peerIndex[peerID]
|
return peerIndex[id]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get peer ID for nickname
|
/// Get peer ID for nickname
|
||||||
func getPeerID(for nickname: String) -> PeerID? {
|
func getPeerID(for nickname: String) -> String? {
|
||||||
for peer in peers {
|
for peer in peers {
|
||||||
if peer.displayName == nickname || peer.nickname == nickname {
|
if peer.displayName == nickname || peer.nickname == nickname {
|
||||||
return peer.peerID
|
return peer.id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if peer is online
|
||||||
|
func isOnline(_ peerID: String) -> Bool {
|
||||||
|
return connectedPeerIDs.contains(peerID)
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if peer is blocked
|
/// Check if peer is blocked
|
||||||
func isBlocked(_ peerID: PeerID) -> Bool {
|
func isBlocked(_ peerID: String) -> Bool {
|
||||||
// Get fingerprint
|
// Get fingerprint
|
||||||
guard let fingerprint = getFingerprint(for: peerID) else { return false }
|
guard let fingerprint = getFingerprint(for: peerID) else { return false }
|
||||||
|
|
||||||
// Check SecureIdentityStateManager for block status
|
// Check SecureIdentityStateManager for block status
|
||||||
if let identity = identityManager.getSocialIdentity(for: fingerprint) {
|
if let identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint) {
|
||||||
return identity.isBlocked
|
return identity.isBlocked
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,9 +279,10 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle favorite status
|
/// Toggle favorite status
|
||||||
func toggleFavorite(_ peerID: PeerID) {
|
func toggleFavorite(_ peerID: String) {
|
||||||
guard let peer = getPeer(by: peerID) else {
|
guard let peer = getPeer(by: peerID) else {
|
||||||
SecureLogger.warning("⚠️ Cannot toggle favorite - peer not found: \(peerID)", category: .session)
|
SecureLogger.log("⚠️ Cannot toggle favorite - peer not found: \(peerID)",
|
||||||
|
category: SecureLogger.session, level: .warning)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,13 +292,15 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
var actualNickname = peer.nickname
|
var actualNickname = peer.nickname
|
||||||
|
|
||||||
// Debug logging to understand the issue
|
// Debug logging to understand the issue
|
||||||
SecureLogger.debug("🔍 Toggle favorite - peer.nickname: '\(peer.nickname)', peer.displayName: '\(peer.displayName)', peerID: \(peerID)", category: .session)
|
SecureLogger.log("🔍 Toggle favorite - peer.nickname: '\(peer.nickname)', peer.displayName: '\(peer.displayName)', peerID: \(peerID)",
|
||||||
|
category: SecureLogger.session, level: .debug)
|
||||||
|
|
||||||
if actualNickname.isEmpty {
|
if actualNickname.isEmpty {
|
||||||
// Try to get from mesh service's current peer list
|
// Try to get from mesh service's current peer list
|
||||||
if let meshPeerNickname = meshService.peerNickname(peerID: peerID) {
|
if let meshPeerNickname = meshService.peerNickname(peerID: peerID) {
|
||||||
actualNickname = meshPeerNickname
|
actualNickname = meshPeerNickname
|
||||||
SecureLogger.debug("🔍 Got nickname from mesh service: '\(actualNickname)'", category: .session)
|
SecureLogger.log("🔍 Got nickname from mesh service: '\(actualNickname)'",
|
||||||
|
category: SecureLogger.session, level: .debug)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,7 +315,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
var peerNostrKey = peer.nostrPublicKey
|
var peerNostrKey = peer.nostrPublicKey
|
||||||
if peerNostrKey == nil {
|
if peerNostrKey == nil {
|
||||||
// Try to get from NostrIdentityBridge association
|
// Try to get from NostrIdentityBridge association
|
||||||
peerNostrKey = idBridge.getNostrPublicKey(for: peer.noisePublicKey)
|
peerNostrKey = NostrIdentityBridge.getNostrPublicKey(for: peer.noisePublicKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add favorite
|
// Add favorite
|
||||||
@@ -304,15 +327,11 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Log the final nickname being saved
|
// Log the final nickname being saved
|
||||||
SecureLogger.debug("⭐️ Toggled favorite for '\(finalNickname)' (peerID: \(peerID), was: \(wasFavorite), now: \(!wasFavorite))", category: .session)
|
SecureLogger.log("⭐️ Toggled favorite for '\(finalNickname)' (peerID: \(peerID), was: \(wasFavorite), now: \(!wasFavorite))",
|
||||||
|
category: SecureLogger.session, level: .debug)
|
||||||
|
|
||||||
// Send favorite notification to the peer via router (mesh or Nostr)
|
// Send favorite notification to the peer
|
||||||
if let router = messageRouter {
|
meshService.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
|
||||||
router.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
|
|
||||||
} else {
|
|
||||||
// Fallback to mesh-only if router not yet wired
|
|
||||||
meshService.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Force update of peers to reflect the change
|
// Force update of peers to reflect the change
|
||||||
updatePeers()
|
updatePeers()
|
||||||
@@ -323,7 +342,39 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getFingerprint(for peerID: PeerID) -> String? {
|
/// Toggle blocked status
|
||||||
|
func toggleBlocked(_ peerID: String) {
|
||||||
|
guard let fingerprint = getFingerprint(for: peerID) else { return }
|
||||||
|
|
||||||
|
// Get or create social identity
|
||||||
|
var identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint)
|
||||||
|
?? SocialIdentity(
|
||||||
|
fingerprint: fingerprint,
|
||||||
|
localPetname: nil,
|
||||||
|
claimedNickname: getPeer(by: peerID)?.displayName ?? "Unknown",
|
||||||
|
trustLevel: .unknown,
|
||||||
|
isFavorite: false,
|
||||||
|
isBlocked: false,
|
||||||
|
notes: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
// Toggle blocked status
|
||||||
|
identity.isBlocked = !identity.isBlocked
|
||||||
|
|
||||||
|
// Can't be both favorite and blocked
|
||||||
|
if identity.isBlocked {
|
||||||
|
identity.isFavorite = false
|
||||||
|
// Also remove from favorites service
|
||||||
|
if let peer = getPeer(by: peerID) {
|
||||||
|
favoritesService.removeFavorite(peerNoisePublicKey: peer.noisePublicKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SecureIdentityStateManager.shared.updateSocialIdentity(identity)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get fingerprint for peer ID
|
||||||
|
func getFingerprint(for peerID: String) -> String? {
|
||||||
// Check cache first
|
// Check cache first
|
||||||
if let cached = fingerprintCache[peerID] {
|
if let cached = fingerprintCache[peerID] {
|
||||||
return cached
|
return cached
|
||||||
@@ -348,13 +399,17 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
// MARK: - Compatibility Methods (for easy migration)
|
// MARK: - Compatibility Methods (for easy migration)
|
||||||
|
|
||||||
var allPeers: [BitchatPeer] { peers }
|
var allPeers: [BitchatPeer] { peers }
|
||||||
var connectedPeers: Set<PeerID> { connectedPeerIDs }
|
var connectedPeers: [String] { Array(connectedPeerIDs) }
|
||||||
var favoritePeers: Set<String> {
|
var favoritePeers: Set<String> {
|
||||||
Set(favorites.compactMap { getFingerprint(for: $0.peerID) })
|
Set(favorites.compactMap { getFingerprint(for: $0.id) })
|
||||||
}
|
}
|
||||||
var blockedUsers: Set<String> {
|
var blockedUsers: Set<String> {
|
||||||
Set(peers.compactMap { peer in
|
Set(peers.compactMap { peer in
|
||||||
isBlocked(peer.peerID) ? getFingerprint(for: peer.peerID) : nil
|
isBlocked(peer.id) ? getFingerprint(for: peer.id) : nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Helper Extensions
|
||||||
|
|
||||||
|
// Moved sha256Fingerprint() to BinaryEncodingUtils for reuse
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
/// QR verification scaffolding: schema, signing, and basic challenge/response helpers.
|
/// QR verification scaffolding: schema, signing, and basic challenge/response helpers.
|
||||||
final class VerificationService {
|
final class VerificationService {
|
||||||
@@ -94,7 +95,7 @@ final class VerificationService {
|
|||||||
nickname: payload.nickname,
|
nickname: payload.nickname,
|
||||||
ts: payload.ts,
|
ts: payload.ts,
|
||||||
nonceB64: payload.nonceB64,
|
nonceB64: payload.nonceB64,
|
||||||
sigHex: sig.hexEncodedString())
|
sigHex: sig.map { String(format: "%02x", $0) }.joined())
|
||||||
let out = signed.toURLString()
|
let out = signed.toURLString()
|
||||||
Cache.last = (nickname, npub, Date(), out)
|
Cache.last = (nickname, npub, Date(), out)
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -1,237 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import CryptoKit
|
|
||||||
|
|
||||||
// Golomb-Coded Set (GCS) filter utilities for sync.
|
|
||||||
// Hashing:
|
|
||||||
// - Packet ID is 16 bytes (see PacketIdUtil). For GCS mapping, use h64 = first 8 bytes of SHA-256 over the 16-byte ID.
|
|
||||||
// - Map to [1, M) by computing (h64 % M) and remapping 0 -> 1 to avoid zero-length deltas.
|
|
||||||
// Encoding (v1):
|
|
||||||
// - Sort mapped values ascending; encode deltas (first is v0, then vi - v{i-1}) as positive integers x >= 1.
|
|
||||||
// - Golomb-Rice with parameter P: q = (x - 1) >> P encoded as unary (q ones then a zero), then write P-bit remainder r = (x - 1) & ((1<<P)-1).
|
|
||||||
// - Bitstream is MSB-first within each byte.
|
|
||||||
enum GCSFilter {
|
|
||||||
struct Params { let p: Int; let m: UInt32; let data: Data }
|
|
||||||
|
|
||||||
// Derive P from FPR (~ 1 / 2^P)
|
|
||||||
static func deriveP(targetFpr: Double) -> Int {
|
|
||||||
let f = max(0.000001, min(0.25, targetFpr))
|
|
||||||
// ceil(log2(1/f))
|
|
||||||
let p = Int(ceil(log2(1.0 / f)))
|
|
||||||
return max(1, p)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Estimate max elements that fit in size bytes: bits per element ~= P + 2 (approx)
|
|
||||||
static func estimateMaxElements(sizeBytes: Int, p: Int) -> Int {
|
|
||||||
let bits = max(8, sizeBytes * 8)
|
|
||||||
let per = max(3, p + 2)
|
|
||||||
return max(1, bits / per)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func buildFilter(ids: [Data], maxBytes: Int, targetFpr: Double) -> Params {
|
|
||||||
let p = deriveP(targetFpr: targetFpr)
|
|
||||||
guard !ids.isEmpty else {
|
|
||||||
return Params(p: p, m: 1, data: Data())
|
|
||||||
}
|
|
||||||
|
|
||||||
let cap = estimateMaxElements(sizeBytes: maxBytes, p: p)
|
|
||||||
let selected = Array(ids.prefix(cap))
|
|
||||||
let range = max(1, hashRange(count: selected.count, p: p))
|
|
||||||
let modulo = UInt64(range)
|
|
||||||
|
|
||||||
var mapped = selected
|
|
||||||
.map { h64($0) }
|
|
||||||
.map { mapHash($0, modulo: modulo) }
|
|
||||||
.sorted()
|
|
||||||
mapped = normalizeMappedValues(mapped, modulo: modulo)
|
|
||||||
|
|
||||||
if mapped.isEmpty {
|
|
||||||
return Params(p: p, m: range, data: Data())
|
|
||||||
}
|
|
||||||
|
|
||||||
var encoded = encode(sorted: mapped, p: p)
|
|
||||||
var trimmedCount = mapped.count
|
|
||||||
|
|
||||||
while encoded.count > maxBytes && trimmedCount > 0 {
|
|
||||||
if trimmedCount == 1 {
|
|
||||||
mapped.removeAll()
|
|
||||||
encoded = Data()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
trimmedCount = max(1, (trimmedCount * 9) / 10)
|
|
||||||
mapped = Array(mapped.prefix(trimmedCount))
|
|
||||||
encoded = encode(sorted: mapped, p: p)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Params(p: p, m: range, data: encoded)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func decodeToSortedSet(p: Int, m: UInt32, data: Data) -> [UInt64] {
|
|
||||||
var values: [UInt64] = []
|
|
||||||
let reader = BitReader(data)
|
|
||||||
var acc: UInt64 = 0
|
|
||||||
while true {
|
|
||||||
guard let q = reader.readUnary() else { break }
|
|
||||||
guard let r = reader.readBits(count: p) else { break }
|
|
||||||
let x = (UInt64(q) << UInt64(p)) + UInt64(r) + 1
|
|
||||||
acc &+= x
|
|
||||||
if acc >= UInt64(m) { break }
|
|
||||||
values.append(acc)
|
|
||||||
}
|
|
||||||
return values
|
|
||||||
}
|
|
||||||
|
|
||||||
static func contains(sortedValues: [UInt64], candidate: UInt64) -> Bool {
|
|
||||||
var lo = 0
|
|
||||||
var hi = sortedValues.count - 1
|
|
||||||
while lo <= hi {
|
|
||||||
let mid = (lo + hi) >> 1
|
|
||||||
let v = sortedValues[mid]
|
|
||||||
if v == candidate { return true }
|
|
||||||
if v < candidate { lo = mid + 1 } else { hi = mid - 1 }
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
static func bucket(for id: Data, modulus m: UInt32) -> UInt64 {
|
|
||||||
let modulo = UInt64(max(1, m))
|
|
||||||
guard modulo > 1 else { return 0 }
|
|
||||||
return mapHash(h64(id), modulo: modulo)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func h64(_ id16: Data) -> UInt64 {
|
|
||||||
var hasher = SHA256()
|
|
||||||
hasher.update(data: id16)
|
|
||||||
let d = hasher.finalize()
|
|
||||||
let db = Data(d)
|
|
||||||
var x: UInt64 = 0
|
|
||||||
let take = min(8, db.count)
|
|
||||||
for i in 0..<take { x = (x << 8) | UInt64(db[i]) }
|
|
||||||
return x & 0x7fff_ffff_ffff_ffff
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func hashRange(count: Int, p: Int) -> UInt32 {
|
|
||||||
guard count > 0 else { return 1 }
|
|
||||||
if p >= 64 { return UInt32.max }
|
|
||||||
let multiplier = UInt64(1) << UInt64(p)
|
|
||||||
let (product, overflow) = UInt64(count).multipliedReportingOverflow(by: multiplier)
|
|
||||||
if overflow { return UInt32.max }
|
|
||||||
if product == 0 { return 1 }
|
|
||||||
return product > UInt64(UInt32.max) ? UInt32.max : UInt32(product)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func mapHash(_ hash: UInt64, modulo: UInt64) -> UInt64 {
|
|
||||||
guard modulo > 1 else { return 0 }
|
|
||||||
let value = hash % modulo
|
|
||||||
if value == 0 { return 1 }
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func normalizeMappedValues(_ values: [UInt64], modulo: UInt64) -> [UInt64] {
|
|
||||||
guard modulo > 1 else { return [] }
|
|
||||||
guard !values.isEmpty else { return [] }
|
|
||||||
var result: [UInt64] = []
|
|
||||||
result.reserveCapacity(values.count)
|
|
||||||
var last: UInt64 = 0
|
|
||||||
for value in values {
|
|
||||||
let normalized = min(value, modulo - 1)
|
|
||||||
if normalized > last {
|
|
||||||
result.append(normalized)
|
|
||||||
last = normalized
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func encode(sorted: [UInt64], p: Int) -> Data {
|
|
||||||
let writer = BitWriter()
|
|
||||||
var prev: UInt64 = 0
|
|
||||||
let mask: UInt64 = (p >= 64) ? ~0 : ((1 << UInt64(p)) - 1)
|
|
||||||
for v in sorted {
|
|
||||||
let delta = v &- prev
|
|
||||||
prev = v
|
|
||||||
let x = delta
|
|
||||||
let q = (x &- 1) >> UInt64(p)
|
|
||||||
let r = (x &- 1) & mask
|
|
||||||
// unary q ones then zero
|
|
||||||
if q > 0 { writer.writeOnes(count: Int(q)) }
|
|
||||||
writer.writeBit(0)
|
|
||||||
writer.writeBits(value: r, count: p)
|
|
||||||
}
|
|
||||||
return writer.toData()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Bit helpers (MSB-first)
|
|
||||||
private final class BitWriter {
|
|
||||||
private var buf = Data()
|
|
||||||
private var cur: UInt8 = 0
|
|
||||||
private var nbits: Int = 0
|
|
||||||
func writeBit(_ bit: Int) { // 0 or 1
|
|
||||||
cur = UInt8((Int(cur) << 1) | (bit & 1))
|
|
||||||
nbits += 1
|
|
||||||
if nbits == 8 {
|
|
||||||
buf.append(cur)
|
|
||||||
cur = 0; nbits = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
func writeOnes(count: Int) {
|
|
||||||
guard count > 0 else { return }
|
|
||||||
for _ in 0..<count { writeBit(1) }
|
|
||||||
}
|
|
||||||
func writeBits(value: UInt64, count: Int) {
|
|
||||||
guard count > 0 else { return }
|
|
||||||
for i in stride(from: count - 1, through: 0, by: -1) {
|
|
||||||
let bit = Int((value >> UInt64(i)) & 1)
|
|
||||||
writeBit(bit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
func toData() -> Data {
|
|
||||||
if nbits > 0 {
|
|
||||||
let rem = UInt8(Int(cur) << (8 - nbits))
|
|
||||||
buf.append(rem)
|
|
||||||
cur = 0; nbits = 0
|
|
||||||
}
|
|
||||||
return buf
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private final class BitReader {
|
|
||||||
private let data: Data
|
|
||||||
private var idx: Int = 0
|
|
||||||
private var cur: UInt8 = 0
|
|
||||||
private var left: Int = 0
|
|
||||||
init(_ data: Data) {
|
|
||||||
self.data = data
|
|
||||||
if !data.isEmpty {
|
|
||||||
cur = data[0]
|
|
||||||
left = 8
|
|
||||||
}
|
|
||||||
}
|
|
||||||
func readBit() -> Int? {
|
|
||||||
if idx >= data.count { return nil }
|
|
||||||
let bit = (Int(cur) >> 7) & 1
|
|
||||||
cur = UInt8((Int(cur) << 1) & 0xFF)
|
|
||||||
left -= 1
|
|
||||||
if left == 0 {
|
|
||||||
idx += 1
|
|
||||||
if idx < data.count { cur = data[idx]; left = 8 }
|
|
||||||
}
|
|
||||||
return bit
|
|
||||||
}
|
|
||||||
func readUnary() -> Int? {
|
|
||||||
var q = 0
|
|
||||||
while true {
|
|
||||||
guard let b = readBit() else { return nil }
|
|
||||||
if b == 1 { q += 1 } else { break }
|
|
||||||
}
|
|
||||||
return q
|
|
||||||
}
|
|
||||||
func readBits(count: Int) -> UInt64? {
|
|
||||||
var v: UInt64 = 0
|
|
||||||
for _ in 0..<count {
|
|
||||||
guard let b = readBit() else { return nil }
|
|
||||||
v = (v << 1) | UInt64(b)
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||