Compare commits

..
Author SHA1 Message Date
jack e868e3bbfb Fix BLE queue usage and tighten actor isolation 2025-10-11 19:49:40 +02:00
82 changed files with 4151 additions and 3699 deletions
@@ -0,0 +1,12 @@
---
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/Swift.swiftmodule/arm64e-apple-macos.swiftmodule'
dependencies:
- mtime: 1757258659000000000
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/Swift.swiftmodule/arm64e-apple-macos.swiftmodule'
size: 14166264
- mtime: 1754189697000000000
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
size: 2261306
sdk_relative: true
version: 1
...
@@ -0,0 +1,16 @@
---
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/SwiftOnoneSupport.swiftmodule/arm64e-apple-macos.swiftmodule'
dependencies:
- mtime: 1757258662000000000
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/SwiftOnoneSupport.swiftmodule/arm64e-apple-macos.swiftmodule'
size: 18068
- mtime: 1754189697000000000
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
size: 2261306
sdk_relative: true
- mtime: 1754191141000000000
path: 'usr/lib/swift/SwiftOnoneSupport.swiftmodule/arm64e-apple-macos.swiftinterface'
size: 1224
sdk_relative: true
version: 1
...
@@ -0,0 +1,16 @@
---
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_Concurrency.swiftmodule/arm64e-apple-macos.swiftmodule'
dependencies:
- mtime: 1757258669000000000
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_Concurrency.swiftmodule/arm64e-apple-macos.swiftmodule'
size: 699544
- mtime: 1754189697000000000
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
size: 2261306
sdk_relative: true
- mtime: 1754192470000000000
path: 'usr/lib/swift/_Concurrency.swiftmodule/arm64e-apple-macos.swiftinterface'
size: 364219
sdk_relative: true
version: 1
...
@@ -0,0 +1,16 @@
---
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_StringProcessing.swiftmodule/arm64e-apple-macos.swiftmodule'
dependencies:
- mtime: 1757258664000000000
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_StringProcessing.swiftmodule/arm64e-apple-macos.swiftmodule'
size: 83568
- mtime: 1754189697000000000
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
size: 2261306
sdk_relative: true
- mtime: 1754192532000000000
path: 'usr/lib/swift/_StringProcessing.swiftmodule/arm64e-apple-macos.swiftinterface'
size: 24507
sdk_relative: true
version: 1
...
-3
View File
@@ -66,9 +66,6 @@ __pycache__/
*.tmp *.tmp
*.temp *.temp
## Cache
.cache/
# Local build results # Local build results
.Result*/ .Result*/
.Result*.xcresult/ .Result*.xcresult/
+2 -5
View File
@@ -14,11 +14,8 @@ default:
# Check prerequisites # Check prerequisites
check: check:
@echo "Checking prerequisites..." @echo "Checking prerequisites..."
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install Xcode from App Store" && exit 1) @command -v xcodebuild >/dev/null 2>&1 || (echo "❌ Xcode not found. Install Xcode from App Store" && exit 1)
@xcode-select -p | grep -q "Xcode.app" || (echo "❌ Full Xcode required, not just command line tools. Install from App Store and run:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1) @security find-identity -v -p codesigning | grep -q "Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
@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,7 +312,6 @@
ne, ne,
"pt-BR", "pt-BR",
ru, ru,
tr,
uk, uk,
"zh-Hans", "zh-Hans",
); );
@@ -13,6 +13,7 @@
"value" : "dark" "value" : "dark"
} }
], ],
"filename" : "image-1024 1.png",
"idiom" : "universal", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"
@@ -24,6 +25,7 @@
"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.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 85 KiB

