Compare commits

...
Author SHA1 Message Date
7040d9ecb0 Add plus-minus feature to location notes (± 1 grid) (#821)
Expands location notes coverage to include 8 neighboring geohash cells,
creating a 3×3 grid around the user's current building-level location.

Changes:
- Add Geohash.neighbors() method to calculate 8 surrounding cells
- Update LocationNotesManager to subscribe to center + neighbors (9 cells total)
- Add NostrFilter.geohashNotes([String]) overload for multi-cell subscriptions
- Display '± 1' indicator in LocationNotesView header

Coverage:
- Single cell: ~38m × 19m
- 3×3 grid: ~114m × 57m total area
- Better discovery of nearby activity

Behavior:
- Subscribe: Fetches notes from all 9 cells (single efficient subscription)
- Post: Still uses center geohash only (correct privacy-preserving behavior)
- UI: Shows 'geohash ± 1' to indicate expanded coverage

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-18 12:44:06 +02:00
88bafb41cc Remove LocationNotesCounter for privacy-first approach (#820)
Simplifies geonotes architecture by eliminating all background subscriptions:

- Delete LocationNotesCounter.swift (104 lines) and related tests (58 lines)
- Remove background subscription system entirely
- Icon is now constant darker orange (indicates availability, not state)
- Subscription ONLY happens when user explicitly opens the sheet
- Total: ~220 lines of code removed

Privacy Benefits:
- Zero network traffic until user action
- No location leaking to relays via background polling
- User must explicitly opt-in to discover notes

The icon (note.text in orange) simply indicates the feature is available
when location permission is granted. Notes are only fetched when the
sheet is opened, prioritizing privacy over convenience.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-18 12:06:31 +02:00
jackandGitHub fb43a8b0f5 Reuse cached mention regex in parseMentions (#812) 2025-10-17 23:02:58 +02:00
jackandGitHub eb35608fa1 Remove unused helpers and add cross-platform logging fallbacks (#811) 2025-10-17 22:58:56 +02:00
RedThoroughbredandGitHub b81ae0b4c0 fix: improve Xcode detection in Justfile (#814)
- Add check for full Xcode vs command line tools only
- Provide clearer error messages with setup instructions
- Verify Xcode is installed and properly configured
- Add better validation for development environment

Fixes issue where 'just run' fails with cryptic error when only
command line tools are installed. Now gives clear guidance on
installing full Xcode and configuring xcode-select properly.

Resolves #760
2025-10-17 21:37:51 +02:00
jackandGitHub b6d42261d0 Guard peer collision checks with snapshot (#810) 2025-10-15 16:12:16 +02:00
3d914dcf46 Convert the remaining tests to Swift Testing (#781)
* SwiftTesting: NoiseProtocolTests + BinaryProtocolPaddingTests

* SwiftTesting: `NotificationStreamAssemblerTests`

* SwiftTesting: `NostrProtocolTests`

* SwiftTesting: `BinaryProtocolTests`

* SwiftTesting: `PeerIDTests`

* SwiftTesting: `BLEServiceTests`

* SwiftTesting: `CommandProcessorTests`

* SwiftTesting: `GCSFilterTests`

* SwiftTesting: `GeohashBookmarksStoreTests`

* Remove `peerID` test constants

* Remove PeerID + String interop from tests

* Refactor IntegrationTests to extract state management

* Refactor global state management of MockBLEService

* NoiseProtocolSwiftTests: `actor` -> `struct`

* Remove measurement tests w/ no benchmark

* `NoiseProtocolSwiftTests` -> `NoiseProtocolTests`

* SwiftTesting: `LocationChannelsTests`

* SwiftTesting: `GossipSyncManagerTests`

* SwiftTesting: `LocationNotesManagerTests`

* Global `sleep` function for tests

* SwiftTesting: `IntegrationTests`

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-15 01:04:01 +02:00
b3ec5eeda0 Refactor Noise: Extract files and remove dead code (#806)
* Extract each type to a separate file

* NoiseSessionManager: Remove unused functions

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2025-10-15 00:32:44 +02:00
IslamandGitHub 47d75ab9d8 PeerID 23/n: ChatViewModel + its dependences (#801) 2025-10-15 00:20:19 +02:00
3479c7d5df Fix people sheet dismiss gestures (#803)
* Allow closing people sheet from X and swipe

* Swipe right to return from DM to people list

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-14 21:11:44 +02:00
8a0727fcf7 Guard BLE link state lookups on BLE queue (#805)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-14 21:11:07 +02:00
6588861e34 Align gossip sync stale cleanup with Android client (#798)
* Align DM sheet toolbar with people list

* Gate stale gossip announcements

* Remove stale peer messages during gossip cleanup

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-14 13:54:36 +02:00
a1647901e5 Add Turkish translations for share extension & Add Turkey to knownRegions (#787)
* Add Turkish translations for share extension

* Add Turkey to knownRegions

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2025-10-14 12:45:23 +02:00
IslamandGitHub 23249f3e41 PeerID 22/n: PrivateChatManager (#800)
* PrivateChatManager: functions to use `PeerID`

* PrivateChatManager: properties to use `PeerID`
2025-10-14 12:30:18 +02:00
ad4103bacc Refactor Nostr ID Bridge & Keychain Helper (#796)
* Extract each type to a separate file

* Nostr ID Bridge: Convert static func/vars to instance

* `KeychainHelper` behind a protocol to easily mock

* Update tests with ID Bridge and MockKeychainHelper

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2025-10-14 12:26:18 +02:00
e3149fa098 Process incoming fragments on message queue (#804)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-14 12:20:39 +02:00
jack 987ba2e694 Fix people sheet close button 2025-10-12 20:18:29 +02:00
615273a63e Align DM sheet toolbar with people list (#795)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-12 19:53:08 +02:00
IslamandGitHub 4563c22d2d Fix hidden source of deadlock (#794) 2025-10-12 12:27:33 +02:00
IslamandGitHub 9f74266527 Centralize repeated queue-checking logic (#791) 2025-10-11 21:38:04 +02:00
IslamandGitHub 239064e4eb Cleanup (#793)
* Remove and ignore `.cache/`

* Optimize debug logo + remove its duplicates
2025-10-11 21:28:28 +02:00
IslamandGitHub d371131ad5 Remove MockBluetoothMeshService (#777) 2025-10-09 23:47:01 +02:00
d994ccf012 Fix send button tap responsiveness and sidebar drag jitter (#783)
Restructured ContentView layout to prevent sidebar from covering input box
and removed gesture conflicts that caused jitter during slow drags.

Changes:
- Moved sidebar overlay to only cover messages area, not input box
- Input box now always accessible below sidebar (not covered by overlay)
- Removed blocking drag gesture from mainChatView
- Changed sidebar gesture from simultaneousGesture to gesture for priority
- Removed animation-disabling transactions that amplified touch noise
- Removed 2pt threshold checks that caused visible jumps

Result: Send button taps immediately, sidebar slides smoothly without jitter.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-10-09 23:45:05 +02:00
82 changed files with 3435 additions and 3947 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
...
+3
View File
@@ -66,6 +66,9 @@ __pycache__/
*.tmp *.tmp
*.temp *.temp
## Cache
.cache/
# Local build results # Local build results
.Result*/ .Result*/
.Result*.xcresult/ .Result*.xcresult/
+5 -2
View File
@@ -14,8 +14,11 @@ default:
# Check prerequisites # Check prerequisites
check: check:
@echo "Checking prerequisites..." @echo "Checking prerequisites..."
@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 "❌ xcodebuild 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) @xcode-select -p | grep -q "Xcode.app" || (echo "❌ Full Xcode required, not just command line tools. Install from App Store and run:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
@test -d "/Applications/Xcode.app" || (echo "❌ Xcode.app not found in Applications folder. Install from App Store" && exit 1)
@xcodebuild -version >/dev/null 2>&1 || (echo "❌ Xcode not properly configured. Try:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
@security find-identity -v -p codesigning | grep -q "Apple Development\|Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
@echo "✅ All prerequisites met" @echo "✅ All prerequisites met"
# Backup original files # Backup original files
+1
View File
@@ -312,6 +312,7 @@
ne, ne,
"pt-BR", "pt-BR",
ru, ru,
tr,
uk, uk,
"zh-Hans", "zh-Hans",
); );
@@ -13,7 +13,6 @@
"value" : "dark" "value" : "dark"
} }
], ],
"filename" : "image-1024 1.png",
"idiom" : "universal", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"
@@ -25,7 +24,6 @@
"value" : "tinted" "value" : "tinted"
} }
], ],
"filename" : "image-1024 2.png",
"idiom" : "universal", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"
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

After

Width:  |  Height:  |  Size: 11 KiB

+6 -2
View File
@@ -26,11 +26,15 @@ struct BitchatApp: App {
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate @NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
#endif #endif
private let idBridge = NostrIdentityBridge()
init() { init() {
let keychain = KeychainManager() let keychain = KeychainManager()
let idBridge = self.idBridge
_chatViewModel = StateObject( _chatViewModel = StateObject(
wrappedValue: ChatViewModel( wrappedValue: ChatViewModel(
keychain: keychain, keychain: keychain,
idBridge: idBridge,
identityManager: SecureIdentityStateManager(keychain) identityManager: SecureIdentityStateManager(keychain)
) )
) )
@@ -50,7 +54,7 @@ struct BitchatApp: App {
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService()) VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
// Prewarm Nostr identity and QR to make first VERIFY sheet fast // Prewarm Nostr identity and QR to make first VERIFY sheet fast
DispatchQueue.global(qos: .utility).async { DispatchQueue.global(qos: .utility).async {
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub let npub = try? idBridge.getCurrentNostrIdentity()?.npub
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub) _ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
} }
#if os(iOS) #if os(iOS)
@@ -217,7 +221,7 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
// Get peer ID from userInfo // Get peer ID from userInfo
if let peerID = userInfo["peerID"] as? String { if let peerID = userInfo["peerID"] as? String {
DispatchQueue.main.async { DispatchQueue.main.async {
self.chatViewModel?.startPrivateChat(with: peerID) self.chatViewModel?.startPrivateChat(with: PeerID(str: peerID))
} }
} }
} }
+34
View File
@@ -180,6 +180,40 @@
} }
} }
}, },
"%@ active": {
"comment": "A label at the bottom of the people list sheet showing the number of active users.",
"localizations": {
"ar": { "stringUnit": { "state": "translated", "value": "%@ نشطين" } },
"bn": { "stringUnit": { "state": "translated", "value": "%@ সক্রিয়" } },
"de": { "stringUnit": { "state": "translated", "value": "%@ aktiv" } },
"en": { "stringUnit": { "state": "translated", "value": "%@ active" } },
"es": { "stringUnit": { "state": "translated", "value": "%@ activos" } },
"fil": { "stringUnit": { "state": "translated", "value": "%@ aktibo" } },
"fr": { "stringUnit": { "state": "translated", "value": "%@ actifs" } },
"he": { "stringUnit": { "state": "translated", "value": "%@ פעילים" } },
"hi": { "stringUnit": { "state": "translated", "value": "%@ सक्रिय" } },
"id": { "stringUnit": { "state": "translated", "value": "%@ aktif" } },
"it": { "stringUnit": { "state": "translated", "value": "%@ attivi" } },
"ja": { "stringUnit": { "state": "translated", "value": "%@ 人がアクティブ" } },
"ko": { "stringUnit": { "state": "translated", "value": "활성 사용자 %@명" } },
"ms": { "stringUnit": { "state": "translated", "value": "%@ aktif" } },
"ne": { "stringUnit": { "state": "translated", "value": "%@ सक्रिय" } },
"nl": { "stringUnit": { "state": "translated", "value": "%@ actief" } },
"pl": { "stringUnit": { "state": "translated", "value": "%@ aktywni" } },
"pt": { "stringUnit": { "state": "translated", "value": "%@ ativos" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "%@ ativos" } },
"ru": { "stringUnit": { "state": "translated", "value": "%@ активных" } },
"sv": { "stringUnit": { "state": "translated", "value": "%@ aktiva" } },
"ta": { "stringUnit": { "state": "translated", "value": "%@ செயலில்" } },
"th": { "stringUnit": { "state": "translated", "value": "%@ กำลังใช้งาน" } },
"tr": { "stringUnit": { "state": "translated", "value": "%@ aktif" } },
"uk": { "stringUnit": { "state": "translated", "value": "%@ активних" } },
"ur": { "stringUnit": { "state": "translated", "value": "%@ فعال" } },
"vi": { "stringUnit": { "state": "translated", "value": "%@ đang hoạt động" } },
"zh-Hans": { "stringUnit": { "state": "translated", "value": "活跃用户 %@ 名" } },
"zh-Hant": { "stringUnit": { "state": "translated", "value": "活躍使用者 %@ 位" } }
}
},
"app_info.close": { "app_info.close": {
"extractionState": "manual", "extractionState": "manual",
"localizations": { "localizations": {
+6 -1
View File
@@ -161,9 +161,14 @@ extension PeerID {
id.rangeOfCharacter(from: validCharset.inverted) == nil id.rangeOfCharacter(from: validCharset.inverted) == nil
} }
/// Returns true if the `bare` id is all hex
var isHex: Bool {
bare.allSatisfy { $0.isHexDigit }
}
/// Short routing IDs (exact 16-hex) /// Short routing IDs (exact 16-hex)
var isShort: Bool { var isShort: Bool {
bare.count == Constants.hexIDLength && Data(hexString: bare) != nil bare.count == Constants.hexIDLength && isHex
} }
/// Full Noise key hex (exact 64-hex) /// Full Noise key hex (exact 64-hex)
+95
View File
@@ -0,0 +1,95 @@
//
// NoiseRateLimiter.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import Foundation
final class NoiseRateLimiter {
private var handshakeTimestamps: [PeerID: [Date]] = [:]
private var messageTimestamps: [PeerID: [Date]] = [:]
// Global rate limiting
private var globalHandshakeTimestamps: [Date] = []
private var globalMessageTimestamps: [Date] = []
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
func allowHandshake(from peerID: PeerID) -> Bool {
return queue.sync(flags: .barrier) {
let now = Date()
let oneMinuteAgo = now.addingTimeInterval(-60)
// Check global rate limit first
globalHandshakeTimestamps = globalHandshakeTimestamps.filter { $0 > oneMinuteAgo }
if globalHandshakeTimestamps.count >= NoiseSecurityConstants.maxGlobalHandshakesPerMinute {
SecureLogger.warning("Global handshake rate limit exceeded: \(globalHandshakeTimestamps.count)/\(NoiseSecurityConstants.maxGlobalHandshakesPerMinute) per minute", category: .security)
return false
}
// Check per-peer rate limit
var timestamps = handshakeTimestamps[peerID] ?? []
timestamps = timestamps.filter { $0 > oneMinuteAgo }
if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute {
SecureLogger.warning("Per-peer handshake rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: .security)
return false
}
// Record new handshake
timestamps.append(now)
handshakeTimestamps[peerID] = timestamps
globalHandshakeTimestamps.append(now)
return true
}
}
func allowMessage(from peerID: PeerID) -> Bool {
return queue.sync(flags: .barrier) {
let now = Date()
let oneSecondAgo = now.addingTimeInterval(-1)
// Check global rate limit first
globalMessageTimestamps = globalMessageTimestamps.filter { $0 > oneSecondAgo }
if globalMessageTimestamps.count >= NoiseSecurityConstants.maxGlobalMessagesPerSecond {
SecureLogger.warning("Global message rate limit exceeded: \(globalMessageTimestamps.count)/\(NoiseSecurityConstants.maxGlobalMessagesPerSecond) per second", category: .security)
return false
}
// Check per-peer rate limit
var timestamps = messageTimestamps[peerID] ?? []
timestamps = timestamps.filter { $0 > oneSecondAgo }
if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond {
SecureLogger.warning("Per-peer message rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: .security)
return false
}
// Record new message
timestamps.append(now)
messageTimestamps[peerID] = timestamps
globalMessageTimestamps.append(now)
return true
}
}
func reset(for peerID: PeerID) {
queue.async(flags: .barrier) {
self.handshakeTimestamps.removeValue(forKey: peerID)
self.messageTimestamps.removeValue(forKey: peerID)
}
}
func resetAll() {
queue.async(flags: .barrier) {
self.handshakeTimestamps.removeAll()
self.messageTimestamps.removeAll()
self.globalHandshakeTimestamps.removeAll()
self.globalMessageTimestamps.removeAll()
}
}
}
@@ -1,227 +0,0 @@
//
// NoiseSecurityConsiderations.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import Foundation
// MARK: - Security Constants
enum NoiseSecurityConstants {
// Maximum message size to prevent memory exhaustion
static let maxMessageSize = 65535 // 64KB as per Noise spec
// Maximum handshake message size
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
// Session timeout - sessions older than this should be renegotiated
static let sessionTimeout: TimeInterval = 86400 // 24 hours
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
// Handshake timeout - abandon incomplete handshakes
static let handshakeTimeout: TimeInterval = 60 // 1 minute
// Maximum concurrent sessions per peer
static let maxSessionsPerPeer = 3
// Rate limiting
static let maxHandshakesPerMinute = 10
static let maxMessagesPerSecond = 100
// Global rate limiting (across all peers)
static let maxGlobalHandshakesPerMinute = 30
static let maxGlobalMessagesPerSecond = 500
}
// MARK: - Security Validations
struct NoiseSecurityValidator {
/// Validate message size
static func validateMessageSize(_ data: Data) -> Bool {
return data.count <= NoiseSecurityConstants.maxMessageSize
}
/// Validate handshake message size
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
}
}
// MARK: - Enhanced Noise Session with Security
final class SecureNoiseSession: NoiseSession {
private(set) var messageCount: UInt64 = 0
private let sessionStartTime = Date()
private(set) var lastActivityTime = Date()
override func encrypt(_ plaintext: Data) throws -> Data {
// Check session age
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
throw NoiseSecurityError.sessionExpired
}
// Check message count
if messageCount >= NoiseSecurityConstants.maxMessagesPerSession {
throw NoiseSecurityError.sessionExhausted
}
// Validate message size
guard NoiseSecurityValidator.validateMessageSize(plaintext) else {
throw NoiseSecurityError.messageTooLarge
}
let encrypted = try super.encrypt(plaintext)
messageCount += 1
lastActivityTime = Date()
return encrypted
}
override func decrypt(_ ciphertext: Data) throws -> Data {
// Check session age
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
throw NoiseSecurityError.sessionExpired
}
// Validate message size
guard NoiseSecurityValidator.validateMessageSize(ciphertext) else {
throw NoiseSecurityError.messageTooLarge
}
let decrypted = try super.decrypt(ciphertext)
lastActivityTime = Date()
return decrypted
}
func needsRenegotiation() -> Bool {
// Check if we've used more than 90% of message limit
let messageThreshold = UInt64(Double(NoiseSecurityConstants.maxMessagesPerSession) * 0.9)
if messageCount >= messageThreshold {
return true
}
// Check if last activity was more than 30 minutes ago
if Date().timeIntervalSince(lastActivityTime) > NoiseSecurityConstants.sessionTimeout {
return true
}
return false
}
// MARK: - Testing Support
#if DEBUG
func setLastActivityTimeForTesting(_ date: Date) {
lastActivityTime = date
}
func setMessageCountForTesting(_ count: UInt64) {
messageCount = count
}
#endif
}
// MARK: - Rate Limiter
final class NoiseRateLimiter {
private var handshakeTimestamps: [PeerID: [Date]] = [:]
private var messageTimestamps: [PeerID: [Date]] = [:]
// Global rate limiting
private var globalHandshakeTimestamps: [Date] = []
private var globalMessageTimestamps: [Date] = []
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
func allowHandshake(from peerID: PeerID) -> Bool {
return queue.sync(flags: .barrier) {
let now = Date()
let oneMinuteAgo = now.addingTimeInterval(-60)
// Check global rate limit first
globalHandshakeTimestamps = globalHandshakeTimestamps.filter { $0 > oneMinuteAgo }
if globalHandshakeTimestamps.count >= NoiseSecurityConstants.maxGlobalHandshakesPerMinute {
SecureLogger.warning("Global handshake rate limit exceeded: \(globalHandshakeTimestamps.count)/\(NoiseSecurityConstants.maxGlobalHandshakesPerMinute) per minute", category: .security)
return false
}
// Check per-peer rate limit
var timestamps = handshakeTimestamps[peerID] ?? []
timestamps = timestamps.filter { $0 > oneMinuteAgo }
if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute {
SecureLogger.warning("Per-peer handshake rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: .security)
return false
}
// Record new handshake
timestamps.append(now)
handshakeTimestamps[peerID] = timestamps
globalHandshakeTimestamps.append(now)
return true
}
}
func allowMessage(from peerID: PeerID) -> Bool {
return queue.sync(flags: .barrier) {
let now = Date()
let oneSecondAgo = now.addingTimeInterval(-1)
// Check global rate limit first
globalMessageTimestamps = globalMessageTimestamps.filter { $0 > oneSecondAgo }
if globalMessageTimestamps.count >= NoiseSecurityConstants.maxGlobalMessagesPerSecond {
SecureLogger.warning("Global message rate limit exceeded: \(globalMessageTimestamps.count)/\(NoiseSecurityConstants.maxGlobalMessagesPerSecond) per second", category: .security)
return false
}
// Check per-peer rate limit
var timestamps = messageTimestamps[peerID] ?? []
timestamps = timestamps.filter { $0 > oneSecondAgo }
if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond {
SecureLogger.warning("Per-peer message rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: .security)
return false
}
// Record new message
timestamps.append(now)
messageTimestamps[peerID] = timestamps
globalMessageTimestamps.append(now)
return true
}
}
func reset(for peerID: PeerID) {
queue.async(flags: .barrier) {
self.handshakeTimestamps.removeValue(forKey: peerID)
self.messageTimestamps.removeValue(forKey: peerID)
}
}
func resetAll() {
queue.async(flags: .barrier) {
self.handshakeTimestamps.removeAll()
self.messageTimestamps.removeAll()
self.globalHandshakeTimestamps.removeAll()
self.globalMessageTimestamps.removeAll()
}
}
}
// MARK: - Security Errors
enum NoiseSecurityError: Error {
case sessionExpired
case sessionExhausted
case messageTooLarge
case invalidPeerID
case rateLimitExceeded
case handshakeTimeout
}
@@ -0,0 +1,37 @@
//
// NoiseSecurityConstants.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
enum NoiseSecurityConstants {
// Maximum message size to prevent memory exhaustion
static let maxMessageSize = 65535 // 64KB as per Noise spec
// Maximum handshake message size
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
// Session timeout - sessions older than this should be renegotiated
static let sessionTimeout: TimeInterval = 86400 // 24 hours
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
// Handshake timeout - abandon incomplete handshakes
static let handshakeTimeout: TimeInterval = 60 // 1 minute
// Maximum concurrent sessions per peer
static let maxSessionsPerPeer = 3
// Rate limiting
static let maxHandshakesPerMinute = 10
static let maxMessagesPerSecond = 100
// Global rate limiting (across all peers)
static let maxGlobalHandshakesPerMinute = 30
static let maxGlobalMessagesPerSecond = 500
}
+18
View File
@@ -0,0 +1,18 @@
//
// NoiseSecurityError.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
enum NoiseSecurityError: Error {
case sessionExpired
case sessionExhausted
case messageTooLarge
case invalidPeerID
case rateLimitExceeded
case handshakeTimeout
}
@@ -0,0 +1,22 @@
//
// NoiseSecurityValidator.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
struct NoiseSecurityValidator {
/// Validate message size
static func validateMessageSize(_ data: Data) -> Bool {
return data.count <= NoiseSecurityConstants.maxMessageSize
}
/// Validate handshake message size
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
}
}
-6
View File
@@ -196,12 +196,6 @@ class NoiseSession {
} }
} }
func getHandshakeHash() -> Data? {
return sessionQueue.sync {
return handshakeHash
}
}
func reset() { func reset() {
sessionQueue.sync(flags: .barrier) { sessionQueue.sync(flags: .barrier) {
let wasEstablished = state == .established let wasEstablished = state == .established
+1 -2
View File
@@ -6,10 +6,9 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
enum NoiseSessionError: Error { enum NoiseSessionError: Error, Equatable {
case invalidState case invalidState
case notEstablished case notEstablished
case sessionNotFound case sessionNotFound
case handshakeFailed(Error)
case alreadyEstablished case alreadyEstablished
} }
+2 -30
View File
@@ -27,19 +27,6 @@ final class NoiseSessionManager {
// MARK: - Session Management // 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? { func getSession(for peerID: PeerID) -> NoiseSession? {
return managerQueue.sync { return managerQueue.sync {
return sessions[peerID] return sessions[peerID]
@@ -48,14 +35,9 @@ final class NoiseSessionManager {
func removeSession(for peerID: PeerID) { func removeSession(for peerID: PeerID) {
managerQueue.sync(flags: .barrier) { managerQueue.sync(flags: .barrier) {
if let session = sessions[peerID] { if let session = sessions.removeValue(forKey: peerID) {
if session.isEstablished() { session.reset() // Clear sensitive data before removing
SecureLogger.info(.sessionExpired(peerID: peerID.id))
} }
// Clear sensitive data before removing
session.reset()
}
_ = sessions.removeValue(forKey: peerID)
} }
} }
@@ -68,12 +50,6 @@ final class NoiseSessionManager {
} }
} }
func getEstablishedSessions() -> [PeerID: NoiseSession] {
return managerQueue.sync {
return sessions.filter { $0.value.isEstablished() }
}
}
// MARK: - Handshake Helpers // MARK: - Handshake Helpers
func initiateHandshake(with peerID: PeerID) throws -> Data { func initiateHandshake(with peerID: PeerID) throws -> Data {
@@ -207,10 +183,6 @@ final class NoiseSessionManager {
return getSession(for: peerID)?.getRemoteStaticPublicKey() return getSession(for: peerID)?.getRemoteStaticPublicKey()
} }
func getHandshakeHash(for peerID: PeerID) -> Data? {
return getSession(for: peerID)?.getHandshakeHash()
}
// MARK: - Session Rekeying // MARK: - Session Rekeying
func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)] { func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)] {
+81
View File
@@ -0,0 +1,81 @@
//
// SecureNoiseSession.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
final class SecureNoiseSession: NoiseSession {
private(set) var messageCount: UInt64 = 0
private let sessionStartTime = Date()
private(set) var lastActivityTime = Date()
override func encrypt(_ plaintext: Data) throws -> Data {
// Check session age
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
throw NoiseSecurityError.sessionExpired
}
// Check message count
if messageCount >= NoiseSecurityConstants.maxMessagesPerSession {
throw NoiseSecurityError.sessionExhausted
}
// Validate message size
guard NoiseSecurityValidator.validateMessageSize(plaintext) else {
throw NoiseSecurityError.messageTooLarge
}
let encrypted = try super.encrypt(plaintext)
messageCount += 1
lastActivityTime = Date()
return encrypted
}
override func decrypt(_ ciphertext: Data) throws -> Data {
// Check session age
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
throw NoiseSecurityError.sessionExpired
}
// Validate message size
guard NoiseSecurityValidator.validateMessageSize(ciphertext) else {
throw NoiseSecurityError.messageTooLarge
}
let decrypted = try super.decrypt(ciphertext)
lastActivityTime = Date()
return decrypted
}
func needsRenegotiation() -> Bool {
// Check if we've used more than 90% of message limit
let messageThreshold = UInt64(Double(NoiseSecurityConstants.maxMessagesPerSession) * 0.9)
if messageCount >= messageThreshold {
return true
}
// Check if last activity was more than 30 minutes ago
if Date().timeIntervalSince(lastActivityTime) > NoiseSecurityConstants.sessionTimeout {
return true
}
return false
}
// MARK: - Testing Support
#if DEBUG
func setLastActivityTimeForTesting(_ date: Date) {
lastActivityTime = date
}
func setMessageCountForTesting(_ count: UInt64) {
messageCount = count
}
#endif
}
+135
View File
@@ -0,0 +1,135 @@
import Foundation
/// Bech32 encoding for Nostr (minimal implementation)
enum Bech32 {
private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
static func encode(hrp: String, data: Data) throws -> String {
let values = convertBits(from: 8, to: 5, pad: true, data: Array(data))
let checksum = createChecksum(hrp: hrp, values: values)
let combined = values + checksum
return hrp + "1" + combined.map {
let index = charset.index(charset.startIndex, offsetBy: Int($0))
return String(charset[index])
}.joined()
}
static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {
// Find the last occurrence of '1'
guard let separatorIndex = bech32String.lastIndex(of: "1") else {
throw Bech32Error.invalidFormat
}
let hrp = String(bech32String[..<separatorIndex])
// Validate HRP contains only ASCII characters
for char in hrp {
guard char.asciiValue != nil else {
throw Bech32Error.invalidCharacter
}
}
let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])
// Convert characters to values
var values = [UInt8]()
for char in dataString {
guard let index = charset.firstIndex(of: char) else {
throw Bech32Error.invalidCharacter
}
values.append(UInt8(charset.distance(from: charset.startIndex, to: index)))
}
// Verify checksum
guard values.count >= 6 else {
throw Bech32Error.invalidChecksum
}
let payloadValues = Array(values.dropLast(6))
let checksum = Array(values.suffix(6))
let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues)
guard checksum == expectedChecksum else {
throw Bech32Error.invalidChecksum
}
// Convert back to bytes
let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues)
return (hrp: hrp, data: Data(bytes))
}
enum Bech32Error: Error {
case invalidFormat
case invalidCharacter
case invalidChecksum
}
private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] {
var acc = 0
var bits = 0
var result = [UInt8]()
let maxv = (1 << to) - 1
for value in data {
acc = (acc << from) | Int(value)
bits += from
while bits >= to {
bits -= to
result.append(UInt8((acc >> bits) & maxv))
}
}
if pad && bits > 0 {
result.append(UInt8((acc << (to - bits)) & maxv))
}
return result
}
private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] {
let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0]
let polymod = polymod(checksumValues) ^ 1
var checksum = [UInt8]()
for i in 0..<6 {
checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31))
}
return checksum
}
private static func hrpExpand(_ hrp: String) -> [UInt8] {
var result = [UInt8]()
for c in hrp {
guard let asciiValue = c.asciiValue else {
return [] // Return empty array for invalid input
}
result.append(UInt8(asciiValue >> 5))
}
result.append(0)
for c in hrp {
guard let asciiValue = c.asciiValue else {
return [] // Return empty array for invalid input
}
result.append(UInt8(asciiValue & 31))
}
return result
}
private static func polymod(_ values: [UInt8]) -> Int {
var chk = 1
for value in values {
let b = chk >> 25
chk = (chk & 0x1ffffff) << 5 ^ Int(value)
for i in 0..<5 {
if (b >> i) & 1 == 1 {
chk ^= generator[i]
}
}
}
return chk
}
}
+23 -4
View File
@@ -33,13 +33,32 @@ final class GeoRelayDirectory {
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate. /// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] { func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
guard !entries.isEmpty else { return [] } guard !entries.isEmpty, count > 0 else { return [] }
let sorted = entries
if entries.count <= count {
return entries
.sorted { a, b in .sorted { a, b in
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon) haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
} }
.prefix(count) .map { "wss://\($0.host)" }
return sorted.map { "wss://\($0.host)" } }
var best: [(entry: Entry, distance: Double)] = []
best.reserveCapacity(count)
for entry in entries {
let distance = haversineKm(lat, lon, entry.lat, entry.lon)
if best.count < count {
let idx = best.firstIndex { $0.distance > distance } ?? best.count
best.insert((entry, distance), at: idx)
} else if let worstDistance = best.last?.distance, distance < worstDistance {
let idx = best.firstIndex { $0.distance > distance } ?? best.count
best.insert((entry, distance), at: idx)
best.removeLast()
}
}
return best.map { "wss://\($0.entry.host)" }
} }
// MARK: - Remote Fetch // MARK: - Remote Fetch
+50
View File
@@ -0,0 +1,50 @@
import Foundation
protocol KeychainHelperProtocol {
func save(key: String, data: Data, service: String, accessible: CFString?)
func load(key: String, service: String) -> Data?
func delete(key: String, service: String)
}
/// Keychain helper for secure storage
struct KeychainHelper: KeychainHelperProtocol {
func save(key: String, data: Data, service: String, accessible: CFString? = nil) {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data
]
if let accessible = accessible {
query[kSecAttrAccessible as String] = accessible
}
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
}
func load(key: String, service: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else { return nil }
return result as? Data
}
func delete(key: String, service: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
}
}
-308
View File
@@ -1,50 +1,5 @@
import Foundation import Foundation
import CryptoKit
import P256K import P256K
import Security
// Keychain helper for secure storage
struct KeychainHelper {
static func save(key: String, data: Data, service: String, accessible: CFString? = nil) {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data
]
if let accessible = accessible {
query[kSecAttrAccessible as String] = accessible
}
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
}
static func load(key: String, service: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else { return nil }
return result as? Data
}
static func delete(key: String, service: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
}
}
/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging /// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
struct NostrIdentity: Codable { struct NostrIdentity: Codable {
@@ -103,266 +58,3 @@ struct NostrIdentity: Codable {
return publicKey.hexEncodedString() return publicKey.hexEncodedString()
} }
} }
/// Bridge between Noise and Nostr identities
struct NostrIdentityBridge {
private static let keychainService = "chat.bitchat.nostr"
private static let currentIdentityKey = "nostr-current-identity"
private static let deviceSeedKey = "nostr-device-seed"
// In-memory cache to avoid transient keychain access issues
private static var deviceSeedCache: Data?
/// Get or create the current Nostr identity
static func getCurrentNostrIdentity() throws -> NostrIdentity? {
// Check if we already have a Nostr identity
if let existingData = KeychainHelper.load(key: currentIdentityKey, service: keychainService),
let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) {
return identity
}
// Generate new Nostr identity
let nostrIdentity = try NostrIdentity.generate()
// Store it
let data = try JSONEncoder().encode(nostrIdentity)
KeychainHelper.save(key: currentIdentityKey, data: data, service: keychainService)
return nostrIdentity
}
/// Associate a Nostr identity with a Noise public key (for favorites)
static func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
if let data = nostrPubkey.data(using: .utf8) {
KeychainHelper.save(key: key, data: data, service: keychainService)
}
}
/// Get Nostr public key associated with a Noise public key
static func getNostrPublicKey(for noisePublicKey: Data) -> String? {
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
guard let data = KeychainHelper.load(key: key, service: keychainService),
let pubkey = String(data: data, encoding: .utf8) else {
return nil
}
return pubkey
}
/// Clear all Nostr identity associations and current identity
static func clearAllAssociations() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecMatchLimit as String: kSecMatchLimitAll,
kSecReturnAttributes as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess, let items = result as? [[String: Any]] {
for item in items {
var deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService
]
if let account = item[kSecAttrAccount as String] as? String {
deleteQuery[kSecAttrAccount as String] = account
}
SecItemDelete(deleteQuery as CFDictionary)
}
} else if status == errSecItemNotFound {
// nothing persisted; no action needed
}
deviceSeedCache = nil
}
// MARK: - Per-Geohash Identities (Location Channels)
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
/// Stored only on device keychain.
private static func getOrCreateDeviceSeed() -> Data {
if let cached = deviceSeedCache { return cached }
if let existing = KeychainHelper.load(key: deviceSeedKey, service: keychainService) {
// Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock
KeychainHelper.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
deviceSeedCache = existing
return existing
}
var seed = Data(count: 32)
_ = seed.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
}
// Ensure availability after first unlock to prevent unintended rotation when locked
KeychainHelper.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
deviceSeedCache = seed
return seed
}
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
/// if the candidate is not a valid secp256k1 private key.
static func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
let seed = getOrCreateDeviceSeed()
guard let msg = geohash.data(using: .utf8) else {
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
}
func candidateKey(iteration: UInt32) -> Data {
var input = Data(msg)
var iterBE = iteration.bigEndian
withUnsafeBytes(of: &iterBE) { bytes in
input.append(contentsOf: bytes)
}
let code = CryptoKit.HMAC<CryptoKit.SHA256>.authenticationCode(for: input, using: SymmetricKey(data: seed))
return Data(code)
}
// Try a few iterations to ensure a valid key can be formed
for i in 0..<10 {
let keyData = candidateKey(iteration: UInt32(i))
if let identity = try? NostrIdentity(privateKeyData: keyData) {
return identity
}
}
// As a final fallback, hash the seed+msg and try again
let fallback = (seed + msg).sha256Hash()
return try NostrIdentity(privateKeyData: fallback)
}
}
// Bech32 encoding for Nostr (minimal implementation)
enum Bech32 {
private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
static func encode(hrp: String, data: Data) throws -> String {
let values = convertBits(from: 8, to: 5, pad: true, data: Array(data))
let checksum = createChecksum(hrp: hrp, values: values)
let combined = values + checksum
return hrp + "1" + combined.map {
let index = charset.index(charset.startIndex, offsetBy: Int($0))
return String(charset[index])
}.joined()
}
static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {
// Find the last occurrence of '1'
guard let separatorIndex = bech32String.lastIndex(of: "1") else {
throw Bech32Error.invalidFormat
}
let hrp = String(bech32String[..<separatorIndex])
// Validate HRP contains only ASCII characters
for char in hrp {
guard char.asciiValue != nil else {
throw Bech32Error.invalidCharacter
}
}
let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])
// Convert characters to values
var values = [UInt8]()
for char in dataString {
guard let index = charset.firstIndex(of: char) else {
throw Bech32Error.invalidCharacter
}
values.append(UInt8(charset.distance(from: charset.startIndex, to: index)))
}
// Verify checksum
guard values.count >= 6 else {
throw Bech32Error.invalidChecksum
}
let payloadValues = Array(values.dropLast(6))
let checksum = Array(values.suffix(6))
let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues)
guard checksum == expectedChecksum else {
throw Bech32Error.invalidChecksum
}
// Convert back to bytes
let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues)
return (hrp: hrp, data: Data(bytes))
}
enum Bech32Error: Error {
case invalidFormat
case invalidCharacter
case invalidChecksum
}
private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] {
var acc = 0
var bits = 0
var result = [UInt8]()
let maxv = (1 << to) - 1
for value in data {
acc = (acc << from) | Int(value)
bits += from
while bits >= to {
bits -= to
result.append(UInt8((acc >> bits) & maxv))
}
}
if pad && bits > 0 {
result.append(UInt8((acc << (to - bits)) & maxv))
}
return result
}
private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] {
let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0]
let polymod = polymod(checksumValues) ^ 1
var checksum = [UInt8]()
for i in 0..<6 {
checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31))
}
return checksum
}
private static func hrpExpand(_ hrp: String) -> [UInt8] {
var result = [UInt8]()
for c in hrp {
guard let asciiValue = c.asciiValue else {
return [] // Return empty array for invalid input
}
result.append(UInt8(asciiValue >> 5))
}
result.append(0)
for c in hrp {
guard let asciiValue = c.asciiValue else {
return [] // Return empty array for invalid input
}
result.append(UInt8(asciiValue & 31))
}
return result
}
private static func polymod(_ values: [UInt8]) -> Int {
var chk = 1
for value in values {
let b = chk >> 25
chk = (chk & 0x1ffffff) << 5 ^ Int(value)
for i in 0..<5 {
if (b >> i) & 1 == 1 {
chk ^= generator[i]
}
}
}
return chk
}
}
// Data hex encoding extension moved to BinaryEncodingUtils.swift to avoid duplication
+135
View File
@@ -0,0 +1,135 @@
import Foundation
import CryptoKit
/// Bridge between Noise and Nostr identities
final class NostrIdentityBridge {
private let keychainService = "chat.bitchat.nostr"
private let currentIdentityKey = "nostr-current-identity"
private let deviceSeedKey = "nostr-device-seed"
// In-memory cache to avoid transient keychain access issues
private var deviceSeedCache: Data?
private let keychain: KeychainHelperProtocol
init(keychain: KeychainHelperProtocol = KeychainHelper()) {
self.keychain = keychain
}
/// Get or create the current Nostr identity
func getCurrentNostrIdentity() throws -> NostrIdentity? {
// Check if we already have a Nostr identity
if let existingData = keychain.load(key: currentIdentityKey, service: keychainService),
let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) {
return identity
}
// Generate new Nostr identity
let nostrIdentity = try NostrIdentity.generate()
// Store it
let data = try JSONEncoder().encode(nostrIdentity)
keychain.save(key: currentIdentityKey, data: data, service: keychainService, accessible: nil)
return nostrIdentity
}
/// Associate a Nostr identity with a Noise public key (for favorites)
func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
if let data = nostrPubkey.data(using: .utf8) {
keychain.save(key: key, data: data, service: keychainService, accessible: nil)
}
}
/// Get Nostr public key associated with a Noise public key
func getNostrPublicKey(for noisePublicKey: Data) -> String? {
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
guard let data = keychain.load(key: key, service: keychainService),
let pubkey = String(data: data, encoding: .utf8) else {
return nil
}
return pubkey
}
/// Clear all Nostr identity associations and current identity
func clearAllAssociations() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecMatchLimit as String: kSecMatchLimitAll,
kSecReturnAttributes as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess, let items = result as? [[String: Any]] {
for item in items {
var deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService
]
if let account = item[kSecAttrAccount as String] as? String {
deleteQuery[kSecAttrAccount as String] = account
}
SecItemDelete(deleteQuery as CFDictionary)
}
} else if status == errSecItemNotFound {
// nothing persisted; no action needed
}
deviceSeedCache = nil
}
// MARK: - Per-Geohash Identities (Location Channels)
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
/// Stored only on device keychain.
private func getOrCreateDeviceSeed() -> Data {
if let cached = deviceSeedCache { return cached }
if let existing = keychain.load(key: deviceSeedKey, service: keychainService) {
// Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock
keychain.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
deviceSeedCache = existing
return existing
}
var seed = Data(count: 32)
_ = seed.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
}
// Ensure availability after first unlock to prevent unintended rotation when locked
keychain.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
deviceSeedCache = seed
return seed
}
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
/// if the candidate is not a valid secp256k1 private key.
func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
let seed = getOrCreateDeviceSeed()
guard let msg = geohash.data(using: .utf8) else {
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
}
func candidateKey(iteration: UInt32) -> Data {
var input = Data(msg)
var iterBE = iteration.bigEndian
withUnsafeBytes(of: &iterBE) { bytes in
input.append(contentsOf: bytes)
}
let code = HMAC<SHA256>.authenticationCode(for: input, using: SymmetricKey(data: seed))
return Data(code)
}
// Try a few iterations to ensure a valid key can be formed
for i in 0..<10 {
let keyData = candidateKey(iteration: UInt32(i))
if let identity = try? NostrIdentity(privateKeyData: keyData) {
return identity
}
}
// As a final fallback, hash the seed+msg and try again
let fallback = (seed + msg).sha256Hash()
return try NostrIdentity(privateKeyData: fallback)
}
}
+10
View File
@@ -906,6 +906,16 @@ struct NostrFilter: Encodable {
filter.limit = limit filter.limit = limit
return filter return filter
} }
// For location notes with neighbors: subscribe to multiple geohashes (center + neighbors)
static func geohashNotes(_ geohashes: [String], since: Date? = nil, limit: Int = 200) -> NostrFilter {
var filter = NostrFilter()
filter.kinds = [1]
filter.since = since?.timeIntervalSince1970.toInt()
filter.tagFilters = ["g": geohashes]
filter.limit = limit
return filter
}
} }
// Dynamic coding key for tag filters // Dynamic coding key for tag filters
+53
View File
@@ -119,4 +119,57 @@ enum Geohash {
} }
return (latInterval.0, latInterval.1, lonInterval.0, lonInterval.1) return (latInterval.0, latInterval.1, lonInterval.0, lonInterval.1)
} }
/// Returns all 8 neighboring geohash cells at the same precision.
/// - Parameter geohash: Base32 geohash string.
/// - Returns: Array of 8 neighboring geohashes (N, NE, E, SE, S, SW, W, NW order).
static func neighbors(of geohash: String) -> [String] {
guard !geohash.isEmpty else { return [] }
let precision = geohash.count
let bounds = decodeBounds(geohash)
let center = decodeCenter(geohash)
// Calculate cell dimensions
let latHeight = bounds.latMax - bounds.latMin
let lonWidth = bounds.lonMax - bounds.lonMin
// Helper to wrap longitude around ±180
func wrapLongitude(_ lon: Double) -> Double {
var wrapped = lon
while wrapped > 180.0 { wrapped -= 360.0 }
while wrapped < -180.0 { wrapped += 360.0 }
return wrapped
}
// Helper to clamp latitude to ±90
func clampLatitude(_ lat: Double) -> Double {
return max(-90.0, min(90.0, lat))
}
// Calculate 8 neighbor centers
let neighbors: [(lat: Double, lon: Double)] = [
(center.lat + latHeight, center.lon), // N
(center.lat + latHeight, center.lon + lonWidth), // NE
(center.lat, center.lon + lonWidth), // E
(center.lat - latHeight, center.lon + lonWidth), // SE
(center.lat - latHeight, center.lon), // S
(center.lat - latHeight, center.lon - lonWidth), // SW
(center.lat, center.lon - lonWidth), // W
(center.lat + latHeight, center.lon - lonWidth) // NW
]
// Encode each neighbor, handling boundary conditions
return neighbors.compactMap { neighbor in
let lat = clampLatitude(neighbor.lat)
let lon = wrapLongitude(neighbor.lon)
// Skip if we've crossed a pole (latitude clamped to boundary)
if (neighbor.lat > 90.0 || neighbor.lat < -90.0) {
return nil
}
return encode(latitude: lat, longitude: lon, precision: precision)
}
}
} }
+96 -102
View File
@@ -94,6 +94,7 @@ final class BLEService: NSObject {
private var noiseService: NoiseEncryptionService private var noiseService: NoiseEncryptionService
private let identityManager: SecureIdentityStateManagerProtocol private let identityManager: SecureIdentityStateManagerProtocol
private let keychain: KeychainManagerProtocol private let keychain: KeychainManagerProtocol
private let idBridge: NostrIdentityBridge
private var myPeerIDData: Data = Data() private var myPeerIDData: Data = Data()
// MARK: - Advertising Privacy // MARK: - Advertising Privacy
@@ -198,8 +199,13 @@ final class BLEService: NSObject {
// MARK: - Initialization // MARK: - Initialization
init(keychain: KeychainManagerProtocol, identityManager: SecureIdentityStateManagerProtocol) { init(
keychain: KeychainManagerProtocol,
idBridge: NostrIdentityBridge,
identityManager: SecureIdentityStateManagerProtocol
) {
self.keychain = keychain self.keychain = keychain
self.idBridge = idBridge
noiseService = NoiseEncryptionService(keychain: keychain) noiseService = NoiseEncryptionService(keychain: keychain)
self.identityManager = identityManager self.identityManager = identityManager
super.init() super.init()
@@ -315,48 +321,49 @@ final class BLEService: NSObject {
startServices() startServices()
} }
func sendMessage(_ content: String, mentions: [String] = [], to recipientID: String? = nil, messageID: String? = nil, timestamp: Date? = nil) {
// Ensure this runs on message queue to avoid main thread blocking // Ensure this runs on message queue to avoid main thread blocking
func sendMessage(_ content: String, mentions: [String] = [], to recipientID: String? = nil, messageID: String? = nil, timestamp: Date? = nil) {
// Call directly if already on messageQueue, otherwise dispatch
if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
messageQueue.async { [weak self] in messageQueue.async { [weak self] in
guard let self = self else { return } self?.sendMessage(content, mentions: mentions, to: recipientID, messageID: messageID, timestamp: timestamp)
}
return
}
guard content.count <= self.maxMessageLength else { guard content.count <= maxMessageLength else {
SecureLogger.error("Message too long: \(content.count) chars", category: .session) SecureLogger.error("Message too long: \(content.count) chars", category: .session)
return return
} }
let finalMessageID = messageID ?? UUID().uuidString if let recipientID {
let _ = UInt64(Date().timeIntervalSince1970 * 1000) sendPrivateMessage(content, to: recipientID, messageID: messageID ?? UUID().uuidString)
return
}
if let recipientID = recipientID {
// Private message
self.sendPrivateMessage(content, to: recipientID, messageID: finalMessageID)
} else {
// Public broadcast // Public broadcast
// Create packet with explicit fields so we can sign it // Create packet with explicit fields so we can sign it
let basePacket = BitchatPacket( let basePacket = BitchatPacket(
type: MessageType.message.rawValue, type: MessageType.message.rawValue,
senderID: Data(hexString: self.myPeerID.id) ?? Data(), senderID: Data(hexString: myPeerID.id) ?? Data(),
recipientID: nil, recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: Data(content.utf8), payload: Data(content.utf8),
signature: nil, signature: nil,
ttl: self.messageTTL ttl: messageTTL
) )
guard let signedPacket = self.noiseService.signPacket(basePacket) else { guard let signedPacket = noiseService.signPacket(basePacket) else {
SecureLogger.error("❌ Failed to sign public message", category: .security) SecureLogger.error("❌ Failed to sign public message", category: .security)
return return
} }
// Pre-mark our own broadcast as processed to avoid handling relayed self copy // Pre-mark our own broadcast as processed to avoid handling relayed self copy
let senderHex = signedPacket.senderID.hexEncodedString() let senderHex = signedPacket.senderID.hexEncodedString()
let dedupID = "\(senderHex)-\(signedPacket.timestamp)-\(signedPacket.type)" let dedupID = "\(senderHex)-\(signedPacket.timestamp)-\(signedPacket.type)"
self.messageDeduplicator.markProcessed(dedupID) messageDeduplicator.markProcessed(dedupID)
// Call synchronously since we're already on background queue // Call synchronously since we're already on background queue
self.broadcastPacket(signedPacket) broadcastPacket(signedPacket)
// Track our own broadcast for sync // Track our own broadcast for sync
self.gossipSyncManager?.onPublicPacketSeen(signedPacket) gossipSyncManager?.onPublicPacketSeen(signedPacket)
}
}
} }
// MARK: - Transport Protocol Conformance // MARK: - Transport Protocol Conformance
@@ -590,11 +597,7 @@ final class BLEService: NSObject {
signature: nil, signature: nil,
ttl: messageTTL ttl: messageTTL
) )
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
broadcastPacket(packet) broadcastPacket(packet)
} else {
messageQueue.async { [weak self] in self?.broadcastPacket(packet) }
}
} catch { } catch {
SecureLogger.error("Failed to send read receipt: \(error)") SecureLogger.error("Failed to send read receipt: \(error)")
} }
@@ -616,7 +619,7 @@ final class BLEService: NSObject {
var content = isFavorite ? "[FAVORITED]" : "[UNFAVORITED]" var content = isFavorite ? "[FAVORITED]" : "[UNFAVORITED]"
// Add our Nostr public key if available // Add our Nostr public key if available
if let myNostrIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() { if let myNostrIdentity = try? idBridge.getCurrentNostrIdentity() {
content += ":" + myNostrIdentity.npub content += ":" + myNostrIdentity.npub
SecureLogger.debug("📝 Sending favorite notification with Nostr npub: \(myNostrIdentity.npub)", category: .session) SecureLogger.debug("📝 Sending favorite notification with Nostr npub: \(myNostrIdentity.npub)", category: .session)
} }
@@ -677,19 +680,11 @@ final class BLEService: NSObject {
// MARK: - GossipSyncManager Delegate // MARK: - GossipSyncManager Delegate
extension BLEService: GossipSyncManager.Delegate { extension BLEService: GossipSyncManager.Delegate {
func sendPacket(_ packet: BitchatPacket) { func sendPacket(_ packet: BitchatPacket) {
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
broadcastPacket(packet) broadcastPacket(packet)
} else {
messageQueue.async { [weak self] in self?.broadcastPacket(packet) }
}
} }
func sendPacket(to peerID: PeerID, packet: BitchatPacket) { func sendPacket(to peerID: PeerID, packet: BitchatPacket) {
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
sendPacketDirected(packet, to: peerID) sendPacketDirected(packet, to: peerID)
} else {
messageQueue.async { [weak self] in self?.sendPacketDirected(packet, to: peerID) }
}
} }
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket { func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {
@@ -1056,13 +1051,7 @@ extension BLEService {
peers[normalizedID] = p peers[normalizedID] = p
} }
} }
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
handleReceivedPacket(packet, from: fromPeerID) handleReceivedPacket(packet, from: fromPeerID)
} else {
messageQueue.async { [weak self] in
self?.handleReceivedPacket(packet, from: fromPeerID)
}
}
} }
} }
#endif #endif
@@ -1525,6 +1514,22 @@ extension BLEService {
} }
} }
/// Safely fetch the current direct-link state for a peer using the BLE queue.
private func linkState(for peerID: PeerID) -> (hasPeripheral: Bool, hasCentral: Bool) {
let computeState = { () -> (Bool, Bool) in
let peripheralUUID = self.peerToPeripheralUUID[peerID]
let hasPeripheral = peripheralUUID.flatMap { self.peripherals[$0]?.isConnected } ?? false
let hasCentral = self.centralToPeerID.values.contains(peerID)
return (hasPeripheral, hasCentral)
}
if DispatchQueue.getSpecific(key: bleQueueKey) != nil {
return computeState()
} else {
return bleQueue.sync { computeState() }
}
}
private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) { private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
service.onPeerAuthenticated = { [weak self] peerID, fingerprint in service.onPeerAuthenticated = { [weak self] peerID, fingerprint in
let peerID = PeerID(str: peerID) let peerID = PeerID(str: peerID)
@@ -1570,11 +1575,7 @@ extension BLEService {
signature: nil, signature: nil,
ttl: messageTTL ttl: messageTTL
) )
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
broadcastPacket(packet) broadcastPacket(packet)
} else {
messageQueue.async { [weak self] in self?.broadcastPacket(packet) }
}
} catch { } catch {
SecureLogger.error("Failed to send verification payload: \(error)") SecureLogger.error("Failed to send verification payload: \(error)")
} }
@@ -1774,14 +1775,8 @@ extension BLEService {
signature: nil, signature: nil,
ttl: messageTTL ttl: messageTTL
) )
// Call directly if already on messageQueue, otherwise dispatch
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
broadcastPacket(packet) broadcastPacket(packet)
} else {
messageQueue.async { [weak self] in
self?.broadcastPacket(packet)
}
}
// Notify delegate that message was sent // Notify delegate that message was sent
notifyUI { [weak self] in notifyUI { [weak self] in
@@ -1828,14 +1823,7 @@ extension BLEService {
signature: nil, signature: nil,
ttl: messageTTL ttl: messageTTL
) )
// Call directly if on messageQueue, otherwise dispatch
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
broadcastPacket(packet) broadcastPacket(packet)
} else {
messageQueue.async { [weak self] in
self?.broadcastPacket(packet)
}
}
} catch { } catch {
SecureLogger.error("Failed to initiate handshake: \(error)") SecureLogger.error("Failed to initiate handshake: \(error)")
} }
@@ -1901,6 +1889,14 @@ extension BLEService {
// MARK: Packet Broadcasting // MARK: Packet Broadcasting
private func broadcastPacket(_ packet: BitchatPacket) { private func broadcastPacket(_ packet: BitchatPacket) {
// Call directly if already on messageQueue, otherwise dispatch
if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
messageQueue.async { [weak self] in
self?.broadcastPacket(packet)
}
return
}
// Encode once using a small per-type padding policy, then delegate by type // Encode once using a small per-type padding policy, then delegate by type
let padForBLE = padPolicy(for: packet.type) let padForBLE = padPolicy(for: packet.type)
guard let data = packet.toBinaryData(padding: padForBLE) else { guard let data = packet.toBinaryData(padding: padForBLE) else {
@@ -2091,6 +2087,14 @@ extension BLEService {
// Directed send helper (unicast to a specific peerID) without altering packet contents // Directed send helper (unicast to a specific peerID) without altering packet contents
private func sendPacketDirected(_ packet: BitchatPacket, to peerID: PeerID) { private func sendPacketDirected(_ packet: BitchatPacket, to peerID: PeerID) {
// Call directly if already on messageQueue, otherwise dispatch
if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
messageQueue.async { [weak self] in
self?.sendPacketDirected(packet, to: peerID)
}
return
}
guard let data = packet.toBinaryData(padding: false) else { return } guard let data = packet.toBinaryData(padding: false) else { return }
sendOnAllLinks(packet: packet, data: data, pad: false, directedOnlyPeer: peerID) sendOnAllLinks(packet: packet, data: data, pad: false, directedOnlyPeer: peerID)
} }
@@ -2125,9 +2129,8 @@ extension BLEService {
} }
return out return out
} }
guard !toSend.isEmpty else { return }
for (_, packet) in toSend { for (_, packet) in toSend {
messageQueue.async { [weak self] in self?.broadcastPacket(packet) } broadcastPacket(packet)
} }
} }
@@ -2146,19 +2149,6 @@ extension BLEService {
} }
} }
private func sendData(_ data: Data, to peripheral: CBPeripheral) {
// Fire-and-forget: Simple send without complex fallback logic
guard peripheral.state == .connected else { return }
let peripheralUUID = peripheral.identifier.uuidString
guard let state = peripherals[peripheralUUID],
let characteristic = state.characteristic else { return }
// Fire-and-forget principle: always use .withoutResponse for speed
// CoreBluetooth will handle fragmentation at L2CAP layer
writeOrEnqueue(data, to: peripheral, characteristic: characteristic)
}
// MARK: Fragmentation (Required for messages > BLE MTU) // MARK: Fragmentation (Required for messages > BLE MTU)
private func sendFragmentedPacket(_ packet: BitchatPacket, pad: Bool, maxChunk: Int? = nil, directedOnlyPeer: PeerID? = nil) { private func sendFragmentedPacket(_ packet: BitchatPacket, pad: Bool, maxChunk: Int? = nil, directedOnlyPeer: PeerID? = nil) {
@@ -2219,6 +2209,16 @@ extension BLEService {
} }
private func handleFragment(_ packet: BitchatPacket, from peerID: PeerID) { private func handleFragment(_ packet: BitchatPacket, from peerID: PeerID) {
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
_handleFragment(packet, from: peerID)
} else {
messageQueue.async(flags: .barrier) { [weak self] in
self?._handleFragment(packet, from: peerID)
}
}
}
private func _handleFragment(_ packet: BitchatPacket, from peerID: PeerID) {
// Don't process our own fragments // Don't process our own fragments
if peerID == myPeerID { if peerID == myPeerID {
return return
@@ -2288,6 +2288,14 @@ extension BLEService {
// MARK: Packet Reception // MARK: Packet Reception
private func handleReceivedPacket(_ packet: BitchatPacket, from peerID: PeerID) { private func handleReceivedPacket(_ packet: BitchatPacket, from peerID: PeerID) {
// Call directly if already on messageQueue, otherwise dispatch
if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
messageQueue.async { [weak self] in
self?.handleReceivedPacket(packet, from: peerID)
}
return
}
// Deduplication (thread-safe) // Deduplication (thread-safe)
let senderID = PeerID(hexData: packet.senderID) let senderID = PeerID(hexData: packet.senderID)
// Include packet type in message ID to prevent collisions between different packet types // Include packet type in message ID to prevent collisions between different packet types
@@ -2453,15 +2461,15 @@ extension BLEService {
// Track if this is a new or reconnected peer // Track if this is a new or reconnected peer
var isNewPeer = false var isNewPeer = false
var isReconnectedPeer = false var isReconnectedPeer = false
let directLinkState = linkState(for: peerID)
collectionsQueue.sync(flags: .barrier) { collectionsQueue.sync(flags: .barrier) {
// Check if we have an actual BLE connection to this peer // Check if we have an actual BLE connection to this peer
let peripheralUUID = peerToPeripheralUUID[peerID] let hasPeripheralConnection = directLinkState.hasPeripheral
let hasPeripheralConnection = peripheralUUID != nil && peripherals[peripheralUUID!]?.isConnected == true
// Check if this peer is subscribed to us as a central // Check if this peer is subscribed to us as a central
// Note: We can't identify which specific central is which peer without additional mapping // Note: We can't identify which specific central is which peer without additional mapping
let hasCentralSubscription = centralToPeerID.values.contains(peerID) let hasCentralSubscription = directLinkState.hasCentral
// Direct announces arrive with full TTL (no prior hop) // Direct announces arrive with full TTL (no prior hop)
let isDirectAnnounce = (packet.ttl == messageTTL) let isDirectAnnounce = (packet.ttl == messageTTL)
@@ -2631,18 +2639,20 @@ extension BLEService {
var accepted = false var accepted = false
var senderNickname: String = "" var senderNickname: String = ""
// Snapshot peers to avoid concurrent mutation while iterating during nickname collision checks.
let peersSnapshot = collectionsQueue.sync { peers }
// If the packet is from ourselves (e.g., recovered via sync TTL==0), accept immediately // If the packet is from ourselves (e.g., recovered via sync TTL==0), accept immediately
if peerID == myPeerID { if peerID == myPeerID {
accepted = true accepted = true
senderNickname = myNickname senderNickname = myNickname
} }
else if let info = peers[peerID], info.isVerifiedNickname { else if let info = peersSnapshot[peerID], info.isVerifiedNickname {
// Known verified peer path // Known verified peer path
accepted = true accepted = true
senderNickname = info.nickname senderNickname = info.nickname
// Handle nickname collisions // Handle nickname collisions
let hasCollision = peers.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID } || (myNickname == info.nickname) let hasCollision = peersSnapshot.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID } || (myNickname == info.nickname)
if hasCollision { if hasCollision {
senderNickname += "#" + String(peerID.id.prefix(4)) senderNickname += "#" + String(peerID.id.prefix(4))
} }
@@ -2693,12 +2703,8 @@ extension BLEService {
return return
} }
// Determine if we have a direct link to the sender // Determine if we have a direct link to the sender
let hasDirectLink: Bool = collectionsQueue.sync { let directLink = linkState(for: peerID)
let perUUID = peerToPeripheralUUID[peerID] let hasDirectLink = directLink.hasPeripheral || directLink.hasCentral
let perConnected = perUUID != nil && peripherals[perUUID!]?.isConnected == true
let hasCentral = centralToPeerID.values.contains(peerID)
return perConnected || hasCentral
}
let pathTag = hasDirectLink ? "direct" : "mesh" let pathTag = hasDirectLink ? "direct" : "mesh"
SecureLogger.debug("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: .session) SecureLogger.debug("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: .session)
@@ -2829,17 +2835,6 @@ extension BLEService {
// MARK: Helper Functions // MARK: Helper Functions
private func sendLeave() {
SecureLogger.debug("👋 Sending leave announcement", category: .session)
let packet = BitchatPacket(
type: MessageType.leave.rawValue,
ttl: messageTTL,
senderID: myPeerID,
payload: Data(myNickname.utf8)
)
broadcastPacket(packet)
}
private func sendAnnounce(forceSend: Bool = false) { private func sendAnnounce(forceSend: Bool = false) {
// Throttle announces to prevent flooding // Throttle announces to prevent flooding
let now = Date() let now = Date()
@@ -2888,14 +2883,8 @@ extension BLEService {
return return
} }
// Call directly if on messageQueue, otherwise dispatch
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
broadcastPacket(signedPacket) broadcastPacket(signedPacket)
} else {
messageQueue.async { [weak self] in
self?.broadcastPacket(signedPacket)
}
}
// Ensure our own announce is included in sync state // Ensure our own announce is included in sync state
gossipSyncManager?.onPublicPacketSeen(signedPacket) gossipSyncManager?.onPublicPacketSeen(signedPacket)
} }
@@ -3047,6 +3036,11 @@ extension BLEService {
private func checkPeerConnectivity() { private func checkPeerConnectivity() {
let now = Date() let now = Date()
var disconnectedPeers: [String] = [] var disconnectedPeers: [String] = []
let peerIDsForLinkState: [PeerID] = collectionsQueue.sync { Array(peers.keys) }
var cachedLinkStates: [PeerID: (hasPeripheral: Bool, hasCentral: Bool)] = [:]
for peerID in peerIDsForLinkState {
cachedLinkStates[peerID] = linkState(for: peerID)
}
var removedOfflineCount = 0 var removedOfflineCount = 0
collectionsQueue.sync(flags: .barrier) { collectionsQueue.sync(flags: .barrier) {
@@ -3055,9 +3049,9 @@ extension BLEService {
let retention: TimeInterval = peer.isVerifiedNickname ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds let retention: TimeInterval = peer.isVerifiedNickname ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds
if peer.isConnected && age > TransportConfig.blePeerInactivityTimeoutSeconds { if peer.isConnected && age > TransportConfig.blePeerInactivityTimeoutSeconds {
// Check if we still have an active BLE connection to this peer // Check if we still have an active BLE connection to this peer
let hasPeripheralConnection = peerToPeripheralUUID[peerID] != nil && let state = cachedLinkStates[peerID] ?? (hasPeripheral: false, hasCentral: false)
peripherals[peerToPeripheralUUID[peerID]!]?.isConnected == true let hasPeripheralConnection = state.hasPeripheral
let hasCentralConnection = centralToPeerID.values.contains(peerID) let hasCentralConnection = state.hasCentral
// If direct link is gone, mark as not connected (retain entry for reachability) // If direct link is gone, mark as not connected (retain entry for reachability)
if !hasPeripheralConnection && !hasCentralConnection { if !hasPeripheralConnection && !hasCentralConnection {
+7 -25
View File
@@ -42,7 +42,7 @@ final class CommandProcessor {
case .location: return true case .location: return true
} }
}() }()
let inGeoDM = (chatViewModel?.selectedPrivateChatPeer?.hasPrefix("nostr_") == true) let inGeoDM = chatViewModel?.selectedPrivateChatPeer?.isGeoDM == true
switch cmd { switch cmd {
case "/m", "/msg": case "/m", "/msg":
@@ -65,9 +65,6 @@ final class CommandProcessor {
case "/unfav": case "/unfav":
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") } if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
return handleFavorite(args, add: false) return handleFavorite(args, add: false)
//
case "/help", "/h":
return .error(message: "unknown command: \(cmd)")
default: default:
return .error(message: "unknown command: \(cmd)") return .error(message: "unknown command: \(cmd)")
} }
@@ -104,7 +101,7 @@ final class CommandProcessor {
case .location(let ch): case .location(let ch):
// Geohash context: show visible geohash participants (exclude self) // Geohash context: show visible geohash participants (exclude self)
guard let vm = chatViewModel else { return .success(message: "nobody around") } guard let vm = chatViewModel else { return .success(message: "nobody around") }
let myHex = (try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased() let myHex = (try? chatViewModel?.idBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
let people = vm.visibleGeohashPeople().filter { person in let people = vm.visibleGeohashPeople().filter { person in
if let me = myHex { return person.id.lowercased() != me } if let me = myHex { return person.id.lowercased() != me }
return true return true
@@ -148,9 +145,9 @@ final class CommandProcessor {
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
@@ -214,7 +211,7 @@ 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 identityManager.isBlocked(fingerprint: fingerprint) {
return .success(message: "\(nickname) is already blocked") return .success(message: "\(nickname) is already blocked")
} }
@@ -258,7 +255,7 @@ 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 !identityManager.isBlocked(fingerprint: fingerprint) {
return .success(message: "\(nickname) is not blocked") return .success(message: "\(nickname) is not blocked")
} }
@@ -285,7 +282,7 @@ final class CommandProcessor {
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname), guard let peerID = chatViewModel?.getPeerIDForNickname(nickname),
let noisePublicKey = Data(hexString: peerID) else { let noisePublicKey = Data(hexString: peerID.id) else {
return .error(message: "can't find peer: \(nickname)") return .error(message: "can't find peer: \(nickname)")
} }
@@ -311,19 +308,4 @@ final class CommandProcessor {
} }
} }
private func handleHelp() -> CommandResult {
let helpText = """
commands:
/msg @name - start private chat
/who - list who's online
/clear - clear messages
/hug @name - send a hug
/slap @name - slap with a trout
/fav @name - add to favorites
/unfav @name - remove from favorites
/block @name - block
/unblock @name - unblock
"""
return .success(message: helpText)
}
} }
@@ -26,6 +26,7 @@ final class FavoritesPersistenceService: ObservableObject {
private static let storageKey = "chat.bitchat.favorites" private static let storageKey = "chat.bitchat.favorites"
private static let keychainService = "chat.bitchat.favorites" private static let keychainService = "chat.bitchat.favorites"
private let keychain: KeychainHelperProtocol
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship @Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
@Published private(set) var mutualFavorites: Set<Data> = [] @Published private(set) var mutualFavorites: Set<Data> = []
@@ -35,7 +36,8 @@ final class FavoritesPersistenceService: ObservableObject {
static let shared = FavoritesPersistenceService() static let shared = FavoritesPersistenceService()
private init() { init(keychain: KeychainHelperProtocol = KeychainHelper()) {
self.keychain = keychain
loadFavorites() loadFavorites()
// Update mutual favorites when favorites change // Update mutual favorites when favorites change
@@ -196,7 +198,7 @@ final class FavoritesPersistenceService: ObservableObject {
saveFavorites() saveFavorites()
// Delete from keychain directly // Delete from keychain directly
KeychainHelper.delete( keychain.delete(
key: Self.storageKey, key: Self.storageKey,
service: Self.keychainService service: Self.keychainService
) )
@@ -216,10 +218,11 @@ final class FavoritesPersistenceService: ObservableObject {
let data = try encoder.encode(relationships) let data = try encoder.encode(relationships)
// Store in keychain for security // Store in keychain for security
KeychainHelper.save( keychain.save(
key: Self.storageKey, key: Self.storageKey,
data: data, data: data,
service: Self.keychainService service: Self.keychainService,
accessible: nil
) )
// Successfully saved favorites // Successfully saved favorites
@@ -231,7 +234,7 @@ final class FavoritesPersistenceService: ObservableObject {
private func loadFavorites() { private func loadFavorites() {
// Loading favorites from keychain // Loading favorites from keychain
guard let data = KeychainHelper.load( guard let data = keychain.load(
key: Self.storageKey, key: Self.storageKey,
service: Self.keychainService service: Self.keychainService
) else { ) else {
@@ -216,15 +216,4 @@ final class GeohashBookmarksStore: ObservableObject {
} }
} }
#endif #endif
#if DEBUG
/// Testing-only reset helper
func _resetForTesting() {
bookmarks.removeAll()
membership.removeAll()
bookmarkNames.removeAll()
persist()
persistNames()
}
#endif
} }
-28
View File
@@ -27,34 +27,6 @@ final class KeychainManager: KeychainManagerProtocol {
private let service = BitchatApp.bundleID private let service = BitchatApp.bundleID
private let appGroup = "group.\(BitchatApp.bundleID)" private let appGroup = "group.\(BitchatApp.bundleID)"
private func isSandboxed() -> Bool {
#if os(macOS)
// More robust sandbox detection using multiple methods
// Method 1: Check environment variable (can be spoofed)
let environment = ProcessInfo.processInfo.environment
let hasEnvVar = environment["APP_SANDBOX_CONTAINER_ID"] != nil
// Method 2: Check if we can access a path outside sandbox
let homeDir = FileManager.default.homeDirectoryForCurrentUser
let testPath = homeDir.appendingPathComponent("../../../tmp/bitchat_sandbox_test_\(UUID().uuidString)")
let canWriteOutsideSandbox = FileManager.default.createFile(atPath: testPath.path, contents: nil, attributes: nil)
if canWriteOutsideSandbox {
try? FileManager.default.removeItem(at: testPath)
}
// Method 3: Check container path
let containerPath = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first?.path ?? ""
let hasContainerPath = containerPath.contains("/Containers/")
// If any method indicates sandbox, we consider it sandboxed
return hasEnvVar || !canWriteOutsideSandbox || hasContainerPath
#else
// iOS is always sandboxed
return true
#endif
}
// MARK: - Identity Keys // MARK: - Identity Keys
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
-104
View File
@@ -1,104 +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
}
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
}
}
+15 -5
View File
@@ -15,6 +15,8 @@ struct LocationNotesDependencies {
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
var now: () -> Date var now: () -> Date
private static let idBridge = NostrIdentityBridge()
static let live = LocationNotesDependencies( static let live = LocationNotesDependencies(
relayLookup: { geohash, count in relayLookup: { geohash, count in
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count) GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
@@ -35,7 +37,7 @@ struct LocationNotesDependencies {
NostrRelayManager.shared.sendEvent(event, to: relays) NostrRelayManager.shared.sendEvent(event, to: relays)
}, },
deriveIdentity: { geohash in deriveIdentity: { geohash in
try NostrIdentityBridge.deriveIdentity(forGeohash: geohash) try idBridge.deriveIdentity(forGeohash: geohash)
}, },
now: { Date() } now: { Date() }
) )
@@ -161,14 +163,22 @@ final class LocationNotesManager: ObservableObject {
subscriptionID = subID subscriptionID = subID
initialLoadComplete = false 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) // Subscribe to center + 8 neighbors (± 1 grid)
let neighbors = Geohash.neighbors(of: geohash)
let allGeohashes = [geohash] + neighbors
let filter = NostrFilter.geohashNotes(allGeohashes, since: nil, limit: 200)
// Build a set of valid geohashes for tag matching (includes all 9 cells)
let validGeohashes = Set(allGeohashes.map { $0.lowercased() })
dependencies.subscribe(filter, subID, relays, { [weak self] event in dependencies.subscribe(filter, subID, relays, { [weak self] event in
guard let self = self else { return } guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return } guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
// Ensure matching tag // Ensure matching tag - accept any of our 9 geohashes
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == self.geohash }) else { return } guard event.tags.contains(where: { tag in
tag.count >= 2 && tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased())
}) else { return }
guard !self.noteIDs.contains(event.id) else { return } guard !self.noteIDs.contains(event.id) else { return }
self.noteIDs.insert(event.id) self.noteIDs.insert(event.id)
let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first
+7 -5
View File
@@ -16,9 +16,11 @@ final class NostrTransport: Transport {
private var isSendingReadAcks = false private var isSendingReadAcks = false
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
private let keychain: KeychainManagerProtocol private let keychain: KeychainManagerProtocol
private let idBridge: NostrIdentityBridge
init(keychain: KeychainManagerProtocol) { init(keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge) {
self.keychain = keychain self.keychain = keychain
self.idBridge = idBridge
} }
// MARK: - Transport Protocol Conformance // MARK: - Transport Protocol Conformance
@@ -65,7 +67,7 @@ final class NostrTransport: Transport {
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { func sendPrivateMessage(_ content: String, to peerID: PeerID, 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? idBridge.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.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
// Convert recipient npub -> hex (x-only) // Convert recipient npub -> hex (x-only)
let recipientHex: String let recipientHex: String
@@ -102,7 +104,7 @@ final class NostrTransport: Transport {
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
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? idBridge.getCurrentNostrIdentity() else { return }
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)" let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))", category: .session) SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))", category: .session)
// Convert recipient npub -> hex // Convert recipient npub -> hex
@@ -129,7 +131,7 @@ final class NostrTransport: Transport {
func sendDeliveryAck(for messageID: String, to peerID: PeerID) { func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
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? idBridge.getCurrentNostrIdentity() else { return }
SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))", category: .session) SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))", category: .session)
let recipientHex: String let recipientHex: String
do { do {
@@ -212,7 +214,7 @@ extension NostrTransport {
let item = readQueue.removeFirst() let item = readQueue.removeFirst()
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? idBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
SecureLogger.debug("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))", category: .session) SecureLogger.debug("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))", category: .session)
// Convert recipient npub -> hex // Convert recipient npub -> hex
let recipientHex: String let recipientHex: String
+7 -7
View File
@@ -12,9 +12,9 @@ import SwiftUI
/// Manages all private chat functionality /// Manages all private chat functionality
final class PrivateChatManager: ObservableObject { final class PrivateChatManager: ObservableObject {
@Published var privateChats: [String: [BitchatMessage]] = [:] @Published var privateChats: [PeerID: [BitchatMessage]] = [:]
@Published var selectedPeer: String? = nil @Published var selectedPeer: PeerID? = nil
@Published var unreadMessages: Set<String> = [] @Published var unreadMessages: Set<PeerID> = []
private var selectedPeerFingerprint: String? = nil private var selectedPeerFingerprint: String? = nil
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
@@ -31,11 +31,11 @@ final class PrivateChatManager: ObservableObject {
private let privateChatCap = TransportConfig.privateChatCap private let privateChatCap = TransportConfig.privateChatCap
/// Start a private chat with a peer /// Start a private chat with a peer
func startChat(with peerID: String) { func startChat(with peerID: PeerID) {
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
} }
@@ -55,7 +55,7 @@ final class PrivateChatManager: ObservableObject {
} }
/// 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: PeerID) {
guard let arr = privateChats[peerID] else { return } guard let arr = privateChats[peerID] else { return }
if arr.count <= 1 { if arr.count <= 1 {
return return
@@ -79,7 +79,7 @@ final class PrivateChatManager: ObservableObject {
} }
/// Mark messages from a peer as read /// Mark messages from a peer as read
func markAsRead(from peerID: String) { func markAsRead(from peerID: PeerID) {
unreadMessages.remove(peerID) unreadMessages.remove(peerID)
// Send read receipts for unread messages that haven't been sent yet // Send read receipts for unread messages that haven't been sent yet
+11 -5
View File
@@ -27,6 +27,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
private var peerIndex: [PeerID: BitchatPeer] = [:] private var peerIndex: [PeerID: BitchatPeer] = [:]
private var fingerprintCache: [PeerID: String] = [:] private var fingerprintCache: [PeerID: String] = [:]
private let meshService: Transport private let meshService: Transport
private let idBridge: NostrIdentityBridge
private let identityManager: SecureIdentityStateManagerProtocol private let identityManager: SecureIdentityStateManagerProtocol
weak var messageRouter: MessageRouter? weak var messageRouter: MessageRouter?
private let favoritesService = FavoritesPersistenceService.shared private let favoritesService = FavoritesPersistenceService.shared
@@ -34,8 +35,13 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
// MARK: - Initialization // MARK: - Initialization
init(meshService: Transport, identityManager: SecureIdentityStateManagerProtocol) { init(
meshService: Transport,
idBridge: NostrIdentityBridge,
identityManager: SecureIdentityStateManagerProtocol
) {
self.meshService = meshService self.meshService = meshService
self.idBridge = idBridge
self.identityManager = identityManager self.identityManager = identityManager
// Subscribe to changes from both services // Subscribe to changes from both services
@@ -229,10 +235,10 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
} }
/// Get peer ID for nickname /// Get peer ID for nickname
func getPeerID(for nickname: String) -> String? { func getPeerID(for nickname: String) -> PeerID? {
for peer in peers { for peer in peers {
if peer.displayName == nickname || peer.nickname == nickname { if peer.displayName == nickname || peer.nickname == nickname {
return peer.peerID.id return peer.peerID
} }
} }
return nil return nil
@@ -285,7 +291,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
var peerNostrKey = peer.nostrPublicKey var peerNostrKey = peer.nostrPublicKey
if peerNostrKey == nil { if peerNostrKey == nil {
// Try to get from NostrIdentityBridge association // Try to get from NostrIdentityBridge association
peerNostrKey = NostrIdentityBridge.getNostrPublicKey(for: peer.noisePublicKey) peerNostrKey = idBridge.getNostrPublicKey(for: peer.noisePublicKey)
} }
// Add favorite // Add favorite
@@ -341,7 +347,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
// MARK: - Compatibility Methods (for easy migration) // MARK: - Compatibility Methods (for easy migration)
var allPeers: [BitchatPeer] { peers } var allPeers: [BitchatPeer] { peers }
var connectedPeers: [PeerID] { Array(connectedPeerIDs) } var connectedPeers: Set<PeerID> { connectedPeerIDs }
var favoritePeers: Set<String> { var favoritePeers: Set<String> {
Set(favorites.compactMap { getFingerprint(for: $0.peerID) }) Set(favorites.compactMap { getFingerprint(for: $0.peerID) })
} }
+79 -5
View File
@@ -13,6 +13,9 @@ final class GossipSyncManager {
var gcsMaxBytes: Int = 400 // filter size budget (128..1024) var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
var gcsTargetFpr: Double = 0.01 // 1% var gcsTargetFpr: Double = 0.01 // 1%
var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - discard older messages var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - discard older messages
var maintenanceIntervalSeconds: TimeInterval = 30.0
var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0
var stalePeerTimeoutSeconds: TimeInterval = 60.0
} }
private let myPeerID: PeerID private let myPeerID: PeerID
@@ -27,6 +30,7 @@ final class GossipSyncManager {
// Timer // Timer
private var periodicTimer: DispatchSourceTimer? private var periodicTimer: DispatchSourceTimer?
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility) private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
private var lastStalePeerCleanup: Date = .distantPast
init(myPeerID: PeerID, config: Config = Config()) { init(myPeerID: PeerID, config: Config = Config()) {
self.myPeerID = myPeerID self.myPeerID = myPeerID
@@ -36,10 +40,10 @@ final class GossipSyncManager {
func start() { func start() {
stop() stop()
let timer = DispatchSource.makeTimerSource(queue: queue) let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now() + 30.0, repeating: 30.0, leeway: .seconds(1)) let interval = max(0.1, config.maintenanceIntervalSeconds)
timer.schedule(deadline: .now() + interval, repeating: interval, leeway: .seconds(1))
timer.setEventHandler { [weak self] in timer.setEventHandler { [weak self] in
self?.cleanupExpiredMessages() self?.performPeriodicMaintenance()
self?.sendRequestSync()
} }
timer.resume() timer.resume()
periodicTimer = timer periodicTimer = timer
@@ -73,6 +77,15 @@ final class GossipSyncManager {
return packet.timestamp >= cutoffMs return packet.timestamp >= cutoffMs
} }
private func isAnnouncementFresh(_ packet: BitchatPacket) -> Bool {
guard config.stalePeerTimeoutSeconds > 0 else { return true }
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
let timeoutMs = UInt64(config.stalePeerTimeoutSeconds * 1000)
guard nowMs >= timeoutMs else { return true }
let cutoffMs = nowMs - timeoutMs
return packet.timestamp >= cutoffMs
}
private func _onPublicPacketSeen(_ packet: BitchatPacket) { private func _onPublicPacketSeen(_ packet: BitchatPacket) {
let mt = MessageType(rawValue: packet.type) let mt = MessageType(rawValue: packet.type)
let isBroadcastRecipient: Bool = { let isBroadcastRecipient: Bool = {
@@ -86,6 +99,14 @@ final class GossipSyncManager {
// Reject expired packets to prevent ghost peers and old messages // Reject expired packets to prevent ghost peers and old messages
guard isPacketFresh(packet) else { return } guard isPacketFresh(packet) else { return }
if isAnnounce {
guard isAnnouncementFresh(packet) else {
let sender = packet.senderID.hexEncodedString().lowercased()
removeState(forNormalizedPeerID: sender)
return
}
}
let idHex = PacketIdUtil.computeId(packet).hexEncodedString() let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
if isBroadcastMessage { if isBroadcastMessage {
@@ -100,7 +121,7 @@ final class GossipSyncManager {
} }
} }
} else if isAnnounce { } else if isAnnounce {
let sender = packet.senderID.hexEncodedString() let sender = packet.senderID.hexEncodedString().lowercased()
latestAnnouncementByPeer[sender] = (id: idHex, packet: packet) latestAnnouncementByPeer[sender] = (id: idHex, packet: packet)
} }
} }
@@ -230,6 +251,34 @@ final class GossipSyncManager {
} }
} }
private func performPeriodicMaintenance(now: Date = Date()) {
cleanupExpiredMessages()
cleanupStaleAnnouncementsIfNeeded(now: now)
sendRequestSync()
}
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
guard now.timeIntervalSince(lastStalePeerCleanup) >= config.stalePeerCleanupIntervalSeconds else {
return
}
lastStalePeerCleanup = now
cleanupStaleAnnouncements(now: now)
}
private func cleanupStaleAnnouncements(now: Date) {
let timeoutMs = UInt64(config.stalePeerTimeoutSeconds * 1000)
let nowMs = UInt64(now.timeIntervalSince1970 * 1000)
guard nowMs >= timeoutMs else { return }
let cutoff = nowMs - timeoutMs
let stalePeerIDs = latestAnnouncementByPeer.compactMap { (peerHex, pair) -> String? in
pair.packet.timestamp < cutoff ? peerHex.lowercased() : nil
}
guard !stalePeerIDs.isEmpty else { return }
for peerKey in stalePeerIDs {
removeState(forNormalizedPeerID: peerKey)
}
}
// Explicit removal hook for LEAVE/stale peer // Explicit removal hook for LEAVE/stale peer
func removeAnnouncementForPeer(_ peerID: PeerID) { func removeAnnouncementForPeer(_ peerID: PeerID) {
queue.async { [weak self] in queue.async { [weak self] in
@@ -239,8 +288,11 @@ final class GossipSyncManager {
private func _removeAnnouncementForPeer(_ peerID: PeerID) { private func _removeAnnouncementForPeer(_ peerID: PeerID) {
let normalizedPeerID = peerID.id.lowercased() let normalizedPeerID = peerID.id.lowercased()
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID) removeState(forNormalizedPeerID: normalizedPeerID)
}
private func removeState(forNormalizedPeerID normalizedPeerID: String) {
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
// Remove messages from this peer // Remove messages from this peer
// Collect IDs to remove first to avoid concurrent modification // Collect IDs to remove first to avoid concurrent modification
let messageIdsToRemove = messages.compactMap { (id, message) -> String? in let messageIdsToRemove = messages.compactMap { (id, message) -> String? in
@@ -254,3 +306,25 @@ final class GossipSyncManager {
} }
} }
} }
#if DEBUG
extension GossipSyncManager {
func _performMaintenanceSynchronously(now: Date = Date()) {
queue.sync {
performPeriodicMaintenance(now: now)
}
}
func _hasAnnouncement(for peerID: PeerID) -> Bool {
queue.sync {
latestAnnouncementByPeer[peerID.id.lowercased()] != nil
}
}
func _messageCount(for peerID: PeerID) -> Int {
queue.sync {
messages.values.filter { $0.senderID.hexEncodedString().lowercased() == peerID.id.lowercased() }.count
}
}
}
#endif
File diff suppressed because it is too large Load Diff
@@ -91,6 +91,7 @@ struct TextMessageView: View {
.environmentObject( .environmentObject(
ChatViewModel( ChatViewModel(
keychain: keychain, keychain: keychain,
idBridge: NostrIdentityBridge(),
identityManager: SecureIdentityStateManager(keychain) identityManager: SecureIdentityStateManager(keychain)
) )
) )
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -66,12 +66,12 @@ struct FingerprintView: View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
// Prefer short mesh ID for session/encryption status // Prefer short mesh ID for session/encryption status
let statusPeerID: String = { let statusPeerID: String = {
if peerID.count == 64, let short = viewModel.getShortIDForNoiseKey(peerID) { return short } if peerID.count == 64, let short = viewModel.getShortIDForNoiseKey(peerID) { return short.id }
return peerID return peerID
}() }()
// Resolve a friendly name // Resolve a friendly name
let peerNickname: String = { let peerNickname: String = {
if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName } if let p = viewModel.getPeer(byID: PeerID(str: statusPeerID)) { return p.displayName }
if let name = viewModel.meshService.peerNickname(peerID: PeerID(str: statusPeerID)) { return name } if let name = viewModel.meshService.peerNickname(peerID: PeerID(str: statusPeerID)) { return name }
if peerID.count == 64, let data = Data(hexString: peerID) { if peerID.count == 64, let data = Data(hexString: peerID) {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname } if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
@@ -84,7 +84,7 @@ struct FingerprintView: View {
return Strings.unknownPeer() return Strings.unknownPeer()
}() }()
// Accurate encryption state based on short ID session // Accurate encryption state based on short ID session
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID) let encryptionStatus = viewModel.getEncryptionStatus(for: PeerID(str: statusPeerID))
HStack { HStack {
if let icon = encryptionStatus.icon { if let icon = encryptionStatus.icon {
@@ -115,7 +115,7 @@ struct FingerprintView: View {
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced)) .font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
.foregroundColor(textColor.opacity(0.7)) .foregroundColor(textColor.opacity(0.7))
if let fingerprint = viewModel.getFingerprint(for: statusPeerID) { if let fingerprint = viewModel.getFingerprint(for: PeerID(str: statusPeerID)) {
Text(formatFingerprint(fingerprint)) Text(formatFingerprint(fingerprint))
.font(.bitchatSystem(size: 14, design: .monospaced)) .font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
@@ -176,6 +176,7 @@ struct FingerprintView: View {
// Verification status // Verification status
if encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified { if encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified {
let isVerified = encryptionStatus == .noiseVerified let isVerified = encryptionStatus == .noiseVerified
let peerID = PeerID(str: peerID)
VStack(spacing: 12) { VStack(spacing: 12) {
Text(isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge) Text(isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge)
+1 -1
View File
@@ -28,7 +28,7 @@ struct GeohashPeopleList: View {
} else { } else {
let myHex: String? = { let myHex: String? = {
if case .location(let ch) = LocationChannelManager.shared.selectedChannel, if case .location(let ch) = LocationChannelManager.shared.selectedChannel,
let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { let id = try? viewModel.idBridge.deriveIdentity(forGeohash: ch.geohash) {
return id.publicKeyHex.lowercased() return id.publicKeyHex.lowercased()
} }
return nil return nil
+1 -1
View File
@@ -141,7 +141,7 @@ struct LocationNotesView: View {
String( String(
format: String(localized: "location_notes.header", comment: "Header displaying the geohash and localized note count"), format: String(localized: "location_notes.header", comment: "Header displaying the geohash and localized note count"),
locale: .current, locale: .current,
geohash, count "\(geohash) ± 1", count
) )
} }
+5 -5
View File
@@ -21,8 +21,8 @@ struct MeshPeerList: View {
let myPeerID = viewModel.meshService.myPeerID let myPeerID = viewModel.meshService.myPeerID
let mapped: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = viewModel.allPeers.map { peer in let mapped: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = viewModel.allPeers.map { peer in
let isMe = peer.peerID == myPeerID let isMe = peer.peerID == myPeerID
let hasUnread = viewModel.hasUnreadMessages(for: peer.peerID.id) let hasUnread = viewModel.hasUnreadMessages(for: peer.peerID)
let enc = viewModel.getEncryptionStatus(for: peer.peerID.id) let enc = viewModel.getEncryptionStatus(for: peer.peerID)
return (peer, isMe, hasUnread, enc) return (peer, isMe, hasUnread, enc)
} }
// Stable visual order without mutating state here // Stable visual order without mutating state here
@@ -47,7 +47,7 @@ struct MeshPeerList: View {
let peer = item.peer let peer = item.peer
let isMe = item.isMe let isMe = item.isMe
HStack(spacing: 4) { HStack(spacing: 4) {
let assigned = viewModel.colorForMeshPeer(id: peer.peerID.id, isDark: colorScheme == .dark) let assigned = viewModel.colorForMeshPeer(id: peer.peerID, isDark: colorScheme == .dark)
let baseColor = isMe ? Color.orange : assigned let baseColor = isMe ? Color.orange : assigned
if isMe { if isMe {
Image(systemName: "person.fill") Image(systemName: "person.fill")
@@ -89,7 +89,7 @@ struct MeshPeerList: View {
} }
} }
if !isMe, viewModel.isPeerBlocked(peer.peerID.id) { if !isMe, viewModel.isPeerBlocked(peer.peerID) {
Image(systemName: "nosign") Image(systemName: "nosign")
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(.red) .foregroundColor(.red)
@@ -105,7 +105,7 @@ struct MeshPeerList: View {
} }
} else { } else {
// Offline: prefer showing verified badge from persisted fingerprints // Offline: prefer showing verified badge from persisted fingerprints
if let fp = viewModel.getFingerprint(for: peer.peerID.id), if let fp = viewModel.getFingerprint(for: peer.peerID),
viewModel.verifiedFingerprints.contains(fp) { viewModel.verifiedFingerprints.contains(fp) {
Image(systemName: "checkmark.seal.fill") Image(systemName: "checkmark.seal.fill")
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
+1 -1
View File
@@ -292,7 +292,7 @@ struct VerificationSheetView: View {
private var boxColor: Color { Color.gray.opacity(0.1) } private var boxColor: Color { Color.gray.opacity(0.1) }
private func myQRString() -> String { private func myQRString() -> String {
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub let npub = try? viewModel.idBridge.getCurrentNostrIdentity()?.npub
return VerificationService.shared.buildMyQRString(nickname: viewModel.nickname, npub: npub) ?? "" return VerificationService.shared.buildMyQRString(nickname: viewModel.nickname, npub: npub) ?? ""
} }
@@ -108,6 +108,13 @@
"value": "공유된 링크", "value": "공유된 링크",
"comment": "Fallback title when saving a shared link" "comment": "Fallback title when saving a shared link"
} }
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "paylaşılan bağlantı",
"comment": "Fallback title when saving a shared link"
}
} }
} }
}, },
@@ -218,6 +225,13 @@
"value": "링크를 인코딩하는 데 실패했습니다", "value": "링크를 인코딩하는 데 실패했습니다",
"comment": "Shown when the share payload cannot be encoded" "comment": "Shown when the share payload cannot be encoded"
} }
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "bağlantı kodlanamadı",
"comment": "Shown when the share payload cannot be encoded"
}
} }
} }
}, },
@@ -328,6 +342,13 @@
"value": "공유할 수 있는 내용이 없습니다", "value": "공유할 수 있는 내용이 없습니다",
"comment": "Shown when provided content cannot be shared" "comment": "Shown when provided content cannot be shared"
} }
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "paylaşılabilir içerik yok",
"comment": "Shown when provided content cannot be shared"
}
} }
} }
}, },
@@ -438,6 +459,13 @@
"value": "공유할 내용이 없습니다", "value": "공유할 내용이 없습니다",
"comment": "Shown when the share extension receives no content" "comment": "Shown when the share extension receives no content"
} }
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "paylaşılacak bir şey yok",
"comment": "Shown when the share extension receives no content"
}
} }
} }
}, },
@@ -548,6 +576,13 @@
"value": "✓ bitchat으로 링크를 공유했습니다", "value": "✓ bitchat으로 링크를 공유했습니다",
"comment": "Confirmation after successfully sharing a link" "comment": "Confirmation after successfully sharing a link"
} }
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "✓ bitchat'e bağlantı paylaşıldı",
"comment": "Confirmation after successfully sharing a link"
}
} }
} }
}, },
@@ -658,6 +693,13 @@
"value": "✓ bitchat으로 텍스트를 공유했습니다", "value": "✓ bitchat으로 텍스트를 공유했습니다",
"comment": "Confirmation after successfully sharing text" "comment": "Confirmation after successfully sharing text"
} }
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "✓ bitchat'e metin paylaşıldı",
"comment": "Confirmation after successfully sharing text"
}
} }
} }
} }
+126 -116
View File
@@ -6,122 +6,128 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import XCTest import Testing
import CoreBluetooth import CoreBluetooth
@testable import bitchat @testable import bitchat
final class BLEServiceTests: XCTestCase { struct BLEServiceTests {
private let service: MockBLEService
private let myUUID = UUID()
private let bus = MockBLEBus()
var service: MockBLEService! init() {
service = MockBLEService.init(bus: bus)
override func setUp() { service.myPeerID = PeerID(str: myUUID.uuidString)
super.setUp()
service = MockBLEService()
service.myPeerID = "TEST1234"
service.mockNickname = "TestUser" service.mockNickname = "TestUser"
} }
override func tearDown() {
service = nil
super.tearDown()
}
// MARK: - Basic Functionality Tests // MARK: - Basic Functionality Tests
func testServiceInitialization() { @Test func serviceInitialization() {
XCTAssertNotNil(service) #expect(service.myPeerID == PeerID(str: myUUID.uuidString))
XCTAssertEqual(service.myPeerID, "TEST1234") #expect(service.myNickname == "TestUser")
XCTAssertEqual(service.myNickname, "TestUser")
} }
func testPeerConnection() { @Test func peerConnection() {
// Test connecting a peer let somePeerID = PeerID(str: UUID().uuidString)
service.simulateConnectedPeer("PEER5678")
XCTAssertTrue(service.isPeerConnected("PEER5678"))
XCTAssertEqual(service.getConnectedPeers().count, 1)
// Test disconnecting a peer service.simulateConnectedPeer(somePeerID)
service.simulateDisconnectedPeer("PEER5678") #expect(service.isPeerConnected(somePeerID))
XCTAssertFalse(service.isPeerConnected("PEER5678")) #expect(service.getConnectedPeers().count == 1)
XCTAssertEqual(service.getConnectedPeers().count, 0)
service.simulateDisconnectedPeer(somePeerID)
#expect(!service.isPeerConnected(somePeerID))
#expect(service.getConnectedPeers().count == 0)
} }
func testMultiplePeerConnections() { @Test func multiplePeerConnections() {
service.simulateConnectedPeer("PEER1") let peerID1 = PeerID(str: UUID().uuidString)
service.simulateConnectedPeer("PEER2") let peerID2 = PeerID(str: UUID().uuidString)
service.simulateConnectedPeer("PEER3") let peerID3 = PeerID(str: UUID().uuidString)
XCTAssertEqual(service.getConnectedPeers().count, 3) service.simulateConnectedPeer(peerID1)
XCTAssertTrue(service.isPeerConnected("PEER1")) service.simulateConnectedPeer(peerID2)
XCTAssertTrue(service.isPeerConnected("PEER2")) service.simulateConnectedPeer(peerID3)
XCTAssertTrue(service.isPeerConnected("PEER3"))
service.simulateDisconnectedPeer("PEER2") #expect(service.getConnectedPeers().count == 3)
XCTAssertEqual(service.getConnectedPeers().count, 2) #expect(service.isPeerConnected(peerID1))
XCTAssertFalse(service.isPeerConnected("PEER2")) #expect(service.isPeerConnected(peerID2))
#expect(service.isPeerConnected(peerID3))
service.simulateDisconnectedPeer(peerID2)
#expect(service.getConnectedPeers().count == 2)
#expect(!service.isPeerConnected(peerID2))
} }
// MARK: - Message Sending Tests // MARK: - Message Sending Tests
func testSendPublicMessage() { @Test func sendPublicMessage() async throws {
let expectation = XCTestExpectation(description: "Message sent") try await confirmation { receivedPublicMessage in
let delegate = MockBitchatDelegate { message in let delegate = MockBitchatDelegate { message in
XCTAssertEqual(message.content, "Hello, world!") #expect(message.content == "Hello, world!")
XCTAssertEqual(message.sender, "TestUser") #expect(message.sender == "TestUser")
XCTAssertFalse(message.isPrivate) #expect(!message.isPrivate)
expectation.fulfill() receivedPublicMessage()
} }
service.delegate = delegate service.delegate = delegate
service.sendMessage("Hello, world!") service.sendMessage("Hello, world!")
wait(for: [expectation], timeout: 1.0) // Allow async processing
XCTAssertEqual(service.sentMessages.count, 1) try await sleep(0.5)
}
#expect(service.sentMessages.count == 1)
} }
func testSendPrivateMessage() { @Test func sendPrivateMessage() async throws {
let expectation = XCTestExpectation(description: "Private message sent") try await confirmation { receivedPrivateMessage in
let delegate = MockBitchatDelegate { message in let delegate = MockBitchatDelegate { message in
XCTAssertEqual(message.content, "Secret message") #expect(message.content == "Secret message")
XCTAssertEqual(message.sender, "TestUser") #expect(message.sender == "TestUser")
XCTAssertTrue(message.isPrivate) #expect(message.senderPeerID == PeerID(str: myUUID.uuidString))
XCTAssertEqual(message.recipientNickname, "Bob") #expect(message.isPrivate)
expectation.fulfill() #expect(message.recipientNickname == "Bob")
receivedPrivateMessage()
} }
service.delegate = delegate service.delegate = delegate
service.sendPrivateMessage(
"Secret message",
to: PeerID(str: UUID().uuidString),
recipientNickname: "Bob",
messageID: "MSG123"
)
service.sendPrivateMessage("Secret message", to: "PEER5678", recipientNickname: "Bob", messageID: "MSG123") // Allow async processing
try await sleep(0.5)
wait(for: [expectation], timeout: 1.0) }
XCTAssertEqual(service.sentMessages.count, 1) #expect(service.sentMessages.count == 1)
} }
func testSendMessageWithMentions() { @Test func sendMessageWithMentions() async throws {
let expectation = XCTestExpectation(description: "Message with mentions sent") try await confirmation { receivedMessageWithMentions in
let delegate = MockBitchatDelegate { message in let delegate = MockBitchatDelegate { message in
XCTAssertEqual(message.content, "@alice @bob check this out") #expect(message.content == "@alice @bob check this out")
XCTAssertEqual(message.mentions, ["alice", "bob"]) #expect(message.mentions == ["alice", "bob"])
expectation.fulfill() receivedMessageWithMentions()
} }
service.delegate = delegate service.delegate = delegate
service.sendMessage("@alice @bob check this out", mentions: ["alice", "bob"]) service.sendMessage("@alice @bob check this out", mentions: ["alice", "bob"])
wait(for: [expectation], timeout: 1.0) // Allow async processing
try await sleep(0.5)
}
} }
// MARK: - Message Reception Tests // MARK: - Message Reception Tests
func testSimulateIncomingMessage() { @Test func simulateIncomingMessage() async throws {
let expectation = XCTestExpectation(description: "Message received") try await confirmation { receiveMessage in
let peerID = PeerID(str: UUID().uuidString)
let delegate = MockBitchatDelegate { message in let delegate = MockBitchatDelegate { message in
XCTAssertEqual(message.content, "Incoming message") #expect(message.content == "Incoming message")
XCTAssertEqual(message.sender, "RemoteUser") #expect(message.sender == "RemoteUser")
expectation.fulfill() #expect(message.senderPeerID == peerID)
receiveMessage()
} }
service.delegate = delegate service.delegate = delegate
@@ -134,21 +140,24 @@ final class BLEServiceTests: XCTestCase {
originalSender: nil, originalSender: nil,
isPrivate: false, isPrivate: false,
recipientNickname: nil, recipientNickname: nil,
senderPeerID: "REMOTE123", senderPeerID: peerID,
mentions: nil mentions: nil
) )
service.simulateIncomingMessage(incomingMessage) service.simulateIncomingMessage(incomingMessage)
wait(for: [expectation], timeout: 1.0) // Allow async processing
try await sleep(0.5)
}
} }
func testSimulateIncomingPacket() { @Test func simulateIncomingPacket() async throws {
let expectation = XCTestExpectation(description: "Packet processed") try await confirmation { processPacket in
let peerID = PeerID(str: UUID().uuidString)
let delegate = MockBitchatDelegate { message in let delegate = MockBitchatDelegate { message in
XCTAssertEqual(message.content, "Packet message") #expect(message.content == "Packet message")
expectation.fulfill() #expect(message.senderPeerID == peerID)
processPacket()
} }
service.delegate = delegate service.delegate = delegate
@@ -161,18 +170,15 @@ final class BLEServiceTests: XCTestCase {
originalSender: nil, originalSender: nil,
isPrivate: false, isPrivate: false,
recipientNickname: nil, recipientNickname: nil,
senderPeerID: "PACKET123", senderPeerID: peerID,
mentions: nil mentions: nil
) )
guard let payload = message.toBinaryPayload() else { let payload = try #require(message.toBinaryPayload(), "Failed to create binary payload")
XCTFail("Failed to create binary payload")
return
}
let packet = BitchatPacket( let packet = BitchatPacket(
type: 0x01, type: 0x01,
senderID: "PACKET123".data(using: .utf8)!, senderID: peerID.id.data(using: .utf8)!,
recipientID: nil, recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload, payload: payload,
@@ -182,56 +188,61 @@ final class BLEServiceTests: XCTestCase {
service.simulateIncomingPacket(packet) service.simulateIncomingPacket(packet)
wait(for: [expectation], timeout: 1.0) // Allow async processing
try await sleep(0.5)
}
} }
// MARK: - Peer Nickname Tests // MARK: - Peer Nickname Tests
func testGetPeerNicknames() { @Test func getPeerNicknames() {
service.simulateConnectedPeer("PEER1") let peerID1 = PeerID(str: UUID().uuidString)
service.simulateConnectedPeer("PEER2") let peerID2 = PeerID(str: UUID().uuidString)
service.simulateConnectedPeer(peerID1)
service.simulateConnectedPeer(peerID2)
let nicknames = service.getPeerNicknames() let nicknames = service.getPeerNicknames()
XCTAssertEqual(nicknames.count, 2) #expect(nicknames.count == 2)
XCTAssertEqual(nicknames["PEER1"], "MockPeer_PEER1") #expect(nicknames[peerID1] == "MockPeer_\(peerID1)")
XCTAssertEqual(nicknames["PEER2"], "MockPeer_PEER2") #expect(nicknames[peerID2] == "MockPeer_\(peerID2)")
} }
// MARK: - Service State Tests // MARK: - Service State Tests
func testStartStopServices() { @Test func startStopServices() {
// These are mock implementations, just ensure they don't crash
service.startServices() service.startServices()
service.stopServices() service.stopServices()
let somePeerID = PeerID(str: UUID().uuidString)
// Service should still be functional after start/stop service.simulateConnectedPeer(somePeerID)
service.simulateConnectedPeer("PEER999") #expect(service.isPeerConnected(somePeerID))
XCTAssertTrue(service.isPeerConnected("PEER999"))
} }
// MARK: - Message Delivery Handler Tests // MARK: - Message Delivery Handler Tests
func testMessageDeliveryHandler() { @Test func messageDeliveryHandler() async throws {
let expectation = XCTestExpectation(description: "Delivery handler called") try await confirmation { deliveryHandler in
service.packetDeliveryHandler = { packet in service.packetDeliveryHandler = { packet in
if let msg = BitchatMessage(packet.payload) { if let msg = BitchatMessage(packet.payload) {
XCTAssertEqual(msg.content, "Test delivery") #expect(msg.content == "Test delivery")
expectation.fulfill() deliveryHandler()
} }
} }
service.sendMessage("Test delivery") service.sendMessage("Test delivery")
wait(for: [expectation], timeout: 1.0) // Allow async processing
try await sleep(0.5)
}
} }
func testPacketDeliveryHandler() { @Test func packetDeliveryHandler() async throws {
let expectation = XCTestExpectation(description: "Packet handler called") try await confirmation("Packet handler called") { packetHandler in
let peerID = PeerID(str: UUID().uuidString)
service.packetDeliveryHandler = { packet in service.packetDeliveryHandler = { packet in
XCTAssertEqual(packet.type, 0x01) #expect(packet.type == 0x01)
expectation.fulfill() #expect(packet.senderID == Data(peerID.id.utf8))
packetHandler()
} }
let message = BitchatMessage( let message = BitchatMessage(
@@ -243,18 +254,15 @@ final class BLEServiceTests: XCTestCase {
originalSender: nil, originalSender: nil,
isPrivate: false, isPrivate: false,
recipientNickname: nil, recipientNickname: nil,
senderPeerID: "TEST123", senderPeerID: peerID,
mentions: nil mentions: nil
) )
guard let payload = message.toBinaryPayload() else { let payload = try #require(message.toBinaryPayload(), "Failed to create payload")
XCTFail("Failed to create payload")
return
}
let packet = BitchatPacket( let packet = BitchatPacket(
type: 0x01, type: 0x01,
senderID: "TEST123".data(using: .utf8)!, senderID: peerID.id.data(using: .utf8)!,
recipientID: nil, recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload, payload: payload,
@@ -264,7 +272,9 @@ final class BLEServiceTests: XCTestCase {
service.simulateIncomingPacket(packet) service.simulateIncomingPacket(packet)
wait(for: [expectation], timeout: 1.0) // Allow async processing
try await sleep(0.5)
}
} }
} }
+12 -24
View File
@@ -1,54 +1,42 @@
import XCTest import Testing
@testable import bitchat @testable import bitchat
final class CommandProcessorTests: XCTestCase { struct CommandProcessorTests {
private var identityManager = MockIdentityManager(MockKeychain())
var identityManager: MockIdentityManager!
override func setUp() {
super.setUp()
// Provide a minimal identity manager for commands that query identity/block lists
identityManager = MockIdentityManager(MockKeychain())
}
override func tearDown() {
identityManager = nil
super.tearDown()
}
@MainActor @MainActor
func test_slap_notFoundGrammar() { @Test func slapNotFoundGrammar() {
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager) let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
let result = processor.process("/slap @system") let result = processor.process("/slap @system")
switch result { switch result {
case .error(let message): case .error(let message):
XCTAssertEqual(message, "cannot slap system: not found") #expect(message == "cannot slap system: not found")
default: default:
XCTFail("Expected error result") Issue.record("Expected error result")
} }
} }
@MainActor @MainActor
func test_hug_notFoundGrammar() { @Test func hugNotFoundGrammar() {
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager) let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
let result = processor.process("/hug @system") let result = processor.process("/hug @system")
switch result { switch result {
case .error(let message): case .error(let message):
XCTAssertEqual(message, "cannot hug system: not found") #expect(message == "cannot hug system: not found")
default: default:
XCTFail("Expected error result") Issue.record("Expected error result")
} }
} }
@MainActor @MainActor
func test_slap_usageMessage() { @Test func slapUsageMessage() {
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager) let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
let result = processor.process("/slap") let result = processor.process("/slap")
switch result { switch result {
case .error(let message): case .error(let message):
XCTAssertEqual(message, "usage: /slap <nickname>") #expect(message == "usage: /slap <nickname>")
default: default:
XCTFail("Expected error result for usage message") Issue.record("Expected error result for usage message")
} }
} }
} }
@@ -11,21 +11,19 @@ import CryptoKit
import struct Foundation.UUID import struct Foundation.UUID
@testable import bitchat @testable import bitchat
// TODO: Remove once MockBLEService is refactored to fix race condition
@Suite(.serialized)
struct PrivateChatE2ETests { struct PrivateChatE2ETests {
private let alice: MockBLEService private let alice: MockBLEService
private let bob: MockBLEService private let bob: MockBLEService
private let charlie: MockBLEService private let charlie: MockBLEService
private let mockKeychain: MockKeychain private let mockKeychain = MockKeychain()
private let bus = MockBLEBus()
init() { init() {
// Create services with unique peer IDs to avoid any collision // Create services with unique peer IDs to avoid any collision
alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1) alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1, bus: bus)
bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2) bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2, bus: bus)
charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3) charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3, bus: bus)
mockKeychain = MockKeychain()
} }
// MARK: - Basic Private Messaging Tests // MARK: - Basic Private Messaging Tests
@@ -53,7 +51,7 @@ struct PrivateChatE2ETests {
) )
// Wait a bit to ensure message would have been delivered if it was going to be // Wait a bit to ensure message would have been delivered if it was going to be
try? await Task.sleep(nanoseconds: UInt64(TestConstants.shortTimeout * 1_000_000_000)) try? await sleep(0.1)
} }
#expect(!bobReceivedMessage, "Bob should not have received the message") #expect(!bobReceivedMessage, "Bob should not have received the message")
@@ -171,7 +169,7 @@ struct PrivateChatE2ETests {
// Send encrypted private message // Send encrypted private message
alice.sendPrivateMessage( alice.sendPrivateMessage(
TestConstants.testMessage1, TestConstants.testMessage1,
to: TestConstants.testPeerID2, to: bob.peerID,
recipientNickname: TestConstants.testNickname2 recipientNickname: TestConstants.testNickname2
) )
} }
@@ -235,7 +233,7 @@ struct PrivateChatE2ETests {
for i in 0..<messageCount { for i in 0..<messageCount {
alice.sendPrivateMessage( alice.sendPrivateMessage(
"Private message \(i)", "Private message \(i)",
to: TestConstants.testPeerID2, to: bob.peerID,
recipientNickname: TestConstants.testNickname2 recipientNickname: TestConstants.testNickname2
) )
} }
@@ -254,7 +252,7 @@ struct PrivateChatE2ETests {
alice.sendPrivateMessage( alice.sendPrivateMessage(
TestConstants.testLongMessage, TestConstants.testLongMessage,
to: TestConstants.testPeerID2, to: bob.peerID,
recipientNickname: TestConstants.testNickname2 recipientNickname: TestConstants.testNickname2
) )
} }
@@ -10,22 +10,22 @@ import Testing
import struct Foundation.UUID import struct Foundation.UUID
@testable import bitchat @testable import bitchat
@Suite(.serialized)
struct PublicChatE2ETests { struct PublicChatE2ETests {
private let alice: MockBLEService private let alice: MockBLEService
private let bob: MockBLEService private let bob: MockBLEService
private let charlie: MockBLEService private let charlie: MockBLEService
private let david: MockBLEService private let david: MockBLEService
private let bus = MockBLEBus()
private var receivedMessages: [String: [BitchatMessage]] = [:] private var receivedMessages: [String: [BitchatMessage]] = [:]
init() { init() {
// Create mock services with unique peer IDs to avoid any collision // Create mock services with unique peer IDs to avoid any collision
alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1) alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1, bus: bus)
bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2) bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2, bus: bus)
charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3) charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3, bus: bus)
david = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname4) david = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname4, bus: bus)
} }
// MARK: - Basic Broadcasting Tests // MARK: - Basic Broadcasting Tests
@@ -15,20 +15,26 @@ struct FragmentationTests {
private let mockKeychain: MockKeychain private let mockKeychain: MockKeychain
private let mockIdentityManager: MockIdentityManager private let mockIdentityManager: MockIdentityManager
private let idBridge: NostrIdentityBridge
init() { init() {
mockKeychain = MockKeychain() mockKeychain = MockKeychain()
mockIdentityManager = MockIdentityManager(mockKeychain) mockIdentityManager = MockIdentityManager(mockKeychain)
idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
} }
@Test("Reassembly from fragments delivers a public message") @Test("Reassembly from fragments delivers a public message")
func reassemblyFromFragmentsDeliversPublicMessage() async throws { func reassemblyFromFragmentsDeliversPublicMessage() async throws {
let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager) let ble = BLEService(
keychain: mockKeychain,
idBridge: idBridge,
identityManager: mockIdentityManager
)
let capture = CaptureDelegate() let capture = CaptureDelegate()
ble.delegate = capture ble.delegate = capture
// Construct a big packet (3KB) from a remote sender (not our own ID) // Construct a big packet (3KB) from a remote sender (not our own ID)
let remoteShortID: PeerID = "1122334455667788" let remoteShortID = PeerID(str: "1122334455667788")
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 3_000) let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 3_000)
// Use a small fragment size to ensure multiple pieces // Use a small fragment size to ensure multiple pieces
@@ -39,15 +45,15 @@ struct FragmentationTests {
// Inject fragments spaced out to avoid concurrent mutation inside BLEService // Inject fragments spaced out to avoid concurrent mutation inside BLEService
for (i, fragment) in shuffled.enumerated() { for (i, fragment) in shuffled.enumerated() {
let delay = UInt64(5 * i) * 1_000_000 // nanoseconds let delay = 5 * Double(i) * 0.001
Task { Task {
try await Task.sleep(nanoseconds: delay) try await sleep(delay)
ble._test_handlePacket(fragment, fromPeerID: remoteShortID) ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
} }
} }
// Allow async processing // Allow async processing
try await Task.sleep(nanoseconds: 500_000_000) // 0.5s try await sleep(0.5)
#expect(capture.publicMessages.count == 1) #expect(capture.publicMessages.count == 1)
#expect(capture.publicMessages.first?.content.count == 3_000) #expect(capture.publicMessages.first?.content.count == 3_000)
@@ -55,11 +61,15 @@ struct FragmentationTests {
@Test("Duplicate fragment does not break reassembly") @Test("Duplicate fragment does not break reassembly")
func duplicateFragmentDoesNotBreakReassembly() async throws { func duplicateFragmentDoesNotBreakReassembly() async throws {
let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager) let ble = BLEService(
keychain: mockKeychain,
idBridge: idBridge,
identityManager: mockIdentityManager
)
let capture = CaptureDelegate() let capture = CaptureDelegate()
ble.delegate = capture ble.delegate = capture
let remoteShortID: PeerID = "A1B2C3D4E5F60708" let remoteShortID = PeerID(str: "A1B2C3D4E5F60708")
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 2048) let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 2048)
var frags = fragmentPacket(original, fragmentSize: 300) var frags = fragmentPacket(original, fragmentSize: 300)
@@ -69,15 +79,15 @@ struct FragmentationTests {
} }
for (i, fragment) in frags.enumerated() { for (i, fragment) in frags.enumerated() {
let delay = UInt64(5 * i) * 1_000_000 // nanoseconds let delay = 5 * Double(i) * 0.001
Task { Task {
try await Task.sleep(nanoseconds: delay) try await sleep(delay)
ble._test_handlePacket(fragment, fromPeerID: remoteShortID) ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
} }
} }
// Allow async processing // Allow async processing
try await Task.sleep(nanoseconds: 500_000_000) // 0.5s try await sleep(0.5)
#expect(capture.publicMessages.count == 1) #expect(capture.publicMessages.count == 1)
#expect(capture.publicMessages.first?.content.count == 2048) #expect(capture.publicMessages.first?.content.count == 2048)
@@ -85,11 +95,15 @@ struct FragmentationTests {
@Test("Invalid fragment header is ignored") @Test("Invalid fragment header is ignored")
func invalidFragmentHeaderIsIgnored() async throws { func invalidFragmentHeaderIsIgnored() async throws {
let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager) let ble = BLEService(
keychain: mockKeychain,
idBridge: idBridge,
identityManager: mockIdentityManager
)
let capture = CaptureDelegate() let capture = CaptureDelegate()
ble.delegate = capture ble.delegate = capture
let remoteShortID: PeerID = "0011223344556677" let remoteShortID = PeerID(str: "0011223344556677")
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 1000) let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 1000)
let fragments = fragmentPacket(original, fragmentSize: 250) let fragments = fragmentPacket(original, fragmentSize: 250)
@@ -110,15 +124,15 @@ struct FragmentationTests {
} }
for (i, fragment) in corrupted.enumerated() { for (i, fragment) in corrupted.enumerated() {
let delay = UInt64(5 * i) * 1_000_000 // nanoseconds let delay = 5 * Double(i) * 0.001
Task { Task {
try await Task.sleep(nanoseconds: delay) try await sleep(delay)
ble._test_handlePacket(fragment, fromPeerID: remoteShortID) ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
} }
} }
// Allow async processing // Allow async processing
try await Task.sleep(nanoseconds: 500_000_000) // 0.5s try await sleep(0.5)
// Should not deliver since one fragment is invalid and reassembly can't complete // Should not deliver since one fragment is invalid and reassembly can't complete
#expect(capture.publicMessages.isEmpty) #expect(capture.publicMessages.isEmpty)
+9 -8
View File
@@ -1,22 +1,23 @@
import XCTest import Testing
import struct Foundation.Data
@testable import bitchat @testable import bitchat
final class GCSFilterTests: XCTestCase { struct GCSFilterTests {
func testBuildFilterWithDuplicateIdsProducesStableEncoding() { @Test func buildFilterWithDuplicateIdsProducesStableEncoding() {
let id = Data(repeating: 0xAB, count: 16) let id = Data(repeating: 0xAB, count: 16)
let ids = Array(repeating: id, count: 64) let ids = Array(repeating: id, count: 64)
let params = GCSFilter.buildFilter(ids: ids, maxBytes: 128, targetFpr: 0.01) let params = GCSFilter.buildFilter(ids: ids, maxBytes: 128, targetFpr: 0.01)
XCTAssertGreaterThanOrEqual(params.m, 1) #expect(params.m >= 1)
let decoded = GCSFilter.decodeToSortedSet(p: params.p, m: params.m, data: params.data) let decoded = GCSFilter.decodeToSortedSet(p: params.p, m: params.m, data: params.data)
XCTAssertLessThanOrEqual(decoded.count, 1) #expect(decoded.count <= 1)
} }
func testBucketAvoidsZeroCandidate() { @Test func bucketAvoidsZeroCandidate() {
let id = Data(repeating: 0x01, count: 16) let id = Data(repeating: 0x01, count: 16)
let bucket = GCSFilter.bucket(for: id, modulus: 2) let bucket = GCSFilter.bucket(for: id, modulus: 2)
XCTAssertNotEqual(bucket, 0) #expect(bucket != 0)
XCTAssertLessThan(bucket, 2) #expect(bucket < 2)
} }
} }
+18 -32
View File
@@ -1,52 +1,38 @@
import XCTest import Testing
import Foundation
@testable import bitchat @testable import bitchat
final class GeohashBookmarksStoreTests: XCTestCase { struct GeohashBookmarksStoreTests {
let storeKey = "locationChannel.bookmarks" private let storeKey = "locationChannel.bookmarks"
var storage: UserDefaults! private let storage = UserDefaults(suiteName: UUID().uuidString)!
var store: GeohashBookmarksStore! private let store: GeohashBookmarksStore
override func setUp() { init() {
super.setUp() store = GeohashBookmarksStore(storage: storage)
// Unique instance for each test to avoid race condition
storage = UserDefaults(suiteName: UUID().uuidString)
store = GeohashBookmarksStore(storage: storage!)
} }
override func tearDown() { @Test func toggleAndNormalize() {
storage.removeObject(forKey: storeKey)
store._resetForTesting()
store = nil
storage = nil
super.tearDown()
}
func testToggleAndNormalize() {
// Start clean // Start clean
XCTAssertTrue(store.bookmarks.isEmpty) #expect(store.bookmarks.isEmpty)
// Add with mixed case and hash prefix // Add with mixed case and hash prefix
store.toggle("#U4PRUY") store.toggle("#U4PRUY")
XCTAssertTrue(store.isBookmarked("u4pruy")) #expect(store.isBookmarked("u4pruy"))
XCTAssertEqual(store.bookmarks.first, "u4pruy") #expect(store.bookmarks.first == "u4pruy")
// Toggling again removes // Toggling again removes
store.toggle("u4pruy") store.toggle("u4pruy")
XCTAssertFalse(store.isBookmarked("u4pruy")) #expect(!store.isBookmarked("u4pruy"))
XCTAssertTrue(store.bookmarks.isEmpty) #expect(store.bookmarks.isEmpty)
} }
func testPersistenceWritten() throws { @Test func persistenceWritten() throws {
store.toggle("ezs42") store.toggle("ezs42")
store.toggle("u4pruy") store.toggle("u4pruy")
// Verify persisted JSON contains both (order not enforced here) // Verify persisted JSON contains both (order not enforced here)
guard let data = storage.data(forKey: storeKey) else { let data = try #require(storage.data(forKey: storeKey), "No persisted data found")
XCTFail("No persisted data found")
return
}
let arr = try JSONDecoder().decode([String].self, from: data) let arr = try JSONDecoder().decode([String].self, from: data)
XCTAssertTrue(arr.contains("ezs42")) #expect(arr.contains("ezs42"))
XCTAssertTrue(arr.contains("u4pruy")) #expect(arr.contains("u4pruy"))
} }
} }
+102 -23
View File
@@ -1,24 +1,28 @@
import Foundation import Foundation
import XCTest import Testing
@testable import bitchat @testable import bitchat
final class GossipSyncManagerTests: XCTestCase { struct GossipSyncManagerTests {
func testConcurrentPacketIntakeAndSyncRequest() {
let manager = GossipSyncManager(myPeerID: "0102030405060708") private let myPeerID = PeerID(str: "0102030405060708")
@Test func concurrentPacketIntakeAndSyncRequest() async throws {
let manager = GossipSyncManager(myPeerID: myPeerID)
let delegate = RecordingDelegate() let delegate = RecordingDelegate()
let sendExpectation = expectation(description: "sync request sent")
delegate.onSend = { sendExpectation.fulfill() }
manager.delegate = delegate manager.delegate = delegate
try await confirmation("sync request sent") { sent in
delegate.onSend = {
sent()
}
let iterations = 200 let iterations = 200
let group = DispatchGroup() let senderID = try #require(Data(hexString: "1122334455667788"))
for i in 0..<iterations { for i in 0..<iterations {
group.enter()
DispatchQueue.global(qos: .userInitiated).async {
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.message.rawValue, type: MessageType.message.rawValue,
senderID: Data(hexString: "1122334455667788") ?? Data(), senderID: senderID,
recipientID: nil, recipientID: nil,
timestamp: 1_000_000 + UInt64(i), timestamp: 1_000_000 + UInt64(i),
payload: Data([UInt8(truncatingIfNeeded: i)]), payload: Data([UInt8(truncatingIfNeeded: i)]),
@@ -26,25 +30,100 @@ final class GossipSyncManagerTests: XCTestCase {
ttl: 1 ttl: 1
) )
manager.onPublicPacketSeen(packet) manager.onPublicPacketSeen(packet)
Thread.sleep(forTimeInterval: 0.001) try await sleep(0.001)
group.leave()
}
} }
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.002) { manager.scheduleInitialSyncToPeer(PeerID(str: "FFFFFFFFFFFFFFFF"), delaySeconds: 0.0)
manager.scheduleInitialSyncToPeer("FFFFFFFFFFFFFFFF", delaySeconds: 0.0) try await sleep(0.002)
} }
group.wait() let lastPacket = try #require(delegate.lastPacket, "Expected sync packet to be sent")
wait(for: [sendExpectation], timeout: 2.0) #expect(lastPacket.type == MessageType.requestSync.rawValue)
#expect(RequestSyncPacket.decode(from: lastPacket.payload) != nil)
guard let lastPacket = delegate.lastPacket else {
XCTFail("Expected sync packet to be sent")
return
} }
XCTAssertEqual(lastPacket.type, MessageType.requestSync.rawValue) @Test func staleAnnouncementsArePurgedWithMessages() throws {
XCTAssertNotNil(RequestSyncPacket.decode(from: lastPacket.payload)) var config = GossipSyncManager.Config()
config.stalePeerCleanupIntervalSeconds = 0
config.stalePeerTimeoutSeconds = 5
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
let peerHex = "0011223344556677"
let senderData = try #require(Data(hexString: peerHex))
let initialTimestampMs = UInt64(Date().timeIntervalSince1970 * 1000)
let announcePacket = BitchatPacket(
type: MessageType.announce.rawValue,
senderID: senderData,
recipientID: nil,
timestamp: initialTimestampMs,
payload: Data(),
signature: nil,
ttl: 1
)
let messagePacket = BitchatPacket(
type: MessageType.message.rawValue,
senderID: senderData,
recipientID: nil,
timestamp: initialTimestampMs,
payload: Data([0x01]),
signature: nil,
ttl: 1
)
manager.onPublicPacketSeen(announcePacket)
manager.onPublicPacketSeen(messagePacket)
// Flush queue without triggering stale cleanup yet
manager._performMaintenanceSynchronously(now: Date())
#expect(manager._hasAnnouncement(for: PeerID(str: peerHex)))
#expect(manager._messageCount(for: PeerID(str: peerHex)) == 1)
// Run cleanup past the timeout
let future = Date().addingTimeInterval(config.stalePeerTimeoutSeconds + 1)
manager._performMaintenanceSynchronously(now: future)
#expect(manager._hasAnnouncement(for: PeerID(str: peerHex)) == false)
#expect(manager._messageCount(for: PeerID(str: peerHex)) == 0)
}
@Test func ignoresAnnounceOlderThanStaleTimeout() throws {
var config = GossipSyncManager.Config()
config.stalePeerTimeoutSeconds = 5
config.maxMessageAgeSeconds = 100
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
let peerHex = "8899aabbccddeeff"
let senderData = try #require(Data(hexString: peerHex))
let staleTimestampMs = UInt64(Date().addingTimeInterval(-(config.stalePeerTimeoutSeconds + 1)).timeIntervalSince1970 * 1000)
let freshMessage = BitchatPacket(
type: MessageType.message.rawValue,
senderID: senderData,
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: Data([0xAA]),
signature: nil,
ttl: 1
)
manager.onPublicPacketSeen(freshMessage)
let announcePacket = BitchatPacket(
type: MessageType.announce.rawValue,
senderID: senderData,
recipientID: nil,
timestamp: staleTimestampMs,
payload: Data(),
signature: nil,
ttl: 1
)
manager.onPublicPacketSeen(announcePacket)
manager._performMaintenanceSynchronously()
#expect(manager._hasAnnouncement(for: PeerID(str: peerHex)) == false)
#expect(manager._messageCount(for: PeerID(str: peerHex)) == 0)
} }
} }
+194 -352
View File
@@ -6,52 +6,31 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import XCTest import Foundation
import CryptoKit import CryptoKit
import Testing
@testable import bitchat @testable import bitchat
final class IntegrationTests: XCTestCase { struct IntegrationTests {
var nodes: [String: MockBluetoothMeshService] = [:] private var helper = TestNetworkHelper()
var noiseManagers: [String: NoiseSessionManager] = [:]
private var mockKeychain: MockKeychain!
override func setUp() { init() {
super.setUp() helper.createNode("Alice", peerID: PeerID(str: UUID().uuidString))
// Use the in-memory test bus with autoFlood enabled to simulate helper.createNode("Bob", peerID: PeerID(str: UUID().uuidString))
// broadcast propagation across a larger mesh. Integration-only. helper.createNode("Charlie", peerID: PeerID(str: UUID().uuidString))
MockBLEService.resetTestBus() helper.createNode("David", peerID: PeerID(str: UUID().uuidString))
MockBLEService.autoFloodEnabled = true
mockKeychain = MockKeychain()
// Create a network of nodes
createNode("Alice", peerID: TestConstants.testPeerID1)
createNode("Bob", peerID: TestConstants.testPeerID2)
createNode("Charlie", peerID: TestConstants.testPeerID3)
createNode("David", peerID: TestConstants.testPeerID4)
}
override func tearDown() {
// Disable flooding to avoid cross-test interference
MockBLEService.autoFloodEnabled = false
nodes.removeAll()
noiseManagers.removeAll()
mockKeychain = nil
super.tearDown()
} }
// MARK: - Multi-Peer Scenarios // MARK: - Multi-Peer Scenarios
func testFullMeshCommunication() { @Test func fullMeshCommunication() async throws {
// Create full mesh - everyone connected to everyone helper.connectFullMesh()
connectFullMesh()
let expectation = XCTestExpectation(description: "All nodes communicate")
var messageMatrix: [String: Set<String>] = [:] var messageMatrix: [String: Set<String>] = [:]
for (senderName, _) in helper.nodes { messageMatrix[senderName] = [] }
// Track all receivers; parse sender name from message content "Hello from <Name>" for (receiverName, receiver) in helper.nodes {
for (senderName, _) in nodes { messageMatrix[senderName] = [] }
for (receiverName, receiver) in nodes {
receiver.messageDeliveryHandler = { message in receiver.messageDeliveryHandler = { message in
let parts = message.content.components(separatedBy: " ") let parts = message.content.components(separatedBy: " ")
if let last = parts.last, message.content.contains("Hello from") { if let last = parts.last, message.content.contains("Hello from") {
@@ -62,108 +41,96 @@ final class IntegrationTests: XCTestCase {
} }
} }
// Each node sends a message for (name, node) in helper.nodes {
for (name, node) in nodes { node.sendMessage("Hello from \(name)")
node.sendMessage("Hello from \(name)", mentions: [], to: nil)
} }
// Wait and verify
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
// Each sender should have reached all other nodes // Each sender should have reached all other nodes
for (sender, receivers) in messageMatrix { for (sender, receivers) in messageMatrix {
let expectedReceivers = Set(self.nodes.keys.filter { $0 != sender }) let expectedReceivers = Set(helper.nodes.keys.filter { $0 != sender })
XCTAssertEqual(receivers, expectedReceivers, "\(sender) didn't reach all nodes") #expect(receivers == expectedReceivers, "\(sender) didn't reach all nodes")
} }
expectation.fulfill()
} }
wait(for: [expectation], timeout: TestConstants.defaultTimeout) @Test func dynamicTopologyChanges() async throws {
}
func testDynamicTopologyChanges() {
// Start with Alice -> Bob -> Charlie // Start with Alice -> Bob -> Charlie
connect("Alice", "Bob") helper.connect("Alice", "Bob")
connect("Bob", "Charlie") helper.connect("Bob", "Charlie")
let expectation = XCTestExpectation(description: "Topology changes handled") try await confirmation("Topology changes handled") { receiveMessage in
var phase = 1 var phase = 1
// Phase 1: Test initial topology helper.nodes["Charlie"]!.messageDeliveryHandler = { message in
nodes["Charlie"]!.messageDeliveryHandler = { message in
if phase == 1 && message.sender == "Alice" { if phase == 1 && message.sender == "Alice" {
// Now change topology: disconnect Bob, connect Alice-Charlie // Now change topology: disconnect Bob, connect Alice-Charlie
self.disconnect("Alice", "Bob") helper.disconnect("Alice", "Bob")
self.disconnect("Bob", "Charlie") helper.disconnect("Bob", "Charlie")
self.connect("Alice", "Charlie") helper.connect("Alice", "Charlie")
phase = 2 phase = 2
// Send another message // Send another message
self.nodes["Alice"]!.sendMessage("Direct message", mentions: [], to: nil) helper.nodes["Alice"]!.sendMessage("Direct message")
} else if phase == 2 && message.content == "Direct message" { } else if phase == 2 && message.content == "Direct message" {
expectation.fulfill() receiveMessage()
} }
} }
// Initial message through relay
// Allow relay handler to be set before first send // Allow relay handler to be set before first send
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { try await sleep(0.05)
self.nodes["Alice"]!.sendMessage("Relayed message", mentions: [], to: nil) helper.nodes["Alice"]!.sendMessage("Relayed message")
}
} }
wait(for: [expectation], timeout: TestConstants.defaultTimeout) @Test func networkPartitionRecovery() async throws {
}
func testNetworkPartitionRecovery() {
// Create two partitions // Create two partitions
connect("Alice", "Bob") helper.connect("Alice", "Bob")
connect("Charlie", "David") helper.connect("Charlie", "David")
let expectation = XCTestExpectation(description: "Partitions merge and communicate")
let messagesBeforeMerge = 0 let messagesBeforeMerge = 0
var messagesAfterMerge = 0 var messagesAfterMerge = 0
try await confirmation("Partitions merge and communicate") { receiveMessage in
// Monitor cross-partition messages // Monitor cross-partition messages
nodes["David"]!.messageDeliveryHandler = { message in helper.nodes["David"]!.messageDeliveryHandler = { message in
if message.sender == "Alice" { if message.sender == "Alice" {
messagesAfterMerge += 1 messagesAfterMerge += 1
if messagesAfterMerge == 1 { if messagesAfterMerge == 1 {
expectation.fulfill() receiveMessage()
} }
} }
} }
// Try to send across partition (should fail) // Try to send across partition (should fail)
nodes["Alice"]!.sendMessage("Before merge", mentions: [], to: nil) helper.nodes["Alice"]!.sendMessage("Before merge")
// Merge partitions after delay // Merge partitions after delay
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { try await sleep(0.05)
// Connect partitions // Connect partitions
self.connect("Bob", "Charlie") helper.connect("Bob", "Charlie")
// Enable relay // Enable relay
self.setupRelay("Bob", nextHops: ["Charlie"]) helper.setupRelay("Bob", nextHops: ["Charlie"])
self.setupRelay("Charlie", nextHops: ["David"]) helper.setupRelay("Charlie", nextHops: ["David"])
// Send message across merged network // Send message across merged network
self.nodes["Alice"]!.sendMessage("After merge", mentions: [], to: nil) helper.nodes["Alice"]!.sendMessage("After merge")
} }
wait(for: [expectation], timeout: TestConstants.defaultTimeout) #expect(messagesBeforeMerge == 0)
XCTAssertEqual(messagesBeforeMerge, 0) #expect(messagesAfterMerge == 1)
XCTAssertEqual(messagesAfterMerge, 1)
} }
// MARK: - Mixed Message Type Scenarios // MARK: - Mixed Message Type Scenarios
func testMixedPublicPrivateMessages() throws { @Test func mixedPublicPrivateMessages() async throws {
connectFullMesh() helper.connectFullMesh()
let expectation = XCTestExpectation(description: "Mixed messages handled correctly")
var publicCount = 0 var publicCount = 0
var privateCount = 0 var privateCount = 0
await confirmation("Mixed messages handled correctly") { completion in
// Bob monitors messages // Bob monitors messages
nodes["Bob"]!.messageDeliveryHandler = { message in helper.nodes["Bob"]!.messageDeliveryHandler = { message in
if message.isPrivate && message.recipientNickname == "Bob" { if message.isPrivate && message.recipientNickname == "Bob" {
privateCount += 1 privateCount += 1
} else if !message.isPrivate { } else if !message.isPrivate {
@@ -171,261 +138,239 @@ final class IntegrationTests: XCTestCase {
} }
if publicCount == 2 && privateCount == 1 { if publicCount == 2 && privateCount == 1 {
expectation.fulfill() completion()
} }
} }
// Alice sends mixed messages // Alice sends mixed messages
nodes["Alice"]!.sendMessage("Public 1", mentions: [], to: nil) helper.nodes["Alice"]!.sendMessage("Public 1")
nodes["Alice"]!.sendPrivateMessage("Private to Bob", to: TestConstants.testPeerID2, recipientNickname: "Bob") helper.nodes["Alice"]!.sendPrivateMessage("Private to Bob", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob")
nodes["Alice"]!.sendMessage("Public 2", mentions: [], to: nil) helper.nodes["Alice"]!.sendMessage("Public 2")
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
XCTAssertEqual(publicCount, 2)
XCTAssertEqual(privateCount, 1)
} }
func testEncryptedAndUnencryptedMix() throws { #expect(publicCount == 2)
connect("Alice", "Bob") #expect(privateCount == 1)
}
@Test func encryptedAndUnencryptedMix() async throws {
helper.connect("Alice", "Bob")
// Setup Noise session // Setup Noise session
try establishNoiseSession("Alice", "Bob") try helper.establishNoiseSession("Alice", "Bob")
let expectation = XCTestExpectation(description: "Both encrypted and plain messages work")
var plainCount = 0 var plainCount = 0
var encryptedCount = 0 var encryptedCount = 0
// Setup handlers try await confirmation("Both encrypted and plain messages work") { completion in
// Plain path: send public message and count at Bob // Plain path: send public message and count at Bob
nodes["Bob"]!.messageDeliveryHandler = { message in helper.nodes["Bob"]!.messageDeliveryHandler = { message in
if message.content == "Plain message" { plainCount += 1 } if message.content == "Plain message" {
if plainCount == 1 && encryptedCount == 1 { expectation.fulfill() } plainCount += 1
}
if plainCount == 1 && encryptedCount == 1 {
completion()
}
} }
// Encrypted path: use NoiseSessionManager explicitly // Encrypted path: use NoiseSessionManager explicitly
let plaintext = "Encrypted message".data(using: .utf8)! let plaintext = "Encrypted message".data(using: .utf8)!
let ciphertext = try noiseManagers["Alice"]!.encrypt(plaintext, for: TestConstants.testPeerID2) let ciphertext = try helper.noiseManagers["Alice"]!.encrypt(plaintext, for: helper.nodes["Bob"]!.peerID)
nodes["Bob"]!.packetDeliveryHandler = { packet in
helper.nodes["Bob"]!.packetDeliveryHandler = { packet in
if packet.type == MessageType.noiseEncrypted.rawValue { if packet.type == MessageType.noiseEncrypted.rawValue {
if let data = try? self.noiseManagers["Bob"]!.decrypt(ciphertext, from: TestConstants.testPeerID1), if let data = try? helper.noiseManagers["Bob"]!.decrypt(ciphertext, from: helper.nodes["Alice"]!.peerID),
data == plaintext { data == plaintext {
encryptedCount = 1 encryptedCount = 1
if plainCount == 1 { expectation.fulfill() } if plainCount == 1 {
completion()
}
} }
} }
} }
nodes["Alice"]!.sendMessage("Plain message", mentions: [], to: nil) helper.nodes["Alice"]!.sendMessage("Plain message")
// Deliver encrypted packet directly // Deliver encrypted packet directly
let encPacket = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext) let encPacket = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext)
nodes["Bob"]!.simulateIncomingPacket(encPacket) helper.nodes["Bob"]!.simulateIncomingPacket(encPacket)
}
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
} }
// MARK: - Network Resilience Tests // MARK: - Network Resilience Tests
func testMessageDeliveryUnderChurn() { @Test func messageDeliveryUnderChurn() async throws {
// Start with stable network // Start with stable network
connectFullMesh() helper.connectFullMesh()
let expectation = XCTestExpectation(description: "Messages delivered despite churn")
var receivedMessages = Set<String>()
let totalMessages = 10 let totalMessages = 10
try await confirmation("Messages delivered despite churn", expectedCount: totalMessages) { completion in
// David tracks received messages // David tracks received messages
nodes["David"]!.messageDeliveryHandler = { message in helper.nodes["David"]!.messageDeliveryHandler = { message in
receivedMessages.insert(message.content) completion()
if receivedMessages.count == totalMessages {
expectation.fulfill()
}
} }
// Send messages while churning network // Send messages while churning network
for i in 0..<totalMessages { for i in 0..<totalMessages {
nodes["Alice"]!.sendMessage("Message \(i)", mentions: [], to: nil) helper.nodes["Alice"]!.sendMessage("Message \(i)")
// Simulate churn // Simulate churn
if i % 3 == 0 { if i % 3 == 0 {
// Disconnect and reconnect random connection // Disconnect and reconnect random connection
let pairs = [("Alice", "Bob"), ("Bob", "Charlie"), ("Charlie", "David")] let pairs = [("Alice", "Bob"), ("Bob", "Charlie"), ("Charlie", "David")]
let randomPair = pairs.randomElement()! let randomPair = pairs.randomElement()!
disconnect(randomPair.0, randomPair.1) helper.disconnect(randomPair.0, randomPair.1)
try await sleep(0.01)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { helper.connect(randomPair.0, randomPair.1)
self.connect(randomPair.0, randomPair.1) }
} }
} }
} }
wait(for: [expectation], timeout: TestConstants.longTimeout) @Test func peerPresenceTrackingAndReconnection() async throws {
XCTAssertEqual(receivedMessages.count, totalMessages) helper.connect("Alice", "Bob")
}
func testPeerPresenceTrackingAndReconnection() { await confirmation("Delivery after reconnection") { delivered in
// Test that after disconnect/reconnect, message delivery resumes helper.nodes["Bob"]!.messageDeliveryHandler = { message in
connect("Alice", "Bob") if message.content == "After reconnect" {
delivered()
let expectation = XCTestExpectation(description: "Delivery after reconnection")
var delivered = false
nodes["Bob"]!.messageDeliveryHandler = { message in
if message.content == "After reconnect" && !delivered {
delivered = true
expectation.fulfill()
} }
} }
// Simulate disconnect (out of range) // Simulate disconnect (out of range)
disconnect("Alice", "Bob") helper.disconnect("Alice", "Bob")
// Reconnect // Reconnect
connect("Alice", "Bob") helper.connect("Alice", "Bob")
// Send after reconnection // Send after reconnection
nodes["Alice"]!.sendMessage("After reconnect", mentions: [], to: nil) helper.nodes["Alice"]!.sendMessage("After reconnect")
}
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
XCTAssertTrue(delivered)
} }
func testEncryptedMessageAfterPeerRestart() { @Test func encryptedMessageAfterPeerRestart() async throws {
// Test that encrypted messages work after one peer restarts helper.connect("Alice", "Bob")
connect("Alice", "Bob")
do { do {
try establishNoiseSession("Alice", "Bob") try helper.establishNoiseSession("Alice", "Bob")
} catch { } catch {
XCTFail("Failed to establish Noise session: \(error)") Issue.record("Failed to establish Noise session: \(error)")
} }
// Exchange an encrypted message // Exchange an encrypted message
let firstExpectation = XCTestExpectation(description: "First message received") await confirmation("First message received") { received in
nodes["Bob"]!.messageDeliveryHandler = { message in helper.nodes["Bob"]!.messageDeliveryHandler = { message in
if message.content == "Before restart" && message.isPrivate { if message.content == "Before restart" && message.isPrivate {
firstExpectation.fulfill() received()
} }
} }
helper.nodes["Alice"]!.sendPrivateMessage("Before restart", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob")
nodes["Alice"]!.sendPrivateMessage("Before restart", to: TestConstants.testPeerID2, recipientNickname: "Bob") }
wait(for: [firstExpectation], timeout: TestConstants.defaultTimeout)
// Simulate Bob restart by recreating his Noise manager // Simulate Bob restart by recreating his Noise manager
let bobKey = Curve25519.KeyAgreement.PrivateKey() let bobKey = Curve25519.KeyAgreement.PrivateKey()
noiseManagers["Bob"] = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) helper.noiseManagers["Bob"] = NoiseSessionManager(localStaticKey: bobKey, keychain: helper.mockKeychain)
// Re-establish Noise handshake explicitly via managers // Re-establish Noise handshake explicitly via managers
do { do {
let m1 = try noiseManagers["Bob"]!.initiateHandshake(with: TestConstants.testPeerID1) let m1 = try helper.noiseManagers["Bob"]!.initiateHandshake(with: helper.nodes["Alice"]!.peerID)
let m2 = try noiseManagers["Alice"]!.handleIncomingHandshake(from: TestConstants.testPeerID2, message: m1)! let m2 = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m1)!
let m3 = try noiseManagers["Bob"]!.handleIncomingHandshake(from: TestConstants.testPeerID1, message: m2)! let m3 = try helper.noiseManagers["Bob"]!.handleIncomingHandshake(from: helper.nodes["Alice"]!.peerID, message: m2)!
_ = try noiseManagers["Alice"]!.handleIncomingHandshake(from: TestConstants.testPeerID2, message: m3) _ = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m3)
} catch { } catch {
XCTFail("Failed to re-establish Noise session after restart: \(error)") Issue.record("Failed to re-establish Noise session after restart: \(error)")
} }
// Now messages should work again // Now messages should work again - simulate encrypted packet
let secondExpectation = XCTestExpectation(description: "Message after restart received") await confirmation("Message after restart received") { received in
nodes["Alice"]!.messageDeliveryHandler = { message in helper.nodes["Alice"]!.messageDeliveryHandler = { message in
if message.content == "After restart success" && message.isPrivate { if message.content == "After restart success" && message.isPrivate {
secondExpectation.fulfill() received()
} }
} }
// Simulate encrypted message using managers
do { do {
let plaintext = "After restart success".data(using: .utf8)! let plaintext = "After restart success".data(using: .utf8)!
let ciphertext = try noiseManagers["Bob"]!.encrypt(plaintext, for: TestConstants.testPeerID1) let ciphertext = try helper.noiseManagers["Bob"]!.encrypt(plaintext, for: helper.nodes["Alice"]!.peerID)
let packet = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext) let packet = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext)
nodes["Alice"]!.packetDeliveryHandler = { pkt in helper.nodes["Alice"]!.packetDeliveryHandler = { pkt in
if pkt.type == MessageType.noiseEncrypted.rawValue { if pkt.type == MessageType.noiseEncrypted.rawValue {
if let data = try? self.noiseManagers["Alice"]!.decrypt(pkt.payload, from: TestConstants.testPeerID2), if let data = try? helper.noiseManagers["Alice"]!.decrypt(pkt.payload, from: helper.nodes["Bob"]!.peerID),
String(data: data, encoding: .utf8) == "After restart success" { String(data: data, encoding: .utf8) == "After restart success" {
secondExpectation.fulfill() received()
} }
} }
} }
nodes["Alice"]!.simulateIncomingPacket(packet) helper.nodes["Alice"]!.simulateIncomingPacket(packet)
} catch { } catch {
XCTFail("Encryption after restart failed: \(error)") Issue.record("Encryption after restart failed: \(error)")
}
} }
wait(for: [secondExpectation], timeout: TestConstants.defaultTimeout)
} }
func testLargeScaleNetwork() { @Test func largeScaleNetwork() async throws {
// Create larger network // Create larger network
for i in 5...10 { for i in 5...10 {
createNode("Node\(i)", peerID: "PEER\(i)") helper.createNode("Node\(i)", peerID: PeerID(str: "PEER\(i)"))
} }
// Connect in ring topology with cross-connections // Connect in ring topology with cross-connections
let allNodes = Array(nodes.keys).sorted() let allNodes = Array(helper.nodes.keys).sorted()
for i in 0..<allNodes.count { for i in 0..<allNodes.count {
// Ring connection // Ring connection
connect(allNodes[i], allNodes[(i + 1) % allNodes.count]) helper.connect(allNodes[i], allNodes[(i + 1) % allNodes.count])
// Cross connection // Cross connection
if i + 3 < allNodes.count { if i + 3 < allNodes.count {
connect(allNodes[i], allNodes[i + 3]) helper.connect(allNodes[i], allNodes[i + 3])
} }
} }
let expectation = XCTestExpectation(description: "Large network handles broadcast") await confirmation("Large network handles broadcast", expectedCount: helper.nodes.count - 1) { nodeReaced in
var nodesReached = Set<String>()
// All nodes except Alice listen // All nodes except Alice listen
for (name, node) in nodes where name != "Alice" { for (name, node) in helper.nodes where name != "Alice" {
node.messageDeliveryHandler = { message in node.messageDeliveryHandler = { message in
if message.content == "Broadcast test" { if message.content == "Broadcast test" {
nodesReached.insert(name) nodeReaced()
if nodesReached.count == self.nodes.count - 1 {
expectation.fulfill()
}
} }
} }
} }
// Alice broadcasts // Alice broadcasts
nodes["Alice"]!.sendMessage("Broadcast test", mentions: [], to: nil) helper.nodes["Alice"]!.sendMessage("Broadcast test")
}
wait(for: [expectation], timeout: TestConstants.longTimeout)
XCTAssertEqual(nodesReached.count, nodes.count - 1)
} }
// MARK: - Stress Tests // MARK: - Stress Tests
func testHighLoadScenario() { @Test func highLoadScenario() async throws {
connectFullMesh() helper.connectFullMesh()
let messagesPerNode = 25 let messagesPerNode = 25
let expectedTotal = messagesPerNode * nodes.count * (nodes.count - 1) let expectedTotal = messagesPerNode * helper.nodes.count * (helper.nodes.count - 1)
var receivedTotal = 0
let expectation = XCTestExpectation(description: "High load handled")
await confirmation("High load handled", expectedCount: expectedTotal) { received in
// Each node tracks messages // Each node tracks messages
for (_, node) in nodes { for (_, node) in helper.nodes {
node.messageDeliveryHandler = { _ in node.messageDeliveryHandler = { _ in
receivedTotal += 1 received()
if receivedTotal >= (expectedTotal - 2) {
expectation.fulfill()
}
} }
} }
// All nodes send many messages simultaneously // All nodes send many messages simultaneously
DispatchQueue.concurrentPerform(iterations: nodes.count) { index in await withTaskGroup(of: Void.self) { group in
let nodeName = Array(nodes.keys).sorted()[index] for (name, node) in helper.nodes {
group.addTask {
for i in 0..<messagesPerNode { for i in 0..<messagesPerNode {
nodes[nodeName]!.sendMessage("\(nodeName) message \(i)", mentions: [], to: nil) node.sendMessage("\(name) message \(i)")
}
}
}
await group.waitForAll()
}
} }
} }
wait(for: [expectation], timeout: TestConstants.longTimeout) @Test func mixedTrafficPatterns() async throws {
XCTAssertGreaterThanOrEqual(receivedTotal, expectedTotal - 2) helper.connectFullMesh()
}
func testMixedTrafficPatterns() {
connectFullMesh()
let expectation = XCTestExpectation(description: "Mixed traffic handled")
var metrics = [ var metrics = [
"public": 0, "public": 0,
"private": 0, "private": 0,
@@ -434,7 +379,7 @@ final class IntegrationTests: XCTestCase {
] ]
// Setup complex handlers // Setup complex handlers
for (name, node) in nodes { for (name, node) in helper.nodes {
node.messageDeliveryHandler = { message in node.messageDeliveryHandler = { message in
if message.isPrivate { if message.isPrivate {
metrics["private"]! += 1 metrics["private"]! += 1
@@ -453,88 +398,78 @@ final class IntegrationTests: XCTestCase {
} }
// Generate mixed traffic // Generate mixed traffic
nodes["Alice"]!.sendMessage("Public broadcast", mentions: [], to: nil) helper.nodes["Alice"]!.sendMessage("Public broadcast")
nodes["Alice"]!.sendPrivateMessage("Private to Bob", to: TestConstants.testPeerID2, recipientNickname: "Bob") helper.nodes["Alice"]!.sendPrivateMessage("Private to Bob", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob")
nodes["Bob"]!.sendMessage("Mentioning @Charlie", mentions: ["Charlie"], to: nil) helper.nodes["Bob"]!.sendMessage("Mentioning @Charlie", mentions: ["Charlie"])
// Disconnect to force relay // Disconnect to force relay
disconnect("Alice", "David") helper.disconnect("Alice", "David")
nodes["Alice"]!.sendMessage("Needs relay to David", mentions: [], to: nil) helper.nodes["Alice"]!.sendMessage("Needs relay to David")
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { #expect(metrics["public", default: 0] > 0)
XCTAssertGreaterThan(metrics["public"]!, 0) #expect(metrics["private", default: 0] > 0)
XCTAssertGreaterThan(metrics["private"]!, 0) #expect(metrics["mentions", default: 0] > 0)
XCTAssertGreaterThan(metrics["mentions"]!, 0)
expectation.fulfill()
}
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
} }
// MARK: - Security Integration Tests // MARK: - Security Integration Tests
// Replacement for the legacy NACK test: verifies that after a // Replacement for the legacy NACK test: verifies that after a
// decryption failure, peers can rehandshake via NoiseSessionManager // decryption failure, peers can rehandshake via NoiseSessionManager
// and resume secure communication. // and resume secure communication.
func testRehandshakeAfterDecryptionFailure() throws { @Test func rehandshakeAfterDecryptionFailure() throws {
// Alice <-> Bob connected // Alice <-> Bob connected
connect("Alice", "Bob") helper.connect("Alice", "Bob")
// Establish initial Noise session // Establish initial Noise session
try establishNoiseSession("Alice", "Bob") try helper.establishNoiseSession("Alice", "Bob")
guard let aliceManager = noiseManagers["Alice"], guard let aliceManager = helper.noiseManagers["Alice"],
let bobManager = noiseManagers["Bob"], let bobManager = helper.noiseManagers["Bob"],
let alicePeerID = nodes["Alice"]?.peerID, let alicePeerID = helper.nodes["Alice"]?.peerID,
let bobPeerID = nodes["Bob"]?.peerID else { let bobPeerID = helper.nodes["Bob"]?.peerID
return XCTFail("Missing managers or peer IDs") else {
Issue.record("Missing managers or peer IDs")
return
} }
// Baseline: encrypt from Alice, decrypt at Bob // Baseline: encrypt from Alice, decrypt at Bob
let plaintext1 = Data("hello-secure".utf8) let plaintext1 = Data("hello-secure".utf8)
let encrypted1 = try aliceManager.encrypt(plaintext1, for: bobPeerID) let encrypted1 = try aliceManager.encrypt(plaintext1, for: bobPeerID)
let decrypted1 = try bobManager.decrypt(encrypted1, from: alicePeerID) let decrypted1 = try bobManager.decrypt(encrypted1, from: alicePeerID)
XCTAssertEqual(decrypted1, plaintext1) #expect(decrypted1 == plaintext1)
// Simulate decryption failure by corrupting ciphertext // Simulate decryption failure by corrupting ciphertext
var corrupted = encrypted1 let corrupted = encrypted1.prefix(15)
if !corrupted.isEmpty { corrupted[corrupted.count - 1] ^= 0xFF } #expect(throws: NoiseError.invalidCiphertext) {
do {
_ = try bobManager.decrypt(corrupted, from: alicePeerID) _ = try bobManager.decrypt(corrupted, from: alicePeerID)
XCTFail("Corrupted ciphertext should not decrypt")
} catch {
// Expected: treat as session desync and rehandshake
} }
// Bob initiates a new handshake; clear Bob's session first so initiateHandshake won't throw // Bob initiates a new handshake; clear Bob's session first so initiateHandshake won't throw
bobManager.removeSession(for: alicePeerID) bobManager.removeSession(for: alicePeerID)
try establishNoiseSession("Bob", "Alice") try helper.establishNoiseSession("Bob", "Alice")
// After rehandshake, encryption/decryption works again // After rehandshake, encryption/decryption works again
let plaintext2 = Data("hello-again".utf8) let plaintext2 = Data("hello-again".utf8)
let encrypted2 = try aliceManager.encrypt(plaintext2, for: bobPeerID) let encrypted2 = try aliceManager.encrypt(plaintext2, for: bobPeerID)
let decrypted2 = try bobManager.decrypt(encrypted2, from: alicePeerID) let decrypted2 = try bobManager.decrypt(encrypted2, from: alicePeerID)
XCTAssertEqual(decrypted2, plaintext2) #expect(decrypted2 == plaintext2)
} }
@Test func endToEndSecurityScenario() async throws {
func testEndToEndSecurityScenario() throws { helper.connect("Alice", "Bob")
connect("Alice", "Bob") helper.connect("Bob", "Charlie") // Charlie will try to eavesdrop
connect("Bob", "Charlie") // Charlie will try to eavesdrop
// Establish secure session between Alice and Bob only // Establish secure session between Alice and Bob only
try establishNoiseSession("Alice", "Bob") try helper.establishNoiseSession("Alice", "Bob")
let expectation = XCTestExpectation(description: "Secure communication maintained") await confirmation("Secure communication maintained", expectedCount: 2) { receivedPacket in
var bobDecrypted = false
var charlieIntercepted = false
// Setup encryption at Alice // Setup encryption at Alice
nodes["Alice"]!.packetDeliveryHandler = { packet in helper.nodes["Alice"]!.packetDeliveryHandler = { packet in
if packet.type == 0x01, if packet.type == 0x01,
let message = BitchatMessage(packet.payload), let message = BitchatMessage(packet.payload),
message.isPrivate && packet.recipientID != nil { message.isPrivate && packet.recipientID != nil {
// Encrypt private messages // Encrypt private messages
if let encrypted = try? self.noiseManagers["Alice"]!.encrypt(packet.payload, for: TestConstants.testPeerID2) { if let encrypted = try? helper.noiseManagers["Alice"]!.encrypt(packet.payload, for: helper.nodes["Bob"]!.peerID) {
let encPacket = BitchatPacket( let encPacket = BitchatPacket(
type: 0x02, type: 0x02,
senderID: packet.senderID, senderID: packet.senderID,
@@ -544,131 +479,38 @@ final class IntegrationTests: XCTestCase {
signature: packet.signature, signature: packet.signature,
ttl: packet.ttl ttl: packet.ttl
) )
self.nodes["Bob"]!.simulateIncomingPacket(encPacket) helper.nodes["Bob"]!.simulateIncomingPacket(encPacket)
} }
} }
} }
// Bob can decrypt // Bob can decrypt
nodes["Bob"]!.packetDeliveryHandler = { packet in helper.nodes["Bob"]!.packetDeliveryHandler = { packet in
if packet.type == 0x02 { if packet.type == 0x02 {
if let decrypted = try? self.noiseManagers["Bob"]!.decrypt(packet.payload, from: TestConstants.testPeerID1), receivedPacket()
let message = BitchatMessage(decrypted) { if let decrypted = try? helper.noiseManagers["Bob"]!.decrypt(packet.payload, from: helper.nodes["Alice"]!.peerID) {
bobDecrypted = message.content == "Secret message" #expect(BitchatMessage(decrypted)?.content == "Secret message")
expectation.fulfill() } else {
Issue.record("Bob was unable to decrypt the message")
} }
// Relay encrypted packet to Charlie // Relay encrypted packet to Charlie
self.nodes["Charlie"]!.simulateIncomingPacket(packet) helper.nodes["Charlie"]!.simulateIncomingPacket(packet)
} }
} }
// Charlie cannot decrypt // Charlie cannot decrypt
nodes["Charlie"]!.packetDeliveryHandler = { packet in helper.nodes["Charlie"]!.packetDeliveryHandler = { packet in
if packet.type == 0x02 { if packet.type == 0x02 {
charlieIntercepted = true receivedPacket()
// Try to decrypt (should fail) #expect(throws: NoiseSessionError.sessionNotFound, "Charlie should not be able to decrypt") {
do { _ = try helper.noiseManagers["Charlie"]?.decrypt(packet.payload, from: helper.nodes["Alice"]!.peerID)
_ = try self.noiseManagers["Charlie"]?.decrypt(packet.payload, from: TestConstants.testPeerID1)
XCTFail("Charlie should not be able to decrypt")
} catch {
// Expected
} }
} }
} }
// Send encrypted private message // Send encrypted private message
nodes["Alice"]!.sendPrivateMessage("Secret message", to: TestConstants.testPeerID2, recipientNickname: "Bob") helper.nodes["Alice"]!.sendPrivateMessage("Secret message", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob")
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
XCTAssertTrue(bobDecrypted)
XCTAssertTrue(charlieIntercepted)
} }
// MARK: - Helper Methods
private func createNode(_ name: String, peerID: PeerID) {
let node = MockBluetoothMeshService()
node.myPeerID = peerID
node.mockNickname = name
nodes[name] = node
// Create Noise manager
let key = Curve25519.KeyAgreement.PrivateKey()
noiseManagers[name] = NoiseSessionManager(localStaticKey: key, keychain: mockKeychain)
}
private func connect(_ node1: String, _ node2: String) {
guard let n1 = nodes[node1], let n2 = nodes[node2] else { return }
n1.simulateConnectedPeer(n2.peerID)
n2.simulateConnectedPeer(n1.peerID)
}
private func disconnect(_ node1: String, _ node2: String) {
guard let n1 = nodes[node1], let n2 = nodes[node2] else { return }
n1.simulateDisconnectedPeer(n2.peerID)
n2.simulateDisconnectedPeer(n1.peerID)
}
private func connectFullMesh() {
let nodeNames = Array(nodes.keys)
for i in 0..<nodeNames.count {
for j in i+1..<nodeNames.count {
connect(nodeNames[i], nodeNames[j])
}
}
}
private func setupRelay(_ nodeName: String, nextHops: [String]) {
guard let node = nodes[nodeName] else { return }
node.packetDeliveryHandler = { packet in
guard packet.ttl > 1 else { return }
if let message = BitchatMessage(packet.payload) {
guard message.senderPeerID != node.peerID else { return }
let relayMessage = BitchatMessage(
id: message.id,
sender: message.sender,
content: message.content,
timestamp: message.timestamp,
isRelay: true,
originalSender: message.isRelay ? message.originalSender : message.sender,
isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname,
senderPeerID: message.senderPeerID,
mentions: message.mentions
)
if let relayPayload = relayMessage.toBinaryPayload() {
let relayPacket = BitchatPacket(
type: packet.type,
senderID: packet.senderID,
recipientID: packet.recipientID,
timestamp: packet.timestamp,
payload: relayPayload,
signature: packet.signature,
ttl: packet.ttl - 1
)
for hop in nextHops {
self.nodes[hop]?.simulateIncomingPacket(relayPacket)
}
}
}
}
}
private func establishNoiseSession(_ node1: String, _ node2: String) throws {
guard let manager1 = noiseManagers[node1],
let manager2 = noiseManagers[node2],
let peer1ID = nodes[node1]?.peerID,
let peer2ID = nodes[node2]?.peerID else { return }
let msg1 = try manager1.initiateHandshake(with: peer2ID)
let msg2 = try manager2.handleIncomingHandshake(from: peer1ID, message: msg1)!
let msg3 = try manager1.handleIncomingHandshake(from: peer2ID, message: msg2)!
_ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3)
} }
} }
@@ -0,0 +1,123 @@
//
// TestNetworkHelper.swift
// bitchatTests
//
// Extracted shared, mutable integration state for nodes and noise sessions.
// Keeps test containers nonmutating (Swift Testing-friendly).
//
import Foundation
import CryptoKit
@testable import bitchat
final class TestNetworkHelper {
// Public, read-only views for tests; mutation only through methods
var nodes: [String: MockBLEService] = [:]
var noiseManagers: [String: NoiseSessionManager] = [:]
let mockKeychain = MockKeychain()
private let bus = MockBLEBus(autoFloodEnabled: true)
// MARK: - Node/Manager management
@discardableResult
func createNode(_ name: String, peerID: PeerID) -> MockBLEService {
let node = MockBLEService(bus: bus)
node.myPeerID = peerID
node.mockNickname = name
nodes[name] = node
// Create/replace Noise manager for this node
let key = Curve25519.KeyAgreement.PrivateKey()
noiseManagers[name] = NoiseSessionManager(localStaticKey: key, keychain: mockKeychain)
return node
}
func getNode(_ name: String) -> MockBLEService? {
nodes[name]
}
func getManager(_ name: String) -> NoiseSessionManager? {
noiseManagers[name]
}
// MARK: - Topology
func connect(_ a: String, _ b: String) {
guard let n1 = nodes[a], let n2 = nodes[b] else { return }
n1.simulateConnectedPeer(n2.peerID)
n2.simulateConnectedPeer(n1.peerID)
}
func disconnect(_ a: String, _ b: String) {
guard let n1 = nodes[a], let n2 = nodes[b] else { return }
n1.simulateDisconnectedPeer(n2.peerID)
n2.simulateDisconnectedPeer(n1.peerID)
}
func connectFullMesh() {
let names = Array(nodes.keys)
for i in 0..<names.count {
for j in (i+1)..<names.count {
connect(names[i], names[j])
}
}
}
// MARK: - Relay
func setupRelay(_ nodeName: String, nextHops: [String]) {
guard let node = nodes[nodeName] else { return }
node.packetDeliveryHandler = { [weak self] packet in
guard let self else { return }
guard packet.ttl > 1 else { return }
if let message = BitchatMessage(packet.payload) {
guard message.senderPeerID != node.peerID else { return }
let relayMessage = BitchatMessage(
id: message.id,
sender: message.sender,
content: message.content,
timestamp: message.timestamp,
isRelay: true,
originalSender: message.isRelay ? message.originalSender : message.sender,
isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname,
senderPeerID: message.senderPeerID,
mentions: message.mentions
)
if let relayPayload = relayMessage.toBinaryPayload() {
let relayPacket = BitchatPacket(
type: packet.type,
senderID: packet.senderID,
recipientID: packet.recipientID,
timestamp: packet.timestamp,
payload: relayPayload,
signature: packet.signature,
ttl: packet.ttl - 1
)
for hop in nextHops {
self.nodes[hop]?.simulateIncomingPacket(relayPacket)
}
}
}
}
}
// MARK: - Noise sessions
func establishNoiseSession(_ node1: String, _ node2: String) throws {
guard let manager1 = noiseManagers[node1],
let manager2 = noiseManagers[node2],
let peer1ID = nodes[node1]?.peerID,
let peer2ID = nodes[node2]?.peerID else { return }
let msg1 = try manager1.initiateHandshake(with: peer2ID)
let msg2 = try manager2.handleIncomingHandshake(from: peer1ID, message: msg1)!
let msg3 = try manager1.handleIncomingHandshake(from: peer2ID, message: msg2)!
_ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3)
}
}
+21 -19
View File
@@ -1,8 +1,9 @@
import XCTest import Testing
import Foundation
@testable import bitchat @testable import bitchat
final class LocationChannelsTests: XCTestCase { struct LocationChannelsTests {
func testGeohashEncoderPrecisionMapping() { @Test func geohashEncoderPrecisionMapping() {
// Sanity: known coords (Statue of Liberty approx) // Sanity: known coords (Statue of Liberty approx)
let lat = 40.6892 let lat = 40.6892
let lon = -74.0445 let lon = -74.0445
@@ -12,34 +13,35 @@ final class LocationChannelsTests: XCTestCase {
let region = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.province.precision) let region = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.province.precision)
let country = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.region.precision) let country = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.region.precision)
XCTAssertEqual(block.count, 7) #expect(block.count == 7)
XCTAssertEqual(neighborhood.count, 6) #expect(neighborhood.count == 6)
XCTAssertEqual(city.count, 5) #expect(city.count == 5)
XCTAssertEqual(region.count, 4) #expect(region.count == 4)
XCTAssertEqual(country.count, 2) #expect(country.count == 2)
// All prefixes must match progressively // All prefixes must match progressively
XCTAssertTrue(block.hasPrefix(neighborhood)) #expect(block.hasPrefix(neighborhood))
XCTAssertTrue(neighborhood.hasPrefix(city)) #expect(neighborhood.hasPrefix(city))
XCTAssertTrue(city.hasPrefix(region)) #expect(city.hasPrefix(region))
XCTAssertTrue(region.hasPrefix(country)) #expect(region.hasPrefix(country))
} }
func testNostrGeohashFilterEncoding() throws { @Test func nostrGeohashFilterEncoding() throws {
let gh = "u4pruy" let gh = "u4pruy"
let filter = NostrFilter.geohashEphemeral(gh) let filter = NostrFilter.geohashEphemeral(gh)
let data = try JSONEncoder().encode(filter) let data = try JSONEncoder().encode(filter)
let json = String(data: data, encoding: .utf8) ?? "" let json = String(data: data, encoding: .utf8) ?? ""
// Expect kinds includes 20000 and tag filter '#g':[gh] // Expect kinds includes 20000 and tag filter '#g':[gh]
XCTAssertTrue(json.contains("20000")) #expect(json.contains("20000"))
XCTAssertTrue(json.contains("\"#g\":[\"\(gh)\"]")) #expect(json.contains("\"#g\":[\"\(gh)\"]"))
} }
func testPerGeohashIdentityDeterministic() throws { @Test func perGeohashIdentityDeterministic() throws {
// Derive twice for same geohash; should be identical // Derive twice for same geohash; should be identical
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
let gh = "u4pruy" let gh = "u4pruy"
let id1 = try NostrIdentityBridge.deriveIdentity(forGeohash: gh) let id1 = try idBridge.deriveIdentity(forGeohash: gh)
let id2 = try NostrIdentityBridge.deriveIdentity(forGeohash: gh) let id2 = try idBridge.deriveIdentity(forGeohash: gh)
XCTAssertEqual(id1.publicKeyHex, id2.publicKeyHex) #expect(id1.publicKeyHex == id2.publicKeyHex)
} }
} }
+11 -68
View File
@@ -1,8 +1,9 @@
import XCTest import Testing
import Foundation
@testable import bitchat @testable import bitchat
@MainActor @MainActor
final class LocationNotesManagerTests: XCTestCase { struct LocationNotesManagerTests {
// func testSubscribeWithoutRelaysSetsNoRelaysState() { // func testSubscribeWithoutRelaysSetsNoRelaysState() {
// var subscribeCalled = false // var subscribeCalled = false
// let deps = LocationNotesDependencies( // let deps = LocationNotesDependencies(
@@ -47,15 +48,15 @@ final class LocationNotesManagerTests: XCTestCase {
// XCTAssertNotEqual(manager.errorMessage, "location_notes.error.no_relays") // XCTAssertNotEqual(manager.errorMessage, "location_notes.error.no_relays")
// } // }
func testSubscribeUsesGeoRelaysAndAppendsNotes() { @Test func subscribeUsesGeoRelaysAndAppendsNotes() {
var relaysCaptured: [String] = [] var relaysCaptured: [String] = []
var storedHandler: ((NostrEvent) -> Void)? var storedHandler: ((NostrEvent) -> Void)?
var storedEOSE: (() -> Void)? var storedEOSE: (() -> Void)?
let deps = LocationNotesDependencies( let deps = LocationNotesDependencies(
relayLookup: { _, _ in ["wss://relay.one"] }, relayLookup: { _, _ in ["wss://relay.one"] },
subscribe: { filter, id, relays, handler, eose in subscribe: { filter, id, relays, handler, eose in
XCTAssertEqual(filter.kinds, [1]) #expect(filter.kinds == [1])
XCTAssertFalse(id.isEmpty) #expect(!id.isEmpty)
relaysCaptured = relays relaysCaptured = relays
storedHandler = handler storedHandler = handler
storedEOSE = eose storedEOSE = eose
@@ -67,8 +68,8 @@ final class LocationNotesManagerTests: XCTestCase {
) )
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps) let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
XCTAssertEqual(relaysCaptured, ["wss://relay.one"]) #expect(relaysCaptured == ["wss://relay.one"])
XCTAssertEqual(manager.state, .loading) #expect(manager.state == .loading)
var event = NostrEvent( var event = NostrEvent(
pubkey: "pub", pubkey: "pub",
@@ -81,70 +82,12 @@ final class LocationNotesManagerTests: XCTestCase {
storedHandler?(event) storedHandler?(event)
storedEOSE?() storedEOSE?()
XCTAssertEqual(manager.state, .ready) #expect(manager.state == .ready)
XCTAssertEqual(manager.notes.count, 1) #expect(manager.notes.count == 1)
XCTAssertEqual(manager.notes.first?.content, "hi") #expect(manager.notes.first?.content == "hi")
} }
private enum TestError: Error { private enum TestError: Error {
case shouldNotDerive case shouldNotDerive
} }
} }
@MainActor
final class LocationNotesCounterTests: XCTestCase {
func testSubscribeWithoutRelaysMarksUnavailable() {
var subscribeCalled = false
let deps = LocationNotesCounterDependencies(
relayLookup: { _, _ in [] },
subscribe: { _, _, _, _, _ in subscribeCalled = true },
unsubscribe: { _ in }
)
let counter = LocationNotesCounter(testDependencies: deps)
counter.subscribe(geohash: "u4pruydq")
XCTAssertFalse(subscribeCalled)
XCTAssertFalse(counter.relayAvailable)
XCTAssertTrue(counter.initialLoadComplete)
XCTAssertEqual(counter.count, 0)
}
func testSubscribeCountsUniqueNotes() {
var storedHandler: ((NostrEvent) -> Void)?
var storedEOSE: (() -> Void)?
let deps = LocationNotesCounterDependencies(
relayLookup: { _, _ in ["wss://relay.geo"] },
subscribe: { filter, id, relays, handler, eose in
XCTAssertEqual(relays, ["wss://relay.geo"])
XCTAssertEqual(filter.kinds, [1])
XCTAssertFalse(id.isEmpty)
storedHandler = handler
storedEOSE = eose
},
unsubscribe: { _ in }
)
let counter = LocationNotesCounter(testDependencies: deps)
counter.subscribe(geohash: "u4pruydq")
var first = NostrEvent(
pubkey: "pub",
createdAt: Date(),
kind: .textNote,
tags: [["g", "u4pruydq"]],
content: "a"
)
first.id = "eventA"
storedHandler?(first)
let duplicate = first
storedHandler?(duplicate)
storedEOSE?()
XCTAssertTrue(counter.relayAvailable)
XCTAssertEqual(counter.count, 1)
XCTAssertTrue(counter.initialLoadComplete)
}
}
+57
View File
@@ -0,0 +1,57 @@
//
// MockBLEBus.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
@testable import bitchat
final class MockBLEBus {
private var registry: [PeerID: MockBLEService] = [:]
private var adjacency: [PeerID: Set<PeerID>] = [:]
// Enable automatic flooding for public messages in integration tests only
let autoFloodEnabled: Bool
init(autoFloodEnabled: Bool = false) {
self.autoFloodEnabled = autoFloodEnabled
}
func register(_ service: MockBLEService, for peerID: PeerID) {
registry[peerID] = service
if adjacency[peerID] == nil { adjacency[peerID] = [] }
}
func connect(_ a: PeerID, _ b: PeerID) {
var setA = adjacency[a] ?? []
setA.insert(b)
adjacency[a] = setA
var setB = adjacency[b] ?? []
setB.insert(a)
adjacency[b] = setB
}
func disconnect(_ a: PeerID, _ b: PeerID) {
if var setA = adjacency[a] { setA.remove(b); adjacency[a] = setA }
if var setB = adjacency[b] { setB.remove(a); adjacency[b] = setB }
}
func neighbors(of peerID: PeerID) -> [MockBLEService] {
let ids = adjacency[peerID] ?? []
let result = ids.compactMap { registry[$0] }
return result
}
func isDirectNeighbor(_ a: PeerID, _ b: PeerID) -> Bool {
let res = adjacency[a]?.contains(b) ?? false
return res
}
func service(for peerID: PeerID) -> MockBLEService? {
let svc = registry[peerID]
return svc
}
}
+16 -48
View File
@@ -26,13 +26,12 @@ import CoreBluetooth
/// simulate broadcast propagation across the mesh. E2E tests keep it off and perform explicit /// simulate broadcast propagation across the mesh. E2E tests keep it off and perform explicit
/// relays when needed. /// relays when needed.
final class MockBLEService: NSObject { final class MockBLEService: NSObject {
// Enable automatic flooding for public messages in integration tests only private let bus: MockBLEBus
static var autoFloodEnabled: Bool = false
// MARK: - Properties matching BLEService // MARK: - Properties matching BLEService
weak var delegate: BitchatDelegate? weak var delegate: BitchatDelegate?
var myPeerID: PeerID = "MOCK1234" var myPeerID = PeerID(str: "MOCK1234")
var myNickname: String = "MockUser" var myNickname: String = "MockUser"
private let mockKeychain = MockKeychain() private let mockKeychain = MockKeychain()
@@ -60,8 +59,8 @@ final class MockBLEService: NSObject {
// MARK: - Initialization // MARK: - Initialization
override init() { init(bus: MockBLEBus) {
super.init() self.bus = bus
} }
// MARK: - Methods matching BLEService // MARK: - Methods matching BLEService
@@ -71,42 +70,15 @@ final class MockBLEService: NSObject {
} }
// MARK: - In-memory test bus (for E2E/Integration) // MARK: - In-memory test bus (for E2E/Integration)
/// Global per-process bus for deterministic routing in tests.
private static var registry: [PeerID: MockBLEService] = [:]
private static var adjacency: [PeerID: Set<PeerID>] = [:]
/// Clears global bus state. Call from test `setUp()`.
static func resetTestBus() {
registry.removeAll()
adjacency.removeAll()
}
/// Registers this instance on first use. /// Registers this instance on first use.
private func registerIfNeeded() { private func registerIfNeeded() {
MockBLEService.registry[myPeerID] = self bus.register(self, for: myPeerID)
if MockBLEService.adjacency[myPeerID] == nil { MockBLEService.adjacency[myPeerID] = [] }
} }
/// Returns adjacent neighbors based on the current simulated topology. /// Returns adjacent neighbors based on the current simulated topology.
private func neighbors() -> [MockBLEService] { private func neighbors() -> [MockBLEService] {
guard let ids = MockBLEService.adjacency[myPeerID] else { return [] } bus.neighbors(of: myPeerID)
return ids.compactMap { MockBLEService.registry[$0] }
}
/// Adds an undirected edge between two peerIDs.
private static func connectPeers(_ a: PeerID, _ b: PeerID) {
var setA = adjacency[a] ?? []
setA.insert(b)
adjacency[a] = setA
var setB = adjacency[b] ?? []
setB.insert(a)
adjacency[b] = setB
}
/// Removes an undirected edge between two peerIDs.
private static func disconnectPeers(_ a: PeerID, _ b: PeerID) {
if var setA = adjacency[a] { setA.remove(b); adjacency[a] = setA }
if var setB = adjacency[b] { setB.remove(a); adjacency[b] = setB }
} }
func startServices() { func startServices() {
@@ -173,7 +145,7 @@ final class MockBLEService: NSObject {
// Surface raw packet to tests that intercept/relay/encrypt // Surface raw packet to tests that intercept/relay/encrypt
packetDeliveryHandler?(packet) packetDeliveryHandler?(packet)
// Deliver public messages to adjacent peers via test bus // Deliver public messages to adjacent peers via bus
if recipientID == nil { if recipientID == nil {
for neighbor in neighbors() { for neighbor in neighbors() {
neighbor.simulateIncomingPacket(packet) neighbor.simulateIncomingPacket(packet)
@@ -219,24 +191,20 @@ final class MockBLEService: NSObject {
packetDeliveryHandler?(packet) packetDeliveryHandler?(packet)
// If directly connected to recipient, deliver only to them. // If directly connected to recipient, deliver only to them.
if let neighbors = MockBLEService.adjacency[myPeerID], neighbors.contains(recipientPeerID), if bus.isDirectNeighbor(myPeerID, recipientPeerID),
let target = MockBLEService.registry[recipientPeerID] { let target = bus.service(for: recipientPeerID) {
target.simulateIncomingPacket(packet) target.simulateIncomingPacket(packet)
} else { } else {
// Not directly connected: deliver to neighbors for relay; also deliver directly if target is known // Not directly connected: deliver to neighbors for relay; also deliver directly if target is known
if let target = MockBLEService.registry[recipientPeerID] { if let target = bus.service(for: recipientPeerID) {
target.simulateIncomingPacket(packet) target.simulateIncomingPacket(packet)
} }
if let neighbors = MockBLEService.adjacency[myPeerID] { for neighbor in neighbors() where neighbor.peerID != recipientPeerID {
for peer in neighbors where peer != recipientPeerID {
if let neighbor = MockBLEService.registry[peer] {
neighbor.simulateIncomingPacket(packet) neighbor.simulateIncomingPacket(packet)
} }
} }
} }
} }
}
}
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) { func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
// Mock implementation // Mock implementation
@@ -279,14 +247,14 @@ final class MockBLEService: NSObject {
func simulateConnectedPeer(_ peerID: PeerID) { func simulateConnectedPeer(_ peerID: PeerID) {
registerIfNeeded() registerIfNeeded()
MockBLEService.connectPeers(myPeerID, peerID) bus.connect(myPeerID, peerID)
connectedPeers.insert(peerID) connectedPeers.insert(peerID)
delegate?.didConnectToPeer(peerID) delegate?.didConnectToPeer(peerID)
delegate?.didUpdatePeerList(Array(connectedPeers)) delegate?.didUpdatePeerList(Array(connectedPeers))
} }
func simulateDisconnectedPeer(_ peerID: PeerID) { func simulateDisconnectedPeer(_ peerID: PeerID) {
MockBLEService.disconnectPeers(myPeerID, peerID) bus.disconnect(myPeerID, peerID)
connectedPeers.remove(peerID) connectedPeers.remove(peerID)
delegate?.didDisconnectFromPeer(peerID) delegate?.didDisconnectFromPeer(peerID)
delegate?.didUpdatePeerList(Array(connectedPeers)) delegate?.didUpdatePeerList(Array(connectedPeers))
@@ -319,7 +287,7 @@ final class MockBLEService: NSObject {
// When enabled, propagate a public broadcast across the entire connected // When enabled, propagate a public broadcast across the entire connected
// component regardless of the original TTL to better emulate large-network // component regardless of the original TTL to better emulate large-network
// broadcast expectations. De-duplication via seenMessageIDs prevents loops. // broadcast expectations. De-duplication via seenMessageIDs prevents loops.
if MockBLEService.autoFloodEnabled, if bus.autoFloodEnabled,
packet.recipientID == nil, packet.recipientID == nil,
!message.isPrivate { !message.isPrivate {
let nextTTL = packet.ttl > 0 ? packet.ttl - 1 : 0 let nextTTL = packet.ttl > 0 ? packet.ttl - 1 : 0
@@ -353,8 +321,8 @@ typealias MockSimplifiedBluetoothService = MockBLEService
// MARK: - Helpers // MARK: - Helpers
extension MockBLEService { extension MockBLEService {
convenience init(peerID: PeerID, nickname: String) { convenience init(peerID: PeerID, nickname: String, bus: MockBLEBus) {
self.init() self.init(bus: bus)
myPeerID = peerID myPeerID = peerID
mockNickname = nickname mockNickname = nickname
} }
@@ -1,14 +0,0 @@
//
// MockBluetoothMeshService.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import CoreBluetooth
@testable import bitchat
// Compatibility wrapper for old tests - please use MockBLEService directly
typealias MockBluetoothMeshService = MockBLEService
+18
View File
@@ -44,3 +44,21 @@ final class MockKeychain: KeychainManagerProtocol {
storage["identity_noiseStaticKey"] != nil storage["identity_noiseStaticKey"] != nil
} }
} }
final class MockKeychainHelper: KeychainHelperProtocol {
private typealias Service = String
private typealias Key = String
private var storage: [Service: [Key: Data]] = [:]
func save(key: String, data: Data, service: String, accessible: CFString?) {
storage[service]?[key] = data
}
func load(key: String, service: String) -> Data? {
storage[service]?[key]
}
func delete(key: String, service: String) {
storage[service]?.removeValue(forKey: key)
}
}
+202 -237
View File
@@ -6,135 +6,123 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import XCTest import Testing
import CryptoKit import CryptoKit
import Foundation
@testable import bitchat @testable import bitchat
final class NoiseProtocolTests: XCTestCase { struct NoiseProtocolTests {
var aliceKey: Curve25519.KeyAgreement.PrivateKey! private let aliceKey = Curve25519.KeyAgreement.PrivateKey()
var bobKey: Curve25519.KeyAgreement.PrivateKey! private let bobKey = Curve25519.KeyAgreement.PrivateKey()
var aliceSession: NoiseSession! private let mockKeychain = MockKeychain()
var bobSession: NoiseSession!
private var mockKeychain: MockKeychain!
override func setUp() { private let alicePeerID = PeerID(str: UUID().uuidString)
super.setUp() private let bobPeerID = PeerID(str: UUID().uuidString)
aliceKey = Curve25519.KeyAgreement.PrivateKey()
bobKey = Curve25519.KeyAgreement.PrivateKey()
mockKeychain = MockKeychain()
}
override func tearDown() { private let aliceSession: NoiseSession
aliceSession = nil private let bobSession: NoiseSession
bobSession = nil
mockKeychain = nil
super.tearDown()
}
// MARK: - Basic Handshake Tests init() {
func testXXPatternHandshake() throws {
// Create sessions
aliceSession = NoiseSession( aliceSession = NoiseSession(
peerID: TestConstants.testPeerID2, peerID: alicePeerID,
role: .initiator, role: .initiator,
keychain: mockKeychain, keychain: mockKeychain,
localStaticKey: aliceKey localStaticKey: aliceKey
) )
bobSession = NoiseSession( bobSession = NoiseSession(
peerID: TestConstants.testPeerID1, peerID: bobPeerID,
role: .responder, role: .responder,
keychain: mockKeychain, keychain: mockKeychain,
localStaticKey: bobKey localStaticKey: bobKey
) )
}
// MARK: - Basic Handshake Tests
@Test func xxPatternHandshake() throws {
// Alice starts handshake (message 1) // Alice starts handshake (message 1)
let message1 = try aliceSession.startHandshake() let message1 = try aliceSession.startHandshake()
XCTAssertFalse(message1.isEmpty) #expect(!message1.isEmpty)
XCTAssertEqual(aliceSession.getState(), .handshaking) #expect(aliceSession.getState() == .handshaking)
// Bob processes message 1 and creates message 2 // Bob processes message 1 and creates message 2
let message2 = try bobSession.processHandshakeMessage(message1) let message2 = try bobSession.processHandshakeMessage(message1)
XCTAssertNotNil(message2) #expect(message2 != nil)
XCTAssertFalse(message2!.isEmpty) #expect(!message2!.isEmpty)
XCTAssertEqual(bobSession.getState(), .handshaking) #expect(bobSession.getState() == .handshaking)
// Alice processes message 2 and creates message 3 // Alice processes message 2 and creates message 3
let message3 = try aliceSession.processHandshakeMessage(message2!) let message3 = try aliceSession.processHandshakeMessage(message2!)
XCTAssertNotNil(message3) #expect(message3 != nil)
XCTAssertFalse(message3!.isEmpty) #expect(!message3!.isEmpty)
XCTAssertEqual(aliceSession.getState(), .established) #expect(aliceSession.getState() == .established)
// Bob processes message 3 and completes handshake // Bob processes message 3 and completes handshake
let finalMessage = try bobSession.processHandshakeMessage(message3!) let finalMessage = try bobSession.processHandshakeMessage(message3!)
XCTAssertNil(finalMessage) // No more messages needed #expect(finalMessage == nil) // No more messages needed
XCTAssertEqual(bobSession.getState(), .established) #expect(bobSession.getState() == .established)
// Verify both sessions are established // Verify both sessions are established
XCTAssertTrue(aliceSession.isEstablished()) #expect(aliceSession.isEstablished())
XCTAssertTrue(bobSession.isEstablished()) #expect(bobSession.isEstablished())
// Verify they have each other's static keys // Verify they have each other's static keys
XCTAssertEqual(aliceSession.getRemoteStaticPublicKey()?.rawRepresentation, bobKey.publicKey.rawRepresentation) #expect(aliceSession.getRemoteStaticPublicKey()?.rawRepresentation == bobKey.publicKey.rawRepresentation)
XCTAssertEqual(bobSession.getRemoteStaticPublicKey()?.rawRepresentation, aliceKey.publicKey.rawRepresentation) #expect(bobSession.getRemoteStaticPublicKey()?.rawRepresentation == aliceKey.publicKey.rawRepresentation)
} }
func testHandshakeStateValidation() throws { @Test func handshakeStateValidation() throws {
aliceSession = NoiseSession(
peerID: TestConstants.testPeerID2,
role: .initiator,
keychain: mockKeychain,
localStaticKey: aliceKey
)
// Cannot process message before starting handshake // Cannot process message before starting handshake
XCTAssertThrowsError(try aliceSession.processHandshakeMessage(Data())) #expect(throws: NoiseSessionError.invalidState) {
try aliceSession.processHandshakeMessage(Data())
}
// Start handshake // Start handshake
_ = try aliceSession.startHandshake() _ = try aliceSession.startHandshake()
// Cannot start handshake twice // Cannot start handshake twice
XCTAssertThrowsError(try aliceSession.startHandshake()) #expect(throws: NoiseSessionError.invalidState) {
try aliceSession.startHandshake()
}
} }
// MARK: - Encryption/Decryption Tests // MARK: - Encryption/Decryption Tests
func testBasicEncryptionDecryption() throws { @Test func basicEncryptionDecryption() throws {
// Establish sessions try performHandshake(initiator: aliceSession, responder: bobSession)
try establishSessions()
let plaintext = "Hello, Bob!".data(using: .utf8)! let plaintext = "Hello, Bob!".data(using: .utf8)!
// Alice encrypts // Alice encrypts
let ciphertext = try aliceSession.encrypt(plaintext) let ciphertext = try aliceSession.encrypt(plaintext)
XCTAssertNotEqual(ciphertext, plaintext) #expect(ciphertext != plaintext)
XCTAssertGreaterThan(ciphertext.count, plaintext.count) // Should have overhead #expect(ciphertext.count > plaintext.count) // Should have overhead
// Bob decrypts // Bob decrypts
let decrypted = try bobSession.decrypt(ciphertext) let decrypted = try bobSession.decrypt(ciphertext)
XCTAssertEqual(decrypted, plaintext) #expect(decrypted == plaintext)
} }
func testBidirectionalEncryption() throws { @Test func bidirectionalEncryption() throws {
try establishSessions() try performHandshake(initiator: aliceSession, responder: bobSession)
// Alice -> Bob // Alice -> Bob
let aliceMessage = "Hello from Alice".data(using: .utf8)! let aliceMessage = "Hello from Alice".data(using: .utf8)!
let aliceCiphertext = try aliceSession.encrypt(aliceMessage) let aliceCiphertext = try aliceSession.encrypt(aliceMessage)
let bobReceived = try bobSession.decrypt(aliceCiphertext) let bobReceived = try bobSession.decrypt(aliceCiphertext)
XCTAssertEqual(bobReceived, aliceMessage) #expect(bobReceived == aliceMessage)
// Bob -> Alice // Bob -> Alice
let bobMessage = "Hello from Bob".data(using: .utf8)! let bobMessage = "Hello from Bob".data(using: .utf8)!
let bobCiphertext = try bobSession.encrypt(bobMessage) let bobCiphertext = try bobSession.encrypt(bobMessage)
let aliceReceived = try aliceSession.decrypt(bobCiphertext) let aliceReceived = try aliceSession.decrypt(bobCiphertext)
XCTAssertEqual(aliceReceived, bobMessage) #expect(aliceReceived == bobMessage)
} }
func testLargeMessageEncryption() throws { @Test func largeMessageEncryption() throws {
try establishSessions() try performHandshake(initiator: aliceSession, responder: bobSession)
// Create a large message // Create a large message
let largeMessage = TestHelpers.generateRandomData(length: 100_000) let largeMessage = TestHelpers.generateRandomData(length: 100_000)
@@ -143,81 +131,78 @@ final class NoiseProtocolTests: XCTestCase {
let ciphertext = try aliceSession.encrypt(largeMessage) let ciphertext = try aliceSession.encrypt(largeMessage)
let decrypted = try bobSession.decrypt(ciphertext) let decrypted = try bobSession.decrypt(ciphertext)
XCTAssertEqual(decrypted, largeMessage) #expect(decrypted == largeMessage)
} }
func testEncryptionBeforeHandshake() { @Test func encryptionBeforeHandshake() {
aliceSession = NoiseSession(
peerID: TestConstants.testPeerID2,
role: .initiator,
keychain: mockKeychain,
localStaticKey: aliceKey
)
let plaintext = "test".data(using: .utf8)! let plaintext = "test".data(using: .utf8)!
// Should throw when not established #expect(throws: NoiseSessionError.notEstablished) {
XCTAssertThrowsError(try aliceSession.encrypt(plaintext)) try aliceSession.encrypt(plaintext)
XCTAssertThrowsError(try aliceSession.decrypt(plaintext)) }
#expect(throws: NoiseSessionError.notEstablished) {
try aliceSession.decrypt(plaintext)
}
} }
// MARK: - Session Manager Tests // MARK: - Session Manager Tests
func testSessionManagerBasicOperations() throws { @Test func sessionManagerBasicOperations() throws {
let manager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) let manager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
// Create session #expect(manager.getSession(for: alicePeerID) == nil)
let session = manager.createSession(for: TestConstants.testPeerID2, role: .initiator)
XCTAssertNotNil(session) _ = try manager.initiateHandshake(with: alicePeerID)
#expect(manager.getSession(for: alicePeerID) != nil)
// Get session // Get session
let retrieved = manager.getSession(for: TestConstants.testPeerID2) let retrieved = manager.getSession(for: alicePeerID)
XCTAssertNotNil(retrieved) #expect(retrieved != nil)
XCTAssertTrue(session === retrieved)
// Remove session // Remove session
manager.removeSession(for: TestConstants.testPeerID2) manager.removeSession(for: alicePeerID)
XCTAssertNil(manager.getSession(for: TestConstants.testPeerID2)) #expect(manager.getSession(for: alicePeerID) == nil)
} }
func testSessionManagerHandshakeInitiation() throws { @Test func sessionManagerHandshakeInitiation() throws {
let manager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) let manager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
// Initiate handshake // Initiate handshake
let handshakeData = try manager.initiateHandshake(with: TestConstants.testPeerID2) let handshakeData = try manager.initiateHandshake(with: alicePeerID)
XCTAssertFalse(handshakeData.isEmpty) #expect(!handshakeData.isEmpty)
// Session should exist // Session should exist
let session = manager.getSession(for: TestConstants.testPeerID2) let session = manager.getSession(for: alicePeerID)
XCTAssertNotNil(session) #expect(session != nil)
XCTAssertEqual(session?.getState(), .handshaking) #expect(session?.getState() == .handshaking)
} }
func testSessionManagerIncomingHandshake() throws { @Test func sessionManagerIncomingHandshake() throws {
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
// Alice initiates // Alice initiates
let message1 = try aliceManager.initiateHandshake(with: TestConstants.testPeerID2) let message1 = try aliceManager.initiateHandshake(with: alicePeerID)
// Bob responds // Bob responds
let message2 = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: message1) let message2 = try bobManager.handleIncomingHandshake(from: bobPeerID, message: message1)
XCTAssertNotNil(message2) #expect(message2 != nil)
// Continue handshake // Continue handshake
let message3 = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: message2!) let message3 = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: message2!)
XCTAssertNotNil(message3) #expect(message3 != nil)
// Complete handshake // Complete handshake
let finalMessage = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: message3!) let finalMessage = try bobManager.handleIncomingHandshake(from: bobPeerID, message: message3!)
XCTAssertNil(finalMessage) #expect(finalMessage == nil)
// Both should have established sessions // Both should have established sessions
XCTAssertTrue(aliceManager.getSession(for: TestConstants.testPeerID2)?.isEstablished() ?? false) #expect(aliceManager.getSession(for: alicePeerID)?.isEstablished() == true)
XCTAssertTrue(bobManager.getSession(for: TestConstants.testPeerID1)?.isEstablished() ?? false) #expect(bobManager.getSession(for: bobPeerID)?.isEstablished() == true)
} }
func testSessionManagerEncryptionDecryption() throws { @Test func sessionManagerEncryptionDecryption() throws {
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
@@ -226,17 +211,17 @@ final class NoiseProtocolTests: XCTestCase {
// Encrypt with manager // Encrypt with manager
let plaintext = "Test message".data(using: .utf8)! let plaintext = "Test message".data(using: .utf8)!
let ciphertext = try aliceManager.encrypt(plaintext, for: TestConstants.testPeerID2) let ciphertext = try aliceManager.encrypt(plaintext, for: alicePeerID)
// Decrypt with manager // Decrypt with manager
let decrypted = try bobManager.decrypt(ciphertext, from: TestConstants.testPeerID1) let decrypted = try bobManager.decrypt(ciphertext, from: bobPeerID)
XCTAssertEqual(decrypted, plaintext) #expect(decrypted == plaintext)
} }
// MARK: - Security Tests // MARK: - Security Tests
func testTamperedCiphertextDetection() throws { @Test func tamperedCiphertextDetection() throws {
try establishSessions() try performHandshake(initiator: aliceSession, responder: bobSession)
let plaintext = "Secret message".data(using: .utf8)! let plaintext = "Secret message".data(using: .utf8)!
var ciphertext = try aliceSession.encrypt(plaintext) var ciphertext = try aliceSession.encrypt(plaintext)
@@ -245,11 +230,19 @@ final class NoiseProtocolTests: XCTestCase {
ciphertext[ciphertext.count / 2] ^= 0xFF ciphertext[ciphertext.count / 2] ^= 0xFF
// Decryption should fail // Decryption should fail
XCTAssertThrowsError(try bobSession.decrypt(ciphertext)) if #available(macOS 14.4, iOS 17.4, *) {
#expect(throws: CryptoKitError.authenticationFailure) {
try bobSession.decrypt(ciphertext)
}
} else {
#expect(throws: (any Error).self) {
try bobSession.decrypt(ciphertext)
}
}
} }
func testReplayPrevention() throws { @Test func replayPrevention() throws {
try establishSessions() try performHandshake(initiator: aliceSession, responder: bobSession)
let plaintext = "Test message".data(using: .utf8)! let plaintext = "Test message".data(using: .utf8)!
let ciphertext = try aliceSession.encrypt(plaintext) let ciphertext = try aliceSession.encrypt(plaintext)
@@ -258,16 +251,18 @@ final class NoiseProtocolTests: XCTestCase {
_ = try bobSession.decrypt(ciphertext) _ = try bobSession.decrypt(ciphertext)
// Replaying the same ciphertext should fail // Replaying the same ciphertext should fail
XCTAssertThrowsError(try bobSession.decrypt(ciphertext)) #expect(throws: NoiseError.replayDetected) {
try bobSession.decrypt(ciphertext)
}
} }
func testSessionIsolation() throws { @Test func sessionIsolation() throws {
// Create two separate session pairs // Create two separate session pairs
let aliceSession1 = NoiseSession(peerID: "peer1", role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey) let aliceSession1 = NoiseSession(peerID: PeerID(str: "peer1"), role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
let bobSession1 = NoiseSession(peerID: "alice1", role: .responder, keychain: mockKeychain, localStaticKey: bobKey) let bobSession1 = NoiseSession(peerID: PeerID(str: "alice1"), role: .responder, keychain: mockKeychain, localStaticKey: bobKey)
let aliceSession2 = NoiseSession(peerID: "peer2", role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey) let aliceSession2 = NoiseSession(peerID: PeerID(str: "peer2"), role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
let bobSession2 = NoiseSession(peerID: "alice2", role: .responder, keychain: mockKeychain, localStaticKey: bobKey) let bobSession2 = NoiseSession(peerID: PeerID(str: "alice2"), role: .responder, keychain: mockKeychain, localStaticKey: bobKey)
// Establish both pairs // Establish both pairs
try performHandshake(initiator: aliceSession1, responder: bobSession1) try performHandshake(initiator: aliceSession1, responder: bobSession1)
@@ -278,16 +273,24 @@ final class NoiseProtocolTests: XCTestCase {
let ciphertext1 = try aliceSession1.encrypt(plaintext) let ciphertext1 = try aliceSession1.encrypt(plaintext)
// Should not be able to decrypt with session 2 // Should not be able to decrypt with session 2
XCTAssertThrowsError(try bobSession2.decrypt(ciphertext1)) if #available(macOS 14.4, iOS 17.4, *) {
#expect(throws: CryptoKitError.authenticationFailure) {
try bobSession2.decrypt(ciphertext1)
}
} else {
#expect(throws: (any Error).self) {
try bobSession2.decrypt(ciphertext1)
}
}
// But should work with correct session // But should work with correct session
let decrypted = try bobSession1.decrypt(ciphertext1) let decrypted = try bobSession1.decrypt(ciphertext1)
XCTAssertEqual(decrypted, plaintext) #expect(decrypted == plaintext)
} }
// MARK: - Session Recovery Tests // MARK: - Session Recovery Tests
func testPeerRestartDetection() throws { @Test func peerRestartDetection() throws {
// Establish initial sessions // Establish initial sessions
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
@@ -295,38 +298,38 @@ final class NoiseProtocolTests: XCTestCase {
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
// Exchange some messages to establish nonce state // Exchange some messages to establish nonce state
let message1 = try aliceManager.encrypt("Hello".data(using: .utf8)!, for: TestConstants.testPeerID2) let message1 = try aliceManager.encrypt("Hello".data(using: .utf8)!, for: alicePeerID)
_ = try bobManager.decrypt(message1, from: TestConstants.testPeerID1) _ = try bobManager.decrypt(message1, from: bobPeerID)
let message2 = try bobManager.encrypt("World".data(using: .utf8)!, for: TestConstants.testPeerID1) let message2 = try bobManager.encrypt("World".data(using: .utf8)!, for: bobPeerID)
_ = try aliceManager.decrypt(message2, from: TestConstants.testPeerID2) _ = try aliceManager.decrypt(message2, from: alicePeerID)
// Simulate Bob restart by creating new manager with same key // Simulate Bob restart by creating new manager with same key
let bobManagerRestarted = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) let bobManagerRestarted = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
// Bob initiates new handshake after restart // Bob initiates new handshake after restart
let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: TestConstants.testPeerID1) let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: bobPeerID)
// Alice should accept the new handshake (clearing old session) // Alice should accept the new handshake (clearing old session)
let newHandshake2 = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: newHandshake1) let newHandshake2 = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake1)
XCTAssertNotNil(newHandshake2) #expect(newHandshake2 != nil)
// Complete the new handshake // Complete the new handshake
let newHandshake3 = try bobManagerRestarted.handleIncomingHandshake(from: TestConstants.testPeerID1, message: newHandshake2!) let newHandshake3 = try bobManagerRestarted.handleIncomingHandshake(from: bobPeerID, message: newHandshake2!)
XCTAssertNotNil(newHandshake3) #expect(newHandshake3 != nil)
_ = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: newHandshake3!) _ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake3!)
// Should be able to exchange messages with new sessions // Should be able to exchange messages with new sessions
let testMessage = "After restart".data(using: .utf8)! let testMessage = "After restart".data(using: .utf8)!
let encrypted = try bobManagerRestarted.encrypt(testMessage, for: TestConstants.testPeerID1) let encrypted = try bobManagerRestarted.encrypt(testMessage, for: bobPeerID)
let decrypted = try aliceManager.decrypt(encrypted, from: TestConstants.testPeerID2) let decrypted = try aliceManager.decrypt(encrypted, from: alicePeerID)
XCTAssertEqual(decrypted, testMessage) #expect(decrypted == testMessage)
} }
func testNonceDesynchronizationRecovery() throws { @Test func nonceDesynchronizationRecovery() throws {
// Create two sessions // Create two sessions
aliceSession = NoiseSession(peerID: TestConstants.testPeerID2, role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey) let aliceSession = NoiseSession(peerID: alicePeerID, role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
bobSession = NoiseSession(peerID: TestConstants.testPeerID1, role: .responder, keychain: mockKeychain, localStaticKey: bobKey) let bobSession = NoiseSession(peerID: bobPeerID, role: .responder, keychain: mockKeychain, localStaticKey: bobKey)
// Establish sessions // Establish sessions
try performHandshake(initiator: aliceSession, responder: bobSession) try performHandshake(initiator: aliceSession, responder: bobSession)
@@ -344,10 +347,12 @@ final class NoiseProtocolTests: XCTestCase {
// With per-packet nonce carried, decryption should not throw here // With per-packet nonce carried, decryption should not throw here
let desyncMessage = try aliceSession.encrypt("This now succeeds".data(using: .utf8)!) let desyncMessage = try aliceSession.encrypt("This now succeeds".data(using: .utf8)!)
XCTAssertNoThrow(try bobSession.decrypt(desyncMessage)) #expect(throws: Never.self) {
try bobSession.decrypt(desyncMessage)
}
} }
func testConcurrentEncryption() throws { @Test func concurrentEncryption() async throws {
// Test thread safety of encryption operations // Test thread safety of encryption operations
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
@@ -355,14 +360,13 @@ final class NoiseProtocolTests: XCTestCase {
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
let messageCount = 100 let messageCount = 100
let expectation = XCTestExpectation(description: "All messages encrypted and decrypted")
expectation.expectedFulfillmentCount = messageCount
try await confirmation("All messages encrypted and decrypted", expectedCount: messageCount) { completion in
var encryptedMessages: [Int: Data] = [:] var encryptedMessages: [Int: Data] = [:]
// Encrypt messages sequentially to avoid nonce races in manager // Encrypt messages sequentially to avoid nonce races in manager
for i in 0..<messageCount { for i in 0..<messageCount {
let plaintext = "Concurrent message \(i)".data(using: .utf8)! let plaintext = "Concurrent message \(i)".data(using: .utf8)!
let encrypted = try aliceManager.encrypt(plaintext, for: TestConstants.testPeerID2) let encrypted = try aliceManager.encrypt(plaintext, for: alicePeerID)
encryptedMessages[i] = encrypted encryptedMessages[i] = encrypted
} }
@@ -370,22 +374,21 @@ final class NoiseProtocolTests: XCTestCase {
for i in 0..<messageCount { for i in 0..<messageCount {
do { do {
guard let encrypted = encryptedMessages[i] else { guard let encrypted = encryptedMessages[i] else {
XCTFail("Missing encrypted message \(i)") Issue.record("Missing encrypted message \(i)")
return return
} }
let decrypted = try bobManager.decrypt(encrypted, from: TestConstants.testPeerID1) let decrypted = try bobManager.decrypt(encrypted, from: bobPeerID)
let expected = "Concurrent message \(i)".data(using: .utf8)! let expected = "Concurrent message \(i)".data(using: .utf8)!
XCTAssertEqual(decrypted, expected) #expect(decrypted == expected)
expectation.fulfill() completion()
} catch { } catch {
XCTFail("Decryption failed for message \(i): \(error)") Issue.record("Decryption failed for message \(i): \(error)")
}
}
} }
} }
wait(for: [expectation], timeout: 10.0) @Test func sessionStaleDetection() throws {
}
func testSessionStaleDetection() throws {
// Test that sessions are properly marked as stale // Test that sessions are properly marked as stale
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
@@ -396,10 +399,10 @@ final class NoiseProtocolTests: XCTestCase {
let sessions = aliceManager.getSessionsNeedingRekey() let sessions = aliceManager.getSessionsNeedingRekey()
// New session should not need rekey // New session should not need rekey
XCTAssertTrue(sessions.isEmpty || sessions.allSatisfy { !$0.needsRekey }) #expect(sessions.isEmpty || sessions.allSatisfy { !$0.needsRekey })
} }
func testHandshakeAfterDecryptionFailure() throws { @Test func handshakeAfterDecryptionFailure() throws {
// Test that handshake is properly initiated after decryption failure // Test that handshake is properly initiated after decryption failure
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
@@ -408,17 +411,25 @@ final class NoiseProtocolTests: XCTestCase {
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
// Create a corrupted message // Create a corrupted message
var encrypted = try aliceManager.encrypt("Test".data(using: .utf8)!, for: TestConstants.testPeerID2) var encrypted = try aliceManager.encrypt("Test".data(using: .utf8)!, for: alicePeerID)
encrypted[10] ^= 0xFF // Corrupt the data encrypted[10] ^= 0xFF // Corrupt the data
// Decryption should fail // Decryption should fail
XCTAssertThrowsError(try bobManager.decrypt(encrypted, from: TestConstants.testPeerID1)) if #available(macOS 14.4, iOS 17.4, *) {
#expect(throws: CryptoKitError.authenticationFailure) {
// Bob should still have the session (it's not removed on single failure) try bobManager.decrypt(encrypted, from: bobPeerID)
XCTAssertNotNil(bobManager.getSession(for: TestConstants.testPeerID1)) }
} else {
#expect(throws: (any Error).self) {
try bobManager.decrypt(encrypted, from: bobPeerID)
}
} }
func testHandshakeAlwaysAcceptedWithExistingSession() throws { // Bob should still have the session (it's not removed on single failure)
#expect(bobManager.getSession(for: bobPeerID) != nil)
}
@Test func handshakeAlwaysAcceptedWithExistingSession() throws {
// Test that handshake is always accepted even with existing valid session // Test that handshake is always accepted even with existing valid session
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
@@ -427,38 +438,38 @@ final class NoiseProtocolTests: XCTestCase {
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
// Verify sessions are established // Verify sessions are established
XCTAssertTrue(aliceManager.getSession(for: TestConstants.testPeerID2)?.isEstablished() ?? false) #expect(aliceManager.getSession(for: alicePeerID)?.isEstablished() == true)
XCTAssertTrue(bobManager.getSession(for: TestConstants.testPeerID1)?.isEstablished() ?? false) #expect(bobManager.getSession(for: bobPeerID)?.isEstablished() == true)
// Exchange messages to verify sessions work // Exchange messages to verify sessions work
let testMessage = "Session works".data(using: .utf8)! let testMessage = "Session works".data(using: .utf8)!
let encrypted = try aliceManager.encrypt(testMessage, for: TestConstants.testPeerID2) let encrypted = try aliceManager.encrypt(testMessage, for: alicePeerID)
let decrypted = try bobManager.decrypt(encrypted, from: TestConstants.testPeerID1) let decrypted = try bobManager.decrypt(encrypted, from: bobPeerID)
XCTAssertEqual(decrypted, testMessage) #expect(decrypted == testMessage)
// Alice clears her session (simulating decryption failure) // Alice clears her session (simulating decryption failure)
aliceManager.removeSession(for: TestConstants.testPeerID2) aliceManager.removeSession(for: alicePeerID)
// Alice initiates new handshake despite Bob having valid session // Alice initiates new handshake despite Bob having valid session
let newHandshake1 = try aliceManager.initiateHandshake(with: TestConstants.testPeerID2) let newHandshake1 = try aliceManager.initiateHandshake(with: alicePeerID)
// Bob should accept the new handshake even though he has a valid session // Bob should accept the new handshake even though he has a valid session
let newHandshake2 = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: newHandshake1) let newHandshake2 = try bobManager.handleIncomingHandshake(from: bobPeerID, message: newHandshake1)
XCTAssertNotNil(newHandshake2, "Bob should accept handshake despite having valid session") #expect(newHandshake2 != nil, "Bob should accept handshake despite having valid session")
// Complete the handshake // Complete the handshake
let newHandshake3 = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: newHandshake2!) let newHandshake3 = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake2!)
XCTAssertNotNil(newHandshake3) #expect(newHandshake3 != nil)
_ = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: newHandshake3!) _ = try bobManager.handleIncomingHandshake(from: bobPeerID, message: newHandshake3!)
// Verify new sessions work // Verify new sessions work
let testMessage2 = "New session works".data(using: .utf8)! let testMessage2 = "New session works".data(using: .utf8)!
let encrypted2 = try aliceManager.encrypt(testMessage2, for: TestConstants.testPeerID2) let encrypted2 = try aliceManager.encrypt(testMessage2, for: alicePeerID)
let decrypted2 = try bobManager.decrypt(encrypted2, from: TestConstants.testPeerID1) let decrypted2 = try bobManager.decrypt(encrypted2, from: bobPeerID)
XCTAssertEqual(decrypted2, testMessage2) #expect(decrypted2 == testMessage2)
} }
func testNonceDesynchronizationCausesRehandshake() throws { @Test func nonceDesynchronizationCausesRehandshake() throws {
// Test that nonce desynchronization leads to proper re-handshake // Test that nonce desynchronization leads to proper re-handshake
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
@@ -468,89 +479,43 @@ final class NoiseProtocolTests: XCTestCase {
// Exchange messages normally // Exchange messages normally
for i in 0..<5 { for i in 0..<5 {
let msg = try aliceManager.encrypt("Message \(i)".data(using: .utf8)!, for: TestConstants.testPeerID2) let msg = try aliceManager.encrypt("Message \(i)".data(using: .utf8)!, for: alicePeerID)
_ = try bobManager.decrypt(msg, from: TestConstants.testPeerID1) _ = try bobManager.decrypt(msg, from: bobPeerID)
} }
// Simulate desynchronization - Alice sends messages that Bob doesn't receive // Simulate desynchronization - Alice sends messages that Bob doesn't receive
for i in 0..<3 { for i in 0..<3 {
_ = try aliceManager.encrypt("Lost message \(i)".data(using: .utf8)!, for: TestConstants.testPeerID2) _ = try aliceManager.encrypt("Lost message \(i)".data(using: .utf8)!, for: alicePeerID)
} }
// With nonce carried in packet, decryption should not throw here // With nonce carried in packet, decryption should not throw here
let desyncMessage = try aliceManager.encrypt("This now succeeds".data(using: .utf8)!, for: TestConstants.testPeerID2) let desyncMessage = try aliceManager.encrypt("This now succeeds".data(using: .utf8)!, for: alicePeerID)
XCTAssertNoThrow(try bobManager.decrypt(desyncMessage, from: TestConstants.testPeerID1)) #expect(throws: Never.self) {
try bobManager.decrypt(desyncMessage, from: bobPeerID)
}
// Bob clears session and initiates new handshake // Bob clears session and initiates new handshake
bobManager.removeSession(for: TestConstants.testPeerID1) bobManager.removeSession(for: bobPeerID)
let rehandshake1 = try bobManager.initiateHandshake(with: TestConstants.testPeerID1) let rehandshake1 = try bobManager.initiateHandshake(with: bobPeerID)
// Alice should accept despite having a "valid" (but desynced) session // Alice should accept despite having a "valid" (but desynced) session
let rehandshake2 = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: rehandshake1) let rehandshake2 = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: rehandshake1)
XCTAssertNotNil(rehandshake2, "Alice should accept handshake to fix desync") #expect(rehandshake2 != nil, "Alice should accept handshake to fix desync")
// Complete handshake // Complete handshake
let rehandshake3 = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: rehandshake2!) let rehandshake3 = try bobManager.handleIncomingHandshake(from: bobPeerID, message: rehandshake2!)
XCTAssertNotNil(rehandshake3) #expect(rehandshake3 != nil)
_ = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: rehandshake3!) _ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: rehandshake3!)
// Verify communication works again // Verify communication works again
let testResynced = "Resynced".data(using: .utf8)! let testResynced = "Resynced".data(using: .utf8)!
let encryptedResync = try aliceManager.encrypt(testResynced, for: TestConstants.testPeerID2) let encryptedResync = try aliceManager.encrypt(testResynced, for: alicePeerID)
let decryptedResync = try bobManager.decrypt(encryptedResync, from: TestConstants.testPeerID1) let decryptedResync = try bobManager.decrypt(encryptedResync, from: bobPeerID)
XCTAssertEqual(decryptedResync, testResynced) #expect(decryptedResync == testResynced)
}
// MARK: - Performance Tests
func testHandshakePerformance() throws {
measure {
do {
let alice = NoiseSession(peerID: "bob", role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
let bob = NoiseSession(peerID: "alice", role: .responder, keychain: mockKeychain, localStaticKey: bobKey)
try performHandshake(initiator: alice, responder: bob)
} catch {
XCTFail("Handshake failed: \(error)")
}
}
}
func testEncryptionPerformance() throws {
try establishSessions()
let message = TestHelpers.generateRandomData(length: 1024)
measure {
do {
for _ in 0..<100 {
let ciphertext = try aliceSession.encrypt(message)
_ = try bobSession.decrypt(ciphertext)
}
} catch {
XCTFail("Encryption/decryption failed: \(error)")
}
}
} }
// MARK: - Helper Methods // MARK: - Helper Methods
private func establishSessions() throws {
aliceSession = NoiseSession(
peerID: TestConstants.testPeerID2,
role: .initiator,
keychain: mockKeychain,
localStaticKey: aliceKey
)
bobSession = NoiseSession(
peerID: TestConstants.testPeerID1,
role: .responder,
keychain: mockKeychain,
localStaticKey: bobKey
)
try performHandshake(initiator: aliceSession, responder: bobSession)
}
private func performHandshake(initiator: NoiseSession, responder: NoiseSession) throws { private func performHandshake(initiator: NoiseSession, responder: NoiseSession) throws {
let msg1 = try initiator.startHandshake() let msg1 = try initiator.startHandshake()
let msg2 = try responder.processHandshakeMessage(msg1)! let msg2 = try responder.processHandshakeMessage(msg1)!
@@ -559,9 +524,9 @@ final class NoiseProtocolTests: XCTestCase {
} }
private func establishManagerSessions(aliceManager: NoiseSessionManager, bobManager: NoiseSessionManager) throws { private func establishManagerSessions(aliceManager: NoiseSessionManager, bobManager: NoiseSessionManager) throws {
let msg1 = try aliceManager.initiateHandshake(with: TestConstants.testPeerID2) let msg1 = try aliceManager.initiateHandshake(with: alicePeerID)
let msg2 = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: msg1)! let msg2 = try bobManager.handleIncomingHandshake(from: bobPeerID, message: msg1)!
let msg3 = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: msg2)! let msg3 = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: msg2)!
_ = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: msg3) _ = try bobManager.handleIncomingHandshake(from: bobPeerID, message: msg3)
} }
} }
+58 -59
View File
@@ -5,20 +5,20 @@
// Tests for NIP-17 gift-wrapped private messages // Tests for NIP-17 gift-wrapped private messages
// //
import XCTest import Testing
import CryptoKit
import Foundation
@testable import bitchat @testable import bitchat
final class NostrProtocolTests: XCTestCase { struct NostrProtocolTests {
func testNIP17MessageRoundTrip() throws { @Test func nip17MessageRoundTrip() throws {
// Create sender and recipient identities // Create sender and recipient identities
let sender = try NostrIdentity.generate() let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate() let recipient = try NostrIdentity.generate()
#if DEBUG
print("Sender pubkey: \(sender.publicKeyHex)") print("Sender pubkey: \(sender.publicKeyHex)")
print("Recipient pubkey: \(recipient.publicKeyHex)") print("Recipient pubkey: \(recipient.publicKeyHex)")
#endif
// Create a test message // Create a test message
let originalContent = "Hello from NIP-17 test!" let originalContent = "Hello from NIP-17 test!"
@@ -30,10 +30,8 @@ final class NostrProtocolTests: XCTestCase {
senderIdentity: sender senderIdentity: sender
) )
#if DEBUG
print("Gift wrap created with ID: \(giftWrap.id)") print("Gift wrap created with ID: \(giftWrap.id)")
print("Gift wrap pubkey: \(giftWrap.pubkey)") print("Gift wrap pubkey: \(giftWrap.pubkey)")
#endif
// Decrypt the gift wrap // Decrypt the gift wrap
let (decryptedContent, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateMessage( let (decryptedContent, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateMessage(
@@ -42,20 +40,18 @@ final class NostrProtocolTests: XCTestCase {
) )
// Verify // Verify
XCTAssertEqual(decryptedContent, originalContent) #expect(decryptedContent == originalContent)
XCTAssertEqual(senderPubkey, sender.publicKeyHex) #expect(senderPubkey == sender.publicKeyHex)
// Verify timestamp is reasonable (within last minute) // Verify timestamp is reasonable (within last minute)
let messageDate = Date(timeIntervalSince1970: TimeInterval(timestamp)) let messageDate = Date(timeIntervalSince1970: TimeInterval(timestamp))
let timeDiff = abs(messageDate.timeIntervalSinceNow) let timeDiff = abs(messageDate.timeIntervalSinceNow)
XCTAssertLessThan(timeDiff, 60, "Message timestamp should be recent") #expect(timeDiff < 60, "Message timestamp should be recent")
#if DEBUG
print("✅ Successfully decrypted message: '\(decryptedContent)' from \(senderPubkey) at \(messageDate)") print("✅ Successfully decrypted message: '\(decryptedContent)' from \(senderPubkey) at \(messageDate)")
#endif
} }
func testGiftWrapUsesUniqueEphemeralKeys() throws { @Test func giftWrapUsesUniqueEphemeralKeys() throws {
// Create identities // Create identities
let sender = try NostrIdentity.generate() let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate() let recipient = try NostrIdentity.generate()
@@ -74,11 +70,10 @@ final class NostrProtocolTests: XCTestCase {
) )
// Gift wrap pubkeys should be different (unique ephemeral keys) // Gift wrap pubkeys should be different (unique ephemeral keys)
XCTAssertNotEqual(message1.pubkey, message2.pubkey) #expect(message1.pubkey != message2.pubkey)
#if DEBUG
print("Message 1 gift wrap pubkey: \(message1.pubkey)") print("Message 1 gift wrap pubkey: \(message1.pubkey)")
print("Message 2 gift wrap pubkey: \(message2.pubkey)") print("Message 2 gift wrap pubkey: \(message2.pubkey)")
#endif
// Both should decrypt successfully // Both should decrypt successfully
let (content1, _, _) = try NostrProtocol.decryptPrivateMessage( let (content1, _, _) = try NostrProtocol.decryptPrivateMessage(
@@ -90,11 +85,11 @@ final class NostrProtocolTests: XCTestCase {
recipientIdentity: recipient recipientIdentity: recipient
) )
XCTAssertEqual(content1, "Message 1") #expect(content1 == "Message 1")
XCTAssertEqual(content2, "Message 2") #expect(content2 == "Message 2")
} }
func testDecryptionFailsWithWrongRecipient() throws { @Test func decryptionFailsWithWrongRecipient() throws {
let sender = try NostrIdentity.generate() let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate() let recipient = try NostrIdentity.generate()
let wrongRecipient = try NostrIdentity.generate() let wrongRecipient = try NostrIdentity.generate()
@@ -107,13 +102,20 @@ final class NostrProtocolTests: XCTestCase {
) )
// Try to decrypt with wrong recipient // Try to decrypt with wrong recipient
XCTAssertThrowsError(try NostrProtocol.decryptPrivateMessage( if #available(macOS 14.4, iOS 17.4, *) {
#expect(throws: CryptoKitError.authenticationFailure) {
try NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap, giftWrap: giftWrap,
recipientIdentity: wrongRecipient recipientIdentity: wrongRecipient
)) { error in )
#if DEBUG }
print("Expected error when decrypting with wrong key: \(error)") } else {
#endif #expect(throws: (any Error).self) {
try NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: wrongRecipient
)
}
} }
} }
@@ -125,10 +127,11 @@ final class NostrProtocolTests: XCTestCase {
// Build a DELIVERED ack embedded payload (geohash-style, no recipient peer ID) // Build a DELIVERED ack embedded payload (geohash-style, no recipient peer ID)
let messageID = "TEST-MSG-DELIVERED-1" let messageID = "TEST-MSG-DELIVERED-1"
let senderPeerID = "0123456789abcdef" // 8-byte hex peer ID let senderPeerID = "0123456789abcdef" // 8-byte hex peer ID
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else {
XCTFail("Failed to embed delivered ack") let embedded = try #require(
return NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID),
} "Failed to embed delivered ack"
)
// Create NIP-17 gift wrap to recipient (uses NIP-44 v2 internally) // Create NIP-17 gift wrap to recipient (uses NIP-44 v2 internally)
let giftWrap = try NostrProtocol.createPrivateMessage( let giftWrap = try NostrProtocol.createPrivateMessage(
@@ -138,7 +141,7 @@ final class NostrProtocolTests: XCTestCase {
) )
// Ensure v2 format was used for ciphertext // Ensure v2 format was used for ciphertext
XCTAssertTrue(giftWrap.content.hasPrefix("v2:")) #expect(giftWrap.content.hasPrefix("v2:"))
// Decrypt as recipient // Decrypt as recipient
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage( let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
@@ -147,39 +150,37 @@ final class NostrProtocolTests: XCTestCase {
) )
// Verify sender is correct // Verify sender is correct
XCTAssertEqual(senderPubkey, sender.publicKeyHex) #expect(senderPubkey == sender.publicKeyHex)
// Parse BitChat payload // Parse BitChat payload
XCTAssertTrue(content.hasPrefix("bitchat1:")) #expect(content.hasPrefix("bitchat1:"))
let base64url = String(content.dropFirst("bitchat1:".count)) let base64url = String(content.dropFirst("bitchat1:".count))
guard let packetData = Self.base64URLDecode(base64url), let packetData = try #require(Self.base64URLDecode(base64url))
let packet = BitchatPacket.from(packetData) else { let packet = try #require(BitchatPacket.from(packetData), "Failed to decode bitchat packet")
return XCTFail("Failed to decode bitchat packet")
} #expect(packet.type == MessageType.noiseEncrypted.rawValue)
XCTAssertEqual(packet.type, MessageType.noiseEncrypted.rawValue) let payload = try #require(NoisePayload.decode(packet.payload), "Failed to decode NoisePayload")
guard let payload = NoisePayload.decode(packet.payload) else {
return XCTFail("Failed to decode NoisePayload")
}
switch payload.type { switch payload.type {
case .delivered: case .delivered:
let mid = String(data: payload.data, encoding: .utf8) let mid = String(data: payload.data, encoding: .utf8)
XCTAssertEqual(mid, messageID) #expect(mid == messageID)
default: default:
XCTFail("Unexpected payload type: \(payload.type)") Issue.record("Unexpected payload type: \(payload.type)")
} }
} }
func testAckRoundTripNIP44V2_ReadReceipt() throws { @Test func ackRoundTripNIP44V2_ReadReceipt() throws {
// Identities // Identities
let sender = try NostrIdentity.generate() let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate() let recipient = try NostrIdentity.generate()
let messageID = "TEST-MSG-READ-1" let messageID = "TEST-MSG-READ-1"
let senderPeerID = "fedcba9876543210" // 8-byte hex peer ID let senderPeerID = "fedcba9876543210" // 8-byte hex peer ID
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { let embedded = try #require(
XCTFail("Failed to embed read ack") NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID),
return "Failed to embed read ack"
} )
let giftWrap = try NostrProtocol.createPrivateMessage( let giftWrap = try NostrProtocol.createPrivateMessage(
content: embedded, content: embedded,
@@ -187,30 +188,28 @@ final class NostrProtocolTests: XCTestCase {
senderIdentity: sender senderIdentity: sender
) )
XCTAssertTrue(giftWrap.content.hasPrefix("v2:")) #expect(giftWrap.content.hasPrefix("v2:"))
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage( let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap, giftWrap: giftWrap,
recipientIdentity: recipient recipientIdentity: recipient
) )
XCTAssertEqual(senderPubkey, sender.publicKeyHex) #expect(senderPubkey == sender.publicKeyHex)
XCTAssertTrue(content.hasPrefix("bitchat1:")) #expect(content.hasPrefix("bitchat1:"))
let base64url = String(content.dropFirst("bitchat1:".count)) let base64url = String(content.dropFirst("bitchat1:".count))
guard let packetData = Self.base64URLDecode(base64url), let packetData = try #require(Self.base64URLDecode(base64url))
let packet = BitchatPacket.from(packetData) else { let packet = try #require(BitchatPacket.from(packetData), "Failed to decode bitchat packet")
return XCTFail("Failed to decode bitchat packet")
} #expect(packet.type == MessageType.noiseEncrypted.rawValue)
XCTAssertEqual(packet.type, MessageType.noiseEncrypted.rawValue) let payload = try #require(NoisePayload.decode(packet.payload), "Failed to decode NoisePayload")
guard let payload = NoisePayload.decode(packet.payload) else {
return XCTFail("Failed to decode NoisePayload")
}
switch payload.type { switch payload.type {
case .readReceipt: case .readReceipt:
let mid = String(data: payload.data, encoding: .utf8) let mid = String(data: payload.data, encoding: .utf8)
XCTAssertEqual(mid, messageID) #expect(mid == messageID)
default: default:
XCTFail("Unexpected payload type: \(payload.type)") Issue.record("Unexpected payload type: \(payload.type)")
} }
} }
@@ -1,7 +1,8 @@
import XCTest import Testing
import Foundation
@testable import bitchat @testable import bitchat
final class NotificationStreamAssemblerTests: XCTestCase { struct NotificationStreamAssemblerTests {
private func makePacket(timestamp: UInt64 = 0x0102030405) -> BitchatPacket { private func makePacket(timestamp: UInt64 = 0x0102030405) -> BitchatPacket {
let sender = Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77]) let sender = Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77])
return BitchatPacket( return BitchatPacket(
@@ -15,60 +16,51 @@ final class NotificationStreamAssemblerTests: XCTestCase {
) )
} }
func testAssemblesSingleFrameAcrossChunks() { @Test func assemblesSingleFrameAcrossChunks() throws {
var assembler = NotificationStreamAssembler() var assembler = NotificationStreamAssembler()
let packet = makePacket() let packet = makePacket()
guard let frame = packet.toBinaryData(padding: false) else { let frame = try #require(packet.toBinaryData(padding: false), "Failed to encode packet")
return XCTFail("Failed to encode packet")
} #expect(BinaryProtocol.decode(frame) != nil)
XCTAssertNotNil(BinaryProtocol.decode(frame))
let payloadLen = (Int(frame[12]) << 8) | Int(frame[13]) let payloadLen = (Int(frame[12]) << 8) | Int(frame[13])
XCTAssertEqual(payloadLen, packet.payload.count) #expect(payloadLen == packet.payload.count)
let splitIndex = min(20, max(1, frame.count / 2)) let splitIndex = min(20, max(1, frame.count / 2))
let first = frame.prefix(splitIndex) let first = frame.prefix(splitIndex)
let second = frame.suffix(from: splitIndex) let second = frame.suffix(from: splitIndex)
XCTAssertEqual(first.count + second.count, frame.count) #expect(first.count + second.count == frame.count)
var result = assembler.append(first) var result = assembler.append(first)
XCTAssertTrue(result.frames.isEmpty) #expect(result.frames.isEmpty)
XCTAssertTrue(result.droppedPrefixes.isEmpty) #expect(result.droppedPrefixes.isEmpty)
XCTAssertFalse(result.reset) #expect(!result.reset)
result = assembler.append(second) result = assembler.append(second)
XCTAssertEqual(result.frames.count, 1) #expect(result.frames.count == 1)
XCTAssertTrue(result.droppedPrefixes.isEmpty) #expect(result.droppedPrefixes.isEmpty)
XCTAssertFalse(result.reset) #expect(!result.reset)
guard let frameData = result.frames.first else { let frameData = try #require(result.frames.first, "Missing frame data")
return XCTFail("Missing frame data") #expect(frameData.count == frame.count)
}
if frameData.count != frame.count { let decoded = try #require(BinaryProtocol.decode(frameData), "Failed to decode frame")
XCTFail("Frame size mismatch: expected \(frame.count) got \(frameData.count)\nframe=\(Array(frame))\nassembled=\(Array(frameData))") #expect(decoded.type == packet.type)
return #expect(decoded.payload == packet.payload)
} #expect(decoded.senderID == packet.senderID)
guard let decoded = BinaryProtocol.decode(frameData) else { #expect(decoded.timestamp == packet.timestamp)
return XCTFail("Failed to decode frame")
}
XCTAssertEqual(decoded.type, packet.type)
XCTAssertEqual(decoded.payload, packet.payload)
XCTAssertEqual(decoded.senderID, packet.senderID)
XCTAssertEqual(decoded.timestamp, packet.timestamp)
var directAssembler = NotificationStreamAssembler() var directAssembler = NotificationStreamAssembler()
let directResult = directAssembler.append(frame) let directResult = directAssembler.append(frame)
XCTAssertEqual(directResult.frames.first?.count, frame.count) #expect(directResult.frames.first?.count == frame.count)
} }
func testAssemblesMultipleFramesSequentially() { @Test func assemblesMultipleFramesSequentially() throws {
var assembler = NotificationStreamAssembler() var assembler = NotificationStreamAssembler()
let packet1 = makePacket(timestamp: 0xABC) let packet1 = makePacket(timestamp: 0xABC)
let packet2 = makePacket(timestamp: 0xDEF) let packet2 = makePacket(timestamp: 0xDEF)
guard let frame1 = packet1.toBinaryData(padding: false), let frame1 = try #require(packet1.toBinaryData(padding: false), "Failed to encode packet")
let frame2 = packet2.toBinaryData(padding: false) else { let frame2 = try #require(packet2.toBinaryData(padding: false), "Failed to encode packet")
return XCTFail("Failed to encode packets")
}
var combined = Data() var combined = Data()
combined.append(frame1) combined.append(frame1)
@@ -77,35 +69,30 @@ final class NotificationStreamAssemblerTests: XCTestCase {
let secondChunk = combined.suffix(from: 20) let secondChunk = combined.suffix(from: 20)
var result = assembler.append(firstChunk) var result = assembler.append(firstChunk)
XCTAssertTrue(result.frames.isEmpty) #expect(result.frames.isEmpty)
result = assembler.append(secondChunk) result = assembler.append(secondChunk)
XCTAssertEqual(result.frames.count, 2) #expect(result.frames.count == 2)
guard let decoded1 = BinaryProtocol.decode(result.frames[0]),
let decoded2 = BinaryProtocol.decode(result.frames[1]) else { let decoded1 = try #require(BinaryProtocol.decode(result.frames[0]), "Failed to decode frame")
return XCTFail("Failed to decode frames") let decoded2 = try #require(BinaryProtocol.decode(result.frames[1]), "Failed to decode frame")
} #expect(decoded1.timestamp == packet1.timestamp)
XCTAssertEqual(decoded1.timestamp, packet1.timestamp) #expect(decoded2.timestamp == packet2.timestamp)
XCTAssertEqual(decoded2.timestamp, packet2.timestamp)
} }
func testDropsInvalidPrefixByte() { @Test func dropsInvalidPrefixByte() throws {
var assembler = NotificationStreamAssembler() var assembler = NotificationStreamAssembler()
let packet = makePacket(timestamp: 0xF00) let packet = makePacket(timestamp: 0xF00)
guard let frame = packet.toBinaryData(padding: false) else { let frame = try #require(packet.toBinaryData(padding: false), "Failed to encode packet")
return XCTFail("Failed to encode packet")
}
var noisyFrame = Data([0x00]) var noisyFrame = Data([0x00])
noisyFrame.append(frame) noisyFrame.append(frame)
let result = assembler.append(noisyFrame) let result = assembler.append(noisyFrame)
XCTAssertEqual(result.droppedPrefixes, [0x00]) #expect(result.droppedPrefixes == [0x00])
XCTAssertEqual(result.frames.count, 1) #expect(result.frames.count == 1)
XCTAssertFalse(result.reset) #expect(result.reset == false)
guard let decoded = BinaryProtocol.decode(result.frames[0]) else { let decoded = try #require(BinaryProtocol.decode(result.frames[0]), "Failed to decode frame after drop")
return XCTFail("Failed to decode frame after drop") #expect(decoded.timestamp == packet.timestamp)
}
XCTAssertEqual(decoded.timestamp, packet.timestamp)
} }
} }
@@ -5,30 +5,29 @@
// This is free and unencumbered software released into the public domain. // This is free and unencumbered software released into the public domain.
// //
import XCTest import Testing
@testable import bitchat @testable import bitchat
final class BinaryProtocolPaddingTests: XCTestCase { struct BinaryProtocolPaddingTests {
func test_padded_vs_unpadded_length() throws { @Test func padded_vs_unpadded_length() throws {
// Use helper to create a small test packet // Use helper to create a small test packet
let packet = TestHelpers.createTestPacket() let packet = TestHelpers.createTestPacket()
guard let padded = BinaryProtocol.encode(packet, padding: true) else { return XCTFail("encode padded") } let padded = try #require(BinaryProtocol.encode(packet, padding: true), "encode padded")
guard let unpadded = BinaryProtocol.encode(packet, padding: false) else { return XCTFail("encode unpadded") } let unpadded = try #require(BinaryProtocol.encode(packet, padding: false), "encode unpadded")
XCTAssertGreaterThanOrEqual(padded.count, unpadded.count, "Padded frame should be >= unpadded") #expect(padded.count >= unpadded.count, "Padded frame should be >= unpadded")
} }
func test_decode_padded_and_unpadded_round_trip() throws { @Test func decode_padded_and_unpadded_round_trip() throws {
let packet = TestHelpers.createTestPacket() let packet = TestHelpers.createTestPacket()
// Padded
guard let padded = BinaryProtocol.encode(packet, padding: true) else { return XCTFail("encode padded") } let padded = try #require(BinaryProtocol.encode(packet, padding: true), "encode padded")
guard let dec1 = BinaryProtocol.decode(padded) else { return XCTFail("decode padded") } let dec1 = try #require(BinaryProtocol.decode(padded), "decode padded")
XCTAssertEqual(dec1.type, packet.type) #expect(dec1.type == packet.type)
XCTAssertEqual(dec1.payload, packet.payload) #expect(dec1.payload == packet.payload)
// Unpadded
guard let unpadded = BinaryProtocol.encode(packet, padding: false) else { return XCTFail("encode unpadded") } let unpadded = try #require(BinaryProtocol.encode(packet, padding: false), "encode unpadded")
guard let dec2 = BinaryProtocol.decode(unpadded) else { return XCTFail("decode unpadded") } let dec2 = try #require(BinaryProtocol.decode(unpadded), "decode unpadded")
XCTAssertEqual(dec2.type, packet.type) #expect(dec2.type == packet.type)
XCTAssertEqual(dec2.payload, packet.payload) #expect(dec2.payload == packet.payload)
} }
} }
+122 -211
View File
@@ -6,119 +6,89 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import XCTest import Testing
import Foundation
@testable import bitchat @testable import bitchat
final class BinaryProtocolTests: XCTestCase { struct BinaryProtocolTests {
// MARK: - Basic Encoding/Decoding Tests // MARK: - Basic Encoding/Decoding Tests
func testBasicPacketEncodingDecoding() throws { @Test func basicPacketEncodingDecoding() throws {
let originalPacket = TestHelpers.createTestPacket() let originalPacket = TestHelpers.createTestPacket()
// Encode let encodedData = try #require(BinaryProtocol.encode(originalPacket), "Failed to encode packet")
guard let encodedData = BinaryProtocol.encode(originalPacket) else { let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode packet")
XCTFail("Failed to encode packet")
return
}
// Decode
guard let decodedPacket = BinaryProtocol.decode(encodedData) else {
XCTFail("Failed to decode packet")
return
}
// Verify // Verify
XCTAssertEqual(decodedPacket.type, originalPacket.type) #expect(decodedPacket.type == originalPacket.type)
XCTAssertEqual(decodedPacket.ttl, originalPacket.ttl) #expect(decodedPacket.ttl == originalPacket.ttl)
XCTAssertEqual(decodedPacket.timestamp, originalPacket.timestamp) #expect(decodedPacket.timestamp == originalPacket.timestamp)
XCTAssertEqual(decodedPacket.payload, originalPacket.payload) #expect(decodedPacket.payload == originalPacket.payload)
// Sender ID should match (accounting for padding) // Sender ID should match (accounting for padding)
let originalSenderID = originalPacket.senderID.prefix(BinaryProtocol.senderIDSize) let originalSenderID = originalPacket.senderID.prefix(BinaryProtocol.senderIDSize)
let decodedSenderID = decodedPacket.senderID.trimmingNullBytes() let decodedSenderID = decodedPacket.senderID.trimmingNullBytes()
XCTAssertEqual(decodedSenderID, originalSenderID) #expect(decodedSenderID == originalSenderID)
} }
func testPacketWithRecipient() throws { @Test func packetWithRecipient() throws {
let recipientID = TestConstants.testPeerID2 let recipientID = PeerID(str: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789")
let packet = TestHelpers.createTestPacket(recipientID: recipientID) let packet = TestHelpers.createTestPacket(recipientID: recipientID)
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with recipient")
// Encode and decode let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode packet with recipient")
guard let encodedData = BinaryProtocol.encode(packet),
let decodedPacket = BinaryProtocol.decode(encodedData) else {
XCTFail("Failed to encode/decode packet with recipient")
return
}
// Verify recipient // Verify recipient
XCTAssertNotNil(decodedPacket.recipientID) #expect(decodedPacket.recipientID != nil)
let decodedRecipientID = decodedPacket.recipientID?.trimmingNullBytes() let decodedRecipientID = decodedPacket.recipientID?.trimmingNullBytes()
XCTAssertTrue(String(data: decodedRecipientID!, encoding: .utf8) == recipientID) // TODO: Check if this is intended that the decoding only gets the first 8
#expect(String(data: decodedRecipientID!, encoding: .utf8) == "abcdef01")
} }
func testPacketWithSignature() throws { @Test func packetWithSignature() throws {
let packet = TestHelpers.createTestPacket( let packet = TestHelpers.createTestPacket(signature: TestConstants.testSignature)
signature: TestConstants.testSignature let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with signature")
) let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode packet with signature")
// Encode and decode
guard let encodedData = BinaryProtocol.encode(packet),
let decodedPacket = BinaryProtocol.decode(encodedData) else {
XCTFail("Failed to encode/decode packet with signature")
return
}
// Verify signature // Verify signature
XCTAssertNotNil(decodedPacket.signature) #expect(decodedPacket.signature != nil)
XCTAssertEqual(decodedPacket.signature, TestConstants.testSignature) #expect(decodedPacket.signature == TestConstants.testSignature)
} }
// MARK: - Compression Tests // MARK: - Compression Tests
func testPayloadCompression() throws { @Test("Create a large, compressible payload above current threshold (2048B)")
// Create a large, compressible payload above current threshold (2048B) func payloadCompression() throws {
let repeatedString = String(repeating: "This is a test message. ", count: 200) let repeatedString = String(repeating: "This is a test message. ", count: 200)
let largePayload = repeatedString.data(using: .utf8)! let largePayload = repeatedString.data(using: .utf8)!
let packet = TestHelpers.createTestPacket(payload: largePayload) let packet = TestHelpers.createTestPacket(payload: largePayload)
// Encode (should compress) // Encode (should compress)
guard let encodedData = BinaryProtocol.encode(packet) else { let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with large payload")
XCTFail("Failed to encode packet with large payload")
return
}
// The encoded size should be smaller than uncompressed due to compression // The encoded size should be smaller than uncompressed due to compression
let uncompressedSize = BinaryProtocol.headerSize + BinaryProtocol.senderIDSize + largePayload.count let uncompressedSize = BinaryProtocol.headerSize + BinaryProtocol.senderIDSize + largePayload.count
XCTAssertLessThan(encodedData.count, uncompressedSize) #expect(encodedData.count < uncompressedSize)
// Decode and verify // Decode and verify
guard let decodedPacket = BinaryProtocol.decode(encodedData) else { let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode compressed packet")
XCTFail("Failed to decode compressed packet")
return #expect(decodedPacket.payload == largePayload)
} }
XCTAssertEqual(decodedPacket.payload, largePayload) @Test("Small payloads should not be compressed")
} func smallPayloadNoCompression() throws {
func testSmallPayloadNoCompression() throws {
// Small payloads should not be compressed
let smallPayload = "Hi".data(using: .utf8)! let smallPayload = "Hi".data(using: .utf8)!
let packet = TestHelpers.createTestPacket(payload: smallPayload) let packet = TestHelpers.createTestPacket(payload: smallPayload)
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode small packet")
guard let encodedData = BinaryProtocol.encode(packet), let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode small packet")
let decodedPacket = BinaryProtocol.decode(encodedData) else { #expect(decodedPacket.payload == smallPayload)
XCTFail("Failed to encode/decode small packet")
return
}
XCTAssertEqual(decodedPacket.payload, smallPayload)
} }
// MARK: - Message Padding Tests // MARK: - Message Padding Tests
func testMessagePadding() throws { @Test func messagePadding() throws {
let payloads = [ let payloads = [
"Short", "Short",
String(repeating: "Medium length message content ", count: 10), // ~300 bytes String(repeating: "Medium length message content ", count: 10), // ~300 bytes
@@ -130,43 +100,32 @@ final class BinaryProtocolTests: XCTestCase {
for payload in payloads { for payload in payloads {
let packet = TestHelpers.createTestPacket(payload: payload.data(using: .utf8)!) let packet = TestHelpers.createTestPacket(payload: payload.data(using: .utf8)!)
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet")
guard let encodedData = BinaryProtocol.encode(packet) else {
XCTFail("Failed to encode packet")
continue
}
// Verify padding creates standard block sizes up to configured limit (no 4096 bucket currently) // Verify padding creates standard block sizes up to configured limit (no 4096 bucket currently)
let blockSizes = [256, 512, 1024, 2048] let blockSizes = [256, 512, 1024, 2048]
if encodedData.count <= 2048 { if encodedData.count <= 2048 {
XCTAssertTrue(blockSizes.contains(encodedData.count), "Encoded size \(encodedData.count) is not a standard block size") #expect(blockSizes.contains(encodedData.count), "Encoded size \(encodedData.count) is not a standard block size")
} else { } else {
// For very large payloads we expect no additional padding beyond raw size // For very large payloads we expect no additional padding beyond raw size
XCTAssertGreaterThan(encodedData.count, 2048) #expect(encodedData.count > 2048)
} }
encodedSizes.insert(encodedData.count) encodedSizes.insert(encodedData.count)
// Verify decoding works // Verify decoding works
guard let decodedPacket = BinaryProtocol.decode(encodedData) else { let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode padded packet")
XCTFail("Failed to decode padded packet") #expect(String(data: decodedPacket.payload, encoding: .utf8) == payload)
continue
}
XCTAssertEqual(String(data: decodedPacket.payload, encoding: .utf8), payload)
} }
// Different payload sizes (within <=2048) may map to the same bucket depending on compression. // Different payload sizes (within <=2048) may map to the same bucket depending on compression.
// Require at least one padded size to be present. // Require at least one padded size to be present.
XCTAssertGreaterThanOrEqual(encodedSizes.filter { $0 <= 2048 }.count, 1, "Expected at least one padded size up to 2048, got \(encodedSizes)") #expect(encodedSizes.filter { $0 <= 2048 }.count >= 1, "Expected at least one padded size up to 2048, got \(encodedSizes)")
} }
func testInvalidPKCS7PaddingIsRejected() throws { @Test func invalidPKCS7PaddingIsRejected() throws {
let pkt = TestHelpers.createTestPacket(payload: Data(repeating: 0x41, count: 50)) // small let pkt = TestHelpers.createTestPacket(payload: Data(repeating: 0x41, count: 50)) // small
guard let enc0 = BinaryProtocol.encode(pkt) else { let enc0 = try #require(BinaryProtocol.encode(pkt), "encode failed")
XCTFail("encode failed")
return
}
// Force padding to known block for test stability // Force padding to known block for test stability
var enc = MessagePadding.pad(enc0, toSize: 256) var enc = MessagePadding.pad(enc0, toSize: 256)
let unpadded = MessagePadding.unpad(enc) let unpadded = MessagePadding.unpad(enc)
@@ -177,39 +136,33 @@ final class BinaryProtocolTests: XCTestCase {
let maybe = BinaryProtocol.decode(enc) let maybe = BinaryProtocol.decode(enc)
// If decode still succeeds (nested pad edge case), at least ensure payload integrity // If decode still succeeds (nested pad edge case), at least ensure payload integrity
if let pkt2 = maybe { if let pkt2 = maybe {
XCTAssertEqual(pkt2.payload, pkt.payload) #expect(pkt2.payload == pkt.payload)
} else { } else {
XCTAssertNil(maybe) #expect(maybe == nil)
} }
} else { } else {
// If no padding was applied, just assert decode succeeds (nothing to test) // If no padding was applied, just assert decode succeeds (nothing to test)
XCTAssertNotNil(BinaryProtocol.decode(enc)) #expect(BinaryProtocol.decode(enc) != nil)
} }
} }
// MARK: - Message Encoding/Decoding Tests // MARK: - Message Encoding/Decoding Tests
func testMessageEncodingDecoding() throws { @Test func messageEncodingDecoding() throws {
let message = TestHelpers.createTestMessage() let message = TestHelpers.createTestMessage()
guard let payload = message.toBinaryPayload() else { let payload = try #require(message.toBinaryPayload(), "Failed to encode message to binary")
XCTFail("Failed to encode message to binary")
return
}
guard let decodedMessage = BitchatMessage(payload) else { let decodedMessage = try #require(BitchatMessage(payload), "Failed to decode message from binary")
XCTFail("Failed to decode message from binary")
return
}
XCTAssertEqual(decodedMessage.content, message.content) #expect(decodedMessage.content == message.content)
XCTAssertEqual(decodedMessage.sender, message.sender) #expect(decodedMessage.sender == message.sender)
XCTAssertEqual(decodedMessage.senderPeerID, message.senderPeerID) #expect(decodedMessage.senderPeerID == message.senderPeerID)
XCTAssertEqual(decodedMessage.isPrivate, message.isPrivate) #expect(decodedMessage.isPrivate == message.isPrivate)
// Timestamp should be close (within 1 second due to conversion) // Timestamp should be close (within 1 second due to conversion)
let timeDiff = abs(decodedMessage.timestamp.timeIntervalSince(message.timestamp)) let timeDiff = abs(decodedMessage.timestamp.timeIntervalSince(message.timestamp))
XCTAssertLessThan(timeDiff, 1.0) #expect(timeDiff < 1)
} }
func testPrivateMessageEncoding() throws { func testPrivateMessageEncoding() throws {
@@ -218,30 +171,22 @@ final class BinaryProtocolTests: XCTestCase {
recipientNickname: TestConstants.testNickname2 recipientNickname: TestConstants.testNickname2
) )
guard let payload = message.toBinaryPayload(), let payload = try #require(message.toBinaryPayload(), "Failed to encode private message")
let decodedMessage = BitchatMessage(payload) else { let decodedMessage = try #require(BitchatMessage(payload), "Failed to decode private message")
XCTFail("Failed to encode/decode private message")
return #expect(decodedMessage.isPrivate)
#expect(decodedMessage.recipientNickname == TestConstants.testNickname2)
} }
XCTAssertTrue(decodedMessage.isPrivate) @Test func messageWithMentions() throws {
XCTAssertEqual(decodedMessage.recipientNickname, TestConstants.testNickname2)
}
func testMessageWithMentions() throws {
let mentions = [TestConstants.testNickname2, TestConstants.testNickname3] let mentions = [TestConstants.testNickname2, TestConstants.testNickname3]
let message = TestHelpers.createTestMessage(mentions: mentions) let message = TestHelpers.createTestMessage(mentions: mentions)
let payload = try #require(message.toBinaryPayload(), "Failed to encode message with mentions")
guard let payload = message.toBinaryPayload(), let decodedMessage = try #require(BitchatMessage(payload), "Failed to decode message with mentions")
let decodedMessage = BitchatMessage(payload) else { #expect(decodedMessage.mentions == mentions)
XCTFail("Failed to encode/decode message with mentions")
return
} }
XCTAssertEqual(decodedMessage.mentions, mentions) @Test func relayMessageEncoding() throws {
}
func testRelayMessageEncoding() throws {
let message = BitchatMessage( let message = BitchatMessage(
id: UUID().uuidString, id: UUID().uuidString,
sender: TestConstants.testNickname1, sender: TestConstants.testNickname1,
@@ -251,105 +196,77 @@ final class BinaryProtocolTests: XCTestCase {
originalSender: TestConstants.testNickname3, originalSender: TestConstants.testNickname3,
isPrivate: false, isPrivate: false,
recipientNickname: nil, recipientNickname: nil,
senderPeerID: TestConstants.testPeerID1,
mentions: nil mentions: nil
) )
let payload = try #require(message.toBinaryPayload(), "Failed to encode relay message")
guard let payload = message.toBinaryPayload(), let decodedMessage = try #require(BitchatMessage(payload), "Failed to decode relay message")
let decodedMessage = BitchatMessage(payload) else { #expect(decodedMessage.isRelay)
XCTFail("Failed to encode/decode relay message") #expect(decodedMessage.originalSender == TestConstants.testNickname3)
return
}
XCTAssertTrue(decodedMessage.isRelay)
XCTAssertEqual(decodedMessage.originalSender, TestConstants.testNickname3)
} }
// MARK: - Edge Cases and Error Handling // MARK: - Edge Cases and Error Handling
func testInvalidDataDecoding() { @Test("Too small data")
// Too small data func invalidDataDecoding() throws {
let tooSmall = Data(repeating: 0, count: 5) let tooSmall = Data(repeating: 0, count: 5)
XCTAssertNil(BinaryProtocol.decode(tooSmall)) #expect(BinaryProtocol.decode(tooSmall) == nil)
// Random data // Random data
let random = TestHelpers.generateRandomData(length: 100) let random = TestHelpers.generateRandomData(length: 100)
XCTAssertNil(BinaryProtocol.decode(random)) #expect(BinaryProtocol.decode(random) == nil)
// Corrupted header // Corrupted header
let packet = TestHelpers.createTestPacket() let packet = TestHelpers.createTestPacket()
guard var encoded = BinaryProtocol.encode(packet) else { var encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode test packet")
XCTFail("Failed to encode test packet")
return
}
// Corrupt the version byte // Corrupt the version byte
encoded[0] = 0xFF encoded[0] = 0xFF
XCTAssertNil(BinaryProtocol.decode(encoded)) #expect(BinaryProtocol.decode(encoded) == nil)
} }
func testLargeMessageHandling() throws { @Test("Test maximum size handling")
// Test maximum size handling func largeMessageHandling() throws {
let largeContent = String(repeating: "X", count: 65535) // Max uint16 let largeContent = String(repeating: "X", count: 65535) // Max uint16
let message = TestHelpers.createTestMessage(content: largeContent) let message = TestHelpers.createTestMessage(content: largeContent)
let payload = try #require(message.toBinaryPayload(), "Failed to handle large message")
guard let payload = message.toBinaryPayload(), let decodedMessage = try #require(BitchatMessage(payload), "Failed to handle large message")
let decodedMessage = BitchatMessage(payload) else { #expect(decodedMessage.content == largeContent)
XCTFail("Failed to handle large message")
return
} }
XCTAssertEqual(decodedMessage.content, largeContent) @Test("Test message with empty content")
} func emptyFieldsHandling() throws {
func testEmptyFieldsHandling() throws {
// Test message with empty content
let emptyMessage = TestHelpers.createTestMessage(content: "") let emptyMessage = TestHelpers.createTestMessage(content: "")
let payload = try #require(emptyMessage.toBinaryPayload(), "Failed to handle empty message")
guard let payload = emptyMessage.toBinaryPayload(), let decodedMessage = try #require(BitchatMessage(payload), "Failed to handle empty message")
let decodedMessage = BitchatMessage(payload) else { #expect(decodedMessage.content.isEmpty)
XCTFail("Failed to handle empty message")
return
}
XCTAssertEqual(decodedMessage.content, "")
} }
// MARK: - Protocol Version Tests // MARK: - Protocol Version Tests
func testProtocolVersionHandling() throws { @Test("Test with supported version (version is always 1 in init)")
// Test with supported version (version is always 1 in init) func protocolVersionHandling() throws {
let packet = TestHelpers.createTestPacket() let packet = TestHelpers.createTestPacket()
let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with version")
guard let encoded = BinaryProtocol.encode(packet), let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode packet with version")
let decoded = BinaryProtocol.decode(encoded) else { #expect(decoded.version == 1)
XCTFail("Failed to encode/decode packet with version")
return
} }
XCTAssertEqual(decoded.version, 1) @Test("Create packet data with unsupported version")
} func unsupportedProtocolVersion() throws {
func testUnsupportedProtocolVersion() throws {
// Create packet data with unsupported version
let packet = TestHelpers.createTestPacket() let packet = TestHelpers.createTestPacket()
var encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode packet")
guard var encoded = BinaryProtocol.encode(packet) else {
XCTFail("Failed to encode packet")
return
}
// Manually change version byte to unsupported value // Manually change version byte to unsupported value
encoded[0] = 99 // Unsupported version encoded[0] = 99 // Unsupported version
// Should fail to decode // Should fail to decode
XCTAssertNil(BinaryProtocol.decode(encoded)) #expect(BinaryProtocol.decode(encoded) == nil)
} }
// MARK: - Bounds Checking Tests (Crash Prevention) // MARK: - Bounds Checking Tests (Crash Prevention)
func testMalformedPacketWithInvalidPayloadLength() throws { @Test("Test the specific crash scenario: payloadLength = 193 (0xc1) but only 30 bytes available")
// Test the specific crash scenario: payloadLength = 193 (0xc1) but only 30 bytes available func malformedPacketWithInvalidPayloadLength() throws {
var malformedData = Data() var malformedData = Data()
// Valid header (13 bytes) // Valid header (13 bytes)
@@ -379,20 +296,17 @@ final class BinaryProtocolTests: XCTestCase {
} }
// Total data is now 30 bytes, but payloadLength claims 193 // Total data is now 30 bytes, but payloadLength claims 193
XCTAssertEqual(malformedData.count, 30) #expect(malformedData.count == 30)
// This should not crash - should return nil gracefully // This should not crash - should return nil gracefully
let result = BinaryProtocol.decode(malformedData) let result = BinaryProtocol.decode(malformedData)
XCTAssertNil(result, "Malformed packet with invalid payload length should return nil, not crash") #expect(result == nil, "Malformed packet with invalid payload length should return nil, not crash")
} }
func testTruncatedPacketHandling() throws { @Test("Test various truncation scenarios")
// Test various truncation scenarios func truncatedPacketHandling() throws {
let packet = TestHelpers.createTestPacket() let packet = TestHelpers.createTestPacket()
guard let validEncoded = BinaryProtocol.encode(packet) else { let validEncoded = try #require(BinaryProtocol.encode(packet), "Failed to encode test packet")
XCTFail("Failed to encode test packet")
return
}
// Test truncation at various points // Test truncation at various points
let truncationPoints = [0, 5, 10, 15, 20, 25] let truncationPoints = [0, 5, 10, 15, 20, 25]
@@ -400,12 +314,12 @@ final class BinaryProtocolTests: XCTestCase {
for point in truncationPoints { for point in truncationPoints {
let truncated = validEncoded.prefix(point) let truncated = validEncoded.prefix(point)
let result = BinaryProtocol.decode(truncated) let result = BinaryProtocol.decode(truncated)
XCTAssertNil(result, "Truncated packet at \(point) bytes should return nil, not crash") #expect(result == nil, "Truncated packet at \(point) bytes should return nil, not crash")
} }
} }
func testMalformedCompressedPacket() throws { @Test("Test compressed packet with invalid original size")
// Test compressed packet with invalid original size func malformedCompressedPacket() throws {
var malformedData = Data() var malformedData = Data()
// Valid header // Valid header
@@ -434,11 +348,11 @@ final class BinaryProtocolTests: XCTestCase {
// Should handle this gracefully // Should handle this gracefully
let result = BinaryProtocol.decode(malformedData) let result = BinaryProtocol.decode(malformedData)
XCTAssertNil(result, "Malformed compressed packet should return nil, not crash") #expect(result == nil, "Malformed compressed packet should return nil, not crash")
} }
func testExcessivelyLargePayloadLength() throws { @Test("Test packet claiming extremely large payload")
// Test packet claiming extremely large payload func excessivelyLargePayloadLength() throws {
var malformedData = Data() var malformedData = Data()
// Valid header // Valid header
@@ -467,11 +381,11 @@ final class BinaryProtocolTests: XCTestCase {
// Should handle this gracefully without trying to allocate massive amounts of memory // Should handle this gracefully without trying to allocate massive amounts of memory
let result = BinaryProtocol.decode(malformedData) let result = BinaryProtocol.decode(malformedData)
XCTAssertNil(result, "Packet with excessive payload length should return nil, not crash") #expect(result == nil, "Packet with excessive payload length should return nil, not crash")
} }
func testCompressedPacketWithInvalidOriginalSize() throws { @Test("Test compressed packet with unreasonable original size")
// Test compressed packet with unreasonable original size func compressedPacketWithInvalidOriginalSize() throws {
var malformedData = Data() var malformedData = Data()
// Valid header // Valid header
@@ -509,11 +423,11 @@ final class BinaryProtocolTests: XCTestCase {
} }
let result = BinaryProtocol.decode(malformedData) let result = BinaryProtocol.decode(malformedData)
XCTAssertNil(result, "Compressed packet with invalid original size should return nil, not crash") #expect(result == nil, "Compressed packet with invalid original size should return nil, not crash")
} }
func testMaliciousPacketWithIntegerOverflow() throws { @Test("Test packet designed to cause integer overflow")
// Test packet designed to cause integer overflow func maliciousPacketWithIntegerOverflow() throws {
var maliciousData = Data() var maliciousData = Data()
// Valid header // Valid header
@@ -548,27 +462,24 @@ final class BinaryProtocolTests: XCTestCase {
// Should handle gracefully without integer overflow issues // Should handle gracefully without integer overflow issues
let result = BinaryProtocol.decode(maliciousData) let result = BinaryProtocol.decode(maliciousData)
XCTAssertNil(result, "Malicious packet designed for integer overflow should return nil, not crash") #expect(result == nil, "Malicious packet designed for integer overflow should return nil, not crash")
} }
func testPartialHeaderData() throws { @Test("Test packets with incomplete headers")
// Test packets with incomplete headers func partialHeaderData() throws {
let headerSizes = [0, 1, 5, 10, 12] // Various incomplete header sizes let headerSizes = [0, 1, 5, 10, 12] // Various incomplete header sizes
for size in headerSizes { for size in headerSizes {
let partialData = Data(repeating: 0x01, count: size) let partialData = Data(repeating: 0x01, count: size)
let result = BinaryProtocol.decode(partialData) let result = BinaryProtocol.decode(partialData)
XCTAssertNil(result, "Partial header data (\(size) bytes) should return nil, not crash") #expect(result == nil, "Partial header data (\(size) bytes) should return nil, not crash")
} }
} }
func testBoundaryConditions() throws { @Test("Test exact boundary conditions")
// Test exact boundary conditions func boundaryConditions() throws {
let packet = TestHelpers.createTestPacket() let packet = TestHelpers.createTestPacket()
guard let validEncoded = BinaryProtocol.encode(packet) else { let validEncoded = try #require(BinaryProtocol.encode(packet), "Failed to encode test packet")
XCTFail("Failed to encode test packet")
return
}
// If truncation only removes padding, decode may still succeed. Compute unpadded size. // If truncation only removes padding, decode may still succeed. Compute unpadded size.
let unpadded = MessagePadding.unpad(validEncoded) let unpadded = MessagePadding.unpad(validEncoded)
@@ -576,7 +487,7 @@ final class BinaryProtocolTests: XCTestCase {
let cut = max(1, unpadded.count - 10) let cut = max(1, unpadded.count - 10)
let truncatedCore = unpadded.prefix(cut) let truncatedCore = unpadded.prefix(cut)
let result = BinaryProtocol.decode(truncatedCore) let result = BinaryProtocol.decode(truncatedCore)
XCTAssertNil(result, "Truncated core frame should return nil, not crash") #expect(result == nil, "Truncated core frame should return nil, not crash")
// Test minimum valid size - create a valid minimal packet // Test minimum valid size - create a valid minimal packet
var minData = Data() var minData = Data()
@@ -14,11 +14,6 @@ struct TestConstants {
static let shortTimeout: TimeInterval = 1.0 static let shortTimeout: TimeInterval = 1.0
static let longTimeout: TimeInterval = 10.0 static let longTimeout: TimeInterval = 10.0
static let testPeerID1: PeerID = "PEER1234"
static let testPeerID2: PeerID = "PEER5678"
static let testPeerID3: PeerID = "PEER9012"
static let testPeerID4: PeerID = "PEER3456"
static let testNickname1 = "Alice" static let testNickname1 = "Alice"
static let testNickname2 = "Bob" static let testNickname2 = "Bob"
static let testNickname3 = "Charlie" static let testNickname3 = "Charlie"
+6 -14
View File
@@ -30,7 +30,7 @@ final class TestHelpers {
static func createTestMessage( static func createTestMessage(
content: String = TestConstants.testMessage1, content: String = TestConstants.testMessage1,
sender: String = TestConstants.testNickname1, sender: String = TestConstants.testNickname1,
senderPeerID: PeerID = TestConstants.testPeerID1, senderPeerID: PeerID = PeerID(str: UUID().uuidString),
isPrivate: Bool = false, isPrivate: Bool = false,
recipientNickname: String? = nil, recipientNickname: String? = nil,
mentions: [String]? = nil mentions: [String]? = nil
@@ -51,7 +51,7 @@ final class TestHelpers {
static func createTestPacket( static func createTestPacket(
type: UInt8 = 0x01, type: UInt8 = 0x01,
senderID: PeerID = TestConstants.testPeerID1, senderID: PeerID = PeerID(str: UUID().uuidString),
recipientID: PeerID? = nil, recipientID: PeerID? = nil,
payload: Data = "test payload".data(using: .utf8)!, payload: Data = "test payload".data(using: .utf8)!,
signature: Data? = nil, signature: Data? = nil,
@@ -90,7 +90,7 @@ final class TestHelpers {
if Date().timeIntervalSince(start) > timeout { if Date().timeIntervalSince(start) > timeout {
throw TestError.timeout throw TestError.timeout
} }
try await Task.sleep(nanoseconds: 10_000_000) // 10ms try await sleep(0.01)
} }
} }
@@ -104,7 +104,7 @@ final class TestHelpers {
} }
group.addTask { group.addTask {
try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) try await sleep(1)
throw TestError.timeout throw TestError.timeout
} }
@@ -121,14 +121,6 @@ enum TestError: Error {
case testFailure(String) case testFailure(String)
} }
// MARK: - PeerID String Helpers func sleep(_ seconds: TimeInterval) async throws {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
/// Raw String can be passed as PeerID
extension PeerID: @retroactive ExpressibleByStringLiteral {
public init(stringLiteral value: String) {
self.init(str: value)
}
} }
/// Interpolated String can be passed as PeerID
extension PeerID: @retroactive ExpressibleByStringInterpolation {}
+181 -190
View File
@@ -6,11 +6,11 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import XCTest import Testing
import Foundation
@testable import bitchat @testable import bitchat
final class PeerIDTests: XCTestCase { struct PeerIDTests {
private let hex16 = "0011223344556677" private let hex16 = "0011223344556677"
private let hex64 = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" private let hex64 = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
@@ -22,212 +22,205 @@ final class PeerIDTests: XCTestCase {
// MARK: - Empty prefix // MARK: - Empty prefix
func test_init_empty_prefix_with16() { @Test func empty_prefix_with16() {
let peerID = PeerID(str: hex16) let peerID = PeerID(str: hex16)
XCTAssertEqual(peerID.id, hex16) #expect(peerID.id == hex16)
XCTAssertEqual(peerID.bare, hex16) #expect(peerID.bare == hex16)
XCTAssertEqual(peerID.prefix, .empty) #expect(peerID.prefix == .empty)
} }
func test_init_empty_prefix_with64() { @Test func empty_prefix_with64() {
let peerID = PeerID(str: hex64) let peerID = PeerID(str: hex64)
XCTAssertEqual(peerID.id, hex64) #expect(peerID.id == hex64)
XCTAssertEqual(peerID.bare, hex64) #expect(peerID.bare == hex64)
XCTAssertEqual(peerID.prefix, .empty) #expect(peerID.prefix == .empty)
} }
// MARK: - Mesh prefix // MARK: - Mesh prefix
func test_init_mesh_prefix_with16() { @Test func mesh_prefix_with16() {
let str = "mesh:" + hex16 let str = "mesh:" + hex16
let peerID = PeerID(str: str) let peerID = PeerID(str: str)
XCTAssertEqual(peerID.id, str) #expect(peerID.id == str)
XCTAssertEqual(peerID.bare, hex16) #expect(peerID.bare == hex16)
XCTAssertEqual(peerID.prefix, .mesh) #expect(peerID.prefix == .mesh)
} }
func test_init_mesh_prefix_with64() { @Test func mesh_prefix_with64() {
let str = "mesh:" + hex64 let str = "mesh:" + hex64
let peerID = PeerID(str: str) let peerID = PeerID(str: str)
XCTAssertEqual(peerID.id, str) #expect(peerID.id == str)
XCTAssertEqual(peerID.bare, hex64) #expect(peerID.bare == hex64)
XCTAssertEqual(peerID.prefix, .mesh) #expect(peerID.prefix == .mesh)
} }
// MARK: - Name prefix // MARK: - Name prefix
func test_init_name_prefix() { @Test func name_prefix() {
let str = "name:some_name" let str = "name:some_name"
let peerID = PeerID(str: str) let peerID = PeerID(str: str)
XCTAssertEqual(peerID.id, str) #expect(peerID.id == str)
XCTAssertEqual(peerID.bare, "some_name") #expect(peerID.bare == "some_name")
XCTAssertEqual(peerID.prefix, .name) #expect(peerID.prefix == .name)
} }
// MARK: - Noise prefix // MARK: - Noise prefix
func test_init_noise_prefix_with16() { @Test func noise_prefix_with16() {
let str = "noise:" + hex16 let str = "noise:" + hex16
let peerID = PeerID(str: str) let peerID = PeerID(str: str)
XCTAssertEqual(peerID.id, str) #expect(peerID.id == str)
XCTAssertEqual(peerID.bare, hex16) #expect(peerID.bare == hex16)
XCTAssertEqual(peerID.prefix, .noise) #expect(peerID.prefix == .noise)
} }
func test_init_noise_prefix_with64() { @Test func noise_prefix_with64() {
let str = "noise:" + hex64 let str = "noise:" + hex64
let peerID = PeerID(str: str) let peerID = PeerID(str: str)
XCTAssertEqual(peerID.id, str) #expect(peerID.id == str)
XCTAssertEqual(peerID.bare, hex64) #expect(peerID.bare == hex64)
XCTAssertEqual(peerID.prefix, .noise) #expect(peerID.prefix == .noise)
} }
// MARK: - GeoDM prefix // MARK: - GeoDM prefix
func test_init_geoDM_prefix_with16() { @Test func geoDM_prefix_with16() {
let str = "nostr_" + hex16 let str = "nostr_" + hex16
let peerID = PeerID(str: str) let peerID = PeerID(str: str)
XCTAssertEqual(peerID.id, str) #expect(peerID.id == str)
XCTAssertEqual(peerID.bare, hex16) #expect(peerID.bare == hex16)
XCTAssertEqual(peerID.prefix, .geoDM) #expect(peerID.prefix == .geoDM)
} }
func test_init_geoDM_prefix_with64() { @Test func geoDM_prefix_with64() {
let str = "nostr_" + hex64 let str = "nostr_" + hex64
let peerID = PeerID(str: str) let peerID = PeerID(str: str)
XCTAssertEqual(peerID.id, str) #expect(peerID.id == str)
XCTAssertEqual(peerID.bare, hex64) #expect(peerID.bare == hex64)
XCTAssertEqual(peerID.prefix, .geoDM) #expect(peerID.prefix == .geoDM)
} }
// MARK: - GeoChat prefix // MARK: - GeoChat prefix
func test_init_geoChat_prefix_with16() { @Test func geoChat_prefix_with16() {
let str = "nostr:" + hex16 let str = "nostr:" + hex16
let peerID = PeerID(str: str) let peerID = PeerID(str: str)
XCTAssertEqual(peerID.id, str) #expect(peerID.id == str)
XCTAssertEqual(peerID.bare, hex16) #expect(peerID.bare == hex16)
XCTAssertEqual(peerID.prefix, .geoChat) #expect(peerID.prefix == .geoChat)
} }
func test_init_geoChat_prefix_with64() { @Test func geoChat_prefix_with64() {
let str = "nostr:" + hex64 let str = "nostr:" + hex64
let peerID = PeerID(str: str) let peerID = PeerID(str: str)
XCTAssertEqual(peerID.id, str) #expect(peerID.id == str)
XCTAssertEqual(peerID.bare, hex64) #expect(peerID.bare == hex64)
XCTAssertEqual(peerID.prefix, .geoChat) #expect(peerID.prefix == .geoChat)
} }
// MARK: - Edge cases // MARK: - Edge cases
func test_init_with_unknown_prefix() { @Test func with_unknown_prefix() {
let str = "unknown:" + hex16 let str = "unknown:" + hex16
let peerID = PeerID(str: str) let peerID = PeerID(str: str)
// Falls back to .empty // Falls back to .empty
XCTAssertEqual(peerID.id, str) #expect(peerID.id == str)
XCTAssertEqual(peerID.bare, str) #expect(peerID.bare == str)
XCTAssertEqual(peerID.prefix, .empty) #expect(peerID.prefix == .empty)
} }
func test_init_with_only_prefix_no_bare() { @Test func with_only_prefix_no_bare() {
let str = "mesh:" let str = "mesh:"
let peerID = PeerID(str: str) let peerID = PeerID(str: str)
XCTAssertEqual(peerID.id, str) #expect(peerID.id == str)
XCTAssertEqual(peerID.bare, "") #expect(peerID.bare == "")
XCTAssertEqual(peerID.prefix, .mesh) #expect(peerID.prefix == .mesh)
} }
// MARK: - init?(data:) // MARK: - init?(data:)
func test_init_data_valid_utf8() { @Test func data_valid_utf8() {
let peerID = PeerID(data: Data(hex16.utf8)) let peerID = PeerID(data: Data(hex16.utf8))
XCTAssertNotNil(peerID) #expect(peerID != nil)
XCTAssertEqual(peerID?.bare, hex16) #expect(peerID?.bare == hex16)
XCTAssertEqual(peerID?.prefix, .empty) #expect(peerID?.prefix == .empty)
} }
func test_init_data_invalid_utf8() { @Test func data_invalid_utf8() {
// Random invalid UTF8 // Random invalid UTF8
let bytes: [UInt8] = [0xFF, 0xFE, 0xFA] let bytes: [UInt8] = [0xFF, 0xFE, 0xFA]
let peerID = PeerID(data: Data(bytes)) let peerID = PeerID(data: Data(bytes))
XCTAssertNil(peerID) #expect(peerID == nil)
} }
// MARK: - init(str: Substring) // MARK: - init(str: Substring)
func test_init_substring() { @Test func substring() {
let substring = hex64.prefix(16) let substring = hex64.prefix(16)
let peerID = PeerID(str: substring) let peerID = PeerID(str: substring)
XCTAssertEqual(peerID.id, String(substring)) #expect(peerID.id == String(substring))
XCTAssertEqual(peerID.bare, String(substring)) #expect(peerID.bare == String(substring))
XCTAssertEqual(peerID.prefix, .empty) #expect(peerID.prefix == .empty)
} }
// MARK: - init(nostr_ pubKey:) // MARK: - init(nostr_ pubKey:)
func test_init_nostrUnderscore_pubKey() { @Test func nostrUnderscore_pubKey() {
let pubKey = hex64 let pubKey = hex64
let peerID = PeerID(nostr_: pubKey) let peerID = PeerID(nostr_: pubKey)
XCTAssertEqual(peerID.id, "nostr_\(pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength))") #expect(peerID.id == "nostr_\(pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength))")
XCTAssertEqual(peerID.bare, String(pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength))) #expect(peerID.bare == String(pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength)))
XCTAssertEqual(peerID.prefix, .geoDM) #expect(peerID.prefix == .geoDM)
} }
// MARK: - init(nostr pubKey:) // MARK: - init(nostr pubKey:)
func test_init_nostr_pubKey() { @Test func nostr_pubKey() {
let pubKey = hex64 let pubKey = hex64
let peerID = PeerID(nostr: pubKey) let peerID = PeerID(nostr: pubKey)
XCTAssertEqual(peerID.id, "nostr:\(pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength))") #expect(peerID.id == "nostr:\(pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength))")
XCTAssertEqual(peerID.bare, String(pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength))) #expect(peerID.bare == String(pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength)))
XCTAssertEqual(peerID.prefix, .geoChat) #expect(peerID.prefix == .geoChat)
} }
// MARK: - init(publicKey:) // MARK: - init(publicKey:)
func test_init_publicKey_derivesFingerprint() { @Test func publicKey_derivesFingerprint() {
let publicKey = Data(hex64.utf8) let publicKey = Data(hex64.utf8)
let expected = publicKey.sha256Fingerprint().prefix(16) let expected = publicKey.sha256Fingerprint().prefix(16)
let peerID = PeerID(publicKey: publicKey) let peerID = PeerID(publicKey: publicKey)
XCTAssertEqual(peerID.bare, String(expected)) #expect(peerID.bare == String(expected))
XCTAssertEqual(peerID.prefix, .empty) #expect(peerID.prefix == .empty)
} }
// MARK: - toShort() // MARK: - toShort()
func test_toShort_whenNoiseKeyExists() { @Test func toShort_whenNoiseKeyExists() {
let peerID = PeerID(str: hex64) let peerID = PeerID(str: hex64)
let short = peerID.toShort() let short = peerID.toShort()
// `toShort()` should derive 16-hex peerID
let expected = Data(hexString: hex64)!.sha256Fingerprint().prefix(16) let expected = Data(hexString: hex64)!.sha256Fingerprint().prefix(16)
#expect(short.bare == String(expected))
XCTAssertEqual(short.bare, String(expected)) #expect(short.prefix == .empty)
XCTAssertEqual(short.prefix, .empty)
} }
func test_toShort_whenNoiseKeyExists_withNoisePrefix() { @Test func toShort_whenNoiseKeyExists_withNoisePrefix() {
let peerID = PeerID(str: "noise:" + hex64) let peerID = PeerID(str: "noise:" + hex64)
let short = peerID.toShort() let short = peerID.toShort()
// `toShort()` should derive 16-hex peerID
let expected = Data(hexString: hex64)!.sha256Fingerprint().prefix(16) let expected = Data(hexString: hex64)!.sha256Fingerprint().prefix(16)
#expect(short.bare == String(expected))
XCTAssertEqual(short.bare, String(expected)) #expect(short.prefix == .empty)
XCTAssertEqual(short.prefix, .empty) #expect(peerID.prefix == .noise)
XCTAssertEqual(peerID.prefix, .noise)
} }
func test_toShort_whenNoNoiseKey() { @Test func toShort_whenNoNoiseKey() {
let peerID = PeerID(str: "some_random_key") let peerID = PeerID(str: "some_random_key")
let short = peerID.toShort() let short = peerID.toShort()
XCTAssertEqual(short, peerID) // unchanged #expect(short == peerID)
} }
// MARK: - Codable // MARK: - Codable
func test_codable_emptyPrefix() throws { @Test func codable_emptyPrefix() throws {
struct Dummy: Codable, Equatable { struct Dummy: Codable, Equatable {
let name: String let name: String
let peerID: PeerID let peerID: PeerID
@@ -237,13 +230,13 @@ final class PeerIDTests: XCTestCase {
let jsonString = "{\"name\":\"some name\",\"peerID\":\"\(str)\"}" let jsonString = "{\"name\":\"some name\",\"peerID\":\"\(str)\"}"
let decoded = try JSONDecoder().decode(Dummy.self, from: Data(jsonString.utf8)) let decoded = try JSONDecoder().decode(Dummy.self, from: Data(jsonString.utf8))
XCTAssertEqual(decoded.peerID, PeerID(str: str)) #expect(decoded.peerID == PeerID(str: str))
let encoded = try encoder.encode(decoded) let encoded = try encoder.encode(decoded)
XCTAssertEqual(String(data: encoded, encoding: .utf8), jsonString) #expect(String(data: encoded, encoding: .utf8) == jsonString)
} }
func test_codable_withPrefix() throws { @Test func codable_withPrefix() throws {
struct Dummy: Codable, Equatable { struct Dummy: Codable, Equatable {
let peerID: PeerID let peerID: PeerID
} }
@@ -252,193 +245,191 @@ final class PeerIDTests: XCTestCase {
let jsonString = "{\"peerID\":\"\(str)\"}" let jsonString = "{\"peerID\":\"\(str)\"}"
let decoded = try JSONDecoder().decode(Dummy.self, from: Data(jsonString.utf8)) let decoded = try JSONDecoder().decode(Dummy.self, from: Data(jsonString.utf8))
XCTAssertEqual(decoded.peerID, PeerID(str: str)) #expect(decoded.peerID == PeerID(str: str))
XCTAssertEqual(decoded.peerID.bare, hex16) #expect(decoded.peerID.bare == hex16)
XCTAssertEqual(decoded.peerID.prefix, .geoDM) #expect(decoded.peerID.prefix == .geoDM)
let encoded = try encoder.encode(decoded) let encoded = try encoder.encode(decoded)
XCTAssertEqual(String(data: encoded, encoding: .utf8), jsonString) #expect(String(data: encoded, encoding: .utf8) == jsonString)
} }
func test_codable_multiplePrefixes() throws { @Test func codable_multiplePrefixes() throws {
// Loop across all Prefix cases (except .empty since already tested) // Loop across all Prefix cases (except .empty since already tested)
for prefix in PeerID.Prefix.allCases where prefix != .empty { for prefix in PeerID.Prefix.allCases where prefix != .empty {
let bare = hex16 let bare = hex16
let str = prefix.rawValue + bare let str = prefix.rawValue + bare
let decoded = try JSONDecoder().decode(PeerID.self, from: Data("\"\(str)\"".utf8)) let decoded = try JSONDecoder().decode(PeerID.self, from: Data("\"\(str)\"".utf8))
XCTAssertEqual(decoded.prefix, prefix) #expect(decoded.prefix == prefix)
XCTAssertEqual(decoded.bare, bare) #expect(decoded.bare == bare)
let encoded = try encoder.encode(decoded) let encoded = try encoder.encode(decoded)
XCTAssertEqual(String(data: encoded, encoding: .utf8), "\"\(str)\"") #expect(String(data: encoded, encoding: .utf8) == "\"\(str)\"")
} }
} }
// MARK: - Comparable // MARK: - Comparable
func test_comparable_sorting_and_equality() { @Test func comparable_sorting_and_equality() {
let p1 = PeerID(str: "aaa") let p1 = PeerID(str: "aaa")
let p2 = PeerID(str: "bbb") let p2 = PeerID(str: "bbb")
let p3 = PeerID(str: "bbb") let p3 = PeerID(str: "bbb")
XCTAssertTrue(p1 < p2) #expect(p1 < p2)
XCTAssertFalse(p2 < p1) #expect(p2 >= p1)
XCTAssertEqual(p2, p3) #expect(p2 == p3)
let sorted = [p2, p1].sorted() let sorted = [p2, p1].sorted()
XCTAssertEqual(sorted, [p1, p2]) #expect(sorted == [p1, p2])
} }
func test_equality() { @Test func equality() {
let string = "aaa" let string = "aaa"
let peerID = PeerID(str: string) let peerID = PeerID(str: string)
let badString = "bbb" let badString = "bbb"
// PeerID == String // PeerID == String
XCTAssertTrue(peerID == string) #expect(peerID == string)
XCTAssertTrue(peerID == Optional(string)) #expect(peerID == Optional(string))
XCTAssertTrue(Optional(peerID) == string) #expect(Optional(peerID) == string)
XCTAssertTrue(Optional(peerID) == Optional(string)) #expect(Optional(peerID) == Optional(string))
// PeerID != String // PeerID != String
XCTAssertTrue(peerID != badString) #expect(peerID != badString)
XCTAssertTrue(peerID != Optional(badString)) #expect(peerID != Optional(badString))
XCTAssertTrue(Optional(peerID) != badString) #expect(Optional(peerID) != badString)
XCTAssertTrue(Optional(peerID) != Optional(badString)) #expect(Optional(peerID) != Optional(badString))
// String == PeerID // String == PeerID
XCTAssertTrue(string == peerID) #expect(string == peerID)
XCTAssertTrue(Optional(string) == peerID) #expect(Optional(string) == peerID)
XCTAssertTrue(string == Optional(peerID)) #expect(string == Optional(peerID))
XCTAssertTrue(Optional(string) == Optional(peerID)) #expect(Optional(string) == Optional(peerID))
// String != PeerID // String != PeerID
XCTAssertTrue(badString != peerID) #expect(badString != peerID)
XCTAssertTrue(Optional(badString) != peerID) #expect(Optional(badString) != peerID)
XCTAssertTrue(badString != Optional(peerID)) #expect(badString != Optional(peerID))
XCTAssertTrue(Optional(badString) != Optional(peerID)) #expect(Optional(badString) != Optional(peerID))
// Regular PeerID <> PeerID
#expect(peerID == PeerID(str: "aaa"))
#expect(peerID == Optional(PeerID(str: "aaa")))
#expect(PeerID(str: "aaa") == peerID)
#expect(Optional(PeerID(str: "aaa")) == Optional(peerID))
// Make sure the regular PeerID <> PeerID is not broken #expect(peerID != PeerID(str: "bbb"))
XCTAssertTrue(peerID == PeerID(str: "aaa")) #expect(peerID != Optional(PeerID(str: "bbb")))
XCTAssertTrue(peerID == Optional(PeerID(str: "aaa"))) #expect(PeerID(str: "bbb") != peerID)
XCTAssertTrue(PeerID(str: "aaa") == peerID) #expect(Optional(PeerID(str: "bbb")) != Optional(peerID))
XCTAssertTrue(Optional(PeerID(str: "aaa")) == Optional(peerID))
XCTAssertTrue(peerID != PeerID(str: "bbb"))
XCTAssertTrue(peerID != Optional(PeerID(str: "bbb")))
XCTAssertTrue(PeerID(str: "bbb") != peerID)
XCTAssertTrue(Optional(PeerID(str: "bbb")) != Optional(peerID))
} }
// MARK: - Computed properties // MARK: - Computed properties
func test_isEmpty_true_and_false() { @Test func isEmpty_true_and_false() {
XCTAssertTrue(PeerID(str: "").isEmpty) #expect(PeerID(str: "").isEmpty)
XCTAssertFalse(PeerID(str: "abc").isEmpty) #expect(!PeerID(str: "abc").isEmpty)
} }
func test_isGeoChat() { @Test func isGeoChat() {
XCTAssertTrue(PeerID(str: "nostr:abcdef").isGeoChat) #expect(PeerID(str: "nostr:abcdef").isGeoChat)
XCTAssertFalse(PeerID(str: "nostr_abcdef").isGeoChat) // different prefix #expect(!PeerID(str: "nostr_abcdef").isGeoChat)
} }
func test_isGeoDM() { @Test func isGeoDM() {
XCTAssertTrue(PeerID(str: "nostr_abcdef").isGeoDM) #expect(PeerID(str: "nostr_abcdef").isGeoDM)
XCTAssertFalse(PeerID(str: "nostr:abcdef").isGeoDM) #expect(!PeerID(str: "nostr:abcdef").isGeoDM)
} }
func test_toPercentEncoded() { @Test func toPercentEncoded() {
let peerID = PeerID(str: "name:some value/with spaces?") let peerID = PeerID(str: "name:some value/with spaces?")
let encoded = peerID.toPercentEncoded() let encoded = peerID.toPercentEncoded()
// spaces and ? should be percent-encoded in urlPathAllowed // spaces and ? should be percent-encoded in urlPathAllowed
XCTAssertEqual(encoded, "name%3Asome%20value/with%20spaces%3F") #expect(encoded == "name%3Asome%20value/with%20spaces%3F")
} }
// MARK: - Validation // MARK: - Validation
func test_accepts_short_hex_peer_id() { @Test func accepts_short_hex_peer_id() {
XCTAssertTrue(PeerID(str: "0011223344556677").isValid) #expect(PeerID(str: "0011223344556677").isValid)
XCTAssertTrue(PeerID(str: "aabbccddeeff0011").isValid) #expect(PeerID(str: "aabbccddeeff0011").isValid)
} }
func test_accepts_full_noise_key_hex() { @Test func accepts_full_noise_key_hex() {
let hex64 = String(repeating: "ab", count: 32) // 64 hex chars let hex64 = String(repeating: "ab", count: 32) // 64 hex chars
XCTAssertTrue(PeerID(str: hex64).isValid) #expect(PeerID(str: hex64).isValid)
} }
func test_accepts_internal_alnum_dash_underscore() { @Test func accepts_internal_alnum_dash_underscore() {
XCTAssertTrue(PeerID(str: "peer_123-ABC").isValid) #expect(PeerID(str: "peer_123-ABC").isValid)
XCTAssertTrue(PeerID(str: "nostr_user_01").isValid) #expect(PeerID(str: "nostr_user_01").isValid)
} }
func test_rejects_invalid_characters() { @Test func rejects_invalid_characters() {
XCTAssertFalse(PeerID(str: "peer!@#").isValid) #expect(!PeerID(str: "peer!@#").isValid)
XCTAssertFalse(PeerID(str: "gggggggggggggggg").isValid) // not hex for short form #expect(!PeerID(str: "gggggggggggggggg").isValid) // not hex for short form
} }
func test_rejects_too_long() { @Test func rejects_too_long() {
let tooLong = String(repeating: "a", count: 65) let tooLong = String(repeating: "a", count: 65)
XCTAssertFalse(PeerID(str: tooLong).isValid) #expect(!PeerID(str: tooLong).isValid)
} }
func test_isShort() { @Test func isShort() {
XCTAssertTrue(PeerID(str: hex16).isShort) #expect(PeerID(str: hex16).isShort)
XCTAssertFalse(PeerID(str: "abcd").isShort) // wrong length #expect(!PeerID(str: "abcd").isShort) // wrong length
} }
func test_isNoiseKeyHex_and_noiseKey() { @Test func isNoiseKeyHex_and_noiseKey() {
let hex64 = String(repeating: "ab", count: 32) // 64 chars valid hex let hex64 = String(repeating: "ab", count: 32) // 64 chars valid hex
let peerID = PeerID(str: hex64) let peerID = PeerID(str: hex64)
XCTAssertTrue(peerID.isNoiseKeyHex) #expect(peerID.isNoiseKeyHex)
XCTAssertNotNil(peerID.noiseKey) #expect(peerID.noiseKey != nil)
let prefixedPeerID = PeerID(str: "noise:" + hex64) let prefixedPeerID = PeerID(str: "noise:" + hex64)
XCTAssertTrue(prefixedPeerID.isNoiseKeyHex) #expect(prefixedPeerID.isNoiseKeyHex)
XCTAssertNotNil(prefixedPeerID.noiseKey) #expect(prefixedPeerID.noiseKey != nil)
let bad = String(repeating: "z", count: 64) // invalid hex let bad = String(repeating: "z", count: 64) // invalid hex
let badPeerID = PeerID(str: bad) let badPeerID = PeerID(str: bad)
XCTAssertFalse(badPeerID.isNoiseKeyHex) #expect(!badPeerID.isNoiseKeyHex)
XCTAssertNil(badPeerID.noiseKey) #expect(badPeerID.noiseKey == nil)
} }
func test_prefixes() { @Test func prefixes() {
let hex64 = String(repeating: "a", count: 64) let hex64 = String(repeating: "a", count: 64)
XCTAssertTrue(PeerID(str: "noise:\(hex64)").isValid) #expect(PeerID(str: "noise:\(hex64)").isValid)
XCTAssertTrue(PeerID(str: "nostr:\(hex64)").isValid) #expect(PeerID(str: "nostr:\(hex64)").isValid)
XCTAssertTrue(PeerID(str: "nostr_\(hex64)").isValid) #expect(PeerID(str: "nostr_\(hex64)").isValid)
let hex63 = String(repeating: "a", count: 63) let hex63 = String(repeating: "a", count: 63)
XCTAssertTrue(PeerID(str: "noise:\(hex63)").isValid) #expect(PeerID(str: "noise:\(hex63)").isValid)
XCTAssertTrue(PeerID(str: "nostr:\(hex63)").isValid) #expect(PeerID(str: "nostr:\(hex63)").isValid)
XCTAssertTrue(PeerID(str: "nostr_\(hex63)").isValid) #expect(PeerID(str: "nostr_\(hex63)").isValid)
let hex16 = String(repeating: "a", count: 16) let hex16 = String(repeating: "a", count: 16)
XCTAssertTrue(PeerID(str: "noise:\(hex16)").isValid) #expect(PeerID(str: "noise:\(hex16)").isValid)
XCTAssertTrue(PeerID(str: "nostr:\(hex16)").isValid) #expect(PeerID(str: "nostr:\(hex16)").isValid)
XCTAssertTrue(PeerID(str: "nostr_\(hex16)").isValid) #expect(PeerID(str: "nostr_\(hex16)").isValid)
let hex8 = String(repeating: "a", count: 8) let hex8 = String(repeating: "a", count: 8)
XCTAssertTrue(PeerID(str: "noise:\(hex8)").isValid) #expect(PeerID(str: "noise:\(hex8)").isValid)
XCTAssertTrue(PeerID(str: "nostr:\(hex8)").isValid) #expect(PeerID(str: "nostr:\(hex8)").isValid)
XCTAssertTrue(PeerID(str: "nostr_\(hex8)").isValid) #expect(PeerID(str: "nostr_\(hex8)").isValid)
let mesh = "mesh:abcdefg" let mesh = "mesh:abcdefg"
XCTAssertTrue(PeerID(str: "name:\(mesh)").isValid) #expect(PeerID(str: "name:\(mesh)").isValid)
let name = "name:some_name" let name = "name:some_name"
XCTAssertTrue(PeerID(str: "name:\(name)").isValid) #expect(PeerID(str: "name:\(name)").isValid)
let badName = "name:bad:name" let badName = "name:bad:name"
XCTAssertFalse(PeerID(str: "name:\(badName)").isValid) #expect(!PeerID(str: "name:\(badName)").isValid)
// Too long // Too long
let hex65 = String(repeating: "a", count: 65) let hex65 = String(repeating: "a", count: 65)
XCTAssertFalse(PeerID(str: "noise:\(hex65)").isValid) #expect(!PeerID(str: "noise:\(hex65)").isValid)
XCTAssertFalse(PeerID(str: "nostr:\(hex65)").isValid) #expect(!PeerID(str: "nostr:\(hex65)").isValid)
XCTAssertFalse(PeerID(str: "nostr_\(hex65)").isValid) #expect(!PeerID(str: "nostr_\(hex65)").isValid)
} }
} }
@@ -6,7 +6,9 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
#if canImport(os.log)
import os.log import os.log
#endif
public extension OSLog { public extension OSLog {
private static let subsystem = "chat.bitchat" private static let subsystem = "chat.bitchat"
@@ -7,7 +7,53 @@
// //
import Foundation import Foundation
#if canImport(os.log)
import os.log import os.log
#else
public struct OSLog {
public let subsystem: String
public let category: String
public init(subsystem: String, category: String) {
self.subsystem = subsystem
self.category = category
}
}
public struct OSLogType: CustomStringConvertible {
private let label: String
private init(_ label: String) {
self.label = label
}
public var description: String { label }
public static let debug = OSLogType("debug")
public static let info = OSLogType("info")
public static let `default` = OSLogType("default")
public static let error = OSLogType("error")
public static let fault = OSLogType("fault")
}
@usableFromInline
let secureLoggerFallbackFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
@usableFromInline
func os_log(_ message: StaticString, log: OSLog, type: OSLogType, _ args: CVarArg...) {
let rawFormat = String(describing: message)
let format = rawFormat
.replacingOccurrences(of: "%{public}@", with: "%@")
.replacingOccurrences(of: "%{private}@", with: "%@")
let formatted = String(format: format, arguments: args)
let timestamp = secureLoggerFallbackFormatter.string(from: Date())
print("[\(timestamp)] [\(log.subsystem)::\(log.category)] [\(type.description)] \(formatted)")
}
#endif
/// Centralized security-aware logging framework /// Centralized security-aware logging framework
/// Provides safe logging that filters sensitive data and security events /// Provides safe logging that filters sensitive data and security events
@@ -276,7 +322,7 @@ private extension SecureLogger {
} }
// Cache the result // Cache the result
cacheQueue.async(flags: .barrier) { cacheQueue.sync {
sanitizationCache.setObject(sanitized as NSString, forKey: key) sanitizationCache.setObject(sanitized as NSString, forKey: key)
} }
+15 -151
View File
@@ -1,11 +1,23 @@
import BitLogger import BitLogger
import Foundation import Foundation
#if canImport(Network)
import Network import Network
#endif
#if canImport(Darwin)
import Darwin import Darwin
#elseif canImport(Glibc)
import Glibc
#endif
// Declare C entrypoint for Tor when statically linked from an xcframework. #if !canImport(Network)
@_silgen_name("tor_main") private final class NWPathMonitor {
private func tor_main_c(_ argc: Int32, _ argv: UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>?) -> Int32 var pathUpdateHandler: ((Any) -> Void)?
func start(queue: DispatchQueue) {
// Path monitoring is unavailable on this platform; nothing to do.
}
}
#endif
// Preferred: tiny C glue that uses Tor's embedding API (tor_api.h) // Preferred: tiny C glue that uses Tor's embedding API (tor_api.h)
@_silgen_name("tor_host_start") @_silgen_name("tor_host_start")
@@ -286,150 +298,6 @@ public final class TorManager: ObservableObject {
} }
} }
// MARK: - Dynamic loader path (no Swift module required)
/// Attempt to locate an embedded tor framework binary and launch Tor via `tor_run_main`.
/// Returns true if the attempt started and port probing was scheduled.
private func startTorViaDlopen() -> Bool {
guard let fwURL = frameworkBinaryURL() else {
SecureLogger.warning("TorManager: no embedded tor framework found", category: .session)
return false
}
// Load the library
let mode = RTLD_NOW | RTLD_LOCAL
SecureLogger.info("TorManager: dlopen(\(fwURL.lastPathComponent))…", category: .session)
guard let handle = dlopen(fwURL.path, mode) else {
let err = String(cString: dlerror())
self.lastError = NSError(domain: "TorManager", code: -10, userInfo: [NSLocalizedDescriptionKey: "dlopen failed: \(err)"])
self.isStarting = false
return false
}
// Resolve tor_main(argc, argv)
typealias TorMainType = @convention(c) (Int32, UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>?) -> Int32
guard let sym = dlsym(handle, "tor_main") else {
// Keep handle open but report error
let err = String(cString: dlerror())
self.lastError = NSError(domain: "TorManager", code: -11, userInfo: [NSLocalizedDescriptionKey: "dlsym tor_main failed: \(err)"])
self.isStarting = false
return false
}
let torMain = unsafeBitCast(sym, to: TorMainType.self)
self._dlHandle = handle
// Prepare args: tor -f <torrc>
var argv: [String] = ["tor"]
if let torrc = torrcURL()?.path {
argv.append(contentsOf: ["-f", torrc])
}
// Run Tor on a background thread to avoid blocking the main actor
SecureLogger.info("TorManager: launching tor_main with torrc", category: .session)
let argc = Int32(argv.count)
DispatchQueue.global(qos: .utility).async {
// Build stable C argv in this thread
let cStrings: [UnsafeMutablePointer<CChar>?] = argv.map { strdup($0) }
let cArgv = UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>.allocate(capacity: cStrings.count + 1)
for i in 0..<cStrings.count { cArgv[i] = cStrings[i] }
cArgv[cStrings.count] = nil
_ = torMain(argc, cArgv)
// Free args after exit (Tor usually never returns)
for ptr in cStrings.compactMap({ $0 }) { free(ptr) }
cArgv.deallocate()
}
// Start control-port monitor and probe readiness asynchronously
startControlMonitorIfNeeded()
Task.detached(priority: .userInitiated) { [weak self] in
guard let self else { return }
let ready = await self.waitForSocksReady(timeout: 60.0)
await MainActor.run {
self.socksReady = ready
if !ready {
self.lastError = NSError(domain: "TorManager", code: -12, userInfo: [NSLocalizedDescriptionKey: "Tor SOCKS not reachable after dlopen start"])
SecureLogger.error("TorManager: SOCKS not reachable (timeout)", category: .session)
} else {
SecureLogger.info("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort)", category: .session)
}
// isStarting will be cleared when bootstrap reaches 100%
}
}
return true
}
private var _dlHandle: UnsafeMutableRawPointer?
private func frameworkBinaryURL() -> URL? {
// Try common embedded locations for the framework binary name
let candidates = [
"tor-nolzma.framework/tor-nolzma",
"Tor.framework/Tor",
]
if let base = Bundle.main.privateFrameworksURL {
for rel in candidates {
let url = base.appendingPathComponent(rel)
if FileManager.default.fileExists(atPath: url.path) { return url }
}
}
// For macOS apps, also try Contents/Frameworks explicitly
#if os(macOS)
if let appURL = Bundle.main.bundleURL as URL?,
let frameworksURL = Optional(appURL.appendingPathComponent("Contents/Frameworks", isDirectory: true)) {
for rel in candidates {
let url = frameworksURL.appendingPathComponent(rel)
if FileManager.default.fileExists(atPath: url.path) { return url }
}
}
#endif
return nil
}
// MARK: - Static-link path (no module import)
private func startTorViaLinkedSymbol() -> Bool {
// Attempt to start tor_run_main directly (statically linked). If the
// symbol is not present at link-time, builds will fail which is
// expected when the xcframework is absent.
var argv: [String] = ["tor"]
if let torrc = torrcURL()?.path { argv.append(contentsOf: ["-f", torrc]) }
SecureLogger.info("TorManager: starting tor_main (static)", category: .session)
let argc = Int32(argv.count)
DispatchQueue.global(qos: .utility).async {
// Build stable C argv in this thread
let cStrings: [UnsafeMutablePointer<CChar>?] = argv.map { strdup($0) }
let cArgv = UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>.allocate(capacity: cStrings.count + 1)
for i in 0..<cStrings.count { cArgv[i] = cStrings[i] }
cArgv[cStrings.count] = nil
_ = tor_main_c(argc, cArgv)
// If tor_main ever returns, free memory
for ptr in cStrings.compactMap({ $0 }) { free(ptr) }
cArgv.deallocate()
}
// Start control monitor early
startControlMonitorIfNeeded()
Task.detached(priority: .userInitiated) { [weak self] in
guard let self else { return }
let ready = await self.waitForSocksReady(timeout: 60.0)
await MainActor.run {
self.socksReady = ready
if ready {
SecureLogger.info("TorManager: SOCKS ready at \(self.socksHost):\(self.socksPort)", category: .session)
} else {
self.lastError = NSError(domain: "TorManager", code: -13, userInfo: [NSLocalizedDescriptionKey: "Tor SOCKS not reachable after static start"])
SecureLogger.error("TorManager: SOCKS not reachable (timeout)", category: .session)
}
// isStarting will be cleared when bootstrap reaches 100%
}
}
return true
}
// MARK: - ControlPort monitoring (bootstrap progress) // MARK: - ControlPort monitoring (bootstrap progress)
private func startControlMonitorIfNeeded() { private func startControlMonitorIfNeeded() {
guard !controlMonitorStarted else { return } guard !controlMonitorStarted else { return }
@@ -440,10 +308,6 @@ public final class TorManager: ObservableObject {
} }
} }
private func controlMonitorLoop() async {}
private func tryControlSessionOnce() async -> Bool { false }
// iOS: Poll GETINFO periodically to track bootstrap progress without long-lived control readers. // iOS: Poll GETINFO periodically to track bootstrap progress without long-lived control readers.
private func bootstrapPollLoop() async { private func bootstrapPollLoop() async {
let deadline = Date().addingTimeInterval(75) let deadline = Date().addingTimeInterval(75)