Compare commits

..
2428 changed files with 10002 additions and 405955 deletions
@@ -1,12 +0,0 @@
---
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/Swift.swiftmodule/arm64e-apple-macos.swiftmodule'
dependencies:
- mtime: 1757258659000000000
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/Swift.swiftmodule/arm64e-apple-macos.swiftmodule'
size: 14166264
- mtime: 1754189697000000000
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
size: 2261306
sdk_relative: true
version: 1
...
@@ -1,16 +0,0 @@
---
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/SwiftOnoneSupport.swiftmodule/arm64e-apple-macos.swiftmodule'
dependencies:
- mtime: 1757258662000000000
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/SwiftOnoneSupport.swiftmodule/arm64e-apple-macos.swiftmodule'
size: 18068
- mtime: 1754189697000000000
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
size: 2261306
sdk_relative: true
- mtime: 1754191141000000000
path: 'usr/lib/swift/SwiftOnoneSupport.swiftmodule/arm64e-apple-macos.swiftinterface'
size: 1224
sdk_relative: true
version: 1
...
@@ -1,16 +0,0 @@
---
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_Concurrency.swiftmodule/arm64e-apple-macos.swiftmodule'
dependencies:
- mtime: 1757258669000000000
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_Concurrency.swiftmodule/arm64e-apple-macos.swiftmodule'
size: 699544
- mtime: 1754189697000000000
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
size: 2261306
sdk_relative: true
- mtime: 1754192470000000000
path: 'usr/lib/swift/_Concurrency.swiftmodule/arm64e-apple-macos.swiftinterface'
size: 364219
sdk_relative: true
version: 1
...
@@ -1,16 +0,0 @@
---
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_StringProcessing.swiftmodule/arm64e-apple-macos.swiftmodule'
dependencies:
- mtime: 1757258664000000000
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_StringProcessing.swiftmodule/arm64e-apple-macos.swiftmodule'
size: 83568
- mtime: 1754189697000000000
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
size: 2261306
sdk_relative: true
- mtime: 1754192532000000000
path: 'usr/lib/swift/_StringProcessing.swiftmodule/arm64e-apple-macos.swiftinterface'
size: 24507
sdk_relative: true
version: 1
...
-20
View File
@@ -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
-40
View File
@@ -1,40 +0,0 @@
name: Fetch GeoRelays Data
on:
schedule:
- cron: '0 6 * * 0'
workflow_dispatch:
permissions:
contents: write
jobs:
update-relay-data:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Fetch GeoRelays
run: |
wget https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
mv nostr_relays.csv ./relays/online_relays_gps.csv
- name: Check for changes
id: git-check
run: |
git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT
- name: Commit and push changes
if: steps.git-check.outputs.changes == 'true'
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add relays/online_relays_gps.csv
git commit -m "Automated update of relay data - $(date -u)"
git push
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-27
View File
@@ -1,27 +0,0 @@
name: Build & Test
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
test:
name: Run Swift Tests
runs-on: macos-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Set up Swift
uses: swift-actions/setup-swift@v2
- name: Build the package
run: swift build
- name: Run Tests
run: swift test --parallel
+4 -5
View File
@@ -9,6 +9,9 @@ plans/
CLAUDE.md CLAUDE.md
AGENTS.md AGENTS.md
## 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
*.xccheckout *.xccheckout
@@ -54,8 +57,7 @@ iOSInjectionProject/
## Xcode project ## Xcode project
*.xcodeproj/project.xcworkspace/ *.xcodeproj/project.xcworkspace/
## Xcode User settings *.xcodeproj/xcshareddata/
xcuserdata/
## Python ## Python
__pycache__/ __pycache__/
@@ -73,6 +75,3 @@ TestResult.xcresult/
*.xcresult/ *.xcresult/
build.log build.log
*.log *.log
# Local configs
Local.xcconfig
+3 -3
View File
@@ -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)
} }
-4
View File
@@ -1,4 +0,0 @@
#include "Release.xcconfig"
// Optional include of local configs
#include? "Local.xcconfig"
-5
View File
@@ -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)
-11
View File
@@ -1,11 +0,0 @@
MARKETING_VERSION = 1.4.4
CURRENT_PROJECT_VERSION = 1
IPHONEOS_DEPLOYMENT_TARGET = 16.0
MACOSX_DEPLOYMENT_TARGET = 13.0
SWIFT_VERSION = 5.0
DEVELOPMENT_TEAM = L3N5LHJD5Y
CODE_SIGN_STYLE = Automatic
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat
+16 -4
View File
@@ -14,6 +14,7 @@ default:
# Check prerequisites # Check prerequisites
check: check:
@echo "Checking prerequisites..." @echo "Checking prerequisites..."
@command -v xcodegen >/dev/null 2>&1 || (echo "❌ XcodeGen not found. Install with: brew install xcodegen" && exit 1)
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ Xcode not found. Install Xcode from App Store" && exit 1) @command -v xcodebuild >/dev/null 2>&1 || (echo "❌ Xcode not found. Install Xcode from App Store" && exit 1)
@security find-identity -v -p codesigning | grep -q "Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0) @security find-identity -v -p codesigning | grep -q "Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
@echo "✅ All prerequisites met" @echo "✅ All prerequisites met"
@@ -21,6 +22,8 @@ check:
# Backup original files # Backup original files
backup: backup:
@echo "Backing up original project configuration..." @echo "Backing up original project configuration..."
@cp project.yml project.yml.backup 2>/dev/null || true
@# Backup other files that get modified by xcodegen
@if [ -f bitchat.xcodeproj/project.pbxproj ]; then cp bitchat.xcodeproj/project.pbxproj bitchat.xcodeproj/project.pbxproj.backup; fi @if [ -f bitchat.xcodeproj/project.pbxproj ]; then cp bitchat.xcodeproj/project.pbxproj bitchat.xcodeproj/project.pbxproj.backup; fi
@if [ -f bitchat/Info.plist ]; then cp bitchat/Info.plist bitchat/Info.plist.backup; fi @if [ -f bitchat/Info.plist ]; then cp bitchat/Info.plist bitchat/Info.plist.backup; fi
@@ -41,10 +44,15 @@ patch-for-macos: backup
@# Move iOS-specific files out of the way temporarily @# Move iOS-specific files out of the way temporarily
@if [ -f bitchat/LaunchScreen.storyboard ]; then mv bitchat/LaunchScreen.storyboard bitchat/LaunchScreen.storyboard.ios; fi @if [ -f bitchat/LaunchScreen.storyboard ]; then mv bitchat/LaunchScreen.storyboard bitchat/LaunchScreen.storyboard.ios; fi
# Generate Xcode project with patches
generate: patch-for-macos
@echo "Generating Xcode project..."
@xcodegen generate
# Build the macOS app # Build the macOS app
build: #check generate build: check generate
@echo "Building BitChat for macOS..." @echo "Building BitChat for macOS..."
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build @xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
# Run the macOS app # Run the macOS app
run: build run: build
@@ -67,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
@@ -96,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"
+3 -23
View File
@@ -4,9 +4,8 @@ import PackageDescription
let package = Package( let package = Package(
name: "bitchat", name: "bitchat",
defaultLocalization: "en",
platforms: [ platforms: [
.iOS(.v17), .iOS(.v16),
.macOS(.v13) .macOS(.v13)
], ],
products: [ products: [
@@ -16,17 +15,13 @@ let package = Package(
), ),
], ],
dependencies:[ dependencies:[
.package(path: "localPackages/Tor"), .package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1"),
.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: "BitLogger", package: "BitLogger"),
.product(name: "Tor", package: "Tor")
], ],
path: "bitchat", path: "bitchat",
exclude: [ exclude: [
@@ -35,22 +30,7 @@ let package = Package(
"bitchat.entitlements", "bitchat.entitlements",
"bitchat-macOS.entitlements", "bitchat-macOS.entitlements",
"LaunchScreen.storyboard" "LaunchScreen.storyboard"
],
resources: [
.process("Localizable.xcstrings")
] ]
), ),
.testTarget(
name: "bitchatTests",
dependencies: ["bitchat"],
path: "bitchatTests",
exclude: [
"Info.plist",
"README.md"
],
resources: [
.process("Localization")
]
)
] ]
) )
+31 -18
View File
@@ -9,7 +9,7 @@ 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] > [!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](https://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns. > Private messages have not received external security review and may contain vulnerabilities. Do not use for sensitive use cases, and do not rely on its security until it has been reviewed. Now uses the [Noise Protocol](http://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
## License ## License
@@ -22,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
@@ -94,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.
-41
View File
@@ -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
File diff suppressed because it is too large Load Diff
@@ -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 = "-DBITCHAT_DEV_ALLOW_CLEARNET"
value = ""
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
BuildableName = "bitchat.app"
BlueprintName = "bitchat_iOS"
ReferencedContainer = "container:bitchat.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -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"
}, },
{ {
Binary file not shown.

After

Width:  |  Height:  |  Size: 378 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 564 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 668 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 641 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 765 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 765 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 628 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 930 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 976 B

@@ -1,38 +0,0 @@
{
"images" : [
{
"filename" : "image-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "image-1024 1.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"filename" : "image-1024 2.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

+1 -1
View File
@@ -3,4 +3,4 @@
"author" : "xcode", "author" : "xcode",
"version" : 1 "version" : 1
} }
} }
+6 -54
View File
@@ -6,35 +6,20 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import Tor
import SwiftUI import SwiftUI
import UserNotifications import UserNotifications
@main @main
struct BitchatApp: App { struct BitchatApp: App {
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
init() { init() {
let keychain = KeychainManager()
_chatViewModel = StateObject(
wrappedValue: ChatViewModel(
keychain: keychain,
identityManager: SecureIdentityStateManager(keychain)
)
)
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
// Warm up georelay directory and refresh if stale (once/day) // Warm up georelay directory and refresh if stale (once/day)
GeoRelayDirectory.shared.prefetchIfNeeded() GeoRelayDirectory.shared.prefetchIfNeeded()
@@ -58,8 +43,6 @@ struct BitchatApp: App {
#elseif os(macOS) #elseif os(macOS)
appDelegate.chatViewModel = chatViewModel appDelegate.chatViewModel = chatViewModel
#endif #endif
// Initialize network activation policy; will start Tor/Nostr only when allowed
NetworkActivationService.shared.start()
// Check for shared content // Check for shared content
checkForSharedContent() checkForSharedContent()
} }
@@ -71,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
@@ -138,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
} }
@@ -179,7 +131,7 @@ 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 {
@@ -191,7 +143,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
#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) {
@@ -204,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?
+18 -1
View File
@@ -87,7 +87,7 @@ import Foundation
/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy. /// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy.
/// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships. /// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships.
struct EphemeralIdentity { struct EphemeralIdentity {
let peerID: PeerID // 8 random bytes let peerID: String // 8 random bytes
let sessionStart: Date let sessionStart: Date
var handshakeState: HandshakeState var handshakeState: HandshakeState
} }
@@ -158,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
+110 -69
View File
@@ -90,63 +90,27 @@
/// - Advanced conflict resolution /// - Advanced conflict resolution
/// ///
import BitLogger
import Foundation import Foundation
import CryptoKit import CryptoKit
protocol SecureIdentityStateManagerProtocol {
// MARK: Secure Loading/Saving
func forceSave()
// MARK: Social Identity Management
func getSocialIdentity(for fingerprint: String) -> SocialIdentity?
// MARK: Cryptographic Identities
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?)
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity]
func updateSocialIdentity(_ identity: SocialIdentity)
// MARK: Favorites Management
func getFavorites() -> Set<String>
func setFavorite(_ fingerprint: String, isFavorite: Bool)
func isFavorite(fingerprint: String) -> Bool
// MARK: Blocked Users Management
func isBlocked(fingerprint: String) -> Bool
func setBlocked(_ fingerprint: String, isBlocked: Bool)
// MARK: Geohash (Nostr) Blocking
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool)
func getBlockedNostrPubkeys() -> Set<String>
// MARK: Ephemeral Session Management
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState)
func updateHandshakeState(peerID: PeerID, state: HandshakeState)
// MARK: Cleanup
func clearAllIdentityData()
func removeEphemeralSession(peerID: PeerID)
// MARK: Verification
func setVerified(fingerprint: String, verified: Bool)
func isVerified(fingerprint: String) -> Bool
func getVerifiedFingerprints() -> Set<String>
}
/// Singleton manager for secure identity state persistence and retrieval. /// Singleton manager for secure identity state persistence and retrieval.
/// Provides thread-safe access to identity mappings with encryption at rest. /// Provides thread-safe access to identity mappings with encryption at rest.
/// All identity data is stored encrypted in the device Keychain for security. /// All identity data is stored encrypted in the device Keychain for security.
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)
@@ -158,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 {
@@ -175,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
@@ -184,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
@@ -202,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
@@ -228,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
@@ -320,12 +301,23 @@ 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 peerID.count == 16, peerID.allSatisfy({ $0.isHexDigit }) 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)
} }
} }
@@ -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 {
+2 -2
View File
@@ -35,10 +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>NSCameraUsageDescription</key>
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>bluetooth-central</string> <string>bluetooth-central</string>
File diff suppressed because it is too large Load Diff
-369
View File
@@ -1,369 +0,0 @@
//
// BitchatMessage.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
/// Represents a user-visible message in the BitChat system.
/// Handles both broadcast messages and private encrypted messages,
/// with support for mentions, replies, and delivery tracking.
/// - Note: This is the primary data model for chat messages
final class BitchatMessage: Codable {
let id: String
let sender: String
let content: String
let timestamp: Date
let isRelay: Bool
let originalSender: String?
let isPrivate: Bool
let recipientNickname: String?
let senderPeerID: PeerID?
let mentions: [String]? // Array of mentioned nicknames
var deliveryStatus: DeliveryStatus? // Delivery tracking
// Cached formatted text (not included in Codable)
private var _cachedFormattedText: [String: AttributedString] = [:]
func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? {
return _cachedFormattedText["\(isDark)-\(isSelf)"]
}
func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) {
_cachedFormattedText["\(isDark)-\(isSelf)"] = text
}
// Codable implementation
enum CodingKeys: String, CodingKey {
case id, sender, content, timestamp, isRelay, originalSender
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
}
init(
id: String? = nil,
sender: String,
content: String,
timestamp: Date,
isRelay: Bool,
originalSender: String? = nil,
isPrivate: Bool = false,
recipientNickname: String? = nil,
senderPeerID: PeerID? = nil,
mentions: [String]? = nil,
deliveryStatus: DeliveryStatus? = nil
) {
self.id = id ?? UUID().uuidString
self.sender = sender
self.content = content
self.timestamp = timestamp
self.isRelay = isRelay
self.originalSender = originalSender
self.isPrivate = isPrivate
self.recipientNickname = recipientNickname
self.senderPeerID = senderPeerID
self.mentions = mentions
self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil)
}
}
// MARK: - Equatable Conformance
extension BitchatMessage: Equatable {
static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool {
return lhs.id == rhs.id &&
lhs.sender == rhs.sender &&
lhs.content == rhs.content &&
lhs.timestamp == rhs.timestamp &&
lhs.isRelay == rhs.isRelay &&
lhs.originalSender == rhs.originalSender &&
lhs.isPrivate == rhs.isPrivate &&
lhs.recipientNickname == rhs.recipientNickname &&
lhs.senderPeerID == rhs.senderPeerID &&
lhs.mentions == rhs.mentions &&
lhs.deliveryStatus == rhs.deliveryStatus
}
}
// MARK: - Binary encoding
extension BitchatMessage {
func toBinaryPayload() -> Data? {
var data = Data()
// Message format:
// - Flags: 1 byte (bit 0: isRelay, bit 1: isPrivate, bit 2: hasOriginalSender, bit 3: hasRecipientNickname, bit 4: hasSenderPeerID, bit 5: hasMentions)
// - Timestamp: 8 bytes (seconds since epoch)
// - ID length: 1 byte
// - ID: variable
// - Sender length: 1 byte
// - Sender: variable
// - Content length: 2 bytes
// - Content: variable
// Optional fields based on flags:
// - Original sender length + data
// - Recipient nickname length + data
// - Sender peer ID length + data
// - Mentions array
var flags: UInt8 = 0
if isRelay { flags |= 0x01 }
if isPrivate { flags |= 0x02 }
if originalSender != nil { flags |= 0x04 }
if recipientNickname != nil { flags |= 0x08 }
if senderPeerID != nil { flags |= 0x10 }
if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }
data.append(flags)
// Timestamp (in milliseconds)
let timestampMillis = UInt64(timestamp.timeIntervalSince1970 * 1000)
// Encode as 8 bytes, big-endian
for i in (0..<8).reversed() {
data.append(UInt8((timestampMillis >> (i * 8)) & 0xFF))
}
// ID
if let idData = id.data(using: .utf8) {
data.append(UInt8(min(idData.count, 255)))
data.append(idData.prefix(255))
} else {
data.append(0)
}
// Sender
if let senderData = sender.data(using: .utf8) {
data.append(UInt8(min(senderData.count, 255)))
data.append(senderData.prefix(255))
} else {
data.append(0)
}
// Content
if let contentData = content.data(using: .utf8) {
let length = UInt16(min(contentData.count, 65535))
// Encode length as 2 bytes, big-endian
data.append(UInt8((length >> 8) & 0xFF))
data.append(UInt8(length & 0xFF))
data.append(contentData.prefix(Int(length)))
} else {
data.append(contentsOf: [0, 0])
}
// Optional fields
if let originalSender = originalSender, let origData = originalSender.data(using: .utf8) {
data.append(UInt8(min(origData.count, 255)))
data.append(origData.prefix(255))
}
if let recipientNickname = recipientNickname, let recipData = recipientNickname.data(using: .utf8) {
data.append(UInt8(min(recipData.count, 255)))
data.append(recipData.prefix(255))
}
if let peerData = senderPeerID?.id.data(using: .utf8) {
data.append(UInt8(min(peerData.count, 255)))
data.append(peerData.prefix(255))
}
// Mentions array
if let mentions = mentions {
data.append(UInt8(min(mentions.count, 255))) // Number of mentions
for mention in mentions.prefix(255) {
if let mentionData = mention.data(using: .utf8) {
data.append(UInt8(min(mentionData.count, 255)))
data.append(mentionData.prefix(255))
} else {
data.append(0)
}
}
}
return data
}
convenience init?(_ data: Data) {
// Create an immutable copy to prevent threading issues
let dataCopy = Data(data)
guard dataCopy.count >= 13 else {
return nil
}
var offset = 0
// Flags
guard offset < dataCopy.count else {
return nil
}
let flags = dataCopy[offset]; offset += 1
let isRelay = (flags & 0x01) != 0
let isPrivate = (flags & 0x02) != 0
let hasOriginalSender = (flags & 0x04) != 0
let hasRecipientNickname = (flags & 0x08) != 0
let hasSenderPeerID = (flags & 0x10) != 0
let hasMentions = (flags & 0x20) != 0
// Timestamp
guard offset + 8 <= dataCopy.count else {
return nil
}
let timestampData = dataCopy[offset..<offset+8]
let timestampMillis = timestampData.reduce(0) { result, byte in
(result << 8) | UInt64(byte)
}
offset += 8
let timestamp = Date(timeIntervalSince1970: TimeInterval(timestampMillis) / 1000.0)
// ID
guard offset < dataCopy.count else {
return nil
}
let idLength = Int(dataCopy[offset]); offset += 1
guard offset + idLength <= dataCopy.count else {
return nil
}
let id = String(data: dataCopy[offset..<offset+idLength], encoding: .utf8) ?? UUID().uuidString
offset += idLength
// Sender
guard offset < dataCopy.count else {
return nil
}
let senderLength = Int(dataCopy[offset]); offset += 1
guard offset + senderLength <= dataCopy.count else {
return nil
}
let sender = String(data: dataCopy[offset..<offset+senderLength], encoding: .utf8) ?? "unknown"
offset += senderLength
// Content
guard offset + 2 <= dataCopy.count else {
return nil
}
let contentLengthData = dataCopy[offset..<offset+2]
let contentLength = Int(contentLengthData.reduce(0) { result, byte in
(result << 8) | UInt16(byte)
})
offset += 2
guard offset + contentLength <= dataCopy.count else {
return nil
}
let content = String(data: dataCopy[offset..<offset+contentLength], encoding: .utf8) ?? ""
offset += contentLength
// Optional fields
var originalSender: String?
if hasOriginalSender && offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
originalSender = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
offset += length
}
}
var recipientNickname: String?
if hasRecipientNickname && offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
recipientNickname = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
offset += length
}
}
var senderPeerID: PeerID?
if hasSenderPeerID && offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
senderPeerID = PeerID(data: dataCopy[offset..<offset+length])
offset += length
}
}
// Mentions array
var mentions: [String]?
if hasMentions && offset < dataCopy.count {
let mentionCount = Int(dataCopy[offset]); offset += 1
if mentionCount > 0 {
mentions = []
for _ in 0..<mentionCount {
if offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
if let mention = String(data: dataCopy[offset..<offset+length], encoding: .utf8) {
mentions?.append(mention)
}
offset += length
}
}
}
}
}
self.init(
id: id,
sender: sender,
content: content,
timestamp: timestamp,
isRelay: isRelay,
originalSender: originalSender,
isPrivate: isPrivate,
recipientNickname: recipientNickname,
senderPeerID: senderPeerID,
mentions: mentions
)
}
}
// MARK: - Helpers
extension BitchatMessage {
private static let timestampFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
return formatter
}()
var formattedTimestamp: String {
Self.timestampFormatter.string(from: timestamp)
}
}
// MARK: - System Message Factory
extension BitchatMessage {
/// Creates a system message with default values
static func system(_ content: String, timestamp: Date = Date()) -> BitchatMessage {
return BitchatMessage(
sender: "system",
content: content,
timestamp: timestamp,
isRelay: false
)
}
}
extension Array where Element == BitchatMessage {
/// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest)
func cleanedAndDeduped() -> [Element] {
let arr = filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false }
guard arr.count > 1 else {
return arr
}
var seen = Set<String>()
var dedup: [BitchatMessage] = []
for m in arr.sorted(by: { $0.timestamp < $1.timestamp }) {
if !seen.contains(m.id) {
dedup.append(m)
seen.insert(m.id)
}
}
return dedup
}
}
-91
View File
@@ -1,91 +0,0 @@
//
// BitchatPacket.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
/// The core packet structure for all BitChat protocol messages.
/// Encapsulates all data needed for routing through the mesh network,
/// including TTL for hop limiting and optional encryption.
/// - Note: Packets larger than BLE MTU (512 bytes) are automatically fragmented
struct BitchatPacket: Codable {
let version: UInt8
let type: UInt8
let senderID: Data
let recipientID: Data?
let timestamp: UInt64
let payload: Data
var signature: Data?
var ttl: UInt8
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) {
self.version = 1
self.type = type
self.senderID = senderID
self.recipientID = recipientID
self.timestamp = timestamp
self.payload = payload
self.signature = signature
self.ttl = ttl
}
// Convenience initializer for new binary format
init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data) {
self.version = 1
self.type = type
// Convert hex string peer ID to binary data (8 bytes)
var senderData = Data()
var tempID = senderID.id
while tempID.count >= 2 {
let hexByte = String(tempID.prefix(2))
if let byte = UInt8(hexByte, radix: 16) {
senderData.append(byte)
}
tempID = String(tempID.dropFirst(2))
}
self.senderID = senderData
self.recipientID = nil
self.timestamp = UInt64(Date().timeIntervalSince1970 * 1000) // milliseconds
self.payload = payload
self.signature = nil
self.ttl = ttl
}
var data: Data? {
BinaryProtocol.encode(self)
}
func toBinaryData(padding: Bool = true) -> Data? {
BinaryProtocol.encode(self, padding: padding)
}
// Backward-compatible helper (defaults to padded encoding)
func toBinaryData() -> Data? {
toBinaryData(padding: true)
}
/// Create binary representation for signing (without signature and TTL fields)
/// TTL is excluded because it changes during packet relay operations
func toBinaryDataForSigning() -> Data? {
// Create a copy without signature and with fixed TTL for signing
// TTL must be excluded because it changes during relay
let unsignedPacket = BitchatPacket(
type: type,
senderID: senderID,
recipientID: recipientID,
timestamp: timestamp,
payload: payload,
signature: nil, // Remove signature for signing
ttl: 0 // Use fixed TTL=0 for signing to ensure relay compatibility
)
return BinaryProtocol.encode(unsignedPacket)
}
static func from(_ data: Data) -> BitchatPacket? {
BinaryProtocol.decode(data)
}
}
+8 -6
View File
@@ -2,8 +2,8 @@ import Foundation
import CoreBluetooth import CoreBluetooth
/// Represents a peer in the BitChat network with all associated metadata /// Represents a peer in the BitChat network with all associated metadata
struct BitchatPeer: 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
@@ -51,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 {
@@ -73,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
@@ -93,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
} }
} }
//
-16
View File
@@ -1,16 +0,0 @@
//
// GeoPerson.swift
// bitchat
//
// Model representing a participant in a geohash channel
// This is free and unencumbered software released into the public domain.
//
import Foundation
/// Represents a person participating in a geohash-based location channel
struct GeoPerson: Identifiable, Equatable {
let id: String // pubkey hex (lowercased)
let displayName: String
let lastSeen: Date
}
-61
View File
@@ -1,61 +0,0 @@
//
// MessagePadding.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
/// Provides privacy-preserving message padding to obscure actual content length.
/// Uses PKCS#7-style padding with random bytes to prevent traffic analysis.
struct MessagePadding {
// Standard block sizes for padding
static let blockSizes = [256, 512, 1024, 2048]
// Add PKCS#7-style padding to reach target size
static func pad(_ data: Data, toSize targetSize: Int) -> Data {
guard data.count < targetSize else { return data }
let paddingNeeded = targetSize - data.count
// Constrain to 255 to fit a single-byte pad length marker
guard paddingNeeded > 0 && paddingNeeded <= 255 else { return data }
var padded = data
// PKCS#7: All pad bytes are equal to the pad length
padded.append(contentsOf: Array(repeating: UInt8(paddingNeeded), count: paddingNeeded))
return padded
}
// Remove padding from data
static func unpad(_ data: Data) -> Data {
guard !data.isEmpty else { return data }
let last = data.last!
let paddingLength = Int(last)
// Must have at least 1 pad byte and not exceed data length
guard paddingLength > 0 && paddingLength <= data.count else { return data }
// Verify PKCS#7: all last N bytes equal to pad length
let start = data.count - paddingLength
let tail = data[start...]
for b in tail { if b != last { return data } }
return Data(data[..<start])
}
// Find optimal block size for data
static func optimalBlockSize(for dataSize: Int) -> Int {
// Account for encryption overhead (~16 bytes for AES-GCM tag)
let totalSize = dataSize + 16
// Find smallest block that fits
for blockSize in blockSizes {
if totalSize <= blockSize {
return blockSize
}
}
// For very large messages, just use the original size
// (will be fragmented anyway)
return dataSize
}
}
-41
View File
@@ -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)
}
}
-212
View File
@@ -1,212 +0,0 @@
//
// PeerID.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
struct PeerID: Equatable, Hashable {
enum Prefix: String, CaseIterable {
/// When no prefix is provided
case empty = ""
/// `"mesh:"`
case mesh = "mesh:"
/// `"name:"`
case name = "name:"
/// `"noise:"` (+ 64 characters hex)
case noise = "noise:"
/// `"nostr_"` (+ 16 characters hex)
case geoDM = "nostr_"
/// `"nostr:"` (+ 8 characters hex)
case geoChat = "nostr:"
}
let prefix: Prefix
/// Returns the actual value without any prefix
let bare: String
/// Returns the full `id` value by combining `(prefix + bare)`
var id: String { prefix.rawValue + bare }
// Private so the callers have to go through a convenience init
private init(prefix: Prefix, bare: any StringProtocol) {
self.prefix = prefix
self.bare = String(bare)
}
}
// MARK: - Convenience Inits
extension PeerID {
/// Convenience init to create GeoDM PeerID by appending `"nostr_"` to the first 16 characters of `pubKey`
init(nostr_ pubKey: String) {
self.init(prefix: .geoDM, bare: pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength))
}
/// Convenience init to create GeoChat PeerID by appending `"nostr:"` to the first 8 characters of `pubKey`
init(nostr pubKey: String) {
self.init(prefix: .geoChat, bare: pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength))
}
/// Convenience init to create PeerID from String/Substring by splitting it into prefix and bare parts
init(str: any StringProtocol) {
if let prefix = Prefix.allCases.first(where: { $0 != .empty && str.hasPrefix($0.rawValue) }) {
self.init(prefix: prefix, bare: String(str).dropFirst(prefix.rawValue.count))
} else {
self.init(prefix: .empty, bare: str)
}
}
/// Convenience init to handle `Optional<String>`
init?(str: (any StringProtocol)?) {
guard let str else { return nil }
self.init(str: str)
}
/// Convenience init to create PeerID by converting Data to String
init?(data: Data) {
self.init(str: String(data: data, encoding: .utf8))
}
/// Convenience init to "hide" hex-encoding implementation detail
init(hexData: Data) {
self.init(str: hexData.hexEncodedString())
}
}
// MARK: - Noise Public Key Helpers
extension PeerID {
/// Derive the stable 16-hex peer ID from a Noise static public key
init(publicKey: Data) {
self.init(str: publicKey.sha256Fingerprint().prefix(16))
}
/// Returns a 16-hex short peer ID derived from a 64-hex Noise public key if needed
func toShort() -> PeerID {
if let noiseKey {
return PeerID(publicKey: noiseKey)
}
return self
}
}
// MARK: - Codable
extension PeerID: Codable {
init(from decoder: any Decoder) throws {
self.init(str: try decoder.singleValueContainer().decode(String.self))
}
func encode(to encoder: any Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(id)
}
}
// MARK: - Helpers
extension PeerID {
var isEmpty: Bool {
id.isEmpty
}
/// Returns true if `id` starts with "`nostr:`"
var isGeoChat: Bool {
prefix == .geoChat
}
/// Returns true if `id` starts with "`nostr_`"
var isGeoDM: Bool {
prefix == .geoDM
}
func toPercentEncoded() -> String {
id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id
}
}
// MARK: - Validation
extension PeerID {
private enum Constants {
static let maxIDLength = 64
static let hexIDLength = 16 // 8 bytes = 16 hex chars
}
/// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
var isValid: Bool {
if prefix != .empty {
return PeerID(str: bare).isValid
}
// Accept short routing IDs (exact 16-hex) or Full Noise key hex (exact 64-hex)
if isShort || isNoiseKeyHex {
return true
}
// If length equals short or full but isn't valid hex, reject
if id.count == Constants.hexIDLength || id.count == Constants.maxIDLength {
return false
}
// Internal format: alphanumeric + dash/underscore up to 63 (not 16 or 64)
let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
return !id.isEmpty &&
id.count < Constants.maxIDLength &&
id.rangeOfCharacter(from: validCharset.inverted) == nil
}
/// Short routing IDs (exact 16-hex)
var isShort: Bool {
bare.count == Constants.hexIDLength && Data(hexString: bare) != nil
}
/// Full Noise key hex (exact 64-hex)
var isNoiseKeyHex: Bool {
noiseKey != nil
}
/// Full Noise key (exact 64-hex) as Data
var noiseKey: Data? {
guard bare.count == Constants.maxIDLength else { return nil }
return Data(hexString: bare)
}
}
// MARK: - Comparable
extension PeerID: Comparable {
static func < (lhs: PeerID, rhs: PeerID) -> Bool {
lhs.id < rhs.id
}
}
// MARK: - String Interop Helpers
// MARK: CustomStringConvertible
extension PeerID: CustomStringConvertible {
/// So it returns the actual `id` like before even inside another String
var description: String {
id
}
}
// MARK: Custom Equatable w/ String & Optionality
// PeerID <> String
extension Optional where Wrapped == PeerID {
static func ==(lhs: Optional<Wrapped>, rhs: Optional<String>) -> Bool { lhs?.id == rhs }
static func !=(lhs: Optional<Wrapped>, rhs: Optional<String>) -> Bool { lhs?.id != rhs }
}
// String <> PeerID
extension Optional where Wrapped == String {
static func ==(lhs: Optional<Wrapped>, rhs: Optional<PeerID>) -> Bool { lhs == rhs?.id }
static func !=(lhs: Optional<Wrapped>, rhs: Optional<PeerID>) -> Bool { lhs != rhs?.id }
}
-95
View File
@@ -1,95 +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
struct ReadReceipt: Codable {
let originalMessageID: String
let receiptID: String
var readerID: String // Who read it
let readerNickname: String
let timestamp: Date
init(originalMessageID: String, readerID: String, readerNickname: String) {
self.originalMessageID = originalMessageID
self.receiptID = UUID().uuidString
self.readerID = readerID
self.readerNickname = readerNickname
self.timestamp = Date()
}
// For binary decoding
private init(originalMessageID: String, receiptID: String, readerID: String, readerNickname: String, timestamp: Date) {
self.originalMessageID = originalMessageID
self.receiptID = receiptID
self.readerID = readerID
self.readerNickname = readerNickname
self.timestamp = timestamp
}
func encode() -> Data? {
try? JSONEncoder().encode(self)
}
static func decode(from data: Data) -> ReadReceipt? {
try? JSONDecoder().decode(ReadReceipt.self, from: data)
}
// MARK: - Binary Encoding
func toBinaryData() -> Data {
var data = Data()
data.appendUUID(originalMessageID)
data.appendUUID(receiptID)
// ReaderID as 8-byte hex string
var readerData = Data()
var tempID = readerID
while tempID.count >= 2 && readerData.count < 8 {
let hexByte = String(tempID.prefix(2))
if let byte = UInt8(hexByte, radix: 16) {
readerData.append(byte)
}
tempID = String(tempID.dropFirst(2))
}
while readerData.count < 8 {
readerData.append(0)
}
data.append(readerData)
data.appendDate(timestamp)
data.appendString(readerNickname)
return data
}
static func fromBinaryData(_ data: Data) -> ReadReceipt? {
// Create defensive copy
let dataCopy = Data(data)
// Minimum size: 2 UUIDs (32) + readerID (8) + timestamp (8) + min nickname
guard dataCopy.count >= 49 else { return nil }
var offset = 0
guard let originalMessageID = dataCopy.readUUID(at: &offset),
let receiptID = dataCopy.readUUID(at: &offset) else { return nil }
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
let readerID = readerIDData.hexEncodedString()
guard PeerID(str: readerID).isValid else { return nil }
guard let timestamp = dataCopy.readDate(at: &offset),
InputValidator.validateTimestamp(timestamp),
let readerNicknameRaw = dataCopy.readString(at: &offset),
let readerNickname = InputValidator.validateNickname(readerNicknameRaw) else { return nil }
return ReadReceipt(originalMessageID: originalMessageID,
receiptID: receiptID,
readerID: readerID,
readerNickname: readerNickname,
timestamp: timestamp)
}
}
-63
View File
@@ -1,63 +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
func encode() -> Data {
var out = Data()
func putTLV(_ t: UInt8, _ v: Data) {
out.append(t)
let len = UInt16(v.count)
out.append(UInt8((len >> 8) & 0xFF))
out.append(UInt8(len & 0xFF))
out.append(v)
}
// P
putTLV(0x01, Data([UInt8(p & 0xFF)]))
// M (uint32)
var mBE = m.bigEndian
putTLV(0x02, withUnsafeBytes(of: &mBE) { Data($0) })
// data
putTLV(0x03, data)
return out
}
static func decode(from data: Data, maxAcceptBytes: Int = 1024) -> RequestSyncPacket? {
var off = 0
var p: Int? = nil
var m: UInt32? = nil
var payload: Data? = nil
while off + 3 <= data.count {
let t = Int(data[off]); off += 1
guard off + 2 <= data.count else { return nil }
let len = (Int(data[off]) << 8) | Int(data[off+1]); off += 2
guard off + len <= data.count else { return nil }
let v = data.subdata(in: off..<(off+len)); off += len
switch t {
case 0x01:
if v.count == 1 { p = Int(v[0]) }
case 0x02:
if v.count == 4 {
var mm: UInt32 = 0
for b in v { mm = (mm << 8) | UInt32(b) }
m = mm
}
case 0x03:
if v.count > maxAcceptBytes { return nil }
payload = v
default:
break // forward compatible; ignore unknown TLVs
}
}
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil }
return RequestSyncPacket(p: pp, m: mm, data: dd)
}
}
@@ -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()
}
}
}
+23 -31
View File
@@ -77,9 +77,9 @@
/// - Noise Specification: http://www.noiseprotocol.org/noise.html /// - Noise Specification: http://www.noiseprotocol.org/noise.html
/// ///
import BitLogger
import Foundation import Foundation
import CryptoKit import CryptoKit
import os.log
// Core Noise Protocol implementation // Core Noise Protocol implementation
// Based on the Noise Protocol Framework specification // Based on the Noise Protocol Framework specification
@@ -127,7 +127,7 @@ struct NoiseProtocolName {
/// Handles ChaCha20-Poly1305 AEAD encryption with automatic nonce management /// Handles ChaCha20-Poly1305 AEAD encryption with automatic nonce management
/// and replay protection using a sliding window algorithm. /// and replay protection using a sliding window algorithm.
/// - Warning: Nonce reuse would be catastrophic for security /// - Warning: Nonce reuse would be catastrophic for security
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
@@ -285,7 +285,7 @@ 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
@@ -307,13 +307,13 @@ 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
} }
@@ -342,7 +342,7 @@ 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 {
@@ -355,9 +355,9 @@ final class NoiseCipherState {
nonce += 1 nonce += 1
return plaintext return plaintext
} catch { } catch {
SecureLogger.debug("Decrypt failed: \(error) for nonce \(decryptionNonce)") SecureLogger.log("Decrypt failed: \(error) for nonce \(decryptionNonce)")
// Log authentication failures with nonce info // 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
} }
} }
@@ -384,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
@@ -397,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
} }
@@ -410,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) {
@@ -488,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
@@ -507,16 +506,9 @@ final class NoiseHandshakeState {
private var messagePatterns: [[NoiseMessagePattern]] = [] private var messagePatterns: [[NoiseMessagePattern]] = []
private var currentPattern = 0 private var currentPattern = 0
init( init(role: NoiseRole, pattern: NoisePattern, localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil, remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil) {
role: NoiseRole,
pattern: NoisePattern,
keychain: KeychainManagerProtocol,
localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil,
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil
) {
self.role = role self.role = role
self.pattern = pattern self.pattern = pattern
self.keychain = keychain
// Initialize static keys // Initialize static keys
if let localKey = localStaticKey { if let localKey = localStaticKey {
@@ -587,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
@@ -635,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)
} }
} }
@@ -669,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)
@@ -686,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
} }
@@ -723,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 {
@@ -733,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:
@@ -746,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 {
@@ -756,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:
@@ -885,7 +877,7 @@ extension NoiseHandshakeState {
// Check against known bad points // Check against known bad points
if lowOrderPoints.contains(keyData) { if lowOrderPoints.contains(keyData) {
SecureLogger.warning("Low-order point detected", category: .security) SecureLogger.log("Low-order point detected", category: SecureLogger.security, level: .warning)
throw NoiseError.invalidPublicKey throw NoiseError.invalidPublicKey
} }
@@ -895,7 +887,7 @@ extension NoiseHandshakeState {
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
} }
} }
+17 -21
View File
@@ -6,8 +6,8 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import BitLogger
import Foundation import Foundation
import CryptoKit
// MARK: - Security Constants // MARK: - Security Constants
@@ -52,11 +52,16 @@ struct NoiseSecurityValidator {
static func validateHandshakeMessageSize(_ data: Data) -> Bool { static func validateHandshakeMessageSize(_ data: Data) -> Bool {
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
} }
/// Validate peer ID format using unified validator
static func validatePeerID(_ peerID: String) -> Bool {
return InputValidator.validatePeerID(peerID)
}
} }
// MARK: - Enhanced Noise Session with Security // MARK: - Enhanced Noise Session with Security
final class SecureNoiseSession: NoiseSession { class SecureNoiseSession: NoiseSession {
private(set) var messageCount: UInt64 = 0 private(set) var messageCount: UInt64 = 0
private let sessionStartTime = Date() private let sessionStartTime = Date()
private(set) var lastActivityTime = Date() private(set) var lastActivityTime = Date()
@@ -130,9 +135,9 @@ final class SecureNoiseSession: NoiseSession {
// MARK: - Rate Limiter // MARK: - Rate Limiter
final class NoiseRateLimiter { class NoiseRateLimiter {
private var handshakeTimestamps: [PeerID: [Date]] = [:] private var handshakeTimestamps: [String: [Date]] = [:] // peerID -> timestamps
private var messageTimestamps: [PeerID: [Date]] = [:] private var messageTimestamps: [String: [Date]] = [:] // peerID -> timestamps
// Global rate limiting // Global rate limiting
private var globalHandshakeTimestamps: [Date] = [] private var globalHandshakeTimestamps: [Date] = []
@@ -140,7 +145,7 @@ final class NoiseRateLimiter {
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent) private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
func allowHandshake(from peerID: PeerID) -> Bool { func allowHandshake(from peerID: String) -> Bool {
return queue.sync(flags: .barrier) { return queue.sync(flags: .barrier) {
let now = Date() let now = Date()
let oneMinuteAgo = now.addingTimeInterval(-60) let oneMinuteAgo = now.addingTimeInterval(-60)
@@ -148,7 +153,7 @@ final class NoiseRateLimiter {
// Check global rate limit first // Check global rate limit first
globalHandshakeTimestamps = globalHandshakeTimestamps.filter { $0 > oneMinuteAgo } globalHandshakeTimestamps = globalHandshakeTimestamps.filter { $0 > oneMinuteAgo }
if globalHandshakeTimestamps.count >= NoiseSecurityConstants.maxGlobalHandshakesPerMinute { if globalHandshakeTimestamps.count >= NoiseSecurityConstants.maxGlobalHandshakesPerMinute {
SecureLogger.warning("Global handshake rate limit exceeded: \(globalHandshakeTimestamps.count)/\(NoiseSecurityConstants.maxGlobalHandshakesPerMinute) per minute", category: .security) SecureLogger.log("Global handshake rate limit exceeded: \(globalHandshakeTimestamps.count)/\(NoiseSecurityConstants.maxGlobalHandshakesPerMinute) per minute", category: SecureLogger.security, level: .warning)
return false return false
} }
@@ -157,7 +162,7 @@ final class NoiseRateLimiter {
timestamps = timestamps.filter { $0 > oneMinuteAgo } timestamps = timestamps.filter { $0 > oneMinuteAgo }
if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute { if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute {
SecureLogger.warning("Per-peer handshake rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: .security) SecureLogger.log("Per-peer handshake rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: SecureLogger.security, level: .warning)
return false return false
} }
@@ -169,7 +174,7 @@ final class NoiseRateLimiter {
} }
} }
func allowMessage(from peerID: PeerID) -> Bool { func allowMessage(from peerID: String) -> Bool {
return queue.sync(flags: .barrier) { return queue.sync(flags: .barrier) {
let now = Date() let now = Date()
let oneSecondAgo = now.addingTimeInterval(-1) let oneSecondAgo = now.addingTimeInterval(-1)
@@ -177,7 +182,7 @@ final class NoiseRateLimiter {
// Check global rate limit first // Check global rate limit first
globalMessageTimestamps = globalMessageTimestamps.filter { $0 > oneSecondAgo } globalMessageTimestamps = globalMessageTimestamps.filter { $0 > oneSecondAgo }
if globalMessageTimestamps.count >= NoiseSecurityConstants.maxGlobalMessagesPerSecond { if globalMessageTimestamps.count >= NoiseSecurityConstants.maxGlobalMessagesPerSecond {
SecureLogger.warning("Global message rate limit exceeded: \(globalMessageTimestamps.count)/\(NoiseSecurityConstants.maxGlobalMessagesPerSecond) per second", category: .security) SecureLogger.log("Global message rate limit exceeded: \(globalMessageTimestamps.count)/\(NoiseSecurityConstants.maxGlobalMessagesPerSecond) per second", category: SecureLogger.security, level: .warning)
return false return false
} }
@@ -186,7 +191,7 @@ final class NoiseRateLimiter {
timestamps = timestamps.filter { $0 > oneSecondAgo } timestamps = timestamps.filter { $0 > oneSecondAgo }
if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond { if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond {
SecureLogger.warning("Per-peer message rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: .security) SecureLogger.log("Per-peer message rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: SecureLogger.security, level: .warning)
return false return false
} }
@@ -198,21 +203,12 @@ final class NoiseRateLimiter {
} }
} }
func reset(for peerID: PeerID) { func reset(for peerID: String) {
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
self.handshakeTimestamps.removeValue(forKey: peerID) self.handshakeTimestamps.removeValue(forKey: peerID)
self.messageTimestamps.removeValue(forKey: peerID) self.messageTimestamps.removeValue(forKey: peerID)
} }
} }
func resetAll() {
queue.async(flags: .barrier) {
self.handshakeTimestamps.removeAll()
self.messageTimestamps.removeAll()
self.globalHandshakeTimestamps.removeAll()
self.globalMessageTimestamps.removeAll()
}
}
} }
// MARK: - Security Errors // MARK: - Security Errors
+266 -24
View File
@@ -6,14 +6,37 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import BitLogger
import Foundation import Foundation
import CryptoKit import CryptoKit
import os.log
// MARK: - Noise Session State
enum NoiseSessionState: Equatable {
case uninitialized
case handshaking
case established
case failed(Error)
static func == (lhs: NoiseSessionState, rhs: NoiseSessionState) -> Bool {
switch (lhs, rhs) {
case (.uninitialized, .uninitialized),
(.handshaking, .handshaking),
(.established, .established):
return true
case (.failed, .failed):
return true // We don't compare the errors
default:
return false
}
}
}
// MARK: - Noise Session
class NoiseSession { class NoiseSession {
let peerID: PeerID let peerID: String
let role: NoiseRole let role: NoiseRole
private let keychain: KeychainManagerProtocol
private var state: NoiseSessionState = .uninitialized private var state: NoiseSessionState = .uninitialized
private var handshakeState: NoiseHandshakeState? private var handshakeState: NoiseHandshakeState?
private var sendCipher: NoiseCipherState? private var sendCipher: NoiseCipherState?
@@ -30,16 +53,9 @@ class NoiseSession {
// Thread safety // Thread safety
private let sessionQueue = DispatchQueue(label: "chat.bitchat.noise.session", attributes: .concurrent) private let sessionQueue = DispatchQueue(label: "chat.bitchat.noise.session", attributes: .concurrent)
init( init(peerID: String, role: NoiseRole, localStaticKey: Curve25519.KeyAgreement.PrivateKey, remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil) {
peerID: PeerID,
role: NoiseRole,
keychain: KeychainManagerProtocol,
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil
) {
self.peerID = peerID self.peerID = peerID
self.role = role self.role = role
self.keychain = keychain
self.localStaticKey = localStaticKey self.localStaticKey = localStaticKey
self.remoteStaticPublicKey = remoteStaticKey self.remoteStaticPublicKey = remoteStaticKey
} }
@@ -56,7 +72,6 @@ class NoiseSession {
handshakeState = NoiseHandshakeState( handshakeState = NoiseHandshakeState(
role: role, role: role,
pattern: .XX, pattern: .XX,
keychain: keychain,
localStaticKey: localStaticKey, localStaticKey: localStaticKey,
remoteStaticKey: nil remoteStaticKey: nil
) )
@@ -77,19 +92,18 @@ class NoiseSession {
func processHandshakeMessage(_ message: Data) throws -> Data? { func processHandshakeMessage(_ message: Data) throws -> Data? {
return try sessionQueue.sync(flags: .barrier) { return try sessionQueue.sync(flags: .barrier) {
SecureLogger.debug("NoiseSession[\(peerID)]: Processing handshake message, current state: \(state), role: \(role)") SecureLogger.log("NoiseSession[\(peerID)]: Processing handshake message, current state: \(state), role: \(role)", category: SecureLogger.noise, level: .debug)
// Initialize handshake state if needed (for responders) // Initialize handshake state if needed (for responders)
if state == .uninitialized && role == .responder { if state == .uninitialized && role == .responder {
handshakeState = NoiseHandshakeState( handshakeState = NoiseHandshakeState(
role: role, role: role,
pattern: .XX, pattern: .XX,
keychain: keychain,
localStaticKey: localStaticKey, localStaticKey: localStaticKey,
remoteStaticKey: nil remoteStaticKey: nil
) )
state = .handshaking state = .handshaking
SecureLogger.debug("NoiseSession[\(peerID)]: Initialized handshake state for responder") SecureLogger.log("NoiseSession[\(peerID)]: Initialized handshake state for responder", category: SecureLogger.noise, level: .debug)
} }
guard case .handshaking = state, let handshake = handshakeState else { guard case .handshaking = state, let handshake = handshakeState else {
@@ -98,7 +112,7 @@ class NoiseSession {
// Process incoming message // Process incoming message
_ = try handshake.readMessage(message) _ = try handshake.readMessage(message)
SecureLogger.debug("NoiseSession[\(peerID)]: Read handshake message, checking if complete") SecureLogger.log("NoiseSession[\(peerID)]: Read handshake message, checking if complete", category: SecureLogger.noise, level: .debug)
// Check if handshake is complete // Check if handshake is complete
if handshake.isHandshakeComplete() { if handshake.isHandshakeComplete() {
@@ -116,15 +130,15 @@ class NoiseSession {
state = .established state = .established
handshakeState = nil // Clear handshake state handshakeState = nil // Clear handshake state
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established") SecureLogger.log("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established", category: SecureLogger.noise, level: .debug)
SecureLogger.info(.handshakeCompleted(peerID: peerID.id)) SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID))
return nil return nil
} else { } else {
// Generate response // Generate response
let response = try handshake.writeMessage() let response = try handshake.writeMessage()
sentHandshakeMessages.append(response) sentHandshakeMessages.append(response)
SecureLogger.debug("NoiseSession[\(peerID)]: Generated handshake response of size \(response.count)") SecureLogger.log("NoiseSession[\(peerID)]: Generated handshake response of size \(response.count)", category: SecureLogger.noise, level: .debug)
// Check if handshake is complete after writing // Check if handshake is complete after writing
if handshake.isHandshakeComplete() { if handshake.isHandshakeComplete() {
@@ -142,8 +156,8 @@ class NoiseSession {
state = .established state = .established
handshakeState = nil // Clear handshake state handshakeState = nil // Clear handshake state
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established") SecureLogger.log("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established", category: SecureLogger.noise, level: .debug)
SecureLogger.info(.handshakeCompleted(peerID: peerID.id)) SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID))
} }
return response return response
@@ -217,19 +231,247 @@ class NoiseSession {
// Clear sent handshake messages // Clear sent handshake messages
for i in 0..<sentHandshakeMessages.count { for i in 0..<sentHandshakeMessages.count {
var message = sentHandshakeMessages[i] var message = sentHandshakeMessages[i]
keychain.secureClear(&message) KeychainManager.secureClear(&message)
} }
sentHandshakeMessages.removeAll() sentHandshakeMessages.removeAll()
// Clear handshake hash // Clear handshake hash
if var hash = handshakeHash { if var hash = handshakeHash {
keychain.secureClear(&hash) KeychainManager.secureClear(&hash)
} }
handshakeHash = nil handshakeHash = nil
if wasEstablished { if wasEstablished {
SecureLogger.info(.sessionExpired(peerID: peerID.id)) 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
}
-15
View File
@@ -1,15 +0,0 @@
//
// NoiseSessionError.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
enum NoiseSessionError: Error {
case invalidState
case notEstablished
case sessionNotFound
case handshakeFailed(Error)
case alreadyEstablished
}
-239
View File
@@ -1,239 +0,0 @@
//
// NoiseSessionManager.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import CryptoKit
import Foundation
final class NoiseSessionManager {
private var sessions: [PeerID: NoiseSession] = [:]
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
private let keychain: KeychainManagerProtocol
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
// Callbacks
var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)?
var onSessionFailed: ((PeerID, Error) -> Void)?
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
self.localStaticKey = localStaticKey
self.keychain = keychain
}
// MARK: - Session Management
func createSession(for peerID: PeerID, role: NoiseRole) -> NoiseSession {
return managerQueue.sync(flags: .barrier) {
let session = SecureNoiseSession(
peerID: peerID,
role: role,
keychain: keychain,
localStaticKey: localStaticKey
)
sessions[peerID] = session
return session
}
}
func getSession(for peerID: PeerID) -> NoiseSession? {
return managerQueue.sync {
return sessions[peerID]
}
}
func removeSession(for peerID: PeerID) {
managerQueue.sync(flags: .barrier) {
if let session = sessions[peerID] {
if session.isEstablished() {
SecureLogger.info(.sessionExpired(peerID: peerID.id))
}
// Clear sensitive data before removing
session.reset()
}
_ = sessions.removeValue(forKey: peerID)
}
}
func removeAllSessions() {
managerQueue.sync(flags: .barrier) {
for (_, session) in sessions {
session.reset()
}
sessions.removeAll()
}
}
func getEstablishedSessions() -> [PeerID: NoiseSession] {
return managerQueue.sync {
return sessions.filter { $0.value.isEstablished() }
}
}
// MARK: - Handshake Helpers
func initiateHandshake(with peerID: PeerID) throws -> Data {
return try managerQueue.sync(flags: .barrier) {
// Check if we already have an established session
if let existingSession = sessions[peerID], existingSession.isEstablished() {
// Session already established, don't recreate
throw NoiseSessionError.alreadyEstablished
}
// Remove any existing non-established session
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
_ = sessions.removeValue(forKey: peerID)
}
// Create new initiator session
let session = SecureNoiseSession(
peerID: peerID,
role: .initiator,
keychain: keychain,
localStaticKey: localStaticKey
)
sessions[peerID] = session
do {
let handshakeData = try session.startHandshake()
return handshakeData
} catch {
// Clean up failed session
_ = sessions.removeValue(forKey: peerID)
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
throw error
}
}
}
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
// Process everything within the synchronized block to prevent race conditions
return try managerQueue.sync(flags: .barrier) {
var shouldCreateNew = false
var existingSession: NoiseSession? = nil
if let existing = sessions[peerID] {
// If we have an established session, the peer must have cleared their session
// for a good reason (e.g., decryption failure, restart, etc.)
// We should accept the new handshake to re-establish encryption
if existing.isEstablished() {
SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session)
_ = sessions.removeValue(forKey: peerID)
shouldCreateNew = true
} else {
// If we're in the middle of a handshake and receive a new initiation,
// reset and start fresh (the other side may have restarted)
if existing.getState() == .handshaking && message.count == 32 {
_ = sessions.removeValue(forKey: peerID)
shouldCreateNew = true
} else {
existingSession = existing
}
}
} else {
shouldCreateNew = true
}
// Get or create session
let session: NoiseSession
if shouldCreateNew {
let newSession = SecureNoiseSession(
peerID: peerID,
role: .responder,
keychain: keychain,
localStaticKey: localStaticKey
)
sessions[peerID] = newSession
session = newSession
} else {
session = existingSession!
}
// Process the handshake message within the synchronized block
do {
let response = try session.processHandshakeMessage(message)
// Check if session is established after processing
if session.isEstablished() {
if let remoteKey = session.getRemoteStaticPublicKey() {
// Schedule callback outside the synchronized block to prevent deadlock
DispatchQueue.global().async { [weak self] in
self?.onSessionEstablished?(peerID, remoteKey)
}
}
}
return response
} catch {
// Reset the session on handshake failure so next attempt can start fresh
_ = sessions.removeValue(forKey: peerID)
// Schedule callback outside the synchronized block to prevent deadlock
DispatchQueue.global().async { [weak self] in
self?.onSessionFailed?(peerID, error)
}
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
throw error
}
}
}
// MARK: - Encryption/Decryption
func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
guard let session = getSession(for: peerID) else {
throw NoiseSessionError.sessionNotFound
}
return try session.encrypt(plaintext)
}
func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data {
guard let session = getSession(for: peerID) else {
throw NoiseSessionError.sessionNotFound
}
return try session.decrypt(ciphertext)
}
// MARK: - Key Management
func getRemoteStaticKey(for peerID: PeerID) -> Curve25519.KeyAgreement.PublicKey? {
return getSession(for: peerID)?.getRemoteStaticPublicKey()
}
func getHandshakeHash(for peerID: PeerID) -> Data? {
return getSession(for: peerID)?.getHandshakeHash()
}
// MARK: - Session Rekeying
func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)] {
return managerQueue.sync {
var needingRekey: [(peerID: PeerID, needsRekey: Bool)] = []
for (peerID, session) in sessions {
if let secureSession = session as? SecureNoiseSession,
secureSession.isEstablished(),
secureSession.needsRenegotiation() {
needingRekey.append((peerID: peerID, needsRekey: true))
}
}
return needingRekey
}
}
func initiateRekey(for peerID: PeerID) throws {
// Remove old session
removeSession(for: peerID)
// Initiate new handshake
_ = try initiateHandshake(with: peerID)
}
}
-13
View File
@@ -1,13 +0,0 @@
//
// NoiseSessionState.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
enum NoiseSessionState: Equatable {
case uninitialized
case handshaking
case established
}
+15 -25
View File
@@ -1,6 +1,4 @@
import BitLogger
import Foundation import Foundation
import Tor
/// 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.
@MainActor @MainActor
@@ -52,31 +50,23 @@ final class GeoRelayDirectory {
private func fetchRemote() { private func fetchRemote() {
let req = URLRequest(url: remoteURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15) let req = URLRequest(url: remoteURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
// Ensure Tor readiness before fetching (fail-closed by default) let task = URLSession.shared.dataTask(with: req) { [weak self] data, _, error in
Task.detached { guard let self = self else { return }
let ready = await TorManager.shared.awaitReady() if let data = data, error == nil, let text = String(data: data, encoding: .utf8) {
if !ready { let parsed = GeoRelayDirectory.parseCSV(text)
SecureLogger.warning("GeoRelayDirectory: Tor not ready; skipping remote fetch (fail-closed)", category: .session) if !parsed.isEmpty {
return Task { @MainActor in
} self.entries = parsed
let task = TorURLSession.shared.session.dataTask(with: req) { [weak self] data, _, error in self.persistCache(text)
guard let self = self else { return } UserDefaults.standard.set(Date(), forKey: self.lastFetchKey)
if let data = data, error == nil, let text = String(data: data, encoding: .utf8) { SecureLogger.log("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: SecureLogger.session, level: .info)
let parsed = GeoRelayDirectory.parseCSV(text)
if !parsed.isEmpty {
Task { @MainActor in
self.entries = parsed
self.persistCache(text)
UserDefaults.standard.set(Date(), forKey: self.lastFetchKey)
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
}
return
} }
return
} }
SecureLogger.warning("GeoRelayDirectory: remote fetch failed; keeping local entries", category: .session)
} }
task.resume() SecureLogger.log("GeoRelayDirectory: remote fetch failed; keeping local entries", category: SecureLogger.session, level: .warning)
} }
task.resume()
} }
private func persistCache(_ text: String) { private func persistCache(_ text: String) {
@@ -84,7 +74,7 @@ final class GeoRelayDirectory {
do { do {
try text.data(using: .utf8)?.write(to: url, options: .atomic) 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)
} }
} }
@@ -115,7 +105,7 @@ final class GeoRelayDirectory {
let text = String(data: data, encoding: .utf8) { let text = String(data: data, encoding: .utf8) {
return Self.parseCSV(text) return Self.parseCSV(text)
} }
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session) SecureLogger.log("GeoRelayDirectory: no local CSV found; entries empty", category: SecureLogger.session, level: .warning)
return [] return []
} }
+1 -1
View File
@@ -100,7 +100,7 @@ struct NostrEmbeddedBitChat {
if let maybeData = Data(hexString: recipientPeerID) { 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).id 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
+11 -26
View File
@@ -150,31 +150,13 @@ struct NostrIdentityBridge {
/// Clear all Nostr identity associations and current identity /// Clear all Nostr identity associations and current identity
static func clearAllAssociations() { static func clearAllAssociations() {
let query: [String: Any] = [ // Delete current Nostr identity
kSecClass as String: kSecClassGenericPassword, KeychainHelper.delete(key: currentIdentityKey, service: keychainService)
kSecAttrService as String: keychainService, KeychainHelper.delete(key: deviceSeedKey, service: keychainService)
kSecMatchLimit as String: kSecMatchLimitAll,
kSecReturnAttributes as String: true // 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
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) // MARK: - Per-Geohash Identities (Location Channels)
@@ -226,7 +208,10 @@ struct NostrIdentityBridge {
} }
} }
// As a final fallback, hash the seed+msg and try again // As a final fallback, hash the seed+msg and try again
let fallback = (seed + msg).sha256Hash() var combined = Data()
combined.append(seed)
combined.append(msg)
let fallback = Data(CryptoKit.SHA256.hash(data: combined))
return try NostrIdentity(privateKeyData: fallback) return try NostrIdentity(privateKeyData: fallback)
} }
} }
+9 -27
View File
@@ -1,4 +1,3 @@
import BitLogger
import Foundation import Foundation
import CryptoKit import CryptoKit
import P256K import P256K
@@ -79,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
} }
@@ -92,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
} }
@@ -108,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?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty { if let nickname = nickname, !nickname.isEmpty {
tags.append(["n", nickname]) tags.append(["n", nickname])
} }
if teleported { if teleported {
@@ -124,28 +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 persistent location note (kind 1: text note) tagged to a street-level geohash.
static func createGeohashTextNote(
content: String,
geohash: String,
senderIdentity: NostrIdentity,
nickname: String? = nil
) throws -> NostrEvent {
var tags = [["g", geohash]]
if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty {
tags.append(["n", nickname])
}
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .textNote,
tags: tags,
content: content
)
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
}
// MARK: - Private Methods // MARK: - Private Methods
@@ -521,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 {
+157 -445
View File
@@ -1,12 +1,10 @@
import BitLogger
import Foundation import Foundation
import Network import Network
import Combine import Combine
import Tor
/// Manages WebSocket connections to Nostr relays /// Manages WebSocket connections to Nostr relays
@MainActor @MainActor
final class NostrRelayManager: ObservableObject { class NostrRelayManager: ObservableObject {
static let shared = NostrRelayManager() static let shared = NostrRelayManager()
// Track gift-wraps (kind 1059) we initiated so we can log OK acks at info // Track gift-wraps (kind 1059) we initiated so we can log OK acks at info
private(set) static var pendingGiftWrapIDs = Set<String>() private(set) static var pendingGiftWrapIDs = Set<String>()
@@ -36,29 +34,14 @@ final class NostrRelayManager: ObservableObject {
"wss://nostr21.com" "wss://nostr21.com"
// For local testing, you can add: "ws://localhost:8080" // For local testing, you can add: "ws://localhost:8080"
] ]
private static let defaultRelaySet = Set(defaultRelays)
@Published private(set) var relays: [Relay] = [] @Published private(set) var relays: [Relay] = []
@Published private(set) var isConnected = false @Published private(set) var isConnected = false
private var allowDefaultRelays: Bool = false
private var hasMutualFavorites: Bool = false
private var hasLocationPermission: Bool = false
private var connections: [String: URLSessionWebSocketTask] = [:] private var connections: [String: URLSessionWebSocketTask] = [:]
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs private var subscriptions: [String: Set<String>] = [:] // relay URL -> subscription IDs
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
private var messageHandlers: [String: (NostrEvent) -> Void] = [:] private var messageHandlers: [String: (NostrEvent) -> Void] = [:]
// Coalesce duplicate subscribe requests for the same id within a short window
private var subscribeCoalesce: [String: Date] = [:]
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
// Track EOSE per subscription to signal when initial stored events are done
private struct EOSETracker {
var pendingRelays: Set<String>
var callback: () -> Void
var timer: Timer?
}
private var eoseTrackers: [String: EOSETracker] = [:]
// Message queue for reliability // Message queue for reliability
// Pending sends held only for relays that are not yet connected. // Pending sends held only for relays that are not yet connected.
@@ -68,10 +51,6 @@ final class NostrRelayManager: ObservableObject {
} }
private var messageQueue: [PendingSend] = [] private var messageQueue: [PendingSend] = []
private let messageQueueLock = NSLock() private let messageQueueLock = NSLock()
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
private var networkService: NetworkActivationService { NetworkActivationService.shared }
private var shouldUseTor: Bool { networkService.userTorEnabled }
// Exponential backoff configuration // Exponential backoff configuration
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
@@ -81,129 +60,45 @@ final class NostrRelayManager: ObservableObject {
// Reconnection timer // Reconnection timer
private var reconnectionTimer: Timer? private var reconnectionTimer: Timer?
// Bump generation to invalidate scheduled reconnects when we reset/disconnect
private var connectionGeneration: Int = 0
init() { init() {
hasMutualFavorites = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty // Initialize with default relays
hasLocationPermission = LocationChannelManager.shared.permissionState == .authorized self.relays = Self.defaultRelays.map { Relay(url: $0) }
applyDefaultRelayPolicy(force: true)
// Deterministic JSON shape for outbound requests
self.encoder.outputFormatting = .sortedKeys
FavoritesPersistenceService.shared.$mutualFavorites
.receive(on: DispatchQueue.main)
.sink { [weak self] favorites in
guard let self = self else { return }
self.hasMutualFavorites = !favorites.isEmpty
self.applyDefaultRelayPolicy()
}
.store(in: &cancellables)
LocationChannelManager.shared.$permissionState
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
guard let self = self else { return }
let authorized = (state == .authorized)
if authorized == self.hasLocationPermission { return }
self.hasLocationPermission = authorized
self.applyDefaultRelayPolicy()
}
.store(in: &cancellables)
} }
deinit {
// Clean up timers and active connections
reconnectionTimer?.invalidate()
for (_, tracker) in eoseTrackers {
tracker.timer?.invalidate()
}
for (_, task) in connections {
task.cancel(with: .goingAway, reason: nil)
}
cancellables.removeAll()
SecureLogger.debug("NostrRelayManager deinitialized", category: .session)
}
/// Connect to all configured relays /// Connect to all configured relays
func connect() { func connect() {
// Global network policy gate SecureLogger.log("🌐 Connecting to \(relays.count) Nostr relays", category: SecureLogger.session, level: .debug)
guard networkService.activationAllowed else { return } for relay in relays {
if shouldUseTor { connectToRelay(relay.url)
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
Task.detached {
let ready = await TorManager.shared.awaitReady()
await MainActor.run {
if !ready {
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
return
}
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
for relay in self.relays {
self.connectToRelay(relay.url)
}
}
}
} else {
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (direct)", category: .session)
for relay in self.relays {
connectToRelay(relay.url)
}
} }
} }
/// Disconnect from all relays /// Disconnect from all relays
func disconnect() { func disconnect() {
connectionGeneration &+= 1
for (_, task) in connections { for (_, task) in connections {
task.cancel(with: .goingAway, reason: nil) task.cancel(with: .goingAway, reason: nil)
} }
connections.removeAll() connections.removeAll()
// Clear known subscriptions and any queued subs since connections are gone
subscriptions.removeAll()
pendingSubscriptions.removeAll()
updateConnectionStatus() updateConnectionStatus()
} }
/// Ensure connections exist to the given relay URLs (idempotent). /// Ensure connections exist to the given relay URLs (idempotent).
func ensureConnections(to relayUrls: [String]) { func ensureConnections(to relayUrls: [String]) {
// Global network policy gate let existing = Set(relays.map { $0.url })
guard networkService.activationAllowed else { return } for url in Set(relayUrls) {
let targets = allowedRelayList(from: relayUrls) if !existing.contains(url) {
guard !targets.isEmpty else { return } relays.append(Relay(url: url))
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady { }
// Defer until Tor is fully ready; avoid queuing connection attempts early if connections[url] == nil {
Task.detached { [weak self] in connectToRelay(url)
guard let self = self else { return }
let ready = await TorManager.shared.awaitReady()
await MainActor.run { if ready { self.ensureConnections(to: relayUrls) } }
} }
return
}
var existing = Set(relays.map { $0.url })
for url in targets where !existing.contains(url) {
relays.append(Relay(url: url))
existing.insert(url)
}
for url in targets where connections[url] == nil {
connectToRelay(url)
} }
} }
/// Send an event to specified relays (or all if none specified) /// Send an event to specified relays (or all if none specified)
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) { func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
// Global network policy gate let targetRelays = relayUrls ?? Self.defaultRelays
guard networkService.activationAllowed else { return }
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
// Defer sends until Tor is ready to avoid premature queueing
Task.detached { [weak self] in
guard let self = self else { return }
let ready = await TorManager.shared.awaitReady()
await MainActor.run { if ready { self.sendEvent(event, to: relayUrls) } }
}
return
}
let requestedRelays = relayUrls ?? Self.defaultRelays
let targetRelays = allowedRelayList(from: requestedRelays)
guard !targetRelays.isEmpty else { return }
ensureConnections(to: targetRelays) ensureConnections(to: targetRelays)
// Attempt immediate send to relays with active connections; queue the rest // Attempt immediate send to relays with active connections; queue the rest
@@ -265,150 +160,68 @@ final class NostrRelayManager: ObservableObject {
filter: NostrFilter, filter: NostrFilter,
id: String = UUID().uuidString, id: String = UUID().uuidString,
relayUrls: [String]? = nil, relayUrls: [String]? = nil,
handler: @escaping (NostrEvent) -> Void, handler: @escaping (NostrEvent) -> Void
onEOSE: (() -> Void)? = nil
) { ) {
// Global network policy gate
guard networkService.activationAllowed else { return }
// Coalesce rapid duplicate subscribe requests only if a handler already exists
let now = Date()
if messageHandlers[id] != nil {
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < 1.0 {
return
}
}
subscribeCoalesce[id] = now
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
// Defer subscription setup until Tor is ready; avoid queuing subs early
Task.detached { [weak self] in
guard let self = self else { return }
let ready = await TorManager.shared.awaitReady()
await MainActor.run {
if ready {
self.subscribe(filter: filter, id: id, relayUrls: relayUrls, handler: handler)
}
}
}
return
}
messageHandlers[id] = handler messageHandlers[id] = handler
SecureLogger.log("📡 Subscribing to Nostr filter id=\(id) kinds=\(filter.kinds ?? []) since=\(filter.since ?? 0)",
category: SecureLogger.session, level: .debug)
let req = NostrRequest.subscribe(id: id, filters: [filter]) let req = NostrRequest.subscribe(id: id, filters: [filter])
do { do {
let encoder = JSONEncoder()
encoder.outputFormatting = .sortedKeys // For consistent output
let message = try encoder.encode(req) let message = try encoder.encode(req)
guard let messageString = String(data: message, encoding: .utf8) else { guard let messageString = String(data: message, encoding: .utf8) else {
SecureLogger.error("❌ Failed to encode subscription request", category: .session) SecureLogger.log("❌ Failed to encode subscription request", category: SecureLogger.session, level: .error)
return return
} }
// SecureLogger.debug("📋 Subscription filter JSON: \(messageString.prefix(200))...", category: .session) // SecureLogger.log("📋 Subscription filter JSON: \(messageString.prefix(200))...",
// category: SecureLogger.session, level: .debug)
// Target specific relays if provided; else default. Filter permanently failed relays. // Target specific relays if provided; else all connections
let baseUrls = relayUrls ?? Self.defaultRelays let urls = relayUrls ?? Self.defaultRelays
let candidateUrls = baseUrls.filter { !isPermanentlyFailed($0) } ensureConnections(to: urls)
let urls = allowedRelayList(from: candidateUrls) let targets: [(String, URLSessionWebSocketTask)] = urls.compactMap { url in
// Always queue subscriptions; sending happens when a relay reports connected connections[url].map { (url, $0) }
let existingSet = Set(relays.map { $0.url })
for url in urls where !existingSet.contains(url) {
relays.append(Relay(url: url))
} }
for url in candidateUrls {
var map = self.pendingSubscriptions[url] ?? [:] for (relayUrl, connection) in targets {
map[id] = messageString connection.send(.string(messageString)) { error in
self.pendingSubscriptions[url] = map if let error = error {
} SecureLogger.log("❌ Failed to send subscription to \(relayUrl): \(error)",
// Initialize EOSE tracking if requested category: SecureLogger.session, level: .error)
if let onEOSE = onEOSE { } else {
if urls.isEmpty { // SecureLogger.log(" Subscription '\(id)' sent to relay: \(relayUrl)",
onEOSE() // category: SecureLogger.session, level: .debug)
} else { // Subscription sent successfully
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)
// Fallback timeout to avoid hanging if a relay never sends EOSE
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
Task { @MainActor in Task { @MainActor in
guard let self = self else { return } var subs = self.subscriptions[relayUrl] ?? Set<String>()
if let t = self.eoseTrackers[id] { subs.insert(id)
t.timer?.invalidate() self.subscriptions[relayUrl] = subs
self.eoseTrackers.removeValue(forKey: id)
onEOSE()
}
} }
} }
eoseTrackers[id] = tracker
} }
} }
SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session)
// Ensure we actually have sockets opening to these relays so queued REQs can flush if connections.isEmpty {
ensureConnections(to: urls) SecureLogger.log("⚠️ No relay connections available for subscription",
// If some targets are already connected, flush immediately for them category: SecureLogger.session, level: .warning)
for url in urls {
if let r = relays.first(where: { $0.url == url }), r.isConnected {
flushPendingSubscriptions(for: url)
}
} }
} catch { } catch {
SecureLogger.error("❌ Failed to encode subscription request: \(error)", category: .session) SecureLogger.log("❌ Failed to encode subscription request: \(error)",
category: SecureLogger.session, level: .error)
} }
} }
private func applyDefaultRelayPolicy(force: Bool = false) {
let shouldAllow = hasMutualFavorites || hasLocationPermission
if !force && shouldAllow == allowDefaultRelays { return }
allowDefaultRelays = shouldAllow
if shouldAllow {
var existing = Set(relays.map { $0.url })
for url in Self.defaultRelays where !existing.contains(url) {
relays.append(Relay(url: url))
existing.insert(url)
}
if networkService.activationAllowed {
ensureConnections(to: Self.defaultRelays)
}
} else {
for url in Self.defaultRelays {
if let connection = connections[url] {
connection.cancel(with: .goingAway, reason: nil)
}
connections.removeValue(forKey: url)
subscriptions.removeValue(forKey: url)
}
messageQueueLock.lock()
for index in (0..<messageQueue.count).reversed() {
var item = messageQueue[index]
item.pendingRelays.subtract(Self.defaultRelaySet)
if item.pendingRelays.isEmpty {
messageQueue.remove(at: index)
} else {
messageQueue[index] = item
}
}
messageQueueLock.unlock()
relays.removeAll { Self.defaultRelaySet.contains($0.url) }
updateConnectionStatus()
}
}
private func allowedRelayList(from urls: [String]) -> [String] {
var seen = Set<String>()
var result: [String] = []
for url in urls {
if !allowDefaultRelays && Self.defaultRelaySet.contains(url) { continue }
if seen.insert(url).inserted {
result.append(url)
}
}
return result
}
/// Unsubscribe from a subscription /// Unsubscribe from a subscription
func unsubscribe(id: String) { func unsubscribe(id: String) {
messageHandlers.removeValue(forKey: id) messageHandlers.removeValue(forKey: id)
// Allow immediate re-subscription by clearing coalescer timestamp
subscribeCoalesce.removeValue(forKey: id)
let req = NostrRequest.close(id: id) let req = NostrRequest.close(id: id)
let message = try? encoder.encode(req) let message = try? JSONEncoder().encode(req)
guard let messageData = message, guard let messageData = message,
let messageString = String(data: messageData, encoding: .utf8) else { return } let messageString = String(data: messageData, encoding: .utf8) else { return }
@@ -428,42 +241,19 @@ final class NostrRelayManager: ObservableObject {
// MARK: - Private Methods // MARK: - Private Methods
private func connectToRelay(_ urlString: String) { private func connectToRelay(_ urlString: String) {
// Global network policy gate
guard networkService.activationAllowed else { return }
guard let url = URL(string: urlString) else { guard let url = URL(string: urlString) else {
SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session) SecureLogger.log("Invalid relay URL: \(urlString)", category: SecureLogger.session, level: .warning)
return return
} }
// Avoid initiating connections while app is backgrounded; we'll reconnect on foreground
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isForeground() {
return
}
// Skip if we already have a connection object // Skip if we already have a connection object
if connections[urlString] != nil { if connections[urlString] != nil {
return return
} }
if isPermanentlyFailed(urlString) {
return
}
// Attempting to connect to Nostr relay via the proxied session // Attempting to connect to Nostr relay
// If Tor is enforced but not ready, delay connection until it is. let session = URLSession(configuration: .default)
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
Task.detached { [weak self] in
guard let self = self else { return }
let ready = await TorManager.shared.awaitReady()
await MainActor.run {
if ready { self.connectToRelay(urlString) }
else { SecureLogger.error("❌ Tor not ready; skipping connection to \(urlString)", category: .session) }
}
}
return
}
let session = TorURLSession.shared.session
let task = session.webSocketTask(with: url) let task = session.webSocketTask(with: url)
connections[urlString] = task connections[urlString] = task
@@ -476,12 +266,12 @@ final class NostrRelayManager: ObservableObject {
task.sendPing { [weak self] error in task.sendPing { [weak self] error in
DispatchQueue.main.async { DispatchQueue.main.async {
if error == nil { if error == nil {
SecureLogger.debug("✅ Connected to Nostr relay: \(urlString)", category: .session) SecureLogger.log("✅ Connected to Nostr relay: \(urlString)",
category: SecureLogger.session, level: .debug)
self?.updateRelayStatus(urlString, isConnected: true) self?.updateRelayStatus(urlString, isConnected: true)
// Flush any pending subscriptions for this relay
self?.flushPendingSubscriptions(for: urlString)
} else { } else {
SecureLogger.error("❌ Failed to connect to Nostr relay \(urlString): \(error?.localizedDescription ?? "Unknown error")", category: .session) SecureLogger.log("❌ Failed to connect to Nostr relay \(urlString): \(error?.localizedDescription ?? "Unknown error")",
category: SecureLogger.session, level: .error)
self?.updateRelayStatus(urlString, isConnected: false, error: error) self?.updateRelayStatus(urlString, isConnected: false, error: error)
// Trigger disconnection handler for proper backoff // Trigger disconnection handler for proper backoff
self?.handleDisconnection(relayUrl: urlString, error: error ?? NSError(domain: "NostrRelay", code: -1, userInfo: nil)) self?.handleDisconnection(relayUrl: urlString, error: error ?? NSError(domain: "NostrRelay", code: -1, userInfo: nil))
@@ -489,27 +279,6 @@ final class NostrRelayManager: ObservableObject {
} }
} }
} }
/// Send any queued subscriptions for a relay that just connected.
private func flushPendingSubscriptions(for relayUrl: String) {
guard let map = pendingSubscriptions[relayUrl], !map.isEmpty else { return }
guard let connection = connections[relayUrl] else { return }
for (id, messageString) in map {
if self.subscriptions[relayUrl]?.contains(id) == true { continue }
connection.send(.string(messageString)) { error in
if let error = error {
SecureLogger.error("❌ Failed to send pending subscription to \(relayUrl): \(error)", category: .session)
} else {
Task { @MainActor in
var subs = self.subscriptions[relayUrl] ?? Set<String>()
subs.insert(id)
self.subscriptions[relayUrl] = subs
}
}
}
}
pendingSubscriptions[relayUrl] = nil
}
private func receiveMessage(from task: URLSessionWebSocketTask, relayUrl: String) { private func receiveMessage(from task: URLSessionWebSocketTask, relayUrl: String) {
task.receive { [weak self] result in task.receive { [weak self] result in
@@ -517,12 +286,19 @@ final class NostrRelayManager: ObservableObject {
switch result { switch result {
case .success(let message): case .success(let message):
// Parse off-main to reduce UI jank, then hop back for state updates switch message {
Task.detached(priority: .utility) { case .string(let text):
guard let parsed = ParsedInbound(message) else { return } Task { @MainActor in
await MainActor.run { self.handleMessage(text, from: relayUrl)
NostrRelayManager.shared.handleParsedMessage(parsed, from: relayUrl)
} }
case .data(let data):
if let text = String(data: data, encoding: .utf8) {
Task { @MainActor in
self.handleMessage(text, from: relayUrl)
}
}
@unknown default:
break
} }
// Continue receiving // Continue receiving
@@ -538,50 +314,79 @@ final class NostrRelayManager: ObservableObject {
} }
} }
// Parsed inbound message type (off-main) private func handleMessage(_ message: String, from relayUrl: String) {
// Note: declared at file scope below to avoid MainActor isolation inside this class guard let data = message.data(using: .utf8) else { return }
// and keep parsing off the main actor.
do {
// Handle parsed message on MainActor (state updates and handlers) // Try to decode as an array first
private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) { if let array = try JSONSerialization.jsonObject(with: data) as? [Any],
switch parsed { array.count >= 2,
case .event(let subId, let event): let type = array[0] as? String {
if event.kind != 1059 {
SecureLogger.debug("📥 Event kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session) // Received message from relay
}
if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) { switch type {
self.relays[index].messagesReceived += 1 case "EVENT":
} if array.count >= 3,
if let handler = self.messageHandlers[subId] { let subId = array[1] as? String,
handler(event) let eventDict = array[2] as? [String: Any] {
} else {
SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session) let event = try NostrEvent(from: eventDict)
}
case .eose(let subId): // Only log non-gift-wrap events to reduce noise
if var tracker = eoseTrackers[subId] { if event.kind != 1059 {
tracker.pendingRelays.remove(relayUrl) SecureLogger.log("📥 Event kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)",
if tracker.pendingRelays.isEmpty { category: SecureLogger.session, level: .debug)
tracker.timer?.invalidate() }
eoseTrackers.removeValue(forKey: subId)
tracker.callback() DispatchQueue.main.async {
} else { // Update relay stats
eoseTrackers[subId] = tracker if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) {
self.relays[index].messagesReceived += 1
}
// Call handler
if let handler = self.messageHandlers[subId] {
handler(event)
} else {
SecureLogger.log("⚠️ No handler for subscription \(subId)",
category: SecureLogger.session, level: .warning)
}
}
}
case "EOSE":
if array.count >= 2 {
// End of stored events
}
case "OK":
if array.count >= 3,
let eventId = array[1] as? String,
let success = array[2] as? Bool {
let reason = array.count >= 4 ? (array[3] as? String ?? "no reason given") : "no reason given"
if success {
_ = Self.pendingGiftWrapIDs.remove(eventId)
SecureLogger.log("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)",
category: SecureLogger.session, level: .debug)
} else {
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
SecureLogger.log("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)",
category: SecureLogger.session, level: isGiftWrap ? .warning : .error)
}
}
case "NOTICE":
if array.count >= 2 {
// Server notice received
}
default:
break // Unknown message type
} }
} }
case .ok(let eventId, let success, let reason): } catch {
if success { SecureLogger.log("Failed to parse Nostr message: \(error)", category: SecureLogger.session, level: .error)
_ = Self.pendingGiftWrapIDs.remove(eventId)
SecureLogger.debug("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)", category: .session)
} else {
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
if isGiftWrap {
SecureLogger.warning("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", category: .session)
} else {
SecureLogger.error("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", category: .session)
}
}
case .notice:
break
} }
} }
@@ -589,17 +394,22 @@ final class NostrRelayManager: ObservableObject {
let req = NostrRequest.event(event) let req = NostrRequest.event(event)
do { do {
let encoder = JSONEncoder()
encoder.outputFormatting = .sortedKeys
let data = try encoder.encode(req) let data = try encoder.encode(req)
let message = String(data: data, encoding: .utf8) ?? "" let message = String(data: data, encoding: .utf8) ?? ""
SecureLogger.debug("📤 Send kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session) SecureLogger.log("📤 Send kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)",
category: SecureLogger.session, level: .debug)
connection.send(.string(message)) { [weak self] error in connection.send(.string(message)) { [weak self] error in
DispatchQueue.main.async { DispatchQueue.main.async {
if let error = error { if let error = error {
SecureLogger.error("❌ Failed to send event to \(relayUrl): \(error)", category: .session) SecureLogger.log("❌ Failed to send event to \(relayUrl): \(error)",
category: SecureLogger.session, level: .error)
} else { } else {
// SecureLogger.debug(" Event sent to relay: \(relayUrl)", category: .session) // SecureLogger.log(" Event sent to relay: \(relayUrl)",
// category: SecureLogger.session, level: .debug)
// Update relay stats // Update relay stats
if let index = self?.relays.firstIndex(where: { $0.url == relayUrl }) { if let index = self?.relays.firstIndex(where: { $0.url == relayUrl }) {
self?.relays[index].messagesSent += 1 self?.relays[index].messagesSent += 1
@@ -608,7 +418,7 @@ final class NostrRelayManager: ObservableObject {
} }
} }
} catch { } catch {
SecureLogger.error("Failed to encode event: \(error)", category: .session) SecureLogger.log("Failed to encode event: \(error)", category: SecureLogger.session, level: .error)
} }
} }
@@ -636,32 +446,22 @@ final class NostrRelayManager: ObservableObject {
} }
private func handleDisconnection(relayUrl: String, error: Error) { private func handleDisconnection(relayUrl: String, error: Error) {
// If networking is disallowed, do not schedule reconnection
if !networkService.activationAllowed {
connections.removeValue(forKey: relayUrl)
subscriptions.removeValue(forKey: relayUrl)
updateRelayStatus(relayUrl, isConnected: false, error: error)
return
}
connections.removeValue(forKey: relayUrl) connections.removeValue(forKey: relayUrl)
subscriptions.removeValue(forKey: relayUrl) subscriptions.removeValue(forKey: relayUrl)
updateRelayStatus(relayUrl, isConnected: false, error: error) updateRelayStatus(relayUrl, isConnected: false, error: error)
// Check if this is a DNS or handshake error; treat as permanent // Check if this is a DNS error
let errorDescription = error.localizedDescription.lowercased() let errorDescription = error.localizedDescription.lowercased()
let ns = error as NSError
if errorDescription.contains("hostname could not be found") || if errorDescription.contains("hostname could not be found") ||
errorDescription.contains("dns") || errorDescription.contains("dns") {
(ns.domain == NSURLErrorDomain && ns.code == NSURLErrorBadServerResponse) { // Only log once for DNS failures
if relays.first(where: { $0.url == relayUrl })?.lastError == nil { if relays.first(where: { $0.url == relayUrl })?.lastError == nil {
SecureLogger.warning("Nostr relay permanent failure for \(relayUrl) - not retrying (code=\(ns.code))", category: .session) SecureLogger.log("Nostr relay DNS failure for \(relayUrl) - not retrying", category: SecureLogger.session, level: .warning)
} }
// Mark relay as permanently failed
if let index = relays.firstIndex(where: { $0.url == relayUrl }) { if let index = relays.firstIndex(where: { $0.url == relayUrl }) {
relays[index].lastError = error relays[index].lastError = error
relays[index].reconnectAttempts = maxReconnectAttempts
relays[index].nextReconnectTime = nil
} }
pendingSubscriptions[relayUrl] = nil
return return
} }
@@ -672,7 +472,8 @@ final class NostrRelayManager: ObservableObject {
// Stop attempting after max attempts // Stop attempting after max attempts
if relays[index].reconnectAttempts >= maxReconnectAttempts { if relays[index].reconnectAttempts >= maxReconnectAttempts {
SecureLogger.warning("Max reconnection attempts (\(maxReconnectAttempts)) reached for \(relayUrl)", category: .session) SecureLogger.log("Max reconnection attempts (\(maxReconnectAttempts)) reached for \(relayUrl)",
category: SecureLogger.session, level: .warning)
return return
} }
@@ -687,11 +488,9 @@ final class NostrRelayManager: ObservableObject {
// Schedule reconnection with exponential backoff // Schedule reconnection with exponential backoff
let gen = connectionGeneration
DispatchQueue.main.asyncAfter(deadline: .now() + backoffInterval) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + backoffInterval) { [weak self] in
guard let self = self else { return } guard let self = self else { return }
// Ignore stale scheduled reconnects from a previous generation
guard gen == self.connectionGeneration else { return }
// Check if we should still reconnect (relay might have been removed) // Check if we should still reconnect (relay might have been removed)
if self.relays.contains(where: { $0.url == relayUrl }) { if self.relays.contains(where: { $0.url == relayUrl }) {
self.connectToRelay(relayUrl) self.connectToRelay(relayUrl)
@@ -732,8 +531,6 @@ final class NostrRelayManager: ObservableObject {
/// Reset all relay connections /// Reset all relay connections
func resetAllConnections() { func resetAllConnections() {
disconnect() disconnect()
// New generation begins now
connectionGeneration &+= 1
// Reset all relay states // Reset all relay states
for index in relays.indices { for index in relays.indices {
@@ -745,81 +542,6 @@ final class NostrRelayManager: ObservableObject {
// Reconnect // Reconnect
connect() connect()
} }
// MARK: - Failure classification
private func isPermanentlyFailed(_ url: String) -> Bool {
guard let r = relays.first(where: { $0.url == url }) else { return false }
if r.reconnectAttempts >= maxReconnectAttempts { return true }
if let ns = r.lastError as NSError?, ns.domain == NSURLErrorDomain {
if ns.code == NSURLErrorBadServerResponse || ns.code == NSURLErrorCannotFindHost {
return true
}
}
return false
}
}
// MARK: - Off-main inbound parsing helpers (file scope, non-isolated)
private enum ParsedInbound {
case event(subId: String, event: NostrEvent)
case ok(eventId: String, success: Bool, reason: String)
case eose(subscriptionId: String)
case notice(String)
init?(_ message: URLSessionWebSocketTask.Message) {
guard let data = message.data,
let array = try? JSONSerialization.jsonObject(with: data) as? [Any],
array.count >= 2,
let type = array[0] as? String else {
return nil
}
switch type {
case "EVENT":
if array.count >= 3,
let subId = array[1] as? String,
let eventDict = array[2] as? [String: Any],
let event = try? NostrEvent(from: eventDict) {
self = .event(subId: subId, event: event)
return
}
return nil
case "EOSE":
if let subId = array[1] as? String {
self = .eose(subscriptionId: subId)
return
}
return nil
case "OK":
if array.count >= 3,
let eventId = array[1] as? String,
let success = array[2] as? Bool {
let reason = array.count >= 4 ? (array[3] as? String ?? "no reason given") : "no reason given"
self = .ok(eventId: eventId, success: success, reason: reason)
return
}
return nil
case "NOTICE":
if array.count >= 2, let msg = array[1] as? String {
self = .notice(msg)
return
}
return nil
default:
return nil
}
}
}
private extension URLSessionWebSocketTask.Message {
var data: Data? {
switch self {
case .string(let text): text.data(using: .utf8)
case .data(let data): data
@unknown default: nil
}
}
} }
// MARK: - Nostr Protocol Types // MARK: - Nostr Protocol Types
@@ -909,16 +631,6 @@ struct NostrFilter: Encodable {
filter.limit = limit filter.limit = limit
return filter return filter
} }
// For location notes: persistent text notes (kind 1) tagged with geohash
static func geohashNotes(_ geohash: String, since: Date? = nil, limit: Int = 200) -> NostrFilter {
var filter = NostrFilter()
filter.kinds = [1]
filter.since = since?.timeIntervalSince1970.toInt()
filter.tagFilters = ["g": [geohash]]
filter.limit = limit
return filter
}
} }
// Dynamic coding key for tag filters // Dynamic coding key for tag filters
+10 -9
View File
@@ -40,23 +40,23 @@ extension Data {
extension Data { extension Data {
// MARK: Writing // MARK: Writing
@inlinable 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))
} }
@@ -113,21 +113,21 @@ extension Data {
// MARK: Reading // MARK: Reading
@inlinable 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 |
@@ -137,7 +137,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 {
@@ -197,7 +197,7 @@ extension Data {
offset += 16 offset += 16
// Convert 16 bytes to UUID string format // Convert 16 bytes to UUID string format
let uuid = uuidData.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 = ""
@@ -220,3 +220,4 @@ extension Data {
return data return data
} }
} }
+361 -103
View File
@@ -139,11 +139,6 @@ struct BinaryProtocol {
} }
// Header // Header
// Reserve capacity to reduce reallocations. Estimate base size conservatively.
// header(13) + sender(8) + opt recipient(8) + opt originalSize(2) + payload + opt signature(64) + up to 255 pad
let estimatedPayload = payload.count + (isCompressed ? 2 : 0)
let estimated = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + estimatedPayload + (packet.signature == nil ? 0 : signatureSize) + 255
data.reserveCapacity(estimated)
data.append(packet.version) data.append(packet.version)
data.append(packet.type) data.append(packet.type)
data.append(packet.ttl) data.append(packet.ttl)
@@ -227,104 +222,367 @@ struct BinaryProtocol {
// Core decoding implementation used by decode(_:) with and without padding removal // Core decoding implementation used by decode(_:) with and without padding removal
private static func decodeCore(_ raw: Data) -> BitchatPacket? { private static func decodeCore(_ raw: Data) -> BitchatPacket? {
// Minimum size: header + senderID // Minimum size check: header + senderID
guard raw.count >= headerSize + senderIDSize else { return nil } guard raw.count >= headerSize + senderIDSize else {
return nil
return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in
guard let base = buf.baseAddress else { return nil }
var offset = 0
func require(_ n: Int) -> Bool { offset + n <= buf.count }
// Read single byte
func read8() -> UInt8? {
guard require(1) else { return nil }
let v = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self).pointee
offset += 1
return v
}
// Read big-endian 16-bit
func read16() -> UInt16? {
guard require(2) else { return nil }
let p = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
let v = (UInt16(p[0]) << 8) | UInt16(p[1])
offset += 2
return v
}
// Copy N bytes into Data
func readData(_ n: Int) -> Data? {
guard require(n) else { return nil }
let ptr = base.advanced(by: offset)
let d = Data(bytes: ptr, count: n)
offset += n
return d
}
// Version
guard let version = read8(), version == 1 else { return nil }
guard let type = read8() else { return nil }
guard let ttl = read8() else { return nil }
// Timestamp 8 bytes BE
guard require(8) else { return nil }
var ts: UInt64 = 0
for _ in 0..<8 {
guard let b = read8() else { return nil }
ts = (ts << 8) | UInt64(b)
}
// Flags
guard let flags = read8() else { return nil }
let hasRecipient = (flags & Flags.hasRecipient) != 0
let hasSignature = (flags & Flags.hasSignature) != 0
let isCompressed = (flags & Flags.isCompressed) != 0
// Payload length
guard let payloadLen = read16(), payloadLen <= 65535 else { return nil }
// SenderID
guard let senderID = readData(senderIDSize) else { return nil }
// Recipient
var recipientID: Data? = nil
if hasRecipient {
recipientID = readData(recipientIDSize)
if recipientID == nil { return nil }
}
// Payload
let payload: Data
if isCompressed {
// Need original size (2 bytes)
guard let origSize16 = read16() else { return nil }
let originalSize = Int(origSize16)
guard originalSize >= 0 && originalSize <= 1_048_576 else { return nil }
let compSize = Int(payloadLen) - 2
guard compSize >= 0, let compressed = readData(compSize) else { return nil }
guard let decompressed = CompressionUtil.decompress(compressed, originalSize: originalSize),
decompressed.count == originalSize else { return nil }
payload = decompressed
} else {
guard let p = readData(Int(payloadLen)) else { return nil }
payload = p
}
// Signature
var signature: Data? = nil
if hasSignature {
signature = readData(signatureSize)
if signature == nil { return nil }
}
guard offset <= buf.count else { return nil }
return BitchatPacket(
type: type,
senderID: senderID,
recipientID: recipientID,
timestamp: ts,
payload: payload,
signature: signature,
ttl: ttl
)
} }
// 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
} }
} }
+348 -16
View File
@@ -59,7 +59,61 @@
/// ///
import Foundation import Foundation
import CoreBluetooth import CryptoKit
// MARK: - Message Padding
/// Provides privacy-preserving message padding to obscure actual content length.
/// Uses PKCS#7-style padding with random bytes to prevent traffic analysis.
struct MessagePadding {
// Standard block sizes for padding
static let blockSizes = [256, 512, 1024, 2048]
// Add PKCS#7-style padding to reach target size
static func pad(_ data: Data, toSize targetSize: Int) -> Data {
guard data.count < targetSize else { return data }
let paddingNeeded = targetSize - data.count
// Constrain to 255 to fit a single-byte pad length marker
guard paddingNeeded > 0 && paddingNeeded <= 255 else { return data }
var padded = data
// PKCS#7: All pad bytes are equal to the pad length
padded.append(contentsOf: Array(repeating: UInt8(paddingNeeded), count: paddingNeeded))
return padded
}
// Remove padding from data
static func unpad(_ data: Data) -> Data {
guard !data.isEmpty else { return data }
let last = data.last!
let paddingLength = Int(last)
// Must have at least 1 pad byte and not exceed data length
guard paddingLength > 0 && paddingLength <= data.count else { return data }
// Verify PKCS#7: all last N bytes equal to pad length
let start = data.count - paddingLength
let tail = data[start...]
for b in tail { if b != last { return data } }
return Data(data[..<start])
}
// Find optimal block size for data
static func optimalBlockSize(for dataSize: Int) -> Int {
// Account for encryption overhead (~16 bytes for AES-GCM tag)
let totalSize = dataSize + 16
// Find smallest block that fits
for blockSize in blockSizes {
if totalSize <= blockSize {
return blockSize
}
}
// For very large messages, just use the original size
// (will be fragmented anyway)
return dataSize
}
}
// MARK: - Message Types // MARK: - Message Types
@@ -71,7 +125,6 @@ enum MessageType: UInt8 {
case announce = 0x01 // "I'm here" with nickname case announce = 0x01 // "I'm here" with nickname
case message = 0x02 // Public chat message case message = 0x02 // Public chat message
case leave = 0x03 // "I'm leaving" case leave = 0x03 // "I'm leaving"
case requestSync = 0x21 // GCS filter-based sync request (local-only)
// Noise encryption // Noise encryption
case noiseHandshake = 0x10 // Handshake (init or response determined by payload) case noiseHandshake = 0x10 // Handshake (init or response determined by payload)
@@ -85,7 +138,6 @@ enum MessageType: UInt8 {
case .announce: return "announce" case .announce: return "announce"
case .message: return "message" case .message: return "message"
case .leave: return "leave" case .leave: return "leave"
case .requestSync: return "requestSync"
case .noiseHandshake: return "noiseHandshake" case .noiseHandshake: return "noiseHandshake"
case .noiseEncrypted: return "noiseEncrypted" case .noiseEncrypted: return "noiseEncrypted"
case .fragment: return "fragment" case .fragment: return "fragment"
@@ -129,10 +181,191 @@ 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 // MARK: - Delivery Status
// Delivery status for messages // Delivery status for messages
enum DeliveryStatus: Codable, Equatable, Hashable { enum DeliveryStatus: Codable, Equatable {
case sending case sending
case sent // Left our device case sent // Left our device
case delivered(to: String, at: Date) // Confirmed by recipient case delivered(to: String, at: Date) // Confirmed by recipient
@@ -158,25 +391,90 @@ enum DeliveryStatus: Codable, Equatable, Hashable {
} }
} }
// 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)
} }
// Provide default implementation to make it effectively optional // Provide default implementation to make it effectively optional
@@ -189,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) { 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)
}
}
-8
View File
@@ -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)
+6 -18
View File
@@ -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")
-3
View File
@@ -15,8 +15,6 @@ struct AnnouncementPacket {
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 }
@@ -90,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 }
+14
View File
@@ -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))
}
}
+1 -1
View File
@@ -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: [])
File diff suppressed because it is too large Load Diff
-328
View File
@@ -1,328 +0,0 @@
//
// ColorPaletteService.swift
// bitchat
//
// Manages consistent color assignment for peers using minimal-distance algorithm
// This is free and unencumbered software released into the public domain.
//
import Foundation
import SwiftUI
/// Service that assigns consistent, visually distinct colors to peers
/// Uses a minimal-distance hue assignment algorithm to maximize color separation
final class ColorPaletteService {
// MARK: - Palette State
private var peerPaletteLight: [String: (slot: Int, ring: Int, hue: Double)] = [:]
private var peerPaletteDark: [String: (slot: Int, ring: Int, hue: Double)] = [:]
private var peerPaletteSeeds: [String: String] = [:] // peerID -> seed used
private var nostrPaletteLight: [String: (slot: Int, ring: Int, hue: Double)] = [:]
private var nostrPaletteDark: [String: (slot: Int, ring: Int, hue: Double)] = [:]
private var nostrPaletteSeeds: [String: String] = [:] // pubkey -> seed used
// MARK: - Configuration
private let slotCount: Int
private let avoidCenter: Double // Hue to avoid (typically orange for self)
private let avoidDelta: Double
private let saturationDark: Double
private let saturationLight: Double
private let baseBrightnessDark: Double
private let baseBrightnessLight: Double
private let ringDeltaDark: Double
private let ringDeltaLight: Double
// MARK: - Initialization
init(
slotCount: Int = max(8, TransportConfig.uiPeerPaletteSlots),
avoidCenter: Double = 30.0 / 360.0, // Orange hue
avoidDelta: Double = TransportConfig.uiColorHueAvoidanceDelta,
saturationDark: Double = 0.80,
saturationLight: Double = 0.70,
baseBrightnessDark: Double = 0.75,
baseBrightnessLight: Double = 0.45,
ringDeltaDark: Double = TransportConfig.uiPeerPaletteRingBrightnessDeltaDark,
ringDeltaLight: Double = TransportConfig.uiPeerPaletteRingBrightnessDeltaLight
) {
self.slotCount = slotCount
self.avoidCenter = avoidCenter
self.avoidDelta = avoidDelta
self.saturationDark = saturationDark
self.saturationLight = saturationLight
self.baseBrightnessDark = baseBrightnessDark
self.baseBrightnessLight = baseBrightnessLight
self.ringDeltaDark = ringDeltaDark
self.ringDeltaLight = ringDeltaLight
}
// MARK: - Public API
/// Get color for a mesh peer
func colorForMeshPeer(
peerID: String,
isDark: Bool,
myPeerID: String,
allPeers: [BitchatPeer],
getNoiseKeyForShortID: (String) -> String?
) -> Color {
// Ensure palette is up to date
rebuildPeerPaletteIfNeeded(
myPeerID: myPeerID,
allPeers: allPeers,
getNoiseKeyForShortID: getNoiseKeyForShortID
)
let entry = (isDark ? peerPaletteDark[peerID] : peerPaletteLight[peerID])
let orange = Color.orange
if peerID == myPeerID { return orange }
let saturation: Double = isDark ? saturationDark : saturationLight
let baseBrightness: Double = isDark ? baseBrightnessDark : baseBrightnessLight
let ringDelta = isDark ? ringDeltaDark : ringDeltaLight
if let e = entry {
let brightness = min(1.0, max(0.0, baseBrightness + ringDelta * Double(e.ring)))
return Color(hue: e.hue, saturation: saturation, brightness: brightness)
}
// Fallback to seed color if not in palette
let seed = meshSeed(for: peerID, getNoiseKeyForShortID: getNoiseKeyForShortID)
return Color(peerSeed: seed, isDark: isDark)
}
/// Get color for a Nostr participant
func colorForNostrPubkey(
pubkeyHexLowercased: String,
isDark: Bool,
myNostrPubkey: String?,
geohashPeople: [(id: String, seed: String)]
) -> Color {
rebuildNostrPaletteIfNeeded(
myNostrPubkey: myNostrPubkey,
geohashPeople: geohashPeople
)
let entry = (isDark ? nostrPaletteDark[pubkeyHexLowercased] : nostrPaletteLight[pubkeyHexLowercased])
if let me = myNostrPubkey, pubkeyHexLowercased == me { return .orange }
let saturation: Double = isDark ? saturationDark : saturationLight
let baseBrightness: Double = isDark ? baseBrightnessDark : baseBrightnessLight
let ringDelta = isDark ? ringDeltaDark : ringDeltaLight
if let e = entry {
let brightness = min(1.0, max(0.0, baseBrightness + ringDelta * Double(e.ring)))
return Color(hue: e.hue, saturation: saturation, brightness: brightness)
}
// Fallback to seed color
return Color(peerSeed: "nostr:" + pubkeyHexLowercased, isDark: isDark)
}
/// Get color for a message sender (auto-detects type)
func peerColor(
for message: BitchatMessage,
isDark: Bool,
myPeerID: String,
myNostrPubkey: String?,
nostrKeyMapping: [String: String],
allPeers: [BitchatPeer],
geohashPeople: [(id: String, seed: String)],
getNoiseKeyForShortID: (String) -> String?
) -> Color {
if let spid = message.senderPeerID?.id {
if spid.hasPrefix("nostr:") || spid.hasPrefix("nostr_") {
let bare: String = {
if spid.hasPrefix("nostr:") { return String(spid.dropFirst(6)) }
if spid.hasPrefix("nostr_") { return String(spid.dropFirst(6)) }
return spid
}()
let full = nostrKeyMapping[spid]?.lowercased() ?? bare.lowercased()
return colorForNostrPubkey(
pubkeyHexLowercased: full,
isDark: isDark,
myNostrPubkey: myNostrPubkey,
geohashPeople: geohashPeople
)
} else if spid.count == 16 {
return colorForMeshPeer(
peerID: spid,
isDark: isDark,
myPeerID: myPeerID,
allPeers: allPeers,
getNoiseKeyForShortID: getNoiseKeyForShortID
)
} else {
return colorForMeshPeer(
peerID: spid.lowercased(),
isDark: isDark,
myPeerID: myPeerID,
allPeers: allPeers,
getNoiseKeyForShortID: getNoiseKeyForShortID
)
}
}
// Fallback when we only have a display name
return Color(peerSeed: message.sender.lowercased(), isDark: isDark)
}
/// Reset all palette state (useful for testing)
func reset() {
peerPaletteLight.removeAll()
peerPaletteDark.removeAll()
peerPaletteSeeds.removeAll()
nostrPaletteLight.removeAll()
nostrPaletteDark.removeAll()
nostrPaletteSeeds.removeAll()
}
// MARK: - Private Helpers
private func meshSeed(for peerID: String, getNoiseKeyForShortID: (String) -> String?) -> String {
if let full = getNoiseKeyForShortID(peerID)?.lowercased() {
return "noise:" + full
}
return peerID.lowercased()
}
private func rebuildPeerPaletteIfNeeded(
myPeerID: String,
allPeers: [BitchatPeer],
getNoiseKeyForShortID: (String) -> String?
) {
// Build current peer->seed map (excluding self)
var currentSeeds: [String: String] = [:]
for p in allPeers where p.peerID.id != myPeerID {
currentSeeds[p.peerID.id] = meshSeed(for: p.peerID.id, getNoiseKeyForShortID: getNoiseKeyForShortID)
}
// If seeds unchanged and palette exists for both themes, skip
if currentSeeds == peerPaletteSeeds,
peerPaletteLight.keys.count == currentSeeds.count,
peerPaletteDark.keys.count == currentSeeds.count {
return
}
peerPaletteSeeds = currentSeeds
// Generate palette
let mapping = assignColorsMinimalDistance(seeds: currentSeeds, previousMapping: peerPaletteLight)
peerPaletteLight = mapping
peerPaletteDark = mapping
}
private func rebuildNostrPaletteIfNeeded(
myNostrPubkey: String?,
geohashPeople: [(id: String, seed: String)]
) {
// Build seeds map from currently visible geohash people (excluding self)
var currentSeeds: [String: String] = [:]
for p in geohashPeople where p.id != myNostrPubkey {
currentSeeds[p.id] = p.seed
}
if currentSeeds == nostrPaletteSeeds,
nostrPaletteLight.keys.count == currentSeeds.count,
nostrPaletteDark.keys.count == currentSeeds.count {
return
}
nostrPaletteSeeds = currentSeeds
let mapping = assignColorsMinimalDistance(seeds: currentSeeds, previousMapping: nostrPaletteLight)
nostrPaletteLight = mapping
nostrPaletteDark = mapping
}
// MARK: - Minimal-Distance Color Assignment Algorithm
private func assignColorsMinimalDistance(
seeds: [String: String],
previousMapping: [String: (slot: Int, ring: Int, hue: Double)]
) -> [String: (slot: Int, ring: Int, hue: Double)] {
// Generate evenly spaced hue slots avoiding self-orange range
var slots: [Double] = []
for i in 0..<slotCount {
let hue = Double(i) / Double(slotCount)
if abs(hue - avoidCenter) < avoidDelta { continue }
slots.append(hue)
}
if slots.isEmpty {
// Safety: if avoidance consumed all (shouldn't happen), fall back to full slots
for i in 0..<slotCount { slots.append(Double(i) / Double(slotCount)) }
}
// Helper to compute circular distance
func circDist(_ a: Double, _ b: Double) -> Double {
let d = abs(a - b)
return d > 0.5 ? 1.0 - d : d
}
// Assign slots to peers to maximize minimal distance, deterministically
let peers = seeds.keys.sorted() // stable order
// Preferred slot index by seed (wrapping to available slots)
let prefIndex: [String: Int] = Dictionary(uniqueKeysWithValues: peers.map { id in
let h = (seeds[id] ?? id).djb2()
let idx = Int(h % UInt64(slots.count))
return (id, idx)
})
var mapping: [String: (slot: Int, ring: Int, hue: Double)] = [:]
var usedSlots = Set<Int>()
var usedHues: [Double] = []
// Keep previous assignments if still valid to minimize churn
for (id, entry) in previousMapping {
if seeds.keys.contains(id), entry.slot < slots.count { // slot index still valid
mapping[id] = (entry.slot, entry.ring, slots[entry.slot])
usedSlots.insert(entry.slot)
usedHues.append(slots[entry.slot])
}
}
// First ring assignment using free slots
let unassigned = peers.filter { mapping[$0] == nil }
for id in unassigned {
// If a preferred slot free, take it
let preferred = prefIndex[id] ?? 0
if !usedSlots.contains(preferred) && preferred < slots.count {
mapping[id] = (preferred, 0, slots[preferred])
usedSlots.insert(preferred)
usedHues.append(slots[preferred])
continue
}
// Choose free slot maximizing minimal distance to used hues
var bestSlot: Int? = nil
var bestScore: Double = -1
for sIdx in 0..<slots.count where !usedSlots.contains(sIdx) {
let hue = slots[sIdx]
let minDist = usedHues.isEmpty ? 1.0 : usedHues.map { circDist(hue, $0) }.min() ?? 1.0
// Bias toward preferred index for stability
let bias = 1.0 - (Double((abs(sIdx - (prefIndex[id] ?? 0)) % slots.count)) / Double(slots.count))
let score = minDist + 0.05 * bias
if score > bestScore { bestScore = score; bestSlot = sIdx }
}
if let s = bestSlot {
mapping[id] = (s, 0, slots[s])
usedSlots.insert(s)
usedHues.append(slots[s])
}
}
// Overflow peers: assign additional rings by reusing slots with stable preference
let stillUnassigned = peers.filter { mapping[$0] == nil }
if !stillUnassigned.isEmpty {
for (idx, id) in stillUnassigned.enumerated() {
let preferred = prefIndex[id] ?? 0
// Spread over slots by rotating from preferred with a golden-step
let goldenStep = 7 // small prime step for dispersion
let s = (preferred + idx * goldenStep) % slots.count
mapping[id] = (s, 1, slots[s])
}
}
return mapping
}
}
+23 -25
View File
@@ -17,15 +17,13 @@ enum CommandResult {
/// 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 chatViewModel: ChatViewModel? weak var chatViewModel: ChatViewModel?
weak var meshService: Transport? weak var meshService: Transport?
private let identityManager: SecureIdentityStateManagerProtocol
init(chatViewModel: ChatViewModel? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) { init(chatViewModel: ChatViewModel? = nil, meshService: Transport? = nil) {
self.chatViewModel = chatViewModel self.chatViewModel = chatViewModel
self.meshService = meshService self.meshService = meshService
self.identityManager = identityManager
} }
/// Process a command string /// Process a command string
@@ -52,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":
@@ -131,27 +129,27 @@ final class CommandProcessor {
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.trimmingCharacters(in: .whitespaces) 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 = chatViewModel?.getPeerIDForNickname(nickname), guard let targetPeerID = chatViewModel?.getPeerIDForNickname(nickname),
let myNickname = chatViewModel?.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 chatViewModel?.selectedPrivateChatPeer != nil { if chatViewModel?.selectedPrivateChatPeer != nil {
// In private chat // In private chat
if let peerNickname = meshService?.peerNickname(peerID: PeerID(str: 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: PeerID(str: 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 = {
@@ -191,7 +189,7 @@ 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 = chatViewModel { if let vm = chatViewModel {
let visible = vm.visibleGeohashPeople() let visible = vm.visibleGeohashPeople()
@@ -214,15 +212,15 @@ final class CommandProcessor {
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
if let peerID = chatViewModel?.getPeerIDForNickname(nickname), if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
let fingerprint = meshService?.getFingerprint(for: PeerID(str: 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,
@@ -233,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 = chatViewModel?.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")
} }
@@ -258,19 +256,19 @@ final class CommandProcessor {
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
if let peerID = chatViewModel?.getPeerIDForNickname(nickname), if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
let fingerprint = meshService?.getFingerprint(for: PeerID(str: 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 = chatViewModel?.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")
@@ -1,73 +0,0 @@
//
// DeliveryTrackingService.swift
// bitchat
//
// Service for tracking message delivery and read status
// This is free and unencumbered software released into the public domain.
//
import BitLogger
import Foundation
/// Service that manages delivery status updates for messages
/// Prevents status downgrades (e.g., read delivered) and maintains consistency
final class DeliveryTrackingService {
// MARK: - Public API
/// Update delivery status for a message, preventing downgrades
/// - Parameters:
/// - messageID: The message ID to update
/// - status: The new delivery status
/// - messages: Array of public messages (inout for mutation)
/// - privateChats: Dictionary of private chats (inout for mutation)
/// - notifyChange: Closure to trigger UI update
func updateStatus(
messageID: String,
status: DeliveryStatus,
messages: inout [BitchatMessage],
privateChats: inout [String: [BitchatMessage]],
notifyChange: @escaping () -> Void
) {
// Update in main messages
if let index = messages.firstIndex(where: { $0.id == messageID }) {
let currentStatus = messages[index].deliveryStatus
if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) {
messages[index].deliveryStatus = status
}
}
// Update in private chats
for (peerID, chatMessages) in privateChats {
guard let index = chatMessages.firstIndex(where: { $0.id == messageID }) else { continue }
let currentStatus = chatMessages[index].deliveryStatus
guard !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) else { continue }
// Update delivery status
privateChats[peerID]?[index].deliveryStatus = status
}
// Trigger UI update
DispatchQueue.main.async {
notifyChange()
}
}
// MARK: - Private Helpers
/// Check if we should skip a status update to prevent downgrades
private func shouldSkipUpdate(currentStatus: DeliveryStatus?, newStatus: DeliveryStatus) -> Bool {
guard let current = currentStatus else { return false }
// Don't downgrade from read to delivered or sent
switch (current, newStatus) {
case (.read, .delivered):
return true
case (.read, .sent):
return true
default:
return false
}
}
}
@@ -1,10 +1,9 @@
import BitLogger
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
@@ -14,16 +13,12 @@ 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"
@@ -45,20 +40,15 @@ final class FavoritesPersistenceService: ObservableObject {
} }
.assign(to: &$mutualFavorites) .assign(to: &$mutualFavorites)
} }
deinit {
// Clean up Combine subscriptions
cancellables.removeAll()
SecureLogger.debug("FavoritesPersistenceService deinitialized", category: .session)
}
/// Add or update a favorite /// Add or update a favorite
func addFavorite( func addFavorite(
peerNoisePublicKey: Data, peerNoisePublicKey: Data,
peerNostrPublicKey: String? = nil, peerNostrPublicKey: String? = nil,
peerNickname: String peerNickname: String
) { ) {
SecureLogger.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]
@@ -74,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
@@ -92,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 {
@@ -133,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,
@@ -154,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)
} }
} }
@@ -185,18 +179,130 @@ 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 peerID.count == 16 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()
@@ -230,7 +336,7 @@ final class FavoritesPersistenceService: ObservableObject {
// 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)
} }
} }
@@ -248,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)
} }
} }
@@ -264,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 ||
@@ -305,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)
} }
} }
} }
+13 -15
View File
@@ -1,7 +1,8 @@
import BitLogger
import Foundation import Foundation
import Combine import Combine
#if os(iOS) || os(macOS)
import CoreLocation import CoreLocation
#endif
/// Stores a user-maintained list of bookmarked geohash channels. /// Stores a user-maintained list of bookmarked geohash channels.
/// - Persistence: UserDefaults (JSON string array) /// - Persistence: UserDefaults (JSON string array)
@@ -15,22 +16,15 @@ final class GeohashBookmarksStore: ObservableObject {
private let storeKey = "locationChannel.bookmarks" private let storeKey = "locationChannel.bookmarks"
private let namesStoreKey = "locationChannel.bookmarkNames" private let namesStoreKey = "locationChannel.bookmarkNames"
private var membership: Set<String> = [] private var membership: Set<String> = []
#if os(iOS) || os(macOS)
private let geocoder = CLGeocoder() private let geocoder = CLGeocoder()
private var resolving: Set<String> = [] private var resolving: Set<String> = []
#endif
private let storage: UserDefaults
init(storage: UserDefaults = .standard) { private init() {
self.storage = storage
load() load()
} }
deinit {
// Cancel any pending geocoding operations
geocoder.cancelGeocode()
SecureLogger.debug("GeohashBookmarksStore deinitialized", category: .session)
}
// MARK: - Public API // MARK: - Public API
func isBookmarked(_ geohash: String) -> Bool { func isBookmarked(_ geohash: String) -> Bool {
return membership.contains(Self.normalize(geohash)) return membership.contains(Self.normalize(geohash))
@@ -70,7 +64,7 @@ final class GeohashBookmarksStore: ObservableObject {
// MARK: - Persistence // MARK: - Persistence
private func load() { private func load() {
guard let data = storage.data(forKey: storeKey) else { return } guard let data = UserDefaults.standard.data(forKey: storeKey) else { return }
if let arr = try? JSONDecoder().decode([String].self, from: data) { if let arr = try? JSONDecoder().decode([String].self, from: data) {
// Sanitize, normalize, dedupe while preserving order (first occurrence wins) // Sanitize, normalize, dedupe while preserving order (first occurrence wins)
var seen = Set<String>() var seen = Set<String>()
@@ -87,7 +81,7 @@ final class GeohashBookmarksStore: ObservableObject {
membership = seen membership = seen
} }
// Load any saved names // Load any saved names
if let namesData = storage.data(forKey: namesStoreKey), if let namesData = UserDefaults.standard.data(forKey: namesStoreKey),
let dict = try? JSONDecoder().decode([String: String].self, from: namesData) { let dict = try? JSONDecoder().decode([String: String].self, from: namesData) {
bookmarkNames = dict bookmarkNames = dict
} }
@@ -95,13 +89,13 @@ final class GeohashBookmarksStore: ObservableObject {
private func persist() { private func persist() {
if let data = try? JSONEncoder().encode(bookmarks) { if let data = try? JSONEncoder().encode(bookmarks) {
storage.set(data, forKey: storeKey) UserDefaults.standard.set(data, forKey: storeKey)
} }
} }
private func persistNames() { private func persistNames() {
if let data = try? JSONEncoder().encode(bookmarkNames) { if let data = try? JSONEncoder().encode(bookmarkNames) {
storage.set(data, forKey: namesStoreKey) UserDefaults.standard.set(data, forKey: namesStoreKey)
} }
} }
@@ -121,6 +115,7 @@ final class GeohashBookmarksStore: ObservableObject {
let gh = Self.normalize(geohash) let gh = Self.normalize(geohash)
guard !gh.isEmpty else { return } guard !gh.isEmpty else { return }
if bookmarkNames[gh] != nil { return } if bookmarkNames[gh] != nil { return }
#if os(iOS) || os(macOS)
if resolving.contains(gh) { return } if resolving.contains(gh) { return }
resolving.insert(gh) resolving.insert(gh)
// For very coarse geohashes, sample multiple points to capture multiple admin areas // For very coarse geohashes, sample multiple points to capture multiple admin areas
@@ -151,8 +146,10 @@ final class GeohashBookmarksStore: ObservableObject {
} }
} }
} }
#endif
} }
#if os(iOS) || os(macOS)
private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) { private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) {
var uniqueAdmins = OrderedSet<String>() var uniqueAdmins = OrderedSet<String>()
var idx = 0 var idx = 0
@@ -215,6 +212,7 @@ final class GeohashBookmarksStore: ObservableObject {
return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country
} }
} }
#endif
#if DEBUG #if DEBUG
/// Testing-only reset helper /// Testing-only reset helper
@@ -1,180 +0,0 @@
//
// GeohashParticipantsService.swift
// bitchat
//
// Manages tracking of participants in geohash-based location channels
// This is free and unencumbered software released into the public domain.
//
import BitLogger
import Foundation
import Combine
/// Service for tracking and managing participants in geohash channels
/// Handles automatic expiration, refresh timers, and participant list management
final class GeohashParticipantsService: ObservableObject {
// MARK: - Published Properties
@Published private(set) var geohashPeople: [GeoPerson] = []
// MARK: - Private State
private var geoParticipants: [String: [String: Date]] = [:] // geohash -> [pubkeyHex -> lastSeen]
private var geoParticipantsTimer: Timer? = nil
private var currentGeohash: String? = nil
// MARK: - Dependencies
private let identityManager: SecureIdentityStateManagerProtocol
private let displayNameProvider: (String) -> String
// MARK: - Configuration
private let activityWindowSeconds: TimeInterval
private let refreshIntervalSeconds: TimeInterval
// MARK: - Initialization
init(
identityManager: SecureIdentityStateManagerProtocol,
displayNameProvider: @escaping (String) -> String,
activityWindowSeconds: TimeInterval = TransportConfig.uiRecentCutoffFiveMinutesSeconds,
refreshIntervalSeconds: TimeInterval = 30.0
) {
self.identityManager = identityManager
self.displayNameProvider = displayNameProvider
self.activityWindowSeconds = activityWindowSeconds
self.refreshIntervalSeconds = refreshIntervalSeconds
}
deinit {
// Note: deinit cannot call @MainActor methods
// Timer cleanup will happen automatically when service is deallocated
SecureLogger.debug("GeohashParticipantsService deinitialized", category: .session)
}
// MARK: - Public API
/// Set the current geohash being tracked (starts/stops timer accordingly)
func setCurrentGeohash(_ geohash: String?) {
if currentGeohash != geohash {
currentGeohash = geohash
refreshPeopleList()
if geohash != nil {
startTimer()
} else {
stopTimer()
}
}
}
/// Record a participant activity in the current geohash
func recordParticipant(pubkeyHex: String) {
guard let gh = currentGeohash else { return }
recordParticipant(pubkeyHex: pubkeyHex, geohash: gh)
}
/// Record a participant activity in a specific geohash
func recordParticipant(pubkeyHex: String, geohash: String) {
let key = pubkeyHex.lowercased()
var map = geoParticipants[geohash] ?? [:]
map[key] = Date()
geoParticipants[geohash] = map
// Only refresh list if this geohash is currently selected
if currentGeohash == geohash {
refreshPeopleList()
}
}
/// Get visible people for the current geohash (without mutating state)
func visiblePeople() -> [GeoPerson] {
guard let gh = currentGeohash else { return [] }
return visiblePeople(for: gh)
}
/// Get visible people for a specific geohash
func visiblePeople(for geohash: String) -> [GeoPerson] {
let cutoff = Date().addingTimeInterval(-activityWindowSeconds)
let map = (geoParticipants[geohash] ?? [:])
.filter { $0.value >= cutoff }
.filter { !identityManager.isNostrBlocked(pubkeyHexLowercased: $0.key) }
let people = map
.map { (pub, seen) in
GeoPerson(id: pub, displayName: displayNameProvider(pub), lastSeen: seen)
}
.sorted { $0.lastSeen > $1.lastSeen }
return people
}
/// Get participant count for a specific geohash (using activity window)
func participantCount(for geohash: String) -> Int {
let cutoff = Date().addingTimeInterval(-activityWindowSeconds)
let map = geoParticipants[geohash] ?? [:]
return map.values.filter { $0 >= cutoff }.count
}
/// Remove a participant from all geohashes (e.g., when blocked)
func removeParticipant(pubkeyHexLowercased: String) {
let hex = pubkeyHexLowercased.lowercased()
for (gh, var map) in geoParticipants {
map.removeValue(forKey: hex)
geoParticipants[gh] = map
}
refreshPeopleList()
}
/// Clear all participant data (for testing or reset)
func reset() {
stopTimer()
geoParticipants.removeAll()
geohashPeople.removeAll()
currentGeohash = nil
}
// MARK: - Private Helpers
private func refreshPeopleList() {
guard let gh = currentGeohash else {
geohashPeople = []
return
}
let cutoff = Date().addingTimeInterval(-activityWindowSeconds)
var map = geoParticipants[gh] ?? [:]
// Prune expired entries
map = map.filter { $0.value >= cutoff }
// Remove blocked Nostr pubkeys
map = map.filter { !identityManager.isNostrBlocked(pubkeyHexLowercased: $0.key) }
// Update cleaned map
geoParticipants[gh] = map
// Build display list
let people = map
.map { (pub, seen) in
GeoPerson(id: pub, displayName: displayNameProvider(pub), lastSeen: seen)
}
.sorted { $0.lastSeen > $1.lastSeen }
geohashPeople = people
}
private func startTimer() {
stopTimer()
geoParticipantsTimer = Timer.scheduledTimer(withTimeInterval: refreshIntervalSeconds, repeats: true) { [weak self] _ in
Task { @MainActor in
self?.refreshPeopleList()
}
}
}
private func stopTimer() {
geoParticipantsTimer?.invalidate()
geoParticipantsTimer = nil
}
}
+18 -26
View File
@@ -6,26 +6,19 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import BitLogger
import Foundation import Foundation
import Security import Security
import os.log
protocol KeychainManagerProtocol { class KeychainManager {
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool static let shared = KeychainManager()
func getIdentityKey(forKey key: String) -> Data?
func deleteIdentityKey(forKey key: String) -> Bool
func deleteAllKeychainData() -> Bool
func secureClear(_ data: inout Data)
func secureClear(_ string: inout String)
func verifyIdentityKeyExists() -> Bool
}
final class KeychainManager: KeychainManagerProtocol {
// Use consistent service name for all keychain items // Use consistent service name for all keychain items
private let service = 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 { private func isSandboxed() -> Bool {
#if os(macOS) #if os(macOS)
@@ -60,7 +53,7 @@ final class KeychainManager: KeychainManagerProtocol {
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
} }
@@ -71,7 +64,7 @@ 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
} }
@@ -120,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
} }
@@ -158,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
} }
@@ -205,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
@@ -268,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)
} }
} }
} }
@@ -282,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"
@@ -311,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
} }
@@ -319,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)
@@ -328,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)
@@ -1,4 +1,3 @@
import BitLogger
import Foundation import Foundation
import Combine import Combine
@@ -198,7 +197,8 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
// Surface as denied/restricted if relevant; otherwise keep previous state // Surface as denied/restricted if relevant; otherwise keep previous state
SecureLogger.error("LocationChannelManager: location error: \(error.localizedDescription)", category: .session) SecureLogger.log("LocationChannelManager: location error: \(error.localizedDescription)",
category: SecureLogger.session, level: .error)
} }
// MARK: - Helpers // MARK: - Helpers
@@ -286,18 +286,12 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
} else if let locality = pm.locality, !locality.isEmpty { } else if let locality = pm.locality, !locality.isEmpty {
dict[.neighborhood] = locality dict[.neighborhood] = locality
} }
// Block: reuse neighborhood/locality granularity // Block: reuse neighborhood/locality granularity without exposing street level
if let subLocality = pm.subLocality, !subLocality.isEmpty { if let subLocality = pm.subLocality, !subLocality.isEmpty {
dict[.block] = subLocality dict[.block] = subLocality
} else if let locality = pm.locality, !locality.isEmpty { } else if let locality = pm.locality, !locality.isEmpty {
dict[.block] = locality dict[.block] = locality
} }
// Building: prefer place name/street/venue when available
if let name = pm.name, !name.isEmpty {
dict[.building] = name
} else if let thoroughfare = pm.thoroughfare, !thoroughfare.isEmpty {
dict[.building] = thoroughfare
}
return dict return dict
} }
} }
-110
View File
@@ -1,110 +0,0 @@
import BitLogger
import Foundation
struct LocationNotesCounterDependencies {
typealias RelayLookup = @MainActor (_ geohash: String, _ count: Int) -> [String]
typealias Subscribe = @MainActor (_ filter: NostrFilter, _ id: String, _ relays: [String], _ handler: @escaping (NostrEvent) -> Void, _ onEOSE: (() -> Void)?) -> Void
typealias Unsubscribe = @MainActor (_ id: String) -> Void
var relayLookup: RelayLookup
var subscribe: Subscribe
var unsubscribe: Unsubscribe
static let live = LocationNotesCounterDependencies(
relayLookup: { geohash, count in
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
},
subscribe: { filter, id, relays, handler, onEOSE in
NostrRelayManager.shared.subscribe(
filter: filter,
id: id,
relayUrls: relays,
handler: handler,
onEOSE: onEOSE
)
},
unsubscribe: { id in
NostrRelayManager.shared.unsubscribe(id: id)
}
)
}
/// Lightweight background counter for location notes (kind 1) at building-level geohash (8 chars).
@MainActor
final class LocationNotesCounter: ObservableObject {
static let shared = LocationNotesCounter()
@Published private(set) var geohash: String? = nil
@Published private(set) var count: Int? = 0
@Published private(set) var initialLoadComplete: Bool = false
@Published private(set) var relayAvailable: Bool = true
private var subscriptionID: String? = nil
private var noteIDs = Set<String>()
private let dependencies: LocationNotesCounterDependencies
private init(dependencies: LocationNotesCounterDependencies = .live) {
self.dependencies = dependencies
}
init(testDependencies: LocationNotesCounterDependencies) {
self.dependencies = testDependencies
}
deinit {
// Note: deinit cannot call @MainActor functions
// Subscription cleanup will happen automatically when counter is deallocated
SecureLogger.debug("LocationNotesCounter deinitialized", category: .session)
}
func subscribe(geohash gh: String) {
let norm = gh.lowercased()
if geohash == norm, subscriptionID != nil { return }
// Validate geohash (building-level precision: 8 chars)
guard Geohash.isValidBuildingGeohash(norm) else {
SecureLogger.warning("LocationNotesCounter: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
return
}
// Unsubscribe previous without clearing count to avoid flicker
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
subscriptionID = nil
geohash = norm
noteIDs.removeAll()
initialLoadComplete = false
relayAvailable = true
// Subscribe only to the building geohash (precision 8)
let subID = "locnotes-count-\(norm)-\(UUID().uuidString.prefix(6))"
let relays = dependencies.relayLookup(norm, TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else {
relayAvailable = false
initialLoadComplete = true
count = 0
SecureLogger.warning("LocationNotesCounter: no geo relays for geohash=\(norm)", category: .session)
return
}
subscriptionID = subID
let filter = NostrFilter.geohashNotes(norm, since: nil, limit: 200)
dependencies.subscribe(filter, subID, relays, { [weak self] event in
guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == norm }) else { return }
if !self.noteIDs.contains(event.id) {
self.noteIDs.insert(event.id)
self.count = self.noteIDs.count
}
}, { [weak self] in
self?.initialLoadComplete = true
})
}
func cancel() {
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
subscriptionID = nil
geohash = nil
count = 0
noteIDs.removeAll()
relayAvailable = true
}
}
-253
View File
@@ -1,253 +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
static let live = LocationNotesDependencies(
relayLookup: { geohash, count in
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
},
subscribe: { filter, id, relays, handler, onEOSE in
NostrRelayManager.shared.subscribe(
filter: filter,
id: id,
relayUrls: relays,
handler: handler,
onEOSE: onEOSE
)
},
unsubscribe: { id in
NostrRelayManager.shared.unsubscribe(id: id)
},
sendEvent: { event, relays in
NostrRelayManager.shared.sendEvent(event, to: relays)
},
deriveIdentity: { geohash in
try NostrIdentityBridge.deriveIdentity(forGeohash: geohash)
},
now: { Date() }
)
}
/// Persistent location notes (Nostr kind 1) scoped to a building-level geohash (precision 8).
/// Subscribes to and publishes notes for a given geohash and provides a send API.
@MainActor
final class LocationNotesManager: ObservableObject {
enum State: Equatable {
case idle
case loading
case ready
case noRelays
}
struct Note: Identifiable, Equatable {
let id: String
let pubkey: String
let content: String
let createdAt: Date
let nickname: String?
var displayName: String {
let suffix = String(pubkey.suffix(4))
if let nick = nickname, !nick.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return "\(nick)#\(suffix)"
}
return "anon#\(suffix)"
}
}
@Published private(set) var notes: [Note] = [] // reverse-chron sorted
@Published private(set) var geohash: String
@Published private(set) var initialLoadComplete: Bool = false
@Published private(set) var state: State = .loading
@Published private(set) var errorMessage: String?
private var subscriptionID: String?
private var noteIDs = Set<String>() // O(1) duplicate detection
private let dependencies: LocationNotesDependencies
private let maxNotesInMemory = 500 // Defensive cap (relay limit is 200)
private enum Strings {
static let noRelays = String(localized: "location_notes.error.no_relays", comment: "Shown when no geo relays are available near the selected location")
static func failedToSend(_ detail: String) -> String {
String(
format: String(localized: "location_notes.error.failed_to_send", comment: "Shown when a location note fails to send"),
locale: .current,
detail
)
}
}
init(geohash: String, dependencies: LocationNotesDependencies = .live) {
let norm = geohash.lowercased()
self.geohash = norm
self.dependencies = dependencies
// Validate geohash (building-level precision: 8 chars)
if !Geohash.isValidBuildingGeohash(norm) {
SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
}
subscribe()
}
deinit {
// Note: deinit cannot call @MainActor functions
// Subscription cleanup will happen automatically when manager is deallocated
SecureLogger.debug("LocationNotesManager deinitialized", category: .session)
}
func setGeohash(_ newGeohash: String) {
let norm = newGeohash.lowercased()
guard norm != geohash else { return }
// Validate geohash (building-level precision: 8 chars)
guard Geohash.isValidBuildingGeohash(norm) else {
SecureLogger.warning("LocationNotesManager: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
return
}
if let sub = subscriptionID {
dependencies.unsubscribe(sub)
subscriptionID = nil
}
// Set loading state before clearing to prevent empty state flicker
state = .loading
initialLoadComplete = false
errorMessage = nil
geohash = norm
notes.removeAll()
noteIDs.removeAll()
subscribe()
}
func refresh() {
if let sub = subscriptionID {
dependencies.unsubscribe(sub)
subscriptionID = nil
}
// Set loading state before clearing to prevent empty state flicker
state = .loading
initialLoadComplete = false
errorMessage = nil
notes.removeAll()
noteIDs.removeAll()
subscribe()
}
func clearError() {
errorMessage = nil
}
private func subscribe() {
state = .loading
errorMessage = nil
if let sub = subscriptionID {
dependencies.unsubscribe(sub)
subscriptionID = nil
}
let subID = "locnotes-\(geohash)-\(UUID().uuidString.prefix(8))"
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else {
subscriptionID = nil
initialLoadComplete = true
state = .noRelays
errorMessage = Strings.noRelays
SecureLogger.warning("LocationNotesManager: no geo relays for geohash=\(geohash)", category: .session)
return
}
subscriptionID = subID
initialLoadComplete = false
// For persistent notes, allow relays to return recent history without an aggressive time cutoff
let filter = NostrFilter.geohashNotes(geohash, since: nil, limit: 200)
dependencies.subscribe(filter, subID, relays, { [weak self] event in
guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
// Ensure matching tag
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == self.geohash }) else { return }
guard !self.noteIDs.contains(event.id) else { return }
self.noteIDs.insert(event.id)
let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first
let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick)
self.notes.append(note)
self.notes.sort { $0.createdAt > $1.createdAt }
self.enforceMemoryCap()
self.state = .ready
}, { [weak self] in
guard let self = self else { return }
self.initialLoadComplete = true
if self.state != .noRelays {
self.state = .ready
}
})
}
/// Send a location note for the current geohash using the per-geohash identity.
func send(content: String, nickname: String) {
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else {
state = .noRelays
errorMessage = Strings.noRelays
SecureLogger.warning("LocationNotesManager: send blocked, no geo relays for geohash=\(geohash)", category: .session)
return
}
do {
let id = try dependencies.deriveIdentity(geohash)
let event = try NostrProtocol.createGeohashTextNote(
content: trimmed,
geohash: geohash,
senderIdentity: id,
nickname: nickname
)
dependencies.sendEvent(event, relays)
// Optimistic local-echo
let echo = Note(
id: event.id,
pubkey: id.publicKeyHex,
content: trimmed,
createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)),
nickname: nickname
)
self.noteIDs.insert(event.id)
self.notes.insert(echo, at: 0)
self.enforceMemoryCap()
self.state = .ready
self.errorMessage = nil
} catch {
SecureLogger.error("LocationNotesManager: failed to send note: \(error)", category: .session)
errorMessage = Strings.failedToSend(error.localizedDescription)
}
}
/// Enforces defensive memory cap on notes array (keeps newest).
private func enforceMemoryCap() {
if notes.count > maxNotesInMemory {
let removed = notes.count - maxNotesInMemory
notes = Array(notes.prefix(maxNotesInMemory))
SecureLogger.debug("LocationNotesManager: trimmed \(removed) old notes (cap: \(maxNotesInMemory))", category: .session)
}
}
/// Explicitly cancel subscription and release resources.
func cancel() {
if let sub = subscriptionID {
dependencies.unsubscribe(sub)
subscriptionID = nil
}
state = .idle
errorMessage = nil
}
}
@@ -1,618 +0,0 @@
//
// MessageFormattingService.swift
// bitchat
//
// Service for formatting chat messages with syntax highlighting
// This is free and unencumbered software released into the public domain.
//
import Foundation
import SwiftUI
/// Service that formats BitchatMessages into styled AttributedStrings
/// Handles hashtags, mentions, links, payment tokens, and more
final class MessageFormattingService {
// MARK: - Precompiled Regexes
private enum Regexes {
static let hashtag: NSRegularExpression = {
try! NSRegularExpression(pattern: "#([a-zA-Z0-9_]+)", options: [])
}()
static let mention: NSRegularExpression = {
try! NSRegularExpression(pattern: "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)", options: [])
}()
static let cashu: NSRegularExpression = {
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
}()
static let bolt11: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: [])
}()
static let lnurl: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: [])
}()
static let lightningScheme: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: [])
}()
static let linkDetector: NSDataDetector? = {
try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
}()
static let quickCashuPresence: NSRegularExpression = {
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
}()
}
// MARK: - Dependencies
private let colorPalette: ColorPaletteService
// MARK: - Initialization
init(colorPalette: ColorPaletteService) {
self.colorPalette = colorPalette
}
// MARK: - Public API
/// Format a message with full syntax highlighting (hashtags, mentions, links, payments)
/// This is the primary formatter used in the main chat view
func formatMessageAsText(
_ message: BitchatMessage,
colorScheme: ColorScheme,
nickname: String,
myPeerID: String,
myNostrPubkey: String?,
activeChannel: ChannelID,
nostrKeyMapping: [String: String],
allPeers: [BitchatPeer],
geohashPeople: [GeoPerson],
getNoiseKeyForShortID: @escaping (String) -> String?
) -> AttributedString {
// Determine if this message was sent by self
let isSelf = isSelfMessage(
message,
nickname: nickname,
myPeerID: myPeerID,
myNostrPubkey: myNostrPubkey,
activeChannel: activeChannel
)
// Check cache first
let isDark = colorScheme == .dark
if let cachedText = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf) {
return cachedText
}
// Not cached, format the message
var result = AttributedString()
let baseColor: Color = isSelf ? .orange : colorPalette.peerColor(
for: message,
isDark: isDark,
myPeerID: myPeerID,
myNostrPubkey: myNostrPubkey,
nostrKeyMapping: nostrKeyMapping,
allPeers: allPeers,
geohashPeople: geohashPeople.map { (id: $0.id, seed: "nostr:" + $0.id) },
getNoiseKeyForShortID: getNoiseKeyForShortID
)
if message.sender != "system" {
// Sender (at the beginning) with light-gray suffix styling if present
let (baseName, suffix) = message.sender.splitSuffix()
var senderStyle = AttributeContainer()
senderStyle.foregroundColor = baseColor
let fontWeight: Font.Weight = isSelf ? .bold : .medium
senderStyle.font = .bitchatSystem(size: 14, weight: fontWeight, design: .monospaced)
// Make sender clickable: encode senderPeerID into a custom URL
if let spid = message.senderPeerID?.id,
let url = URL(string: "bitchat://user/\(spid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid)") {
senderStyle.link = url
}
// Format: <@name#suffix>
result.append(AttributedString("<@").mergingAttributes(senderStyle))
result.append(AttributedString(baseName).mergingAttributes(senderStyle))
if !suffix.isEmpty {
var suffixStyle = senderStyle
suffixStyle.foregroundColor = baseColor.opacity(0.6)
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
}
result.append(AttributedString("> ").mergingAttributes(senderStyle))
// Process content with syntax highlighting
let content = message.content
let nsContent = content as NSString
let nsLen = nsContent.length
// Check for Cashu presence early to decide rendering strategy
let containsCashuEarly = Regexes.quickCashuPresence.numberOfMatches(
in: content,
options: [],
range: NSRange(location: 0, length: nsLen)
) > 0
// For extremely long content, render as plain text (unless has Cashu)
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly {
var plainStyle = AttributeContainer()
plainStyle.foregroundColor = baseColor
plainStyle.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
: .bitchatSystem(size: 14, design: .monospaced)
result.append(AttributedString(content).mergingAttributes(plainStyle))
} else {
// Full syntax highlighting
result.append(formatContent(
content,
nsContent: nsContent,
nsLen: nsLen,
message: message,
baseColor: baseColor,
isSelf: isSelf,
isDark: isDark,
nickname: nickname,
myPeerID: myPeerID,
myNostrPubkey: myNostrPubkey,
activeChannel: activeChannel
))
}
// Add timestamp
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
var timestampStyle = AttributeContainer()
timestampStyle.foregroundColor = Color.gray.opacity(0.7)
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
result.append(timestamp.mergingAttributes(timestampStyle))
} else {
// System message
var contentStyle = AttributeContainer()
contentStyle.foregroundColor = Color.gray
let content = AttributedString("* \(message.content) *")
contentStyle.font = .bitchatSystem(size: 12, design: .monospaced).italic()
result.append(content.mergingAttributes(contentStyle))
// Add timestamp
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
var timestampStyle = AttributeContainer()
timestampStyle.foregroundColor = Color.gray.opacity(0.5)
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
result.append(timestamp.mergingAttributes(timestampStyle))
}
// Cache the formatted text
message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf)
return result
}
/// Simpler message formatter (used in legacy contexts)
func formatMessage(
_ message: BitchatMessage,
colorScheme: ColorScheme,
nickname: String
) -> AttributedString {
var result = AttributedString()
let isDark = colorScheme == .dark
let primaryColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
if message.sender == "system" {
let content = AttributedString("* \(message.content) *")
var contentStyle = AttributeContainer()
contentStyle.foregroundColor = Color.gray
contentStyle.font = .bitchatSystem(size: 12, design: .monospaced).italic()
result.append(content.mergingAttributes(contentStyle))
// Add timestamp
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
var timestampStyle = AttributeContainer()
timestampStyle.foregroundColor = Color.gray.opacity(0.5)
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
result.append(timestamp.mergingAttributes(timestampStyle))
} else {
let sender = AttributedString("<@\(message.sender)> ")
var senderStyle = AttributeContainer()
senderStyle.foregroundColor = primaryColor
let fontWeight: Font.Weight = message.sender == nickname ? .bold : .medium
senderStyle.font = .bitchatSystem(size: 12, weight: fontWeight, design: .monospaced)
result.append(sender.mergingAttributes(senderStyle))
// Process content to highlight mentions
let contentText = message.content
let pattern = "@([\\p{L}0-9_]+)"
let regex = try? NSRegularExpression(pattern: pattern, options: [])
let nsContent = contentText as NSString
let nsLen = nsContent.length
let matches = regex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
var processedContent = AttributedString()
var lastEndIndex = contentText.startIndex
for match in matches {
if let range = Range(match.range(at: 0), in: contentText) {
// Add text before mention
if lastEndIndex < range.lowerBound {
let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
if !beforeText.isEmpty {
var normalStyle = AttributeContainer()
normalStyle.font = .bitchatSystem(size: 14, design: .monospaced)
normalStyle.foregroundColor = isDark ? Color.white : Color.black
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
}
}
// Add the mention with highlight
let mentionText = String(contentText[range])
var mentionStyle = AttributeContainer()
mentionStyle.font = .bitchatSystem(size: 14, weight: .semibold, design: .monospaced)
mentionStyle.foregroundColor = Color.orange
processedContent.append(AttributedString(mentionText).mergingAttributes(mentionStyle))
if lastEndIndex < range.upperBound { lastEndIndex = range.upperBound }
}
}
// Add remaining text
if lastEndIndex < contentText.endIndex {
let remainingText = String(contentText[lastEndIndex...])
var normalStyle = AttributeContainer()
normalStyle.font = .bitchatSystem(size: 14, design: .monospaced)
normalStyle.foregroundColor = isDark ? Color.white : Color.black
processedContent.append(AttributedString(remainingText).mergingAttributes(normalStyle))
}
result.append(processedContent)
if message.isRelay, let originalSender = message.originalSender {
let relay = AttributedString(" (via \(originalSender))")
var relayStyle = AttributeContainer()
relayStyle.foregroundColor = primaryColor.opacity(0.7)
relayStyle.font = .bitchatSystem(size: 11, design: .monospaced)
result.append(relay.mergingAttributes(relayStyle))
}
// Add timestamp
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
var timestampStyle = AttributeContainer()
timestampStyle.foregroundColor = Color.gray.opacity(0.7)
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
result.append(timestamp.mergingAttributes(timestampStyle))
}
return result
}
// MARK: - Private Helpers
private func isSelfMessage(
_ message: BitchatMessage,
nickname: String,
myPeerID: String,
myNostrPubkey: String?,
activeChannel: ChannelID
) -> Bool {
if let spid = message.senderPeerID?.id {
// In geohash channels, compare against our per-geohash nostr short ID
if case .location = activeChannel, spid.hasPrefix("nostr:"),
let myGeo = myNostrPubkey {
return spid == "nostr:\(myGeo.prefix(TransportConfig.nostrShortKeyDisplayLength))"
}
return spid == myPeerID
}
// Fallback by nickname
if message.sender == nickname { return true }
if message.sender.hasPrefix(nickname + "#") { return true }
return false
}
private func formatContent(
_ content: String,
nsContent: NSString,
nsLen: Int,
message: BitchatMessage,
baseColor: Color,
isSelf: Bool,
isDark: Bool,
nickname: String,
myPeerID: String,
myNostrPubkey: String?,
activeChannel: ChannelID
) -> AttributedString {
// Extract all matches
let hasMentionsHint = content.contains("@")
let hasHashtagsHint = content.contains("#")
let hasURLHint = content.contains("://") || content.contains("www.") || content.contains("http")
let hasLightningHint = content.lowercased().contains("ln") || content.lowercased().contains("lightning:")
let hasCashuHint = content.lowercased().contains("cashu")
let hashtagMatches = hasHashtagsHint ? Regexes.hashtag.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
let mentionMatches = hasMentionsHint ? Regexes.mention.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
let urlMatches = hasURLHint ? (Regexes.linkDetector?.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) ?? []) : []
let cashuMatches = hasCashuHint ? Regexes.cashu.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
let lightningMatches = hasLightningHint ? Regexes.lightningScheme.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
let bolt11Matches = hasLightningHint ? Regexes.bolt11.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
let lnurlMatches = hasLightningHint ? Regexes.lnurl.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
// Combine and sort matches, excluding hashtags/URLs overlapping mentions
let mentionRanges = mentionMatches.map { $0.range(at: 0) }
func overlapsMention(_ r: NSRange) -> Bool {
for mr in mentionRanges {
if NSIntersectionRange(r, mr).length > 0 { return true }
}
return false
}
func attachedToMention(_ r: NSRange) -> Bool {
if let nsRange = Range(r, in: content), nsRange.lowerBound > content.startIndex {
var i = content.index(before: nsRange.lowerBound)
while true {
let ch = content[i]
if ch.isWhitespace || ch.isNewline { break }
if ch == "@" { return true }
if i == content.startIndex { break }
i = content.index(before: i)
}
}
return false
}
func isStandaloneHashtag(_ r: NSRange) -> Bool {
guard let nsRange = Range(r, in: content) else { return false }
if nsRange.lowerBound == content.startIndex { return true }
let prev = content.index(before: nsRange.lowerBound)
return content[prev].isWhitespace || content[prev].isNewline
}
var allMatches: [(range: NSRange, type: String)] = []
for match in hashtagMatches where !overlapsMention(match.range(at: 0)) && !attachedToMention(match.range(at: 0)) && isStandaloneHashtag(match.range(at: 0)) {
allMatches.append((match.range(at: 0), "hashtag"))
}
for match in mentionMatches {
allMatches.append((match.range(at: 0), "mention"))
}
for match in urlMatches where !overlapsMention(match.range) {
allMatches.append((match.range, "url"))
}
for match in cashuMatches where !overlapsMention(match.range(at: 0)) {
allMatches.append((match.range(at: 0), "cashu"))
}
for match in lightningMatches where !overlapsMention(match.range(at: 0)) {
allMatches.append((match.range(at: 0), "lightning"))
}
// Exclude overlaps with lightning/url for bolt11/lnurl
let occupied: [NSRange] = urlMatches.map { $0.range } + lightningMatches.map { $0.range(at: 0) }
func overlapsOccupied(_ r: NSRange) -> Bool {
for or in occupied {
if NSIntersectionRange(r, or).length > 0 { return true }
}
return false
}
for match in bolt11Matches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
allMatches.append((match.range(at: 0), "bolt11"))
}
for match in lnurlMatches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
allMatches.append((match.range(at: 0), "lnurl"))
}
allMatches.sort { $0.range.location < $1.range.location }
// Build content with styling
var processedContent = AttributedString()
var lastEnd = content.startIndex
let isMentioned = message.mentions?.contains(nickname) ?? false
for (range, type) in allMatches {
if let nsRange = Range(range, in: content) {
// Add text before match
if lastEnd < nsRange.lowerBound {
let beforeText = String(content[lastEnd..<nsRange.lowerBound])
if !beforeText.isEmpty {
var beforeStyle = AttributeContainer()
beforeStyle.foregroundColor = baseColor
beforeStyle.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
: .bitchatSystem(size: 14, design: .monospaced)
if isMentioned {
beforeStyle.font = beforeStyle.font?.bold()
}
processedContent.append(AttributedString(beforeText).mergingAttributes(beforeStyle))
}
}
// Add styled match
let matchText = String(content[nsRange])
processedContent.append(formatMatch(
matchText,
type: type,
baseColor: baseColor,
isSelf: isSelf,
isDark: isDark,
nickname: nickname,
myPeerID: myPeerID,
myNostrPubkey: myNostrPubkey,
activeChannel: activeChannel
))
lastEnd = nsRange.upperBound
}
}
// Add remaining text after last match
if lastEnd < content.endIndex {
let remainingText = String(content[lastEnd...])
var remainingStyle = AttributeContainer()
remainingStyle.foregroundColor = baseColor
remainingStyle.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
: .bitchatSystem(size: 14, design: .monospaced)
if isMentioned {
remainingStyle.font = remainingStyle.font?.bold()
}
processedContent.append(AttributedString(remainingText).mergingAttributes(remainingStyle))
}
return processedContent
}
private func formatMatch(
_ matchText: String,
type: String,
baseColor: Color,
isSelf: Bool,
isDark: Bool,
nickname: String,
myPeerID: String,
myNostrPubkey: String?,
activeChannel: ChannelID
) -> AttributedString {
switch type {
case "mention":
return formatMention(
matchText,
baseColor: baseColor,
isSelf: isSelf,
nickname: nickname,
myPeerID: myPeerID,
myNostrPubkey: myNostrPubkey,
activeChannel: activeChannel
)
case "hashtag":
return formatHashtag(matchText, isDark: isDark, baseColor: baseColor, activeChannel: activeChannel)
case "url":
return formatURL(matchText, baseColor: baseColor, isSelf: isSelf)
case "cashu", "bolt11", "lnurl", "lightning":
return formatPayment(matchText, type: type, baseColor: baseColor, isSelf: isSelf)
default:
return AttributedString(matchText)
}
}
private func formatMention(
_ matchText: String,
baseColor: Color,
isSelf: Bool,
nickname: String,
myPeerID: String,
myNostrPubkey: String?,
activeChannel: ChannelID
) -> AttributedString {
// Split optional '#abcd' suffix and color suffix light grey
let (mBase, mSuffix) = matchText.splitSuffix()
// Determine if this mention targets me
let mySuffix: String? = {
if case .location = activeChannel, let myGeo = myNostrPubkey {
return String(myGeo.suffix(4))
}
return String(myPeerID.prefix(4))
}()
let isMentionToMe: Bool = {
if mBase == nickname {
if let suf = mySuffix, !mSuffix.isEmpty {
return mSuffix == "#\(suf)"
}
return mSuffix.isEmpty
}
return false
}()
var mentionStyle = AttributeContainer()
mentionStyle.font = .bitchatSystem(size: 14, weight: .semibold, design: .monospaced)
mentionStyle.foregroundColor = isMentionToMe ? .orange : baseColor
var result = AttributedString()
result.append(AttributedString(mBase).mergingAttributes(mentionStyle))
if !mSuffix.isEmpty {
var suffixStyle = mentionStyle
suffixStyle.foregroundColor = (isMentionToMe ? Color.orange : baseColor).opacity(0.5)
result.append(AttributedString(mSuffix).mergingAttributes(suffixStyle))
}
return result
}
private func formatHashtag(
_ matchText: String,
isDark: Bool,
baseColor: Color,
activeChannel: ChannelID
) -> AttributedString {
var hashtagStyle = AttributeContainer()
hashtagStyle.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
// Determine if this hashtag represents the active channel
let isActiveChannel: Bool = {
if matchText.count > 1 {
let tag = String(matchText.dropFirst()) // Remove '#'
switch activeChannel {
case .mesh:
return tag.lowercased() == "mesh"
case .location(let ch):
return tag.lowercased() == ch.geohash.lowercased()
}
}
return false
}()
if isActiveChannel {
// Highlight active channel hashtag in green
hashtagStyle.foregroundColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
hashtagStyle.font = .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
} else {
// Link to geohash if valid
if matchText.count > 1 {
let tag = String(matchText.dropFirst())
if tag.count >= 2, tag.count <= 12,
tag.allSatisfy({ "0123456789bcdefghjkmnpqrstuvwxyz".contains($0) }) {
if let url = URL(string: "bitchat://geohash/\(tag)") {
hashtagStyle.link = url
}
}
}
hashtagStyle.foregroundColor = baseColor.opacity(0.8)
}
return AttributedString(matchText).mergingAttributes(hashtagStyle)
}
private func formatURL(_ matchText: String, baseColor: Color, isSelf: Bool) -> AttributedString {
var urlStyle = AttributeContainer()
if let url = URL(string: matchText) {
urlStyle.link = url
}
urlStyle.foregroundColor = baseColor
urlStyle.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
: .bitchatSystem(size: 14, design: .monospaced)
urlStyle.underlineStyle = .single
return AttributedString(matchText).mergingAttributes(urlStyle)
}
private func formatPayment(
_ matchText: String,
type: String,
baseColor: Color,
isSelf: Bool
) -> AttributedString {
var paymentStyle = AttributeContainer()
paymentStyle.foregroundColor = baseColor
paymentStyle.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
: .bitchatSystem(size: 14, design: .monospaced)
// Make payment tokens tappable
if type == "cashu", let url = URL(string: "cashu:\(matchText)") {
paymentStyle.link = url
} else if type == "lightning" || type == "bolt11" || type == "lnurl" {
if let url = URL(string: matchText.lowercased().hasPrefix("lightning:") ? matchText : "lightning:\(matchText)") {
paymentStyle.link = url
}
}
return AttributedString(matchText).mergingAttributes(paymentStyle)
}
}
+29 -22
View File
@@ -1,4 +1,3 @@
import BitLogger
import Foundation import Foundation
/// Routes messages between BLE and Nostr transports /// Routes messages between BLE and Nostr transports
@@ -6,7 +5,7 @@ import Foundation
final class MessageRouter { final class MessageRouter {
private let mesh: Transport private let mesh: Transport
private let nostr: NostrTransport private let nostr: NostrTransport
private var outbox: [PeerID: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages private var outbox: [String: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
init(mesh: Transport, nostr: NostrTransport) { init(mesh: Transport, nostr: NostrTransport) {
self.mesh = mesh self.mesh = mesh
@@ -21,7 +20,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)
} }
@@ -29,7 +28,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)
} }
@@ -37,45 +36,50 @@ final class MessageRouter {
} }
} }
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
let reachableMesh = mesh.isPeerReachable(peerID) let reachableMesh = mesh.isPeerReachable(peerID)
if reachableMesh { if reachableMesh {
SecureLogger.debug("Routing PM via mesh (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session) 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 // BLEService will initiate a handshake if needed and queue the message
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
} else if canSendViaNostr(peerID: peerID) { } else if canSendViaNostr(peerID: peerID) {
SecureLogger.debug("Routing PM via Nostr to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session) SecureLogger.log("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))",
category: SecureLogger.session, level: .debug)
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
} else { } else {
// Queue for later (when mesh connects or Nostr mapping appears) // 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)) outbox[peerID]?.append((content, recipientNickname, messageID))
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))", category: .session) SecureLogger.log("Queued PM for \(peerID.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))",
category: SecureLogger.session, level: .debug)
} }
} }
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
// Prefer mesh for reachable peers; BLE will queue if handshake is needed // Prefer mesh for reachable peers; BLE will queue if handshake is needed
if mesh.isPeerReachable(peerID) { if mesh.isPeerReachable(peerID) {
SecureLogger.debug("Routing READ ack via mesh (reachable) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", category: .session) SecureLogger.log("Routing READ ack via mesh (reachable) to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))",
category: SecureLogger.session, level: .debug)
mesh.sendReadReceipt(receipt, to: peerID) mesh.sendReadReceipt(receipt, to: peerID)
} else { } else {
SecureLogger.debug("Routing READ ack via Nostr to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", category: .session) 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) nostr.sendReadReceipt(receipt, to: peerID)
} }
} }
func sendDeliveryAck(_ messageID: String, to peerID: PeerID) { func sendDeliveryAck(_ messageID: String, to peerID: String) {
if mesh.isPeerReachable(peerID) { if mesh.isPeerReachable(peerID) {
SecureLogger.debug("Routing DELIVERED ack via mesh (reachable) 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))",
category: SecureLogger.session, level: .debug)
mesh.sendDeliveryAck(for: messageID, to: peerID) mesh.sendDeliveryAck(for: messageID, to: peerID)
} else { } else {
nostr.sendDeliveryAck(for: messageID, to: peerID) nostr.sendDeliveryAck(for: messageID, to: peerID)
} }
} }
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
// Route via mesh when connected; else use Nostr
if mesh.isPeerConnected(peerID) { if mesh.isPeerConnected(peerID) {
mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
} else { } else {
@@ -84,16 +88,16 @@ final class MessageRouter {
} }
// MARK: - Outbox Management // MARK: - Outbox Management
private func canSendViaNostr(peerID: PeerID) -> Bool { private func canSendViaNostr(peerID: String) -> Bool {
// Two forms are supported: // Two forms are supported:
// - 64-hex Noise public key (32 bytes) // - 64-hex Noise public key (32 bytes)
// - 16-hex short peer ID (derived from Noise pubkey) // - 16-hex short peer ID (derived from Noise pubkey)
if let noiseKey = peerID.noiseKey { if peerID.count == 64, let noiseKey = Data(hexString: peerID) {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
fav.peerNostrPublicKey != nil { fav.peerNostrPublicKey != nil {
return true return true
} }
} else if peerID.isShort { } else if peerID.count == 16 {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID), if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
fav.peerNostrPublicKey != nil { fav.peerNostrPublicKey != nil {
return true return true
@@ -102,17 +106,20 @@ final class MessageRouter {
return false 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)
var remaining: [(content: String, nickname: String, messageID: String)] = [] var remaining: [(content: String, nickname: String, messageID: String)] = []
// Prefer mesh if connected; else try Nostr if mapping exists // Prefer mesh if connected; else try Nostr if mapping exists
for (content, nickname, messageID) in queued { for (content, nickname, messageID) in queued {
if mesh.isPeerReachable(peerID) { if mesh.isPeerReachable(peerID) {
SecureLogger.debug("Outbox -> mesh for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session) SecureLogger.log("Outbox -> mesh for \(peerID.prefix(8))… id=\(messageID.prefix(8))",
category: SecureLogger.session, level: .debug)
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID) mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
} else if canSendViaNostr(peerID: peerID) { } else if canSendViaNostr(peerID: peerID) {
SecureLogger.debug("Outbox -> Nostr for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session) 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) nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
} else { } else {
// Keep unsent items queued // Keep unsent items queued
@@ -1,120 +0,0 @@
import Foundation
import BitLogger
import Combine
import Tor
/// Coordinates when the app is allowed to start Tor and connect to Nostr relays.
/// Policy: permit start when either location permissions are authorized OR
/// there exists at least one mutual favorite. Otherwise, do not start.
@MainActor
final class NetworkActivationService: ObservableObject {
static let shared = NetworkActivationService()
@Published private(set) var activationAllowed: Bool = false
@Published private(set) var userTorEnabled: Bool = true
private var cancellables = Set<AnyCancellable>()
private var started = false
private let torPreferenceKey = "networkActivationService.userTorEnabled"
private var torAutoStartDesired: Bool = false
private init() {}
deinit {
// Clean up Combine subscriptions
cancellables.removeAll()
SecureLogger.debug("NetworkActivationService deinitialized", category: .session)
}
func start() {
guard !started else { return }
started = true
if let stored = UserDefaults.standard.object(forKey: torPreferenceKey) as? Bool {
userTorEnabled = stored
} else {
userTorEnabled = true
}
// Initial compute
let allowed = basePolicyAllowed()
activationAllowed = allowed
torAutoStartDesired = allowed && userTorEnabled
TorManager.shared.setAutoStartAllowed(torAutoStartDesired)
applyTorState(torDesired: torAutoStartDesired)
if allowed {
NostrRelayManager.shared.connect()
} else {
NostrRelayManager.shared.disconnect()
}
// React to location permission changes
LocationChannelManager.shared.$permissionState
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.reevaluate()
}
.store(in: &cancellables)
// React to mutual favorites changes
FavoritesPersistenceService.shared.$mutualFavorites
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.reevaluate()
}
.store(in: &cancellables)
}
func setUserTorEnabled(_ enabled: Bool) {
guard enabled != userTorEnabled else { return }
userTorEnabled = enabled
UserDefaults.standard.set(enabled, forKey: torPreferenceKey)
NotificationCenter.default.post(
name: .TorUserPreferenceChanged,
object: nil,
userInfo: ["enabled": enabled]
)
reevaluate()
}
private func reevaluate() {
let allowed = basePolicyAllowed()
let torDesired = allowed && userTorEnabled
let statusChanged = allowed != activationAllowed
let torChanged = torDesired != torAutoStartDesired
if statusChanged {
SecureLogger.info("NetworkActivationService: activationAllowed -> \(allowed)", category: .session)
activationAllowed = allowed
}
if statusChanged || torChanged {
torAutoStartDesired = torDesired
TorManager.shared.setAutoStartAllowed(torDesired)
applyTorState(torDesired: torDesired)
}
if allowed {
if torChanged {
// Reset relay sockets when switching transport path (Tor direct)
NostrRelayManager.shared.disconnect()
}
NostrRelayManager.shared.connect()
} else if statusChanged {
NostrRelayManager.shared.disconnect()
}
}
private func basePolicyAllowed() -> Bool {
let permOK = LocationChannelManager.shared.permissionState == .authorized
let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
return permOK || hasMutual
}
private func applyTorState(torDesired: Bool) {
TorURLSession.shared.setProxyMode(useTor: torDesired)
if torDesired {
TorManager.shared.startIfNeeded()
} else {
TorManager.shared.shutdownCompletely()
}
}
}
+75 -74
View File
@@ -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,9 +83,9 @@
/// - Background queue for CPU-intensive operations /// - Background queue for CPU-intensive operations
/// ///
import BitLogger
import Foundation import Foundation
import CryptoKit import CryptoKit
import os.log
// MARK: - Encryption Status // MARK: - Encryption Status
@@ -115,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")
} }
} }
} }
@@ -149,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
@@ -162,15 +148,14 @@ 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?
@@ -178,7 +163,7 @@ final class NoiseEncryptionService {
// Callbacks // Callbacks
private var onPeerAuthenticatedHandlers: [((String, 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 (String, String) -> Void) { func addOnPeerAuthenticatedHandler(_ handler: @escaping (String, String) -> Void) {
@@ -197,17 +182,15 @@ final class NoiseEncryptionService {
} }
} }
init(keychain: KeychainManagerProtocol) { init() {
self.keychain = keychain
// Load or create static identity key (ONLY from keychain) // Load or create static identity key (ONLY from keychain)
let loadedKey: Curve25519.KeyAgreement.PrivateKey let loadedKey: Curve25519.KeyAgreement.PrivateKey
// Try to load from keychain // Try to load from keychain
if let identityData = keychain.getIdentityKey(forKey: "noiseStaticKey"), if let identityData = KeychainManager.shared.getIdentityKey(forKey: "noiseStaticKey"),
let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) { let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) {
loadedKey = key loadedKey = key
SecureLogger.logKeyOperation(.load, keyType: "noiseStaticKey", success: true) SecureLogger.logKeyOperation("load", keyType: "noiseStaticKey", success: true)
} }
// If no identity exists, create new one // If no identity exists, create new one
else { else {
@@ -215,8 +198,8 @@ final class NoiseEncryptionService {
let keyData = loadedKey.rawRepresentation let keyData = loadedKey.rawRepresentation
// Save to keychain // Save to keychain
let saved = keychain.saveIdentityKey(keyData, forKey: "noiseStaticKey") let saved = KeychainManager.shared.saveIdentityKey(keyData, forKey: "noiseStaticKey")
SecureLogger.logKeyOperation(.create, keyType: "noiseStaticKey", success: saved) SecureLogger.logKeyOperation("create", keyType: "noiseStaticKey", success: saved)
} }
// Now assign the final value // Now assign the final value
@@ -227,10 +210,10 @@ final class NoiseEncryptionService {
let loadedSigningKey: Curve25519.Signing.PrivateKey let loadedSigningKey: Curve25519.Signing.PrivateKey
// Try to load from keychain // Try to load from keychain
if let signingData = keychain.getIdentityKey(forKey: "ed25519SigningKey"), if let signingData = KeychainManager.shared.getIdentityKey(forKey: "ed25519SigningKey"),
let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) { let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) {
loadedSigningKey = key loadedSigningKey = key
SecureLogger.logKeyOperation(.load, keyType: "ed25519SigningKey", success: true) SecureLogger.logKeyOperation("load", keyType: "ed25519SigningKey", success: true)
} }
// If no signing key exists, create new one // If no signing key exists, create new one
else { else {
@@ -238,8 +221,8 @@ final class NoiseEncryptionService {
let keyData = loadedSigningKey.rawRepresentation let keyData = loadedSigningKey.rawRepresentation
// Save to keychain // Save to keychain
let saved = keychain.saveIdentityKey(keyData, forKey: "ed25519SigningKey") let saved = KeychainManager.shared.saveIdentityKey(keyData, forKey: "ed25519SigningKey")
SecureLogger.logKeyOperation(.create, keyType: "ed25519SigningKey", success: saved) SecureLogger.logKeyOperation("create", keyType: "ed25519SigningKey", success: saved)
} }
// Now assign the signing keys // Now assign the signing keys
@@ -247,7 +230,7 @@ final class NoiseEncryptionService {
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
@@ -272,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()
} }
@@ -297,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
} }
} }
@@ -308,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
} }
} }
@@ -404,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
@@ -427,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
} }
@@ -457,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
@@ -491,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
@@ -513,26 +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() }
/// Remove a peer session
func removePeer(_ peerID: String) {
sessionManager.removeSession(for: peerID)
serviceQueue.sync(flags: .barrier) {
if let fingerprint = peerFingerprints[peerID] {
fingerprintToPeerID.removeValue(forKey: fingerprint)
}
peerFingerprints.removeValue(forKey: peerID)
}
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) {
@@ -541,15 +537,20 @@ 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
self?.onPeerAuthenticatedHandlers.forEach { handler in self?.onPeerAuthenticatedHandlers.forEach { handler in
handler(peerID.id, fingerprint) handler(peerID, fingerprint)
} }
} }
} }
private func calculateFingerprint(for publicKey: Curve25519.KeyAgreement.PublicKey) -> String {
let hash = SHA256.hash(data: publicKey.rawRepresentation)
return hash.map { String(format: "%02x", $0) }.joined()
}
// MARK: - Session Maintenance // MARK: - Session Maintenance
@@ -572,12 +573,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)
} }
} }
} }
+146 -157
View File
@@ -1,37 +1,28 @@
import BitLogger
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 { final class NostrTransport: Transport {
// 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
init(keychain: KeychainManagerProtocol) {
self.keychain = keychain
}
// 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 */ }
@@ -39,167 +30,62 @@ final class NostrTransport: Transport {
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: PeerID) -> Bool { false } func isPeerReachable(_ peerID: String) -> Bool { false }
func peerNickname(peerID: PeerID) -> String? { nil } func peerNickname(peerID: String) -> String? { nil }
func getPeerNicknames() -> [PeerID : String] { [:] } func getPeerNicknames() -> [String : 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) else { return } guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return } guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session) SecureLogger.log("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.prefix(8))… id=\(messageID.prefix(8))",
category: SecureLogger.session, level: .debug)
// Convert recipient npub -> hex (x-only) // Convert recipient npub -> hex (x-only)
let recipientHex: String let recipientHex: String
do { do {
let (hrp, data) = try Bech32.decode(recipientNpub) let (hrp, data) = try Bech32.decode(recipientNpub)
guard hrp == "npub" else { guard hrp == "npub" else {
SecureLogger.error("NostrTransport: recipient key not npub (hrp=\(hrp))", category: .session) SecureLogger.log("NostrTransport: recipient key not npub (hrp=\(hrp))", category: SecureLogger.session, level: .error)
return return
} }
recipientHex = data.hexEncodedString() recipientHex = data.hexEncodedString()
} catch { } catch {
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session) SecureLogger.log("NostrTransport: failed to decode npub -> hex: \(error)", category: SecureLogger.session, level: .error)
return return
} }
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) 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
} }
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else { guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event for PM", category: .session) SecureLogger.log("NostrTransport: failed to build Nostr event for PM", category: SecureLogger.session, level: .error)
return return
} }
SecureLogger.debug("NostrTransport: sending PM giftWrap id=\(event.id.prefix(16))", category: .session) SecureLogger.log("NostrTransport: sending PM giftWrap id=\(event.id.prefix(16))",
category: SecureLogger.session, level: .debug)
NostrRelayManager.shared.sendEvent(event) 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
readQueue.append(QueuedRead(receipt: receipt, peerID: peerID)) readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
processReadQueueIfNeeded() processReadQueueIfNeeded()
} }
func sendFavoriteNotification(to peerID: PeerID, 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.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))", category: .session)
// 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.id, senderPeerID: senderPeerID.id) else {
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
return
}
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event for favorite notification", category: .session)
return
}
SecureLogger.debug("NostrTransport: sending favorite giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.shared.sendEvent(event)
}
}
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))", category: .session)
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.id, senderPeerID: senderPeerID.id) else {
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
return
}
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event for DELIVERED ack", category: .session)
return
}
SecureLogger.debug("NostrTransport: sending DELIVERED ack giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.shared.sendEvent(event)
}
}
}
// 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 -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID.id) 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.debug("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID.id) 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.debug("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))", category: .session)
// Build embedded BitChat packet without recipient peer ID
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID.id) else {
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
return
}
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event for geohash PM", category: .session)
return
}
SecureLogger.debug("NostrTransport: sending geohash PM giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.registerPendingGiftWrap(id: event.id)
NostrRelayManager.shared.sendEvent(event)
}
}
}
// MARK: - Private Helpers
extension NostrTransport {
private func processReadQueueIfNeeded() { private func processReadQueueIfNeeded() {
guard !isSendingReadAcks else { return } guard !isSendingReadAcks else { return }
guard !readQueue.isEmpty else { return } guard !readQueue.isEmpty else { return }
@@ -213,7 +99,8 @@ extension NostrTransport {
Task { @MainActor in Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return } guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return } guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
SecureLogger.debug("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))", category: .session) SecureLogger.log("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))",
category: SecureLogger.session, level: .debug)
// Convert recipient npub -> hex // Convert recipient npub -> hex
let recipientHex: String let recipientHex: String
do { do {
@@ -221,15 +108,16 @@ extension NostrTransport {
guard hrp == "npub" else { scheduleNextReadAck(); return } guard hrp == "npub" else { scheduleNextReadAck(); return }
recipientHex = data.hexEncodedString() recipientHex = data.hexEncodedString()
} catch { scheduleNextReadAck(); return } } catch { scheduleNextReadAck(); return }
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID.id, senderPeerID: senderPeerID.id) 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)
scheduleNextReadAck(); return scheduleNextReadAck(); return
} }
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else { guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event for READ ack", category: .session) SecureLogger.log("NostrTransport: failed to build Nostr event for READ ack", category: SecureLogger.session, level: .error)
scheduleNextReadAck(); return scheduleNextReadAck(); return
} }
SecureLogger.debug("NostrTransport: sending READ ack giftWrap id=\(event.id.prefix(16))", category: .session) SecureLogger.log("NostrTransport: sending READ ack giftWrap id=\(event.id.prefix(16))",
category: SecureLogger.session, level: .debug)
NostrRelayManager.shared.sendEvent(event) NostrRelayManager.shared.sendEvent(event)
scheduleNextReadAck() scheduleNextReadAck()
} }
@@ -243,18 +131,119 @@ extension NostrTransport {
} }
} }
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 = FavoritesPersistenceService.shared.getFavoriteStatus(for: 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 peerID.count == 16,
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: 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)
}
}
} }
+22 -2
View File
@@ -14,7 +14,7 @@ import UIKit
import AppKit import AppKit
#endif #endif
final class NotificationService { class NotificationService {
static let shared = NotificationService() static let shared = NotificationService()
private init() {} private init() {}
@@ -62,7 +62,7 @@ final class NotificationService {
} }
func sendPrivateMessageNotification(from sender: String, message: String, peerID: String) { 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, "senderName": sender] let userInfo = ["peerID": peerID, "senderName": sender]
@@ -70,6 +70,26 @@ final class NotificationService {
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)"
@@ -1,81 +0,0 @@
//
// NotificationStreamAssembler.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
struct NotificationStreamAssembler {
private var buffer = Data()
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 reset = false
let maxFrameLength = TransportConfig.blePendingWriteBufferCapBytes
let minHeaderBytes = 14 // version + type + ttl + timestamp(8) + flags + length(2)
let minFramePrefix = minHeaderBytes + BinaryProtocol.senderIDSize
while buffer.count >= minFramePrefix {
guard let first = buffer.first else { break }
if first != 1 {
dropped.append(buffer.removeFirst())
continue
}
guard buffer.count >= minHeaderBytes else { break }
let headerBytes = Array(buffer.prefix(minFramePrefix))
guard headerBytes.count == minFramePrefix else { break }
let flags = headerBytes[11]
let hasRecipient = (flags & BinaryProtocol.Flags.hasRecipient) != 0
let hasSignature = (flags & BinaryProtocol.Flags.hasSignature) != 0
let payloadLen = (Int(headerBytes[12]) << 8) | Int(headerBytes[13])
var frameLength = minFramePrefix + payloadLen
if hasRecipient { frameLength += BinaryProtocol.recipientIDSize }
if hasSignature { frameLength += BinaryProtocol.signatureSize }
guard frameLength > 0, frameLength <= maxFrameLength else {
buffer.removeAll()
reset = true
break
}
if buffer.count < frameLength {
// Check if a new frame start exists within the incomplete buffer; if so, drop leading partial bytes.
if let nextStart = buffer.dropFirst().firstIndex(of: 1) {
let dropCount = buffer.distance(from: buffer.startIndex, to: nextStart)
if dropCount > 0 {
buffer.removeFirst(dropCount)
dropped.append(1) // treat as dropped partial start
}
}
break
}
let frame = Data(buffer.prefix(frameLength))
frames.append(frame)
buffer.removeFirst(frameLength)
}
if !buffer.isEmpty, buffer.allSatisfy({ $0 == 0 }) {
buffer.removeAll(keepingCapacity: false)
}
return (frames, dropped, reset)
}
mutating func reset() {
buffer.removeAll(keepingCapacity: false)
}
}
+136 -22
View File
@@ -6,12 +6,11 @@
// 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 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: [String: [BitchatMessage]] = [:] @Published var privateChats: [String: [BitchatMessage]] = [:]
@Published var selectedPeer: String? = nil @Published var selectedPeer: String? = nil
@Published var unreadMessages: Set<String> = [] @Published var unreadMessages: Set<String> = []
@@ -27,10 +26,6 @@ final class PrivateChatManager: ObservableObject {
self.meshService = meshService self.meshService = meshService
} }
deinit {
SecureLogger.debug("PrivateChatManager deinitialized", category: .session)
}
// Cap for messages stored per private chat // Cap for messages stored per private chat
private let privateChatCap = TransportConfig.privateChatCap private let privateChatCap = TransportConfig.privateChatCap
@@ -39,7 +34,7 @@ final class PrivateChatManager: ObservableObject {
selectedPeer = peerID selectedPeer = peerID
// Store fingerprint for persistence across reconnections // Store fingerprint for persistence across reconnections
if let fingerprint = meshService?.getFingerprint(for: PeerID(str: peerID)) { if let fingerprint = meshService?.getFingerprint(for: peerID) {
selectedPeerFingerprint = fingerprint selectedPeerFingerprint = fingerprint
} }
@@ -57,28 +52,104 @@ final class PrivateChatManager: ObservableObject {
selectedPeer = nil selectedPeer = nil
selectedPeerFingerprint = nil selectedPeerFingerprint = nil
} }
/// Send a private message
func sendMessage(_ content: String, to peerID: String) {
guard let meshService = meshService,
let peerNickname = meshService.peerNickname(peerID: peerID) else {
return
}
let messageID = UUID().uuidString
// Create local message
let message = BitchatMessage(
id: messageID,
sender: meshService.myNickname,
content: content,
timestamp: Date(),
isRelay: false,
originalSender: nil,
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 !sentReadReceipts.contains(message.id) {
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 /// Remove duplicate messages by ID and keep chronological order
func sanitizeChat(for peerID: String) { func sanitizeChat(for peerID: String) {
guard let arr = privateChats[peerID] else { return } guard let arr = privateChats[peerID] else { return }
if arr.count <= 1 { var seen = Set<String>()
return
}
var indexByID: [String: Int] = [:]
indexByID.reserveCapacity(arr.count)
var deduped: [BitchatMessage] = [] var deduped: [BitchatMessage] = []
deduped.reserveCapacity(arr.count)
for msg in arr.sorted(by: { $0.timestamp < $1.timestamp }) { for msg in arr.sorted(by: { $0.timestamp < $1.timestamp }) {
if let existing = indexByID[msg.id] { if !seen.contains(msg.id) {
deduped[existing] = msg seen.insert(msg.id)
} else {
indexByID[msg.id] = deduped.count
deduped.append(msg) 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
} }
@@ -96,6 +167,48 @@ final class PrivateChatManager: ObservableObject {
} }
} }
/// 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) {
@@ -109,13 +222,14 @@ final class PrivateChatManager: ObservableObject {
// 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.id ?? "", 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)
} }
+23 -16
View File
@@ -18,17 +18,13 @@ struct RelayController {
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
@@ -36,17 +32,28 @@ struct RelayController {
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs) return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
} }
// Degree-aware probability to reduce floods in dense graphs (broadcast/public)
let baseProb: Double
switch degree {
case 0...2: baseProb = 1.0
case 3...4: baseProb = 0.9
case 5...6: baseProb = 0.7
case 7...9: baseProb = 0.55
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
@@ -57,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)
} }
} }
+23 -23
View File
@@ -4,7 +4,7 @@ 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?
@@ -12,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)
@@ -32,33 +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 sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String)
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String)
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) func sendFavoriteNotification(to peerID: String, isFavorite: Bool)
func sendBroadcastAnnounce() func sendBroadcastAnnounce()
func sendDeliveryAck(for messageID: String, to peerID: PeerID) func sendDeliveryAck(for messageID: String, to peerID: 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)
// Peer snapshots (for non-UI services)
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get }
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) {}
} }
protocol TransportPeerEventsDelegate: AnyObject { protocol TransportPeerEventsDelegate: AnyObject {
+8 -4
View File
@@ -37,10 +37,14 @@ enum TransportConfig {
// 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
// UI rate limiters (token buckets)
static let uiSenderRateBucketCapacity: Double = 5
static let uiSenderRateBucketRefillPerSec: Double = 1.0
static let uiContentRateBucketCapacity: Double = 3
static let uiContentRateBucketRefillPerSec: Double = 0.5
// UI sleeps/delays // UI sleeps/delays
static let uiStartupInitialDelaySeconds: TimeInterval = 1.0 static let uiStartupInitialDelaySeconds: TimeInterval = 1.0
@@ -97,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)
@@ -182,7 +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
} }

Some files were not shown because too many files have changed in this diff Show More