+22 -25
View File
@@ -26,15 +26,11 @@ 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)
) )
) )
@@ -53,9 +49,10 @@ struct BitchatApp: App {
// Inject live Noise service into VerificationService to avoid creating new BLE instances // Inject live Noise service into VerificationService to avoid creating new BLE instances
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService()) VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
// Prewarm Nostr identity and QR to make first VERIFY sheet fast // Prewarm Nostr identity and QR to make first VERIFY sheet fast
let nickname = chatViewModel.nickname
DispatchQueue.global(qos: .utility).async { DispatchQueue.global(qos: .utility).async {
let npub = try? idBridge.getCurrentNostrIdentity()?.npub let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub) _ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub)
} }
#if os(iOS) #if os(iOS)
appDelegate.chatViewModel = chatViewModel appDelegate.chatViewModel = chatViewModel
@@ -221,7 +218,7 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
// Get peer ID from userInfo // Get peer ID from userInfo
if let peerID = userInfo["peerID"] as? String { if let peerID = userInfo["peerID"] as? String {
DispatchQueue.main.async { DispatchQueue.main.async {
self.chatViewModel?.startPrivateChat(with: PeerID(str: peerID)) self.chatViewModel?.startPrivateChat(with: peerID)
} }
} }
} }
@@ -241,29 +238,29 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
let identifier = notification.request.identifier let identifier = notification.request.identifier
let userInfo = notification.request.content.userInfo let userInfo = notification.request.content.userInfo
// Check if this is a private message notification Task { @MainActor [weak self] in
if identifier.hasPrefix("private-") { guard let self = self else {
// Get peer ID from userInfo completionHandler([.banner, .sound])
if let peerID = userInfo["peerID"] as? String { return
// Don't show notification if the private chat is already open
if chatViewModel?.selectedPrivateChatPeer == peerID {
completionHandler([])
return
}
} }
} // Check if this is a private message notification and chat already open
// Suppress geohash activity notification if we're already in that geohash channel if identifier.hasPrefix("private-"),
if identifier.hasPrefix("geo-activity-"), let peerID = userInfo["peerID"] as? String,
let deep = userInfo["deeplink"] as? String, self.chatViewModel?.selectedPrivateChatPeer == peerID {
let gh = deep.components(separatedBy: "/").last {
if case .location(let ch) = LocationChannelManager.shared.selectedChannel, ch.geohash == gh {
completionHandler([]) completionHandler([])
return return
} }
// Suppress geohash activity notification if we're already in that geohash channel
if identifier.hasPrefix("geo-activity-"),
let deep = userInfo["deeplink"] as? String,
let gh = deep.components(separatedBy: "/").last,
case .location(let ch) = LocationChannelManager.shared.selectedChannel,
ch.geohash == gh {
completionHandler([])
return
}
completionHandler([.banner, .sound])
} }
// Show notification in all other cases
completionHandler([.banner, .sound])
} }
} }
+1 -35
View File
@@ -177,41 +177,7 @@
"state": "translated", "state": "translated",
"value": "bitchat" "value": "bitchat"
} }
} }
}
},
"%@ 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": {
+1 -6
View File
@@ -161,14 +161,9 @@ 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 && isHex bare.count == Constants.hexIDLength && Data(hexString: bare) != nil
} }
/// Full Noise key hex (exact 64-hex) /// Full Noise key hex (exact 64-hex)
-95
View File
@@ -1,95 +0,0 @@
//
// 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()
}
}
}
@@ -0,0 +1,227 @@
//
// 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
}
@@ -1,37 +0,0 @@
//
// 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
@@ -1,18 +0,0 @@
//
// 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
}
@@ -1,22 +0,0 @@
//
// 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,6 +196,12 @@ 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
+2 -1
View File
@@ -6,9 +6,10 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
enum NoiseSessionError: Error, Equatable { enum NoiseSessionError: Error {
case invalidState case invalidState
case notEstablished case notEstablished
case sessionNotFound case sessionNotFound
case handshakeFailed(Error)
case alreadyEstablished case alreadyEstablished
} }
+30 -2
View File
@@ -27,6 +27,19 @@ 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]
@@ -35,9 +48,14 @@ 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.removeValue(forKey: peerID) { if let session = sessions[peerID] {
session.reset() // Clear sensitive data before removing if session.isEstablished() {
SecureLogger.info(.sessionExpired(peerID: peerID.id))
}
// Clear sensitive data before removing
session.reset()
} }
_ = sessions.removeValue(forKey: peerID)
} }
} }
@@ -50,6 +68,12 @@ 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 {
@@ -183,6 +207,10 @@ 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
@@ -1,81 +0,0 @@
//
// 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
@@ -1,135 +0,0 @@
import Foundation
/// Bech32 encoding for Nostr (minimal implementation)
enum Bech32 {
private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
static func encode(hrp: String, data: Data) throws -> String {
let values = convertBits(from: 8, to: 5, pad: true, data: Array(data))
let checksum = createChecksum(hrp: hrp, values: values)
let combined = values + checksum
return hrp + "1" + combined.map {
let index = charset.index(charset.startIndex, offsetBy: Int($0))
return String(charset[index])
}.joined()
}
static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {
// Find the last occurrence of '1'
guard let separatorIndex = bech32String.lastIndex(of: "1") else {
throw Bech32Error.invalidFormat
}
let hrp = String(bech32String[..<separatorIndex])
// Validate HRP contains only ASCII characters
for char in hrp {
guard char.asciiValue != nil else {
throw Bech32Error.invalidCharacter
}
}
let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])
// Convert characters to values
var values = [UInt8]()
for char in dataString {
guard let index = charset.firstIndex(of: char) else {
throw Bech32Error.invalidCharacter
}
values.append(UInt8(charset.distance(from: charset.startIndex, to: index)))
}
// Verify checksum
guard values.count >= 6 else {
throw Bech32Error.invalidChecksum
}
let payloadValues = Array(values.dropLast(6))
let checksum = Array(values.suffix(6))
let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues)
guard checksum == expectedChecksum else {
throw Bech32Error.invalidChecksum
}
// Convert back to bytes
let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues)
return (hrp: hrp, data: Data(bytes))
}
enum Bech32Error: Error {
case invalidFormat
case invalidCharacter
case invalidChecksum
}
private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] {
var acc = 0
var bits = 0
var result = [UInt8]()
let maxv = (1 << to) - 1
for value in data {
acc = (acc << from) | Int(value)
bits += from
while bits >= to {
bits -= to
result.append(UInt8((acc >> bits) & maxv))
}
}
if pad && bits > 0 {
result.append(UInt8((acc << (to - bits)) & maxv))
}
return result
}
private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] {
let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0]
let polymod = polymod(checksumValues) ^ 1
var checksum = [UInt8]()
for i in 0..<6 {
checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31))
}
return checksum
}
private static func hrpExpand(_ hrp: String) -> [UInt8] {
var result = [UInt8]()
for c in hrp {
guard let asciiValue = c.asciiValue else {
return [] // Return empty array for invalid input
}
result.append(UInt8(asciiValue >> 5))
}
result.append(0)
for c in hrp {
guard let asciiValue = c.asciiValue else {
return [] // Return empty array for invalid input
}
result.append(UInt8(asciiValue & 31))
}
return result
}
private static func polymod(_ values: [UInt8]) -> Int {
var chk = 1
for value in values {
let b = chk >> 25
chk = (chk & 0x1ffffff) << 5 ^ Int(value)
for i in 0..<5 {
if (b >> i) & 1 == 1 {
chk ^= generator[i]
}
}
}
return chk
}
}
+6 -25
View File
@@ -33,32 +33,13 @@ final class GeoRelayDirectory {
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate. /// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] { func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
guard !entries.isEmpty, count > 0 else { return [] } guard !entries.isEmpty else { return [] }
let sorted = entries
if entries.count <= count { .sorted { a, b in
return entries haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
.sorted { a, b in
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
}
.map { "wss://\($0.host)" }
}
var best: [(entry: Entry, distance: Double)] = []
best.reserveCapacity(count)
for entry in entries {
let distance = haversineKm(lat, lon, entry.lat, entry.lon)
if best.count < count {
let idx = best.firstIndex { $0.distance > distance } ?? best.count
best.insert((entry, distance), at: idx)
} else if let worstDistance = best.last?.distance, distance < worstDistance {
let idx = best.firstIndex { $0.distance > distance } ?? best.count
best.insert((entry, distance), at: idx)
best.removeLast()
} }
} .prefix(count)
return sorted.map { "wss://\($0.host)" }
return best.map { "wss://\($0.entry.host)" }
} }
// MARK: - Remote Fetch // MARK: - Remote Fetch
-50
View File
@@ -1,50 +0,0 @@
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,5 +1,50 @@
import Foundation import Foundation
import CryptoKit
import P256K import P256K
import Security
// Keychain helper for secure storage
struct KeychainHelper {
static func save(key: String, data: Data, service: String, accessible: CFString? = nil) {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data
]
if let accessible = accessible {
query[kSecAttrAccessible as String] = accessible
}
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
}
static func load(key: String, service: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else { return nil }
return result as? Data
}
static func delete(key: String, service: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
}
}
/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging /// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
struct NostrIdentity: Codable { struct NostrIdentity: Codable {
@@ -58,3 +103,266 @@ 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
@@ -1,135 +0,0 @@
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,16 +906,6 @@ 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
+2
View File
@@ -160,6 +160,7 @@ enum DeliveryStatus: Codable, Equatable, Hashable {
// MARK: - Delegate Protocol // MARK: - Delegate Protocol
@MainActor
protocol BitchatDelegate: AnyObject { protocol BitchatDelegate: AnyObject {
func didReceiveMessage(_ message: BitchatMessage) func didReceiveMessage(_ message: BitchatMessage)
func didConnectToPeer(_ peerID: PeerID) func didConnectToPeer(_ peerID: PeerID)
@@ -180,6 +181,7 @@ protocol BitchatDelegate: AnyObject {
} }
// Provide default implementation to make it effectively optional // Provide default implementation to make it effectively optional
@MainActor
extension BitchatDelegate { extension BitchatDelegate {
func isFavorite(fingerprint: String) -> Bool { func isFavorite(fingerprint: String) -> Bool {
return false return false
-53
View File
@@ -119,57 +119,4 @@ 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)
}
}
} }
+304 -296
View File
@@ -94,7 +94,6 @@ 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
@@ -199,13 +198,8 @@ final class BLEService: NSObject {
// MARK: - Initialization // MARK: - Initialization
init( init(keychain: KeychainManagerProtocol, identityManager: SecureIdentityStateManagerProtocol) {
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()
@@ -321,49 +315,48 @@ final class BLEService: NSObject {
startServices() startServices()
} }
// 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) { func sendMessage(_ content: String, mentions: [String] = [], to recipientID: String? = nil, messageID: String? = nil, timestamp: Date? = nil) {
// Call directly if already on messageQueue, otherwise dispatch // Ensure this runs on message queue to avoid main thread blocking
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)
guard content.count <= self.maxMessageLength else {
SecureLogger.error("Message too long: \(content.count) chars", category: .session)
return
} }
return
}
guard content.count <= maxMessageLength else { let finalMessageID = messageID ?? UUID().uuidString
SecureLogger.error("Message too long: \(content.count) chars", category: .session) let _ = UInt64(Date().timeIntervalSince1970 * 1000)
return
}
if let recipientID { if let recipientID = recipientID {
sendPrivateMessage(content, to: recipientID, messageID: messageID ?? UUID().uuidString) // Private message
return self.sendPrivateMessage(content, to: recipientID, messageID: finalMessageID)
} else {
// Public broadcast
// Create packet with explicit fields so we can sign it
let basePacket = BitchatPacket(
type: MessageType.message.rawValue,
senderID: Data(hexString: self.myPeerID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: Data(content.utf8),
signature: nil,
ttl: self.messageTTL
)
guard let signedPacket = self.noiseService.signPacket(basePacket) else {
SecureLogger.error("❌ Failed to sign public message", category: .security)
return
}
// Pre-mark our own broadcast as processed to avoid handling relayed self copy
let senderHex = signedPacket.senderID.hexEncodedString()
let dedupID = "\(senderHex)-\(signedPacket.timestamp)-\(signedPacket.type)"
self.messageDeduplicator.markProcessed(dedupID)
// Call synchronously since we're already on background queue
self.broadcastPacket(signedPacket)
// Track our own broadcast for sync
self.gossipSyncManager?.onPublicPacketSeen(signedPacket)
}
} }
// Public broadcast
// Create packet with explicit fields so we can sign it
let basePacket = BitchatPacket(
type: MessageType.message.rawValue,
senderID: Data(hexString: myPeerID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: Data(content.utf8),
signature: nil,
ttl: messageTTL
)
guard let signedPacket = noiseService.signPacket(basePacket) else {
SecureLogger.error("❌ Failed to sign public message", category: .security)
return
}
// Pre-mark our own broadcast as processed to avoid handling relayed self copy
let senderHex = signedPacket.senderID.hexEncodedString()
let dedupID = "\(senderHex)-\(signedPacket.timestamp)-\(signedPacket.type)"
messageDeduplicator.markProcessed(dedupID)
// Call synchronously since we're already on background queue
broadcastPacket(signedPacket)
// Track our own broadcast for sync
gossipSyncManager?.onPublicPacketSeen(signedPacket)
} }
// MARK: - Transport Protocol Conformance // MARK: - Transport Protocol Conformance
@@ -413,23 +406,35 @@ final class BLEService: NSObject {
// MARK: Lifecycle // MARK: Lifecycle
func startServices() { func startServices() {
// Start BLE services if not already running performOnBLEQueue { service in
if centralManager?.state == .poweredOn { // Start BLE services if not already running
centralManager?.scanForPeripherals( if service.centralManager?.state == .poweredOn {
withServices: [BLEService.serviceUUID], service.centralManager?.scanForPeripherals(
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false] withServices: [BLEService.serviceUUID],
) options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
)
}
} }
// Send initial announce after services are ready // Send initial announce after services are ready.
// Use longer delay to avoid conflicts with other announces
messageQueue.asyncAfter(deadline: .now() + TransportConfig.bleInitialAnnounceDelaySeconds) { [weak self] in messageQueue.asyncAfter(deadline: .now() + TransportConfig.bleInitialAnnounceDelaySeconds) { [weak self] in
self?.sendAnnounce(forceSend: true) self?.sendAnnounce(forceSend: true)
} }
} }
func stopServices() { func stopServices() {
// Send leave message synchronously to ensure delivery if DispatchQueue.getSpecific(key: bleQueueKey) != nil {
stopServicesOnBLEQueue()
} else {
bleQueue.sync {
self.stopServicesOnBLEQueue()
}
}
}
private func stopServicesOnBLEQueue() {
assertOnBLEQueue()
// Send a final leave message to connected peers before shutting down.
let leavePacket = BitchatPacket( let leavePacket = BitchatPacket(
type: MessageType.leave.rawValue, type: MessageType.leave.rawValue,
senderID: myPeerIDData, senderID: myPeerIDData,
@@ -440,30 +445,28 @@ final class BLEService: NSObject {
ttl: messageTTL ttl: messageTTL
) )
// Send immediately to all connected peers
if let data = leavePacket.toBinaryData(padding: false) { if let data = leavePacket.toBinaryData(padding: false) {
// Send to peripherals we're connected to as central
for state in peripherals.values where state.isConnected { for state in peripherals.values where state.isConnected {
if let characteristic = state.characteristic { if let characteristic = state.characteristic {
writeOrEnqueue(data, to: state.peripheral, characteristic: characteristic) writeOrEnqueue(data, to: state.peripheral, characteristic: characteristic)
} }
} }
if !subscribedCentrals.isEmpty, let characteristic = characteristic {
// Send to centrals subscribed to us as peripheral _ = peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: nil)
if subscribedCentrals.count > 0 && characteristic != nil {
peripheralManager?.updateValue(data, for: characteristic!, onSubscribedCentrals: nil)
} }
} }
// Give leave message a moment to send let delay = TransportConfig.bleThreadSleepWriteShortDelaySeconds
Thread.sleep(forTimeInterval: TransportConfig.bleThreadSleepWriteShortDelaySeconds) if delay > 0 {
Thread.sleep(forTimeInterval: delay)
}
// Clear pending notifications
collectionsQueue.sync(flags: .barrier) { collectionsQueue.sync(flags: .barrier) {
pendingNotifications.removeAll() pendingNotifications.removeAll()
pendingPeripheralWrites.removeAll()
pendingDirectedRelays.removeAll()
} }
// Stop timer
maintenanceTimer?.cancel() maintenanceTimer?.cancel()
maintenanceTimer = nil maintenanceTimer = nil
scanDutyTimer?.cancel() scanDutyTimer?.cancel()
@@ -472,10 +475,14 @@ final class BLEService: NSObject {
centralManager?.stopScan() centralManager?.stopScan()
peripheralManager?.stopAdvertising() peripheralManager?.stopAdvertising()
// Disconnect all peripherals
for state in peripherals.values { for state in peripherals.values {
centralManager?.cancelPeripheralConnection(state.peripheral) centralManager?.cancelPeripheralConnection(state.peripheral)
} }
peripherals.removeAll()
peerToPeripheralUUID.removeAll()
subscribedCentrals.removeAll()
centralToPeerID.removeAll()
} }
func emergencyDisconnectAll() { func emergencyDisconnectAll() {
@@ -490,12 +497,6 @@ final class BLEService: NSObject {
// Clear processed messages // Clear processed messages
messageDeduplicator.reset() messageDeduplicator.reset()
// Clear peripheral references
peripherals.removeAll()
peerToPeripheralUUID.removeAll()
subscribedCentrals.removeAll()
centralToPeerID.removeAll()
} }
// MARK: Connectivity and peers // MARK: Connectivity and peers
@@ -597,7 +598,11 @@ final class BLEService: NSObject {
signature: nil, signature: nil,
ttl: messageTTL ttl: messageTTL
) )
broadcastPacket(packet) if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
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)")
} }
@@ -619,7 +624,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? idBridge.getCurrentNostrIdentity() { if let myNostrIdentity = try? NostrIdentityBridge.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)
} }
@@ -680,11 +685,19 @@ 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) {
broadcastPacket(packet) if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
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) {
sendPacketDirected(packet, to: peerID) if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
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 {
@@ -697,8 +710,9 @@ extension BLEService: GossipSyncManager.Delegate {
extension BLEService: CBCentralManagerDelegate { extension BLEService: CBCentralManagerDelegate {
func centralManagerDidUpdateState(_ central: CBCentralManager) { func centralManagerDidUpdateState(_ central: CBCentralManager) {
// Notify delegate about state change on main thread // Notify delegate about state change on main thread
Task { @MainActor in let state = central.state
self.delegate?.didUpdateBluetoothState(central.state) notifyUI { service in
service.delegate?.didUpdateBluetoothState(state)
} }
if central.state == .poweredOn { if central.state == .poweredOn {
@@ -928,17 +942,13 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
bleQueue.async { [weak self] in self?.tryConnectFromQueue() } bleQueue.async { [weak self] in self?.tryConnectFromQueue() }
// Notify delegate about disconnection on main thread (direct link dropped) // Notify delegate about disconnection on main thread (direct link dropped)
notifyUI { [weak self] in notifyUI { service in
guard let self = self else { return } let currentPeerIDs = service.collectionsQueue.sync { service.currentPeerIDs }
// Get current peer list (after removal)
let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs }
if let peerID { if let peerID {
self.notifyPeerDisconnectedDebounced(peerID) service.notifyPeerDisconnectedDebounced(peerID)
} }
self.requestPeerDataPublish() service.requestPeerDataPublish()
self.delegate?.didUpdatePeerList(currentPeerIDs) service.delegate?.didUpdatePeerList(currentPeerIDs)
} }
} }
@@ -1051,7 +1061,13 @@ extension BLEService {
peers[normalizedID] = p peers[normalizedID] = p
} }
} }
handleReceivedPacket(packet, from: fromPeerID) if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
handleReceivedPacket(packet, from: fromPeerID)
} else {
messageQueue.async { [weak self] in
self?.handleReceivedPacket(packet, from: fromPeerID)
}
}
} }
} }
#endif #endif
@@ -1343,16 +1359,11 @@ extension BLEService: CBPeripheralManagerDelegate {
centralToPeerID.removeValue(forKey: centralUUID) centralToPeerID.removeValue(forKey: centralUUID)
// Update UI immediately // Update UI immediately
notifyUI { [weak self] in notifyUI { service in
guard let self = self else { return } let currentPeerIDs = service.collectionsQueue.sync { service.currentPeerIDs }
service.notifyPeerDisconnectedDebounced(peerID)
// Get current peer list (after removal) service.requestPeerDataPublish()
let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs } service.delegate?.didUpdatePeerList(currentPeerIDs)
self.notifyPeerDisconnectedDebounced(peerID)
// Publish snapshots so UnifiedPeerService can refresh icons promptly
self.requestPeerDataPublish()
self.delegate?.didUpdatePeerList(currentPeerIDs)
} }
} }
} }
@@ -1506,30 +1517,30 @@ extension BLEService {
extension BLEService { extension BLEService {
/// Notify UI on the MainActor to satisfy Swift concurrency isolation /// Hop to the MainActor and hand `self` to the caller for UI delegate updates.
private func notifyUI(_ block: @escaping () -> Void) { private func notifyUI(_ block: @MainActor @escaping (BLEService) -> Void) {
// Always hop onto the MainActor so calls to @MainActor delegates are safe Task { @MainActor [weak self] in
Task { @MainActor in guard let self = self else { return }
block() block(self)
} }
} }
/// Safely fetch the current direct-link state for a peer using the BLE queue. /// Ensure work that touches CoreBluetooth objects executes on `bleQueue`
private func linkState(for peerID: PeerID) -> (hasPeripheral: Bool, hasCentral: Bool) { private func performOnBLEQueue(_ work: @escaping (BLEService) -> Void) {
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 { if DispatchQueue.getSpecific(key: bleQueueKey) != nil {
return computeState() work(self)
} else { } else {
return bleQueue.sync { computeState() } bleQueue.async { [weak self] in
guard let self = self else { return }
work(self)
}
} }
} }
private func assertOnBLEQueue(file: StaticString = #file, line: UInt = #line) {
assert(DispatchQueue.getSpecific(key: bleQueueKey) != nil, "BLE queue required", file: file, line: line)
}
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)
@@ -1575,7 +1586,11 @@ extension BLEService {
signature: nil, signature: nil,
ttl: messageTTL ttl: messageTTL
) )
broadcastPacket(packet) if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
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)")
} }
@@ -1635,42 +1650,13 @@ extension BLEService {
} }
private func writeOrEnqueue(_ data: Data, to peripheral: CBPeripheral, characteristic: CBCharacteristic) { private func writeOrEnqueue(_ data: Data, to peripheral: CBPeripheral, characteristic: CBCharacteristic) {
// BLE operations run on bleQueue; keep queue affinity performOnBLEQueue { service in
bleQueue.async { [weak self] in service.writeOrEnqueueOnBLEQueue(data, to: peripheral, characteristic: characteristic)
guard let self = self else { return }
let uuid = peripheral.identifier.uuidString
if peripheral.canSendWriteWithoutResponse {
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
} else {
self.collectionsQueue.async(flags: .barrier) {
var queue = self.pendingPeripheralWrites[uuid] ?? []
let capBytes = TransportConfig.blePendingWriteBufferCapBytes
let newSize = data.count
// If single chunk exceeds cap, drop it immediately
if newSize > capBytes {
SecureLogger.warning("⚠️ Dropping oversized write chunk (\(newSize)B) for peripheral \(uuid)", category: .session)
} else {
// Append and trim from the front to respect cap
var total = queue.reduce(0) { $0 + $1.count }
queue.append(data)
total += newSize
if total > capBytes {
var removedBytes = 0
while total > capBytes && !queue.isEmpty {
let removed = queue.removeFirst()
removedBytes += removed.count
total -= removed.count
}
SecureLogger.warning("📉 Trimmed pending write buffer for \(uuid) by \(removedBytes)B to \(total)B", category: .session)
}
self.pendingPeripheralWrites[uuid] = queue.isEmpty ? nil : queue
}
}
}
} }
} }
private func drainPendingWrites(for peripheral: CBPeripheral) { private func drainPendingWrites(for peripheral: CBPeripheral) {
assertOnBLEQueue()
let uuid = peripheral.identifier.uuidString let uuid = peripheral.identifier.uuidString
bleQueue.async { [weak self] in bleQueue.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
@@ -1703,6 +1689,38 @@ extension BLEService {
} }
} }
private func writeOrEnqueueOnBLEQueue(_ data: Data, to peripheral: CBPeripheral, characteristic: CBCharacteristic) {
let uuid = peripheral.identifier.uuidString
if peripheral.canSendWriteWithoutResponse {
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
return
}
collectionsQueue.async(flags: .barrier) {
var queue = self.pendingPeripheralWrites[uuid] ?? []
let capBytes = TransportConfig.blePendingWriteBufferCapBytes
let newSize = data.count
if newSize > capBytes {
SecureLogger.warning("⚠️ Dropping oversized write chunk (\(newSize)B) for peripheral \(uuid)", category: .session)
return
}
var total = queue.reduce(0) { $0 + $1.count }
queue.append(data)
total += newSize
if total > capBytes {
var removedBytes = 0
while total > capBytes && !queue.isEmpty {
let removed = queue.removeFirst()
removedBytes += removed.count
total -= removed.count
}
SecureLogger.warning("📉 Trimmed pending write buffer for \(uuid) by \(removedBytes)B to \(total)B", category: .session)
}
self.pendingPeripheralWrites[uuid] = queue.isEmpty ? nil : queue
}
}
// MARK: Application State Handlers (iOS) // MARK: Application State Handlers (iOS)
#if os(iOS) #if os(iOS)
@@ -1766,21 +1784,27 @@ extension BLEService {
} }
} }
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue, type: MessageType.noiseEncrypted.rawValue,
senderID: myPeerIDData, senderID: myPeerIDData,
recipientID: recipientData, recipientID: recipientData,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: encrypted, payload: encrypted,
signature: nil, signature: nil,
ttl: messageTTL ttl: messageTTL
) )
// Call directly if already on messageQueue, otherwise dispatch
broadcastPacket(packet) if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
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 { service in
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent) service.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent)
} }
} catch { } catch {
SecureLogger.error("Failed to encrypt message: \(error)") SecureLogger.error("Failed to encrypt message: \(error)")
@@ -1800,8 +1824,8 @@ extension BLEService {
initiateNoiseHandshake(with: recipientID) initiateNoiseHandshake(with: recipientID)
// Notify delegate that message is pending // Notify delegate that message is pending
notifyUI { [weak self] in notifyUI { service in
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sending) service.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sending)
} }
} }
} }
@@ -1823,7 +1847,14 @@ extension BLEService {
signature: nil, signature: nil,
ttl: messageTTL ttl: messageTTL
) )
broadcastPacket(packet) // Call directly if on messageQueue, otherwise dispatch
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
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)")
} }
@@ -1870,8 +1901,8 @@ extension BLEService {
broadcastPacket(packet) broadcastPacket(packet)
// Notify delegate that message was sent // Notify delegate that message was sent
notifyUI { [weak self] in notifyUI { service in
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent) service.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent)
} }
SecureLogger.debug("✅ Sent pending message \(messageID) to \(peerID) after handshake", category: .session) SecureLogger.debug("✅ Sent pending message \(messageID) to \(peerID) after handshake", category: .session)
@@ -1879,8 +1910,8 @@ extension BLEService {
SecureLogger.error("Failed to send pending message after handshake: \(error)") SecureLogger.error("Failed to send pending message after handshake: \(error)")
// Notify delegate of failure // Notify delegate of failure
notifyUI { [weak self] in notifyUI { service in
self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .failed(reason: "Encryption failed")) service.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .failed(reason: "Encryption failed"))
} }
} }
} }
@@ -1889,25 +1920,19 @@ 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 {
SecureLogger.error("❌ Failed to convert packet to binary data", category: .session) SecureLogger.error("❌ Failed to convert packet to binary data", category: .session)
return return
} }
if packet.type == MessageType.noiseEncrypted.rawValue { performOnBLEQueue { service in
sendEncrypted(packet, data: data, pad: padForBLE) if packet.type == MessageType.noiseEncrypted.rawValue {
return service.sendEncrypted(packet, data: data, pad: padForBLE)
} else {
service.sendGenericBroadcast(packet, data: data, pad: padForBLE)
}
} }
sendGenericBroadcast(packet, data: data, pad: padForBLE)
} }
// MARK: Broadcast helpers (single responsibility) // MARK: Broadcast helpers (single responsibility)
@@ -2087,14 +2112,6 @@ 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)
} }
@@ -2129,8 +2146,9 @@ extension BLEService {
} }
return out return out
} }
guard !toSend.isEmpty else { return }
for (_, packet) in toSend { for (_, packet) in toSend {
broadcastPacket(packet) messageQueue.async { [weak self] in self?.broadcastPacket(packet) }
} }
} }
@@ -2149,6 +2167,19 @@ 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) {
@@ -2209,16 +2240,6 @@ 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,14 +2309,6 @@ 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
@@ -2461,15 +2474,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 hasPeripheralConnection = directLinkState.hasPeripheral let peripheralUUID = peerToPeripheralUUID[peerID]
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 = directLinkState.hasCentral let hasCentralSubscription = centralToPeerID.values.contains(peerID)
// 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)
@@ -2558,21 +2571,15 @@ extension BLEService {
} }
// Notify UI on main thread // Notify UI on main thread
notifyUI { [weak self] in let isDirectAnnounce = (packet.ttl == messageTTL) && (isNewPeer || isReconnectedPeer)
guard let self = self else { return } notifyUI { service in
let currentPeerIDs = service.collectionsQueue.sync { service.currentPeerIDs }
// Get current peer list (after addition) if isDirectAnnounce {
let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs } service.delegate?.didConnectToPeer(peerID)
service.gossipSyncManager?.scheduleInitialSyncToPeer(peerID, delaySeconds: 1.0)
// Only notify of connection for new or reconnected peers when it is a direct announce
if (packet.ttl == self.messageTTL) && (isNewPeer || isReconnectedPeer) {
self.delegate?.didConnectToPeer(peerID)
// Schedule initial unicast sync to this peer
self.gossipSyncManager?.scheduleInitialSyncToPeer(peerID, delaySeconds: 1.0)
} }
service.requestPeerDataPublish()
self.requestPeerDataPublish() service.delegate?.didUpdatePeerList(currentPeerIDs)
self.delegate?.didUpdatePeerList(currentPeerIDs)
} }
// Track for sync (include our own and others' announces) // Track for sync (include our own and others' announces)
@@ -2639,24 +2646,31 @@ 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 = peersSnapshot[peerID], info.isVerifiedNickname {
// Known verified peer path
accepted = true
senderNickname = info.nickname
// Handle nickname collisions
let hasCollision = peersSnapshot.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID } || (myNickname == info.nickname)
if hasCollision {
senderNickname += "#" + String(peerID.id.prefix(4))
}
} else { } else {
let selfNickname = myNickname
let peerLookup = collectionsQueue.sync { () -> (PeerInfo?, Bool) in
guard let info = peers[peerID] else { return (nil, false) }
let collision = peers.values.contains {
$0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID
}
return (info, collision)
}
if let info = peerLookup.0, info.isVerifiedNickname {
accepted = true
senderNickname = info.nickname
let hasCollision = peerLookup.1 || (selfNickname == info.nickname)
if hasCollision {
senderNickname += "#" + String(peerID.id.prefix(4))
}
}
}
if !accepted {
// Fallback: verify signature using persisted signing key for this peerID's fingerprint prefix // Fallback: verify signature using persisted signing key for this peerID's fingerprint prefix
if let signature = packet.signature, let packetData = packet.toBinaryDataForSigning() { if let signature = packet.signature, let packetData = packet.toBinaryDataForSigning() {
// Find candidate identities by peerID prefix (16 hex) // Find candidate identities by peerID prefix (16 hex)
@@ -2703,15 +2717,19 @@ 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 directLink = linkState(for: peerID) let hasDirectLink: Bool = collectionsQueue.sync {
let hasDirectLink = directLink.hasPeripheral || directLink.hasCentral let perUUID = peerToPeripheralUUID[peerID]
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)
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
notifyUI { [weak self] in notifyUI { service in
self?.delegate?.didReceivePublicMessage(from: peerID, nickname: senderNickname, content: content, timestamp: ts) service.delegate?.didReceivePublicMessage(from: peerID, nickname: senderNickname, content: content, timestamp: ts)
} }
} }
@@ -2776,28 +2794,28 @@ extension BLEService {
switch NoisePayloadType(rawValue: payloadType) { switch NoisePayloadType(rawValue: payloadType) {
case .privateMessage: case .privateMessage:
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
notifyUI { [weak self] in notifyUI { service in
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .privateMessage, payload: Data(payloadData), timestamp: ts) service.delegate?.didReceiveNoisePayload(from: peerID, type: .privateMessage, payload: Data(payloadData), timestamp: ts)
} }
case .delivered: case .delivered:
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
notifyUI { [weak self] in notifyUI { service in
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .delivered, payload: Data(payloadData), timestamp: ts) service.delegate?.didReceiveNoisePayload(from: peerID, type: .delivered, payload: Data(payloadData), timestamp: ts)
} }
case .readReceipt: case .readReceipt:
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
notifyUI { [weak self] in notifyUI { service in
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .readReceipt, payload: Data(payloadData), timestamp: ts) service.delegate?.didReceiveNoisePayload(from: peerID, type: .readReceipt, payload: Data(payloadData), timestamp: ts)
} }
case .verifyChallenge: case .verifyChallenge:
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
notifyUI { [weak self] in notifyUI { service in
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyChallenge, payload: Data(payloadData), timestamp: ts) service.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyChallenge, payload: Data(payloadData), timestamp: ts)
} }
case .verifyResponse: case .verifyResponse:
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
notifyUI { [weak self] in notifyUI { service in
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyResponse, payload: Data(payloadData), timestamp: ts) service.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyResponse, payload: Data(payloadData), timestamp: ts)
} }
default: default:
SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)") SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)")
@@ -2822,38 +2840,41 @@ extension BLEService {
// Remove any stored announcement for sync purposes // Remove any stored announcement for sync purposes
gossipSyncManager?.removeAnnouncementForPeer(peerID) gossipSyncManager?.removeAnnouncementForPeer(peerID)
// Send on main thread // Send on main thread
notifyUI { [weak self] in notifyUI { service in
guard let self = self else { return } let currentPeerIDs = service.collectionsQueue.sync { service.currentPeerIDs }
service.delegate?.didDisconnectFromPeer(peerID)
// Get current peer list (after removal) service.delegate?.didUpdatePeerList(currentPeerIDs)
let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs }
self.delegate?.didDisconnectFromPeer(peerID)
self.delegate?.didUpdatePeerList(currentPeerIDs)
} }
} }
// 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 performOnBLEQueue { service in
let now = Date() service.sendAnnounceOnBLEQueue(forceSend: forceSend)
let timeSinceLastAnnounce = now.timeIntervalSince(lastAnnounceSent)
// Even forced sends should respect a minimum interval to avoid overwhelming BLE
let minInterval = forceSend ? TransportConfig.bleForceAnnounceMinIntervalSeconds : announceMinInterval
if timeSinceLastAnnounce < minInterval {
// Skipping announce (rate limited)
return
} }
}
private func sendAnnounceOnBLEQueue(forceSend: Bool) {
let now = Date()
let elapsed = now.timeIntervalSince(lastAnnounceSent)
let minInterval = forceSend ? TransportConfig.bleForceAnnounceMinIntervalSeconds : announceMinInterval
guard elapsed >= minInterval else { return }
lastAnnounceSent = now lastAnnounceSent = now
// Reduced logging - only log errors, not every announce let noisePub = noiseService.getStaticPublicKeyData()
let signingPub = noiseService.getSigningPublicKeyData()
// Create announce payload with both noise and signing public keys
let noisePub = noiseService.getStaticPublicKeyData() // For noise handshakes and peer identification
let signingPub = noiseService.getSigningPublicKeyData() // For signature verification
let announcement = AnnouncementPacket( let announcement = AnnouncementPacket(
nickname: myNickname, nickname: myNickname,
@@ -2866,26 +2887,22 @@ extension BLEService {
return return
} }
// Create packet with signature using the noise private key let unsigned = BitchatPacket(
let packet = BitchatPacket(
type: MessageType.announce.rawValue, type: MessageType.announce.rawValue,
senderID: myPeerIDData, senderID: myPeerIDData,
recipientID: nil, recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload, payload: payload,
signature: nil, // Will be set by signPacket below signature: nil,
ttl: messageTTL ttl: messageTTL
) )
// Sign the packet using the noise private key guard let signedPacket = noiseService.signPacket(unsigned) else {
guard let signedPacket = noiseService.signPacket(packet) else {
SecureLogger.error("❌ Failed to sign announce packet", category: .security) SecureLogger.error("❌ Failed to sign announce packet", category: .security)
return return
} }
broadcastPacket(signedPacket) broadcastPacket(signedPacket)
// Ensure our own announce is included in sync state
gossipSyncManager?.onPublicPacketSeen(signedPacket) gossipSyncManager?.onPublicPacketSeen(signedPacket)
} }
@@ -2927,6 +2944,7 @@ extension BLEService {
} }
// Debounced disconnect notifier to avoid duplicate disconnect callbacks within a short window // Debounced disconnect notifier to avoid duplicate disconnect callbacks within a short window
@MainActor
private func notifyPeerDisconnectedDebounced(_ peerID: PeerID) { private func notifyPeerDisconnectedDebounced(_ peerID: PeerID) {
let now = Date() let now = Date()
let last = recentDisconnectNotifies[peerID] let last = recentDisconnectNotifies[peerID]
@@ -3036,11 +3054,6 @@ 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) {
@@ -3049,9 +3062,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 state = cachedLinkStates[peerID] ?? (hasPeripheral: false, hasCentral: false) let hasPeripheralConnection = peerToPeripheralUUID[peerID] != nil &&
let hasPeripheralConnection = state.hasPeripheral peripherals[peerToPeripheralUUID[peerID]!]?.isConnected == true
let hasCentralConnection = state.hasCentral let hasCentralConnection = centralToPeerID.values.contains(peerID)
// 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 {
@@ -3076,18 +3089,13 @@ extension BLEService {
// Update UI if there were direct disconnections or offline removals // Update UI if there were direct disconnections or offline removals
if !disconnectedPeers.isEmpty || removedOfflineCount > 0 { if !disconnectedPeers.isEmpty || removedOfflineCount > 0 {
notifyUI { [weak self] in notifyUI { service in
guard let self else { return } let currentPeerIDs = service.collectionsQueue.sync { service.currentPeerIDs }
// Get current peer list (after removal)
let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs }
for peerID in disconnectedPeers { for peerID in disconnectedPeers {
self.delegate?.didDisconnectFromPeer(PeerID(str: peerID)) service.delegate?.didDisconnectFromPeer(PeerID(str: peerID))
} }
// Publish snapshots so UnifiedPeerService updates connection/reachability icons service.requestPeerDataPublish()
self.requestPeerDataPublish() service.delegate?.didUpdatePeerList(currentPeerIDs)
self.delegate?.didUpdatePeerList(currentPeerIDs)
} }
} }
} }
+25 -7
View File
@@ -42,7 +42,7 @@ final class CommandProcessor {
case .location: return true case .location: return true
} }
}() }()
let inGeoDM = chatViewModel?.selectedPrivateChatPeer?.isGeoDM == true let inGeoDM = (chatViewModel?.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
switch cmd { switch cmd {
case "/m", "/msg": case "/m", "/msg":
@@ -65,6 +65,9 @@ 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)")
} }
@@ -101,7 +104,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? chatViewModel?.idBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased() let myHex = (try? NostrIdentityBridge.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
@@ -145,9 +148,9 @@ final class CommandProcessor {
if chatViewModel?.selectedPrivateChatPeer != nil { if chatViewModel?.selectedPrivateChatPeer != nil {
// In private chat // In private chat
if let peerNickname = meshService?.peerNickname(peerID: targetPeerID) { if let peerNickname = meshService?.peerNickname(peerID: PeerID(str: targetPeerID)) {
let personalMessage = "* \(emoji) \(myNickname) \(action) you\(suffix) *" let personalMessage = "* \(emoji) \(myNickname) \(action) you\(suffix) *"
meshService?.sendPrivateMessage(personalMessage, to: targetPeerID, meshService?.sendPrivateMessage(personalMessage, to: PeerID(str: 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
@@ -211,7 +214,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) { let fingerprint = meshService?.getFingerprint(for: PeerID(str: 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")
} }
@@ -255,7 +258,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) { let fingerprint = meshService?.getFingerprint(for: PeerID(str: 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")
} }
@@ -282,7 +285,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.id) else { let noisePublicKey = Data(hexString: peerID) else {
return .error(message: "can't find peer: \(nickname)") return .error(message: "can't find peer: \(nickname)")
} }
@@ -308,4 +311,19 @@ 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,7 +26,6 @@ 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> = []
@@ -36,8 +35,7 @@ final class FavoritesPersistenceService: ObservableObject {
static let shared = FavoritesPersistenceService() static let shared = FavoritesPersistenceService()
init(keychain: KeychainHelperProtocol = KeychainHelper()) { private init() {
self.keychain = keychain
loadFavorites() loadFavorites()
// Update mutual favorites when favorites change // Update mutual favorites when favorites change
@@ -198,7 +196,7 @@ final class FavoritesPersistenceService: ObservableObject {
saveFavorites() saveFavorites()
// Delete from keychain directly // Delete from keychain directly
keychain.delete( KeychainHelper.delete(
key: Self.storageKey, key: Self.storageKey,
service: Self.keychainService service: Self.keychainService
) )
@@ -218,11 +216,10 @@ final class FavoritesPersistenceService: ObservableObject {
let data = try encoder.encode(relationships) let data = try encoder.encode(relationships)
// Store in keychain for security // Store in keychain for security
keychain.save( KeychainHelper.save(
key: Self.storageKey, key: Self.storageKey,
data: data, data: data,
service: Self.keychainService, service: Self.keychainService
accessible: nil
) )
// Successfully saved favorites // Successfully saved favorites
@@ -234,7 +231,7 @@ final class FavoritesPersistenceService: ObservableObject {
private func loadFavorites() { private func loadFavorites() {
// Loading favorites from keychain // Loading favorites from keychain
guard let data = keychain.load( guard let data = KeychainHelper.load(
key: Self.storageKey, key: Self.storageKey,
service: Self.keychainService service: Self.keychainService
) else { ) else {
@@ -216,4 +216,15 @@ 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,6 +27,34 @@ 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
@@ -0,0 +1,104 @@
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
}
}
+5 -15
View File
@@ -15,8 +15,6 @@ 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)
@@ -37,7 +35,7 @@ struct LocationNotesDependencies {
NostrRelayManager.shared.sendEvent(event, to: relays) NostrRelayManager.shared.sendEvent(event, to: relays)
}, },
deriveIdentity: { geohash in deriveIdentity: { geohash in
try idBridge.deriveIdentity(forGeohash: geohash) try NostrIdentityBridge.deriveIdentity(forGeohash: geohash)
}, },
now: { Date() } now: { Date() }
) )
@@ -163,22 +161,14 @@ 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
// Subscribe to center + 8 neighbors (± 1 grid) let filter = NostrFilter.geohashNotes(geohash, since: nil, limit: 200)
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 - accept any of our 9 geohashes // Ensure matching tag
guard event.tags.contains(where: { tag in guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == self.geohash }) else { return }
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
+9 -10
View File
@@ -16,11 +16,9 @@ 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, idBridge: NostrIdentityBridge) { init(keychain: KeychainManagerProtocol) {
self.keychain = keychain self.keychain = keychain
self.idBridge = idBridge
} }
// MARK: - Transport Protocol Conformance // MARK: - Transport Protocol Conformance
@@ -52,10 +50,11 @@ final class NostrTransport: Transport {
// Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation // Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation
private static var cachedNoiseService: NoiseEncryptionService? private static var cachedNoiseService: NoiseEncryptionService?
private static let noiseServiceLock = NSLock()
func getNoiseService() -> NoiseEncryptionService { func getNoiseService() -> NoiseEncryptionService {
if let noiseService = Self.cachedNoiseService { Self.noiseServiceLock.lock()
return noiseService defer { Self.noiseServiceLock.unlock() }
} if let noiseService = Self.cachedNoiseService { return noiseService }
let noiseService = NoiseEncryptionService(keychain: keychain) let noiseService = NoiseEncryptionService(keychain: keychain)
Self.cachedNoiseService = noiseService Self.cachedNoiseService = noiseService
return noiseService return noiseService
@@ -67,7 +66,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? idBridge.getCurrentNostrIdentity() else { return } guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session) SecureLogger.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
@@ -104,7 +103,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? idBridge.getCurrentNostrIdentity() else { return } guard let senderIdentity = try? NostrIdentityBridge.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
@@ -131,7 +130,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? idBridge.getCurrentNostrIdentity() else { return } guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))", category: .session) 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 {
@@ -214,7 +213,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? idBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return } guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
SecureLogger.debug("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))", category: .session) SecureLogger.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: [PeerID: [BitchatMessage]] = [:] @Published var privateChats: [String: [BitchatMessage]] = [:]
@Published var selectedPeer: PeerID? = nil @Published var selectedPeer: String? = nil
@Published var unreadMessages: Set<PeerID> = [] @Published var unreadMessages: Set<String> = []
private var selectedPeerFingerprint: String? = nil private var selectedPeerFingerprint: String? = nil
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel 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: PeerID) { func startChat(with peerID: String) {
selectedPeer = peerID selectedPeer = peerID
// Store fingerprint for persistence across reconnections // Store fingerprint for persistence across reconnections
if let fingerprint = meshService?.getFingerprint(for: peerID) { if let fingerprint = meshService?.getFingerprint(for: PeerID(str: 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: PeerID) { func sanitizeChat(for peerID: String) {
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: PeerID) { func markAsRead(from peerID: String) {
unreadMessages.remove(peerID) unreadMessages.remove(peerID)
// Send read receipts for unread messages that haven't been sent yet // Send read receipts for unread messages that haven't been sent yet
+5 -11
View File
@@ -27,7 +27,6 @@ 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
@@ -35,13 +34,8 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
// MARK: - Initialization // MARK: - Initialization
init( init(meshService: Transport, identityManager: SecureIdentityStateManagerProtocol) {
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
@@ -235,10 +229,10 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
} }
/// Get peer ID for nickname /// Get peer ID for nickname
func getPeerID(for nickname: String) -> PeerID? { func getPeerID(for nickname: String) -> String? {
for peer in peers { for peer in peers {
if peer.displayName == nickname || peer.nickname == nickname { if peer.displayName == nickname || peer.nickname == nickname {
return peer.peerID return peer.peerID.id
} }
} }
return nil return nil
@@ -291,7 +285,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
var peerNostrKey = peer.nostrPublicKey var peerNostrKey = peer.nostrPublicKey
if peerNostrKey == nil { if peerNostrKey == nil {
// Try to get from NostrIdentityBridge association // Try to get from NostrIdentityBridge association
peerNostrKey = idBridge.getNostrPublicKey(for: peer.noisePublicKey) peerNostrKey = NostrIdentityBridge.getNostrPublicKey(for: peer.noisePublicKey)
} }
// Add favorite // Add favorite
@@ -347,7 +341,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: Set<PeerID> { connectedPeerIDs } var connectedPeers: [PeerID] { Array(connectedPeerIDs) }
var favoritePeers: Set<String> { var favoritePeers: Set<String> {
Set(favorites.compactMap { getFingerprint(for: $0.peerID) }) Set(favorites.compactMap { getFingerprint(for: $0.peerID) })
} }
+5 -79
View File
@@ -13,9 +13,6 @@ 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
@@ -30,7 +27,6 @@ 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
@@ -40,10 +36,10 @@ final class GossipSyncManager {
func start() { func start() {
stop() stop()
let timer = DispatchSource.makeTimerSource(queue: queue) let timer = DispatchSource.makeTimerSource(queue: queue)
let interval = max(0.1, config.maintenanceIntervalSeconds) timer.schedule(deadline: .now() + 30.0, repeating: 30.0, leeway: .seconds(1))
timer.schedule(deadline: .now() + interval, repeating: interval, leeway: .seconds(1))
timer.setEventHandler { [weak self] in timer.setEventHandler { [weak self] in
self?.performPeriodicMaintenance() self?.cleanupExpiredMessages()
self?.sendRequestSync()
} }
timer.resume() timer.resume()
periodicTimer = timer periodicTimer = timer
@@ -77,15 +73,6 @@ 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 = {
@@ -99,14 +86,6 @@ 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 {
@@ -121,7 +100,7 @@ final class GossipSyncManager {
} }
} }
} else if isAnnounce { } else if isAnnounce {
let sender = packet.senderID.hexEncodedString().lowercased() let sender = packet.senderID.hexEncodedString()
latestAnnouncementByPeer[sender] = (id: idHex, packet: packet) latestAnnouncementByPeer[sender] = (id: idHex, packet: packet)
} }
} }
@@ -251,34 +230,6 @@ 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
@@ -288,11 +239,8 @@ final class GossipSyncManager {
private func _removeAnnouncementForPeer(_ peerID: PeerID) { private func _removeAnnouncementForPeer(_ peerID: PeerID) {
let normalizedPeerID = peerID.id.lowercased() let normalizedPeerID = peerID.id.lowercased()
removeState(forNormalizedPeerID: normalizedPeerID)
}
private func removeState(forNormalizedPeerID normalizedPeerID: String) {
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID) _ = 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
@@ -306,25 +254,3 @@ 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,7 +91,6 @@ 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
+4 -5
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.id } if peerID.count == 64, let short = viewModel.getShortIDForNoiseKey(peerID) { return short }
return peerID return peerID
}() }()
// Resolve a friendly name // Resolve a friendly name
let peerNickname: String = { let peerNickname: String = {
if let p = viewModel.getPeer(byID: PeerID(str: statusPeerID)) { return p.displayName } if let p = viewModel.getPeer(byID: 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: PeerID(str: statusPeerID)) let encryptionStatus = viewModel.getEncryptionStatus(for: 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: PeerID(str: statusPeerID)) { if let fingerprint = viewModel.getFingerprint(for: statusPeerID) {
Text(formatFingerprint(fingerprint)) Text(formatFingerprint(fingerprint))
.font(.bitchatSystem(size: 14, design: .monospaced)) .font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
@@ -176,7 +176,6 @@ 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? viewModel.idBridge.deriveIdentity(forGeohash: ch.geohash) { let id = try? NostrIdentityBridge.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) ± 1", count geohash, 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) let hasUnread = viewModel.hasUnreadMessages(for: peer.peerID.id)
let enc = viewModel.getEncryptionStatus(for: peer.peerID) let enc = viewModel.getEncryptionStatus(for: peer.peerID.id)
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, isDark: colorScheme == .dark) let assigned = viewModel.colorForMeshPeer(id: peer.peerID.id, 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) { if !isMe, viewModel.isPeerBlocked(peer.peerID.id) {
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), if let fp = viewModel.getFingerprint(for: peer.peerID.id),
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? viewModel.idBridge.getCurrentNostrIdentity()?.npub let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
return VerificationService.shared.buildMyQRString(nickname: viewModel.nickname, npub: npub) ?? "" return VerificationService.shared.buildMyQRString(nickname: viewModel.nickname, npub: npub) ?? ""
} }
@@ -108,13 +108,6 @@
"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"
}
} }
} }
}, },
@@ -225,13 +218,6 @@
"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"
}
} }
} }
}, },
@@ -342,13 +328,6 @@
"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"
}
} }
} }
}, },
@@ -459,13 +438,6 @@
"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"
}
} }
} }
}, },
@@ -576,13 +548,6 @@
"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"
}
} }
} }
}, },
@@ -693,13 +658,6 @@
"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"
}
} }
} }
} }
+197 -207
View File
@@ -6,275 +6,265 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import Testing import XCTest
import CoreBluetooth import CoreBluetooth
@testable import bitchat @testable import bitchat
struct BLEServiceTests { final class BLEServiceTests: XCTestCase {
private let service: MockBLEService
private let myUUID = UUID()
private let bus = MockBLEBus()
init() { var service: MockBLEService!
service = MockBLEService.init(bus: bus)
service.myPeerID = PeerID(str: myUUID.uuidString) override func setUp() {
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
@Test func serviceInitialization() { func testServiceInitialization() {
#expect(service.myPeerID == PeerID(str: myUUID.uuidString)) XCTAssertNotNil(service)
#expect(service.myNickname == "TestUser") XCTAssertEqual(service.myPeerID, "TEST1234")
XCTAssertEqual(service.myNickname, "TestUser")
} }
@Test func peerConnection() { func testPeerConnection() {
let somePeerID = PeerID(str: UUID().uuidString) // Test connecting a peer
service.simulateConnectedPeer("PEER5678")
XCTAssertTrue(service.isPeerConnected("PEER5678"))
XCTAssertEqual(service.getConnectedPeers().count, 1)
service.simulateConnectedPeer(somePeerID) // Test disconnecting a peer
#expect(service.isPeerConnected(somePeerID)) service.simulateDisconnectedPeer("PEER5678")
#expect(service.getConnectedPeers().count == 1) XCTAssertFalse(service.isPeerConnected("PEER5678"))
XCTAssertEqual(service.getConnectedPeers().count, 0)
service.simulateDisconnectedPeer(somePeerID)
#expect(!service.isPeerConnected(somePeerID))
#expect(service.getConnectedPeers().count == 0)
} }
@Test func multiplePeerConnections() { func testMultiplePeerConnections() {
let peerID1 = PeerID(str: UUID().uuidString) service.simulateConnectedPeer("PEER1")
let peerID2 = PeerID(str: UUID().uuidString) service.simulateConnectedPeer("PEER2")
let peerID3 = PeerID(str: UUID().uuidString) service.simulateConnectedPeer("PEER3")
service.simulateConnectedPeer(peerID1) XCTAssertEqual(service.getConnectedPeers().count, 3)
service.simulateConnectedPeer(peerID2) XCTAssertTrue(service.isPeerConnected("PEER1"))
service.simulateConnectedPeer(peerID3) XCTAssertTrue(service.isPeerConnected("PEER2"))
XCTAssertTrue(service.isPeerConnected("PEER3"))
#expect(service.getConnectedPeers().count == 3) service.simulateDisconnectedPeer("PEER2")
#expect(service.isPeerConnected(peerID1)) XCTAssertEqual(service.getConnectedPeers().count, 2)
#expect(service.isPeerConnected(peerID2)) XCTAssertFalse(service.isPeerConnected("PEER2"))
#expect(service.isPeerConnected(peerID3))
service.simulateDisconnectedPeer(peerID2)
#expect(service.getConnectedPeers().count == 2)
#expect(!service.isPeerConnected(peerID2))
} }
// MARK: - Message Sending Tests // MARK: - Message Sending Tests
@Test func sendPublicMessage() async throws { func testSendPublicMessage() {
try await confirmation { receivedPublicMessage in let expectation = XCTestExpectation(description: "Message sent")
let delegate = MockBitchatDelegate { message in
#expect(message.content == "Hello, world!")
#expect(message.sender == "TestUser")
#expect(!message.isPrivate)
receivedPublicMessage()
}
service.delegate = delegate
service.sendMessage("Hello, world!")
// Allow async processing let delegate = MockBitchatDelegate { message in
try await sleep(0.5) XCTAssertEqual(message.content, "Hello, world!")
XCTAssertEqual(message.sender, "TestUser")
XCTAssertFalse(message.isPrivate)
expectation.fulfill()
} }
#expect(service.sentMessages.count == 1) service.delegate = delegate
service.sendMessage("Hello, world!")
wait(for: [expectation], timeout: 1.0)
XCTAssertEqual(service.sentMessages.count, 1)
} }
@Test func sendPrivateMessage() async throws { func testSendPrivateMessage() {
try await confirmation { receivedPrivateMessage in let expectation = XCTestExpectation(description: "Private message sent")
let delegate = MockBitchatDelegate { message in
#expect(message.content == "Secret message")
#expect(message.sender == "TestUser")
#expect(message.senderPeerID == PeerID(str: myUUID.uuidString))
#expect(message.isPrivate)
#expect(message.recipientNickname == "Bob")
receivedPrivateMessage()
}
service.delegate = delegate
service.sendPrivateMessage(
"Secret message",
to: PeerID(str: UUID().uuidString),
recipientNickname: "Bob",
messageID: "MSG123"
)
// Allow async processing let delegate = MockBitchatDelegate { message in
try await sleep(0.5) XCTAssertEqual(message.content, "Secret message")
XCTAssertEqual(message.sender, "TestUser")
XCTAssertTrue(message.isPrivate)
XCTAssertEqual(message.recipientNickname, "Bob")
expectation.fulfill()
} }
#expect(service.sentMessages.count == 1) service.delegate = delegate
service.sendPrivateMessage("Secret message", to: "PEER5678", recipientNickname: "Bob", messageID: "MSG123")
wait(for: [expectation], timeout: 1.0)
XCTAssertEqual(service.sentMessages.count, 1)
} }
@Test func sendMessageWithMentions() async throws { func testSendMessageWithMentions() {
try await confirmation { receivedMessageWithMentions in let expectation = XCTestExpectation(description: "Message with mentions sent")
let delegate = MockBitchatDelegate { message in
#expect(message.content == "@alice @bob check this out")
#expect(message.mentions == ["alice", "bob"])
receivedMessageWithMentions()
}
service.delegate = delegate
service.sendMessage("@alice @bob check this out", mentions: ["alice", "bob"])
// Allow async processing let delegate = MockBitchatDelegate { message in
try await sleep(0.5) XCTAssertEqual(message.content, "@alice @bob check this out")
XCTAssertEqual(message.mentions, ["alice", "bob"])
expectation.fulfill()
} }
service.delegate = delegate
service.sendMessage("@alice @bob check this out", mentions: ["alice", "bob"])
wait(for: [expectation], timeout: 1.0)
} }
// MARK: - Message Reception Tests // MARK: - Message Reception Tests
@Test func simulateIncomingMessage() async throws { func testSimulateIncomingMessage() {
try await confirmation { receiveMessage in let expectation = XCTestExpectation(description: "Message received")
let peerID = PeerID(str: UUID().uuidString)
let delegate = MockBitchatDelegate { message in let delegate = MockBitchatDelegate { message in
#expect(message.content == "Incoming message") XCTAssertEqual(message.content, "Incoming message")
#expect(message.sender == "RemoteUser") XCTAssertEqual(message.sender, "RemoteUser")
#expect(message.senderPeerID == peerID) expectation.fulfill()
receiveMessage()
}
service.delegate = delegate
let incomingMessage = BitchatMessage(
id: "MSG456",
sender: "RemoteUser",
content: "Incoming message",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: peerID,
mentions: nil
)
service.simulateIncomingMessage(incomingMessage)
// Allow async processing
try await sleep(0.5)
} }
service.delegate = delegate
let incomingMessage = BitchatMessage(
id: "MSG456",
sender: "RemoteUser",
content: "Incoming message",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: "REMOTE123",
mentions: nil
)
service.simulateIncomingMessage(incomingMessage)
wait(for: [expectation], timeout: 1.0)
} }
@Test func simulateIncomingPacket() async throws { func testSimulateIncomingPacket() {
try await confirmation { processPacket in let expectation = XCTestExpectation(description: "Packet processed")
let peerID = PeerID(str: UUID().uuidString)
let delegate = MockBitchatDelegate { message in let delegate = MockBitchatDelegate { message in
#expect(message.content == "Packet message") XCTAssertEqual(message.content, "Packet message")
#expect(message.senderPeerID == peerID) expectation.fulfill()
processPacket()
}
service.delegate = delegate
let message = BitchatMessage(
id: "MSG789",
sender: "PacketSender",
content: "Packet message",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: peerID,
mentions: nil
)
let payload = try #require(message.toBinaryPayload(), "Failed to create binary payload")
let packet = BitchatPacket(
type: 0x01,
senderID: peerID.id.data(using: .utf8)!,
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: 3
)
service.simulateIncomingPacket(packet)
// Allow async processing
try await sleep(0.5)
} }
service.delegate = delegate
let message = BitchatMessage(
id: "MSG789",
sender: "PacketSender",
content: "Packet message",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: "PACKET123",
mentions: nil
)
guard let payload = message.toBinaryPayload() else {
XCTFail("Failed to create binary payload")
return
}
let packet = BitchatPacket(
type: 0x01,
senderID: "PACKET123".data(using: .utf8)!,
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: 3
)
service.simulateIncomingPacket(packet)
wait(for: [expectation], timeout: 1.0)
} }
// MARK: - Peer Nickname Tests // MARK: - Peer Nickname Tests
@Test func getPeerNicknames() { func testGetPeerNicknames() {
let peerID1 = PeerID(str: UUID().uuidString) service.simulateConnectedPeer("PEER1")
let peerID2 = PeerID(str: UUID().uuidString) service.simulateConnectedPeer("PEER2")
service.simulateConnectedPeer(peerID1)
service.simulateConnectedPeer(peerID2)
let nicknames = service.getPeerNicknames() let nicknames = service.getPeerNicknames()
#expect(nicknames.count == 2) XCTAssertEqual(nicknames.count, 2)
#expect(nicknames[peerID1] == "MockPeer_\(peerID1)") XCTAssertEqual(nicknames["PEER1"], "MockPeer_PEER1")
#expect(nicknames[peerID2] == "MockPeer_\(peerID2)") XCTAssertEqual(nicknames["PEER2"], "MockPeer_PEER2")
} }
// MARK: - Service State Tests // MARK: - Service State Tests
@Test func startStopServices() { func testStartStopServices() {
// 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.simulateConnectedPeer(somePeerID) // Service should still be functional after start/stop
#expect(service.isPeerConnected(somePeerID)) service.simulateConnectedPeer("PEER999")
XCTAssertTrue(service.isPeerConnected("PEER999"))
} }
// MARK: - Message Delivery Handler Tests // MARK: - Message Delivery Handler Tests
@Test func messageDeliveryHandler() async throws { func testMessageDeliveryHandler() {
try await confirmation { deliveryHandler in let expectation = XCTestExpectation(description: "Delivery handler called")
service.packetDeliveryHandler = { packet in
if let msg = BitchatMessage(packet.payload) {
#expect(msg.content == "Test delivery")
deliveryHandler()
}
}
service.sendMessage("Test delivery")
// Allow async processing service.packetDeliveryHandler = { packet in
try await sleep(0.5) if let msg = BitchatMessage(packet.payload) {
XCTAssertEqual(msg.content, "Test delivery")
expectation.fulfill()
}
} }
service.sendMessage("Test delivery")
wait(for: [expectation], timeout: 1.0)
} }
@Test func packetDeliveryHandler() async throws { func testPacketDeliveryHandler() {
try await confirmation("Packet handler called") { packetHandler in let expectation = XCTestExpectation(description: "Packet handler called")
let peerID = PeerID(str: UUID().uuidString)
service.packetDeliveryHandler = { packet in service.packetDeliveryHandler = { packet in
#expect(packet.type == 0x01) XCTAssertEqual(packet.type, 0x01)
#expect(packet.senderID == Data(peerID.id.utf8)) expectation.fulfill()
packetHandler()
}
let message = BitchatMessage(
id: "PKT123",
sender: "TestSender",
content: "Test packet",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: peerID,
mentions: nil
)
let payload = try #require(message.toBinaryPayload(), "Failed to create payload")
let packet = BitchatPacket(
type: 0x01,
senderID: peerID.id.data(using: .utf8)!,
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: 3
)
service.simulateIncomingPacket(packet)
// Allow async processing
try await sleep(0.5)
} }
let message = BitchatMessage(
id: "PKT123",
sender: "TestSender",
content: "Test packet",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: "TEST123",
mentions: nil
)
guard let payload = message.toBinaryPayload() else {
XCTFail("Failed to create payload")
return
}
let packet = BitchatPacket(
type: 0x01,
senderID: "TEST123".data(using: .utf8)!,
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: 3
)
service.simulateIncomingPacket(packet)
wait(for: [expectation], timeout: 1.0)
} }
} }
+24 -12
View File
@@ -1,42 +1,54 @@
import Testing import XCTest
@testable import bitchat @testable import bitchat
struct CommandProcessorTests { final class CommandProcessorTests: XCTestCase {
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
@Test func slapNotFoundGrammar() { func test_slap_notFoundGrammar() {
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):
#expect(message == "cannot slap system: not found") XCTAssertEqual(message, "cannot slap system: not found")
default: default:
Issue.record("Expected error result") XCTFail("Expected error result")
} }
} }
@MainActor @MainActor
@Test func hugNotFoundGrammar() { func test_hug_notFoundGrammar() {
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):
#expect(message == "cannot hug system: not found") XCTAssertEqual(message, "cannot hug system: not found")
default: default:
Issue.record("Expected error result") XCTFail("Expected error result")
} }
} }
@MainActor @MainActor
@Test func slapUsageMessage() { func test_slap_usageMessage() {
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):
#expect(message == "usage: /slap <nickname>") XCTAssertEqual(message, "usage: /slap <nickname>")
default: default:
Issue.record("Expected error result for usage message") XCTFail("Expected error result for usage message")
} }
} }
} }
@@ -11,19 +11,21 @@ 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, bus: bus) alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1)
bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2, bus: bus) bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2)
charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3, bus: bus) charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3)
mockKeychain = MockKeychain()
} }
// MARK: - Basic Private Messaging Tests // MARK: - Basic Private Messaging Tests
@@ -51,7 +53,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 sleep(0.1) try? await Task.sleep(nanoseconds: UInt64(TestConstants.shortTimeout * 1_000_000_000))
} }
#expect(!bobReceivedMessage, "Bob should not have received the message") #expect(!bobReceivedMessage, "Bob should not have received the message")
@@ -169,7 +171,7 @@ struct PrivateChatE2ETests {
// Send encrypted private message // Send encrypted private message
alice.sendPrivateMessage( alice.sendPrivateMessage(
TestConstants.testMessage1, TestConstants.testMessage1,
to: bob.peerID, to: TestConstants.testPeerID2,
recipientNickname: TestConstants.testNickname2 recipientNickname: TestConstants.testNickname2
) )
} }
@@ -233,7 +235,7 @@ struct PrivateChatE2ETests {
for i in 0..<messageCount { for i in 0..<messageCount {
alice.sendPrivateMessage( alice.sendPrivateMessage(
"Private message \(i)", "Private message \(i)",
to: bob.peerID, to: TestConstants.testPeerID2,
recipientNickname: TestConstants.testNickname2 recipientNickname: TestConstants.testNickname2
) )
} }
@@ -252,7 +254,7 @@ struct PrivateChatE2ETests {
alice.sendPrivateMessage( alice.sendPrivateMessage(
TestConstants.testLongMessage, TestConstants.testLongMessage,
to: bob.peerID, to: TestConstants.testPeerID2,
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, bus: bus) alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1)
bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2, bus: bus) bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2)
charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3, bus: bus) charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3)
david = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname4, bus: bus) david = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname4)
} }
// MARK: - Basic Broadcasting Tests // MARK: - Basic Broadcasting Tests
@@ -15,26 +15,20 @@ 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( let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager)
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(str: "1122334455667788") let remoteShortID: PeerID = "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
@@ -45,15 +39,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 = 5 * Double(i) * 0.001 let delay = UInt64(5 * i) * 1_000_000 // nanoseconds
Task { Task {
try await sleep(delay) try await Task.sleep(nanoseconds: delay)
ble._test_handlePacket(fragment, fromPeerID: remoteShortID) ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
} }
} }
// Allow async processing // Allow async processing
try await sleep(0.5) try await Task.sleep(nanoseconds: 500_000_000) // 0.5s
#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)
@@ -61,15 +55,11 @@ 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( let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager)
keychain: mockKeychain,
idBridge: idBridge,
identityManager: mockIdentityManager
)
let capture = CaptureDelegate() let capture = CaptureDelegate()
ble.delegate = capture ble.delegate = capture
let remoteShortID = PeerID(str: "A1B2C3D4E5F60708") let remoteShortID: PeerID = "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)
@@ -79,15 +69,15 @@ struct FragmentationTests {
} }
for (i, fragment) in frags.enumerated() { for (i, fragment) in frags.enumerated() {
let delay = 5 * Double(i) * 0.001 let delay = UInt64(5 * i) * 1_000_000 // nanoseconds
Task { Task {
try await sleep(delay) try await Task.sleep(nanoseconds: delay)
ble._test_handlePacket(fragment, fromPeerID: remoteShortID) ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
} }
} }
// Allow async processing // Allow async processing
try await sleep(0.5) try await Task.sleep(nanoseconds: 500_000_000) // 0.5s
#expect(capture.publicMessages.count == 1) #expect(capture.publicMessages.count == 1)
#expect(capture.publicMessages.first?.content.count == 2048) #expect(capture.publicMessages.first?.content.count == 2048)
@@ -95,15 +85,11 @@ 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( let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager)
keychain: mockKeychain,
idBridge: idBridge,
identityManager: mockIdentityManager
)
let capture = CaptureDelegate() let capture = CaptureDelegate()
ble.delegate = capture ble.delegate = capture
let remoteShortID = PeerID(str: "0011223344556677") let remoteShortID: PeerID = "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)
@@ -124,15 +110,15 @@ struct FragmentationTests {
} }
for (i, fragment) in corrupted.enumerated() { for (i, fragment) in corrupted.enumerated() {
let delay = 5 * Double(i) * 0.001 let delay = UInt64(5 * i) * 1_000_000 // nanoseconds
Task { Task {
try await sleep(delay) try await Task.sleep(nanoseconds: delay)
ble._test_handlePacket(fragment, fromPeerID: remoteShortID) ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
} }
} }
// Allow async processing // Allow async processing
try await sleep(0.5) try await Task.sleep(nanoseconds: 500_000_000) // 0.5s
// 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)
+8 -9
View File
@@ -1,23 +1,22 @@
import Testing import XCTest
import struct Foundation.Data
@testable import bitchat @testable import bitchat
struct GCSFilterTests { final class GCSFilterTests: XCTestCase {
@Test func buildFilterWithDuplicateIdsProducesStableEncoding() { func testBuildFilterWithDuplicateIdsProducesStableEncoding() {
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)
#expect(params.m >= 1) XCTAssertGreaterThanOrEqual(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)
#expect(decoded.count <= 1) XCTAssertLessThanOrEqual(decoded.count, 1)
} }
@Test func bucketAvoidsZeroCandidate() { func testBucketAvoidsZeroCandidate() {
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)
#expect(bucket != 0) XCTAssertNotEqual(bucket, 0)
#expect(bucket < 2) XCTAssertLessThan(bucket, 2)
} }
} }
+32 -18
View File
@@ -1,38 +1,52 @@
import Testing import XCTest
import Foundation
@testable import bitchat @testable import bitchat
struct GeohashBookmarksStoreTests { final class GeohashBookmarksStoreTests: XCTestCase {
private let storeKey = "locationChannel.bookmarks" let storeKey = "locationChannel.bookmarks"
private let storage = UserDefaults(suiteName: UUID().uuidString)! var storage: UserDefaults!
private let store: GeohashBookmarksStore var store: GeohashBookmarksStore!
init() { override func setUp() {
store = GeohashBookmarksStore(storage: storage) super.setUp()
// Unique instance for each test to avoid race condition
storage = UserDefaults(suiteName: UUID().uuidString)
store = GeohashBookmarksStore(storage: storage!)
} }
@Test func toggleAndNormalize() { override func tearDown() {
storage.removeObject(forKey: storeKey)
store._resetForTesting()
store = nil
storage = nil
super.tearDown()
}
func testToggleAndNormalize() {
// Start clean // Start clean
#expect(store.bookmarks.isEmpty) XCTAssertTrue(store.bookmarks.isEmpty)
// Add with mixed case and hash prefix // Add with mixed case and hash prefix
store.toggle("#U4PRUY") store.toggle("#U4PRUY")
#expect(store.isBookmarked("u4pruy")) XCTAssertTrue(store.isBookmarked("u4pruy"))
#expect(store.bookmarks.first == "u4pruy") XCTAssertEqual(store.bookmarks.first, "u4pruy")
// Toggling again removes // Toggling again removes
store.toggle("u4pruy") store.toggle("u4pruy")
#expect(!store.isBookmarked("u4pruy")) XCTAssertFalse(store.isBookmarked("u4pruy"))
#expect(store.bookmarks.isEmpty) XCTAssertTrue(store.bookmarks.isEmpty)
} }
@Test func persistenceWritten() throws { func testPersistenceWritten() 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)
let data = try #require(storage.data(forKey: storeKey), "No persisted data found") guard let data = storage.data(forKey: storeKey) else {
XCTFail("No persisted data found")
return
}
let arr = try JSONDecoder().decode([String].self, from: data) let arr = try JSONDecoder().decode([String].self, from: data)
#expect(arr.contains("ezs42")) XCTAssertTrue(arr.contains("ezs42"))
#expect(arr.contains("u4pruy")) XCTAssertTrue(arr.contains("u4pruy"))
} }
} }
+25 -104
View File
@@ -1,28 +1,24 @@
import Foundation import Foundation
import Testing import XCTest
@testable import bitchat @testable import bitchat
struct GossipSyncManagerTests { final class GossipSyncManagerTests: XCTestCase {
func testConcurrentPacketIntakeAndSyncRequest() {
private let myPeerID = PeerID(str: "0102030405060708") let manager = GossipSyncManager(myPeerID: "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 let iterations = 200
delegate.onSend = { let group = DispatchGroup()
sent()
}
let iterations = 200 for i in 0..<iterations {
let senderID = try #require(Data(hexString: "1122334455667788")) group.enter()
DispatchQueue.global(qos: .userInitiated).async {
for i in 0..<iterations {
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.message.rawValue, type: MessageType.message.rawValue,
senderID: senderID, senderID: Data(hexString: "1122334455667788") ?? Data(),
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)]),
@@ -30,100 +26,25 @@ struct GossipSyncManagerTests {
ttl: 1 ttl: 1
) )
manager.onPublicPacketSeen(packet) manager.onPublicPacketSeen(packet)
try await sleep(0.001) Thread.sleep(forTimeInterval: 0.001)
group.leave()
} }
manager.scheduleInitialSyncToPeer(PeerID(str: "FFFFFFFFFFFFFFFF"), delaySeconds: 0.0)
try await sleep(0.002)
} }
let lastPacket = try #require(delegate.lastPacket, "Expected sync packet to be sent") DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.002) {
#expect(lastPacket.type == MessageType.requestSync.rawValue) manager.scheduleInitialSyncToPeer("FFFFFFFFFFFFFFFF", delaySeconds: 0.0)
#expect(RequestSyncPacket.decode(from: lastPacket.payload) != nil) }
}
@Test func staleAnnouncementsArePurgedWithMessages() throws { group.wait()
var config = GossipSyncManager.Config() wait(for: [sendExpectation], timeout: 2.0)
config.stalePeerCleanupIntervalSeconds = 0
config.stalePeerTimeoutSeconds = 5
let manager = GossipSyncManager(myPeerID: myPeerID, config: config) guard let lastPacket = delegate.lastPacket else {
let peerHex = "0011223344556677" XCTFail("Expected sync packet to be sent")
let senderData = try #require(Data(hexString: peerHex)) return
let initialTimestampMs = UInt64(Date().timeIntervalSince1970 * 1000) }
let announcePacket = BitchatPacket( XCTAssertEqual(lastPacket.type, MessageType.requestSync.rawValue)
type: MessageType.announce.rawValue, XCTAssertNotNil(RequestSyncPacket.decode(from: lastPacket.payload))
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)
} }
} }
+460 -302
View File
@@ -6,31 +6,52 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import Foundation import XCTest
import CryptoKit import CryptoKit
import Testing
@testable import bitchat @testable import bitchat
struct IntegrationTests { final class IntegrationTests: XCTestCase {
private var helper = TestNetworkHelper() var nodes: [String: MockBLEService] = [:]
var noiseManagers: [String: NoiseSessionManager] = [:]
private var mockKeychain: MockKeychain!
init() { override func setUp() {
helper.createNode("Alice", peerID: PeerID(str: UUID().uuidString)) super.setUp()
helper.createNode("Bob", peerID: PeerID(str: UUID().uuidString)) // Use the in-memory test bus with autoFlood enabled to simulate
helper.createNode("Charlie", peerID: PeerID(str: UUID().uuidString)) // broadcast propagation across a larger mesh. Integration-only.
helper.createNode("David", peerID: PeerID(str: UUID().uuidString)) MockBLEService.resetTestBus()
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
@Test func fullMeshCommunication() async throws { func testFullMeshCommunication() {
helper.connectFullMesh() // Create full mesh - everyone connected to everyone
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] = [] }
for (receiverName, receiver) in helper.nodes { // Track all receivers; parse sender name from message content "Hello from <Name>"
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") {
@@ -41,336 +62,370 @@ struct IntegrationTests {
} }
} }
for (name, node) in helper.nodes { // Each node sends a message
node.sendMessage("Hello from \(name)") for (name, node) in nodes {
node.sendMessage("Hello from \(name)", mentions: [], to: nil)
} }
// Each sender should have reached all other nodes // Wait and verify
for (sender, receivers) in messageMatrix { DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
let expectedReceivers = Set(helper.nodes.keys.filter { $0 != sender }) // Each sender should have reached all other nodes
#expect(receivers == expectedReceivers, "\(sender) didn't reach all nodes") for (sender, receivers) in messageMatrix {
} let expectedReceivers = Set(self.nodes.keys.filter { $0 != sender })
} XCTAssertEqual(receivers, expectedReceivers, "\(sender) didn't reach all nodes")
@Test func dynamicTopologyChanges() async throws {
// Start with Alice -> Bob -> Charlie
helper.connect("Alice", "Bob")
helper.connect("Bob", "Charlie")
try await confirmation("Topology changes handled") { receiveMessage in
var phase = 1
helper.nodes["Charlie"]!.messageDeliveryHandler = { message in
if phase == 1 && message.sender == "Alice" {
// Now change topology: disconnect Bob, connect Alice-Charlie
helper.disconnect("Alice", "Bob")
helper.disconnect("Bob", "Charlie")
helper.connect("Alice", "Charlie")
phase = 2
// Send another message
helper.nodes["Alice"]!.sendMessage("Direct message")
} else if phase == 2 && message.content == "Direct message" {
receiveMessage()
}
} }
expectation.fulfill()
// Allow relay handler to be set before first send
try await sleep(0.05)
helper.nodes["Alice"]!.sendMessage("Relayed message")
} }
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
} }
@Test func networkPartitionRecovery() async throws { func testDynamicTopologyChanges() {
// Create two partitions // Start with Alice -> Bob -> Charlie
helper.connect("Alice", "Bob") connect("Alice", "Bob")
helper.connect("Charlie", "David") connect("Bob", "Charlie")
let expectation = XCTestExpectation(description: "Topology changes handled")
var phase = 1
// Phase 1: Test initial topology
nodes["Charlie"]!.messageDeliveryHandler = { message in
if phase == 1 && message.sender == "Alice" {
// Now change topology: disconnect Bob, connect Alice-Charlie
self.disconnect("Alice", "Bob")
self.disconnect("Bob", "Charlie")
self.connect("Alice", "Charlie")
phase = 2
// Send another message
self.nodes["Alice"]!.sendMessage("Direct message", mentions: [], to: nil)
} else if phase == 2 && message.content == "Direct message" {
expectation.fulfill()
}
}
// Initial message through relay
// Allow relay handler to be set before first send
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
self.nodes["Alice"]!.sendMessage("Relayed message", mentions: [], to: nil)
}
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
}
func testNetworkPartitionRecovery() {
// Create two partitions
connect("Alice", "Bob")
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)
helper.nodes["Alice"]!.sendMessage("Before merge")
// Merge partitions after delay
try await sleep(0.05)
// Connect partitions
helper.connect("Bob", "Charlie")
// Enable relay
helper.setupRelay("Bob", nextHops: ["Charlie"])
helper.setupRelay("Charlie", nextHops: ["David"])
// Send message across merged network
helper.nodes["Alice"]!.sendMessage("After merge")
} }
#expect(messagesBeforeMerge == 0) // Try to send across partition (should fail)
#expect(messagesAfterMerge == 1) nodes["Alice"]!.sendMessage("Before merge", mentions: [], to: nil)
// Merge partitions after delay
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
// Connect partitions
self.connect("Bob", "Charlie")
// Enable relay
self.setupRelay("Bob", nextHops: ["Charlie"])
self.setupRelay("Charlie", nextHops: ["David"])
// Send message across merged network
self.nodes["Alice"]!.sendMessage("After merge", mentions: [], to: nil)
}
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
XCTAssertEqual(messagesBeforeMerge, 0)
XCTAssertEqual(messagesAfterMerge, 1)
} }
// MARK: - Mixed Message Type Scenarios // MARK: - Mixed Message Type Scenarios
@Test func mixedPublicPrivateMessages() async throws { func testMixedPublicPrivateMessages() throws {
helper.connectFullMesh() 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 { publicCount += 1
publicCount += 1
}
if publicCount == 2 && privateCount == 1 {
completion()
}
} }
// Alice sends mixed messages if publicCount == 2 && privateCount == 1 {
helper.nodes["Alice"]!.sendMessage("Public 1") expectation.fulfill()
helper.nodes["Alice"]!.sendPrivateMessage("Private to Bob", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob") }
helper.nodes["Alice"]!.sendMessage("Public 2")
} }
#expect(publicCount == 2) // Alice sends mixed messages
#expect(privateCount == 1) nodes["Alice"]!.sendMessage("Public 1", mentions: [], to: nil)
nodes["Alice"]!.sendPrivateMessage("Private to Bob", to: TestConstants.testPeerID2, recipientNickname: "Bob")
nodes["Alice"]!.sendMessage("Public 2", mentions: [], to: nil)
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
XCTAssertEqual(publicCount, 2)
XCTAssertEqual(privateCount, 1)
} }
@Test func encryptedAndUnencryptedMix() async throws { func testEncryptedAndUnencryptedMix() throws {
helper.connect("Alice", "Bob") connect("Alice", "Bob")
// Setup Noise session // Setup Noise session
try helper.establishNoiseSession("Alice", "Bob") try 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
try await confirmation("Both encrypted and plain messages work") { completion in // Setup handlers
// Plain path: send public message and count at Bob // Plain path: send public message and count at Bob
helper.nodes["Bob"]!.messageDeliveryHandler = { message in nodes["Bob"]!.messageDeliveryHandler = { message in
if message.content == "Plain message" { if message.content == "Plain message" { plainCount += 1 }
plainCount += 1 if plainCount == 1 && encryptedCount == 1 { expectation.fulfill() }
}
if plainCount == 1 && encryptedCount == 1 {
completion()
}
}
// Encrypted path: use NoiseSessionManager explicitly
let plaintext = "Encrypted message".data(using: .utf8)!
let ciphertext = try helper.noiseManagers["Alice"]!.encrypt(plaintext, for: helper.nodes["Bob"]!.peerID)
helper.nodes["Bob"]!.packetDeliveryHandler = { packet in
if packet.type == MessageType.noiseEncrypted.rawValue {
if let data = try? helper.noiseManagers["Bob"]!.decrypt(ciphertext, from: helper.nodes["Alice"]!.peerID),
data == plaintext {
encryptedCount = 1
if plainCount == 1 {
completion()
}
}
}
}
helper.nodes["Alice"]!.sendMessage("Plain message")
// Deliver encrypted packet directly
let encPacket = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext)
helper.nodes["Bob"]!.simulateIncomingPacket(encPacket)
} }
// Encrypted path: use NoiseSessionManager explicitly
let plaintext = "Encrypted message".data(using: .utf8)!
let ciphertext = try noiseManagers["Alice"]!.encrypt(plaintext, for: TestConstants.testPeerID2)
nodes["Bob"]!.packetDeliveryHandler = { packet in
if packet.type == MessageType.noiseEncrypted.rawValue {
if let data = try? self.noiseManagers["Bob"]!.decrypt(ciphertext, from: TestConstants.testPeerID1),
data == plaintext {
encryptedCount = 1
if plainCount == 1 { expectation.fulfill() }
}
}
}
nodes["Alice"]!.sendMessage("Plain message", mentions: [], to: nil)
// Deliver encrypted packet directly
let encPacket = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext)
nodes["Bob"]!.simulateIncomingPacket(encPacket)
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
} }
// MARK: - Network Resilience Tests // MARK: - Network Resilience Tests
@Test func messageDeliveryUnderChurn() async throws { func testMessageDeliveryUnderChurn() {
// Start with stable network // Start with stable network
helper.connectFullMesh() 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 {
helper.nodes["Alice"]!.sendMessage("Message \(i)") nodes["Alice"]!.sendMessage("Message \(i)", mentions: [], to: nil)
// 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()!
helper.disconnect(randomPair.0, randomPair.1) disconnect(randomPair.0, randomPair.1)
try await sleep(0.01)
helper.connect(randomPair.0, randomPair.1) DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self.connect(randomPair.0, randomPair.1)
} }
} }
} }
wait(for: [expectation], timeout: TestConstants.longTimeout)
XCTAssertEqual(receivedMessages.count, totalMessages)
} }
@Test func peerPresenceTrackingAndReconnection() async throws { func testPeerPresenceTrackingAndReconnection() {
helper.connect("Alice", "Bob") // Test that after disconnect/reconnect, message delivery resumes
connect("Alice", "Bob")
await confirmation("Delivery after reconnection") { delivered in let expectation = XCTestExpectation(description: "Delivery after reconnection")
helper.nodes["Bob"]!.messageDeliveryHandler = { message in var delivered = false
if message.content == "After reconnect" {
delivered() nodes["Bob"]!.messageDeliveryHandler = { message in
} if message.content == "After reconnect" && !delivered {
delivered = true
expectation.fulfill()
} }
// Simulate disconnect (out of range)
helper.disconnect("Alice", "Bob")
// Reconnect
helper.connect("Alice", "Bob")
// Send after reconnection
helper.nodes["Alice"]!.sendMessage("After reconnect")
} }
// Simulate disconnect (out of range)
disconnect("Alice", "Bob")
// Reconnect
connect("Alice", "Bob")
// Send after reconnection
nodes["Alice"]!.sendMessage("After reconnect", mentions: [], to: nil)
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
XCTAssertTrue(delivered)
} }
@Test func encryptedMessageAfterPeerRestart() async throws { func testEncryptedMessageAfterPeerRestart() {
helper.connect("Alice", "Bob") // Test that encrypted messages work after one peer restarts
connect("Alice", "Bob")
do { do {
try helper.establishNoiseSession("Alice", "Bob") try establishNoiseSession("Alice", "Bob")
} catch { } catch {
Issue.record("Failed to establish Noise session: \(error)") XCTFail("Failed to establish Noise session: \(error)")
} }
// Exchange an encrypted message // Exchange an encrypted message
await confirmation("First message received") { received in let firstExpectation = XCTestExpectation(description: "First message received")
helper.nodes["Bob"]!.messageDeliveryHandler = { message in nodes["Bob"]!.messageDeliveryHandler = { message in
if message.content == "Before restart" && message.isPrivate { if message.content == "Before restart" && message.isPrivate {
received() firstExpectation.fulfill()
}
} }
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()
helper.noiseManagers["Bob"] = NoiseSessionManager(localStaticKey: bobKey, keychain: helper.mockKeychain) noiseManagers["Bob"] = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
// Re-establish Noise handshake explicitly via managers // Re-establish Noise handshake explicitly via managers
do { do {
let m1 = try helper.noiseManagers["Bob"]!.initiateHandshake(with: helper.nodes["Alice"]!.peerID) let m1 = try noiseManagers["Bob"]!.initiateHandshake(with: TestConstants.testPeerID1)
let m2 = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m1)! let m2 = try noiseManagers["Alice"]!.handleIncomingHandshake(from: TestConstants.testPeerID2, message: m1)!
let m3 = try helper.noiseManagers["Bob"]!.handleIncomingHandshake(from: helper.nodes["Alice"]!.peerID, message: m2)! let m3 = try noiseManagers["Bob"]!.handleIncomingHandshake(from: TestConstants.testPeerID1, message: m2)!
_ = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m3) _ = try noiseManagers["Alice"]!.handleIncomingHandshake(from: TestConstants.testPeerID2, message: m3)
} catch { } catch {
Issue.record("Failed to re-establish Noise session after restart: \(error)") XCTFail("Failed to re-establish Noise session after restart: \(error)")
} }
// Now messages should work again - simulate encrypted packet // Now messages should work again
await confirmation("Message after restart received") { received in let secondExpectation = XCTestExpectation(description: "Message after restart received")
helper.nodes["Alice"]!.messageDeliveryHandler = { message in nodes["Alice"]!.messageDeliveryHandler = { message in
if message.content == "After restart success" && message.isPrivate { if message.content == "After restart success" && message.isPrivate {
received() secondExpectation.fulfill()
}
} }
}
do { // Simulate encrypted message using managers
let plaintext = "After restart success".data(using: .utf8)! do {
let ciphertext = try helper.noiseManagers["Bob"]!.encrypt(plaintext, for: helper.nodes["Alice"]!.peerID) let plaintext = "After restart success".data(using: .utf8)!
let packet = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext) let ciphertext = try noiseManagers["Bob"]!.encrypt(plaintext, for: TestConstants.testPeerID1)
helper.nodes["Alice"]!.packetDeliveryHandler = { pkt in let packet = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext)
if pkt.type == MessageType.noiseEncrypted.rawValue { nodes["Alice"]!.packetDeliveryHandler = { pkt in
if let data = try? helper.noiseManagers["Alice"]!.decrypt(pkt.payload, from: helper.nodes["Bob"]!.peerID), if pkt.type == MessageType.noiseEncrypted.rawValue {
String(data: data, encoding: .utf8) == "After restart success" { if let data = try? self.noiseManagers["Alice"]!.decrypt(pkt.payload, from: TestConstants.testPeerID2),
received() String(data: data, encoding: .utf8) == "After restart success" {
} secondExpectation.fulfill()
} }
} }
helper.nodes["Alice"]!.simulateIncomingPacket(packet)
} catch {
Issue.record("Encryption after restart failed: \(error)")
} }
nodes["Alice"]!.simulateIncomingPacket(packet)
} catch {
XCTFail("Encryption after restart failed: \(error)")
} }
wait(for: [secondExpectation], timeout: TestConstants.defaultTimeout)
} }
@Test func largeScaleNetwork() async throws { func testLargeScaleNetwork() {
// Create larger network // Create larger network
for i in 5...10 { for i in 5...10 {
helper.createNode("Node\(i)", peerID: PeerID(str: "PEER\(i)")) createNode("Node\(i)", peerID: "PEER\(i)")
} }
// Connect in ring topology with cross-connections // Connect in ring topology with cross-connections
let allNodes = Array(helper.nodes.keys).sorted() let allNodes = Array(nodes.keys).sorted()
for i in 0..<allNodes.count { for i in 0..<allNodes.count {
// Ring connection // Ring connection
helper.connect(allNodes[i], allNodes[(i + 1) % allNodes.count]) connect(allNodes[i], allNodes[(i + 1) % allNodes.count])
// Cross connection // Cross connection
if i + 3 < allNodes.count { if i + 3 < allNodes.count {
helper.connect(allNodes[i], allNodes[i + 3]) connect(allNodes[i], allNodes[i + 3])
} }
} }
await confirmation("Large network handles broadcast", expectedCount: helper.nodes.count - 1) { nodeReaced in let expectation = XCTestExpectation(description: "Large network handles broadcast")
// All nodes except Alice listen var nodesReached = Set<String>()
for (name, node) in helper.nodes where name != "Alice" {
node.messageDeliveryHandler = { message in // All nodes except Alice listen
if message.content == "Broadcast test" { for (name, node) in nodes where name != "Alice" {
nodeReaced() node.messageDeliveryHandler = { message in
if message.content == "Broadcast test" {
nodesReached.insert(name)
if nodesReached.count == self.nodes.count - 1 {
expectation.fulfill()
} }
} }
} }
// Alice broadcasts
helper.nodes["Alice"]!.sendMessage("Broadcast test")
} }
// Alice broadcasts
nodes["Alice"]!.sendMessage("Broadcast test", mentions: [], to: nil)
wait(for: [expectation], timeout: TestConstants.longTimeout)
XCTAssertEqual(nodesReached.count, nodes.count - 1)
} }
// MARK: - Stress Tests // MARK: - Stress Tests
@Test func highLoadScenario() async throws { func testHighLoadScenario() {
helper.connectFullMesh() connectFullMesh()
let messagesPerNode = 25 let messagesPerNode = 25
let expectedTotal = messagesPerNode * helper.nodes.count * (helper.nodes.count - 1) let expectedTotal = messagesPerNode * nodes.count * (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
await withTaskGroup(of: Void.self) { group in
for (name, node) in helper.nodes {
group.addTask {
for i in 0..<messagesPerNode {
node.sendMessage("\(name) message \(i)")
}
}
}
await group.waitForAll()
}
} }
// All nodes send many messages simultaneously
DispatchQueue.concurrentPerform(iterations: nodes.count) { index in
let nodeName = Array(nodes.keys).sorted()[index]
for i in 0..<messagesPerNode {
nodes[nodeName]!.sendMessage("\(nodeName) message \(i)", mentions: [], to: nil)
}
}
wait(for: [expectation], timeout: TestConstants.longTimeout)
XCTAssertGreaterThanOrEqual(receivedTotal, expectedTotal - 2)
} }
@Test func mixedTrafficPatterns() async throws { func testMixedTrafficPatterns() {
helper.connectFullMesh() connectFullMesh()
let expectation = XCTestExpectation(description: "Mixed traffic handled")
var metrics = [ var metrics = [
"public": 0, "public": 0,
"private": 0, "private": 0,
@@ -379,7 +434,7 @@ struct IntegrationTests {
] ]
// Setup complex handlers // Setup complex handlers
for (name, node) in helper.nodes { for (name, node) in nodes {
node.messageDeliveryHandler = { message in node.messageDeliveryHandler = { message in
if message.isPrivate { if message.isPrivate {
metrics["private"]! += 1 metrics["private"]! += 1
@@ -398,119 +453,222 @@ struct IntegrationTests {
} }
// Generate mixed traffic // Generate mixed traffic
helper.nodes["Alice"]!.sendMessage("Public broadcast") nodes["Alice"]!.sendMessage("Public broadcast", mentions: [], to: nil)
helper.nodes["Alice"]!.sendPrivateMessage("Private to Bob", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob") nodes["Alice"]!.sendPrivateMessage("Private to Bob", to: TestConstants.testPeerID2, recipientNickname: "Bob")
helper.nodes["Bob"]!.sendMessage("Mentioning @Charlie", mentions: ["Charlie"]) nodes["Bob"]!.sendMessage("Mentioning @Charlie", mentions: ["Charlie"], to: nil)
// Disconnect to force relay // Disconnect to force relay
helper.disconnect("Alice", "David") disconnect("Alice", "David")
helper.nodes["Alice"]!.sendMessage("Needs relay to David") nodes["Alice"]!.sendMessage("Needs relay to David", mentions: [], to: nil)
#expect(metrics["public", default: 0] > 0) DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
#expect(metrics["private", default: 0] > 0) XCTAssertGreaterThan(metrics["public"]!, 0)
#expect(metrics["mentions", default: 0] > 0) XCTAssertGreaterThan(metrics["private"]!, 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.
@Test func rehandshakeAfterDecryptionFailure() throws { func testRehandshakeAfterDecryptionFailure() throws {
// Alice <-> Bob connected // Alice <-> Bob connected
helper.connect("Alice", "Bob") connect("Alice", "Bob")
// Establish initial Noise session // Establish initial Noise session
try helper.establishNoiseSession("Alice", "Bob") try establishNoiseSession("Alice", "Bob")
guard let aliceManager = helper.noiseManagers["Alice"], guard let aliceManager = noiseManagers["Alice"],
let bobManager = helper.noiseManagers["Bob"], let bobManager = noiseManagers["Bob"],
let alicePeerID = helper.nodes["Alice"]?.peerID, let alicePeerID = nodes["Alice"]?.peerID,
let bobPeerID = helper.nodes["Bob"]?.peerID let bobPeerID = nodes["Bob"]?.peerID else {
else { return XCTFail("Missing managers or peer IDs")
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)
#expect(decrypted1 == plaintext1) XCTAssertEqual(decrypted1, plaintext1)
// Simulate decryption failure by corrupting ciphertext // Simulate decryption failure by corrupting ciphertext
let corrupted = encrypted1.prefix(15) var corrupted = encrypted1
#expect(throws: NoiseError.invalidCiphertext) { if !corrupted.isEmpty { corrupted[corrupted.count - 1] ^= 0xFF }
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 helper.establishNoiseSession("Bob", "Alice") try 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)
#expect(decrypted2 == plaintext2) XCTAssertEqual(decrypted2, plaintext2)
} }
@Test func endToEndSecurityScenario() async throws {
helper.connect("Alice", "Bob") func testEndToEndSecurityScenario() throws {
helper.connect("Bob", "Charlie") // Charlie will try to eavesdrop connect("Alice", "Bob")
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 helper.establishNoiseSession("Alice", "Bob") try establishNoiseSession("Alice", "Bob")
await confirmation("Secure communication maintained", expectedCount: 2) { receivedPacket in let expectation = XCTestExpectation(description: "Secure communication maintained")
var bobDecrypted = false
var charlieIntercepted = false
// Setup encryption at Alice // Setup encryption at Alice
helper.nodes["Alice"]!.packetDeliveryHandler = { packet in 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? helper.noiseManagers["Alice"]!.encrypt(packet.payload, for: helper.nodes["Bob"]!.peerID) { if let encrypted = try? self.noiseManagers["Alice"]!.encrypt(packet.payload, for: TestConstants.testPeerID2) {
let encPacket = BitchatPacket( let encPacket = BitchatPacket(
type: 0x02, type: 0x02,
senderID: packet.senderID, senderID: packet.senderID,
recipientID: packet.recipientID, recipientID: packet.recipientID,
timestamp: packet.timestamp, timestamp: packet.timestamp,
payload: encrypted, payload: encrypted,
signature: packet.signature, signature: packet.signature,
ttl: packet.ttl ttl: packet.ttl
) )
helper.nodes["Bob"]!.simulateIncomingPacket(encPacket) self.nodes["Bob"]!.simulateIncomingPacket(encPacket)
}
} }
} }
}
// Bob can decrypt // Bob can decrypt
helper.nodes["Bob"]!.packetDeliveryHandler = { packet in nodes["Bob"]!.packetDeliveryHandler = { packet in
if packet.type == 0x02 { if packet.type == 0x02 {
receivedPacket() if let decrypted = try? self.noiseManagers["Bob"]!.decrypt(packet.payload, from: TestConstants.testPeerID1),
if let decrypted = try? helper.noiseManagers["Bob"]!.decrypt(packet.payload, from: helper.nodes["Alice"]!.peerID) { let message = BitchatMessage(decrypted) {
#expect(BitchatMessage(decrypted)?.content == "Secret message") bobDecrypted = message.content == "Secret message"
} else { expectation.fulfill()
Issue.record("Bob was unable to decrypt the message") }
}
// Relay encrypted packet to Charlie // Relay encrypted packet to Charlie
helper.nodes["Charlie"]!.simulateIncomingPacket(packet) self.nodes["Charlie"]!.simulateIncomingPacket(packet)
}
}
// Charlie cannot decrypt
nodes["Charlie"]!.packetDeliveryHandler = { packet in
if packet.type == 0x02 {
charlieIntercepted = true
// Try to decrypt (should fail)
do {
_ = try self.noiseManagers["Charlie"]?.decrypt(packet.payload, from: TestConstants.testPeerID1)
XCTFail("Charlie should not be able to decrypt")
} catch {
// Expected
} }
} }
}
// Charlie cannot decrypt // Send encrypted private message
helper.nodes["Charlie"]!.packetDeliveryHandler = { packet in nodes["Alice"]!.sendPrivateMessage("Secret message", to: TestConstants.testPeerID2, recipientNickname: "Bob")
if packet.type == 0x02 {
receivedPacket() wait(for: [expectation], timeout: TestConstants.defaultTimeout)
#expect(throws: NoiseSessionError.sessionNotFound, "Charlie should not be able to decrypt") { XCTAssertTrue(bobDecrypted)
_ = try helper.noiseManagers["Charlie"]?.decrypt(packet.payload, from: helper.nodes["Alice"]!.peerID) XCTAssertTrue(charlieIntercepted)
} }
}
// MARK: - Helper Methods
private func createNode(_ name: String, peerID: PeerID) {
let node = MockBLEService()
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])
} }
// Send encrypted private message
helper.nodes["Alice"]!.sendPrivateMessage("Secret message", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob")
} }
} }
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)
}
} }
@@ -1,123 +0,0 @@
//
// 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)
}
}
+19 -21
View File
@@ -1,9 +1,8 @@
import Testing import XCTest
import Foundation
@testable import bitchat @testable import bitchat
struct LocationChannelsTests { final class LocationChannelsTests: XCTestCase {
@Test func geohashEncoderPrecisionMapping() { func testGeohashEncoderPrecisionMapping() {
// 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
@@ -13,35 +12,34 @@ struct LocationChannelsTests {
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)
#expect(block.count == 7) XCTAssertEqual(block.count, 7)
#expect(neighborhood.count == 6) XCTAssertEqual(neighborhood.count, 6)
#expect(city.count == 5) XCTAssertEqual(city.count, 5)
#expect(region.count == 4) XCTAssertEqual(region.count, 4)
#expect(country.count == 2) XCTAssertEqual(country.count, 2)
// All prefixes must match progressively // All prefixes must match progressively
#expect(block.hasPrefix(neighborhood)) XCTAssertTrue(block.hasPrefix(neighborhood))
#expect(neighborhood.hasPrefix(city)) XCTAssertTrue(neighborhood.hasPrefix(city))
#expect(city.hasPrefix(region)) XCTAssertTrue(city.hasPrefix(region))
#expect(region.hasPrefix(country)) XCTAssertTrue(region.hasPrefix(country))
} }
@Test func nostrGeohashFilterEncoding() throws { func testNostrGeohashFilterEncoding() 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]
#expect(json.contains("20000")) XCTAssertTrue(json.contains("20000"))
#expect(json.contains("\"#g\":[\"\(gh)\"]")) XCTAssertTrue(json.contains("\"#g\":[\"\(gh)\"]"))
} }
@Test func perGeohashIdentityDeterministic() throws { func testPerGeohashIdentityDeterministic() 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 idBridge.deriveIdentity(forGeohash: gh) let id1 = try NostrIdentityBridge.deriveIdentity(forGeohash: gh)
let id2 = try idBridge.deriveIdentity(forGeohash: gh) let id2 = try NostrIdentityBridge.deriveIdentity(forGeohash: gh)
#expect(id1.publicKeyHex == id2.publicKeyHex) XCTAssertEqual(id1.publicKeyHex, id2.publicKeyHex)
} }
} }
+68 -11
View File
@@ -1,9 +1,8 @@
import Testing import XCTest
import Foundation
@testable import bitchat @testable import bitchat
@MainActor @MainActor
struct LocationNotesManagerTests { final class LocationNotesManagerTests: XCTestCase {
// func testSubscribeWithoutRelaysSetsNoRelaysState() { // func testSubscribeWithoutRelaysSetsNoRelaysState() {
// var subscribeCalled = false // var subscribeCalled = false
// let deps = LocationNotesDependencies( // let deps = LocationNotesDependencies(
@@ -48,15 +47,15 @@ struct LocationNotesManagerTests {
// XCTAssertNotEqual(manager.errorMessage, "location_notes.error.no_relays") // XCTAssertNotEqual(manager.errorMessage, "location_notes.error.no_relays")
// } // }
@Test func subscribeUsesGeoRelaysAndAppendsNotes() { func testSubscribeUsesGeoRelaysAndAppendsNotes() {
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
#expect(filter.kinds == [1]) XCTAssertEqual(filter.kinds, [1])
#expect(!id.isEmpty) XCTAssertFalse(id.isEmpty)
relaysCaptured = relays relaysCaptured = relays
storedHandler = handler storedHandler = handler
storedEOSE = eose storedEOSE = eose
@@ -68,8 +67,8 @@ struct LocationNotesManagerTests {
) )
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps) let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
#expect(relaysCaptured == ["wss://relay.one"]) XCTAssertEqual(relaysCaptured, ["wss://relay.one"])
#expect(manager.state == .loading) XCTAssertEqual(manager.state, .loading)
var event = NostrEvent( var event = NostrEvent(
pubkey: "pub", pubkey: "pub",
@@ -82,12 +81,70 @@ struct LocationNotesManagerTests {
storedHandler?(event) storedHandler?(event)
storedEOSE?() storedEOSE?()
#expect(manager.state == .ready) XCTAssertEqual(manager.state, .ready)
#expect(manager.notes.count == 1) XCTAssertEqual(manager.notes.count, 1)
#expect(manager.notes.first?.content == "hi") XCTAssertEqual(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
@@ -1,57 +0,0 @@
//
// 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
}
}
+49 -17
View File
@@ -26,12 +26,13 @@ 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 {
private let bus: MockBLEBus // Enable automatic flooding for public messages in integration tests only
static var autoFloodEnabled: Bool = false
// MARK: - Properties matching BLEService // MARK: - Properties matching BLEService
weak var delegate: BitchatDelegate? weak var delegate: BitchatDelegate?
var myPeerID = PeerID(str: "MOCK1234") var myPeerID: PeerID = "MOCK1234"
var myNickname: String = "MockUser" var myNickname: String = "MockUser"
private let mockKeychain = MockKeychain() private let mockKeychain = MockKeychain()
@@ -59,8 +60,8 @@ final class MockBLEService: NSObject {
// MARK: - Initialization // MARK: - Initialization
init(bus: MockBLEBus) { override init() {
self.bus = bus super.init()
} }
// MARK: - Methods matching BLEService // MARK: - Methods matching BLEService
@@ -70,15 +71,42 @@ 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() {
bus.register(self, for: myPeerID) MockBLEService.registry[myPeerID] = self
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] {
bus.neighbors(of: myPeerID) guard let ids = MockBLEService.adjacency[myPeerID] else { return [] }
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() {
@@ -145,7 +173,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 bus // Deliver public messages to adjacent peers via test bus
if recipientID == nil { if recipientID == nil {
for neighbor in neighbors() { for neighbor in neighbors() {
neighbor.simulateIncomingPacket(packet) neighbor.simulateIncomingPacket(packet)
@@ -191,16 +219,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 bus.isDirectNeighbor(myPeerID, recipientPeerID), if let neighbors = MockBLEService.adjacency[myPeerID], neighbors.contains(recipientPeerID),
let target = bus.service(for: recipientPeerID) { let target = MockBLEService.registry[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 = bus.service(for: recipientPeerID) { if let target = MockBLEService.registry[recipientPeerID] {
target.simulateIncomingPacket(packet) target.simulateIncomingPacket(packet)
} }
for neighbor in neighbors() where neighbor.peerID != recipientPeerID { if let neighbors = MockBLEService.adjacency[myPeerID] {
neighbor.simulateIncomingPacket(packet) for peer in neighbors where peer != recipientPeerID {
if let neighbor = MockBLEService.registry[peer] {
neighbor.simulateIncomingPacket(packet)
}
}
} }
} }
} }
@@ -247,14 +279,14 @@ final class MockBLEService: NSObject {
func simulateConnectedPeer(_ peerID: PeerID) { func simulateConnectedPeer(_ peerID: PeerID) {
registerIfNeeded() registerIfNeeded()
bus.connect(myPeerID, peerID) MockBLEService.connectPeers(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) {
bus.disconnect(myPeerID, peerID) MockBLEService.disconnectPeers(myPeerID, peerID)
connectedPeers.remove(peerID) connectedPeers.remove(peerID)
delegate?.didDisconnectFromPeer(peerID) delegate?.didDisconnectFromPeer(peerID)
delegate?.didUpdatePeerList(Array(connectedPeers)) delegate?.didUpdatePeerList(Array(connectedPeers))
@@ -287,7 +319,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 bus.autoFloodEnabled, if MockBLEService.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
@@ -321,8 +353,8 @@ typealias MockSimplifiedBluetoothService = MockBLEService
// MARK: - Helpers // MARK: - Helpers
extension MockBLEService { extension MockBLEService {
convenience init(peerID: PeerID, nickname: String, bus: MockBLEBus) { convenience init(peerID: PeerID, nickname: String) {
self.init(bus: bus) self.init()
myPeerID = peerID myPeerID = peerID
mockNickname = nickname mockNickname = nickname
} }
-18
View File
@@ -44,21 +44,3 @@ 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)
}
}
+247 -212
View File
@@ -6,123 +6,135 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import Testing import XCTest
import CryptoKit import CryptoKit
import Foundation
@testable import bitchat @testable import bitchat
struct NoiseProtocolTests { final class NoiseProtocolTests: XCTestCase {
private let aliceKey = Curve25519.KeyAgreement.PrivateKey() var aliceKey: Curve25519.KeyAgreement.PrivateKey!
private let bobKey = Curve25519.KeyAgreement.PrivateKey() var bobKey: Curve25519.KeyAgreement.PrivateKey!
private let mockKeychain = MockKeychain() var aliceSession: NoiseSession!
var bobSession: NoiseSession!
private var mockKeychain: MockKeychain!
private let alicePeerID = PeerID(str: UUID().uuidString) override func setUp() {
private let bobPeerID = PeerID(str: UUID().uuidString) super.setUp()
aliceKey = Curve25519.KeyAgreement.PrivateKey()
bobKey = Curve25519.KeyAgreement.PrivateKey()
mockKeychain = MockKeychain()
}
private let aliceSession: NoiseSession override func tearDown() {
private let bobSession: NoiseSession aliceSession = nil
bobSession = nil
mockKeychain = nil
super.tearDown()
}
init() { // MARK: - Basic Handshake Tests
func testXXPatternHandshake() throws {
// Create sessions
aliceSession = NoiseSession( aliceSession = NoiseSession(
peerID: alicePeerID, peerID: TestConstants.testPeerID2,
role: .initiator, role: .initiator,
keychain: mockKeychain, keychain: mockKeychain,
localStaticKey: aliceKey localStaticKey: aliceKey
) )
bobSession = NoiseSession( bobSession = NoiseSession(
peerID: bobPeerID, peerID: TestConstants.testPeerID1,
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()
#expect(!message1.isEmpty) XCTAssertFalse(message1.isEmpty)
#expect(aliceSession.getState() == .handshaking) XCTAssertEqual(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)
#expect(message2 != nil) XCTAssertNotNil(message2)
#expect(!message2!.isEmpty) XCTAssertFalse(message2!.isEmpty)
#expect(bobSession.getState() == .handshaking) XCTAssertEqual(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!)
#expect(message3 != nil) XCTAssertNotNil(message3)
#expect(!message3!.isEmpty) XCTAssertFalse(message3!.isEmpty)
#expect(aliceSession.getState() == .established) XCTAssertEqual(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!)
#expect(finalMessage == nil) // No more messages needed XCTAssertNil(finalMessage) // No more messages needed
#expect(bobSession.getState() == .established) XCTAssertEqual(bobSession.getState(), .established)
// Verify both sessions are established // Verify both sessions are established
#expect(aliceSession.isEstablished()) XCTAssertTrue(aliceSession.isEstablished())
#expect(bobSession.isEstablished()) XCTAssertTrue(bobSession.isEstablished())
// Verify they have each other's static keys // Verify they have each other's static keys
#expect(aliceSession.getRemoteStaticPublicKey()?.rawRepresentation == bobKey.publicKey.rawRepresentation) XCTAssertEqual(aliceSession.getRemoteStaticPublicKey()?.rawRepresentation, bobKey.publicKey.rawRepresentation)
#expect(bobSession.getRemoteStaticPublicKey()?.rawRepresentation == aliceKey.publicKey.rawRepresentation) XCTAssertEqual(bobSession.getRemoteStaticPublicKey()?.rawRepresentation, aliceKey.publicKey.rawRepresentation)
} }
@Test func handshakeStateValidation() throws { func testHandshakeStateValidation() throws {
aliceSession = NoiseSession(
peerID: TestConstants.testPeerID2,
role: .initiator,
keychain: mockKeychain,
localStaticKey: aliceKey
)
// Cannot process message before starting handshake // Cannot process message before starting handshake
#expect(throws: NoiseSessionError.invalidState) { XCTAssertThrowsError(try aliceSession.processHandshakeMessage(Data()))
try aliceSession.processHandshakeMessage(Data())
}
// Start handshake // Start handshake
_ = try aliceSession.startHandshake() _ = try aliceSession.startHandshake()
// Cannot start handshake twice // Cannot start handshake twice
#expect(throws: NoiseSessionError.invalidState) { XCTAssertThrowsError(try aliceSession.startHandshake())
try aliceSession.startHandshake()
}
} }
// MARK: - Encryption/Decryption Tests // MARK: - Encryption/Decryption Tests
@Test func basicEncryptionDecryption() throws { func testBasicEncryptionDecryption() throws {
try performHandshake(initiator: aliceSession, responder: bobSession) // Establish sessions
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)
#expect(ciphertext != plaintext) XCTAssertNotEqual(ciphertext, plaintext)
#expect(ciphertext.count > plaintext.count) // Should have overhead XCTAssertGreaterThan(ciphertext.count, plaintext.count) // Should have overhead
// Bob decrypts // Bob decrypts
let decrypted = try bobSession.decrypt(ciphertext) let decrypted = try bobSession.decrypt(ciphertext)
#expect(decrypted == plaintext) XCTAssertEqual(decrypted, plaintext)
} }
@Test func bidirectionalEncryption() throws { func testBidirectionalEncryption() throws {
try performHandshake(initiator: aliceSession, responder: bobSession) try establishSessions()
// 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)
#expect(bobReceived == aliceMessage) XCTAssertEqual(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)
#expect(aliceReceived == bobMessage) XCTAssertEqual(aliceReceived, bobMessage)
} }
@Test func largeMessageEncryption() throws { func testLargeMessageEncryption() throws {
try performHandshake(initiator: aliceSession, responder: bobSession) try establishSessions()
// Create a large message // Create a large message
let largeMessage = TestHelpers.generateRandomData(length: 100_000) let largeMessage = TestHelpers.generateRandomData(length: 100_000)
@@ -131,78 +143,81 @@ struct NoiseProtocolTests {
let ciphertext = try aliceSession.encrypt(largeMessage) let ciphertext = try aliceSession.encrypt(largeMessage)
let decrypted = try bobSession.decrypt(ciphertext) let decrypted = try bobSession.decrypt(ciphertext)
#expect(decrypted == largeMessage) XCTAssertEqual(decrypted, largeMessage)
} }
@Test func encryptionBeforeHandshake() { func testEncryptionBeforeHandshake() {
aliceSession = NoiseSession(
peerID: TestConstants.testPeerID2,
role: .initiator,
keychain: mockKeychain,
localStaticKey: aliceKey
)
let plaintext = "test".data(using: .utf8)! let plaintext = "test".data(using: .utf8)!
#expect(throws: NoiseSessionError.notEstablished) { // Should throw when not established
try aliceSession.encrypt(plaintext) XCTAssertThrowsError(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
@Test func sessionManagerBasicOperations() throws { func testSessionManagerBasicOperations() throws {
let manager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) let manager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
#expect(manager.getSession(for: alicePeerID) == nil) // Create session
let session = manager.createSession(for: TestConstants.testPeerID2, role: .initiator)
_ = try manager.initiateHandshake(with: alicePeerID) XCTAssertNotNil(session)
#expect(manager.getSession(for: alicePeerID) != nil)
// Get session // Get session
let retrieved = manager.getSession(for: alicePeerID) let retrieved = manager.getSession(for: TestConstants.testPeerID2)
#expect(retrieved != nil) XCTAssertNotNil(retrieved)
XCTAssertTrue(session === retrieved)
// Remove session // Remove session
manager.removeSession(for: alicePeerID) manager.removeSession(for: TestConstants.testPeerID2)
#expect(manager.getSession(for: alicePeerID) == nil) XCTAssertNil(manager.getSession(for: TestConstants.testPeerID2))
} }
@Test func sessionManagerHandshakeInitiation() throws { func testSessionManagerHandshakeInitiation() 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: alicePeerID) let handshakeData = try manager.initiateHandshake(with: TestConstants.testPeerID2)
#expect(!handshakeData.isEmpty) XCTAssertFalse(handshakeData.isEmpty)
// Session should exist // Session should exist
let session = manager.getSession(for: alicePeerID) let session = manager.getSession(for: TestConstants.testPeerID2)
#expect(session != nil) XCTAssertNotNil(session)
#expect(session?.getState() == .handshaking) XCTAssertEqual(session?.getState(), .handshaking)
} }
@Test func sessionManagerIncomingHandshake() throws { func testSessionManagerIncomingHandshake() 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: alicePeerID) let message1 = try aliceManager.initiateHandshake(with: TestConstants.testPeerID2)
// Bob responds // Bob responds
let message2 = try bobManager.handleIncomingHandshake(from: bobPeerID, message: message1) let message2 = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: message1)
#expect(message2 != nil) XCTAssertNotNil(message2)
// Continue handshake // Continue handshake
let message3 = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: message2!) let message3 = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: message2!)
#expect(message3 != nil) XCTAssertNotNil(message3)
// Complete handshake // Complete handshake
let finalMessage = try bobManager.handleIncomingHandshake(from: bobPeerID, message: message3!) let finalMessage = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: message3!)
#expect(finalMessage == nil) XCTAssertNil(finalMessage)
// Both should have established sessions // Both should have established sessions
#expect(aliceManager.getSession(for: alicePeerID)?.isEstablished() == true) XCTAssertTrue(aliceManager.getSession(for: TestConstants.testPeerID2)?.isEstablished() ?? false)
#expect(bobManager.getSession(for: bobPeerID)?.isEstablished() == true) XCTAssertTrue(bobManager.getSession(for: TestConstants.testPeerID1)?.isEstablished() ?? false)
} }
@Test func sessionManagerEncryptionDecryption() throws { func testSessionManagerEncryptionDecryption() 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)
@@ -211,17 +226,17 @@ struct NoiseProtocolTests {
// 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: alicePeerID) let ciphertext = try aliceManager.encrypt(plaintext, for: TestConstants.testPeerID2)
// Decrypt with manager // Decrypt with manager
let decrypted = try bobManager.decrypt(ciphertext, from: bobPeerID) let decrypted = try bobManager.decrypt(ciphertext, from: TestConstants.testPeerID1)
#expect(decrypted == plaintext) XCTAssertEqual(decrypted, plaintext)
} }
// MARK: - Security Tests // MARK: - Security Tests
@Test func tamperedCiphertextDetection() throws { func testTamperedCiphertextDetection() throws {
try performHandshake(initiator: aliceSession, responder: bobSession) try establishSessions()
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)
@@ -230,19 +245,11 @@ struct NoiseProtocolTests {
ciphertext[ciphertext.count / 2] ^= 0xFF ciphertext[ciphertext.count / 2] ^= 0xFF
// Decryption should fail // Decryption should fail
if #available(macOS 14.4, iOS 17.4, *) { XCTAssertThrowsError(try bobSession.decrypt(ciphertext))
#expect(throws: CryptoKitError.authenticationFailure) {
try bobSession.decrypt(ciphertext)
}
} else {
#expect(throws: (any Error).self) {
try bobSession.decrypt(ciphertext)
}
}
} }
@Test func replayPrevention() throws { func testReplayPrevention() throws {
try performHandshake(initiator: aliceSession, responder: bobSession) try establishSessions()
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)
@@ -251,18 +258,16 @@ struct NoiseProtocolTests {
_ = try bobSession.decrypt(ciphertext) _ = try bobSession.decrypt(ciphertext)
// Replaying the same ciphertext should fail // Replaying the same ciphertext should fail
#expect(throws: NoiseError.replayDetected) { XCTAssertThrowsError(try bobSession.decrypt(ciphertext))
try bobSession.decrypt(ciphertext)
}
} }
@Test func sessionIsolation() throws { func testSessionIsolation() throws {
// Create two separate session pairs // Create two separate session pairs
let aliceSession1 = NoiseSession(peerID: PeerID(str: "peer1"), role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey) let aliceSession1 = NoiseSession(peerID: "peer1", role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
let bobSession1 = NoiseSession(peerID: PeerID(str: "alice1"), role: .responder, keychain: mockKeychain, localStaticKey: bobKey) let bobSession1 = NoiseSession(peerID: "alice1", role: .responder, keychain: mockKeychain, localStaticKey: bobKey)
let aliceSession2 = NoiseSession(peerID: PeerID(str: "peer2"), role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey) let aliceSession2 = NoiseSession(peerID: "peer2", role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
let bobSession2 = NoiseSession(peerID: PeerID(str: "alice2"), role: .responder, keychain: mockKeychain, localStaticKey: bobKey) let bobSession2 = NoiseSession(peerID: "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)
@@ -273,24 +278,16 @@ struct NoiseProtocolTests {
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
if #available(macOS 14.4, iOS 17.4, *) { XCTAssertThrowsError(try bobSession2.decrypt(ciphertext1))
#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)
#expect(decrypted == plaintext) XCTAssertEqual(decrypted, plaintext)
} }
// MARK: - Session Recovery Tests // MARK: - Session Recovery Tests
@Test func peerRestartDetection() throws { func testPeerRestartDetection() 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)
@@ -298,38 +295,38 @@ struct NoiseProtocolTests {
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: alicePeerID) let message1 = try aliceManager.encrypt("Hello".data(using: .utf8)!, for: TestConstants.testPeerID2)
_ = try bobManager.decrypt(message1, from: bobPeerID) _ = try bobManager.decrypt(message1, from: TestConstants.testPeerID1)
let message2 = try bobManager.encrypt("World".data(using: .utf8)!, for: bobPeerID) let message2 = try bobManager.encrypt("World".data(using: .utf8)!, for: TestConstants.testPeerID1)
_ = try aliceManager.decrypt(message2, from: alicePeerID) _ = try aliceManager.decrypt(message2, from: TestConstants.testPeerID2)
// 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: bobPeerID) let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: TestConstants.testPeerID1)
// Alice should accept the new handshake (clearing old session) // Alice should accept the new handshake (clearing old session)
let newHandshake2 = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake1) let newHandshake2 = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: newHandshake1)
#expect(newHandshake2 != nil) XCTAssertNotNil(newHandshake2)
// Complete the new handshake // Complete the new handshake
let newHandshake3 = try bobManagerRestarted.handleIncomingHandshake(from: bobPeerID, message: newHandshake2!) let newHandshake3 = try bobManagerRestarted.handleIncomingHandshake(from: TestConstants.testPeerID1, message: newHandshake2!)
#expect(newHandshake3 != nil) XCTAssertNotNil(newHandshake3)
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake3!) _ = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, 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: bobPeerID) let encrypted = try bobManagerRestarted.encrypt(testMessage, for: TestConstants.testPeerID1)
let decrypted = try aliceManager.decrypt(encrypted, from: alicePeerID) let decrypted = try aliceManager.decrypt(encrypted, from: TestConstants.testPeerID2)
#expect(decrypted == testMessage) XCTAssertEqual(decrypted, testMessage)
} }
@Test func nonceDesynchronizationRecovery() throws { func testNonceDesynchronizationRecovery() throws {
// Create two sessions // Create two sessions
let aliceSession = NoiseSession(peerID: alicePeerID, role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey) aliceSession = NoiseSession(peerID: TestConstants.testPeerID2, role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
let bobSession = NoiseSession(peerID: bobPeerID, role: .responder, keychain: mockKeychain, localStaticKey: bobKey) bobSession = NoiseSession(peerID: TestConstants.testPeerID1, role: .responder, keychain: mockKeychain, localStaticKey: bobKey)
// Establish sessions // Establish sessions
try performHandshake(initiator: aliceSession, responder: bobSession) try performHandshake(initiator: aliceSession, responder: bobSession)
@@ -347,12 +344,10 @@ struct NoiseProtocolTests {
// 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)!)
#expect(throws: Never.self) { XCTAssertNoThrow(try bobSession.decrypt(desyncMessage))
try bobSession.decrypt(desyncMessage)
}
} }
@Test func concurrentEncryption() async throws { func testConcurrentEncryption() 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)
@@ -360,35 +355,37 @@ struct NoiseProtocolTests {
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 }
}
// Decrypt messages sequentially to avoid triggering anti-replay with reordering // Decrypt messages sequentially to avoid triggering anti-replay with reordering
for i in 0..<messageCount { for i in 0..<messageCount {
do { do {
guard let encrypted = encryptedMessages[i] else { guard let encrypted = encryptedMessages[i] else {
Issue.record("Missing encrypted message \(i)") XCTFail("Missing encrypted message \(i)")
return return
}
let decrypted = try bobManager.decrypt(encrypted, from: bobPeerID)
let expected = "Concurrent message \(i)".data(using: .utf8)!
#expect(decrypted == expected)
completion()
} catch {
Issue.record("Decryption failed for message \(i): \(error)")
} }
let decrypted = try bobManager.decrypt(encrypted, from: TestConstants.testPeerID1)
let expected = "Concurrent message \(i)".data(using: .utf8)!
XCTAssertEqual(decrypted, expected)
expectation.fulfill()
} catch {
XCTFail("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)
@@ -399,10 +396,10 @@ struct NoiseProtocolTests {
let sessions = aliceManager.getSessionsNeedingRekey() let sessions = aliceManager.getSessionsNeedingRekey()
// New session should not need rekey // New session should not need rekey
#expect(sessions.isEmpty || sessions.allSatisfy { !$0.needsRekey }) XCTAssertTrue(sessions.isEmpty || sessions.allSatisfy { !$0.needsRekey })
} }
@Test func handshakeAfterDecryptionFailure() throws { func testHandshakeAfterDecryptionFailure() 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)
@@ -411,25 +408,17 @@ struct NoiseProtocolTests {
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: alicePeerID) var encrypted = try aliceManager.encrypt("Test".data(using: .utf8)!, for: TestConstants.testPeerID2)
encrypted[10] ^= 0xFF // Corrupt the data encrypted[10] ^= 0xFF // Corrupt the data
// Decryption should fail // Decryption should fail
if #available(macOS 14.4, iOS 17.4, *) { XCTAssertThrowsError(try bobManager.decrypt(encrypted, from: TestConstants.testPeerID1))
#expect(throws: CryptoKitError.authenticationFailure) {
try bobManager.decrypt(encrypted, from: bobPeerID)
}
} else {
#expect(throws: (any Error).self) {
try bobManager.decrypt(encrypted, from: bobPeerID)
}
}
// Bob should still have the session (it's not removed on single failure) // Bob should still have the session (it's not removed on single failure)
#expect(bobManager.getSession(for: bobPeerID) != nil) XCTAssertNotNil(bobManager.getSession(for: TestConstants.testPeerID1))
} }
@Test func handshakeAlwaysAcceptedWithExistingSession() throws { func testHandshakeAlwaysAcceptedWithExistingSession() 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)
@@ -438,38 +427,38 @@ struct NoiseProtocolTests {
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
// Verify sessions are established // Verify sessions are established
#expect(aliceManager.getSession(for: alicePeerID)?.isEstablished() == true) XCTAssertTrue(aliceManager.getSession(for: TestConstants.testPeerID2)?.isEstablished() ?? false)
#expect(bobManager.getSession(for: bobPeerID)?.isEstablished() == true) XCTAssertTrue(bobManager.getSession(for: TestConstants.testPeerID1)?.isEstablished() ?? false)
// 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: alicePeerID) let encrypted = try aliceManager.encrypt(testMessage, for: TestConstants.testPeerID2)
let decrypted = try bobManager.decrypt(encrypted, from: bobPeerID) let decrypted = try bobManager.decrypt(encrypted, from: TestConstants.testPeerID1)
#expect(decrypted == testMessage) XCTAssertEqual(decrypted, testMessage)
// Alice clears her session (simulating decryption failure) // Alice clears her session (simulating decryption failure)
aliceManager.removeSession(for: alicePeerID) aliceManager.removeSession(for: TestConstants.testPeerID2)
// Alice initiates new handshake despite Bob having valid session // Alice initiates new handshake despite Bob having valid session
let newHandshake1 = try aliceManager.initiateHandshake(with: alicePeerID) let newHandshake1 = try aliceManager.initiateHandshake(with: TestConstants.testPeerID2)
// 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: bobPeerID, message: newHandshake1) let newHandshake2 = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: newHandshake1)
#expect(newHandshake2 != nil, "Bob should accept handshake despite having valid session") XCTAssertNotNil(newHandshake2, "Bob should accept handshake despite having valid session")
// Complete the handshake // Complete the handshake
let newHandshake3 = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake2!) let newHandshake3 = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: newHandshake2!)
#expect(newHandshake3 != nil) XCTAssertNotNil(newHandshake3)
_ = try bobManager.handleIncomingHandshake(from: bobPeerID, message: newHandshake3!) _ = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, 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: alicePeerID) let encrypted2 = try aliceManager.encrypt(testMessage2, for: TestConstants.testPeerID2)
let decrypted2 = try bobManager.decrypt(encrypted2, from: bobPeerID) let decrypted2 = try bobManager.decrypt(encrypted2, from: TestConstants.testPeerID1)
#expect(decrypted2 == testMessage2) XCTAssertEqual(decrypted2, testMessage2)
} }
@Test func nonceDesynchronizationCausesRehandshake() throws { func testNonceDesynchronizationCausesRehandshake() 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)
@@ -479,43 +468,89 @@ struct NoiseProtocolTests {
// 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: alicePeerID) let msg = try aliceManager.encrypt("Message \(i)".data(using: .utf8)!, for: TestConstants.testPeerID2)
_ = try bobManager.decrypt(msg, from: bobPeerID) _ = try bobManager.decrypt(msg, from: TestConstants.testPeerID1)
} }
// 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: alicePeerID) _ = try aliceManager.encrypt("Lost message \(i)".data(using: .utf8)!, for: TestConstants.testPeerID2)
} }
// 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: alicePeerID) let desyncMessage = try aliceManager.encrypt("This now succeeds".data(using: .utf8)!, for: TestConstants.testPeerID2)
#expect(throws: Never.self) { XCTAssertNoThrow(try bobManager.decrypt(desyncMessage, from: TestConstants.testPeerID1))
try bobManager.decrypt(desyncMessage, from: bobPeerID)
}
// Bob clears session and initiates new handshake // Bob clears session and initiates new handshake
bobManager.removeSession(for: bobPeerID) bobManager.removeSession(for: TestConstants.testPeerID1)
let rehandshake1 = try bobManager.initiateHandshake(with: bobPeerID) let rehandshake1 = try bobManager.initiateHandshake(with: TestConstants.testPeerID1)
// 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: alicePeerID, message: rehandshake1) let rehandshake2 = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: rehandshake1)
#expect(rehandshake2 != nil, "Alice should accept handshake to fix desync") XCTAssertNotNil(rehandshake2, "Alice should accept handshake to fix desync")
// Complete handshake // Complete handshake
let rehandshake3 = try bobManager.handleIncomingHandshake(from: bobPeerID, message: rehandshake2!) let rehandshake3 = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: rehandshake2!)
#expect(rehandshake3 != nil) XCTAssertNotNil(rehandshake3)
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: rehandshake3!) _ = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, 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: alicePeerID) let encryptedResync = try aliceManager.encrypt(testResynced, for: TestConstants.testPeerID2)
let decryptedResync = try bobManager.decrypt(encryptedResync, from: bobPeerID) let decryptedResync = try bobManager.decrypt(encryptedResync, from: TestConstants.testPeerID1)
#expect(decryptedResync == testResynced) XCTAssertEqual(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)!
@@ -524,9 +559,9 @@ struct NoiseProtocolTests {
} }
private func establishManagerSessions(aliceManager: NoiseSessionManager, bobManager: NoiseSessionManager) throws { private func establishManagerSessions(aliceManager: NoiseSessionManager, bobManager: NoiseSessionManager) throws {
let msg1 = try aliceManager.initiateHandshake(with: alicePeerID) let msg1 = try aliceManager.initiateHandshake(with: TestConstants.testPeerID2)
let msg2 = try bobManager.handleIncomingHandshake(from: bobPeerID, message: msg1)! let msg2 = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: msg1)!
let msg3 = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: msg2)! let msg3 = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: msg2)!
_ = try bobManager.handleIncomingHandshake(from: bobPeerID, message: msg3) _ = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: msg3)
} }
} }
+61 -60
View File
@@ -5,20 +5,20 @@
// Tests for NIP-17 gift-wrapped private messages // Tests for NIP-17 gift-wrapped private messages
// //
import Testing import XCTest
import CryptoKit
import Foundation
@testable import bitchat @testable import bitchat
struct NostrProtocolTests { final class NostrProtocolTests: XCTestCase {
@Test func nip17MessageRoundTrip() throws { func testNIP17MessageRoundTrip() 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,8 +30,10 @@ struct NostrProtocolTests {
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(
@@ -40,18 +42,20 @@ struct NostrProtocolTests {
) )
// Verify // Verify
#expect(decryptedContent == originalContent) XCTAssertEqual(decryptedContent, originalContent)
#expect(senderPubkey == sender.publicKeyHex) XCTAssertEqual(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)
#expect(timeDiff < 60, "Message timestamp should be recent") XCTAssertLessThan(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
} }
@Test func giftWrapUsesUniqueEphemeralKeys() throws { func testGiftWrapUsesUniqueEphemeralKeys() 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()
@@ -70,10 +74,11 @@ struct NostrProtocolTests {
) )
// Gift wrap pubkeys should be different (unique ephemeral keys) // Gift wrap pubkeys should be different (unique ephemeral keys)
#expect(message1.pubkey != message2.pubkey) XCTAssertNotEqual(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(
@@ -85,11 +90,11 @@ struct NostrProtocolTests {
recipientIdentity: recipient recipientIdentity: recipient
) )
#expect(content1 == "Message 1") XCTAssertEqual(content1, "Message 1")
#expect(content2 == "Message 2") XCTAssertEqual(content2, "Message 2")
} }
@Test func decryptionFailsWithWrongRecipient() throws { func testDecryptionFailsWithWrongRecipient() 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()
@@ -102,20 +107,13 @@ struct NostrProtocolTests {
) )
// Try to decrypt with wrong recipient // Try to decrypt with wrong recipient
if #available(macOS 14.4, iOS 17.4, *) { XCTAssertThrowsError(try NostrProtocol.decryptPrivateMessage(
#expect(throws: CryptoKitError.authenticationFailure) { giftWrap: giftWrap,
try NostrProtocol.decryptPrivateMessage( recipientIdentity: wrongRecipient
giftWrap: giftWrap, )) { error in
recipientIdentity: wrongRecipient #if DEBUG
) print("Expected error when decrypting with wrong key: \(error)")
} #endif
} else {
#expect(throws: (any Error).self) {
try NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: wrongRecipient
)
}
} }
} }
@@ -127,11 +125,10 @@ struct NostrProtocolTests {
// 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 {
let embedded = try #require( XCTFail("Failed to embed delivered ack")
NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID), return
"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(
@@ -141,7 +138,7 @@ struct NostrProtocolTests {
) )
// Ensure v2 format was used for ciphertext // Ensure v2 format was used for ciphertext
#expect(giftWrap.content.hasPrefix("v2:")) XCTAssertTrue(giftWrap.content.hasPrefix("v2:"))
// Decrypt as recipient // Decrypt as recipient
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage( let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
@@ -150,37 +147,39 @@ struct NostrProtocolTests {
) )
// Verify sender is correct // Verify sender is correct
#expect(senderPubkey == sender.publicKeyHex) XCTAssertEqual(senderPubkey, sender.publicKeyHex)
// Parse BitChat payload // Parse BitChat payload
#expect(content.hasPrefix("bitchat1:")) XCTAssertTrue(content.hasPrefix("bitchat1:"))
let base64url = String(content.dropFirst("bitchat1:".count)) let base64url = String(content.dropFirst("bitchat1:".count))
let packetData = try #require(Self.base64URLDecode(base64url)) guard let packetData = Self.base64URLDecode(base64url),
let packet = try #require(BitchatPacket.from(packetData), "Failed to decode bitchat packet") let packet = BitchatPacket.from(packetData) else {
return XCTFail("Failed to decode bitchat packet")
#expect(packet.type == MessageType.noiseEncrypted.rawValue) }
let payload = try #require(NoisePayload.decode(packet.payload), "Failed to decode NoisePayload") XCTAssertEqual(packet.type, MessageType.noiseEncrypted.rawValue)
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)
#expect(mid == messageID) XCTAssertEqual(mid, messageID)
default: default:
Issue.record("Unexpected payload type: \(payload.type)") XCTFail("Unexpected payload type: \(payload.type)")
} }
} }
@Test func ackRoundTripNIP44V2_ReadReceipt() throws { func testAckRoundTripNIP44V2_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
let embedded = try #require( guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else {
NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID), XCTFail("Failed to embed read ack")
"Failed to embed read ack" return
) }
let giftWrap = try NostrProtocol.createPrivateMessage( let giftWrap = try NostrProtocol.createPrivateMessage(
content: embedded, content: embedded,
@@ -188,28 +187,30 @@ struct NostrProtocolTests {
senderIdentity: sender senderIdentity: sender
) )
#expect(giftWrap.content.hasPrefix("v2:")) XCTAssertTrue(giftWrap.content.hasPrefix("v2:"))
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage( let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap, giftWrap: giftWrap,
recipientIdentity: recipient recipientIdentity: recipient
) )
#expect(senderPubkey == sender.publicKeyHex) XCTAssertEqual(senderPubkey, sender.publicKeyHex)
#expect(content.hasPrefix("bitchat1:")) XCTAssertTrue(content.hasPrefix("bitchat1:"))
let base64url = String(content.dropFirst("bitchat1:".count)) let base64url = String(content.dropFirst("bitchat1:".count))
let packetData = try #require(Self.base64URLDecode(base64url)) guard let packetData = Self.base64URLDecode(base64url),
let packet = try #require(BitchatPacket.from(packetData), "Failed to decode bitchat packet") let packet = BitchatPacket.from(packetData) else {
return XCTFail("Failed to decode bitchat packet")
#expect(packet.type == MessageType.noiseEncrypted.rawValue) }
let payload = try #require(NoisePayload.decode(packet.payload), "Failed to decode NoisePayload") XCTAssertEqual(packet.type, MessageType.noiseEncrypted.rawValue)
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)
#expect(mid == messageID) XCTAssertEqual(mid, messageID)
default: default:
Issue.record("Unexpected payload type: \(payload.type)") XCTFail("Unexpected payload type: \(payload.type)")
} }
} }
@@ -1,8 +1,7 @@
import Testing import XCTest
import Foundation
@testable import bitchat @testable import bitchat
struct NotificationStreamAssemblerTests { final class NotificationStreamAssemblerTests: XCTestCase {
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(
@@ -16,51 +15,60 @@ struct NotificationStreamAssemblerTests {
) )
} }
@Test func assemblesSingleFrameAcrossChunks() throws { func testAssemblesSingleFrameAcrossChunks() {
var assembler = NotificationStreamAssembler() var assembler = NotificationStreamAssembler()
let packet = makePacket() let packet = makePacket()
let frame = try #require(packet.toBinaryData(padding: false), "Failed to encode packet") guard let frame = packet.toBinaryData(padding: false) else {
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])
#expect(payloadLen == packet.payload.count) XCTAssertEqual(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)
#expect(first.count + second.count == frame.count) XCTAssertEqual(first.count + second.count, frame.count)
var result = assembler.append(first) var result = assembler.append(first)
#expect(result.frames.isEmpty) XCTAssertTrue(result.frames.isEmpty)
#expect(result.droppedPrefixes.isEmpty) XCTAssertTrue(result.droppedPrefixes.isEmpty)
#expect(!result.reset) XCTAssertFalse(result.reset)
result = assembler.append(second) result = assembler.append(second)
#expect(result.frames.count == 1) XCTAssertEqual(result.frames.count, 1)
#expect(result.droppedPrefixes.isEmpty) XCTAssertTrue(result.droppedPrefixes.isEmpty)
#expect(!result.reset) XCTAssertFalse(result.reset)
let frameData = try #require(result.frames.first, "Missing frame data") guard let frameData = result.frames.first else {
#expect(frameData.count == frame.count) return XCTFail("Missing frame data")
}
let decoded = try #require(BinaryProtocol.decode(frameData), "Failed to decode frame") if frameData.count != frame.count {
#expect(decoded.type == packet.type) XCTFail("Frame size mismatch: expected \(frame.count) got \(frameData.count)\nframe=\(Array(frame))\nassembled=\(Array(frameData))")
#expect(decoded.payload == packet.payload) return
#expect(decoded.senderID == packet.senderID) }
#expect(decoded.timestamp == packet.timestamp) guard let decoded = BinaryProtocol.decode(frameData) else {
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)
#expect(directResult.frames.first?.count == frame.count) XCTAssertEqual(directResult.frames.first?.count, frame.count)
} }
@Test func assemblesMultipleFramesSequentially() throws { func testAssemblesMultipleFramesSequentially() {
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)
let frame1 = try #require(packet1.toBinaryData(padding: false), "Failed to encode packet") guard let frame1 = packet1.toBinaryData(padding: false),
let frame2 = try #require(packet2.toBinaryData(padding: false), "Failed to encode packet") let frame2 = packet2.toBinaryData(padding: false) else {
return XCTFail("Failed to encode packets")
}
var combined = Data() var combined = Data()
combined.append(frame1) combined.append(frame1)
@@ -69,30 +77,35 @@ struct NotificationStreamAssemblerTests {
let secondChunk = combined.suffix(from: 20) let secondChunk = combined.suffix(from: 20)
var result = assembler.append(firstChunk) var result = assembler.append(firstChunk)
#expect(result.frames.isEmpty) XCTAssertTrue(result.frames.isEmpty)
result = assembler.append(secondChunk) result = assembler.append(secondChunk)
#expect(result.frames.count == 2) XCTAssertEqual(result.frames.count, 2)
guard let decoded1 = BinaryProtocol.decode(result.frames[0]),
let decoded1 = try #require(BinaryProtocol.decode(result.frames[0]), "Failed to decode frame") let decoded2 = BinaryProtocol.decode(result.frames[1]) else {
let decoded2 = try #require(BinaryProtocol.decode(result.frames[1]), "Failed to decode frame") return XCTFail("Failed to decode frames")
#expect(decoded1.timestamp == packet1.timestamp) }
#expect(decoded2.timestamp == packet2.timestamp) XCTAssertEqual(decoded1.timestamp, packet1.timestamp)
XCTAssertEqual(decoded2.timestamp, packet2.timestamp)
} }
@Test func dropsInvalidPrefixByte() throws { func testDropsInvalidPrefixByte() {
var assembler = NotificationStreamAssembler() var assembler = NotificationStreamAssembler()
let packet = makePacket(timestamp: 0xF00) let packet = makePacket(timestamp: 0xF00)
let frame = try #require(packet.toBinaryData(padding: false), "Failed to encode packet") guard let frame = packet.toBinaryData(padding: false) else {
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)
#expect(result.droppedPrefixes == [0x00]) XCTAssertEqual(result.droppedPrefixes, [0x00])
#expect(result.frames.count == 1) XCTAssertEqual(result.frames.count, 1)
#expect(result.reset == false) XCTAssertFalse(result.reset)
let decoded = try #require(BinaryProtocol.decode(result.frames[0]), "Failed to decode frame after drop") guard let decoded = BinaryProtocol.decode(result.frames[0]) else {
#expect(decoded.timestamp == packet.timestamp) return XCTFail("Failed to decode frame after drop")
}
XCTAssertEqual(decoded.timestamp, packet.timestamp)
} }
} }
@@ -5,29 +5,30 @@
// This is free and unencumbered software released into the public domain. // This is free and unencumbered software released into the public domain.
// //
import Testing import XCTest
@testable import bitchat @testable import bitchat
struct BinaryProtocolPaddingTests { final class BinaryProtocolPaddingTests: XCTestCase {
@Test func padded_vs_unpadded_length() throws { func test_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()
let padded = try #require(BinaryProtocol.encode(packet, padding: true), "encode padded") guard let padded = BinaryProtocol.encode(packet, padding: true) else { return XCTFail("encode padded") }
let unpadded = try #require(BinaryProtocol.encode(packet, padding: false), "encode unpadded") guard let unpadded = BinaryProtocol.encode(packet, padding: false) else { return XCTFail("encode unpadded") }
#expect(padded.count >= unpadded.count, "Padded frame should be >= unpadded") XCTAssertGreaterThanOrEqual(padded.count, unpadded.count, "Padded frame should be >= unpadded")
} }
@Test func decode_padded_and_unpadded_round_trip() throws { func test_decode_padded_and_unpadded_round_trip() throws {
let packet = TestHelpers.createTestPacket() let packet = TestHelpers.createTestPacket()
// Padded
let padded = try #require(BinaryProtocol.encode(packet, padding: true), "encode padded") guard let padded = BinaryProtocol.encode(packet, padding: true) else { return XCTFail("encode padded") }
let dec1 = try #require(BinaryProtocol.decode(padded), "decode padded") guard let dec1 = BinaryProtocol.decode(padded) else { return XCTFail("decode padded") }
#expect(dec1.type == packet.type) XCTAssertEqual(dec1.type, packet.type)
#expect(dec1.payload == packet.payload) XCTAssertEqual(dec1.payload, packet.payload)
// Unpadded
let unpadded = try #require(BinaryProtocol.encode(packet, padding: false), "encode unpadded") guard let unpadded = BinaryProtocol.encode(packet, padding: false) else { return XCTFail("encode unpadded") }
let dec2 = try #require(BinaryProtocol.decode(unpadded), "decode unpadded") guard let dec2 = BinaryProtocol.decode(unpadded) else { return XCTFail("decode unpadded") }
#expect(dec2.type == packet.type) XCTAssertEqual(dec2.type, packet.type)
#expect(dec2.payload == packet.payload) XCTAssertEqual(dec2.payload, packet.payload)
} }
} }
+209 -120
View File
@@ -6,89 +6,119 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import Testing import XCTest
import Foundation
@testable import bitchat @testable import bitchat
struct BinaryProtocolTests { final class BinaryProtocolTests: XCTestCase {
// MARK: - Basic Encoding/Decoding Tests // MARK: - Basic Encoding/Decoding Tests
@Test func basicPacketEncodingDecoding() throws { func testBasicPacketEncodingDecoding() throws {
let originalPacket = TestHelpers.createTestPacket() let originalPacket = TestHelpers.createTestPacket()
let encodedData = try #require(BinaryProtocol.encode(originalPacket), "Failed to encode packet") // Encode
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode packet") guard let encodedData = BinaryProtocol.encode(originalPacket) else {
XCTFail("Failed to encode packet")
return
}
// Decode
guard let decodedPacket = BinaryProtocol.decode(encodedData) else {
XCTFail("Failed to decode packet")
return
}
// Verify // Verify
#expect(decodedPacket.type == originalPacket.type) XCTAssertEqual(decodedPacket.type, originalPacket.type)
#expect(decodedPacket.ttl == originalPacket.ttl) XCTAssertEqual(decodedPacket.ttl, originalPacket.ttl)
#expect(decodedPacket.timestamp == originalPacket.timestamp) XCTAssertEqual(decodedPacket.timestamp, originalPacket.timestamp)
#expect(decodedPacket.payload == originalPacket.payload) XCTAssertEqual(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()
#expect(decodedSenderID == originalSenderID) XCTAssertEqual(decodedSenderID, originalSenderID)
} }
@Test func packetWithRecipient() throws { func testPacketWithRecipient() throws {
let recipientID = PeerID(str: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789") let recipientID = TestConstants.testPeerID2
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")
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode packet with recipient") // Encode and decode
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
#expect(decodedPacket.recipientID != nil) XCTAssertNotNil(decodedPacket.recipientID)
let decodedRecipientID = decodedPacket.recipientID?.trimmingNullBytes() let decodedRecipientID = decodedPacket.recipientID?.trimmingNullBytes()
// TODO: Check if this is intended that the decoding only gets the first 8 XCTAssertTrue(String(data: decodedRecipientID!, encoding: .utf8) == recipientID)
#expect(String(data: decodedRecipientID!, encoding: .utf8) == "abcdef01")
} }
@Test func packetWithSignature() throws { func testPacketWithSignature() throws {
let packet = TestHelpers.createTestPacket(signature: TestConstants.testSignature) let packet = TestHelpers.createTestPacket(
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with signature") signature: TestConstants.testSignature
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
#expect(decodedPacket.signature != nil) XCTAssertNotNil(decodedPacket.signature)
#expect(decodedPacket.signature == TestConstants.testSignature) XCTAssertEqual(decodedPacket.signature, TestConstants.testSignature)
} }
// MARK: - Compression Tests // MARK: - Compression Tests
@Test("Create a large, compressible payload above current threshold (2048B)") func testPayloadCompression() throws {
func payloadCompression() throws { // Create a large, compressible payload above current threshold (2048B)
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)
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with large payload") guard let encodedData = BinaryProtocol.encode(packet) else {
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
#expect(encodedData.count < uncompressedSize) XCTAssertLessThan(encodedData.count, uncompressedSize)
// Decode and verify // Decode and verify
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode compressed packet") guard let decodedPacket = BinaryProtocol.decode(encodedData) else {
XCTFail("Failed to decode compressed packet")
return
}
#expect(decodedPacket.payload == largePayload) XCTAssertEqual(decodedPacket.payload, largePayload)
} }
@Test("Small payloads should not be compressed") func testSmallPayloadNoCompression() throws {
func smallPayloadNoCompression() 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")
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode small packet") guard let encodedData = BinaryProtocol.encode(packet),
#expect(decodedPacket.payload == smallPayload) let decodedPacket = BinaryProtocol.decode(encodedData) else {
XCTFail("Failed to encode/decode small packet")
return
}
XCTAssertEqual(decodedPacket.payload, smallPayload)
} }
// MARK: - Message Padding Tests // MARK: - Message Padding Tests
@Test func messagePadding() throws { func testMessagePadding() 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
@@ -100,32 +130,43 @@ struct BinaryProtocolTests {
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 {
#expect(blockSizes.contains(encodedData.count), "Encoded size \(encodedData.count) is not a standard block size") XCTAssertTrue(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
#expect(encodedData.count > 2048) XCTAssertGreaterThan(encodedData.count, 2048)
} }
encodedSizes.insert(encodedData.count) encodedSizes.insert(encodedData.count)
// Verify decoding works // Verify decoding works
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode padded packet") guard let decodedPacket = BinaryProtocol.decode(encodedData) else {
#expect(String(data: decodedPacket.payload, encoding: .utf8) == payload) XCTFail("Failed to decode padded packet")
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.
#expect(encodedSizes.filter { $0 <= 2048 }.count >= 1, "Expected at least one padded size up to 2048, got \(encodedSizes)") XCTAssertGreaterThanOrEqual(encodedSizes.filter { $0 <= 2048 }.count, 1, "Expected at least one padded size up to 2048, got \(encodedSizes)")
} }
@Test func invalidPKCS7PaddingIsRejected() throws { func testInvalidPKCS7PaddingIsRejected() throws {
let pkt = TestHelpers.createTestPacket(payload: Data(repeating: 0x41, count: 50)) // small let pkt = TestHelpers.createTestPacket(payload: Data(repeating: 0x41, count: 50)) // small
let enc0 = try #require(BinaryProtocol.encode(pkt), "encode failed") guard let enc0 = BinaryProtocol.encode(pkt) else {
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)
@@ -136,33 +177,39 @@ struct BinaryProtocolTests {
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 {
#expect(pkt2.payload == pkt.payload) XCTAssertEqual(pkt2.payload, pkt.payload)
} else { } else {
#expect(maybe == nil) XCTAssertNil(maybe)
} }
} 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)
#expect(BinaryProtocol.decode(enc) != nil) XCTAssertNotNil(BinaryProtocol.decode(enc))
} }
} }
// MARK: - Message Encoding/Decoding Tests // MARK: - Message Encoding/Decoding Tests
@Test func messageEncodingDecoding() throws { func testMessageEncodingDecoding() throws {
let message = TestHelpers.createTestMessage() let message = TestHelpers.createTestMessage()
let payload = try #require(message.toBinaryPayload(), "Failed to encode message to binary") guard let payload = message.toBinaryPayload() else {
XCTFail("Failed to encode message to binary")
return
}
let decodedMessage = try #require(BitchatMessage(payload), "Failed to decode message from binary") guard let decodedMessage = BitchatMessage(payload) else {
XCTFail("Failed to decode message from binary")
return
}
#expect(decodedMessage.content == message.content) XCTAssertEqual(decodedMessage.content, message.content)
#expect(decodedMessage.sender == message.sender) XCTAssertEqual(decodedMessage.sender, message.sender)
#expect(decodedMessage.senderPeerID == message.senderPeerID) XCTAssertEqual(decodedMessage.senderPeerID, message.senderPeerID)
#expect(decodedMessage.isPrivate == message.isPrivate) XCTAssertEqual(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))
#expect(timeDiff < 1) XCTAssertLessThan(timeDiff, 1.0)
} }
func testPrivateMessageEncoding() throws { func testPrivateMessageEncoding() throws {
@@ -171,22 +218,30 @@ struct BinaryProtocolTests {
recipientNickname: TestConstants.testNickname2 recipientNickname: TestConstants.testNickname2
) )
let payload = try #require(message.toBinaryPayload(), "Failed to encode private message") guard let payload = message.toBinaryPayload(),
let decodedMessage = try #require(BitchatMessage(payload), "Failed to decode private message") let decodedMessage = BitchatMessage(payload) else {
XCTFail("Failed to encode/decode private message")
return
}
#expect(decodedMessage.isPrivate) XCTAssertTrue(decodedMessage.isPrivate)
#expect(decodedMessage.recipientNickname == TestConstants.testNickname2) XCTAssertEqual(decodedMessage.recipientNickname, TestConstants.testNickname2)
} }
@Test func messageWithMentions() throws { 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")
let decodedMessage = try #require(BitchatMessage(payload), "Failed to decode message with mentions") guard let payload = message.toBinaryPayload(),
#expect(decodedMessage.mentions == mentions) let decodedMessage = BitchatMessage(payload) else {
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,
@@ -196,77 +251,105 @@ struct BinaryProtocolTests {
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")
let decodedMessage = try #require(BitchatMessage(payload), "Failed to decode relay message") guard let payload = message.toBinaryPayload(),
#expect(decodedMessage.isRelay) let decodedMessage = BitchatMessage(payload) else {
#expect(decodedMessage.originalSender == TestConstants.testNickname3) XCTFail("Failed to encode/decode relay message")
return
}
XCTAssertTrue(decodedMessage.isRelay)
XCTAssertEqual(decodedMessage.originalSender, TestConstants.testNickname3)
} }
// MARK: - Edge Cases and Error Handling // MARK: - Edge Cases and Error Handling
@Test("Too small data") func testInvalidDataDecoding() {
func invalidDataDecoding() throws { // Too small data
let tooSmall = Data(repeating: 0, count: 5) let tooSmall = Data(repeating: 0, count: 5)
#expect(BinaryProtocol.decode(tooSmall) == nil) XCTAssertNil(BinaryProtocol.decode(tooSmall))
// Random data // Random data
let random = TestHelpers.generateRandomData(length: 100) let random = TestHelpers.generateRandomData(length: 100)
#expect(BinaryProtocol.decode(random) == nil) XCTAssertNil(BinaryProtocol.decode(random))
// Corrupted header // Corrupted header
let packet = TestHelpers.createTestPacket() let packet = TestHelpers.createTestPacket()
var encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode test packet") guard var encoded = BinaryProtocol.encode(packet) else {
XCTFail("Failed to encode test packet")
return
}
// Corrupt the version byte // Corrupt the version byte
encoded[0] = 0xFF encoded[0] = 0xFF
#expect(BinaryProtocol.decode(encoded) == nil) XCTAssertNil(BinaryProtocol.decode(encoded))
} }
@Test("Test maximum size handling") func testLargeMessageHandling() throws {
func largeMessageHandling() throws { // Test maximum size handling
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")
let decodedMessage = try #require(BitchatMessage(payload), "Failed to handle large message") guard let payload = message.toBinaryPayload(),
#expect(decodedMessage.content == largeContent) let decodedMessage = BitchatMessage(payload) else {
XCTFail("Failed to handle large message")
return
}
XCTAssertEqual(decodedMessage.content, largeContent)
} }
@Test("Test message with empty content") func testEmptyFieldsHandling() throws {
func emptyFieldsHandling() 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")
let decodedMessage = try #require(BitchatMessage(payload), "Failed to handle empty message") guard let payload = emptyMessage.toBinaryPayload(),
#expect(decodedMessage.content.isEmpty) let decodedMessage = BitchatMessage(payload) else {
XCTFail("Failed to handle empty message")
return
}
XCTAssertEqual(decodedMessage.content, "")
} }
// MARK: - Protocol Version Tests // MARK: - Protocol Version Tests
@Test("Test with supported version (version is always 1 in init)") func testProtocolVersionHandling() throws {
func protocolVersionHandling() throws { // Test with supported version (version is always 1 in init)
let packet = TestHelpers.createTestPacket() let packet = TestHelpers.createTestPacket()
let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with version")
let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode packet with version") guard let encoded = BinaryProtocol.encode(packet),
#expect(decoded.version == 1) let decoded = BinaryProtocol.decode(encoded) else {
XCTFail("Failed to encode/decode packet with version")
return
}
XCTAssertEqual(decoded.version, 1)
} }
@Test("Create packet data with unsupported version") func testUnsupportedProtocolVersion() throws {
func unsupportedProtocolVersion() 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
#expect(BinaryProtocol.decode(encoded) == nil) XCTAssertNil(BinaryProtocol.decode(encoded))
} }
// MARK: - Bounds Checking Tests (Crash Prevention) // MARK: - Bounds Checking Tests (Crash Prevention)
@Test("Test the specific crash scenario: payloadLength = 193 (0xc1) but only 30 bytes available") func testMalformedPacketWithInvalidPayloadLength() throws {
func malformedPacketWithInvalidPayloadLength() throws { // Test the specific crash scenario: payloadLength = 193 (0xc1) but only 30 bytes available
var malformedData = Data() var malformedData = Data()
// Valid header (13 bytes) // Valid header (13 bytes)
@@ -296,17 +379,20 @@ struct BinaryProtocolTests {
} }
// Total data is now 30 bytes, but payloadLength claims 193 // Total data is now 30 bytes, but payloadLength claims 193
#expect(malformedData.count == 30) XCTAssertEqual(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)
#expect(result == nil, "Malformed packet with invalid payload length should return nil, not crash") XCTAssertNil(result, "Malformed packet with invalid payload length should return nil, not crash")
} }
@Test("Test various truncation scenarios") func testTruncatedPacketHandling() throws {
func truncatedPacketHandling() throws { // Test various truncation scenarios
let packet = TestHelpers.createTestPacket() let packet = TestHelpers.createTestPacket()
let validEncoded = try #require(BinaryProtocol.encode(packet), "Failed to encode test packet") guard let validEncoded = BinaryProtocol.encode(packet) else {
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]
@@ -314,12 +400,12 @@ struct BinaryProtocolTests {
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)
#expect(result == nil, "Truncated packet at \(point) bytes should return nil, not crash") XCTAssertNil(result, "Truncated packet at \(point) bytes should return nil, not crash")
} }
} }
@Test("Test compressed packet with invalid original size") func testMalformedCompressedPacket() throws {
func malformedCompressedPacket() throws { // Test compressed packet with invalid original size
var malformedData = Data() var malformedData = Data()
// Valid header // Valid header
@@ -348,11 +434,11 @@ struct BinaryProtocolTests {
// Should handle this gracefully // Should handle this gracefully
let result = BinaryProtocol.decode(malformedData) let result = BinaryProtocol.decode(malformedData)
#expect(result == nil, "Malformed compressed packet should return nil, not crash") XCTAssertNil(result, "Malformed compressed packet should return nil, not crash")
} }
@Test("Test packet claiming extremely large payload") func testExcessivelyLargePayloadLength() throws {
func excessivelyLargePayloadLength() throws { // Test packet claiming extremely large payload
var malformedData = Data() var malformedData = Data()
// Valid header // Valid header
@@ -381,11 +467,11 @@ struct BinaryProtocolTests {
// 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)
#expect(result == nil, "Packet with excessive payload length should return nil, not crash") XCTAssertNil(result, "Packet with excessive payload length should return nil, not crash")
} }
@Test("Test compressed packet with unreasonable original size") func testCompressedPacketWithInvalidOriginalSize() throws {
func compressedPacketWithInvalidOriginalSize() throws { // Test compressed packet with unreasonable original size
var malformedData = Data() var malformedData = Data()
// Valid header // Valid header
@@ -423,11 +509,11 @@ struct BinaryProtocolTests {
} }
let result = BinaryProtocol.decode(malformedData) let result = BinaryProtocol.decode(malformedData)
#expect(result == nil, "Compressed packet with invalid original size should return nil, not crash") XCTAssertNil(result, "Compressed packet with invalid original size should return nil, not crash")
} }
@Test("Test packet designed to cause integer overflow") func testMaliciousPacketWithIntegerOverflow() throws {
func maliciousPacketWithIntegerOverflow() throws { // Test packet designed to cause integer overflow
var maliciousData = Data() var maliciousData = Data()
// Valid header // Valid header
@@ -462,24 +548,27 @@ struct BinaryProtocolTests {
// Should handle gracefully without integer overflow issues // Should handle gracefully without integer overflow issues
let result = BinaryProtocol.decode(maliciousData) let result = BinaryProtocol.decode(maliciousData)
#expect(result == nil, "Malicious packet designed for integer overflow should return nil, not crash") XCTAssertNil(result, "Malicious packet designed for integer overflow should return nil, not crash")
} }
@Test("Test packets with incomplete headers") func testPartialHeaderData() throws {
func partialHeaderData() throws { // Test packets with incomplete headers
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)
#expect(result == nil, "Partial header data (\(size) bytes) should return nil, not crash") XCTAssertNil(result, "Partial header data (\(size) bytes) should return nil, not crash")
} }
} }
@Test("Test exact boundary conditions") func testBoundaryConditions() throws {
func boundaryConditions() throws { // Test exact boundary conditions
let packet = TestHelpers.createTestPacket() let packet = TestHelpers.createTestPacket()
let validEncoded = try #require(BinaryProtocol.encode(packet), "Failed to encode test packet") guard let validEncoded = BinaryProtocol.encode(packet) else {
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)
@@ -487,7 +576,7 @@ struct BinaryProtocolTests {
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)
#expect(result == nil, "Truncated core frame should return nil, not crash") XCTAssertNil(result, "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,6 +14,11 @@ 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"
+14 -6
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 = PeerID(str: UUID().uuidString), senderPeerID: PeerID = TestConstants.testPeerID1,
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 = PeerID(str: UUID().uuidString), senderID: PeerID = TestConstants.testPeerID1,
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 sleep(0.01) try await Task.sleep(nanoseconds: 10_000_000) // 10ms
} }
} }
@@ -104,7 +104,7 @@ final class TestHelpers {
} }
group.addTask { group.addTask {
try await sleep(1) try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
throw TestError.timeout throw TestError.timeout
} }
@@ -121,6 +121,14 @@ enum TestError: Error {
case testFailure(String) case testFailure(String)
} }
func sleep(_ seconds: TimeInterval) async throws { // MARK: - PeerID String Helpers
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 {}
+190 -181
View File
@@ -6,11 +6,11 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import Testing import XCTest
import Foundation
@testable import bitchat @testable import bitchat
struct PeerIDTests { final class PeerIDTests: XCTestCase {
private let hex16 = "0011223344556677" private let hex16 = "0011223344556677"
private let hex64 = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" private let hex64 = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
@@ -22,205 +22,212 @@ struct PeerIDTests {
// MARK: - Empty prefix // MARK: - Empty prefix
@Test func empty_prefix_with16() { func test_init_empty_prefix_with16() {
let peerID = PeerID(str: hex16) let peerID = PeerID(str: hex16)
#expect(peerID.id == hex16) XCTAssertEqual(peerID.id, hex16)
#expect(peerID.bare == hex16) XCTAssertEqual(peerID.bare, hex16)
#expect(peerID.prefix == .empty) XCTAssertEqual(peerID.prefix, .empty)
} }
@Test func empty_prefix_with64() { func test_init_empty_prefix_with64() {
let peerID = PeerID(str: hex64) let peerID = PeerID(str: hex64)
#expect(peerID.id == hex64) XCTAssertEqual(peerID.id, hex64)
#expect(peerID.bare == hex64) XCTAssertEqual(peerID.bare, hex64)
#expect(peerID.prefix == .empty) XCTAssertEqual(peerID.prefix, .empty)
} }
// MARK: - Mesh prefix // MARK: - Mesh prefix
@Test func mesh_prefix_with16() { func test_init_mesh_prefix_with16() {
let str = "mesh:" + hex16 let str = "mesh:" + hex16
let peerID = PeerID(str: str) let peerID = PeerID(str: str)
#expect(peerID.id == str) XCTAssertEqual(peerID.id, str)
#expect(peerID.bare == hex16) XCTAssertEqual(peerID.bare, hex16)
#expect(peerID.prefix == .mesh) XCTAssertEqual(peerID.prefix, .mesh)
} }
@Test func mesh_prefix_with64() { func test_init_mesh_prefix_with64() {
let str = "mesh:" + hex64 let str = "mesh:" + hex64
let peerID = PeerID(str: str) let peerID = PeerID(str: str)
#expect(peerID.id == str) XCTAssertEqual(peerID.id, str)
#expect(peerID.bare == hex64) XCTAssertEqual(peerID.bare, hex64)
#expect(peerID.prefix == .mesh) XCTAssertEqual(peerID.prefix, .mesh)
} }
// MARK: - Name prefix // MARK: - Name prefix
@Test func name_prefix() { func test_init_name_prefix() {
let str = "name:some_name" let str = "name:some_name"
let peerID = PeerID(str: str) let peerID = PeerID(str: str)
#expect(peerID.id == str) XCTAssertEqual(peerID.id, str)
#expect(peerID.bare == "some_name") XCTAssertEqual(peerID.bare, "some_name")
#expect(peerID.prefix == .name) XCTAssertEqual(peerID.prefix, .name)
} }
// MARK: - Noise prefix // MARK: - Noise prefix
@Test func noise_prefix_with16() { func test_init_noise_prefix_with16() {
let str = "noise:" + hex16 let str = "noise:" + hex16
let peerID = PeerID(str: str) let peerID = PeerID(str: str)
#expect(peerID.id == str) XCTAssertEqual(peerID.id, str)
#expect(peerID.bare == hex16) XCTAssertEqual(peerID.bare, hex16)
#expect(peerID.prefix == .noise) XCTAssertEqual(peerID.prefix, .noise)
} }
@Test func noise_prefix_with64() { func test_init_noise_prefix_with64() {
let str = "noise:" + hex64 let str = "noise:" + hex64
let peerID = PeerID(str: str) let peerID = PeerID(str: str)
#expect(peerID.id == str) XCTAssertEqual(peerID.id, str)
#expect(peerID.bare == hex64) XCTAssertEqual(peerID.bare, hex64)
#expect(peerID.prefix == .noise) XCTAssertEqual(peerID.prefix, .noise)
} }
// MARK: - GeoDM prefix // MARK: - GeoDM prefix
@Test func geoDM_prefix_with16() { func test_init_geoDM_prefix_with16() {
let str = "nostr_" + hex16 let str = "nostr_" + hex16
let peerID = PeerID(str: str) let peerID = PeerID(str: str)
#expect(peerID.id == str) XCTAssertEqual(peerID.id, str)
#expect(peerID.bare == hex16) XCTAssertEqual(peerID.bare, hex16)
#expect(peerID.prefix == .geoDM) XCTAssertEqual(peerID.prefix, .geoDM)
} }
@Test func geoDM_prefix_with64() { func test_init_geoDM_prefix_with64() {
let str = "nostr_" + hex64 let str = "nostr_" + hex64
let peerID = PeerID(str: str) let peerID = PeerID(str: str)
#expect(peerID.id == str) XCTAssertEqual(peerID.id, str)
#expect(peerID.bare == hex64) XCTAssertEqual(peerID.bare, hex64)
#expect(peerID.prefix == .geoDM) XCTAssertEqual(peerID.prefix, .geoDM)
} }
// MARK: - GeoChat prefix // MARK: - GeoChat prefix
@Test func geoChat_prefix_with16() { func test_init_geoChat_prefix_with16() {
let str = "nostr:" + hex16 let str = "nostr:" + hex16
let peerID = PeerID(str: str) let peerID = PeerID(str: str)
#expect(peerID.id == str) XCTAssertEqual(peerID.id, str)
#expect(peerID.bare == hex16) XCTAssertEqual(peerID.bare, hex16)
#expect(peerID.prefix == .geoChat) XCTAssertEqual(peerID.prefix, .geoChat)
} }
@Test func geoChat_prefix_with64() { func test_init_geoChat_prefix_with64() {
let str = "nostr:" + hex64 let str = "nostr:" + hex64
let peerID = PeerID(str: str) let peerID = PeerID(str: str)
#expect(peerID.id == str) XCTAssertEqual(peerID.id, str)
#expect(peerID.bare == hex64) XCTAssertEqual(peerID.bare, hex64)
#expect(peerID.prefix == .geoChat) XCTAssertEqual(peerID.prefix, .geoChat)
} }
// MARK: - Edge cases // MARK: - Edge cases
@Test func with_unknown_prefix() { func test_init_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
#expect(peerID.id == str) XCTAssertEqual(peerID.id, str)
#expect(peerID.bare == str) XCTAssertEqual(peerID.bare, str)
#expect(peerID.prefix == .empty) XCTAssertEqual(peerID.prefix, .empty)
} }
@Test func with_only_prefix_no_bare() { func test_init_with_only_prefix_no_bare() {
let str = "mesh:" let str = "mesh:"
let peerID = PeerID(str: str) let peerID = PeerID(str: str)
#expect(peerID.id == str) XCTAssertEqual(peerID.id, str)
#expect(peerID.bare == "") XCTAssertEqual(peerID.bare, "")
#expect(peerID.prefix == .mesh) XCTAssertEqual(peerID.prefix, .mesh)
} }
// MARK: - init?(data:) // MARK: - init?(data:)
@Test func data_valid_utf8() { func test_init_data_valid_utf8() {
let peerID = PeerID(data: Data(hex16.utf8)) let peerID = PeerID(data: Data(hex16.utf8))
#expect(peerID != nil) XCTAssertNotNil(peerID)
#expect(peerID?.bare == hex16) XCTAssertEqual(peerID?.bare, hex16)
#expect(peerID?.prefix == .empty) XCTAssertEqual(peerID?.prefix, .empty)
} }
@Test func data_invalid_utf8() { func test_init_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))
#expect(peerID == nil) XCTAssertNil(peerID)
} }
// MARK: - init(str: Substring) // MARK: - init(str: Substring)
@Test func substring() { func test_init_substring() {
let substring = hex64.prefix(16) let substring = hex64.prefix(16)
let peerID = PeerID(str: substring) let peerID = PeerID(str: substring)
#expect(peerID.id == String(substring)) XCTAssertEqual(peerID.id, String(substring))
#expect(peerID.bare == String(substring)) XCTAssertEqual(peerID.bare, String(substring))
#expect(peerID.prefix == .empty) XCTAssertEqual(peerID.prefix, .empty)
} }
// MARK: - init(nostr_ pubKey:) // MARK: - init(nostr_ pubKey:)
@Test func nostrUnderscore_pubKey() { func test_init_nostrUnderscore_pubKey() {
let pubKey = hex64 let pubKey = hex64
let peerID = PeerID(nostr_: pubKey) let peerID = PeerID(nostr_: pubKey)
#expect(peerID.id == "nostr_\(pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength))") XCTAssertEqual(peerID.id, "nostr_\(pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength))")
#expect(peerID.bare == String(pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength))) XCTAssertEqual(peerID.bare, String(pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength)))
#expect(peerID.prefix == .geoDM) XCTAssertEqual(peerID.prefix, .geoDM)
} }
// MARK: - init(nostr pubKey:) // MARK: - init(nostr pubKey:)
@Test func nostr_pubKey() { func test_init_nostr_pubKey() {
let pubKey = hex64 let pubKey = hex64
let peerID = PeerID(nostr: pubKey) let peerID = PeerID(nostr: pubKey)
#expect(peerID.id == "nostr:\(pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength))") XCTAssertEqual(peerID.id, "nostr:\(pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength))")
#expect(peerID.bare == String(pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength))) XCTAssertEqual(peerID.bare, String(pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength)))
#expect(peerID.prefix == .geoChat) XCTAssertEqual(peerID.prefix, .geoChat)
} }
// MARK: - init(publicKey:) // MARK: - init(publicKey:)
@Test func publicKey_derivesFingerprint() { func test_init_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)
#expect(peerID.bare == String(expected)) XCTAssertEqual(peerID.bare, String(expected))
#expect(peerID.prefix == .empty) XCTAssertEqual(peerID.prefix, .empty)
} }
// MARK: - toShort() // MARK: - toShort()
@Test func toShort_whenNoiseKeyExists() { func test_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))
#expect(short.prefix == .empty) XCTAssertEqual(short.bare, String(expected))
XCTAssertEqual(short.prefix, .empty)
} }
@Test func toShort_whenNoiseKeyExists_withNoisePrefix() { func test_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))
#expect(short.prefix == .empty) XCTAssertEqual(short.bare, String(expected))
#expect(peerID.prefix == .noise) XCTAssertEqual(short.prefix, .empty)
XCTAssertEqual(peerID.prefix, .noise)
} }
@Test func toShort_whenNoNoiseKey() { func test_toShort_whenNoNoiseKey() {
let peerID = PeerID(str: "some_random_key") let peerID = PeerID(str: "some_random_key")
let short = peerID.toShort() let short = peerID.toShort()
#expect(short == peerID) XCTAssertEqual(short, peerID) // unchanged
} }
// MARK: - Codable // MARK: - Codable
@Test func codable_emptyPrefix() throws { func test_codable_emptyPrefix() throws {
struct Dummy: Codable, Equatable { struct Dummy: Codable, Equatable {
let name: String let name: String
let peerID: PeerID let peerID: PeerID
@@ -230,13 +237,13 @@ struct PeerIDTests {
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))
#expect(decoded.peerID == PeerID(str: str)) XCTAssertEqual(decoded.peerID, PeerID(str: str))
let encoded = try encoder.encode(decoded) let encoded = try encoder.encode(decoded)
#expect(String(data: encoded, encoding: .utf8) == jsonString) XCTAssertEqual(String(data: encoded, encoding: .utf8), jsonString)
} }
@Test func codable_withPrefix() throws { func test_codable_withPrefix() throws {
struct Dummy: Codable, Equatable { struct Dummy: Codable, Equatable {
let peerID: PeerID let peerID: PeerID
} }
@@ -245,191 +252,193 @@ struct PeerIDTests {
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))
#expect(decoded.peerID == PeerID(str: str)) XCTAssertEqual(decoded.peerID, PeerID(str: str))
#expect(decoded.peerID.bare == hex16) XCTAssertEqual(decoded.peerID.bare, hex16)
#expect(decoded.peerID.prefix == .geoDM) XCTAssertEqual(decoded.peerID.prefix, .geoDM)
let encoded = try encoder.encode(decoded) let encoded = try encoder.encode(decoded)
#expect(String(data: encoded, encoding: .utf8) == jsonString) XCTAssertEqual(String(data: encoded, encoding: .utf8), jsonString)
} }
@Test func codable_multiplePrefixes() throws { func test_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))
#expect(decoded.prefix == prefix) XCTAssertEqual(decoded.prefix, prefix)
#expect(decoded.bare == bare) XCTAssertEqual(decoded.bare, bare)
let encoded = try encoder.encode(decoded) let encoded = try encoder.encode(decoded)
#expect(String(data: encoded, encoding: .utf8) == "\"\(str)\"") XCTAssertEqual(String(data: encoded, encoding: .utf8), "\"\(str)\"")
} }
} }
// MARK: - Comparable // MARK: - Comparable
@Test func comparable_sorting_and_equality() { func test_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")
#expect(p1 < p2) XCTAssertTrue(p1 < p2)
#expect(p2 >= p1) XCTAssertFalse(p2 < p1)
#expect(p2 == p3) XCTAssertEqual(p2, p3)
let sorted = [p2, p1].sorted() let sorted = [p2, p1].sorted()
#expect(sorted == [p1, p2]) XCTAssertEqual(sorted, [p1, p2])
} }
@Test func equality() { func test_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
#expect(peerID == string) XCTAssertTrue(peerID == string)
#expect(peerID == Optional(string)) XCTAssertTrue(peerID == Optional(string))
#expect(Optional(peerID) == string) XCTAssertTrue(Optional(peerID) == string)
#expect(Optional(peerID) == Optional(string)) XCTAssertTrue(Optional(peerID) == Optional(string))
// PeerID != String // PeerID != String
#expect(peerID != badString) XCTAssertTrue(peerID != badString)
#expect(peerID != Optional(badString)) XCTAssertTrue(peerID != Optional(badString))
#expect(Optional(peerID) != badString) XCTAssertTrue(Optional(peerID) != badString)
#expect(Optional(peerID) != Optional(badString)) XCTAssertTrue(Optional(peerID) != Optional(badString))
// String == PeerID // String == PeerID
#expect(string == peerID) XCTAssertTrue(string == peerID)
#expect(Optional(string) == peerID) XCTAssertTrue(Optional(string) == peerID)
#expect(string == Optional(peerID)) XCTAssertTrue(string == Optional(peerID))
#expect(Optional(string) == Optional(peerID)) XCTAssertTrue(Optional(string) == Optional(peerID))
// String != PeerID // String != PeerID
#expect(badString != peerID) XCTAssertTrue(badString != peerID)
#expect(Optional(badString) != peerID) XCTAssertTrue(Optional(badString) != peerID)
#expect(badString != Optional(peerID)) XCTAssertTrue(badString != Optional(peerID))
#expect(Optional(badString) != Optional(peerID)) XCTAssertTrue(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))
#expect(peerID != PeerID(str: "bbb")) // Make sure the regular PeerID <> PeerID is not broken
#expect(peerID != Optional(PeerID(str: "bbb"))) XCTAssertTrue(peerID == PeerID(str: "aaa"))
#expect(PeerID(str: "bbb") != peerID) XCTAssertTrue(peerID == Optional(PeerID(str: "aaa")))
#expect(Optional(PeerID(str: "bbb")) != Optional(peerID)) XCTAssertTrue(PeerID(str: "aaa") == 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
@Test func isEmpty_true_and_false() { func test_isEmpty_true_and_false() {
#expect(PeerID(str: "").isEmpty) XCTAssertTrue(PeerID(str: "").isEmpty)
#expect(!PeerID(str: "abc").isEmpty) XCTAssertFalse(PeerID(str: "abc").isEmpty)
} }
@Test func isGeoChat() { func test_isGeoChat() {
#expect(PeerID(str: "nostr:abcdef").isGeoChat) XCTAssertTrue(PeerID(str: "nostr:abcdef").isGeoChat)
#expect(!PeerID(str: "nostr_abcdef").isGeoChat) XCTAssertFalse(PeerID(str: "nostr_abcdef").isGeoChat) // different prefix
} }
@Test func isGeoDM() { func test_isGeoDM() {
#expect(PeerID(str: "nostr_abcdef").isGeoDM) XCTAssertTrue(PeerID(str: "nostr_abcdef").isGeoDM)
#expect(!PeerID(str: "nostr:abcdef").isGeoDM) XCTAssertFalse(PeerID(str: "nostr:abcdef").isGeoDM)
} }
@Test func toPercentEncoded() { func test_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
#expect(encoded == "name%3Asome%20value/with%20spaces%3F") XCTAssertEqual(encoded, "name%3Asome%20value/with%20spaces%3F")
} }
// MARK: - Validation // MARK: - Validation
@Test func accepts_short_hex_peer_id() { func test_accepts_short_hex_peer_id() {
#expect(PeerID(str: "0011223344556677").isValid) XCTAssertTrue(PeerID(str: "0011223344556677").isValid)
#expect(PeerID(str: "aabbccddeeff0011").isValid) XCTAssertTrue(PeerID(str: "aabbccddeeff0011").isValid)
} }
@Test func accepts_full_noise_key_hex() { func test_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
#expect(PeerID(str: hex64).isValid) XCTAssertTrue(PeerID(str: hex64).isValid)
} }
@Test func accepts_internal_alnum_dash_underscore() { func test_accepts_internal_alnum_dash_underscore() {
#expect(PeerID(str: "peer_123-ABC").isValid) XCTAssertTrue(PeerID(str: "peer_123-ABC").isValid)
#expect(PeerID(str: "nostr_user_01").isValid) XCTAssertTrue(PeerID(str: "nostr_user_01").isValid)
} }
@Test func rejects_invalid_characters() { func test_rejects_invalid_characters() {
#expect(!PeerID(str: "peer!@#").isValid) XCTAssertFalse(PeerID(str: "peer!@#").isValid)
#expect(!PeerID(str: "gggggggggggggggg").isValid) // not hex for short form XCTAssertFalse(PeerID(str: "gggggggggggggggg").isValid) // not hex for short form
} }
@Test func rejects_too_long() { func test_rejects_too_long() {
let tooLong = String(repeating: "a", count: 65) let tooLong = String(repeating: "a", count: 65)
#expect(!PeerID(str: tooLong).isValid) XCTAssertFalse(PeerID(str: tooLong).isValid)
} }
@Test func isShort() { func test_isShort() {
#expect(PeerID(str: hex16).isShort) XCTAssertTrue(PeerID(str: hex16).isShort)
#expect(!PeerID(str: "abcd").isShort) // wrong length XCTAssertFalse(PeerID(str: "abcd").isShort) // wrong length
} }
@Test func isNoiseKeyHex_and_noiseKey() { func test_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)
#expect(peerID.isNoiseKeyHex) XCTAssertTrue(peerID.isNoiseKeyHex)
#expect(peerID.noiseKey != nil) XCTAssertNotNil(peerID.noiseKey)
let prefixedPeerID = PeerID(str: "noise:" + hex64) let prefixedPeerID = PeerID(str: "noise:" + hex64)
#expect(prefixedPeerID.isNoiseKeyHex) XCTAssertTrue(prefixedPeerID.isNoiseKeyHex)
#expect(prefixedPeerID.noiseKey != nil) XCTAssertNotNil(prefixedPeerID.noiseKey)
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)
#expect(!badPeerID.isNoiseKeyHex) XCTAssertFalse(badPeerID.isNoiseKeyHex)
#expect(badPeerID.noiseKey == nil) XCTAssertNil(badPeerID.noiseKey)
} }
@Test func prefixes() { func test_prefixes() {
let hex64 = String(repeating: "a", count: 64) let hex64 = String(repeating: "a", count: 64)
#expect(PeerID(str: "noise:\(hex64)").isValid) XCTAssertTrue(PeerID(str: "noise:\(hex64)").isValid)
#expect(PeerID(str: "nostr:\(hex64)").isValid) XCTAssertTrue(PeerID(str: "nostr:\(hex64)").isValid)
#expect(PeerID(str: "nostr_\(hex64)").isValid) XCTAssertTrue(PeerID(str: "nostr_\(hex64)").isValid)
let hex63 = String(repeating: "a", count: 63) let hex63 = String(repeating: "a", count: 63)
#expect(PeerID(str: "noise:\(hex63)").isValid) XCTAssertTrue(PeerID(str: "noise:\(hex63)").isValid)
#expect(PeerID(str: "nostr:\(hex63)").isValid) XCTAssertTrue(PeerID(str: "nostr:\(hex63)").isValid)
#expect(PeerID(str: "nostr_\(hex63)").isValid) XCTAssertTrue(PeerID(str: "nostr_\(hex63)").isValid)
let hex16 = String(repeating: "a", count: 16) let hex16 = String(repeating: "a", count: 16)
#expect(PeerID(str: "noise:\(hex16)").isValid) XCTAssertTrue(PeerID(str: "noise:\(hex16)").isValid)
#expect(PeerID(str: "nostr:\(hex16)").isValid) XCTAssertTrue(PeerID(str: "nostr:\(hex16)").isValid)
#expect(PeerID(str: "nostr_\(hex16)").isValid) XCTAssertTrue(PeerID(str: "nostr_\(hex16)").isValid)
let hex8 = String(repeating: "a", count: 8) let hex8 = String(repeating: "a", count: 8)
#expect(PeerID(str: "noise:\(hex8)").isValid) XCTAssertTrue(PeerID(str: "noise:\(hex8)").isValid)
#expect(PeerID(str: "nostr:\(hex8)").isValid) XCTAssertTrue(PeerID(str: "nostr:\(hex8)").isValid)
#expect(PeerID(str: "nostr_\(hex8)").isValid) XCTAssertTrue(PeerID(str: "nostr_\(hex8)").isValid)
let mesh = "mesh:abcdefg" let mesh = "mesh:abcdefg"
#expect(PeerID(str: "name:\(mesh)").isValid) XCTAssertTrue(PeerID(str: "name:\(mesh)").isValid)
let name = "name:some_name" let name = "name:some_name"
#expect(PeerID(str: "name:\(name)").isValid) XCTAssertTrue(PeerID(str: "name:\(name)").isValid)
let badName = "name:bad:name" let badName = "name:bad:name"
#expect(!PeerID(str: "name:\(badName)").isValid) XCTAssertFalse(PeerID(str: "name:\(badName)").isValid)
// Too long // Too long
let hex65 = String(repeating: "a", count: 65) let hex65 = String(repeating: "a", count: 65)
#expect(!PeerID(str: "noise:\(hex65)").isValid) XCTAssertFalse(PeerID(str: "noise:\(hex65)").isValid)
#expect(!PeerID(str: "nostr:\(hex65)").isValid) XCTAssertFalse(PeerID(str: "nostr:\(hex65)").isValid)
#expect(!PeerID(str: "nostr_\(hex65)").isValid) XCTAssertFalse(PeerID(str: "nostr_\(hex65)").isValid)
} }
} }
@@ -6,9 +6,7 @@
// 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,53 +7,7 @@
// //
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
@@ -322,7 +276,7 @@ private extension SecureLogger {
} }
// Cache the result // Cache the result
cacheQueue.sync { cacheQueue.async(flags: .barrier) {
sanitizationCache.setObject(sanitized as NSString, forKey: key) sanitizationCache.setObject(sanitized as NSString, forKey: key)
} }
+151 -15
View File
@@ -1,23 +1,11 @@
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
#if !canImport(Network) // Declare C entrypoint for Tor when statically linked from an xcframework.
private final class NWPathMonitor { @_silgen_name("tor_main")
var pathUpdateHandler: ((Any) -> Void)? private func tor_main_c(_ argc: Int32, _ argv: UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>?) -> Int32
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")
@@ -298,6 +286,150 @@ 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 }
@@ -308,6 +440,10 @@ 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)