mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 17:45:19 +00:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d487e5bdb | ||
|
|
3d914dcf46 | ||
|
|
4acb2cf540 | ||
|
|
b3ec5eeda0 | ||
|
|
47d75ab9d8 | ||
|
|
3479c7d5df | ||
|
|
8a0727fcf7 | ||
|
|
6588861e34 | ||
|
|
a1647901e5 | ||
|
|
23249f3e41 | ||
|
|
ad4103bacc | ||
|
|
e3149fa098 | ||
|
|
987ba2e694 | ||
|
|
615273a63e | ||
|
|
4563c22d2d | ||
|
|
9f74266527 | ||
|
|
239064e4eb | ||
|
|
d371131ad5 | ||
|
|
d994ccf012 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,12 +0,0 @@
|
|||||||
---
|
|
||||||
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/Swift.swiftmodule/arm64e-apple-macos.swiftmodule'
|
|
||||||
dependencies:
|
|
||||||
- mtime: 1757258659000000000
|
|
||||||
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/Swift.swiftmodule/arm64e-apple-macos.swiftmodule'
|
|
||||||
size: 14166264
|
|
||||||
- mtime: 1754189697000000000
|
|
||||||
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
|
|
||||||
size: 2261306
|
|
||||||
sdk_relative: true
|
|
||||||
version: 1
|
|
||||||
...
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
---
|
|
||||||
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/SwiftOnoneSupport.swiftmodule/arm64e-apple-macos.swiftmodule'
|
|
||||||
dependencies:
|
|
||||||
- mtime: 1757258662000000000
|
|
||||||
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/SwiftOnoneSupport.swiftmodule/arm64e-apple-macos.swiftmodule'
|
|
||||||
size: 18068
|
|
||||||
- mtime: 1754189697000000000
|
|
||||||
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
|
|
||||||
size: 2261306
|
|
||||||
sdk_relative: true
|
|
||||||
- mtime: 1754191141000000000
|
|
||||||
path: 'usr/lib/swift/SwiftOnoneSupport.swiftmodule/arm64e-apple-macos.swiftinterface'
|
|
||||||
size: 1224
|
|
||||||
sdk_relative: true
|
|
||||||
version: 1
|
|
||||||
...
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
---
|
|
||||||
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_Concurrency.swiftmodule/arm64e-apple-macos.swiftmodule'
|
|
||||||
dependencies:
|
|
||||||
- mtime: 1757258669000000000
|
|
||||||
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_Concurrency.swiftmodule/arm64e-apple-macos.swiftmodule'
|
|
||||||
size: 699544
|
|
||||||
- mtime: 1754189697000000000
|
|
||||||
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
|
|
||||||
size: 2261306
|
|
||||||
sdk_relative: true
|
|
||||||
- mtime: 1754192470000000000
|
|
||||||
path: 'usr/lib/swift/_Concurrency.swiftmodule/arm64e-apple-macos.swiftinterface'
|
|
||||||
size: 364219
|
|
||||||
sdk_relative: true
|
|
||||||
version: 1
|
|
||||||
...
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
---
|
|
||||||
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_StringProcessing.swiftmodule/arm64e-apple-macos.swiftmodule'
|
|
||||||
dependencies:
|
|
||||||
- mtime: 1757258664000000000
|
|
||||||
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_StringProcessing.swiftmodule/arm64e-apple-macos.swiftmodule'
|
|
||||||
size: 83568
|
|
||||||
- mtime: 1754189697000000000
|
|
||||||
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
|
|
||||||
size: 2261306
|
|
||||||
sdk_relative: true
|
|
||||||
- mtime: 1754192532000000000
|
|
||||||
path: 'usr/lib/swift/_StringProcessing.swiftmodule/arm64e-apple-macos.swiftinterface'
|
|
||||||
size: 24507
|
|
||||||
sdk_relative: true
|
|
||||||
version: 1
|
|
||||||
...
|
|
||||||
@@ -66,6 +66,9 @@ __pycache__/
|
|||||||
*.tmp
|
*.tmp
|
||||||
*.temp
|
*.temp
|
||||||
|
|
||||||
|
## Cache
|
||||||
|
.cache/
|
||||||
|
|
||||||
# Local build results
|
# Local build results
|
||||||
.Result*/
|
.Result*/
|
||||||
.Result*.xcresult/
|
.Result*.xcresult/
|
||||||
|
|||||||
Generated
+1
@@ -312,6 +312,7 @@
|
|||||||
ne,
|
ne,
|
||||||
"pt-BR",
|
"pt-BR",
|
||||||
ru,
|
ru,
|
||||||
|
tr,
|
||||||
uk,
|
uk,
|
||||||
"zh-Hans",
|
"zh-Hans",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
"value" : "dark"
|
"value" : "dark"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"filename" : "image-1024 1.png",
|
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
@@ -25,7 +24,6 @@
|
|||||||
"value" : "tinted"
|
"value" : "tinted"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"filename" : "image-1024 2.png",
|
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 11 KiB |
@@ -26,11 +26,15 @@ struct BitchatApp: App {
|
|||||||
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
private let idBridge = NostrIdentityBridge()
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let keychain = KeychainManager()
|
let keychain = KeychainManager()
|
||||||
|
let idBridge = self.idBridge
|
||||||
_chatViewModel = StateObject(
|
_chatViewModel = StateObject(
|
||||||
wrappedValue: ChatViewModel(
|
wrappedValue: ChatViewModel(
|
||||||
keychain: keychain,
|
keychain: keychain,
|
||||||
|
idBridge: idBridge,
|
||||||
identityManager: SecureIdentityStateManager(keychain)
|
identityManager: SecureIdentityStateManager(keychain)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -50,7 +54,7 @@ struct BitchatApp: App {
|
|||||||
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
||||||
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
||||||
DispatchQueue.global(qos: .utility).async {
|
DispatchQueue.global(qos: .utility).async {
|
||||||
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
|
let npub = try? idBridge.getCurrentNostrIdentity()?.npub
|
||||||
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
|
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -217,7 +221,7 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
// Get peer ID from userInfo
|
// Get peer ID from userInfo
|
||||||
if let peerID = userInfo["peerID"] as? String {
|
if let peerID = userInfo["peerID"] as? String {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.chatViewModel?.startPrivateChat(with: peerID)
|
self.chatViewModel?.startPrivateChat(with: PeerID(str: peerID))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,6 +180,40 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"%@ active": {
|
||||||
|
"comment": "A label at the bottom of the people list sheet showing the number of active users.",
|
||||||
|
"localizations": {
|
||||||
|
"ar": { "stringUnit": { "state": "translated", "value": "%@ نشطين" } },
|
||||||
|
"bn": { "stringUnit": { "state": "translated", "value": "%@ সক্রিয়" } },
|
||||||
|
"de": { "stringUnit": { "state": "translated", "value": "%@ aktiv" } },
|
||||||
|
"en": { "stringUnit": { "state": "translated", "value": "%@ active" } },
|
||||||
|
"es": { "stringUnit": { "state": "translated", "value": "%@ activos" } },
|
||||||
|
"fil": { "stringUnit": { "state": "translated", "value": "%@ aktibo" } },
|
||||||
|
"fr": { "stringUnit": { "state": "translated", "value": "%@ actifs" } },
|
||||||
|
"he": { "stringUnit": { "state": "translated", "value": "%@ פעילים" } },
|
||||||
|
"hi": { "stringUnit": { "state": "translated", "value": "%@ सक्रिय" } },
|
||||||
|
"id": { "stringUnit": { "state": "translated", "value": "%@ aktif" } },
|
||||||
|
"it": { "stringUnit": { "state": "translated", "value": "%@ attivi" } },
|
||||||
|
"ja": { "stringUnit": { "state": "translated", "value": "%@ 人がアクティブ" } },
|
||||||
|
"ko": { "stringUnit": { "state": "translated", "value": "활성 사용자 %@명" } },
|
||||||
|
"ms": { "stringUnit": { "state": "translated", "value": "%@ aktif" } },
|
||||||
|
"ne": { "stringUnit": { "state": "translated", "value": "%@ सक्रिय" } },
|
||||||
|
"nl": { "stringUnit": { "state": "translated", "value": "%@ actief" } },
|
||||||
|
"pl": { "stringUnit": { "state": "translated", "value": "%@ aktywni" } },
|
||||||
|
"pt": { "stringUnit": { "state": "translated", "value": "%@ ativos" } },
|
||||||
|
"pt-BR": { "stringUnit": { "state": "translated", "value": "%@ ativos" } },
|
||||||
|
"ru": { "stringUnit": { "state": "translated", "value": "%@ активных" } },
|
||||||
|
"sv": { "stringUnit": { "state": "translated", "value": "%@ aktiva" } },
|
||||||
|
"ta": { "stringUnit": { "state": "translated", "value": "%@ செயலில்" } },
|
||||||
|
"th": { "stringUnit": { "state": "translated", "value": "%@ กำลังใช้งาน" } },
|
||||||
|
"tr": { "stringUnit": { "state": "translated", "value": "%@ aktif" } },
|
||||||
|
"uk": { "stringUnit": { "state": "translated", "value": "%@ активних" } },
|
||||||
|
"ur": { "stringUnit": { "state": "translated", "value": "%@ فعال" } },
|
||||||
|
"vi": { "stringUnit": { "state": "translated", "value": "%@ đang hoạt động" } },
|
||||||
|
"zh-Hans": { "stringUnit": { "state": "translated", "value": "活跃用户 %@ 名" } },
|
||||||
|
"zh-Hant": { "stringUnit": { "state": "translated", "value": "活躍使用者 %@ 位" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
"app_info.close": {
|
"app_info.close": {
|
||||||
"extractionState": "manual",
|
"extractionState": "manual",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
|
|||||||
@@ -161,9 +161,14 @@ extension PeerID {
|
|||||||
id.rangeOfCharacter(from: validCharset.inverted) == nil
|
id.rangeOfCharacter(from: validCharset.inverted) == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns true if the `bare` id is all hex
|
||||||
|
var isHex: Bool {
|
||||||
|
bare.allSatisfy { $0.isHexDigit }
|
||||||
|
}
|
||||||
|
|
||||||
/// Short routing IDs (exact 16-hex)
|
/// Short routing IDs (exact 16-hex)
|
||||||
var isShort: Bool {
|
var isShort: Bool {
|
||||||
bare.count == Constants.hexIDLength && Data(hexString: bare) != nil
|
bare.count == Constants.hexIDLength && isHex
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Full Noise key hex (exact 64-hex)
|
/// Full Noise key hex (exact 64-hex)
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
//
|
||||||
|
// NoiseRateLimiter.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class NoiseRateLimiter {
|
||||||
|
private var handshakeTimestamps: [PeerID: [Date]] = [:]
|
||||||
|
private var messageTimestamps: [PeerID: [Date]] = [:]
|
||||||
|
|
||||||
|
// Global rate limiting
|
||||||
|
private var globalHandshakeTimestamps: [Date] = []
|
||||||
|
private var globalMessageTimestamps: [Date] = []
|
||||||
|
|
||||||
|
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
|
||||||
|
|
||||||
|
func allowHandshake(from peerID: PeerID) -> Bool {
|
||||||
|
return queue.sync(flags: .barrier) {
|
||||||
|
let now = Date()
|
||||||
|
let oneMinuteAgo = now.addingTimeInterval(-60)
|
||||||
|
|
||||||
|
// Check global rate limit first
|
||||||
|
globalHandshakeTimestamps = globalHandshakeTimestamps.filter { $0 > oneMinuteAgo }
|
||||||
|
if globalHandshakeTimestamps.count >= NoiseSecurityConstants.maxGlobalHandshakesPerMinute {
|
||||||
|
SecureLogger.warning("Global handshake rate limit exceeded: \(globalHandshakeTimestamps.count)/\(NoiseSecurityConstants.maxGlobalHandshakesPerMinute) per minute", category: .security)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check per-peer rate limit
|
||||||
|
var timestamps = handshakeTimestamps[peerID] ?? []
|
||||||
|
timestamps = timestamps.filter { $0 > oneMinuteAgo }
|
||||||
|
|
||||||
|
if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute {
|
||||||
|
SecureLogger.warning("Per-peer handshake rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: .security)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record new handshake
|
||||||
|
timestamps.append(now)
|
||||||
|
handshakeTimestamps[peerID] = timestamps
|
||||||
|
globalHandshakeTimestamps.append(now)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func allowMessage(from peerID: PeerID) -> Bool {
|
||||||
|
return queue.sync(flags: .barrier) {
|
||||||
|
let now = Date()
|
||||||
|
let oneSecondAgo = now.addingTimeInterval(-1)
|
||||||
|
|
||||||
|
// Check global rate limit first
|
||||||
|
globalMessageTimestamps = globalMessageTimestamps.filter { $0 > oneSecondAgo }
|
||||||
|
if globalMessageTimestamps.count >= NoiseSecurityConstants.maxGlobalMessagesPerSecond {
|
||||||
|
SecureLogger.warning("Global message rate limit exceeded: \(globalMessageTimestamps.count)/\(NoiseSecurityConstants.maxGlobalMessagesPerSecond) per second", category: .security)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check per-peer rate limit
|
||||||
|
var timestamps = messageTimestamps[peerID] ?? []
|
||||||
|
timestamps = timestamps.filter { $0 > oneSecondAgo }
|
||||||
|
|
||||||
|
if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond {
|
||||||
|
SecureLogger.warning("Per-peer message rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: .security)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record new message
|
||||||
|
timestamps.append(now)
|
||||||
|
messageTimestamps[peerID] = timestamps
|
||||||
|
globalMessageTimestamps.append(now)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reset(for peerID: PeerID) {
|
||||||
|
queue.async(flags: .barrier) {
|
||||||
|
self.handshakeTimestamps.removeValue(forKey: peerID)
|
||||||
|
self.messageTimestamps.removeValue(forKey: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetAll() {
|
||||||
|
queue.async(flags: .barrier) {
|
||||||
|
self.handshakeTimestamps.removeAll()
|
||||||
|
self.messageTimestamps.removeAll()
|
||||||
|
self.globalHandshakeTimestamps.removeAll()
|
||||||
|
self.globalMessageTimestamps.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
//
|
|
||||||
// NoiseSecurityConsiderations.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import BitLogger
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
// MARK: - Security Constants
|
|
||||||
|
|
||||||
enum NoiseSecurityConstants {
|
|
||||||
// Maximum message size to prevent memory exhaustion
|
|
||||||
static let maxMessageSize = 65535 // 64KB as per Noise spec
|
|
||||||
|
|
||||||
// Maximum handshake message size
|
|
||||||
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
|
|
||||||
|
|
||||||
// Session timeout - sessions older than this should be renegotiated
|
|
||||||
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
|
||||||
|
|
||||||
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
|
|
||||||
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
|
|
||||||
|
|
||||||
// Handshake timeout - abandon incomplete handshakes
|
|
||||||
static let handshakeTimeout: TimeInterval = 60 // 1 minute
|
|
||||||
|
|
||||||
// Maximum concurrent sessions per peer
|
|
||||||
static let maxSessionsPerPeer = 3
|
|
||||||
|
|
||||||
// Rate limiting
|
|
||||||
static let maxHandshakesPerMinute = 10
|
|
||||||
static let maxMessagesPerSecond = 100
|
|
||||||
|
|
||||||
// Global rate limiting (across all peers)
|
|
||||||
static let maxGlobalHandshakesPerMinute = 30
|
|
||||||
static let maxGlobalMessagesPerSecond = 500
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Security Validations
|
|
||||||
|
|
||||||
struct NoiseSecurityValidator {
|
|
||||||
|
|
||||||
/// Validate message size
|
|
||||||
static func validateMessageSize(_ data: Data) -> Bool {
|
|
||||||
return data.count <= NoiseSecurityConstants.maxMessageSize
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate handshake message size
|
|
||||||
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
|
||||||
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Enhanced Noise Session with Security
|
|
||||||
|
|
||||||
final class SecureNoiseSession: NoiseSession {
|
|
||||||
private(set) var messageCount: UInt64 = 0
|
|
||||||
private let sessionStartTime = Date()
|
|
||||||
private(set) var lastActivityTime = Date()
|
|
||||||
|
|
||||||
override func encrypt(_ plaintext: Data) throws -> Data {
|
|
||||||
// Check session age
|
|
||||||
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
|
|
||||||
throw NoiseSecurityError.sessionExpired
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check message count
|
|
||||||
if messageCount >= NoiseSecurityConstants.maxMessagesPerSession {
|
|
||||||
throw NoiseSecurityError.sessionExhausted
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate message size
|
|
||||||
guard NoiseSecurityValidator.validateMessageSize(plaintext) else {
|
|
||||||
throw NoiseSecurityError.messageTooLarge
|
|
||||||
}
|
|
||||||
|
|
||||||
let encrypted = try super.encrypt(plaintext)
|
|
||||||
messageCount += 1
|
|
||||||
lastActivityTime = Date()
|
|
||||||
|
|
||||||
return encrypted
|
|
||||||
}
|
|
||||||
|
|
||||||
override func decrypt(_ ciphertext: Data) throws -> Data {
|
|
||||||
// Check session age
|
|
||||||
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
|
|
||||||
throw NoiseSecurityError.sessionExpired
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate message size
|
|
||||||
guard NoiseSecurityValidator.validateMessageSize(ciphertext) else {
|
|
||||||
throw NoiseSecurityError.messageTooLarge
|
|
||||||
}
|
|
||||||
|
|
||||||
let decrypted = try super.decrypt(ciphertext)
|
|
||||||
lastActivityTime = Date()
|
|
||||||
|
|
||||||
return decrypted
|
|
||||||
}
|
|
||||||
|
|
||||||
func needsRenegotiation() -> Bool {
|
|
||||||
// Check if we've used more than 90% of message limit
|
|
||||||
let messageThreshold = UInt64(Double(NoiseSecurityConstants.maxMessagesPerSession) * 0.9)
|
|
||||||
if messageCount >= messageThreshold {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if last activity was more than 30 minutes ago
|
|
||||||
if Date().timeIntervalSince(lastActivityTime) > NoiseSecurityConstants.sessionTimeout {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Testing Support
|
|
||||||
#if DEBUG
|
|
||||||
func setLastActivityTimeForTesting(_ date: Date) {
|
|
||||||
lastActivityTime = date
|
|
||||||
}
|
|
||||||
|
|
||||||
func setMessageCountForTesting(_ count: UInt64) {
|
|
||||||
messageCount = count
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Rate Limiter
|
|
||||||
|
|
||||||
final class NoiseRateLimiter {
|
|
||||||
private var handshakeTimestamps: [PeerID: [Date]] = [:]
|
|
||||||
private var messageTimestamps: [PeerID: [Date]] = [:]
|
|
||||||
|
|
||||||
// Global rate limiting
|
|
||||||
private var globalHandshakeTimestamps: [Date] = []
|
|
||||||
private var globalMessageTimestamps: [Date] = []
|
|
||||||
|
|
||||||
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
|
|
||||||
|
|
||||||
func allowHandshake(from peerID: PeerID) -> Bool {
|
|
||||||
return queue.sync(flags: .barrier) {
|
|
||||||
let now = Date()
|
|
||||||
let oneMinuteAgo = now.addingTimeInterval(-60)
|
|
||||||
|
|
||||||
// Check global rate limit first
|
|
||||||
globalHandshakeTimestamps = globalHandshakeTimestamps.filter { $0 > oneMinuteAgo }
|
|
||||||
if globalHandshakeTimestamps.count >= NoiseSecurityConstants.maxGlobalHandshakesPerMinute {
|
|
||||||
SecureLogger.warning("Global handshake rate limit exceeded: \(globalHandshakeTimestamps.count)/\(NoiseSecurityConstants.maxGlobalHandshakesPerMinute) per minute", category: .security)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check per-peer rate limit
|
|
||||||
var timestamps = handshakeTimestamps[peerID] ?? []
|
|
||||||
timestamps = timestamps.filter { $0 > oneMinuteAgo }
|
|
||||||
|
|
||||||
if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute {
|
|
||||||
SecureLogger.warning("Per-peer handshake rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: .security)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record new handshake
|
|
||||||
timestamps.append(now)
|
|
||||||
handshakeTimestamps[peerID] = timestamps
|
|
||||||
globalHandshakeTimestamps.append(now)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func allowMessage(from peerID: PeerID) -> Bool {
|
|
||||||
return queue.sync(flags: .barrier) {
|
|
||||||
let now = Date()
|
|
||||||
let oneSecondAgo = now.addingTimeInterval(-1)
|
|
||||||
|
|
||||||
// Check global rate limit first
|
|
||||||
globalMessageTimestamps = globalMessageTimestamps.filter { $0 > oneSecondAgo }
|
|
||||||
if globalMessageTimestamps.count >= NoiseSecurityConstants.maxGlobalMessagesPerSecond {
|
|
||||||
SecureLogger.warning("Global message rate limit exceeded: \(globalMessageTimestamps.count)/\(NoiseSecurityConstants.maxGlobalMessagesPerSecond) per second", category: .security)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check per-peer rate limit
|
|
||||||
var timestamps = messageTimestamps[peerID] ?? []
|
|
||||||
timestamps = timestamps.filter { $0 > oneSecondAgo }
|
|
||||||
|
|
||||||
if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond {
|
|
||||||
SecureLogger.warning("Per-peer message rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: .security)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record new message
|
|
||||||
timestamps.append(now)
|
|
||||||
messageTimestamps[peerID] = timestamps
|
|
||||||
globalMessageTimestamps.append(now)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func reset(for peerID: PeerID) {
|
|
||||||
queue.async(flags: .barrier) {
|
|
||||||
self.handshakeTimestamps.removeValue(forKey: peerID)
|
|
||||||
self.messageTimestamps.removeValue(forKey: peerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func resetAll() {
|
|
||||||
queue.async(flags: .barrier) {
|
|
||||||
self.handshakeTimestamps.removeAll()
|
|
||||||
self.messageTimestamps.removeAll()
|
|
||||||
self.globalHandshakeTimestamps.removeAll()
|
|
||||||
self.globalMessageTimestamps.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Security Errors
|
|
||||||
|
|
||||||
enum NoiseSecurityError: Error {
|
|
||||||
case sessionExpired
|
|
||||||
case sessionExhausted
|
|
||||||
case messageTooLarge
|
|
||||||
case invalidPeerID
|
|
||||||
case rateLimitExceeded
|
|
||||||
case handshakeTimeout
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
//
|
||||||
|
// NoiseSecurityConstants.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum NoiseSecurityConstants {
|
||||||
|
// Maximum message size to prevent memory exhaustion
|
||||||
|
static let maxMessageSize = 65535 // 64KB as per Noise spec
|
||||||
|
|
||||||
|
// Maximum handshake message size
|
||||||
|
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
|
||||||
|
|
||||||
|
// Session timeout - sessions older than this should be renegotiated
|
||||||
|
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
||||||
|
|
||||||
|
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
|
||||||
|
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
|
||||||
|
|
||||||
|
// Handshake timeout - abandon incomplete handshakes
|
||||||
|
static let handshakeTimeout: TimeInterval = 60 // 1 minute
|
||||||
|
|
||||||
|
// Maximum concurrent sessions per peer
|
||||||
|
static let maxSessionsPerPeer = 3
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
static let maxHandshakesPerMinute = 10
|
||||||
|
static let maxMessagesPerSecond = 100
|
||||||
|
|
||||||
|
// Global rate limiting (across all peers)
|
||||||
|
static let maxGlobalHandshakesPerMinute = 30
|
||||||
|
static let maxGlobalMessagesPerSecond = 500
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
//
|
||||||
|
// NoiseSecurityError.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum NoiseSecurityError: Error {
|
||||||
|
case sessionExpired
|
||||||
|
case sessionExhausted
|
||||||
|
case messageTooLarge
|
||||||
|
case invalidPeerID
|
||||||
|
case rateLimitExceeded
|
||||||
|
case handshakeTimeout
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
//
|
||||||
|
// NoiseSecurityValidator.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct NoiseSecurityValidator {
|
||||||
|
|
||||||
|
/// Validate message size
|
||||||
|
static func validateMessageSize(_ data: Data) -> Bool {
|
||||||
|
return data.count <= NoiseSecurityConstants.maxMessageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate handshake message size
|
||||||
|
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
||||||
|
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -196,12 +196,6 @@ class NoiseSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getHandshakeHash() -> Data? {
|
|
||||||
return sessionQueue.sync {
|
|
||||||
return handshakeHash
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func reset() {
|
func reset() {
|
||||||
sessionQueue.sync(flags: .barrier) {
|
sessionQueue.sync(flags: .barrier) {
|
||||||
let wasEstablished = state == .established
|
let wasEstablished = state == .established
|
||||||
|
|||||||
@@ -6,10 +6,9 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
enum NoiseSessionError: Error {
|
enum NoiseSessionError: Error, Equatable {
|
||||||
case invalidState
|
case invalidState
|
||||||
case notEstablished
|
case notEstablished
|
||||||
case sessionNotFound
|
case sessionNotFound
|
||||||
case handshakeFailed(Error)
|
|
||||||
case alreadyEstablished
|
case alreadyEstablished
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,19 +27,6 @@ final class NoiseSessionManager {
|
|||||||
|
|
||||||
// MARK: - Session Management
|
// MARK: - Session Management
|
||||||
|
|
||||||
func createSession(for peerID: PeerID, role: NoiseRole) -> NoiseSession {
|
|
||||||
return managerQueue.sync(flags: .barrier) {
|
|
||||||
let session = SecureNoiseSession(
|
|
||||||
peerID: peerID,
|
|
||||||
role: role,
|
|
||||||
keychain: keychain,
|
|
||||||
localStaticKey: localStaticKey
|
|
||||||
)
|
|
||||||
sessions[peerID] = session
|
|
||||||
return session
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getSession(for peerID: PeerID) -> NoiseSession? {
|
func getSession(for peerID: PeerID) -> NoiseSession? {
|
||||||
return managerQueue.sync {
|
return managerQueue.sync {
|
||||||
return sessions[peerID]
|
return sessions[peerID]
|
||||||
@@ -48,14 +35,9 @@ final class NoiseSessionManager {
|
|||||||
|
|
||||||
func removeSession(for peerID: PeerID) {
|
func removeSession(for peerID: PeerID) {
|
||||||
managerQueue.sync(flags: .barrier) {
|
managerQueue.sync(flags: .barrier) {
|
||||||
if let session = sessions[peerID] {
|
if let session = sessions.removeValue(forKey: peerID) {
|
||||||
if session.isEstablished() {
|
session.reset() // Clear sensitive data before removing
|
||||||
SecureLogger.info(.sessionExpired(peerID: peerID.id))
|
|
||||||
}
|
}
|
||||||
// Clear sensitive data before removing
|
|
||||||
session.reset()
|
|
||||||
}
|
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,12 +50,6 @@ final class NoiseSessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEstablishedSessions() -> [PeerID: NoiseSession] {
|
|
||||||
return managerQueue.sync {
|
|
||||||
return sessions.filter { $0.value.isEstablished() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Handshake Helpers
|
// MARK: - Handshake Helpers
|
||||||
|
|
||||||
func initiateHandshake(with peerID: PeerID) throws -> Data {
|
func initiateHandshake(with peerID: PeerID) throws -> Data {
|
||||||
@@ -84,23 +60,24 @@ final class NoiseSessionManager {
|
|||||||
throw NoiseSessionError.alreadyEstablished
|
throw NoiseSessionError.alreadyEstablished
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove any existing non-established session
|
let session: NoiseSession
|
||||||
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
|
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if let existingSession = sessions[peerID] {
|
||||||
|
session = existingSession
|
||||||
|
session.reset()
|
||||||
|
} else {
|
||||||
// Create new initiator session
|
// Create new initiator session
|
||||||
let session = SecureNoiseSession(
|
session = SecureNoiseSession(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
role: .initiator,
|
role: .initiator,
|
||||||
keychain: keychain,
|
keychain: keychain,
|
||||||
localStaticKey: localStaticKey
|
localStaticKey: localStaticKey
|
||||||
)
|
)
|
||||||
sessions[peerID] = session
|
sessions[peerID] = session
|
||||||
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let handshakeData = try session.startHandshake()
|
return try session.startHandshake()
|
||||||
return handshakeData
|
|
||||||
} catch {
|
} catch {
|
||||||
// Clean up failed session
|
// Clean up failed session
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
@@ -207,10 +184,6 @@ final class NoiseSessionManager {
|
|||||||
return getSession(for: peerID)?.getRemoteStaticPublicKey()
|
return getSession(for: peerID)?.getRemoteStaticPublicKey()
|
||||||
}
|
}
|
||||||
|
|
||||||
func getHandshakeHash(for peerID: PeerID) -> Data? {
|
|
||||||
return getSession(for: peerID)?.getHandshakeHash()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Session Rekeying
|
// MARK: - Session Rekeying
|
||||||
|
|
||||||
func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)] {
|
func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)] {
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
//
|
||||||
|
// SecureNoiseSession.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class SecureNoiseSession: NoiseSession {
|
||||||
|
private(set) var messageCount: UInt64 = 0
|
||||||
|
private let sessionStartTime = Date()
|
||||||
|
private(set) var lastActivityTime = Date()
|
||||||
|
|
||||||
|
override func encrypt(_ plaintext: Data) throws -> Data {
|
||||||
|
// Check session age
|
||||||
|
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
|
||||||
|
throw NoiseSecurityError.sessionExpired
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check message count
|
||||||
|
if messageCount >= NoiseSecurityConstants.maxMessagesPerSession {
|
||||||
|
throw NoiseSecurityError.sessionExhausted
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate message size
|
||||||
|
guard NoiseSecurityValidator.validateMessageSize(plaintext) else {
|
||||||
|
throw NoiseSecurityError.messageTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
let encrypted = try super.encrypt(plaintext)
|
||||||
|
messageCount += 1
|
||||||
|
lastActivityTime = Date()
|
||||||
|
|
||||||
|
return encrypted
|
||||||
|
}
|
||||||
|
|
||||||
|
override func decrypt(_ ciphertext: Data) throws -> Data {
|
||||||
|
// Check session age
|
||||||
|
if Date().timeIntervalSince(sessionStartTime) > NoiseSecurityConstants.sessionTimeout {
|
||||||
|
throw NoiseSecurityError.sessionExpired
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate message size
|
||||||
|
guard NoiseSecurityValidator.validateMessageSize(ciphertext) else {
|
||||||
|
throw NoiseSecurityError.messageTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
let decrypted = try super.decrypt(ciphertext)
|
||||||
|
lastActivityTime = Date()
|
||||||
|
|
||||||
|
return decrypted
|
||||||
|
}
|
||||||
|
|
||||||
|
func needsRenegotiation() -> Bool {
|
||||||
|
// Check if we've used more than 90% of message limit
|
||||||
|
let messageThreshold = UInt64(Double(NoiseSecurityConstants.maxMessagesPerSession) * 0.9)
|
||||||
|
if messageCount >= messageThreshold {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if last activity was more than 30 minutes ago
|
||||||
|
if Date().timeIntervalSince(lastActivityTime) > NoiseSecurityConstants.sessionTimeout {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Testing Support
|
||||||
|
#if DEBUG
|
||||||
|
func setLastActivityTimeForTesting(_ date: Date) {
|
||||||
|
lastActivityTime = date
|
||||||
|
}
|
||||||
|
|
||||||
|
func setMessageCountForTesting(_ count: UInt64) {
|
||||||
|
messageCount = count
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Bech32 encoding for Nostr (minimal implementation)
|
||||||
|
enum Bech32 {
|
||||||
|
private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||||
|
private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
|
||||||
|
|
||||||
|
static func encode(hrp: String, data: Data) throws -> String {
|
||||||
|
let values = convertBits(from: 8, to: 5, pad: true, data: Array(data))
|
||||||
|
let checksum = createChecksum(hrp: hrp, values: values)
|
||||||
|
let combined = values + checksum
|
||||||
|
|
||||||
|
return hrp + "1" + combined.map {
|
||||||
|
let index = charset.index(charset.startIndex, offsetBy: Int($0))
|
||||||
|
return String(charset[index])
|
||||||
|
}.joined()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {
|
||||||
|
// Find the last occurrence of '1'
|
||||||
|
guard let separatorIndex = bech32String.lastIndex(of: "1") else {
|
||||||
|
throw Bech32Error.invalidFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
let hrp = String(bech32String[..<separatorIndex])
|
||||||
|
|
||||||
|
// Validate HRP contains only ASCII characters
|
||||||
|
for char in hrp {
|
||||||
|
guard char.asciiValue != nil else {
|
||||||
|
throw Bech32Error.invalidCharacter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])
|
||||||
|
|
||||||
|
// Convert characters to values
|
||||||
|
var values = [UInt8]()
|
||||||
|
for char in dataString {
|
||||||
|
guard let index = charset.firstIndex(of: char) else {
|
||||||
|
throw Bech32Error.invalidCharacter
|
||||||
|
}
|
||||||
|
values.append(UInt8(charset.distance(from: charset.startIndex, to: index)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify checksum
|
||||||
|
guard values.count >= 6 else {
|
||||||
|
throw Bech32Error.invalidChecksum
|
||||||
|
}
|
||||||
|
|
||||||
|
let payloadValues = Array(values.dropLast(6))
|
||||||
|
let checksum = Array(values.suffix(6))
|
||||||
|
let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues)
|
||||||
|
|
||||||
|
guard checksum == expectedChecksum else {
|
||||||
|
throw Bech32Error.invalidChecksum
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert back to bytes
|
||||||
|
let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues)
|
||||||
|
return (hrp: hrp, data: Data(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Bech32Error: Error {
|
||||||
|
case invalidFormat
|
||||||
|
case invalidCharacter
|
||||||
|
case invalidChecksum
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] {
|
||||||
|
var acc = 0
|
||||||
|
var bits = 0
|
||||||
|
var result = [UInt8]()
|
||||||
|
let maxv = (1 << to) - 1
|
||||||
|
|
||||||
|
for value in data {
|
||||||
|
acc = (acc << from) | Int(value)
|
||||||
|
bits += from
|
||||||
|
|
||||||
|
while bits >= to {
|
||||||
|
bits -= to
|
||||||
|
result.append(UInt8((acc >> bits) & maxv))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if pad && bits > 0 {
|
||||||
|
result.append(UInt8((acc << (to - bits)) & maxv))
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] {
|
||||||
|
let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0]
|
||||||
|
let polymod = polymod(checksumValues) ^ 1
|
||||||
|
var checksum = [UInt8]()
|
||||||
|
|
||||||
|
for i in 0..<6 {
|
||||||
|
checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31))
|
||||||
|
}
|
||||||
|
|
||||||
|
return checksum
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func hrpExpand(_ hrp: String) -> [UInt8] {
|
||||||
|
var result = [UInt8]()
|
||||||
|
for c in hrp {
|
||||||
|
guard let asciiValue = c.asciiValue else {
|
||||||
|
return [] // Return empty array for invalid input
|
||||||
|
}
|
||||||
|
result.append(UInt8(asciiValue >> 5))
|
||||||
|
}
|
||||||
|
result.append(0)
|
||||||
|
for c in hrp {
|
||||||
|
guard let asciiValue = c.asciiValue else {
|
||||||
|
return [] // Return empty array for invalid input
|
||||||
|
}
|
||||||
|
result.append(UInt8(asciiValue & 31))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func polymod(_ values: [UInt8]) -> Int {
|
||||||
|
var chk = 1
|
||||||
|
for value in values {
|
||||||
|
let b = chk >> 25
|
||||||
|
chk = (chk & 0x1ffffff) << 5 ^ Int(value)
|
||||||
|
for i in 0..<5 {
|
||||||
|
if (b >> i) & 1 == 1 {
|
||||||
|
chk ^= generator[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chk
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol KeychainHelperProtocol {
|
||||||
|
func save(key: String, data: Data, service: String, accessible: CFString?)
|
||||||
|
func load(key: String, service: String) -> Data?
|
||||||
|
func delete(key: String, service: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keychain helper for secure storage
|
||||||
|
struct KeychainHelper: KeychainHelperProtocol {
|
||||||
|
func save(key: String, data: Data, service: String, accessible: CFString? = nil) {
|
||||||
|
var query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
kSecValueData as String: data
|
||||||
|
]
|
||||||
|
if let accessible = accessible {
|
||||||
|
query[kSecAttrAccessible as String] = accessible
|
||||||
|
}
|
||||||
|
|
||||||
|
SecItemDelete(query as CFDictionary)
|
||||||
|
SecItemAdd(query as CFDictionary, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func load(key: String, service: String) -> Data? {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key,
|
||||||
|
kSecReturnData as String: true
|
||||||
|
]
|
||||||
|
|
||||||
|
var result: AnyObject?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
|
|
||||||
|
guard status == errSecSuccess else { return nil }
|
||||||
|
return result as? Data
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete(key: String, service: String) {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: key
|
||||||
|
]
|
||||||
|
|
||||||
|
SecItemDelete(query as CFDictionary)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,50 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
|
||||||
import P256K
|
import P256K
|
||||||
import Security
|
|
||||||
|
|
||||||
// Keychain helper for secure storage
|
|
||||||
struct KeychainHelper {
|
|
||||||
static func save(key: String, data: Data, service: String, accessible: CFString? = nil) {
|
|
||||||
var query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: service,
|
|
||||||
kSecAttrAccount as String: key,
|
|
||||||
kSecValueData as String: data
|
|
||||||
]
|
|
||||||
if let accessible = accessible {
|
|
||||||
query[kSecAttrAccessible as String] = accessible
|
|
||||||
}
|
|
||||||
|
|
||||||
SecItemDelete(query as CFDictionary)
|
|
||||||
SecItemAdd(query as CFDictionary, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func load(key: String, service: String) -> Data? {
|
|
||||||
let query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: service,
|
|
||||||
kSecAttrAccount as String: key,
|
|
||||||
kSecReturnData as String: true
|
|
||||||
]
|
|
||||||
|
|
||||||
var result: AnyObject?
|
|
||||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
||||||
|
|
||||||
guard status == errSecSuccess else { return nil }
|
|
||||||
return result as? Data
|
|
||||||
}
|
|
||||||
|
|
||||||
static func delete(key: String, service: String) {
|
|
||||||
let query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: service,
|
|
||||||
kSecAttrAccount as String: key
|
|
||||||
]
|
|
||||||
|
|
||||||
SecItemDelete(query as CFDictionary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
|
/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
|
||||||
struct NostrIdentity: Codable {
|
struct NostrIdentity: Codable {
|
||||||
@@ -103,266 +58,3 @@ struct NostrIdentity: Codable {
|
|||||||
return publicKey.hexEncodedString()
|
return publicKey.hexEncodedString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bridge between Noise and Nostr identities
|
|
||||||
struct NostrIdentityBridge {
|
|
||||||
private static let keychainService = "chat.bitchat.nostr"
|
|
||||||
private static let currentIdentityKey = "nostr-current-identity"
|
|
||||||
private static let deviceSeedKey = "nostr-device-seed"
|
|
||||||
// In-memory cache to avoid transient keychain access issues
|
|
||||||
private static var deviceSeedCache: Data?
|
|
||||||
|
|
||||||
/// Get or create the current Nostr identity
|
|
||||||
static func getCurrentNostrIdentity() throws -> NostrIdentity? {
|
|
||||||
// Check if we already have a Nostr identity
|
|
||||||
if let existingData = KeychainHelper.load(key: currentIdentityKey, service: keychainService),
|
|
||||||
let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) {
|
|
||||||
return identity
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate new Nostr identity
|
|
||||||
let nostrIdentity = try NostrIdentity.generate()
|
|
||||||
|
|
||||||
// Store it
|
|
||||||
let data = try JSONEncoder().encode(nostrIdentity)
|
|
||||||
KeychainHelper.save(key: currentIdentityKey, data: data, service: keychainService)
|
|
||||||
|
|
||||||
return nostrIdentity
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Associate a Nostr identity with a Noise public key (for favorites)
|
|
||||||
static func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
|
|
||||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
|
||||||
if let data = nostrPubkey.data(using: .utf8) {
|
|
||||||
KeychainHelper.save(key: key, data: data, service: keychainService)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get Nostr public key associated with a Noise public key
|
|
||||||
static func getNostrPublicKey(for noisePublicKey: Data) -> String? {
|
|
||||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
|
||||||
guard let data = KeychainHelper.load(key: key, service: keychainService),
|
|
||||||
let pubkey = String(data: data, encoding: .utf8) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return pubkey
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear all Nostr identity associations and current identity
|
|
||||||
static func clearAllAssociations() {
|
|
||||||
let query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: keychainService,
|
|
||||||
kSecMatchLimit as String: kSecMatchLimitAll,
|
|
||||||
kSecReturnAttributes as String: true
|
|
||||||
]
|
|
||||||
|
|
||||||
var result: AnyObject?
|
|
||||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
||||||
if status == errSecSuccess, let items = result as? [[String: Any]] {
|
|
||||||
for item in items {
|
|
||||||
var deleteQuery: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: keychainService
|
|
||||||
]
|
|
||||||
if let account = item[kSecAttrAccount as String] as? String {
|
|
||||||
deleteQuery[kSecAttrAccount as String] = account
|
|
||||||
}
|
|
||||||
SecItemDelete(deleteQuery as CFDictionary)
|
|
||||||
}
|
|
||||||
} else if status == errSecItemNotFound {
|
|
||||||
// nothing persisted; no action needed
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceSeedCache = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Per-Geohash Identities (Location Channels)
|
|
||||||
|
|
||||||
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
|
|
||||||
/// Stored only on device keychain.
|
|
||||||
private static func getOrCreateDeviceSeed() -> Data {
|
|
||||||
if let cached = deviceSeedCache { return cached }
|
|
||||||
if let existing = KeychainHelper.load(key: deviceSeedKey, service: keychainService) {
|
|
||||||
// Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock
|
|
||||||
KeychainHelper.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
|
||||||
deviceSeedCache = existing
|
|
||||||
return existing
|
|
||||||
}
|
|
||||||
var seed = Data(count: 32)
|
|
||||||
_ = seed.withUnsafeMutableBytes { ptr in
|
|
||||||
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
|
||||||
}
|
|
||||||
// Ensure availability after first unlock to prevent unintended rotation when locked
|
|
||||||
KeychainHelper.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
|
||||||
deviceSeedCache = seed
|
|
||||||
return seed
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
|
|
||||||
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
|
||||||
/// if the candidate is not a valid secp256k1 private key.
|
|
||||||
static func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
|
||||||
let seed = getOrCreateDeviceSeed()
|
|
||||||
guard let msg = geohash.data(using: .utf8) else {
|
|
||||||
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func candidateKey(iteration: UInt32) -> Data {
|
|
||||||
var input = Data(msg)
|
|
||||||
var iterBE = iteration.bigEndian
|
|
||||||
withUnsafeBytes(of: &iterBE) { bytes in
|
|
||||||
input.append(contentsOf: bytes)
|
|
||||||
}
|
|
||||||
let code = CryptoKit.HMAC<CryptoKit.SHA256>.authenticationCode(for: input, using: SymmetricKey(data: seed))
|
|
||||||
return Data(code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try a few iterations to ensure a valid key can be formed
|
|
||||||
for i in 0..<10 {
|
|
||||||
let keyData = candidateKey(iteration: UInt32(i))
|
|
||||||
if let identity = try? NostrIdentity(privateKeyData: keyData) {
|
|
||||||
return identity
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// As a final fallback, hash the seed+msg and try again
|
|
||||||
let fallback = (seed + msg).sha256Hash()
|
|
||||||
return try NostrIdentity(privateKeyData: fallback)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bech32 encoding for Nostr (minimal implementation)
|
|
||||||
enum Bech32 {
|
|
||||||
private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
|
||||||
private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
|
|
||||||
|
|
||||||
static func encode(hrp: String, data: Data) throws -> String {
|
|
||||||
let values = convertBits(from: 8, to: 5, pad: true, data: Array(data))
|
|
||||||
let checksum = createChecksum(hrp: hrp, values: values)
|
|
||||||
let combined = values + checksum
|
|
||||||
|
|
||||||
return hrp + "1" + combined.map {
|
|
||||||
let index = charset.index(charset.startIndex, offsetBy: Int($0))
|
|
||||||
return String(charset[index])
|
|
||||||
}.joined()
|
|
||||||
}
|
|
||||||
|
|
||||||
static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {
|
|
||||||
// Find the last occurrence of '1'
|
|
||||||
guard let separatorIndex = bech32String.lastIndex(of: "1") else {
|
|
||||||
throw Bech32Error.invalidFormat
|
|
||||||
}
|
|
||||||
|
|
||||||
let hrp = String(bech32String[..<separatorIndex])
|
|
||||||
|
|
||||||
// Validate HRP contains only ASCII characters
|
|
||||||
for char in hrp {
|
|
||||||
guard char.asciiValue != nil else {
|
|
||||||
throw Bech32Error.invalidCharacter
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])
|
|
||||||
|
|
||||||
// Convert characters to values
|
|
||||||
var values = [UInt8]()
|
|
||||||
for char in dataString {
|
|
||||||
guard let index = charset.firstIndex(of: char) else {
|
|
||||||
throw Bech32Error.invalidCharacter
|
|
||||||
}
|
|
||||||
values.append(UInt8(charset.distance(from: charset.startIndex, to: index)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify checksum
|
|
||||||
guard values.count >= 6 else {
|
|
||||||
throw Bech32Error.invalidChecksum
|
|
||||||
}
|
|
||||||
|
|
||||||
let payloadValues = Array(values.dropLast(6))
|
|
||||||
let checksum = Array(values.suffix(6))
|
|
||||||
let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues)
|
|
||||||
|
|
||||||
guard checksum == expectedChecksum else {
|
|
||||||
throw Bech32Error.invalidChecksum
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert back to bytes
|
|
||||||
let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues)
|
|
||||||
return (hrp: hrp, data: Data(bytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Bech32Error: Error {
|
|
||||||
case invalidFormat
|
|
||||||
case invalidCharacter
|
|
||||||
case invalidChecksum
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] {
|
|
||||||
var acc = 0
|
|
||||||
var bits = 0
|
|
||||||
var result = [UInt8]()
|
|
||||||
let maxv = (1 << to) - 1
|
|
||||||
|
|
||||||
for value in data {
|
|
||||||
acc = (acc << from) | Int(value)
|
|
||||||
bits += from
|
|
||||||
|
|
||||||
while bits >= to {
|
|
||||||
bits -= to
|
|
||||||
result.append(UInt8((acc >> bits) & maxv))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if pad && bits > 0 {
|
|
||||||
result.append(UInt8((acc << (to - bits)) & maxv))
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] {
|
|
||||||
let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0]
|
|
||||||
let polymod = polymod(checksumValues) ^ 1
|
|
||||||
var checksum = [UInt8]()
|
|
||||||
|
|
||||||
for i in 0..<6 {
|
|
||||||
checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31))
|
|
||||||
}
|
|
||||||
|
|
||||||
return checksum
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func hrpExpand(_ hrp: String) -> [UInt8] {
|
|
||||||
var result = [UInt8]()
|
|
||||||
for c in hrp {
|
|
||||||
guard let asciiValue = c.asciiValue else {
|
|
||||||
return [] // Return empty array for invalid input
|
|
||||||
}
|
|
||||||
result.append(UInt8(asciiValue >> 5))
|
|
||||||
}
|
|
||||||
result.append(0)
|
|
||||||
for c in hrp {
|
|
||||||
guard let asciiValue = c.asciiValue else {
|
|
||||||
return [] // Return empty array for invalid input
|
|
||||||
}
|
|
||||||
result.append(UInt8(asciiValue & 31))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func polymod(_ values: [UInt8]) -> Int {
|
|
||||||
var chk = 1
|
|
||||||
for value in values {
|
|
||||||
let b = chk >> 25
|
|
||||||
chk = (chk & 0x1ffffff) << 5 ^ Int(value)
|
|
||||||
for i in 0..<5 {
|
|
||||||
if (b >> i) & 1 == 1 {
|
|
||||||
chk ^= generator[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return chk
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Data hex encoding extension moved to BinaryEncodingUtils.swift to avoid duplication
|
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
/// Bridge between Noise and Nostr identities
|
||||||
|
final class NostrIdentityBridge {
|
||||||
|
private let keychainService = "chat.bitchat.nostr"
|
||||||
|
private let currentIdentityKey = "nostr-current-identity"
|
||||||
|
private let deviceSeedKey = "nostr-device-seed"
|
||||||
|
// In-memory cache to avoid transient keychain access issues
|
||||||
|
private var deviceSeedCache: Data?
|
||||||
|
|
||||||
|
private let keychain: KeychainHelperProtocol
|
||||||
|
|
||||||
|
init(keychain: KeychainHelperProtocol = KeychainHelper()) {
|
||||||
|
self.keychain = keychain
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get or create the current Nostr identity
|
||||||
|
func getCurrentNostrIdentity() throws -> NostrIdentity? {
|
||||||
|
// Check if we already have a Nostr identity
|
||||||
|
if let existingData = keychain.load(key: currentIdentityKey, service: keychainService),
|
||||||
|
let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) {
|
||||||
|
return identity
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new Nostr identity
|
||||||
|
let nostrIdentity = try NostrIdentity.generate()
|
||||||
|
|
||||||
|
// Store it
|
||||||
|
let data = try JSONEncoder().encode(nostrIdentity)
|
||||||
|
keychain.save(key: currentIdentityKey, data: data, service: keychainService, accessible: nil)
|
||||||
|
|
||||||
|
return nostrIdentity
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Associate a Nostr identity with a Noise public key (for favorites)
|
||||||
|
func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
|
||||||
|
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||||
|
if let data = nostrPubkey.data(using: .utf8) {
|
||||||
|
keychain.save(key: key, data: data, service: keychainService, accessible: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get Nostr public key associated with a Noise public key
|
||||||
|
func getNostrPublicKey(for noisePublicKey: Data) -> String? {
|
||||||
|
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||||
|
guard let data = keychain.load(key: key, service: keychainService),
|
||||||
|
let pubkey = String(data: data, encoding: .utf8) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return pubkey
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all Nostr identity associations and current identity
|
||||||
|
func clearAllAssociations() {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: keychainService,
|
||||||
|
kSecMatchLimit as String: kSecMatchLimitAll,
|
||||||
|
kSecReturnAttributes as String: true
|
||||||
|
]
|
||||||
|
|
||||||
|
var result: AnyObject?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
|
if status == errSecSuccess, let items = result as? [[String: Any]] {
|
||||||
|
for item in items {
|
||||||
|
var deleteQuery: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: keychainService
|
||||||
|
]
|
||||||
|
if let account = item[kSecAttrAccount as String] as? String {
|
||||||
|
deleteQuery[kSecAttrAccount as String] = account
|
||||||
|
}
|
||||||
|
SecItemDelete(deleteQuery as CFDictionary)
|
||||||
|
}
|
||||||
|
} else if status == errSecItemNotFound {
|
||||||
|
// nothing persisted; no action needed
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceSeedCache = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Per-Geohash Identities (Location Channels)
|
||||||
|
|
||||||
|
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
|
||||||
|
/// Stored only on device keychain.
|
||||||
|
private func getOrCreateDeviceSeed() -> Data {
|
||||||
|
if let cached = deviceSeedCache { return cached }
|
||||||
|
if let existing = keychain.load(key: deviceSeedKey, service: keychainService) {
|
||||||
|
// Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock
|
||||||
|
keychain.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||||
|
deviceSeedCache = existing
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
var seed = Data(count: 32)
|
||||||
|
_ = seed.withUnsafeMutableBytes { ptr in
|
||||||
|
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
||||||
|
}
|
||||||
|
// Ensure availability after first unlock to prevent unintended rotation when locked
|
||||||
|
keychain.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||||
|
deviceSeedCache = seed
|
||||||
|
return seed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
|
||||||
|
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
||||||
|
/// if the candidate is not a valid secp256k1 private key.
|
||||||
|
func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
||||||
|
let seed = getOrCreateDeviceSeed()
|
||||||
|
guard let msg = geohash.data(using: .utf8) else {
|
||||||
|
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func candidateKey(iteration: UInt32) -> Data {
|
||||||
|
var input = Data(msg)
|
||||||
|
var iterBE = iteration.bigEndian
|
||||||
|
withUnsafeBytes(of: &iterBE) { bytes in
|
||||||
|
input.append(contentsOf: bytes)
|
||||||
|
}
|
||||||
|
let code = HMAC<SHA256>.authenticationCode(for: input, using: SymmetricKey(data: seed))
|
||||||
|
return Data(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try a few iterations to ensure a valid key can be formed
|
||||||
|
for i in 0..<10 {
|
||||||
|
let keyData = candidateKey(iteration: UInt32(i))
|
||||||
|
if let identity = try? NostrIdentity(privateKeyData: keyData) {
|
||||||
|
return identity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// As a final fallback, hash the seed+msg and try again
|
||||||
|
let fallback = (seed + msg).sha256Hash()
|
||||||
|
return try NostrIdentity(privateKeyData: fallback)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,6 +94,7 @@ final class BLEService: NSObject {
|
|||||||
private var noiseService: NoiseEncryptionService
|
private var noiseService: NoiseEncryptionService
|
||||||
private let identityManager: SecureIdentityStateManagerProtocol
|
private let identityManager: SecureIdentityStateManagerProtocol
|
||||||
private let keychain: KeychainManagerProtocol
|
private let keychain: KeychainManagerProtocol
|
||||||
|
private let idBridge: NostrIdentityBridge
|
||||||
private var myPeerIDData: Data = Data()
|
private var myPeerIDData: Data = Data()
|
||||||
|
|
||||||
// MARK: - Advertising Privacy
|
// MARK: - Advertising Privacy
|
||||||
@@ -198,8 +199,13 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
init(keychain: KeychainManagerProtocol, identityManager: SecureIdentityStateManagerProtocol) {
|
init(
|
||||||
|
keychain: KeychainManagerProtocol,
|
||||||
|
idBridge: NostrIdentityBridge,
|
||||||
|
identityManager: SecureIdentityStateManagerProtocol
|
||||||
|
) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
|
self.idBridge = idBridge
|
||||||
noiseService = NoiseEncryptionService(keychain: keychain)
|
noiseService = NoiseEncryptionService(keychain: keychain)
|
||||||
self.identityManager = identityManager
|
self.identityManager = identityManager
|
||||||
super.init()
|
super.init()
|
||||||
@@ -315,48 +321,49 @@ final class BLEService: NSObject {
|
|||||||
startServices()
|
startServices()
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendMessage(_ content: String, mentions: [String] = [], to recipientID: String? = nil, messageID: String? = nil, timestamp: Date? = nil) {
|
|
||||||
// Ensure this runs on message queue to avoid main thread blocking
|
// Ensure this runs on message queue to avoid main thread blocking
|
||||||
|
func sendMessage(_ content: String, mentions: [String] = [], to recipientID: String? = nil, messageID: String? = nil, timestamp: Date? = nil) {
|
||||||
|
// Call directly if already on messageQueue, otherwise dispatch
|
||||||
|
if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
|
||||||
messageQueue.async { [weak self] in
|
messageQueue.async { [weak self] in
|
||||||
guard let self = self else { return }
|
self?.sendMessage(content, mentions: mentions, to: recipientID, messageID: messageID, timestamp: timestamp)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
guard content.count <= self.maxMessageLength else {
|
guard content.count <= maxMessageLength else {
|
||||||
SecureLogger.error("Message too long: \(content.count) chars", category: .session)
|
SecureLogger.error("Message too long: \(content.count) chars", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let finalMessageID = messageID ?? UUID().uuidString
|
if let recipientID {
|
||||||
let _ = UInt64(Date().timeIntervalSince1970 * 1000)
|
sendPrivateMessage(content, to: recipientID, messageID: messageID ?? UUID().uuidString)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if let recipientID = recipientID {
|
|
||||||
// Private message
|
|
||||||
self.sendPrivateMessage(content, to: recipientID, messageID: finalMessageID)
|
|
||||||
} else {
|
|
||||||
// Public broadcast
|
// Public broadcast
|
||||||
// Create packet with explicit fields so we can sign it
|
// Create packet with explicit fields so we can sign it
|
||||||
let basePacket = BitchatPacket(
|
let basePacket = BitchatPacket(
|
||||||
type: MessageType.message.rawValue,
|
type: MessageType.message.rawValue,
|
||||||
senderID: Data(hexString: self.myPeerID.id) ?? Data(),
|
senderID: Data(hexString: myPeerID.id) ?? Data(),
|
||||||
recipientID: nil,
|
recipientID: nil,
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: Data(content.utf8),
|
payload: Data(content.utf8),
|
||||||
signature: nil,
|
signature: nil,
|
||||||
ttl: self.messageTTL
|
ttl: messageTTL
|
||||||
)
|
)
|
||||||
guard let signedPacket = self.noiseService.signPacket(basePacket) else {
|
guard let signedPacket = noiseService.signPacket(basePacket) else {
|
||||||
SecureLogger.error("❌ Failed to sign public message", category: .security)
|
SecureLogger.error("❌ Failed to sign public message", category: .security)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Pre-mark our own broadcast as processed to avoid handling relayed self copy
|
// Pre-mark our own broadcast as processed to avoid handling relayed self copy
|
||||||
let senderHex = signedPacket.senderID.hexEncodedString()
|
let senderHex = signedPacket.senderID.hexEncodedString()
|
||||||
let dedupID = "\(senderHex)-\(signedPacket.timestamp)-\(signedPacket.type)"
|
let dedupID = "\(senderHex)-\(signedPacket.timestamp)-\(signedPacket.type)"
|
||||||
self.messageDeduplicator.markProcessed(dedupID)
|
messageDeduplicator.markProcessed(dedupID)
|
||||||
// Call synchronously since we're already on background queue
|
// Call synchronously since we're already on background queue
|
||||||
self.broadcastPacket(signedPacket)
|
broadcastPacket(signedPacket)
|
||||||
// Track our own broadcast for sync
|
// Track our own broadcast for sync
|
||||||
self.gossipSyncManager?.onPublicPacketSeen(signedPacket)
|
gossipSyncManager?.onPublicPacketSeen(signedPacket)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Transport Protocol Conformance
|
// MARK: - Transport Protocol Conformance
|
||||||
@@ -590,11 +597,7 @@ final class BLEService: NSObject {
|
|||||||
signature: nil,
|
signature: nil,
|
||||||
ttl: messageTTL
|
ttl: messageTTL
|
||||||
)
|
)
|
||||||
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
|
||||||
broadcastPacket(packet)
|
broadcastPacket(packet)
|
||||||
} else {
|
|
||||||
messageQueue.async { [weak self] in self?.broadcastPacket(packet) }
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("Failed to send read receipt: \(error)")
|
SecureLogger.error("Failed to send read receipt: \(error)")
|
||||||
}
|
}
|
||||||
@@ -616,7 +619,7 @@ final class BLEService: NSObject {
|
|||||||
var content = isFavorite ? "[FAVORITED]" : "[UNFAVORITED]"
|
var content = isFavorite ? "[FAVORITED]" : "[UNFAVORITED]"
|
||||||
|
|
||||||
// Add our Nostr public key if available
|
// Add our Nostr public key if available
|
||||||
if let myNostrIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() {
|
if let myNostrIdentity = try? idBridge.getCurrentNostrIdentity() {
|
||||||
content += ":" + myNostrIdentity.npub
|
content += ":" + myNostrIdentity.npub
|
||||||
SecureLogger.debug("📝 Sending favorite notification with Nostr npub: \(myNostrIdentity.npub)", category: .session)
|
SecureLogger.debug("📝 Sending favorite notification with Nostr npub: \(myNostrIdentity.npub)", category: .session)
|
||||||
}
|
}
|
||||||
@@ -677,19 +680,11 @@ final class BLEService: NSObject {
|
|||||||
// MARK: - GossipSyncManager Delegate
|
// MARK: - GossipSyncManager Delegate
|
||||||
extension BLEService: GossipSyncManager.Delegate {
|
extension BLEService: GossipSyncManager.Delegate {
|
||||||
func sendPacket(_ packet: BitchatPacket) {
|
func sendPacket(_ packet: BitchatPacket) {
|
||||||
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
|
||||||
broadcastPacket(packet)
|
broadcastPacket(packet)
|
||||||
} else {
|
|
||||||
messageQueue.async { [weak self] in self?.broadcastPacket(packet) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendPacket(to peerID: PeerID, packet: BitchatPacket) {
|
func sendPacket(to peerID: PeerID, packet: BitchatPacket) {
|
||||||
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
|
||||||
sendPacketDirected(packet, to: peerID)
|
sendPacketDirected(packet, to: peerID)
|
||||||
} else {
|
|
||||||
messageQueue.async { [weak self] in self?.sendPacketDirected(packet, to: peerID) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {
|
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {
|
||||||
@@ -1056,13 +1051,7 @@ extension BLEService {
|
|||||||
peers[normalizedID] = p
|
peers[normalizedID] = p
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
|
||||||
handleReceivedPacket(packet, from: fromPeerID)
|
handleReceivedPacket(packet, from: fromPeerID)
|
||||||
} else {
|
|
||||||
messageQueue.async { [weak self] in
|
|
||||||
self?.handleReceivedPacket(packet, from: fromPeerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -1525,6 +1514,22 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Safely fetch the current direct-link state for a peer using the BLE queue.
|
||||||
|
private func linkState(for peerID: PeerID) -> (hasPeripheral: Bool, hasCentral: Bool) {
|
||||||
|
let computeState = { () -> (Bool, Bool) in
|
||||||
|
let peripheralUUID = self.peerToPeripheralUUID[peerID]
|
||||||
|
let hasPeripheral = peripheralUUID.flatMap { self.peripherals[$0]?.isConnected } ?? false
|
||||||
|
let hasCentral = self.centralToPeerID.values.contains(peerID)
|
||||||
|
return (hasPeripheral, hasCentral)
|
||||||
|
}
|
||||||
|
|
||||||
|
if DispatchQueue.getSpecific(key: bleQueueKey) != nil {
|
||||||
|
return computeState()
|
||||||
|
} else {
|
||||||
|
return bleQueue.sync { computeState() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
|
private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
|
||||||
service.onPeerAuthenticated = { [weak self] peerID, fingerprint in
|
service.onPeerAuthenticated = { [weak self] peerID, fingerprint in
|
||||||
let peerID = PeerID(str: peerID)
|
let peerID = PeerID(str: peerID)
|
||||||
@@ -1570,11 +1575,7 @@ extension BLEService {
|
|||||||
signature: nil,
|
signature: nil,
|
||||||
ttl: messageTTL
|
ttl: messageTTL
|
||||||
)
|
)
|
||||||
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
|
||||||
broadcastPacket(packet)
|
broadcastPacket(packet)
|
||||||
} else {
|
|
||||||
messageQueue.async { [weak self] in self?.broadcastPacket(packet) }
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("Failed to send verification payload: \(error)")
|
SecureLogger.error("Failed to send verification payload: \(error)")
|
||||||
}
|
}
|
||||||
@@ -1774,14 +1775,8 @@ extension BLEService {
|
|||||||
signature: nil,
|
signature: nil,
|
||||||
ttl: messageTTL
|
ttl: messageTTL
|
||||||
)
|
)
|
||||||
// Call directly if already on messageQueue, otherwise dispatch
|
|
||||||
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
|
||||||
broadcastPacket(packet)
|
broadcastPacket(packet)
|
||||||
} else {
|
|
||||||
messageQueue.async { [weak self] in
|
|
||||||
self?.broadcastPacket(packet)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notify delegate that message was sent
|
// Notify delegate that message was sent
|
||||||
notifyUI { [weak self] in
|
notifyUI { [weak self] in
|
||||||
@@ -1828,14 +1823,7 @@ extension BLEService {
|
|||||||
signature: nil,
|
signature: nil,
|
||||||
ttl: messageTTL
|
ttl: messageTTL
|
||||||
)
|
)
|
||||||
// Call directly if on messageQueue, otherwise dispatch
|
|
||||||
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
|
||||||
broadcastPacket(packet)
|
broadcastPacket(packet)
|
||||||
} else {
|
|
||||||
messageQueue.async { [weak self] in
|
|
||||||
self?.broadcastPacket(packet)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("Failed to initiate handshake: \(error)")
|
SecureLogger.error("Failed to initiate handshake: \(error)")
|
||||||
}
|
}
|
||||||
@@ -1901,6 +1889,14 @@ extension BLEService {
|
|||||||
// MARK: Packet Broadcasting
|
// MARK: Packet Broadcasting
|
||||||
|
|
||||||
private func broadcastPacket(_ packet: BitchatPacket) {
|
private func broadcastPacket(_ packet: BitchatPacket) {
|
||||||
|
// Call directly if already on messageQueue, otherwise dispatch
|
||||||
|
if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
|
||||||
|
messageQueue.async { [weak self] in
|
||||||
|
self?.broadcastPacket(packet)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Encode once using a small per-type padding policy, then delegate by type
|
// Encode once using a small per-type padding policy, then delegate by type
|
||||||
let padForBLE = padPolicy(for: packet.type)
|
let padForBLE = padPolicy(for: packet.type)
|
||||||
guard let data = packet.toBinaryData(padding: padForBLE) else {
|
guard let data = packet.toBinaryData(padding: padForBLE) else {
|
||||||
@@ -2091,6 +2087,14 @@ extension BLEService {
|
|||||||
|
|
||||||
// Directed send helper (unicast to a specific peerID) without altering packet contents
|
// Directed send helper (unicast to a specific peerID) without altering packet contents
|
||||||
private func sendPacketDirected(_ packet: BitchatPacket, to peerID: PeerID) {
|
private func sendPacketDirected(_ packet: BitchatPacket, to peerID: PeerID) {
|
||||||
|
// Call directly if already on messageQueue, otherwise dispatch
|
||||||
|
if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
|
||||||
|
messageQueue.async { [weak self] in
|
||||||
|
self?.sendPacketDirected(packet, to: peerID)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
guard let data = packet.toBinaryData(padding: false) else { return }
|
guard let data = packet.toBinaryData(padding: false) else { return }
|
||||||
sendOnAllLinks(packet: packet, data: data, pad: false, directedOnlyPeer: peerID)
|
sendOnAllLinks(packet: packet, data: data, pad: false, directedOnlyPeer: peerID)
|
||||||
}
|
}
|
||||||
@@ -2125,9 +2129,8 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
guard !toSend.isEmpty else { return }
|
|
||||||
for (_, packet) in toSend {
|
for (_, packet) in toSend {
|
||||||
messageQueue.async { [weak self] in self?.broadcastPacket(packet) }
|
broadcastPacket(packet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2219,6 +2222,16 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleFragment(_ packet: BitchatPacket, from peerID: PeerID) {
|
private func handleFragment(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
|
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
||||||
|
_handleFragment(packet, from: peerID)
|
||||||
|
} else {
|
||||||
|
messageQueue.async(flags: .barrier) { [weak self] in
|
||||||
|
self?._handleFragment(packet, from: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func _handleFragment(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
// Don't process our own fragments
|
// Don't process our own fragments
|
||||||
if peerID == myPeerID {
|
if peerID == myPeerID {
|
||||||
return
|
return
|
||||||
@@ -2288,6 +2301,14 @@ extension BLEService {
|
|||||||
// MARK: Packet Reception
|
// MARK: Packet Reception
|
||||||
|
|
||||||
private func handleReceivedPacket(_ packet: BitchatPacket, from peerID: PeerID) {
|
private func handleReceivedPacket(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
|
// Call directly if already on messageQueue, otherwise dispatch
|
||||||
|
if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
|
||||||
|
messageQueue.async { [weak self] in
|
||||||
|
self?.handleReceivedPacket(packet, from: peerID)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Deduplication (thread-safe)
|
// Deduplication (thread-safe)
|
||||||
let senderID = PeerID(hexData: packet.senderID)
|
let senderID = PeerID(hexData: packet.senderID)
|
||||||
// Include packet type in message ID to prevent collisions between different packet types
|
// Include packet type in message ID to prevent collisions between different packet types
|
||||||
@@ -2453,15 +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 peripheralUUID = peerToPeripheralUUID[peerID]
|
let hasPeripheralConnection = directLinkState.hasPeripheral
|
||||||
let hasPeripheralConnection = peripheralUUID != nil && peripherals[peripheralUUID!]?.isConnected == true
|
|
||||||
|
|
||||||
// Check if this peer is subscribed to us as a central
|
// Check if this peer is subscribed to us as a central
|
||||||
// Note: We can't identify which specific central is which peer without additional mapping
|
// Note: We can't identify which specific central is which peer without additional mapping
|
||||||
let hasCentralSubscription = centralToPeerID.values.contains(peerID)
|
let hasCentralSubscription = directLinkState.hasCentral
|
||||||
|
|
||||||
// Direct announces arrive with full TTL (no prior hop)
|
// Direct announces arrive with full TTL (no prior hop)
|
||||||
let isDirectAnnounce = (packet.ttl == messageTTL)
|
let isDirectAnnounce = (packet.ttl == messageTTL)
|
||||||
@@ -2693,12 +2714,8 @@ extension BLEService {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Determine if we have a direct link to the sender
|
// Determine if we have a direct link to the sender
|
||||||
let hasDirectLink: Bool = collectionsQueue.sync {
|
let directLink = linkState(for: peerID)
|
||||||
let perUUID = peerToPeripheralUUID[peerID]
|
let hasDirectLink = directLink.hasPeripheral || directLink.hasCentral
|
||||||
let perConnected = perUUID != nil && peripherals[perUUID!]?.isConnected == true
|
|
||||||
let hasCentral = centralToPeerID.values.contains(peerID)
|
|
||||||
return perConnected || hasCentral
|
|
||||||
}
|
|
||||||
|
|
||||||
let pathTag = hasDirectLink ? "direct" : "mesh"
|
let pathTag = hasDirectLink ? "direct" : "mesh"
|
||||||
SecureLogger.debug("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: .session)
|
SecureLogger.debug("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: .session)
|
||||||
@@ -2888,14 +2905,8 @@ extension BLEService {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call directly if on messageQueue, otherwise dispatch
|
|
||||||
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
|
||||||
broadcastPacket(signedPacket)
|
broadcastPacket(signedPacket)
|
||||||
} else {
|
|
||||||
messageQueue.async { [weak self] in
|
|
||||||
self?.broadcastPacket(signedPacket)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Ensure our own announce is included in sync state
|
// Ensure our own announce is included in sync state
|
||||||
gossipSyncManager?.onPublicPacketSeen(signedPacket)
|
gossipSyncManager?.onPublicPacketSeen(signedPacket)
|
||||||
}
|
}
|
||||||
@@ -3047,6 +3058,11 @@ extension BLEService {
|
|||||||
private func checkPeerConnectivity() {
|
private func checkPeerConnectivity() {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
var disconnectedPeers: [String] = []
|
var disconnectedPeers: [String] = []
|
||||||
|
let peerIDsForLinkState: [PeerID] = collectionsQueue.sync { Array(peers.keys) }
|
||||||
|
var cachedLinkStates: [PeerID: (hasPeripheral: Bool, hasCentral: Bool)] = [:]
|
||||||
|
for peerID in peerIDsForLinkState {
|
||||||
|
cachedLinkStates[peerID] = linkState(for: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
var removedOfflineCount = 0
|
var removedOfflineCount = 0
|
||||||
collectionsQueue.sync(flags: .barrier) {
|
collectionsQueue.sync(flags: .barrier) {
|
||||||
@@ -3055,9 +3071,9 @@ extension BLEService {
|
|||||||
let retention: TimeInterval = peer.isVerifiedNickname ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds
|
let retention: TimeInterval = peer.isVerifiedNickname ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds
|
||||||
if peer.isConnected && age > TransportConfig.blePeerInactivityTimeoutSeconds {
|
if peer.isConnected && age > TransportConfig.blePeerInactivityTimeoutSeconds {
|
||||||
// Check if we still have an active BLE connection to this peer
|
// Check if we still have an active BLE connection to this peer
|
||||||
let hasPeripheralConnection = peerToPeripheralUUID[peerID] != nil &&
|
let state = cachedLinkStates[peerID] ?? (hasPeripheral: false, hasCentral: false)
|
||||||
peripherals[peerToPeripheralUUID[peerID]!]?.isConnected == true
|
let hasPeripheralConnection = state.hasPeripheral
|
||||||
let hasCentralConnection = centralToPeerID.values.contains(peerID)
|
let hasCentralConnection = state.hasCentral
|
||||||
|
|
||||||
// If direct link is gone, mark as not connected (retain entry for reachability)
|
// If direct link is gone, mark as not connected (retain entry for reachability)
|
||||||
if !hasPeripheralConnection && !hasCentralConnection {
|
if !hasPeripheralConnection && !hasCentralConnection {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ final class CommandProcessor {
|
|||||||
case .location: return true
|
case .location: return true
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
let inGeoDM = (chatViewModel?.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
|
let inGeoDM = chatViewModel?.selectedPrivateChatPeer?.isGeoDM == true
|
||||||
|
|
||||||
switch cmd {
|
switch cmd {
|
||||||
case "/m", "/msg":
|
case "/m", "/msg":
|
||||||
@@ -104,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? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
|
let myHex = (try? chatViewModel?.idBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
|
||||||
let people = vm.visibleGeohashPeople().filter { person in
|
let people = vm.visibleGeohashPeople().filter { person in
|
||||||
if let me = myHex { return person.id.lowercased() != me }
|
if let me = myHex { return person.id.lowercased() != me }
|
||||||
return true
|
return true
|
||||||
@@ -148,9 +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: PeerID(str: targetPeerID)) {
|
if let peerNickname = meshService?.peerNickname(peerID: targetPeerID) {
|
||||||
let personalMessage = "* \(emoji) \(myNickname) \(action) you\(suffix) *"
|
let personalMessage = "* \(emoji) \(myNickname) \(action) you\(suffix) *"
|
||||||
meshService?.sendPrivateMessage(personalMessage, to: PeerID(str: targetPeerID),
|
meshService?.sendPrivateMessage(personalMessage, to: targetPeerID,
|
||||||
recipientNickname: peerNickname,
|
recipientNickname: peerNickname,
|
||||||
messageID: UUID().uuidString)
|
messageID: UUID().uuidString)
|
||||||
// Also add a local system message so the sender sees a natural-language confirmation
|
// Also add a local system message so the sender sees a natural-language confirmation
|
||||||
@@ -214,7 +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(str: peerID)) {
|
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||||
if identityManager.isBlocked(fingerprint: fingerprint) {
|
if identityManager.isBlocked(fingerprint: fingerprint) {
|
||||||
return .success(message: "\(nickname) is already blocked")
|
return .success(message: "\(nickname) is already blocked")
|
||||||
}
|
}
|
||||||
@@ -258,7 +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(str: peerID)) {
|
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||||
if !identityManager.isBlocked(fingerprint: fingerprint) {
|
if !identityManager.isBlocked(fingerprint: fingerprint) {
|
||||||
return .success(message: "\(nickname) is not blocked")
|
return .success(message: "\(nickname) is not blocked")
|
||||||
}
|
}
|
||||||
@@ -285,7 +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) else {
|
let noisePublicKey = Data(hexString: peerID.id) else {
|
||||||
return .error(message: "can't find peer: \(nickname)")
|
return .error(message: "can't find peer: \(nickname)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
|
|
||||||
private static let storageKey = "chat.bitchat.favorites"
|
private static let storageKey = "chat.bitchat.favorites"
|
||||||
private static let keychainService = "chat.bitchat.favorites"
|
private static let keychainService = "chat.bitchat.favorites"
|
||||||
|
private let keychain: KeychainHelperProtocol
|
||||||
|
|
||||||
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
|
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
|
||||||
@Published private(set) var mutualFavorites: Set<Data> = []
|
@Published private(set) var mutualFavorites: Set<Data> = []
|
||||||
@@ -35,7 +36,8 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
|
|
||||||
static let shared = FavoritesPersistenceService()
|
static let shared = FavoritesPersistenceService()
|
||||||
|
|
||||||
private init() {
|
init(keychain: KeychainHelperProtocol = KeychainHelper()) {
|
||||||
|
self.keychain = keychain
|
||||||
loadFavorites()
|
loadFavorites()
|
||||||
|
|
||||||
// Update mutual favorites when favorites change
|
// Update mutual favorites when favorites change
|
||||||
@@ -196,7 +198,7 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
saveFavorites()
|
saveFavorites()
|
||||||
|
|
||||||
// Delete from keychain directly
|
// Delete from keychain directly
|
||||||
KeychainHelper.delete(
|
keychain.delete(
|
||||||
key: Self.storageKey,
|
key: Self.storageKey,
|
||||||
service: Self.keychainService
|
service: Self.keychainService
|
||||||
)
|
)
|
||||||
@@ -216,10 +218,11 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
let data = try encoder.encode(relationships)
|
let data = try encoder.encode(relationships)
|
||||||
|
|
||||||
// Store in keychain for security
|
// Store in keychain for security
|
||||||
KeychainHelper.save(
|
keychain.save(
|
||||||
key: Self.storageKey,
|
key: Self.storageKey,
|
||||||
data: data,
|
data: data,
|
||||||
service: Self.keychainService
|
service: Self.keychainService,
|
||||||
|
accessible: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
// Successfully saved favorites
|
// Successfully saved favorites
|
||||||
@@ -231,7 +234,7 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
private func loadFavorites() {
|
private func loadFavorites() {
|
||||||
// Loading favorites from keychain
|
// Loading favorites from keychain
|
||||||
|
|
||||||
guard let data = KeychainHelper.load(
|
guard let data = keychain.load(
|
||||||
key: Self.storageKey,
|
key: Self.storageKey,
|
||||||
service: Self.keychainService
|
service: Self.keychainService
|
||||||
) else {
|
) else {
|
||||||
|
|||||||
@@ -216,15 +216,4 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
/// Testing-only reset helper
|
|
||||||
func _resetForTesting() {
|
|
||||||
bookmarks.removeAll()
|
|
||||||
membership.removeAll()
|
|
||||||
bookmarkNames.removeAll()
|
|
||||||
persist()
|
|
||||||
persistNames()
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ struct LocationNotesDependencies {
|
|||||||
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
|
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
|
||||||
var now: () -> Date
|
var now: () -> Date
|
||||||
|
|
||||||
|
private static let idBridge = NostrIdentityBridge()
|
||||||
|
|
||||||
static let live = LocationNotesDependencies(
|
static let live = LocationNotesDependencies(
|
||||||
relayLookup: { geohash, count in
|
relayLookup: { geohash, count in
|
||||||
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
|
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
|
||||||
@@ -35,7 +37,7 @@ struct LocationNotesDependencies {
|
|||||||
NostrRelayManager.shared.sendEvent(event, to: relays)
|
NostrRelayManager.shared.sendEvent(event, to: relays)
|
||||||
},
|
},
|
||||||
deriveIdentity: { geohash in
|
deriveIdentity: { geohash in
|
||||||
try NostrIdentityBridge.deriveIdentity(forGeohash: geohash)
|
try idBridge.deriveIdentity(forGeohash: geohash)
|
||||||
},
|
},
|
||||||
now: { Date() }
|
now: { Date() }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ final class NostrTransport: Transport {
|
|||||||
private var isSendingReadAcks = false
|
private var isSendingReadAcks = false
|
||||||
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
|
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
|
||||||
private let keychain: KeychainManagerProtocol
|
private let keychain: KeychainManagerProtocol
|
||||||
|
private let idBridge: NostrIdentityBridge
|
||||||
|
|
||||||
init(keychain: KeychainManagerProtocol) {
|
init(keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
|
self.idBridge = idBridge
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Transport Protocol Conformance
|
// MARK: - Transport Protocol Conformance
|
||||||
@@ -65,7 +67,7 @@ final class NostrTransport: Transport {
|
|||||||
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||||
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
// Convert recipient npub -> hex (x-only)
|
// Convert recipient npub -> hex (x-only)
|
||||||
let recipientHex: String
|
let recipientHex: String
|
||||||
@@ -102,7 +104,7 @@ final class NostrTransport: Transport {
|
|||||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||||
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
||||||
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session)
|
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session)
|
||||||
// Convert recipient npub -> hex
|
// Convert recipient npub -> hex
|
||||||
@@ -129,7 +131,7 @@ final class NostrTransport: Transport {
|
|||||||
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
|
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||||
SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
||||||
let recipientHex: String
|
let recipientHex: String
|
||||||
do {
|
do {
|
||||||
@@ -212,7 +214,7 @@ extension NostrTransport {
|
|||||||
let item = readQueue.removeFirst()
|
let item = readQueue.removeFirst()
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
|
guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
|
||||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
|
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
|
||||||
SecureLogger.debug("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
SecureLogger.debug("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
||||||
// Convert recipient npub -> hex
|
// Convert recipient npub -> hex
|
||||||
let recipientHex: String
|
let recipientHex: String
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import SwiftUI
|
|||||||
|
|
||||||
/// Manages all private chat functionality
|
/// Manages all private chat functionality
|
||||||
final class PrivateChatManager: ObservableObject {
|
final class PrivateChatManager: ObservableObject {
|
||||||
@Published var privateChats: [String: [BitchatMessage]] = [:]
|
@Published var privateChats: [PeerID: [BitchatMessage]] = [:]
|
||||||
@Published var selectedPeer: String? = nil
|
@Published var selectedPeer: PeerID? = nil
|
||||||
@Published var unreadMessages: Set<String> = []
|
@Published var unreadMessages: Set<PeerID> = []
|
||||||
|
|
||||||
private var selectedPeerFingerprint: String? = nil
|
private var selectedPeerFingerprint: String? = nil
|
||||||
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
|
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
|
||||||
@@ -31,11 +31,11 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
private let privateChatCap = TransportConfig.privateChatCap
|
private let privateChatCap = TransportConfig.privateChatCap
|
||||||
|
|
||||||
/// Start a private chat with a peer
|
/// Start a private chat with a peer
|
||||||
func startChat(with peerID: String) {
|
func startChat(with peerID: PeerID) {
|
||||||
selectedPeer = peerID
|
selectedPeer = peerID
|
||||||
|
|
||||||
// Store fingerprint for persistence across reconnections
|
// Store fingerprint for persistence across reconnections
|
||||||
if let fingerprint = meshService?.getFingerprint(for: PeerID(str: peerID)) {
|
if let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||||
selectedPeerFingerprint = fingerprint
|
selectedPeerFingerprint = fingerprint
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Remove duplicate messages by ID and keep chronological order
|
/// Remove duplicate messages by ID and keep chronological order
|
||||||
func sanitizeChat(for peerID: String) {
|
func sanitizeChat(for peerID: PeerID) {
|
||||||
guard let arr = privateChats[peerID] else { return }
|
guard let arr = privateChats[peerID] else { return }
|
||||||
if arr.count <= 1 {
|
if arr.count <= 1 {
|
||||||
return
|
return
|
||||||
@@ -79,7 +79,7 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Mark messages from a peer as read
|
/// Mark messages from a peer as read
|
||||||
func markAsRead(from peerID: String) {
|
func markAsRead(from peerID: PeerID) {
|
||||||
unreadMessages.remove(peerID)
|
unreadMessages.remove(peerID)
|
||||||
|
|
||||||
// Send read receipts for unread messages that haven't been sent yet
|
// Send read receipts for unread messages that haven't been sent yet
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
private var peerIndex: [PeerID: BitchatPeer] = [:]
|
private var peerIndex: [PeerID: BitchatPeer] = [:]
|
||||||
private var fingerprintCache: [PeerID: String] = [:]
|
private var fingerprintCache: [PeerID: String] = [:]
|
||||||
private let meshService: Transport
|
private let meshService: Transport
|
||||||
|
private let idBridge: NostrIdentityBridge
|
||||||
private let identityManager: SecureIdentityStateManagerProtocol
|
private let identityManager: SecureIdentityStateManagerProtocol
|
||||||
weak var messageRouter: MessageRouter?
|
weak var messageRouter: MessageRouter?
|
||||||
private let favoritesService = FavoritesPersistenceService.shared
|
private let favoritesService = FavoritesPersistenceService.shared
|
||||||
@@ -34,8 +35,13 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
init(meshService: Transport, identityManager: SecureIdentityStateManagerProtocol) {
|
init(
|
||||||
|
meshService: Transport,
|
||||||
|
idBridge: NostrIdentityBridge,
|
||||||
|
identityManager: SecureIdentityStateManagerProtocol
|
||||||
|
) {
|
||||||
self.meshService = meshService
|
self.meshService = meshService
|
||||||
|
self.idBridge = idBridge
|
||||||
self.identityManager = identityManager
|
self.identityManager = identityManager
|
||||||
|
|
||||||
// Subscribe to changes from both services
|
// Subscribe to changes from both services
|
||||||
@@ -229,10 +235,10 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get peer ID for nickname
|
/// Get peer ID for nickname
|
||||||
func getPeerID(for nickname: String) -> String? {
|
func getPeerID(for nickname: String) -> PeerID? {
|
||||||
for peer in peers {
|
for peer in peers {
|
||||||
if peer.displayName == nickname || peer.nickname == nickname {
|
if peer.displayName == nickname || peer.nickname == nickname {
|
||||||
return peer.peerID.id
|
return peer.peerID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -285,7 +291,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
var peerNostrKey = peer.nostrPublicKey
|
var peerNostrKey = peer.nostrPublicKey
|
||||||
if peerNostrKey == nil {
|
if peerNostrKey == nil {
|
||||||
// Try to get from NostrIdentityBridge association
|
// Try to get from NostrIdentityBridge association
|
||||||
peerNostrKey = NostrIdentityBridge.getNostrPublicKey(for: peer.noisePublicKey)
|
peerNostrKey = idBridge.getNostrPublicKey(for: peer.noisePublicKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add favorite
|
// Add favorite
|
||||||
@@ -341,7 +347,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
// MARK: - Compatibility Methods (for easy migration)
|
// MARK: - Compatibility Methods (for easy migration)
|
||||||
|
|
||||||
var allPeers: [BitchatPeer] { peers }
|
var allPeers: [BitchatPeer] { peers }
|
||||||
var connectedPeers: [PeerID] { Array(connectedPeerIDs) }
|
var connectedPeers: Set<PeerID> { connectedPeerIDs }
|
||||||
var favoritePeers: Set<String> {
|
var favoritePeers: Set<String> {
|
||||||
Set(favorites.compactMap { getFingerprint(for: $0.peerID) })
|
Set(favorites.compactMap { getFingerprint(for: $0.peerID) })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ final class GossipSyncManager {
|
|||||||
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
|
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
|
||||||
var gcsTargetFpr: Double = 0.01 // 1%
|
var gcsTargetFpr: Double = 0.01 // 1%
|
||||||
var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - discard older messages
|
var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - discard older messages
|
||||||
|
var maintenanceIntervalSeconds: TimeInterval = 30.0
|
||||||
|
var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0
|
||||||
|
var stalePeerTimeoutSeconds: TimeInterval = 60.0
|
||||||
}
|
}
|
||||||
|
|
||||||
private let myPeerID: PeerID
|
private let myPeerID: PeerID
|
||||||
@@ -27,6 +30,7 @@ final class GossipSyncManager {
|
|||||||
// Timer
|
// Timer
|
||||||
private var periodicTimer: DispatchSourceTimer?
|
private var periodicTimer: DispatchSourceTimer?
|
||||||
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
|
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
|
||||||
|
private var lastStalePeerCleanup: Date = .distantPast
|
||||||
|
|
||||||
init(myPeerID: PeerID, config: Config = Config()) {
|
init(myPeerID: PeerID, config: Config = Config()) {
|
||||||
self.myPeerID = myPeerID
|
self.myPeerID = myPeerID
|
||||||
@@ -36,10 +40,10 @@ final class GossipSyncManager {
|
|||||||
func start() {
|
func start() {
|
||||||
stop()
|
stop()
|
||||||
let timer = DispatchSource.makeTimerSource(queue: queue)
|
let timer = DispatchSource.makeTimerSource(queue: queue)
|
||||||
timer.schedule(deadline: .now() + 30.0, repeating: 30.0, leeway: .seconds(1))
|
let interval = max(0.1, config.maintenanceIntervalSeconds)
|
||||||
|
timer.schedule(deadline: .now() + interval, repeating: interval, leeway: .seconds(1))
|
||||||
timer.setEventHandler { [weak self] in
|
timer.setEventHandler { [weak self] in
|
||||||
self?.cleanupExpiredMessages()
|
self?.performPeriodicMaintenance()
|
||||||
self?.sendRequestSync()
|
|
||||||
}
|
}
|
||||||
timer.resume()
|
timer.resume()
|
||||||
periodicTimer = timer
|
periodicTimer = timer
|
||||||
@@ -73,6 +77,15 @@ final class GossipSyncManager {
|
|||||||
return packet.timestamp >= cutoffMs
|
return packet.timestamp >= cutoffMs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func isAnnouncementFresh(_ packet: BitchatPacket) -> Bool {
|
||||||
|
guard config.stalePeerTimeoutSeconds > 0 else { return true }
|
||||||
|
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||||
|
let timeoutMs = UInt64(config.stalePeerTimeoutSeconds * 1000)
|
||||||
|
guard nowMs >= timeoutMs else { return true }
|
||||||
|
let cutoffMs = nowMs - timeoutMs
|
||||||
|
return packet.timestamp >= cutoffMs
|
||||||
|
}
|
||||||
|
|
||||||
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
|
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
|
||||||
let mt = MessageType(rawValue: packet.type)
|
let mt = MessageType(rawValue: packet.type)
|
||||||
let isBroadcastRecipient: Bool = {
|
let isBroadcastRecipient: Bool = {
|
||||||
@@ -86,6 +99,14 @@ final class GossipSyncManager {
|
|||||||
// Reject expired packets to prevent ghost peers and old messages
|
// Reject expired packets to prevent ghost peers and old messages
|
||||||
guard isPacketFresh(packet) else { return }
|
guard isPacketFresh(packet) else { return }
|
||||||
|
|
||||||
|
if isAnnounce {
|
||||||
|
guard isAnnouncementFresh(packet) else {
|
||||||
|
let sender = packet.senderID.hexEncodedString().lowercased()
|
||||||
|
removeState(forNormalizedPeerID: sender)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||||
|
|
||||||
if isBroadcastMessage {
|
if isBroadcastMessage {
|
||||||
@@ -100,7 +121,7 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if isAnnounce {
|
} else if isAnnounce {
|
||||||
let sender = packet.senderID.hexEncodedString()
|
let sender = packet.senderID.hexEncodedString().lowercased()
|
||||||
latestAnnouncementByPeer[sender] = (id: idHex, packet: packet)
|
latestAnnouncementByPeer[sender] = (id: idHex, packet: packet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,6 +251,34 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func performPeriodicMaintenance(now: Date = Date()) {
|
||||||
|
cleanupExpiredMessages()
|
||||||
|
cleanupStaleAnnouncementsIfNeeded(now: now)
|
||||||
|
sendRequestSync()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
|
||||||
|
guard now.timeIntervalSince(lastStalePeerCleanup) >= config.stalePeerCleanupIntervalSeconds else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastStalePeerCleanup = now
|
||||||
|
cleanupStaleAnnouncements(now: now)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cleanupStaleAnnouncements(now: Date) {
|
||||||
|
let timeoutMs = UInt64(config.stalePeerTimeoutSeconds * 1000)
|
||||||
|
let nowMs = UInt64(now.timeIntervalSince1970 * 1000)
|
||||||
|
guard nowMs >= timeoutMs else { return }
|
||||||
|
let cutoff = nowMs - timeoutMs
|
||||||
|
let stalePeerIDs = latestAnnouncementByPeer.compactMap { (peerHex, pair) -> String? in
|
||||||
|
pair.packet.timestamp < cutoff ? peerHex.lowercased() : nil
|
||||||
|
}
|
||||||
|
guard !stalePeerIDs.isEmpty else { return }
|
||||||
|
for peerKey in stalePeerIDs {
|
||||||
|
removeState(forNormalizedPeerID: peerKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Explicit removal hook for LEAVE/stale peer
|
// Explicit removal hook for LEAVE/stale peer
|
||||||
func removeAnnouncementForPeer(_ peerID: PeerID) {
|
func removeAnnouncementForPeer(_ peerID: PeerID) {
|
||||||
queue.async { [weak self] in
|
queue.async { [weak self] in
|
||||||
@@ -239,8 +288,11 @@ final class GossipSyncManager {
|
|||||||
|
|
||||||
private func _removeAnnouncementForPeer(_ peerID: PeerID) {
|
private func _removeAnnouncementForPeer(_ peerID: PeerID) {
|
||||||
let normalizedPeerID = peerID.id.lowercased()
|
let normalizedPeerID = peerID.id.lowercased()
|
||||||
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
|
removeState(forNormalizedPeerID: normalizedPeerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeState(forNormalizedPeerID normalizedPeerID: String) {
|
||||||
|
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
|
||||||
// Remove messages from this peer
|
// Remove messages from this peer
|
||||||
// Collect IDs to remove first to avoid concurrent modification
|
// Collect IDs to remove first to avoid concurrent modification
|
||||||
let messageIdsToRemove = messages.compactMap { (id, message) -> String? in
|
let messageIdsToRemove = messages.compactMap { (id, message) -> String? in
|
||||||
@@ -254,3 +306,25 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
extension GossipSyncManager {
|
||||||
|
func _performMaintenanceSynchronously(now: Date = Date()) {
|
||||||
|
queue.sync {
|
||||||
|
performPeriodicMaintenance(now: now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func _hasAnnouncement(for peerID: PeerID) -> Bool {
|
||||||
|
queue.sync {
|
||||||
|
latestAnnouncementByPeer[peerID.id.lowercased()] != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func _messageCount(for peerID: PeerID) -> Int {
|
||||||
|
queue.sync {
|
||||||
|
messages.values.filter { $0.senderID.hexEncodedString().lowercased() == peerID.id.lowercased() }.count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -91,6 +91,7 @@ struct TextMessageView: View {
|
|||||||
.environmentObject(
|
.environmentObject(
|
||||||
ChatViewModel(
|
ChatViewModel(
|
||||||
keychain: keychain,
|
keychain: keychain,
|
||||||
|
idBridge: NostrIdentityBridge(),
|
||||||
identityManager: SecureIdentityStateManager(keychain)
|
identityManager: SecureIdentityStateManager(keychain)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
+382
-506
File diff suppressed because it is too large
Load Diff
@@ -66,12 +66,12 @@ struct FingerprintView: View {
|
|||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
// Prefer short mesh ID for session/encryption status
|
// Prefer short mesh ID for session/encryption status
|
||||||
let statusPeerID: String = {
|
let statusPeerID: String = {
|
||||||
if peerID.count == 64, let short = viewModel.getShortIDForNoiseKey(peerID) { return short }
|
if peerID.count == 64, let short = viewModel.getShortIDForNoiseKey(peerID) { return short.id }
|
||||||
return peerID
|
return peerID
|
||||||
}()
|
}()
|
||||||
// Resolve a friendly name
|
// Resolve a friendly name
|
||||||
let peerNickname: String = {
|
let peerNickname: String = {
|
||||||
if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName }
|
if let p = viewModel.getPeer(byID: PeerID(str: statusPeerID)) { return p.displayName }
|
||||||
if let name = viewModel.meshService.peerNickname(peerID: PeerID(str: statusPeerID)) { return name }
|
if let name = viewModel.meshService.peerNickname(peerID: PeerID(str: statusPeerID)) { return name }
|
||||||
if peerID.count == 64, let data = Data(hexString: peerID) {
|
if peerID.count == 64, let data = Data(hexString: peerID) {
|
||||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
|
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
|
||||||
@@ -84,7 +84,7 @@ struct FingerprintView: View {
|
|||||||
return Strings.unknownPeer()
|
return Strings.unknownPeer()
|
||||||
}()
|
}()
|
||||||
// Accurate encryption state based on short ID session
|
// Accurate encryption state based on short ID session
|
||||||
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
let encryptionStatus = viewModel.getEncryptionStatus(for: PeerID(str: statusPeerID))
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
if let icon = encryptionStatus.icon {
|
if let icon = encryptionStatus.icon {
|
||||||
@@ -115,7 +115,7 @@ struct FingerprintView: View {
|
|||||||
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(textColor.opacity(0.7))
|
.foregroundColor(textColor.opacity(0.7))
|
||||||
|
|
||||||
if let fingerprint = viewModel.getFingerprint(for: statusPeerID) {
|
if let fingerprint = viewModel.getFingerprint(for: PeerID(str: statusPeerID)) {
|
||||||
Text(formatFingerprint(fingerprint))
|
Text(formatFingerprint(fingerprint))
|
||||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
@@ -176,6 +176,7 @@ struct FingerprintView: View {
|
|||||||
// Verification status
|
// Verification status
|
||||||
if encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified {
|
if encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified {
|
||||||
let isVerified = encryptionStatus == .noiseVerified
|
let isVerified = encryptionStatus == .noiseVerified
|
||||||
|
let peerID = PeerID(str: peerID)
|
||||||
|
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Text(isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge)
|
Text(isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ struct GeohashPeopleList: View {
|
|||||||
} else {
|
} else {
|
||||||
let myHex: String? = {
|
let myHex: String? = {
|
||||||
if case .location(let ch) = LocationChannelManager.shared.selectedChannel,
|
if case .location(let ch) = LocationChannelManager.shared.selectedChannel,
|
||||||
let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
|
let id = try? viewModel.idBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||||
return id.publicKeyHex.lowercased()
|
return id.publicKeyHex.lowercased()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ struct MeshPeerList: View {
|
|||||||
let myPeerID = viewModel.meshService.myPeerID
|
let myPeerID = viewModel.meshService.myPeerID
|
||||||
let mapped: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = viewModel.allPeers.map { peer in
|
let mapped: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = viewModel.allPeers.map { peer in
|
||||||
let isMe = peer.peerID == myPeerID
|
let isMe = peer.peerID == myPeerID
|
||||||
let hasUnread = viewModel.hasUnreadMessages(for: peer.peerID.id)
|
let hasUnread = viewModel.hasUnreadMessages(for: peer.peerID)
|
||||||
let enc = viewModel.getEncryptionStatus(for: peer.peerID.id)
|
let enc = viewModel.getEncryptionStatus(for: peer.peerID)
|
||||||
return (peer, isMe, hasUnread, enc)
|
return (peer, isMe, hasUnread, enc)
|
||||||
}
|
}
|
||||||
// Stable visual order without mutating state here
|
// Stable visual order without mutating state here
|
||||||
@@ -47,7 +47,7 @@ struct MeshPeerList: View {
|
|||||||
let peer = item.peer
|
let peer = item.peer
|
||||||
let isMe = item.isMe
|
let isMe = item.isMe
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
let assigned = viewModel.colorForMeshPeer(id: peer.peerID.id, isDark: colorScheme == .dark)
|
let assigned = viewModel.colorForMeshPeer(id: peer.peerID, isDark: colorScheme == .dark)
|
||||||
let baseColor = isMe ? Color.orange : assigned
|
let baseColor = isMe ? Color.orange : assigned
|
||||||
if isMe {
|
if isMe {
|
||||||
Image(systemName: "person.fill")
|
Image(systemName: "person.fill")
|
||||||
@@ -89,7 +89,7 @@ struct MeshPeerList: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isMe, viewModel.isPeerBlocked(peer.peerID.id) {
|
if !isMe, viewModel.isPeerBlocked(peer.peerID) {
|
||||||
Image(systemName: "nosign")
|
Image(systemName: "nosign")
|
||||||
.font(.bitchatSystem(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
@@ -105,7 +105,7 @@ struct MeshPeerList: View {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Offline: prefer showing verified badge from persisted fingerprints
|
// Offline: prefer showing verified badge from persisted fingerprints
|
||||||
if let fp = viewModel.getFingerprint(for: peer.peerID.id),
|
if let fp = viewModel.getFingerprint(for: peer.peerID),
|
||||||
viewModel.verifiedFingerprints.contains(fp) {
|
viewModel.verifiedFingerprints.contains(fp) {
|
||||||
Image(systemName: "checkmark.seal.fill")
|
Image(systemName: "checkmark.seal.fill")
|
||||||
.font(.bitchatSystem(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ struct VerificationSheetView: View {
|
|||||||
private var boxColor: Color { Color.gray.opacity(0.1) }
|
private var boxColor: Color { Color.gray.opacity(0.1) }
|
||||||
|
|
||||||
private func myQRString() -> String {
|
private func myQRString() -> String {
|
||||||
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
|
let npub = try? viewModel.idBridge.getCurrentNostrIdentity()?.npub
|
||||||
return VerificationService.shared.buildMyQRString(nickname: viewModel.nickname, npub: npub) ?? ""
|
return VerificationService.shared.buildMyQRString(nickname: viewModel.nickname, npub: npub) ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,13 @@
|
|||||||
"value": "공유된 링크",
|
"value": "공유된 링크",
|
||||||
"comment": "Fallback title when saving a shared link"
|
"comment": "Fallback title when saving a shared link"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"tr": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "paylaşılan bağlantı",
|
||||||
|
"comment": "Fallback title when saving a shared link"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -218,6 +225,13 @@
|
|||||||
"value": "링크를 인코딩하는 데 실패했습니다",
|
"value": "링크를 인코딩하는 데 실패했습니다",
|
||||||
"comment": "Shown when the share payload cannot be encoded"
|
"comment": "Shown when the share payload cannot be encoded"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"tr": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "bağlantı kodlanamadı",
|
||||||
|
"comment": "Shown when the share payload cannot be encoded"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -328,6 +342,13 @@
|
|||||||
"value": "공유할 수 있는 내용이 없습니다",
|
"value": "공유할 수 있는 내용이 없습니다",
|
||||||
"comment": "Shown when provided content cannot be shared"
|
"comment": "Shown when provided content cannot be shared"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"tr": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "paylaşılabilir içerik yok",
|
||||||
|
"comment": "Shown when provided content cannot be shared"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -438,6 +459,13 @@
|
|||||||
"value": "공유할 내용이 없습니다",
|
"value": "공유할 내용이 없습니다",
|
||||||
"comment": "Shown when the share extension receives no content"
|
"comment": "Shown when the share extension receives no content"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"tr": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "paylaşılacak bir şey yok",
|
||||||
|
"comment": "Shown when the share extension receives no content"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -548,6 +576,13 @@
|
|||||||
"value": "✓ bitchat으로 링크를 공유했습니다",
|
"value": "✓ bitchat으로 링크를 공유했습니다",
|
||||||
"comment": "Confirmation after successfully sharing a link"
|
"comment": "Confirmation after successfully sharing a link"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"tr": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "✓ bitchat'e bağlantı paylaşıldı",
|
||||||
|
"comment": "Confirmation after successfully sharing a link"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -658,6 +693,13 @@
|
|||||||
"value": "✓ bitchat으로 텍스트를 공유했습니다",
|
"value": "✓ bitchat으로 텍스트를 공유했습니다",
|
||||||
"comment": "Confirmation after successfully sharing text"
|
"comment": "Confirmation after successfully sharing text"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"tr": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "✓ bitchat'e metin paylaşıldı",
|
||||||
|
"comment": "Confirmation after successfully sharing text"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+126
-116
@@ -6,122 +6,128 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import XCTest
|
import Testing
|
||||||
import CoreBluetooth
|
import CoreBluetooth
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
final class BLEServiceTests: XCTestCase {
|
struct BLEServiceTests {
|
||||||
|
private let service: MockBLEService
|
||||||
|
private let myUUID = UUID()
|
||||||
|
private let bus = MockBLEBus()
|
||||||
|
|
||||||
var service: MockBLEService!
|
init() {
|
||||||
|
service = MockBLEService.init(bus: bus)
|
||||||
override func setUp() {
|
service.myPeerID = PeerID(str: myUUID.uuidString)
|
||||||
super.setUp()
|
|
||||||
service = MockBLEService()
|
|
||||||
service.myPeerID = "TEST1234"
|
|
||||||
service.mockNickname = "TestUser"
|
service.mockNickname = "TestUser"
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tearDown() {
|
|
||||||
service = nil
|
|
||||||
super.tearDown()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Basic Functionality Tests
|
// MARK: - Basic Functionality Tests
|
||||||
|
|
||||||
func testServiceInitialization() {
|
@Test func serviceInitialization() {
|
||||||
XCTAssertNotNil(service)
|
#expect(service.myPeerID == PeerID(str: myUUID.uuidString))
|
||||||
XCTAssertEqual(service.myPeerID, "TEST1234")
|
#expect(service.myNickname == "TestUser")
|
||||||
XCTAssertEqual(service.myNickname, "TestUser")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPeerConnection() {
|
@Test func peerConnection() {
|
||||||
// Test connecting a peer
|
let somePeerID = PeerID(str: UUID().uuidString)
|
||||||
service.simulateConnectedPeer("PEER5678")
|
|
||||||
XCTAssertTrue(service.isPeerConnected("PEER5678"))
|
|
||||||
XCTAssertEqual(service.getConnectedPeers().count, 1)
|
|
||||||
|
|
||||||
// Test disconnecting a peer
|
service.simulateConnectedPeer(somePeerID)
|
||||||
service.simulateDisconnectedPeer("PEER5678")
|
#expect(service.isPeerConnected(somePeerID))
|
||||||
XCTAssertFalse(service.isPeerConnected("PEER5678"))
|
#expect(service.getConnectedPeers().count == 1)
|
||||||
XCTAssertEqual(service.getConnectedPeers().count, 0)
|
|
||||||
|
service.simulateDisconnectedPeer(somePeerID)
|
||||||
|
#expect(!service.isPeerConnected(somePeerID))
|
||||||
|
#expect(service.getConnectedPeers().count == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMultiplePeerConnections() {
|
@Test func multiplePeerConnections() {
|
||||||
service.simulateConnectedPeer("PEER1")
|
let peerID1 = PeerID(str: UUID().uuidString)
|
||||||
service.simulateConnectedPeer("PEER2")
|
let peerID2 = PeerID(str: UUID().uuidString)
|
||||||
service.simulateConnectedPeer("PEER3")
|
let peerID3 = PeerID(str: UUID().uuidString)
|
||||||
|
|
||||||
XCTAssertEqual(service.getConnectedPeers().count, 3)
|
service.simulateConnectedPeer(peerID1)
|
||||||
XCTAssertTrue(service.isPeerConnected("PEER1"))
|
service.simulateConnectedPeer(peerID2)
|
||||||
XCTAssertTrue(service.isPeerConnected("PEER2"))
|
service.simulateConnectedPeer(peerID3)
|
||||||
XCTAssertTrue(service.isPeerConnected("PEER3"))
|
|
||||||
|
|
||||||
service.simulateDisconnectedPeer("PEER2")
|
#expect(service.getConnectedPeers().count == 3)
|
||||||
XCTAssertEqual(service.getConnectedPeers().count, 2)
|
#expect(service.isPeerConnected(peerID1))
|
||||||
XCTAssertFalse(service.isPeerConnected("PEER2"))
|
#expect(service.isPeerConnected(peerID2))
|
||||||
|
#expect(service.isPeerConnected(peerID3))
|
||||||
|
|
||||||
|
service.simulateDisconnectedPeer(peerID2)
|
||||||
|
#expect(service.getConnectedPeers().count == 2)
|
||||||
|
#expect(!service.isPeerConnected(peerID2))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Message Sending Tests
|
// MARK: - Message Sending Tests
|
||||||
|
|
||||||
func testSendPublicMessage() {
|
@Test func sendPublicMessage() async throws {
|
||||||
let expectation = XCTestExpectation(description: "Message sent")
|
try await confirmation { receivedPublicMessage in
|
||||||
|
|
||||||
let delegate = MockBitchatDelegate { message in
|
let delegate = MockBitchatDelegate { message in
|
||||||
XCTAssertEqual(message.content, "Hello, world!")
|
#expect(message.content == "Hello, world!")
|
||||||
XCTAssertEqual(message.sender, "TestUser")
|
#expect(message.sender == "TestUser")
|
||||||
XCTAssertFalse(message.isPrivate)
|
#expect(!message.isPrivate)
|
||||||
expectation.fulfill()
|
receivedPublicMessage()
|
||||||
}
|
}
|
||||||
service.delegate = delegate
|
service.delegate = delegate
|
||||||
|
|
||||||
service.sendMessage("Hello, world!")
|
service.sendMessage("Hello, world!")
|
||||||
|
|
||||||
wait(for: [expectation], timeout: 1.0)
|
// Allow async processing
|
||||||
XCTAssertEqual(service.sentMessages.count, 1)
|
try await sleep(0.5)
|
||||||
|
}
|
||||||
|
#expect(service.sentMessages.count == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSendPrivateMessage() {
|
@Test func sendPrivateMessage() async throws {
|
||||||
let expectation = XCTestExpectation(description: "Private message sent")
|
try await confirmation { receivedPrivateMessage in
|
||||||
|
|
||||||
let delegate = MockBitchatDelegate { message in
|
let delegate = MockBitchatDelegate { message in
|
||||||
XCTAssertEqual(message.content, "Secret message")
|
#expect(message.content == "Secret message")
|
||||||
XCTAssertEqual(message.sender, "TestUser")
|
#expect(message.sender == "TestUser")
|
||||||
XCTAssertTrue(message.isPrivate)
|
#expect(message.senderPeerID == PeerID(str: myUUID.uuidString))
|
||||||
XCTAssertEqual(message.recipientNickname, "Bob")
|
#expect(message.isPrivate)
|
||||||
expectation.fulfill()
|
#expect(message.recipientNickname == "Bob")
|
||||||
|
receivedPrivateMessage()
|
||||||
}
|
}
|
||||||
service.delegate = delegate
|
service.delegate = delegate
|
||||||
|
service.sendPrivateMessage(
|
||||||
|
"Secret message",
|
||||||
|
to: PeerID(str: UUID().uuidString),
|
||||||
|
recipientNickname: "Bob",
|
||||||
|
messageID: "MSG123"
|
||||||
|
)
|
||||||
|
|
||||||
service.sendPrivateMessage("Secret message", to: "PEER5678", recipientNickname: "Bob", messageID: "MSG123")
|
// Allow async processing
|
||||||
|
try await sleep(0.5)
|
||||||
wait(for: [expectation], timeout: 1.0)
|
}
|
||||||
XCTAssertEqual(service.sentMessages.count, 1)
|
#expect(service.sentMessages.count == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSendMessageWithMentions() {
|
@Test func sendMessageWithMentions() async throws {
|
||||||
let expectation = XCTestExpectation(description: "Message with mentions sent")
|
try await confirmation { receivedMessageWithMentions in
|
||||||
|
|
||||||
let delegate = MockBitchatDelegate { message in
|
let delegate = MockBitchatDelegate { message in
|
||||||
XCTAssertEqual(message.content, "@alice @bob check this out")
|
#expect(message.content == "@alice @bob check this out")
|
||||||
XCTAssertEqual(message.mentions, ["alice", "bob"])
|
#expect(message.mentions == ["alice", "bob"])
|
||||||
expectation.fulfill()
|
receivedMessageWithMentions()
|
||||||
}
|
}
|
||||||
service.delegate = delegate
|
service.delegate = delegate
|
||||||
|
|
||||||
service.sendMessage("@alice @bob check this out", mentions: ["alice", "bob"])
|
service.sendMessage("@alice @bob check this out", mentions: ["alice", "bob"])
|
||||||
|
|
||||||
wait(for: [expectation], timeout: 1.0)
|
// Allow async processing
|
||||||
|
try await sleep(0.5)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Message Reception Tests
|
// MARK: - Message Reception Tests
|
||||||
|
|
||||||
func testSimulateIncomingMessage() {
|
@Test func simulateIncomingMessage() async throws {
|
||||||
let expectation = XCTestExpectation(description: "Message received")
|
try await confirmation { receiveMessage in
|
||||||
|
let peerID = PeerID(str: UUID().uuidString)
|
||||||
|
|
||||||
let delegate = MockBitchatDelegate { message in
|
let delegate = MockBitchatDelegate { message in
|
||||||
XCTAssertEqual(message.content, "Incoming message")
|
#expect(message.content == "Incoming message")
|
||||||
XCTAssertEqual(message.sender, "RemoteUser")
|
#expect(message.sender == "RemoteUser")
|
||||||
expectation.fulfill()
|
#expect(message.senderPeerID == peerID)
|
||||||
|
receiveMessage()
|
||||||
}
|
}
|
||||||
service.delegate = delegate
|
service.delegate = delegate
|
||||||
|
|
||||||
@@ -134,21 +140,24 @@ final class BLEServiceTests: XCTestCase {
|
|||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
isPrivate: false,
|
isPrivate: false,
|
||||||
recipientNickname: nil,
|
recipientNickname: nil,
|
||||||
senderPeerID: "REMOTE123",
|
senderPeerID: peerID,
|
||||||
mentions: nil
|
mentions: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
service.simulateIncomingMessage(incomingMessage)
|
service.simulateIncomingMessage(incomingMessage)
|
||||||
|
|
||||||
wait(for: [expectation], timeout: 1.0)
|
// Allow async processing
|
||||||
|
try await sleep(0.5)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSimulateIncomingPacket() {
|
@Test func simulateIncomingPacket() async throws {
|
||||||
let expectation = XCTestExpectation(description: "Packet processed")
|
try await confirmation { processPacket in
|
||||||
|
let peerID = PeerID(str: UUID().uuidString)
|
||||||
|
|
||||||
let delegate = MockBitchatDelegate { message in
|
let delegate = MockBitchatDelegate { message in
|
||||||
XCTAssertEqual(message.content, "Packet message")
|
#expect(message.content == "Packet message")
|
||||||
expectation.fulfill()
|
#expect(message.senderPeerID == peerID)
|
||||||
|
processPacket()
|
||||||
}
|
}
|
||||||
service.delegate = delegate
|
service.delegate = delegate
|
||||||
|
|
||||||
@@ -161,18 +170,15 @@ final class BLEServiceTests: XCTestCase {
|
|||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
isPrivate: false,
|
isPrivate: false,
|
||||||
recipientNickname: nil,
|
recipientNickname: nil,
|
||||||
senderPeerID: "PACKET123",
|
senderPeerID: peerID,
|
||||||
mentions: nil
|
mentions: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
guard let payload = message.toBinaryPayload() else {
|
let payload = try #require(message.toBinaryPayload(), "Failed to create binary payload")
|
||||||
XCTFail("Failed to create binary payload")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: 0x01,
|
type: 0x01,
|
||||||
senderID: "PACKET123".data(using: .utf8)!,
|
senderID: peerID.id.data(using: .utf8)!,
|
||||||
recipientID: nil,
|
recipientID: nil,
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
@@ -182,56 +188,61 @@ final class BLEServiceTests: XCTestCase {
|
|||||||
|
|
||||||
service.simulateIncomingPacket(packet)
|
service.simulateIncomingPacket(packet)
|
||||||
|
|
||||||
wait(for: [expectation], timeout: 1.0)
|
// Allow async processing
|
||||||
|
try await sleep(0.5)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Peer Nickname Tests
|
// MARK: - Peer Nickname Tests
|
||||||
|
|
||||||
func testGetPeerNicknames() {
|
@Test func getPeerNicknames() {
|
||||||
service.simulateConnectedPeer("PEER1")
|
let peerID1 = PeerID(str: UUID().uuidString)
|
||||||
service.simulateConnectedPeer("PEER2")
|
let peerID2 = PeerID(str: UUID().uuidString)
|
||||||
|
|
||||||
|
service.simulateConnectedPeer(peerID1)
|
||||||
|
service.simulateConnectedPeer(peerID2)
|
||||||
|
|
||||||
let nicknames = service.getPeerNicknames()
|
let nicknames = service.getPeerNicknames()
|
||||||
XCTAssertEqual(nicknames.count, 2)
|
#expect(nicknames.count == 2)
|
||||||
XCTAssertEqual(nicknames["PEER1"], "MockPeer_PEER1")
|
#expect(nicknames[peerID1] == "MockPeer_\(peerID1)")
|
||||||
XCTAssertEqual(nicknames["PEER2"], "MockPeer_PEER2")
|
#expect(nicknames[peerID2] == "MockPeer_\(peerID2)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Service State Tests
|
// MARK: - Service State Tests
|
||||||
|
|
||||||
func testStartStopServices() {
|
@Test func startStopServices() {
|
||||||
// These are mock implementations, just ensure they don't crash
|
|
||||||
service.startServices()
|
service.startServices()
|
||||||
service.stopServices()
|
service.stopServices()
|
||||||
|
let somePeerID = PeerID(str: UUID().uuidString)
|
||||||
// Service should still be functional after start/stop
|
service.simulateConnectedPeer(somePeerID)
|
||||||
service.simulateConnectedPeer("PEER999")
|
#expect(service.isPeerConnected(somePeerID))
|
||||||
XCTAssertTrue(service.isPeerConnected("PEER999"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Message Delivery Handler Tests
|
// MARK: - Message Delivery Handler Tests
|
||||||
|
|
||||||
func testMessageDeliveryHandler() {
|
@Test func messageDeliveryHandler() async throws {
|
||||||
let expectation = XCTestExpectation(description: "Delivery handler called")
|
try await confirmation { deliveryHandler in
|
||||||
|
|
||||||
service.packetDeliveryHandler = { packet in
|
service.packetDeliveryHandler = { packet in
|
||||||
if let msg = BitchatMessage(packet.payload) {
|
if let msg = BitchatMessage(packet.payload) {
|
||||||
XCTAssertEqual(msg.content, "Test delivery")
|
#expect(msg.content == "Test delivery")
|
||||||
expectation.fulfill()
|
deliveryHandler()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
service.sendMessage("Test delivery")
|
service.sendMessage("Test delivery")
|
||||||
|
|
||||||
wait(for: [expectation], timeout: 1.0)
|
// Allow async processing
|
||||||
|
try await sleep(0.5)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPacketDeliveryHandler() {
|
@Test func packetDeliveryHandler() async throws {
|
||||||
let expectation = XCTestExpectation(description: "Packet handler called")
|
try await confirmation("Packet handler called") { packetHandler in
|
||||||
|
let peerID = PeerID(str: UUID().uuidString)
|
||||||
|
|
||||||
service.packetDeliveryHandler = { packet in
|
service.packetDeliveryHandler = { packet in
|
||||||
XCTAssertEqual(packet.type, 0x01)
|
#expect(packet.type == 0x01)
|
||||||
expectation.fulfill()
|
#expect(packet.senderID == Data(peerID.id.utf8))
|
||||||
|
packetHandler()
|
||||||
}
|
}
|
||||||
|
|
||||||
let message = BitchatMessage(
|
let message = BitchatMessage(
|
||||||
@@ -243,18 +254,15 @@ final class BLEServiceTests: XCTestCase {
|
|||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
isPrivate: false,
|
isPrivate: false,
|
||||||
recipientNickname: nil,
|
recipientNickname: nil,
|
||||||
senderPeerID: "TEST123",
|
senderPeerID: peerID,
|
||||||
mentions: nil
|
mentions: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
guard let payload = message.toBinaryPayload() else {
|
let payload = try #require(message.toBinaryPayload(), "Failed to create payload")
|
||||||
XCTFail("Failed to create payload")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: 0x01,
|
type: 0x01,
|
||||||
senderID: "TEST123".data(using: .utf8)!,
|
senderID: peerID.id.data(using: .utf8)!,
|
||||||
recipientID: nil,
|
recipientID: nil,
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
@@ -264,7 +272,9 @@ final class BLEServiceTests: XCTestCase {
|
|||||||
|
|
||||||
service.simulateIncomingPacket(packet)
|
service.simulateIncomingPacket(packet)
|
||||||
|
|
||||||
wait(for: [expectation], timeout: 1.0)
|
// Allow async processing
|
||||||
|
try await sleep(0.5)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,54 +1,42 @@
|
|||||||
import XCTest
|
import Testing
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
final class CommandProcessorTests: XCTestCase {
|
struct CommandProcessorTests {
|
||||||
|
private var identityManager = MockIdentityManager(MockKeychain())
|
||||||
var identityManager: MockIdentityManager!
|
|
||||||
|
|
||||||
override func setUp() {
|
|
||||||
super.setUp()
|
|
||||||
// Provide a minimal identity manager for commands that query identity/block lists
|
|
||||||
identityManager = MockIdentityManager(MockKeychain())
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tearDown() {
|
|
||||||
identityManager = nil
|
|
||||||
super.tearDown()
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func test_slap_notFoundGrammar() {
|
@Test func slapNotFoundGrammar() {
|
||||||
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
|
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
|
||||||
let result = processor.process("/slap @system")
|
let result = processor.process("/slap @system")
|
||||||
switch result {
|
switch result {
|
||||||
case .error(let message):
|
case .error(let message):
|
||||||
XCTAssertEqual(message, "cannot slap system: not found")
|
#expect(message == "cannot slap system: not found")
|
||||||
default:
|
default:
|
||||||
XCTFail("Expected error result")
|
Issue.record("Expected error result")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func test_hug_notFoundGrammar() {
|
@Test func hugNotFoundGrammar() {
|
||||||
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
|
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
|
||||||
let result = processor.process("/hug @system")
|
let result = processor.process("/hug @system")
|
||||||
switch result {
|
switch result {
|
||||||
case .error(let message):
|
case .error(let message):
|
||||||
XCTAssertEqual(message, "cannot hug system: not found")
|
#expect(message == "cannot hug system: not found")
|
||||||
default:
|
default:
|
||||||
XCTFail("Expected error result")
|
Issue.record("Expected error result")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func test_slap_usageMessage() {
|
@Test func slapUsageMessage() {
|
||||||
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
|
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
|
||||||
let result = processor.process("/slap")
|
let result = processor.process("/slap")
|
||||||
switch result {
|
switch result {
|
||||||
case .error(let message):
|
case .error(let message):
|
||||||
XCTAssertEqual(message, "usage: /slap <nickname>")
|
#expect(message == "usage: /slap <nickname>")
|
||||||
default:
|
default:
|
||||||
XCTFail("Expected error result for usage message")
|
Issue.record("Expected error result for usage message")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,21 +11,19 @@ import CryptoKit
|
|||||||
import struct Foundation.UUID
|
import struct Foundation.UUID
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
// TODO: Remove once MockBLEService is refactored to fix race condition
|
|
||||||
@Suite(.serialized)
|
|
||||||
struct PrivateChatE2ETests {
|
struct PrivateChatE2ETests {
|
||||||
|
|
||||||
private let alice: MockBLEService
|
private let alice: MockBLEService
|
||||||
private let bob: MockBLEService
|
private let bob: MockBLEService
|
||||||
private let charlie: MockBLEService
|
private let charlie: MockBLEService
|
||||||
private let mockKeychain: MockKeychain
|
private let mockKeychain = MockKeychain()
|
||||||
|
private let bus = MockBLEBus()
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Create services with unique peer IDs to avoid any collision
|
// Create services with unique peer IDs to avoid any collision
|
||||||
alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1)
|
alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1, bus: bus)
|
||||||
bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2)
|
bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2, bus: bus)
|
||||||
charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3)
|
charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3, bus: bus)
|
||||||
mockKeychain = MockKeychain()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Basic Private Messaging Tests
|
// MARK: - Basic Private Messaging Tests
|
||||||
@@ -53,7 +51,7 @@ struct PrivateChatE2ETests {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Wait a bit to ensure message would have been delivered if it was going to be
|
// Wait a bit to ensure message would have been delivered if it was going to be
|
||||||
try? await Task.sleep(nanoseconds: UInt64(TestConstants.shortTimeout * 1_000_000_000))
|
try? await sleep(0.1)
|
||||||
}
|
}
|
||||||
|
|
||||||
#expect(!bobReceivedMessage, "Bob should not have received the message")
|
#expect(!bobReceivedMessage, "Bob should not have received the message")
|
||||||
@@ -171,7 +169,7 @@ struct PrivateChatE2ETests {
|
|||||||
// Send encrypted private message
|
// Send encrypted private message
|
||||||
alice.sendPrivateMessage(
|
alice.sendPrivateMessage(
|
||||||
TestConstants.testMessage1,
|
TestConstants.testMessage1,
|
||||||
to: TestConstants.testPeerID2,
|
to: bob.peerID,
|
||||||
recipientNickname: TestConstants.testNickname2
|
recipientNickname: TestConstants.testNickname2
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -235,7 +233,7 @@ struct PrivateChatE2ETests {
|
|||||||
for i in 0..<messageCount {
|
for i in 0..<messageCount {
|
||||||
alice.sendPrivateMessage(
|
alice.sendPrivateMessage(
|
||||||
"Private message \(i)",
|
"Private message \(i)",
|
||||||
to: TestConstants.testPeerID2,
|
to: bob.peerID,
|
||||||
recipientNickname: TestConstants.testNickname2
|
recipientNickname: TestConstants.testNickname2
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -254,7 +252,7 @@ struct PrivateChatE2ETests {
|
|||||||
|
|
||||||
alice.sendPrivateMessage(
|
alice.sendPrivateMessage(
|
||||||
TestConstants.testLongMessage,
|
TestConstants.testLongMessage,
|
||||||
to: TestConstants.testPeerID2,
|
to: bob.peerID,
|
||||||
recipientNickname: TestConstants.testNickname2
|
recipientNickname: TestConstants.testNickname2
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,22 +10,22 @@ import Testing
|
|||||||
import struct Foundation.UUID
|
import struct Foundation.UUID
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
@Suite(.serialized)
|
|
||||||
struct PublicChatE2ETests {
|
struct PublicChatE2ETests {
|
||||||
|
|
||||||
private let alice: MockBLEService
|
private let alice: MockBLEService
|
||||||
private let bob: MockBLEService
|
private let bob: MockBLEService
|
||||||
private let charlie: MockBLEService
|
private let charlie: MockBLEService
|
||||||
private let david: MockBLEService
|
private let david: MockBLEService
|
||||||
|
private let bus = MockBLEBus()
|
||||||
|
|
||||||
private var receivedMessages: [String: [BitchatMessage]] = [:]
|
private var receivedMessages: [String: [BitchatMessage]] = [:]
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Create mock services with unique peer IDs to avoid any collision
|
// Create mock services with unique peer IDs to avoid any collision
|
||||||
alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1)
|
alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1, bus: bus)
|
||||||
bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2)
|
bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2, bus: bus)
|
||||||
charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3)
|
charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3, bus: bus)
|
||||||
david = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname4)
|
david = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname4, bus: bus)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Basic Broadcasting Tests
|
// MARK: - Basic Broadcasting Tests
|
||||||
|
|||||||
@@ -15,20 +15,26 @@ struct FragmentationTests {
|
|||||||
|
|
||||||
private let mockKeychain: MockKeychain
|
private let mockKeychain: MockKeychain
|
||||||
private let mockIdentityManager: MockIdentityManager
|
private let mockIdentityManager: MockIdentityManager
|
||||||
|
private let idBridge: NostrIdentityBridge
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
mockKeychain = MockKeychain()
|
mockKeychain = MockKeychain()
|
||||||
mockIdentityManager = MockIdentityManager(mockKeychain)
|
mockIdentityManager = MockIdentityManager(mockKeychain)
|
||||||
|
idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Reassembly from fragments delivers a public message")
|
@Test("Reassembly from fragments delivers a public message")
|
||||||
func reassemblyFromFragmentsDeliversPublicMessage() async throws {
|
func reassemblyFromFragmentsDeliversPublicMessage() async throws {
|
||||||
let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager)
|
let ble = BLEService(
|
||||||
|
keychain: mockKeychain,
|
||||||
|
idBridge: idBridge,
|
||||||
|
identityManager: mockIdentityManager
|
||||||
|
)
|
||||||
let capture = CaptureDelegate()
|
let capture = CaptureDelegate()
|
||||||
ble.delegate = capture
|
ble.delegate = capture
|
||||||
|
|
||||||
// Construct a big packet (3KB) from a remote sender (not our own ID)
|
// Construct a big packet (3KB) from a remote sender (not our own ID)
|
||||||
let remoteShortID: PeerID = "1122334455667788"
|
let remoteShortID = PeerID(str: "1122334455667788")
|
||||||
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 3_000)
|
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 3_000)
|
||||||
|
|
||||||
// Use a small fragment size to ensure multiple pieces
|
// Use a small fragment size to ensure multiple pieces
|
||||||
@@ -39,15 +45,15 @@ struct FragmentationTests {
|
|||||||
|
|
||||||
// Inject fragments spaced out to avoid concurrent mutation inside BLEService
|
// Inject fragments spaced out to avoid concurrent mutation inside BLEService
|
||||||
for (i, fragment) in shuffled.enumerated() {
|
for (i, fragment) in shuffled.enumerated() {
|
||||||
let delay = UInt64(5 * i) * 1_000_000 // nanoseconds
|
let delay = 5 * Double(i) * 0.001
|
||||||
Task {
|
Task {
|
||||||
try await Task.sleep(nanoseconds: delay)
|
try await sleep(delay)
|
||||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow async processing
|
// Allow async processing
|
||||||
try await Task.sleep(nanoseconds: 500_000_000) // 0.5s
|
try await sleep(0.5)
|
||||||
|
|
||||||
#expect(capture.publicMessages.count == 1)
|
#expect(capture.publicMessages.count == 1)
|
||||||
#expect(capture.publicMessages.first?.content.count == 3_000)
|
#expect(capture.publicMessages.first?.content.count == 3_000)
|
||||||
@@ -55,11 +61,15 @@ struct FragmentationTests {
|
|||||||
|
|
||||||
@Test("Duplicate fragment does not break reassembly")
|
@Test("Duplicate fragment does not break reassembly")
|
||||||
func duplicateFragmentDoesNotBreakReassembly() async throws {
|
func duplicateFragmentDoesNotBreakReassembly() async throws {
|
||||||
let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager)
|
let ble = BLEService(
|
||||||
|
keychain: mockKeychain,
|
||||||
|
idBridge: idBridge,
|
||||||
|
identityManager: mockIdentityManager
|
||||||
|
)
|
||||||
let capture = CaptureDelegate()
|
let capture = CaptureDelegate()
|
||||||
ble.delegate = capture
|
ble.delegate = capture
|
||||||
|
|
||||||
let remoteShortID: PeerID = "A1B2C3D4E5F60708"
|
let remoteShortID = PeerID(str: "A1B2C3D4E5F60708")
|
||||||
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 2048)
|
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 2048)
|
||||||
var frags = fragmentPacket(original, fragmentSize: 300)
|
var frags = fragmentPacket(original, fragmentSize: 300)
|
||||||
|
|
||||||
@@ -69,15 +79,15 @@ struct FragmentationTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (i, fragment) in frags.enumerated() {
|
for (i, fragment) in frags.enumerated() {
|
||||||
let delay = UInt64(5 * i) * 1_000_000 // nanoseconds
|
let delay = 5 * Double(i) * 0.001
|
||||||
Task {
|
Task {
|
||||||
try await Task.sleep(nanoseconds: delay)
|
try await sleep(delay)
|
||||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow async processing
|
// Allow async processing
|
||||||
try await Task.sleep(nanoseconds: 500_000_000) // 0.5s
|
try await sleep(0.5)
|
||||||
|
|
||||||
#expect(capture.publicMessages.count == 1)
|
#expect(capture.publicMessages.count == 1)
|
||||||
#expect(capture.publicMessages.first?.content.count == 2048)
|
#expect(capture.publicMessages.first?.content.count == 2048)
|
||||||
@@ -85,11 +95,15 @@ struct FragmentationTests {
|
|||||||
|
|
||||||
@Test("Invalid fragment header is ignored")
|
@Test("Invalid fragment header is ignored")
|
||||||
func invalidFragmentHeaderIsIgnored() async throws {
|
func invalidFragmentHeaderIsIgnored() async throws {
|
||||||
let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager)
|
let ble = BLEService(
|
||||||
|
keychain: mockKeychain,
|
||||||
|
idBridge: idBridge,
|
||||||
|
identityManager: mockIdentityManager
|
||||||
|
)
|
||||||
let capture = CaptureDelegate()
|
let capture = CaptureDelegate()
|
||||||
ble.delegate = capture
|
ble.delegate = capture
|
||||||
|
|
||||||
let remoteShortID: PeerID = "0011223344556677"
|
let remoteShortID = PeerID(str: "0011223344556677")
|
||||||
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 1000)
|
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 1000)
|
||||||
let fragments = fragmentPacket(original, fragmentSize: 250)
|
let fragments = fragmentPacket(original, fragmentSize: 250)
|
||||||
|
|
||||||
@@ -110,15 +124,15 @@ struct FragmentationTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (i, fragment) in corrupted.enumerated() {
|
for (i, fragment) in corrupted.enumerated() {
|
||||||
let delay = UInt64(5 * i) * 1_000_000 // nanoseconds
|
let delay = 5 * Double(i) * 0.001
|
||||||
Task {
|
Task {
|
||||||
try await Task.sleep(nanoseconds: delay)
|
try await sleep(delay)
|
||||||
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow async processing
|
// Allow async processing
|
||||||
try await Task.sleep(nanoseconds: 500_000_000) // 0.5s
|
try await sleep(0.5)
|
||||||
|
|
||||||
// Should not deliver since one fragment is invalid and reassembly can't complete
|
// Should not deliver since one fragment is invalid and reassembly can't complete
|
||||||
#expect(capture.publicMessages.isEmpty)
|
#expect(capture.publicMessages.isEmpty)
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
import XCTest
|
import Testing
|
||||||
|
import struct Foundation.Data
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
final class GCSFilterTests: XCTestCase {
|
struct GCSFilterTests {
|
||||||
func testBuildFilterWithDuplicateIdsProducesStableEncoding() {
|
@Test func buildFilterWithDuplicateIdsProducesStableEncoding() {
|
||||||
let id = Data(repeating: 0xAB, count: 16)
|
let id = Data(repeating: 0xAB, count: 16)
|
||||||
let ids = Array(repeating: id, count: 64)
|
let ids = Array(repeating: id, count: 64)
|
||||||
|
|
||||||
let params = GCSFilter.buildFilter(ids: ids, maxBytes: 128, targetFpr: 0.01)
|
let params = GCSFilter.buildFilter(ids: ids, maxBytes: 128, targetFpr: 0.01)
|
||||||
XCTAssertGreaterThanOrEqual(params.m, 1)
|
#expect(params.m >= 1)
|
||||||
|
|
||||||
let decoded = GCSFilter.decodeToSortedSet(p: params.p, m: params.m, data: params.data)
|
let decoded = GCSFilter.decodeToSortedSet(p: params.p, m: params.m, data: params.data)
|
||||||
XCTAssertLessThanOrEqual(decoded.count, 1)
|
#expect(decoded.count <= 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBucketAvoidsZeroCandidate() {
|
@Test func bucketAvoidsZeroCandidate() {
|
||||||
let id = Data(repeating: 0x01, count: 16)
|
let id = Data(repeating: 0x01, count: 16)
|
||||||
let bucket = GCSFilter.bucket(for: id, modulus: 2)
|
let bucket = GCSFilter.bucket(for: id, modulus: 2)
|
||||||
XCTAssertNotEqual(bucket, 0)
|
#expect(bucket != 0)
|
||||||
XCTAssertLessThan(bucket, 2)
|
#expect(bucket < 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +1,38 @@
|
|||||||
import XCTest
|
import Testing
|
||||||
|
import Foundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
final class GeohashBookmarksStoreTests: XCTestCase {
|
struct GeohashBookmarksStoreTests {
|
||||||
let storeKey = "locationChannel.bookmarks"
|
private let storeKey = "locationChannel.bookmarks"
|
||||||
var storage: UserDefaults!
|
private let storage = UserDefaults(suiteName: UUID().uuidString)!
|
||||||
var store: GeohashBookmarksStore!
|
private let store: GeohashBookmarksStore
|
||||||
|
|
||||||
override func setUp() {
|
init() {
|
||||||
super.setUp()
|
store = GeohashBookmarksStore(storage: storage)
|
||||||
// Unique instance for each test to avoid race condition
|
|
||||||
storage = UserDefaults(suiteName: UUID().uuidString)
|
|
||||||
store = GeohashBookmarksStore(storage: storage!)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tearDown() {
|
@Test func toggleAndNormalize() {
|
||||||
storage.removeObject(forKey: storeKey)
|
|
||||||
store._resetForTesting()
|
|
||||||
store = nil
|
|
||||||
storage = nil
|
|
||||||
super.tearDown()
|
|
||||||
}
|
|
||||||
|
|
||||||
func testToggleAndNormalize() {
|
|
||||||
// Start clean
|
// Start clean
|
||||||
XCTAssertTrue(store.bookmarks.isEmpty)
|
#expect(store.bookmarks.isEmpty)
|
||||||
|
|
||||||
// Add with mixed case and hash prefix
|
// Add with mixed case and hash prefix
|
||||||
store.toggle("#U4PRUY")
|
store.toggle("#U4PRUY")
|
||||||
XCTAssertTrue(store.isBookmarked("u4pruy"))
|
#expect(store.isBookmarked("u4pruy"))
|
||||||
XCTAssertEqual(store.bookmarks.first, "u4pruy")
|
#expect(store.bookmarks.first == "u4pruy")
|
||||||
|
|
||||||
// Toggling again removes
|
// Toggling again removes
|
||||||
store.toggle("u4pruy")
|
store.toggle("u4pruy")
|
||||||
XCTAssertFalse(store.isBookmarked("u4pruy"))
|
#expect(!store.isBookmarked("u4pruy"))
|
||||||
XCTAssertTrue(store.bookmarks.isEmpty)
|
#expect(store.bookmarks.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPersistenceWritten() throws {
|
@Test func persistenceWritten() throws {
|
||||||
store.toggle("ezs42")
|
store.toggle("ezs42")
|
||||||
store.toggle("u4pruy")
|
store.toggle("u4pruy")
|
||||||
|
|
||||||
// Verify persisted JSON contains both (order not enforced here)
|
// Verify persisted JSON contains both (order not enforced here)
|
||||||
guard let data = storage.data(forKey: storeKey) else {
|
let data = try #require(storage.data(forKey: storeKey), "No persisted data found")
|
||||||
XCTFail("No persisted data found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let arr = try JSONDecoder().decode([String].self, from: data)
|
let arr = try JSONDecoder().decode([String].self, from: data)
|
||||||
XCTAssertTrue(arr.contains("ezs42"))
|
#expect(arr.contains("ezs42"))
|
||||||
XCTAssertTrue(arr.contains("u4pruy"))
|
#expect(arr.contains("u4pruy"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,28 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import XCTest
|
import Testing
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
final class GossipSyncManagerTests: XCTestCase {
|
struct GossipSyncManagerTests {
|
||||||
func testConcurrentPacketIntakeAndSyncRequest() {
|
|
||||||
let manager = GossipSyncManager(myPeerID: "0102030405060708")
|
private let myPeerID = PeerID(str: "0102030405060708")
|
||||||
|
|
||||||
|
@Test func concurrentPacketIntakeAndSyncRequest() async throws {
|
||||||
|
let manager = GossipSyncManager(myPeerID: myPeerID)
|
||||||
let delegate = RecordingDelegate()
|
let delegate = RecordingDelegate()
|
||||||
let sendExpectation = expectation(description: "sync request sent")
|
|
||||||
delegate.onSend = { sendExpectation.fulfill() }
|
|
||||||
manager.delegate = delegate
|
manager.delegate = delegate
|
||||||
|
|
||||||
|
try await confirmation("sync request sent") { sent in
|
||||||
|
delegate.onSend = {
|
||||||
|
sent()
|
||||||
|
}
|
||||||
|
|
||||||
let iterations = 200
|
let iterations = 200
|
||||||
let group = DispatchGroup()
|
let senderID = try #require(Data(hexString: "1122334455667788"))
|
||||||
|
|
||||||
for i in 0..<iterations {
|
for i in 0..<iterations {
|
||||||
group.enter()
|
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.message.rawValue,
|
type: MessageType.message.rawValue,
|
||||||
senderID: Data(hexString: "1122334455667788") ?? Data(),
|
senderID: senderID,
|
||||||
recipientID: nil,
|
recipientID: nil,
|
||||||
timestamp: 1_000_000 + UInt64(i),
|
timestamp: 1_000_000 + UInt64(i),
|
||||||
payload: Data([UInt8(truncatingIfNeeded: i)]),
|
payload: Data([UInt8(truncatingIfNeeded: i)]),
|
||||||
@@ -26,25 +30,100 @@ final class GossipSyncManagerTests: XCTestCase {
|
|||||||
ttl: 1
|
ttl: 1
|
||||||
)
|
)
|
||||||
manager.onPublicPacketSeen(packet)
|
manager.onPublicPacketSeen(packet)
|
||||||
Thread.sleep(forTimeInterval: 0.001)
|
try await sleep(0.001)
|
||||||
group.leave()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.002) {
|
manager.scheduleInitialSyncToPeer(PeerID(str: "FFFFFFFFFFFFFFFF"), delaySeconds: 0.0)
|
||||||
manager.scheduleInitialSyncToPeer("FFFFFFFFFFFFFFFF", delaySeconds: 0.0)
|
try await sleep(0.002)
|
||||||
}
|
}
|
||||||
|
|
||||||
group.wait()
|
let lastPacket = try #require(delegate.lastPacket, "Expected sync packet to be sent")
|
||||||
wait(for: [sendExpectation], timeout: 2.0)
|
#expect(lastPacket.type == MessageType.requestSync.rawValue)
|
||||||
|
#expect(RequestSyncPacket.decode(from: lastPacket.payload) != nil)
|
||||||
guard let lastPacket = delegate.lastPacket else {
|
|
||||||
XCTFail("Expected sync packet to be sent")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
XCTAssertEqual(lastPacket.type, MessageType.requestSync.rawValue)
|
@Test func staleAnnouncementsArePurgedWithMessages() throws {
|
||||||
XCTAssertNotNil(RequestSyncPacket.decode(from: lastPacket.payload))
|
var config = GossipSyncManager.Config()
|
||||||
|
config.stalePeerCleanupIntervalSeconds = 0
|
||||||
|
config.stalePeerTimeoutSeconds = 5
|
||||||
|
|
||||||
|
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
|
||||||
|
let peerHex = "0011223344556677"
|
||||||
|
let senderData = try #require(Data(hexString: peerHex))
|
||||||
|
let initialTimestampMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||||
|
|
||||||
|
let announcePacket = BitchatPacket(
|
||||||
|
type: MessageType.announce.rawValue,
|
||||||
|
senderID: senderData,
|
||||||
|
recipientID: nil,
|
||||||
|
timestamp: initialTimestampMs,
|
||||||
|
payload: Data(),
|
||||||
|
signature: nil,
|
||||||
|
ttl: 1
|
||||||
|
)
|
||||||
|
|
||||||
|
let messagePacket = BitchatPacket(
|
||||||
|
type: MessageType.message.rawValue,
|
||||||
|
senderID: senderData,
|
||||||
|
recipientID: nil,
|
||||||
|
timestamp: initialTimestampMs,
|
||||||
|
payload: Data([0x01]),
|
||||||
|
signature: nil,
|
||||||
|
ttl: 1
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.onPublicPacketSeen(announcePacket)
|
||||||
|
manager.onPublicPacketSeen(messagePacket)
|
||||||
|
|
||||||
|
// Flush queue without triggering stale cleanup yet
|
||||||
|
manager._performMaintenanceSynchronously(now: Date())
|
||||||
|
#expect(manager._hasAnnouncement(for: PeerID(str: peerHex)))
|
||||||
|
#expect(manager._messageCount(for: PeerID(str: peerHex)) == 1)
|
||||||
|
|
||||||
|
// Run cleanup past the timeout
|
||||||
|
let future = Date().addingTimeInterval(config.stalePeerTimeoutSeconds + 1)
|
||||||
|
manager._performMaintenanceSynchronously(now: future)
|
||||||
|
#expect(manager._hasAnnouncement(for: PeerID(str: peerHex)) == false)
|
||||||
|
#expect(manager._messageCount(for: PeerID(str: peerHex)) == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func ignoresAnnounceOlderThanStaleTimeout() throws {
|
||||||
|
var config = GossipSyncManager.Config()
|
||||||
|
config.stalePeerTimeoutSeconds = 5
|
||||||
|
config.maxMessageAgeSeconds = 100
|
||||||
|
|
||||||
|
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
|
||||||
|
let peerHex = "8899aabbccddeeff"
|
||||||
|
let senderData = try #require(Data(hexString: peerHex))
|
||||||
|
let staleTimestampMs = UInt64(Date().addingTimeInterval(-(config.stalePeerTimeoutSeconds + 1)).timeIntervalSince1970 * 1000)
|
||||||
|
|
||||||
|
let freshMessage = BitchatPacket(
|
||||||
|
type: MessageType.message.rawValue,
|
||||||
|
senderID: senderData,
|
||||||
|
recipientID: nil,
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
payload: Data([0xAA]),
|
||||||
|
signature: nil,
|
||||||
|
ttl: 1
|
||||||
|
)
|
||||||
|
manager.onPublicPacketSeen(freshMessage)
|
||||||
|
|
||||||
|
let announcePacket = BitchatPacket(
|
||||||
|
type: MessageType.announce.rawValue,
|
||||||
|
senderID: senderData,
|
||||||
|
recipientID: nil,
|
||||||
|
timestamp: staleTimestampMs,
|
||||||
|
payload: Data(),
|
||||||
|
signature: nil,
|
||||||
|
ttl: 1
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.onPublicPacketSeen(announcePacket)
|
||||||
|
|
||||||
|
manager._performMaintenanceSynchronously()
|
||||||
|
|
||||||
|
#expect(manager._hasAnnouncement(for: PeerID(str: peerHex)) == false)
|
||||||
|
#expect(manager._messageCount(for: PeerID(str: peerHex)) == 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,52 +6,31 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import XCTest
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
import Testing
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
final class IntegrationTests: XCTestCase {
|
struct IntegrationTests {
|
||||||
|
|
||||||
var nodes: [String: MockBluetoothMeshService] = [:]
|
private var helper = TestNetworkHelper()
|
||||||
var noiseManagers: [String: NoiseSessionManager] = [:]
|
|
||||||
private var mockKeychain: MockKeychain!
|
|
||||||
|
|
||||||
override func setUp() {
|
init() {
|
||||||
super.setUp()
|
helper.createNode("Alice", peerID: PeerID(str: UUID().uuidString))
|
||||||
// Use the in-memory test bus with autoFlood enabled to simulate
|
helper.createNode("Bob", peerID: PeerID(str: UUID().uuidString))
|
||||||
// broadcast propagation across a larger mesh. Integration-only.
|
helper.createNode("Charlie", peerID: PeerID(str: UUID().uuidString))
|
||||||
MockBLEService.resetTestBus()
|
helper.createNode("David", peerID: PeerID(str: UUID().uuidString))
|
||||||
MockBLEService.autoFloodEnabled = true
|
|
||||||
mockKeychain = MockKeychain()
|
|
||||||
|
|
||||||
// Create a network of nodes
|
|
||||||
createNode("Alice", peerID: TestConstants.testPeerID1)
|
|
||||||
createNode("Bob", peerID: TestConstants.testPeerID2)
|
|
||||||
createNode("Charlie", peerID: TestConstants.testPeerID3)
|
|
||||||
createNode("David", peerID: TestConstants.testPeerID4)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tearDown() {
|
|
||||||
// Disable flooding to avoid cross-test interference
|
|
||||||
MockBLEService.autoFloodEnabled = false
|
|
||||||
nodes.removeAll()
|
|
||||||
noiseManagers.removeAll()
|
|
||||||
mockKeychain = nil
|
|
||||||
super.tearDown()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Multi-Peer Scenarios
|
// MARK: - Multi-Peer Scenarios
|
||||||
|
|
||||||
func testFullMeshCommunication() {
|
@Test func fullMeshCommunication() async throws {
|
||||||
// Create full mesh - everyone connected to everyone
|
helper.connectFullMesh()
|
||||||
connectFullMesh()
|
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "All nodes communicate")
|
|
||||||
var messageMatrix: [String: Set<String>] = [:]
|
var messageMatrix: [String: Set<String>] = [:]
|
||||||
|
for (senderName, _) in helper.nodes { messageMatrix[senderName] = [] }
|
||||||
|
|
||||||
// Track all receivers; parse sender name from message content "Hello from <Name>"
|
for (receiverName, receiver) in helper.nodes {
|
||||||
for (senderName, _) in nodes { messageMatrix[senderName] = [] }
|
|
||||||
for (receiverName, receiver) in nodes {
|
|
||||||
receiver.messageDeliveryHandler = { message in
|
receiver.messageDeliveryHandler = { message in
|
||||||
let parts = message.content.components(separatedBy: " ")
|
let parts = message.content.components(separatedBy: " ")
|
||||||
if let last = parts.last, message.content.contains("Hello from") {
|
if let last = parts.last, message.content.contains("Hello from") {
|
||||||
@@ -62,108 +41,96 @@ final class IntegrationTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Each node sends a message
|
for (name, node) in helper.nodes {
|
||||||
for (name, node) in nodes {
|
node.sendMessage("Hello from \(name)")
|
||||||
node.sendMessage("Hello from \(name)", mentions: [], to: nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait and verify
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
|
||||||
// Each sender should have reached all other nodes
|
// Each sender should have reached all other nodes
|
||||||
for (sender, receivers) in messageMatrix {
|
for (sender, receivers) in messageMatrix {
|
||||||
let expectedReceivers = Set(self.nodes.keys.filter { $0 != sender })
|
let expectedReceivers = Set(helper.nodes.keys.filter { $0 != sender })
|
||||||
XCTAssertEqual(receivers, expectedReceivers, "\(sender) didn't reach all nodes")
|
#expect(receivers == expectedReceivers, "\(sender) didn't reach all nodes")
|
||||||
}
|
}
|
||||||
expectation.fulfill()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
@Test func dynamicTopologyChanges() async throws {
|
||||||
}
|
|
||||||
|
|
||||||
func testDynamicTopologyChanges() {
|
|
||||||
// Start with Alice -> Bob -> Charlie
|
// Start with Alice -> Bob -> Charlie
|
||||||
connect("Alice", "Bob")
|
helper.connect("Alice", "Bob")
|
||||||
connect("Bob", "Charlie")
|
helper.connect("Bob", "Charlie")
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Topology changes handled")
|
try await confirmation("Topology changes handled") { receiveMessage in
|
||||||
var phase = 1
|
var phase = 1
|
||||||
|
|
||||||
// Phase 1: Test initial topology
|
helper.nodes["Charlie"]!.messageDeliveryHandler = { message in
|
||||||
nodes["Charlie"]!.messageDeliveryHandler = { message in
|
|
||||||
if phase == 1 && message.sender == "Alice" {
|
if phase == 1 && message.sender == "Alice" {
|
||||||
// Now change topology: disconnect Bob, connect Alice-Charlie
|
// Now change topology: disconnect Bob, connect Alice-Charlie
|
||||||
self.disconnect("Alice", "Bob")
|
helper.disconnect("Alice", "Bob")
|
||||||
self.disconnect("Bob", "Charlie")
|
helper.disconnect("Bob", "Charlie")
|
||||||
self.connect("Alice", "Charlie")
|
helper.connect("Alice", "Charlie")
|
||||||
phase = 2
|
phase = 2
|
||||||
|
|
||||||
// Send another message
|
// Send another message
|
||||||
self.nodes["Alice"]!.sendMessage("Direct message", mentions: [], to: nil)
|
helper.nodes["Alice"]!.sendMessage("Direct message")
|
||||||
} else if phase == 2 && message.content == "Direct message" {
|
} else if phase == 2 && message.content == "Direct message" {
|
||||||
expectation.fulfill()
|
receiveMessage()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial message through relay
|
|
||||||
// Allow relay handler to be set before first send
|
// Allow relay handler to be set before first send
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
try await sleep(0.05)
|
||||||
self.nodes["Alice"]!.sendMessage("Relayed message", mentions: [], to: nil)
|
helper.nodes["Alice"]!.sendMessage("Relayed message")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
@Test func networkPartitionRecovery() async throws {
|
||||||
}
|
|
||||||
|
|
||||||
func testNetworkPartitionRecovery() {
|
|
||||||
// Create two partitions
|
// Create two partitions
|
||||||
connect("Alice", "Bob")
|
helper.connect("Alice", "Bob")
|
||||||
connect("Charlie", "David")
|
helper.connect("Charlie", "David")
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Partitions merge and communicate")
|
|
||||||
let messagesBeforeMerge = 0
|
let messagesBeforeMerge = 0
|
||||||
var messagesAfterMerge = 0
|
var messagesAfterMerge = 0
|
||||||
|
|
||||||
|
try await confirmation("Partitions merge and communicate") { receiveMessage in
|
||||||
// Monitor cross-partition messages
|
// Monitor cross-partition messages
|
||||||
nodes["David"]!.messageDeliveryHandler = { message in
|
helper.nodes["David"]!.messageDeliveryHandler = { message in
|
||||||
if message.sender == "Alice" {
|
if message.sender == "Alice" {
|
||||||
messagesAfterMerge += 1
|
messagesAfterMerge += 1
|
||||||
if messagesAfterMerge == 1 {
|
if messagesAfterMerge == 1 {
|
||||||
expectation.fulfill()
|
receiveMessage()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to send across partition (should fail)
|
// Try to send across partition (should fail)
|
||||||
nodes["Alice"]!.sendMessage("Before merge", mentions: [], to: nil)
|
helper.nodes["Alice"]!.sendMessage("Before merge")
|
||||||
|
|
||||||
// Merge partitions after delay
|
// Merge partitions after delay
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
try await sleep(0.05)
|
||||||
// Connect partitions
|
// Connect partitions
|
||||||
self.connect("Bob", "Charlie")
|
helper.connect("Bob", "Charlie")
|
||||||
|
|
||||||
// Enable relay
|
// Enable relay
|
||||||
self.setupRelay("Bob", nextHops: ["Charlie"])
|
helper.setupRelay("Bob", nextHops: ["Charlie"])
|
||||||
self.setupRelay("Charlie", nextHops: ["David"])
|
helper.setupRelay("Charlie", nextHops: ["David"])
|
||||||
|
|
||||||
// Send message across merged network
|
// Send message across merged network
|
||||||
self.nodes["Alice"]!.sendMessage("After merge", mentions: [], to: nil)
|
helper.nodes["Alice"]!.sendMessage("After merge")
|
||||||
}
|
}
|
||||||
|
|
||||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
#expect(messagesBeforeMerge == 0)
|
||||||
XCTAssertEqual(messagesBeforeMerge, 0)
|
#expect(messagesAfterMerge == 1)
|
||||||
XCTAssertEqual(messagesAfterMerge, 1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Mixed Message Type Scenarios
|
// MARK: - Mixed Message Type Scenarios
|
||||||
|
|
||||||
func testMixedPublicPrivateMessages() throws {
|
@Test func mixedPublicPrivateMessages() async throws {
|
||||||
connectFullMesh()
|
helper.connectFullMesh()
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Mixed messages handled correctly")
|
|
||||||
var publicCount = 0
|
var publicCount = 0
|
||||||
var privateCount = 0
|
var privateCount = 0
|
||||||
|
|
||||||
|
await confirmation("Mixed messages handled correctly") { completion in
|
||||||
// Bob monitors messages
|
// Bob monitors messages
|
||||||
nodes["Bob"]!.messageDeliveryHandler = { message in
|
helper.nodes["Bob"]!.messageDeliveryHandler = { message in
|
||||||
if message.isPrivate && message.recipientNickname == "Bob" {
|
if message.isPrivate && message.recipientNickname == "Bob" {
|
||||||
privateCount += 1
|
privateCount += 1
|
||||||
} else if !message.isPrivate {
|
} else if !message.isPrivate {
|
||||||
@@ -171,261 +138,239 @@ final class IntegrationTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if publicCount == 2 && privateCount == 1 {
|
if publicCount == 2 && privateCount == 1 {
|
||||||
expectation.fulfill()
|
completion()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alice sends mixed messages
|
// Alice sends mixed messages
|
||||||
nodes["Alice"]!.sendMessage("Public 1", mentions: [], to: nil)
|
helper.nodes["Alice"]!.sendMessage("Public 1")
|
||||||
nodes["Alice"]!.sendPrivateMessage("Private to Bob", to: TestConstants.testPeerID2, recipientNickname: "Bob")
|
helper.nodes["Alice"]!.sendPrivateMessage("Private to Bob", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob")
|
||||||
nodes["Alice"]!.sendMessage("Public 2", mentions: [], to: nil)
|
helper.nodes["Alice"]!.sendMessage("Public 2")
|
||||||
|
|
||||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
|
||||||
XCTAssertEqual(publicCount, 2)
|
|
||||||
XCTAssertEqual(privateCount, 1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testEncryptedAndUnencryptedMix() throws {
|
#expect(publicCount == 2)
|
||||||
connect("Alice", "Bob")
|
#expect(privateCount == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func encryptedAndUnencryptedMix() async throws {
|
||||||
|
helper.connect("Alice", "Bob")
|
||||||
|
|
||||||
// Setup Noise session
|
// Setup Noise session
|
||||||
try establishNoiseSession("Alice", "Bob")
|
try helper.establishNoiseSession("Alice", "Bob")
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Both encrypted and plain messages work")
|
|
||||||
var plainCount = 0
|
var plainCount = 0
|
||||||
var encryptedCount = 0
|
var encryptedCount = 0
|
||||||
|
|
||||||
// Setup handlers
|
try await confirmation("Both encrypted and plain messages work") { completion in
|
||||||
// Plain path: send public message and count at Bob
|
// Plain path: send public message and count at Bob
|
||||||
nodes["Bob"]!.messageDeliveryHandler = { message in
|
helper.nodes["Bob"]!.messageDeliveryHandler = { message in
|
||||||
if message.content == "Plain message" { plainCount += 1 }
|
if message.content == "Plain message" {
|
||||||
if plainCount == 1 && encryptedCount == 1 { expectation.fulfill() }
|
plainCount += 1
|
||||||
|
}
|
||||||
|
if plainCount == 1 && encryptedCount == 1 {
|
||||||
|
completion()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encrypted path: use NoiseSessionManager explicitly
|
// Encrypted path: use NoiseSessionManager explicitly
|
||||||
let plaintext = "Encrypted message".data(using: .utf8)!
|
let plaintext = "Encrypted message".data(using: .utf8)!
|
||||||
let ciphertext = try noiseManagers["Alice"]!.encrypt(plaintext, for: TestConstants.testPeerID2)
|
let ciphertext = try helper.noiseManagers["Alice"]!.encrypt(plaintext, for: helper.nodes["Bob"]!.peerID)
|
||||||
nodes["Bob"]!.packetDeliveryHandler = { packet in
|
|
||||||
|
helper.nodes["Bob"]!.packetDeliveryHandler = { packet in
|
||||||
if packet.type == MessageType.noiseEncrypted.rawValue {
|
if packet.type == MessageType.noiseEncrypted.rawValue {
|
||||||
if let data = try? self.noiseManagers["Bob"]!.decrypt(ciphertext, from: TestConstants.testPeerID1),
|
if let data = try? helper.noiseManagers["Bob"]!.decrypt(ciphertext, from: helper.nodes["Alice"]!.peerID),
|
||||||
data == plaintext {
|
data == plaintext {
|
||||||
encryptedCount = 1
|
encryptedCount = 1
|
||||||
if plainCount == 1 { expectation.fulfill() }
|
if plainCount == 1 {
|
||||||
|
completion()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nodes["Alice"]!.sendMessage("Plain message", mentions: [], to: nil)
|
helper.nodes["Alice"]!.sendMessage("Plain message")
|
||||||
// Deliver encrypted packet directly
|
// Deliver encrypted packet directly
|
||||||
let encPacket = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext)
|
let encPacket = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext)
|
||||||
nodes["Bob"]!.simulateIncomingPacket(encPacket)
|
helper.nodes["Bob"]!.simulateIncomingPacket(encPacket)
|
||||||
|
}
|
||||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Network Resilience Tests
|
// MARK: - Network Resilience Tests
|
||||||
|
|
||||||
func testMessageDeliveryUnderChurn() {
|
@Test func messageDeliveryUnderChurn() async throws {
|
||||||
// Start with stable network
|
// Start with stable network
|
||||||
connectFullMesh()
|
helper.connectFullMesh()
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Messages delivered despite churn")
|
|
||||||
var receivedMessages = Set<String>()
|
|
||||||
let totalMessages = 10
|
let totalMessages = 10
|
||||||
|
|
||||||
|
try await confirmation("Messages delivered despite churn", expectedCount: totalMessages) { completion in
|
||||||
// David tracks received messages
|
// David tracks received messages
|
||||||
nodes["David"]!.messageDeliveryHandler = { message in
|
helper.nodes["David"]!.messageDeliveryHandler = { message in
|
||||||
receivedMessages.insert(message.content)
|
completion()
|
||||||
if receivedMessages.count == totalMessages {
|
|
||||||
expectation.fulfill()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send messages while churning network
|
// Send messages while churning network
|
||||||
for i in 0..<totalMessages {
|
for i in 0..<totalMessages {
|
||||||
nodes["Alice"]!.sendMessage("Message \(i)", mentions: [], to: nil)
|
helper.nodes["Alice"]!.sendMessage("Message \(i)")
|
||||||
|
|
||||||
// Simulate churn
|
// Simulate churn
|
||||||
if i % 3 == 0 {
|
if i % 3 == 0 {
|
||||||
// Disconnect and reconnect random connection
|
// Disconnect and reconnect random connection
|
||||||
let pairs = [("Alice", "Bob"), ("Bob", "Charlie"), ("Charlie", "David")]
|
let pairs = [("Alice", "Bob"), ("Bob", "Charlie"), ("Charlie", "David")]
|
||||||
let randomPair = pairs.randomElement()!
|
let randomPair = pairs.randomElement()!
|
||||||
disconnect(randomPair.0, randomPair.1)
|
helper.disconnect(randomPair.0, randomPair.1)
|
||||||
|
try await sleep(0.01)
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
helper.connect(randomPair.0, randomPair.1)
|
||||||
self.connect(randomPair.0, randomPair.1)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
wait(for: [expectation], timeout: TestConstants.longTimeout)
|
@Test func peerPresenceTrackingAndReconnection() async throws {
|
||||||
XCTAssertEqual(receivedMessages.count, totalMessages)
|
helper.connect("Alice", "Bob")
|
||||||
}
|
|
||||||
|
|
||||||
func testPeerPresenceTrackingAndReconnection() {
|
await confirmation("Delivery after reconnection") { delivered in
|
||||||
// Test that after disconnect/reconnect, message delivery resumes
|
helper.nodes["Bob"]!.messageDeliveryHandler = { message in
|
||||||
connect("Alice", "Bob")
|
if message.content == "After reconnect" {
|
||||||
|
delivered()
|
||||||
let expectation = XCTestExpectation(description: "Delivery after reconnection")
|
|
||||||
var delivered = false
|
|
||||||
|
|
||||||
nodes["Bob"]!.messageDeliveryHandler = { message in
|
|
||||||
if message.content == "After reconnect" && !delivered {
|
|
||||||
delivered = true
|
|
||||||
expectation.fulfill()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simulate disconnect (out of range)
|
// Simulate disconnect (out of range)
|
||||||
disconnect("Alice", "Bob")
|
helper.disconnect("Alice", "Bob")
|
||||||
// Reconnect
|
// Reconnect
|
||||||
connect("Alice", "Bob")
|
helper.connect("Alice", "Bob")
|
||||||
|
|
||||||
// Send after reconnection
|
// Send after reconnection
|
||||||
nodes["Alice"]!.sendMessage("After reconnect", mentions: [], to: nil)
|
helper.nodes["Alice"]!.sendMessage("After reconnect")
|
||||||
|
}
|
||||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
|
||||||
XCTAssertTrue(delivered)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testEncryptedMessageAfterPeerRestart() {
|
@Test func encryptedMessageAfterPeerRestart() async throws {
|
||||||
// Test that encrypted messages work after one peer restarts
|
helper.connect("Alice", "Bob")
|
||||||
connect("Alice", "Bob")
|
|
||||||
do {
|
do {
|
||||||
try establishNoiseSession("Alice", "Bob")
|
try helper.establishNoiseSession("Alice", "Bob")
|
||||||
} catch {
|
} catch {
|
||||||
XCTFail("Failed to establish Noise session: \(error)")
|
Issue.record("Failed to establish Noise session: \(error)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exchange an encrypted message
|
// Exchange an encrypted message
|
||||||
let firstExpectation = XCTestExpectation(description: "First message received")
|
await confirmation("First message received") { received in
|
||||||
nodes["Bob"]!.messageDeliveryHandler = { message in
|
helper.nodes["Bob"]!.messageDeliveryHandler = { message in
|
||||||
if message.content == "Before restart" && message.isPrivate {
|
if message.content == "Before restart" && message.isPrivate {
|
||||||
firstExpectation.fulfill()
|
received()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
helper.nodes["Alice"]!.sendPrivateMessage("Before restart", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob")
|
||||||
nodes["Alice"]!.sendPrivateMessage("Before restart", to: TestConstants.testPeerID2, recipientNickname: "Bob")
|
}
|
||||||
wait(for: [firstExpectation], timeout: TestConstants.defaultTimeout)
|
|
||||||
|
|
||||||
// Simulate Bob restart by recreating his Noise manager
|
// Simulate Bob restart by recreating his Noise manager
|
||||||
let bobKey = Curve25519.KeyAgreement.PrivateKey()
|
let bobKey = Curve25519.KeyAgreement.PrivateKey()
|
||||||
noiseManagers["Bob"] = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
helper.noiseManagers["Bob"] = NoiseSessionManager(localStaticKey: bobKey, keychain: helper.mockKeychain)
|
||||||
|
|
||||||
// Re-establish Noise handshake explicitly via managers
|
// Re-establish Noise handshake explicitly via managers
|
||||||
do {
|
do {
|
||||||
let m1 = try noiseManagers["Bob"]!.initiateHandshake(with: TestConstants.testPeerID1)
|
let m1 = try helper.noiseManagers["Bob"]!.initiateHandshake(with: helper.nodes["Alice"]!.peerID)
|
||||||
let m2 = try noiseManagers["Alice"]!.handleIncomingHandshake(from: TestConstants.testPeerID2, message: m1)!
|
let m2 = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m1)!
|
||||||
let m3 = try noiseManagers["Bob"]!.handleIncomingHandshake(from: TestConstants.testPeerID1, message: m2)!
|
let m3 = try helper.noiseManagers["Bob"]!.handleIncomingHandshake(from: helper.nodes["Alice"]!.peerID, message: m2)!
|
||||||
_ = try noiseManagers["Alice"]!.handleIncomingHandshake(from: TestConstants.testPeerID2, message: m3)
|
_ = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m3)
|
||||||
} catch {
|
} catch {
|
||||||
XCTFail("Failed to re-establish Noise session after restart: \(error)")
|
Issue.record("Failed to re-establish Noise session after restart: \(error)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now messages should work again
|
// Now messages should work again - simulate encrypted packet
|
||||||
let secondExpectation = XCTestExpectation(description: "Message after restart received")
|
await confirmation("Message after restart received") { received in
|
||||||
nodes["Alice"]!.messageDeliveryHandler = { message in
|
helper.nodes["Alice"]!.messageDeliveryHandler = { message in
|
||||||
if message.content == "After restart success" && message.isPrivate {
|
if message.content == "After restart success" && message.isPrivate {
|
||||||
secondExpectation.fulfill()
|
received()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simulate encrypted message using managers
|
|
||||||
do {
|
do {
|
||||||
let plaintext = "After restart success".data(using: .utf8)!
|
let plaintext = "After restart success".data(using: .utf8)!
|
||||||
let ciphertext = try noiseManagers["Bob"]!.encrypt(plaintext, for: TestConstants.testPeerID1)
|
let ciphertext = try helper.noiseManagers["Bob"]!.encrypt(plaintext, for: helper.nodes["Alice"]!.peerID)
|
||||||
let packet = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext)
|
let packet = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext)
|
||||||
nodes["Alice"]!.packetDeliveryHandler = { pkt in
|
helper.nodes["Alice"]!.packetDeliveryHandler = { pkt in
|
||||||
if pkt.type == MessageType.noiseEncrypted.rawValue {
|
if pkt.type == MessageType.noiseEncrypted.rawValue {
|
||||||
if let data = try? self.noiseManagers["Alice"]!.decrypt(pkt.payload, from: TestConstants.testPeerID2),
|
if let data = try? helper.noiseManagers["Alice"]!.decrypt(pkt.payload, from: helper.nodes["Bob"]!.peerID),
|
||||||
String(data: data, encoding: .utf8) == "After restart success" {
|
String(data: data, encoding: .utf8) == "After restart success" {
|
||||||
secondExpectation.fulfill()
|
received()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
nodes["Alice"]!.simulateIncomingPacket(packet)
|
helper.nodes["Alice"]!.simulateIncomingPacket(packet)
|
||||||
} catch {
|
} catch {
|
||||||
XCTFail("Encryption after restart failed: \(error)")
|
Issue.record("Encryption after restart failed: \(error)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
wait(for: [secondExpectation], timeout: TestConstants.defaultTimeout)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testLargeScaleNetwork() {
|
@Test func largeScaleNetwork() async throws {
|
||||||
// Create larger network
|
// Create larger network
|
||||||
for i in 5...10 {
|
for i in 5...10 {
|
||||||
createNode("Node\(i)", peerID: "PEER\(i)")
|
helper.createNode("Node\(i)", peerID: PeerID(str: "PEER\(i)"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect in ring topology with cross-connections
|
// Connect in ring topology with cross-connections
|
||||||
let allNodes = Array(nodes.keys).sorted()
|
let allNodes = Array(helper.nodes.keys).sorted()
|
||||||
for i in 0..<allNodes.count {
|
for i in 0..<allNodes.count {
|
||||||
// Ring connection
|
// Ring connection
|
||||||
connect(allNodes[i], allNodes[(i + 1) % allNodes.count])
|
helper.connect(allNodes[i], allNodes[(i + 1) % allNodes.count])
|
||||||
|
|
||||||
// Cross connection
|
// Cross connection
|
||||||
if i + 3 < allNodes.count {
|
if i + 3 < allNodes.count {
|
||||||
connect(allNodes[i], allNodes[i + 3])
|
helper.connect(allNodes[i], allNodes[i + 3])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Large network handles broadcast")
|
await confirmation("Large network handles broadcast", expectedCount: helper.nodes.count - 1) { nodeReaced in
|
||||||
var nodesReached = Set<String>()
|
|
||||||
|
|
||||||
// All nodes except Alice listen
|
// All nodes except Alice listen
|
||||||
for (name, node) in nodes where name != "Alice" {
|
for (name, node) in helper.nodes where name != "Alice" {
|
||||||
node.messageDeliveryHandler = { message in
|
node.messageDeliveryHandler = { message in
|
||||||
if message.content == "Broadcast test" {
|
if message.content == "Broadcast test" {
|
||||||
nodesReached.insert(name)
|
nodeReaced()
|
||||||
if nodesReached.count == self.nodes.count - 1 {
|
|
||||||
expectation.fulfill()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alice broadcasts
|
// Alice broadcasts
|
||||||
nodes["Alice"]!.sendMessage("Broadcast test", mentions: [], to: nil)
|
helper.nodes["Alice"]!.sendMessage("Broadcast test")
|
||||||
|
}
|
||||||
wait(for: [expectation], timeout: TestConstants.longTimeout)
|
|
||||||
XCTAssertEqual(nodesReached.count, nodes.count - 1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Stress Tests
|
// MARK: - Stress Tests
|
||||||
|
|
||||||
func testHighLoadScenario() {
|
@Test func highLoadScenario() async throws {
|
||||||
connectFullMesh()
|
helper.connectFullMesh()
|
||||||
|
|
||||||
let messagesPerNode = 25
|
let messagesPerNode = 25
|
||||||
let expectedTotal = messagesPerNode * nodes.count * (nodes.count - 1)
|
let expectedTotal = messagesPerNode * helper.nodes.count * (helper.nodes.count - 1)
|
||||||
var receivedTotal = 0
|
|
||||||
let expectation = XCTestExpectation(description: "High load handled")
|
|
||||||
|
|
||||||
|
await confirmation("High load handled", expectedCount: expectedTotal) { received in
|
||||||
// Each node tracks messages
|
// Each node tracks messages
|
||||||
for (_, node) in nodes {
|
for (_, node) in helper.nodes {
|
||||||
node.messageDeliveryHandler = { _ in
|
node.messageDeliveryHandler = { _ in
|
||||||
receivedTotal += 1
|
received()
|
||||||
if receivedTotal >= (expectedTotal - 2) {
|
|
||||||
expectation.fulfill()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// All nodes send many messages simultaneously
|
// All nodes send many messages simultaneously
|
||||||
DispatchQueue.concurrentPerform(iterations: nodes.count) { index in
|
await withTaskGroup(of: Void.self) { group in
|
||||||
let nodeName = Array(nodes.keys).sorted()[index]
|
for (name, node) in helper.nodes {
|
||||||
|
group.addTask {
|
||||||
for i in 0..<messagesPerNode {
|
for i in 0..<messagesPerNode {
|
||||||
nodes[nodeName]!.sendMessage("\(nodeName) message \(i)", mentions: [], to: nil)
|
node.sendMessage("\(name) message \(i)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await group.waitForAll()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
wait(for: [expectation], timeout: TestConstants.longTimeout)
|
@Test func mixedTrafficPatterns() async throws {
|
||||||
XCTAssertGreaterThanOrEqual(receivedTotal, expectedTotal - 2)
|
helper.connectFullMesh()
|
||||||
}
|
|
||||||
|
|
||||||
func testMixedTrafficPatterns() {
|
|
||||||
connectFullMesh()
|
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Mixed traffic handled")
|
|
||||||
var metrics = [
|
var metrics = [
|
||||||
"public": 0,
|
"public": 0,
|
||||||
"private": 0,
|
"private": 0,
|
||||||
@@ -434,7 +379,7 @@ final class IntegrationTests: XCTestCase {
|
|||||||
]
|
]
|
||||||
|
|
||||||
// Setup complex handlers
|
// Setup complex handlers
|
||||||
for (name, node) in nodes {
|
for (name, node) in helper.nodes {
|
||||||
node.messageDeliveryHandler = { message in
|
node.messageDeliveryHandler = { message in
|
||||||
if message.isPrivate {
|
if message.isPrivate {
|
||||||
metrics["private"]! += 1
|
metrics["private"]! += 1
|
||||||
@@ -453,88 +398,78 @@ final class IntegrationTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate mixed traffic
|
// Generate mixed traffic
|
||||||
nodes["Alice"]!.sendMessage("Public broadcast", mentions: [], to: nil)
|
helper.nodes["Alice"]!.sendMessage("Public broadcast")
|
||||||
nodes["Alice"]!.sendPrivateMessage("Private to Bob", to: TestConstants.testPeerID2, recipientNickname: "Bob")
|
helper.nodes["Alice"]!.sendPrivateMessage("Private to Bob", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob")
|
||||||
nodes["Bob"]!.sendMessage("Mentioning @Charlie", mentions: ["Charlie"], to: nil)
|
helper.nodes["Bob"]!.sendMessage("Mentioning @Charlie", mentions: ["Charlie"])
|
||||||
|
|
||||||
// Disconnect to force relay
|
// Disconnect to force relay
|
||||||
disconnect("Alice", "David")
|
helper.disconnect("Alice", "David")
|
||||||
nodes["Alice"]!.sendMessage("Needs relay to David", mentions: [], to: nil)
|
helper.nodes["Alice"]!.sendMessage("Needs relay to David")
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
#expect(metrics["public", default: 0] > 0)
|
||||||
XCTAssertGreaterThan(metrics["public"]!, 0)
|
#expect(metrics["private", default: 0] > 0)
|
||||||
XCTAssertGreaterThan(metrics["private"]!, 0)
|
#expect(metrics["mentions", default: 0] > 0)
|
||||||
XCTAssertGreaterThan(metrics["mentions"]!, 0)
|
|
||||||
expectation.fulfill()
|
|
||||||
}
|
|
||||||
|
|
||||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Security Integration Tests
|
// MARK: - Security Integration Tests
|
||||||
// Replacement for the legacy NACK test: verifies that after a
|
// Replacement for the legacy NACK test: verifies that after a
|
||||||
// decryption failure, peers can rehandshake via NoiseSessionManager
|
// decryption failure, peers can rehandshake via NoiseSessionManager
|
||||||
// and resume secure communication.
|
// and resume secure communication.
|
||||||
func testRehandshakeAfterDecryptionFailure() throws {
|
@Test func rehandshakeAfterDecryptionFailure() throws {
|
||||||
// Alice <-> Bob connected
|
// Alice <-> Bob connected
|
||||||
connect("Alice", "Bob")
|
helper.connect("Alice", "Bob")
|
||||||
|
|
||||||
// Establish initial Noise session
|
// Establish initial Noise session
|
||||||
try establishNoiseSession("Alice", "Bob")
|
try helper.establishNoiseSession("Alice", "Bob")
|
||||||
|
|
||||||
guard let aliceManager = noiseManagers["Alice"],
|
guard let aliceManager = helper.noiseManagers["Alice"],
|
||||||
let bobManager = noiseManagers["Bob"],
|
let bobManager = helper.noiseManagers["Bob"],
|
||||||
let alicePeerID = nodes["Alice"]?.peerID,
|
let alicePeerID = helper.nodes["Alice"]?.peerID,
|
||||||
let bobPeerID = nodes["Bob"]?.peerID else {
|
let bobPeerID = helper.nodes["Bob"]?.peerID
|
||||||
return XCTFail("Missing managers or peer IDs")
|
else {
|
||||||
|
Issue.record("Missing managers or peer IDs")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Baseline: encrypt from Alice, decrypt at Bob
|
// Baseline: encrypt from Alice, decrypt at Bob
|
||||||
let plaintext1 = Data("hello-secure".utf8)
|
let plaintext1 = Data("hello-secure".utf8)
|
||||||
let encrypted1 = try aliceManager.encrypt(plaintext1, for: bobPeerID)
|
let encrypted1 = try aliceManager.encrypt(plaintext1, for: bobPeerID)
|
||||||
let decrypted1 = try bobManager.decrypt(encrypted1, from: alicePeerID)
|
let decrypted1 = try bobManager.decrypt(encrypted1, from: alicePeerID)
|
||||||
XCTAssertEqual(decrypted1, plaintext1)
|
#expect(decrypted1 == plaintext1)
|
||||||
|
|
||||||
// Simulate decryption failure by corrupting ciphertext
|
// Simulate decryption failure by corrupting ciphertext
|
||||||
var corrupted = encrypted1
|
let corrupted = encrypted1.prefix(15)
|
||||||
if !corrupted.isEmpty { corrupted[corrupted.count - 1] ^= 0xFF }
|
#expect(throws: NoiseError.invalidCiphertext) {
|
||||||
do {
|
|
||||||
_ = try bobManager.decrypt(corrupted, from: alicePeerID)
|
_ = try bobManager.decrypt(corrupted, from: alicePeerID)
|
||||||
XCTFail("Corrupted ciphertext should not decrypt")
|
|
||||||
} catch {
|
|
||||||
// Expected: treat as session desync and rehandshake
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bob initiates a new handshake; clear Bob's session first so initiateHandshake won't throw
|
// Bob initiates a new handshake; clear Bob's session first so initiateHandshake won't throw
|
||||||
bobManager.removeSession(for: alicePeerID)
|
bobManager.removeSession(for: alicePeerID)
|
||||||
try establishNoiseSession("Bob", "Alice")
|
try helper.establishNoiseSession("Bob", "Alice")
|
||||||
|
|
||||||
// After rehandshake, encryption/decryption works again
|
// After rehandshake, encryption/decryption works again
|
||||||
let plaintext2 = Data("hello-again".utf8)
|
let plaintext2 = Data("hello-again".utf8)
|
||||||
let encrypted2 = try aliceManager.encrypt(plaintext2, for: bobPeerID)
|
let encrypted2 = try aliceManager.encrypt(plaintext2, for: bobPeerID)
|
||||||
let decrypted2 = try bobManager.decrypt(encrypted2, from: alicePeerID)
|
let decrypted2 = try bobManager.decrypt(encrypted2, from: alicePeerID)
|
||||||
XCTAssertEqual(decrypted2, plaintext2)
|
#expect(decrypted2 == plaintext2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func endToEndSecurityScenario() async throws {
|
||||||
func testEndToEndSecurityScenario() throws {
|
helper.connect("Alice", "Bob")
|
||||||
connect("Alice", "Bob")
|
helper.connect("Bob", "Charlie") // Charlie will try to eavesdrop
|
||||||
connect("Bob", "Charlie") // Charlie will try to eavesdrop
|
|
||||||
|
|
||||||
// Establish secure session between Alice and Bob only
|
// Establish secure session between Alice and Bob only
|
||||||
try establishNoiseSession("Alice", "Bob")
|
try helper.establishNoiseSession("Alice", "Bob")
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Secure communication maintained")
|
await confirmation("Secure communication maintained", expectedCount: 2) { receivedPacket in
|
||||||
var bobDecrypted = false
|
|
||||||
var charlieIntercepted = false
|
|
||||||
|
|
||||||
// Setup encryption at Alice
|
// Setup encryption at Alice
|
||||||
nodes["Alice"]!.packetDeliveryHandler = { packet in
|
helper.nodes["Alice"]!.packetDeliveryHandler = { packet in
|
||||||
if packet.type == 0x01,
|
if packet.type == 0x01,
|
||||||
let message = BitchatMessage(packet.payload),
|
let message = BitchatMessage(packet.payload),
|
||||||
message.isPrivate && packet.recipientID != nil {
|
message.isPrivate && packet.recipientID != nil {
|
||||||
// Encrypt private messages
|
// Encrypt private messages
|
||||||
if let encrypted = try? self.noiseManagers["Alice"]!.encrypt(packet.payload, for: TestConstants.testPeerID2) {
|
if let encrypted = try? helper.noiseManagers["Alice"]!.encrypt(packet.payload, for: helper.nodes["Bob"]!.peerID) {
|
||||||
let encPacket = BitchatPacket(
|
let encPacket = BitchatPacket(
|
||||||
type: 0x02,
|
type: 0x02,
|
||||||
senderID: packet.senderID,
|
senderID: packet.senderID,
|
||||||
@@ -544,131 +479,38 @@ final class IntegrationTests: XCTestCase {
|
|||||||
signature: packet.signature,
|
signature: packet.signature,
|
||||||
ttl: packet.ttl
|
ttl: packet.ttl
|
||||||
)
|
)
|
||||||
self.nodes["Bob"]!.simulateIncomingPacket(encPacket)
|
helper.nodes["Bob"]!.simulateIncomingPacket(encPacket)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bob can decrypt
|
// Bob can decrypt
|
||||||
nodes["Bob"]!.packetDeliveryHandler = { packet in
|
helper.nodes["Bob"]!.packetDeliveryHandler = { packet in
|
||||||
if packet.type == 0x02 {
|
if packet.type == 0x02 {
|
||||||
if let decrypted = try? self.noiseManagers["Bob"]!.decrypt(packet.payload, from: TestConstants.testPeerID1),
|
receivedPacket()
|
||||||
let message = BitchatMessage(decrypted) {
|
if let decrypted = try? helper.noiseManagers["Bob"]!.decrypt(packet.payload, from: helper.nodes["Alice"]!.peerID) {
|
||||||
bobDecrypted = message.content == "Secret message"
|
#expect(BitchatMessage(decrypted)?.content == "Secret message")
|
||||||
expectation.fulfill()
|
} else {
|
||||||
|
Issue.record("Bob was unable to decrypt the message")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Relay encrypted packet to Charlie
|
// Relay encrypted packet to Charlie
|
||||||
self.nodes["Charlie"]!.simulateIncomingPacket(packet)
|
helper.nodes["Charlie"]!.simulateIncomingPacket(packet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Charlie cannot decrypt
|
// Charlie cannot decrypt
|
||||||
nodes["Charlie"]!.packetDeliveryHandler = { packet in
|
helper.nodes["Charlie"]!.packetDeliveryHandler = { packet in
|
||||||
if packet.type == 0x02 {
|
if packet.type == 0x02 {
|
||||||
charlieIntercepted = true
|
receivedPacket()
|
||||||
// Try to decrypt (should fail)
|
#expect(throws: NoiseSessionError.sessionNotFound, "Charlie should not be able to decrypt") {
|
||||||
do {
|
_ = try helper.noiseManagers["Charlie"]?.decrypt(packet.payload, from: helper.nodes["Alice"]!.peerID)
|
||||||
_ = try self.noiseManagers["Charlie"]?.decrypt(packet.payload, from: TestConstants.testPeerID1)
|
|
||||||
XCTFail("Charlie should not be able to decrypt")
|
|
||||||
} catch {
|
|
||||||
// Expected
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send encrypted private message
|
// Send encrypted private message
|
||||||
nodes["Alice"]!.sendPrivateMessage("Secret message", to: TestConstants.testPeerID2, recipientNickname: "Bob")
|
helper.nodes["Alice"]!.sendPrivateMessage("Secret message", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob")
|
||||||
|
|
||||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
|
||||||
XCTAssertTrue(bobDecrypted)
|
|
||||||
XCTAssertTrue(charlieIntercepted)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helper Methods
|
|
||||||
|
|
||||||
private func createNode(_ name: String, peerID: PeerID) {
|
|
||||||
let node = MockBluetoothMeshService()
|
|
||||||
node.myPeerID = peerID
|
|
||||||
node.mockNickname = name
|
|
||||||
nodes[name] = node
|
|
||||||
|
|
||||||
// Create Noise manager
|
|
||||||
let key = Curve25519.KeyAgreement.PrivateKey()
|
|
||||||
noiseManagers[name] = NoiseSessionManager(localStaticKey: key, keychain: mockKeychain)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func connect(_ node1: String, _ node2: String) {
|
|
||||||
guard let n1 = nodes[node1], let n2 = nodes[node2] else { return }
|
|
||||||
n1.simulateConnectedPeer(n2.peerID)
|
|
||||||
n2.simulateConnectedPeer(n1.peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func disconnect(_ node1: String, _ node2: String) {
|
|
||||||
guard let n1 = nodes[node1], let n2 = nodes[node2] else { return }
|
|
||||||
n1.simulateDisconnectedPeer(n2.peerID)
|
|
||||||
n2.simulateDisconnectedPeer(n1.peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func connectFullMesh() {
|
|
||||||
let nodeNames = Array(nodes.keys)
|
|
||||||
for i in 0..<nodeNames.count {
|
|
||||||
for j in i+1..<nodeNames.count {
|
|
||||||
connect(nodeNames[i], nodeNames[j])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupRelay(_ nodeName: String, nextHops: [String]) {
|
|
||||||
guard let node = nodes[nodeName] else { return }
|
|
||||||
|
|
||||||
node.packetDeliveryHandler = { packet in
|
|
||||||
guard packet.ttl > 1 else { return }
|
|
||||||
|
|
||||||
if let message = BitchatMessage(packet.payload) {
|
|
||||||
guard message.senderPeerID != node.peerID else { return }
|
|
||||||
|
|
||||||
let relayMessage = BitchatMessage(
|
|
||||||
id: message.id,
|
|
||||||
sender: message.sender,
|
|
||||||
content: message.content,
|
|
||||||
timestamp: message.timestamp,
|
|
||||||
isRelay: true,
|
|
||||||
originalSender: message.isRelay ? message.originalSender : message.sender,
|
|
||||||
isPrivate: message.isPrivate,
|
|
||||||
recipientNickname: message.recipientNickname,
|
|
||||||
senderPeerID: message.senderPeerID,
|
|
||||||
mentions: message.mentions
|
|
||||||
)
|
|
||||||
|
|
||||||
if let relayPayload = relayMessage.toBinaryPayload() {
|
|
||||||
let relayPacket = BitchatPacket(
|
|
||||||
type: packet.type,
|
|
||||||
senderID: packet.senderID,
|
|
||||||
recipientID: packet.recipientID,
|
|
||||||
timestamp: packet.timestamp,
|
|
||||||
payload: relayPayload,
|
|
||||||
signature: packet.signature,
|
|
||||||
ttl: packet.ttl - 1
|
|
||||||
)
|
|
||||||
|
|
||||||
for hop in nextHops {
|
|
||||||
self.nodes[hop]?.simulateIncomingPacket(relayPacket)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func establishNoiseSession(_ node1: String, _ node2: String) throws {
|
|
||||||
guard let manager1 = noiseManagers[node1],
|
|
||||||
let manager2 = noiseManagers[node2],
|
|
||||||
let peer1ID = nodes[node1]?.peerID,
|
|
||||||
let peer2ID = nodes[node2]?.peerID else { return }
|
|
||||||
|
|
||||||
let msg1 = try manager1.initiateHandshake(with: peer2ID)
|
|
||||||
let msg2 = try manager2.handleIncomingHandshake(from: peer1ID, message: msg1)!
|
|
||||||
let msg3 = try manager1.handleIncomingHandshake(from: peer2ID, message: msg2)!
|
|
||||||
_ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
//
|
||||||
|
// TestNetworkHelper.swift
|
||||||
|
// bitchatTests
|
||||||
|
//
|
||||||
|
// Extracted shared, mutable integration state for nodes and noise sessions.
|
||||||
|
// Keeps test containers nonmutating (Swift Testing-friendly).
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
final class TestNetworkHelper {
|
||||||
|
// Public, read-only views for tests; mutation only through methods
|
||||||
|
var nodes: [String: MockBLEService] = [:]
|
||||||
|
var noiseManagers: [String: NoiseSessionManager] = [:]
|
||||||
|
let mockKeychain = MockKeychain()
|
||||||
|
private let bus = MockBLEBus(autoFloodEnabled: true)
|
||||||
|
|
||||||
|
// MARK: - Node/Manager management
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func createNode(_ name: String, peerID: PeerID) -> MockBLEService {
|
||||||
|
let node = MockBLEService(bus: bus)
|
||||||
|
node.myPeerID = peerID
|
||||||
|
node.mockNickname = name
|
||||||
|
nodes[name] = node
|
||||||
|
|
||||||
|
// Create/replace Noise manager for this node
|
||||||
|
let key = Curve25519.KeyAgreement.PrivateKey()
|
||||||
|
noiseManagers[name] = NoiseSessionManager(localStaticKey: key, keychain: mockKeychain)
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
func getNode(_ name: String) -> MockBLEService? {
|
||||||
|
nodes[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
func getManager(_ name: String) -> NoiseSessionManager? {
|
||||||
|
noiseManagers[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Topology
|
||||||
|
|
||||||
|
func connect(_ a: String, _ b: String) {
|
||||||
|
guard let n1 = nodes[a], let n2 = nodes[b] else { return }
|
||||||
|
n1.simulateConnectedPeer(n2.peerID)
|
||||||
|
n2.simulateConnectedPeer(n1.peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func disconnect(_ a: String, _ b: String) {
|
||||||
|
guard let n1 = nodes[a], let n2 = nodes[b] else { return }
|
||||||
|
n1.simulateDisconnectedPeer(n2.peerID)
|
||||||
|
n2.simulateDisconnectedPeer(n1.peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectFullMesh() {
|
||||||
|
let names = Array(nodes.keys)
|
||||||
|
for i in 0..<names.count {
|
||||||
|
for j in (i+1)..<names.count {
|
||||||
|
connect(names[i], names[j])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Relay
|
||||||
|
|
||||||
|
func setupRelay(_ nodeName: String, nextHops: [String]) {
|
||||||
|
guard let node = nodes[nodeName] else { return }
|
||||||
|
node.packetDeliveryHandler = { [weak self] packet in
|
||||||
|
guard let self else { return }
|
||||||
|
guard packet.ttl > 1 else { return }
|
||||||
|
|
||||||
|
if let message = BitchatMessage(packet.payload) {
|
||||||
|
guard message.senderPeerID != node.peerID else { return }
|
||||||
|
|
||||||
|
let relayMessage = BitchatMessage(
|
||||||
|
id: message.id,
|
||||||
|
sender: message.sender,
|
||||||
|
content: message.content,
|
||||||
|
timestamp: message.timestamp,
|
||||||
|
isRelay: true,
|
||||||
|
originalSender: message.isRelay ? message.originalSender : message.sender,
|
||||||
|
isPrivate: message.isPrivate,
|
||||||
|
recipientNickname: message.recipientNickname,
|
||||||
|
senderPeerID: message.senderPeerID,
|
||||||
|
mentions: message.mentions
|
||||||
|
)
|
||||||
|
|
||||||
|
if let relayPayload = relayMessage.toBinaryPayload() {
|
||||||
|
let relayPacket = BitchatPacket(
|
||||||
|
type: packet.type,
|
||||||
|
senderID: packet.senderID,
|
||||||
|
recipientID: packet.recipientID,
|
||||||
|
timestamp: packet.timestamp,
|
||||||
|
payload: relayPayload,
|
||||||
|
signature: packet.signature,
|
||||||
|
ttl: packet.ttl - 1
|
||||||
|
)
|
||||||
|
|
||||||
|
for hop in nextHops {
|
||||||
|
self.nodes[hop]?.simulateIncomingPacket(relayPacket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Noise sessions
|
||||||
|
|
||||||
|
func establishNoiseSession(_ node1: String, _ node2: String) throws {
|
||||||
|
guard let manager1 = noiseManagers[node1],
|
||||||
|
let manager2 = noiseManagers[node2],
|
||||||
|
let peer1ID = nodes[node1]?.peerID,
|
||||||
|
let peer2ID = nodes[node2]?.peerID else { return }
|
||||||
|
|
||||||
|
let msg1 = try manager1.initiateHandshake(with: peer2ID)
|
||||||
|
let msg2 = try manager2.handleIncomingHandshake(from: peer1ID, message: msg1)!
|
||||||
|
let msg3 = try manager1.handleIncomingHandshake(from: peer2ID, message: msg2)!
|
||||||
|
_ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import XCTest
|
import Testing
|
||||||
|
import Foundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
final class LocationChannelsTests: XCTestCase {
|
struct LocationChannelsTests {
|
||||||
func testGeohashEncoderPrecisionMapping() {
|
@Test func geohashEncoderPrecisionMapping() {
|
||||||
// Sanity: known coords (Statue of Liberty approx)
|
// Sanity: known coords (Statue of Liberty approx)
|
||||||
let lat = 40.6892
|
let lat = 40.6892
|
||||||
let lon = -74.0445
|
let lon = -74.0445
|
||||||
@@ -12,34 +13,35 @@ final class LocationChannelsTests: XCTestCase {
|
|||||||
let region = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.province.precision)
|
let region = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.province.precision)
|
||||||
let country = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.region.precision)
|
let country = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.region.precision)
|
||||||
|
|
||||||
XCTAssertEqual(block.count, 7)
|
#expect(block.count == 7)
|
||||||
XCTAssertEqual(neighborhood.count, 6)
|
#expect(neighborhood.count == 6)
|
||||||
XCTAssertEqual(city.count, 5)
|
#expect(city.count == 5)
|
||||||
XCTAssertEqual(region.count, 4)
|
#expect(region.count == 4)
|
||||||
XCTAssertEqual(country.count, 2)
|
#expect(country.count == 2)
|
||||||
|
|
||||||
// All prefixes must match progressively
|
// All prefixes must match progressively
|
||||||
XCTAssertTrue(block.hasPrefix(neighborhood))
|
#expect(block.hasPrefix(neighborhood))
|
||||||
XCTAssertTrue(neighborhood.hasPrefix(city))
|
#expect(neighborhood.hasPrefix(city))
|
||||||
XCTAssertTrue(city.hasPrefix(region))
|
#expect(city.hasPrefix(region))
|
||||||
XCTAssertTrue(region.hasPrefix(country))
|
#expect(region.hasPrefix(country))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testNostrGeohashFilterEncoding() throws {
|
@Test func nostrGeohashFilterEncoding() throws {
|
||||||
let gh = "u4pruy"
|
let gh = "u4pruy"
|
||||||
let filter = NostrFilter.geohashEphemeral(gh)
|
let filter = NostrFilter.geohashEphemeral(gh)
|
||||||
let data = try JSONEncoder().encode(filter)
|
let data = try JSONEncoder().encode(filter)
|
||||||
let json = String(data: data, encoding: .utf8) ?? ""
|
let json = String(data: data, encoding: .utf8) ?? ""
|
||||||
// Expect kinds includes 20000 and tag filter '#g':[gh]
|
// Expect kinds includes 20000 and tag filter '#g':[gh]
|
||||||
XCTAssertTrue(json.contains("20000"))
|
#expect(json.contains("20000"))
|
||||||
XCTAssertTrue(json.contains("\"#g\":[\"\(gh)\"]"))
|
#expect(json.contains("\"#g\":[\"\(gh)\"]"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPerGeohashIdentityDeterministic() throws {
|
@Test func perGeohashIdentityDeterministic() throws {
|
||||||
// Derive twice for same geohash; should be identical
|
// Derive twice for same geohash; should be identical
|
||||||
|
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||||
let gh = "u4pruy"
|
let gh = "u4pruy"
|
||||||
let id1 = try NostrIdentityBridge.deriveIdentity(forGeohash: gh)
|
let id1 = try idBridge.deriveIdentity(forGeohash: gh)
|
||||||
let id2 = try NostrIdentityBridge.deriveIdentity(forGeohash: gh)
|
let id2 = try idBridge.deriveIdentity(forGeohash: gh)
|
||||||
XCTAssertEqual(id1.publicKeyHex, id2.publicKeyHex)
|
#expect(id1.publicKeyHex == id2.publicKeyHex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import XCTest
|
import Testing
|
||||||
|
import Foundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class LocationNotesManagerTests: XCTestCase {
|
struct LocationNotesManagerTests {
|
||||||
// func testSubscribeWithoutRelaysSetsNoRelaysState() {
|
// func testSubscribeWithoutRelaysSetsNoRelaysState() {
|
||||||
// var subscribeCalled = false
|
// var subscribeCalled = false
|
||||||
// let deps = LocationNotesDependencies(
|
// let deps = LocationNotesDependencies(
|
||||||
@@ -47,15 +48,15 @@ final class LocationNotesManagerTests: XCTestCase {
|
|||||||
// XCTAssertNotEqual(manager.errorMessage, "location_notes.error.no_relays")
|
// XCTAssertNotEqual(manager.errorMessage, "location_notes.error.no_relays")
|
||||||
// }
|
// }
|
||||||
|
|
||||||
func testSubscribeUsesGeoRelaysAndAppendsNotes() {
|
@Test func subscribeUsesGeoRelaysAndAppendsNotes() {
|
||||||
var relaysCaptured: [String] = []
|
var relaysCaptured: [String] = []
|
||||||
var storedHandler: ((NostrEvent) -> Void)?
|
var storedHandler: ((NostrEvent) -> Void)?
|
||||||
var storedEOSE: (() -> Void)?
|
var storedEOSE: (() -> Void)?
|
||||||
let deps = LocationNotesDependencies(
|
let deps = LocationNotesDependencies(
|
||||||
relayLookup: { _, _ in ["wss://relay.one"] },
|
relayLookup: { _, _ in ["wss://relay.one"] },
|
||||||
subscribe: { filter, id, relays, handler, eose in
|
subscribe: { filter, id, relays, handler, eose in
|
||||||
XCTAssertEqual(filter.kinds, [1])
|
#expect(filter.kinds == [1])
|
||||||
XCTAssertFalse(id.isEmpty)
|
#expect(!id.isEmpty)
|
||||||
relaysCaptured = relays
|
relaysCaptured = relays
|
||||||
storedHandler = handler
|
storedHandler = handler
|
||||||
storedEOSE = eose
|
storedEOSE = eose
|
||||||
@@ -67,8 +68,8 @@ final class LocationNotesManagerTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
|
|
||||||
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
||||||
XCTAssertEqual(relaysCaptured, ["wss://relay.one"])
|
#expect(relaysCaptured == ["wss://relay.one"])
|
||||||
XCTAssertEqual(manager.state, .loading)
|
#expect(manager.state == .loading)
|
||||||
|
|
||||||
var event = NostrEvent(
|
var event = NostrEvent(
|
||||||
pubkey: "pub",
|
pubkey: "pub",
|
||||||
@@ -81,9 +82,9 @@ final class LocationNotesManagerTests: XCTestCase {
|
|||||||
storedHandler?(event)
|
storedHandler?(event)
|
||||||
storedEOSE?()
|
storedEOSE?()
|
||||||
|
|
||||||
XCTAssertEqual(manager.state, .ready)
|
#expect(manager.state == .ready)
|
||||||
XCTAssertEqual(manager.notes.count, 1)
|
#expect(manager.notes.count == 1)
|
||||||
XCTAssertEqual(manager.notes.first?.content, "hi")
|
#expect(manager.notes.first?.content == "hi")
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum TestError: Error {
|
private enum TestError: Error {
|
||||||
@@ -92,8 +93,8 @@ final class LocationNotesManagerTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class LocationNotesCounterTests: XCTestCase {
|
struct LocationNotesCounterTests {
|
||||||
func testSubscribeWithoutRelaysMarksUnavailable() {
|
@Test func subscribeWithoutRelaysMarksUnavailable() {
|
||||||
var subscribeCalled = false
|
var subscribeCalled = false
|
||||||
let deps = LocationNotesCounterDependencies(
|
let deps = LocationNotesCounterDependencies(
|
||||||
relayLookup: { _, _ in [] },
|
relayLookup: { _, _ in [] },
|
||||||
@@ -104,21 +105,21 @@ final class LocationNotesCounterTests: XCTestCase {
|
|||||||
let counter = LocationNotesCounter(testDependencies: deps)
|
let counter = LocationNotesCounter(testDependencies: deps)
|
||||||
counter.subscribe(geohash: "u4pruydq")
|
counter.subscribe(geohash: "u4pruydq")
|
||||||
|
|
||||||
XCTAssertFalse(subscribeCalled)
|
#expect(!subscribeCalled)
|
||||||
XCTAssertFalse(counter.relayAvailable)
|
#expect(!counter.relayAvailable)
|
||||||
XCTAssertTrue(counter.initialLoadComplete)
|
#expect(counter.initialLoadComplete)
|
||||||
XCTAssertEqual(counter.count, 0)
|
#expect(counter.count == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSubscribeCountsUniqueNotes() {
|
@Test func subscribeCountsUniqueNotes() {
|
||||||
var storedHandler: ((NostrEvent) -> Void)?
|
var storedHandler: ((NostrEvent) -> Void)?
|
||||||
var storedEOSE: (() -> Void)?
|
var storedEOSE: (() -> Void)?
|
||||||
let deps = LocationNotesCounterDependencies(
|
let deps = LocationNotesCounterDependencies(
|
||||||
relayLookup: { _, _ in ["wss://relay.geo"] },
|
relayLookup: { _, _ in ["wss://relay.geo"] },
|
||||||
subscribe: { filter, id, relays, handler, eose in
|
subscribe: { filter, id, relays, handler, eose in
|
||||||
XCTAssertEqual(relays, ["wss://relay.geo"])
|
#expect(relays == ["wss://relay.geo"])
|
||||||
XCTAssertEqual(filter.kinds, [1])
|
#expect(filter.kinds == [1])
|
||||||
XCTAssertFalse(id.isEmpty)
|
#expect(!id.isEmpty)
|
||||||
storedHandler = handler
|
storedHandler = handler
|
||||||
storedEOSE = eose
|
storedEOSE = eose
|
||||||
},
|
},
|
||||||
@@ -143,8 +144,8 @@ final class LocationNotesCounterTests: XCTestCase {
|
|||||||
|
|
||||||
storedEOSE?()
|
storedEOSE?()
|
||||||
|
|
||||||
XCTAssertTrue(counter.relayAvailable)
|
#expect(counter.relayAvailable)
|
||||||
XCTAssertEqual(counter.count, 1)
|
#expect(counter.count == 1)
|
||||||
XCTAssertTrue(counter.initialLoadComplete)
|
#expect(counter.initialLoadComplete)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
//
|
||||||
|
// MockBLEBus.swift
|
||||||
|
// bitchatTests
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
final class MockBLEBus {
|
||||||
|
private var registry: [PeerID: MockBLEService] = [:]
|
||||||
|
private var adjacency: [PeerID: Set<PeerID>] = [:]
|
||||||
|
|
||||||
|
// Enable automatic flooding for public messages in integration tests only
|
||||||
|
let autoFloodEnabled: Bool
|
||||||
|
|
||||||
|
init(autoFloodEnabled: Bool = false) {
|
||||||
|
self.autoFloodEnabled = autoFloodEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
func register(_ service: MockBLEService, for peerID: PeerID) {
|
||||||
|
registry[peerID] = service
|
||||||
|
if adjacency[peerID] == nil { adjacency[peerID] = [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
func connect(_ a: PeerID, _ b: PeerID) {
|
||||||
|
var setA = adjacency[a] ?? []
|
||||||
|
setA.insert(b)
|
||||||
|
adjacency[a] = setA
|
||||||
|
var setB = adjacency[b] ?? []
|
||||||
|
setB.insert(a)
|
||||||
|
adjacency[b] = setB
|
||||||
|
}
|
||||||
|
|
||||||
|
func disconnect(_ a: PeerID, _ b: PeerID) {
|
||||||
|
if var setA = adjacency[a] { setA.remove(b); adjacency[a] = setA }
|
||||||
|
if var setB = adjacency[b] { setB.remove(a); adjacency[b] = setB }
|
||||||
|
}
|
||||||
|
|
||||||
|
func neighbors(of peerID: PeerID) -> [MockBLEService] {
|
||||||
|
let ids = adjacency[peerID] ?? []
|
||||||
|
let result = ids.compactMap { registry[$0] }
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDirectNeighbor(_ a: PeerID, _ b: PeerID) -> Bool {
|
||||||
|
let res = adjacency[a]?.contains(b) ?? false
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func service(for peerID: PeerID) -> MockBLEService? {
|
||||||
|
let svc = registry[peerID]
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,13 +26,12 @@ import CoreBluetooth
|
|||||||
/// simulate broadcast propagation across the mesh. E2E tests keep it off and perform explicit
|
/// simulate broadcast propagation across the mesh. E2E tests keep it off and perform explicit
|
||||||
/// relays when needed.
|
/// relays when needed.
|
||||||
final class MockBLEService: NSObject {
|
final class MockBLEService: NSObject {
|
||||||
// Enable automatic flooding for public messages in integration tests only
|
private let bus: MockBLEBus
|
||||||
static var autoFloodEnabled: Bool = false
|
|
||||||
|
|
||||||
// MARK: - Properties matching BLEService
|
// MARK: - Properties matching BLEService
|
||||||
|
|
||||||
weak var delegate: BitchatDelegate?
|
weak var delegate: BitchatDelegate?
|
||||||
var myPeerID: PeerID = "MOCK1234"
|
var myPeerID = PeerID(str: "MOCK1234")
|
||||||
var myNickname: String = "MockUser"
|
var myNickname: String = "MockUser"
|
||||||
|
|
||||||
private let mockKeychain = MockKeychain()
|
private let mockKeychain = MockKeychain()
|
||||||
@@ -60,8 +59,8 @@ final class MockBLEService: NSObject {
|
|||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
override init() {
|
init(bus: MockBLEBus) {
|
||||||
super.init()
|
self.bus = bus
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Methods matching BLEService
|
// MARK: - Methods matching BLEService
|
||||||
@@ -71,42 +70,15 @@ final class MockBLEService: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - In-memory test bus (for E2E/Integration)
|
// MARK: - In-memory test bus (for E2E/Integration)
|
||||||
/// Global per-process bus for deterministic routing in tests.
|
|
||||||
private static var registry: [PeerID: MockBLEService] = [:]
|
|
||||||
private static var adjacency: [PeerID: Set<PeerID>] = [:]
|
|
||||||
|
|
||||||
/// Clears global bus state. Call from test `setUp()`.
|
|
||||||
static func resetTestBus() {
|
|
||||||
registry.removeAll()
|
|
||||||
adjacency.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Registers this instance on first use.
|
/// Registers this instance on first use.
|
||||||
private func registerIfNeeded() {
|
private func registerIfNeeded() {
|
||||||
MockBLEService.registry[myPeerID] = self
|
bus.register(self, for: myPeerID)
|
||||||
if MockBLEService.adjacency[myPeerID] == nil { MockBLEService.adjacency[myPeerID] = [] }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns adjacent neighbors based on the current simulated topology.
|
/// Returns adjacent neighbors based on the current simulated topology.
|
||||||
private func neighbors() -> [MockBLEService] {
|
private func neighbors() -> [MockBLEService] {
|
||||||
guard let ids = MockBLEService.adjacency[myPeerID] else { return [] }
|
bus.neighbors(of: myPeerID)
|
||||||
return ids.compactMap { MockBLEService.registry[$0] }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Adds an undirected edge between two peerIDs.
|
|
||||||
private static func connectPeers(_ a: PeerID, _ b: PeerID) {
|
|
||||||
var setA = adjacency[a] ?? []
|
|
||||||
setA.insert(b)
|
|
||||||
adjacency[a] = setA
|
|
||||||
var setB = adjacency[b] ?? []
|
|
||||||
setB.insert(a)
|
|
||||||
adjacency[b] = setB
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Removes an undirected edge between two peerIDs.
|
|
||||||
private static func disconnectPeers(_ a: PeerID, _ b: PeerID) {
|
|
||||||
if var setA = adjacency[a] { setA.remove(b); adjacency[a] = setA }
|
|
||||||
if var setB = adjacency[b] { setB.remove(a); adjacency[b] = setB }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func startServices() {
|
func startServices() {
|
||||||
@@ -173,7 +145,7 @@ final class MockBLEService: NSObject {
|
|||||||
// Surface raw packet to tests that intercept/relay/encrypt
|
// Surface raw packet to tests that intercept/relay/encrypt
|
||||||
packetDeliveryHandler?(packet)
|
packetDeliveryHandler?(packet)
|
||||||
|
|
||||||
// Deliver public messages to adjacent peers via test bus
|
// Deliver public messages to adjacent peers via bus
|
||||||
if recipientID == nil {
|
if recipientID == nil {
|
||||||
for neighbor in neighbors() {
|
for neighbor in neighbors() {
|
||||||
neighbor.simulateIncomingPacket(packet)
|
neighbor.simulateIncomingPacket(packet)
|
||||||
@@ -219,24 +191,20 @@ final class MockBLEService: NSObject {
|
|||||||
packetDeliveryHandler?(packet)
|
packetDeliveryHandler?(packet)
|
||||||
|
|
||||||
// If directly connected to recipient, deliver only to them.
|
// If directly connected to recipient, deliver only to them.
|
||||||
if let neighbors = MockBLEService.adjacency[myPeerID], neighbors.contains(recipientPeerID),
|
if bus.isDirectNeighbor(myPeerID, recipientPeerID),
|
||||||
let target = MockBLEService.registry[recipientPeerID] {
|
let target = bus.service(for: recipientPeerID) {
|
||||||
target.simulateIncomingPacket(packet)
|
target.simulateIncomingPacket(packet)
|
||||||
} else {
|
} else {
|
||||||
// Not directly connected: deliver to neighbors for relay; also deliver directly if target is known
|
// Not directly connected: deliver to neighbors for relay; also deliver directly if target is known
|
||||||
if let target = MockBLEService.registry[recipientPeerID] {
|
if let target = bus.service(for: recipientPeerID) {
|
||||||
target.simulateIncomingPacket(packet)
|
target.simulateIncomingPacket(packet)
|
||||||
}
|
}
|
||||||
if let neighbors = MockBLEService.adjacency[myPeerID] {
|
for neighbor in neighbors() where neighbor.peerID != recipientPeerID {
|
||||||
for peer in neighbors where peer != recipientPeerID {
|
|
||||||
if let neighbor = MockBLEService.registry[peer] {
|
|
||||||
neighbor.simulateIncomingPacket(packet)
|
neighbor.simulateIncomingPacket(packet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
|
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
|
||||||
// Mock implementation
|
// Mock implementation
|
||||||
@@ -279,14 +247,14 @@ final class MockBLEService: NSObject {
|
|||||||
|
|
||||||
func simulateConnectedPeer(_ peerID: PeerID) {
|
func simulateConnectedPeer(_ peerID: PeerID) {
|
||||||
registerIfNeeded()
|
registerIfNeeded()
|
||||||
MockBLEService.connectPeers(myPeerID, peerID)
|
bus.connect(myPeerID, peerID)
|
||||||
connectedPeers.insert(peerID)
|
connectedPeers.insert(peerID)
|
||||||
delegate?.didConnectToPeer(peerID)
|
delegate?.didConnectToPeer(peerID)
|
||||||
delegate?.didUpdatePeerList(Array(connectedPeers))
|
delegate?.didUpdatePeerList(Array(connectedPeers))
|
||||||
}
|
}
|
||||||
|
|
||||||
func simulateDisconnectedPeer(_ peerID: PeerID) {
|
func simulateDisconnectedPeer(_ peerID: PeerID) {
|
||||||
MockBLEService.disconnectPeers(myPeerID, peerID)
|
bus.disconnect(myPeerID, peerID)
|
||||||
connectedPeers.remove(peerID)
|
connectedPeers.remove(peerID)
|
||||||
delegate?.didDisconnectFromPeer(peerID)
|
delegate?.didDisconnectFromPeer(peerID)
|
||||||
delegate?.didUpdatePeerList(Array(connectedPeers))
|
delegate?.didUpdatePeerList(Array(connectedPeers))
|
||||||
@@ -319,7 +287,7 @@ final class MockBLEService: NSObject {
|
|||||||
// When enabled, propagate a public broadcast across the entire connected
|
// When enabled, propagate a public broadcast across the entire connected
|
||||||
// component regardless of the original TTL to better emulate large-network
|
// component regardless of the original TTL to better emulate large-network
|
||||||
// broadcast expectations. De-duplication via seenMessageIDs prevents loops.
|
// broadcast expectations. De-duplication via seenMessageIDs prevents loops.
|
||||||
if MockBLEService.autoFloodEnabled,
|
if bus.autoFloodEnabled,
|
||||||
packet.recipientID == nil,
|
packet.recipientID == nil,
|
||||||
!message.isPrivate {
|
!message.isPrivate {
|
||||||
let nextTTL = packet.ttl > 0 ? packet.ttl - 1 : 0
|
let nextTTL = packet.ttl > 0 ? packet.ttl - 1 : 0
|
||||||
@@ -353,8 +321,8 @@ typealias MockSimplifiedBluetoothService = MockBLEService
|
|||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
extension MockBLEService {
|
extension MockBLEService {
|
||||||
convenience init(peerID: PeerID, nickname: String) {
|
convenience init(peerID: PeerID, nickname: String, bus: MockBLEBus) {
|
||||||
self.init()
|
self.init(bus: bus)
|
||||||
myPeerID = peerID
|
myPeerID = peerID
|
||||||
mockNickname = nickname
|
mockNickname = nickname
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
//
|
|
||||||
// MockBluetoothMeshService.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import CoreBluetooth
|
|
||||||
@testable import bitchat
|
|
||||||
|
|
||||||
// Compatibility wrapper for old tests - please use MockBLEService directly
|
|
||||||
typealias MockBluetoothMeshService = MockBLEService
|
|
||||||
@@ -44,3 +44,21 @@ final class MockKeychain: KeychainManagerProtocol {
|
|||||||
storage["identity_noiseStaticKey"] != nil
|
storage["identity_noiseStaticKey"] != nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class MockKeychainHelper: KeychainHelperProtocol {
|
||||||
|
private typealias Service = String
|
||||||
|
private typealias Key = String
|
||||||
|
private var storage: [Service: [Key: Data]] = [:]
|
||||||
|
|
||||||
|
func save(key: String, data: Data, service: String, accessible: CFString?) {
|
||||||
|
storage[service]?[key] = data
|
||||||
|
}
|
||||||
|
|
||||||
|
func load(key: String, service: String) -> Data? {
|
||||||
|
storage[service]?[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete(key: String, service: String) {
|
||||||
|
storage[service]?.removeValue(forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,135 +6,123 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import XCTest
|
import Testing
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
final class NoiseProtocolTests: XCTestCase {
|
struct NoiseProtocolTests {
|
||||||
|
|
||||||
var aliceKey: Curve25519.KeyAgreement.PrivateKey!
|
private let aliceKey = Curve25519.KeyAgreement.PrivateKey()
|
||||||
var bobKey: Curve25519.KeyAgreement.PrivateKey!
|
private let bobKey = Curve25519.KeyAgreement.PrivateKey()
|
||||||
var aliceSession: NoiseSession!
|
private let mockKeychain = MockKeychain()
|
||||||
var bobSession: NoiseSession!
|
|
||||||
private var mockKeychain: MockKeychain!
|
|
||||||
|
|
||||||
override func setUp() {
|
private let alicePeerID = PeerID(str: UUID().uuidString)
|
||||||
super.setUp()
|
private let bobPeerID = PeerID(str: UUID().uuidString)
|
||||||
aliceKey = Curve25519.KeyAgreement.PrivateKey()
|
|
||||||
bobKey = Curve25519.KeyAgreement.PrivateKey()
|
|
||||||
mockKeychain = MockKeychain()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tearDown() {
|
private let aliceSession: NoiseSession
|
||||||
aliceSession = nil
|
private let bobSession: NoiseSession
|
||||||
bobSession = nil
|
|
||||||
mockKeychain = nil
|
|
||||||
super.tearDown()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Basic Handshake Tests
|
init() {
|
||||||
|
|
||||||
func testXXPatternHandshake() throws {
|
|
||||||
// Create sessions
|
|
||||||
aliceSession = NoiseSession(
|
aliceSession = NoiseSession(
|
||||||
peerID: TestConstants.testPeerID2,
|
peerID: alicePeerID,
|
||||||
role: .initiator,
|
role: .initiator,
|
||||||
keychain: mockKeychain,
|
keychain: mockKeychain,
|
||||||
localStaticKey: aliceKey
|
localStaticKey: aliceKey
|
||||||
)
|
)
|
||||||
|
|
||||||
bobSession = NoiseSession(
|
bobSession = NoiseSession(
|
||||||
peerID: TestConstants.testPeerID1,
|
peerID: bobPeerID,
|
||||||
role: .responder,
|
role: .responder,
|
||||||
keychain: mockKeychain,
|
keychain: mockKeychain,
|
||||||
localStaticKey: bobKey
|
localStaticKey: bobKey
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Basic Handshake Tests
|
||||||
|
|
||||||
|
@Test func xxPatternHandshake() throws {
|
||||||
// Alice starts handshake (message 1)
|
// Alice starts handshake (message 1)
|
||||||
let message1 = try aliceSession.startHandshake()
|
let message1 = try aliceSession.startHandshake()
|
||||||
XCTAssertFalse(message1.isEmpty)
|
#expect(!message1.isEmpty)
|
||||||
XCTAssertEqual(aliceSession.getState(), .handshaking)
|
#expect(aliceSession.getState() == .handshaking)
|
||||||
|
|
||||||
// Bob processes message 1 and creates message 2
|
// Bob processes message 1 and creates message 2
|
||||||
let message2 = try bobSession.processHandshakeMessage(message1)
|
let message2 = try bobSession.processHandshakeMessage(message1)
|
||||||
XCTAssertNotNil(message2)
|
#expect(message2 != nil)
|
||||||
XCTAssertFalse(message2!.isEmpty)
|
#expect(!message2!.isEmpty)
|
||||||
XCTAssertEqual(bobSession.getState(), .handshaking)
|
#expect(bobSession.getState() == .handshaking)
|
||||||
|
|
||||||
// Alice processes message 2 and creates message 3
|
// Alice processes message 2 and creates message 3
|
||||||
let message3 = try aliceSession.processHandshakeMessage(message2!)
|
let message3 = try aliceSession.processHandshakeMessage(message2!)
|
||||||
XCTAssertNotNil(message3)
|
#expect(message3 != nil)
|
||||||
XCTAssertFalse(message3!.isEmpty)
|
#expect(!message3!.isEmpty)
|
||||||
XCTAssertEqual(aliceSession.getState(), .established)
|
#expect(aliceSession.getState() == .established)
|
||||||
|
|
||||||
// Bob processes message 3 and completes handshake
|
// Bob processes message 3 and completes handshake
|
||||||
let finalMessage = try bobSession.processHandshakeMessage(message3!)
|
let finalMessage = try bobSession.processHandshakeMessage(message3!)
|
||||||
XCTAssertNil(finalMessage) // No more messages needed
|
#expect(finalMessage == nil) // No more messages needed
|
||||||
XCTAssertEqual(bobSession.getState(), .established)
|
#expect(bobSession.getState() == .established)
|
||||||
|
|
||||||
// Verify both sessions are established
|
// Verify both sessions are established
|
||||||
XCTAssertTrue(aliceSession.isEstablished())
|
#expect(aliceSession.isEstablished())
|
||||||
XCTAssertTrue(bobSession.isEstablished())
|
#expect(bobSession.isEstablished())
|
||||||
|
|
||||||
// Verify they have each other's static keys
|
// Verify they have each other's static keys
|
||||||
XCTAssertEqual(aliceSession.getRemoteStaticPublicKey()?.rawRepresentation, bobKey.publicKey.rawRepresentation)
|
#expect(aliceSession.getRemoteStaticPublicKey()?.rawRepresentation == bobKey.publicKey.rawRepresentation)
|
||||||
XCTAssertEqual(bobSession.getRemoteStaticPublicKey()?.rawRepresentation, aliceKey.publicKey.rawRepresentation)
|
#expect(bobSession.getRemoteStaticPublicKey()?.rawRepresentation == aliceKey.publicKey.rawRepresentation)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testHandshakeStateValidation() throws {
|
@Test func handshakeStateValidation() throws {
|
||||||
aliceSession = NoiseSession(
|
|
||||||
peerID: TestConstants.testPeerID2,
|
|
||||||
role: .initiator,
|
|
||||||
keychain: mockKeychain,
|
|
||||||
localStaticKey: aliceKey
|
|
||||||
)
|
|
||||||
|
|
||||||
// Cannot process message before starting handshake
|
// Cannot process message before starting handshake
|
||||||
XCTAssertThrowsError(try aliceSession.processHandshakeMessage(Data()))
|
#expect(throws: NoiseSessionError.invalidState) {
|
||||||
|
try aliceSession.processHandshakeMessage(Data())
|
||||||
|
}
|
||||||
|
|
||||||
// Start handshake
|
// Start handshake
|
||||||
_ = try aliceSession.startHandshake()
|
_ = try aliceSession.startHandshake()
|
||||||
|
|
||||||
// Cannot start handshake twice
|
// Cannot start handshake twice
|
||||||
XCTAssertThrowsError(try aliceSession.startHandshake())
|
#expect(throws: NoiseSessionError.invalidState) {
|
||||||
|
try aliceSession.startHandshake()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Encryption/Decryption Tests
|
// MARK: - Encryption/Decryption Tests
|
||||||
|
|
||||||
func testBasicEncryptionDecryption() throws {
|
@Test func basicEncryptionDecryption() throws {
|
||||||
// Establish sessions
|
try performHandshake(initiator: aliceSession, responder: bobSession)
|
||||||
try establishSessions()
|
|
||||||
|
|
||||||
let plaintext = "Hello, Bob!".data(using: .utf8)!
|
let plaintext = "Hello, Bob!".data(using: .utf8)!
|
||||||
|
|
||||||
// Alice encrypts
|
// Alice encrypts
|
||||||
let ciphertext = try aliceSession.encrypt(plaintext)
|
let ciphertext = try aliceSession.encrypt(plaintext)
|
||||||
XCTAssertNotEqual(ciphertext, plaintext)
|
#expect(ciphertext != plaintext)
|
||||||
XCTAssertGreaterThan(ciphertext.count, plaintext.count) // Should have overhead
|
#expect(ciphertext.count > plaintext.count) // Should have overhead
|
||||||
|
|
||||||
// Bob decrypts
|
// Bob decrypts
|
||||||
let decrypted = try bobSession.decrypt(ciphertext)
|
let decrypted = try bobSession.decrypt(ciphertext)
|
||||||
XCTAssertEqual(decrypted, plaintext)
|
#expect(decrypted == plaintext)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBidirectionalEncryption() throws {
|
@Test func bidirectionalEncryption() throws {
|
||||||
try establishSessions()
|
try performHandshake(initiator: aliceSession, responder: bobSession)
|
||||||
|
|
||||||
// Alice -> Bob
|
// Alice -> Bob
|
||||||
let aliceMessage = "Hello from Alice".data(using: .utf8)!
|
let aliceMessage = "Hello from Alice".data(using: .utf8)!
|
||||||
let aliceCiphertext = try aliceSession.encrypt(aliceMessage)
|
let aliceCiphertext = try aliceSession.encrypt(aliceMessage)
|
||||||
let bobReceived = try bobSession.decrypt(aliceCiphertext)
|
let bobReceived = try bobSession.decrypt(aliceCiphertext)
|
||||||
XCTAssertEqual(bobReceived, aliceMessage)
|
#expect(bobReceived == aliceMessage)
|
||||||
|
|
||||||
// Bob -> Alice
|
// Bob -> Alice
|
||||||
let bobMessage = "Hello from Bob".data(using: .utf8)!
|
let bobMessage = "Hello from Bob".data(using: .utf8)!
|
||||||
let bobCiphertext = try bobSession.encrypt(bobMessage)
|
let bobCiphertext = try bobSession.encrypt(bobMessage)
|
||||||
let aliceReceived = try aliceSession.decrypt(bobCiphertext)
|
let aliceReceived = try aliceSession.decrypt(bobCiphertext)
|
||||||
XCTAssertEqual(aliceReceived, bobMessage)
|
#expect(aliceReceived == bobMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testLargeMessageEncryption() throws {
|
@Test func largeMessageEncryption() throws {
|
||||||
try establishSessions()
|
try performHandshake(initiator: aliceSession, responder: bobSession)
|
||||||
|
|
||||||
// Create a large message
|
// Create a large message
|
||||||
let largeMessage = TestHelpers.generateRandomData(length: 100_000)
|
let largeMessage = TestHelpers.generateRandomData(length: 100_000)
|
||||||
@@ -143,81 +131,78 @@ final class NoiseProtocolTests: XCTestCase {
|
|||||||
let ciphertext = try aliceSession.encrypt(largeMessage)
|
let ciphertext = try aliceSession.encrypt(largeMessage)
|
||||||
let decrypted = try bobSession.decrypt(ciphertext)
|
let decrypted = try bobSession.decrypt(ciphertext)
|
||||||
|
|
||||||
XCTAssertEqual(decrypted, largeMessage)
|
#expect(decrypted == largeMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testEncryptionBeforeHandshake() {
|
@Test func encryptionBeforeHandshake() {
|
||||||
aliceSession = NoiseSession(
|
|
||||||
peerID: TestConstants.testPeerID2,
|
|
||||||
role: .initiator,
|
|
||||||
keychain: mockKeychain,
|
|
||||||
localStaticKey: aliceKey
|
|
||||||
)
|
|
||||||
|
|
||||||
let plaintext = "test".data(using: .utf8)!
|
let plaintext = "test".data(using: .utf8)!
|
||||||
|
|
||||||
// Should throw when not established
|
#expect(throws: NoiseSessionError.notEstablished) {
|
||||||
XCTAssertThrowsError(try aliceSession.encrypt(plaintext))
|
try aliceSession.encrypt(plaintext)
|
||||||
XCTAssertThrowsError(try aliceSession.decrypt(plaintext))
|
}
|
||||||
|
|
||||||
|
#expect(throws: NoiseSessionError.notEstablished) {
|
||||||
|
try aliceSession.decrypt(plaintext)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Session Manager Tests
|
// MARK: - Session Manager Tests
|
||||||
|
|
||||||
func testSessionManagerBasicOperations() throws {
|
@Test func sessionManagerBasicOperations() throws {
|
||||||
let manager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
let manager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
||||||
|
|
||||||
// Create session
|
#expect(manager.getSession(for: alicePeerID) == nil)
|
||||||
let session = manager.createSession(for: TestConstants.testPeerID2, role: .initiator)
|
|
||||||
XCTAssertNotNil(session)
|
_ = try manager.initiateHandshake(with: alicePeerID)
|
||||||
|
#expect(manager.getSession(for: alicePeerID) != nil)
|
||||||
|
|
||||||
// Get session
|
// Get session
|
||||||
let retrieved = manager.getSession(for: TestConstants.testPeerID2)
|
let retrieved = manager.getSession(for: alicePeerID)
|
||||||
XCTAssertNotNil(retrieved)
|
#expect(retrieved != nil)
|
||||||
XCTAssertTrue(session === retrieved)
|
|
||||||
|
|
||||||
// Remove session
|
// Remove session
|
||||||
manager.removeSession(for: TestConstants.testPeerID2)
|
manager.removeSession(for: alicePeerID)
|
||||||
XCTAssertNil(manager.getSession(for: TestConstants.testPeerID2))
|
#expect(manager.getSession(for: alicePeerID) == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSessionManagerHandshakeInitiation() throws {
|
@Test func sessionManagerHandshakeInitiation() throws {
|
||||||
let manager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
let manager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
||||||
|
|
||||||
// Initiate handshake
|
// Initiate handshake
|
||||||
let handshakeData = try manager.initiateHandshake(with: TestConstants.testPeerID2)
|
let handshakeData = try manager.initiateHandshake(with: alicePeerID)
|
||||||
XCTAssertFalse(handshakeData.isEmpty)
|
#expect(!handshakeData.isEmpty)
|
||||||
|
|
||||||
// Session should exist
|
// Session should exist
|
||||||
let session = manager.getSession(for: TestConstants.testPeerID2)
|
let session = manager.getSession(for: alicePeerID)
|
||||||
XCTAssertNotNil(session)
|
#expect(session != nil)
|
||||||
XCTAssertEqual(session?.getState(), .handshaking)
|
#expect(session?.getState() == .handshaking)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSessionManagerIncomingHandshake() throws {
|
@Test func sessionManagerIncomingHandshake() throws {
|
||||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
||||||
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
||||||
|
|
||||||
// Alice initiates
|
// Alice initiates
|
||||||
let message1 = try aliceManager.initiateHandshake(with: TestConstants.testPeerID2)
|
let message1 = try aliceManager.initiateHandshake(with: alicePeerID)
|
||||||
|
|
||||||
// Bob responds
|
// Bob responds
|
||||||
let message2 = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: message1)
|
let message2 = try bobManager.handleIncomingHandshake(from: bobPeerID, message: message1)
|
||||||
XCTAssertNotNil(message2)
|
#expect(message2 != nil)
|
||||||
|
|
||||||
// Continue handshake
|
// Continue handshake
|
||||||
let message3 = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: message2!)
|
let message3 = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: message2!)
|
||||||
XCTAssertNotNil(message3)
|
#expect(message3 != nil)
|
||||||
|
|
||||||
// Complete handshake
|
// Complete handshake
|
||||||
let finalMessage = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: message3!)
|
let finalMessage = try bobManager.handleIncomingHandshake(from: bobPeerID, message: message3!)
|
||||||
XCTAssertNil(finalMessage)
|
#expect(finalMessage == nil)
|
||||||
|
|
||||||
// Both should have established sessions
|
// Both should have established sessions
|
||||||
XCTAssertTrue(aliceManager.getSession(for: TestConstants.testPeerID2)?.isEstablished() ?? false)
|
#expect(aliceManager.getSession(for: alicePeerID)?.isEstablished() == true)
|
||||||
XCTAssertTrue(bobManager.getSession(for: TestConstants.testPeerID1)?.isEstablished() ?? false)
|
#expect(bobManager.getSession(for: bobPeerID)?.isEstablished() == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSessionManagerEncryptionDecryption() throws {
|
@Test func sessionManagerEncryptionDecryption() throws {
|
||||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
||||||
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
||||||
|
|
||||||
@@ -226,17 +211,17 @@ final class NoiseProtocolTests: XCTestCase {
|
|||||||
|
|
||||||
// Encrypt with manager
|
// Encrypt with manager
|
||||||
let plaintext = "Test message".data(using: .utf8)!
|
let plaintext = "Test message".data(using: .utf8)!
|
||||||
let ciphertext = try aliceManager.encrypt(plaintext, for: TestConstants.testPeerID2)
|
let ciphertext = try aliceManager.encrypt(plaintext, for: alicePeerID)
|
||||||
|
|
||||||
// Decrypt with manager
|
// Decrypt with manager
|
||||||
let decrypted = try bobManager.decrypt(ciphertext, from: TestConstants.testPeerID1)
|
let decrypted = try bobManager.decrypt(ciphertext, from: bobPeerID)
|
||||||
XCTAssertEqual(decrypted, plaintext)
|
#expect(decrypted == plaintext)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Security Tests
|
// MARK: - Security Tests
|
||||||
|
|
||||||
func testTamperedCiphertextDetection() throws {
|
@Test func tamperedCiphertextDetection() throws {
|
||||||
try establishSessions()
|
try performHandshake(initiator: aliceSession, responder: bobSession)
|
||||||
|
|
||||||
let plaintext = "Secret message".data(using: .utf8)!
|
let plaintext = "Secret message".data(using: .utf8)!
|
||||||
var ciphertext = try aliceSession.encrypt(plaintext)
|
var ciphertext = try aliceSession.encrypt(plaintext)
|
||||||
@@ -245,11 +230,19 @@ final class NoiseProtocolTests: XCTestCase {
|
|||||||
ciphertext[ciphertext.count / 2] ^= 0xFF
|
ciphertext[ciphertext.count / 2] ^= 0xFF
|
||||||
|
|
||||||
// Decryption should fail
|
// Decryption should fail
|
||||||
XCTAssertThrowsError(try bobSession.decrypt(ciphertext))
|
if #available(macOS 14.4, iOS 17.4, *) {
|
||||||
|
#expect(throws: CryptoKitError.authenticationFailure) {
|
||||||
|
try bobSession.decrypt(ciphertext)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
#expect(throws: (any Error).self) {
|
||||||
|
try bobSession.decrypt(ciphertext)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testReplayPrevention() throws {
|
@Test func replayPrevention() throws {
|
||||||
try establishSessions()
|
try performHandshake(initiator: aliceSession, responder: bobSession)
|
||||||
|
|
||||||
let plaintext = "Test message".data(using: .utf8)!
|
let plaintext = "Test message".data(using: .utf8)!
|
||||||
let ciphertext = try aliceSession.encrypt(plaintext)
|
let ciphertext = try aliceSession.encrypt(plaintext)
|
||||||
@@ -258,16 +251,18 @@ final class NoiseProtocolTests: XCTestCase {
|
|||||||
_ = try bobSession.decrypt(ciphertext)
|
_ = try bobSession.decrypt(ciphertext)
|
||||||
|
|
||||||
// Replaying the same ciphertext should fail
|
// Replaying the same ciphertext should fail
|
||||||
XCTAssertThrowsError(try bobSession.decrypt(ciphertext))
|
#expect(throws: NoiseError.replayDetected) {
|
||||||
|
try bobSession.decrypt(ciphertext)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSessionIsolation() throws {
|
@Test func sessionIsolation() throws {
|
||||||
// Create two separate session pairs
|
// Create two separate session pairs
|
||||||
let aliceSession1 = NoiseSession(peerID: "peer1", role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
|
let aliceSession1 = NoiseSession(peerID: PeerID(str: "peer1"), role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
|
||||||
let bobSession1 = NoiseSession(peerID: "alice1", role: .responder, keychain: mockKeychain, localStaticKey: bobKey)
|
let bobSession1 = NoiseSession(peerID: PeerID(str: "alice1"), role: .responder, keychain: mockKeychain, localStaticKey: bobKey)
|
||||||
|
|
||||||
let aliceSession2 = NoiseSession(peerID: "peer2", role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
|
let aliceSession2 = NoiseSession(peerID: PeerID(str: "peer2"), role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
|
||||||
let bobSession2 = NoiseSession(peerID: "alice2", role: .responder, keychain: mockKeychain, localStaticKey: bobKey)
|
let bobSession2 = NoiseSession(peerID: PeerID(str: "alice2"), role: .responder, keychain: mockKeychain, localStaticKey: bobKey)
|
||||||
|
|
||||||
// Establish both pairs
|
// Establish both pairs
|
||||||
try performHandshake(initiator: aliceSession1, responder: bobSession1)
|
try performHandshake(initiator: aliceSession1, responder: bobSession1)
|
||||||
@@ -278,16 +273,24 @@ final class NoiseProtocolTests: XCTestCase {
|
|||||||
let ciphertext1 = try aliceSession1.encrypt(plaintext)
|
let ciphertext1 = try aliceSession1.encrypt(plaintext)
|
||||||
|
|
||||||
// Should not be able to decrypt with session 2
|
// Should not be able to decrypt with session 2
|
||||||
XCTAssertThrowsError(try bobSession2.decrypt(ciphertext1))
|
if #available(macOS 14.4, iOS 17.4, *) {
|
||||||
|
#expect(throws: CryptoKitError.authenticationFailure) {
|
||||||
|
try bobSession2.decrypt(ciphertext1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
#expect(throws: (any Error).self) {
|
||||||
|
try bobSession2.decrypt(ciphertext1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// But should work with correct session
|
// But should work with correct session
|
||||||
let decrypted = try bobSession1.decrypt(ciphertext1)
|
let decrypted = try bobSession1.decrypt(ciphertext1)
|
||||||
XCTAssertEqual(decrypted, plaintext)
|
#expect(decrypted == plaintext)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Session Recovery Tests
|
// MARK: - Session Recovery Tests
|
||||||
|
|
||||||
func testPeerRestartDetection() throws {
|
@Test func peerRestartDetection() throws {
|
||||||
// Establish initial sessions
|
// Establish initial sessions
|
||||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
||||||
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
||||||
@@ -295,38 +298,38 @@ final class NoiseProtocolTests: XCTestCase {
|
|||||||
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||||
|
|
||||||
// Exchange some messages to establish nonce state
|
// Exchange some messages to establish nonce state
|
||||||
let message1 = try aliceManager.encrypt("Hello".data(using: .utf8)!, for: TestConstants.testPeerID2)
|
let message1 = try aliceManager.encrypt("Hello".data(using: .utf8)!, for: alicePeerID)
|
||||||
_ = try bobManager.decrypt(message1, from: TestConstants.testPeerID1)
|
_ = try bobManager.decrypt(message1, from: bobPeerID)
|
||||||
|
|
||||||
let message2 = try bobManager.encrypt("World".data(using: .utf8)!, for: TestConstants.testPeerID1)
|
let message2 = try bobManager.encrypt("World".data(using: .utf8)!, for: bobPeerID)
|
||||||
_ = try aliceManager.decrypt(message2, from: TestConstants.testPeerID2)
|
_ = try aliceManager.decrypt(message2, from: alicePeerID)
|
||||||
|
|
||||||
// Simulate Bob restart by creating new manager with same key
|
// Simulate Bob restart by creating new manager with same key
|
||||||
let bobManagerRestarted = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
let bobManagerRestarted = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
||||||
|
|
||||||
// Bob initiates new handshake after restart
|
// Bob initiates new handshake after restart
|
||||||
let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: TestConstants.testPeerID1)
|
let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: bobPeerID)
|
||||||
|
|
||||||
// Alice should accept the new handshake (clearing old session)
|
// Alice should accept the new handshake (clearing old session)
|
||||||
let newHandshake2 = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: newHandshake1)
|
let newHandshake2 = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake1)
|
||||||
XCTAssertNotNil(newHandshake2)
|
#expect(newHandshake2 != nil)
|
||||||
|
|
||||||
// Complete the new handshake
|
// Complete the new handshake
|
||||||
let newHandshake3 = try bobManagerRestarted.handleIncomingHandshake(from: TestConstants.testPeerID1, message: newHandshake2!)
|
let newHandshake3 = try bobManagerRestarted.handleIncomingHandshake(from: bobPeerID, message: newHandshake2!)
|
||||||
XCTAssertNotNil(newHandshake3)
|
#expect(newHandshake3 != nil)
|
||||||
_ = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: newHandshake3!)
|
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake3!)
|
||||||
|
|
||||||
// Should be able to exchange messages with new sessions
|
// Should be able to exchange messages with new sessions
|
||||||
let testMessage = "After restart".data(using: .utf8)!
|
let testMessage = "After restart".data(using: .utf8)!
|
||||||
let encrypted = try bobManagerRestarted.encrypt(testMessage, for: TestConstants.testPeerID1)
|
let encrypted = try bobManagerRestarted.encrypt(testMessage, for: bobPeerID)
|
||||||
let decrypted = try aliceManager.decrypt(encrypted, from: TestConstants.testPeerID2)
|
let decrypted = try aliceManager.decrypt(encrypted, from: alicePeerID)
|
||||||
XCTAssertEqual(decrypted, testMessage)
|
#expect(decrypted == testMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testNonceDesynchronizationRecovery() throws {
|
@Test func nonceDesynchronizationRecovery() throws {
|
||||||
// Create two sessions
|
// Create two sessions
|
||||||
aliceSession = NoiseSession(peerID: TestConstants.testPeerID2, role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
|
let aliceSession = NoiseSession(peerID: alicePeerID, role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
|
||||||
bobSession = NoiseSession(peerID: TestConstants.testPeerID1, role: .responder, keychain: mockKeychain, localStaticKey: bobKey)
|
let bobSession = NoiseSession(peerID: bobPeerID, role: .responder, keychain: mockKeychain, localStaticKey: bobKey)
|
||||||
|
|
||||||
// Establish sessions
|
// Establish sessions
|
||||||
try performHandshake(initiator: aliceSession, responder: bobSession)
|
try performHandshake(initiator: aliceSession, responder: bobSession)
|
||||||
@@ -344,10 +347,12 @@ final class NoiseProtocolTests: XCTestCase {
|
|||||||
|
|
||||||
// With per-packet nonce carried, decryption should not throw here
|
// With per-packet nonce carried, decryption should not throw here
|
||||||
let desyncMessage = try aliceSession.encrypt("This now succeeds".data(using: .utf8)!)
|
let desyncMessage = try aliceSession.encrypt("This now succeeds".data(using: .utf8)!)
|
||||||
XCTAssertNoThrow(try bobSession.decrypt(desyncMessage))
|
#expect(throws: Never.self) {
|
||||||
|
try bobSession.decrypt(desyncMessage)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testConcurrentEncryption() throws {
|
@Test func concurrentEncryption() async throws {
|
||||||
// Test thread safety of encryption operations
|
// Test thread safety of encryption operations
|
||||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
||||||
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
||||||
@@ -355,14 +360,13 @@ final class NoiseProtocolTests: XCTestCase {
|
|||||||
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||||
|
|
||||||
let messageCount = 100
|
let messageCount = 100
|
||||||
let expectation = XCTestExpectation(description: "All messages encrypted and decrypted")
|
|
||||||
expectation.expectedFulfillmentCount = messageCount
|
|
||||||
|
|
||||||
|
try await confirmation("All messages encrypted and decrypted", expectedCount: messageCount) { completion in
|
||||||
var encryptedMessages: [Int: Data] = [:]
|
var encryptedMessages: [Int: Data] = [:]
|
||||||
// Encrypt messages sequentially to avoid nonce races in manager
|
// Encrypt messages sequentially to avoid nonce races in manager
|
||||||
for i in 0..<messageCount {
|
for i in 0..<messageCount {
|
||||||
let plaintext = "Concurrent message \(i)".data(using: .utf8)!
|
let plaintext = "Concurrent message \(i)".data(using: .utf8)!
|
||||||
let encrypted = try aliceManager.encrypt(plaintext, for: TestConstants.testPeerID2)
|
let encrypted = try aliceManager.encrypt(plaintext, for: alicePeerID)
|
||||||
encryptedMessages[i] = encrypted
|
encryptedMessages[i] = encrypted
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,22 +374,21 @@ final class NoiseProtocolTests: XCTestCase {
|
|||||||
for i in 0..<messageCount {
|
for i in 0..<messageCount {
|
||||||
do {
|
do {
|
||||||
guard let encrypted = encryptedMessages[i] else {
|
guard let encrypted = encryptedMessages[i] else {
|
||||||
XCTFail("Missing encrypted message \(i)")
|
Issue.record("Missing encrypted message \(i)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let decrypted = try bobManager.decrypt(encrypted, from: TestConstants.testPeerID1)
|
let decrypted = try bobManager.decrypt(encrypted, from: bobPeerID)
|
||||||
let expected = "Concurrent message \(i)".data(using: .utf8)!
|
let expected = "Concurrent message \(i)".data(using: .utf8)!
|
||||||
XCTAssertEqual(decrypted, expected)
|
#expect(decrypted == expected)
|
||||||
expectation.fulfill()
|
completion()
|
||||||
} catch {
|
} catch {
|
||||||
XCTFail("Decryption failed for message \(i): \(error)")
|
Issue.record("Decryption failed for message \(i): \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
wait(for: [expectation], timeout: 10.0)
|
@Test func sessionStaleDetection() throws {
|
||||||
}
|
|
||||||
|
|
||||||
func testSessionStaleDetection() throws {
|
|
||||||
// Test that sessions are properly marked as stale
|
// Test that sessions are properly marked as stale
|
||||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
||||||
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
||||||
@@ -396,10 +399,10 @@ final class NoiseProtocolTests: XCTestCase {
|
|||||||
let sessions = aliceManager.getSessionsNeedingRekey()
|
let sessions = aliceManager.getSessionsNeedingRekey()
|
||||||
|
|
||||||
// New session should not need rekey
|
// New session should not need rekey
|
||||||
XCTAssertTrue(sessions.isEmpty || sessions.allSatisfy { !$0.needsRekey })
|
#expect(sessions.isEmpty || sessions.allSatisfy { !$0.needsRekey })
|
||||||
}
|
}
|
||||||
|
|
||||||
func testHandshakeAfterDecryptionFailure() throws {
|
@Test func handshakeAfterDecryptionFailure() throws {
|
||||||
// Test that handshake is properly initiated after decryption failure
|
// Test that handshake is properly initiated after decryption failure
|
||||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
||||||
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
||||||
@@ -408,17 +411,25 @@ final class NoiseProtocolTests: XCTestCase {
|
|||||||
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||||
|
|
||||||
// Create a corrupted message
|
// Create a corrupted message
|
||||||
var encrypted = try aliceManager.encrypt("Test".data(using: .utf8)!, for: TestConstants.testPeerID2)
|
var encrypted = try aliceManager.encrypt("Test".data(using: .utf8)!, for: alicePeerID)
|
||||||
encrypted[10] ^= 0xFF // Corrupt the data
|
encrypted[10] ^= 0xFF // Corrupt the data
|
||||||
|
|
||||||
// Decryption should fail
|
// Decryption should fail
|
||||||
XCTAssertThrowsError(try bobManager.decrypt(encrypted, from: TestConstants.testPeerID1))
|
if #available(macOS 14.4, iOS 17.4, *) {
|
||||||
|
#expect(throws: CryptoKitError.authenticationFailure) {
|
||||||
// Bob should still have the session (it's not removed on single failure)
|
try bobManager.decrypt(encrypted, from: bobPeerID)
|
||||||
XCTAssertNotNil(bobManager.getSession(for: TestConstants.testPeerID1))
|
}
|
||||||
|
} else {
|
||||||
|
#expect(throws: (any Error).self) {
|
||||||
|
try bobManager.decrypt(encrypted, from: bobPeerID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testHandshakeAlwaysAcceptedWithExistingSession() throws {
|
// Bob should still have the session (it's not removed on single failure)
|
||||||
|
#expect(bobManager.getSession(for: bobPeerID) != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func handshakeAlwaysAcceptedWithExistingSession() throws {
|
||||||
// Test that handshake is always accepted even with existing valid session
|
// Test that handshake is always accepted even with existing valid session
|
||||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
||||||
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
||||||
@@ -427,38 +438,38 @@ final class NoiseProtocolTests: XCTestCase {
|
|||||||
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||||
|
|
||||||
// Verify sessions are established
|
// Verify sessions are established
|
||||||
XCTAssertTrue(aliceManager.getSession(for: TestConstants.testPeerID2)?.isEstablished() ?? false)
|
#expect(aliceManager.getSession(for: alicePeerID)?.isEstablished() == true)
|
||||||
XCTAssertTrue(bobManager.getSession(for: TestConstants.testPeerID1)?.isEstablished() ?? false)
|
#expect(bobManager.getSession(for: bobPeerID)?.isEstablished() == true)
|
||||||
|
|
||||||
// Exchange messages to verify sessions work
|
// Exchange messages to verify sessions work
|
||||||
let testMessage = "Session works".data(using: .utf8)!
|
let testMessage = "Session works".data(using: .utf8)!
|
||||||
let encrypted = try aliceManager.encrypt(testMessage, for: TestConstants.testPeerID2)
|
let encrypted = try aliceManager.encrypt(testMessage, for: alicePeerID)
|
||||||
let decrypted = try bobManager.decrypt(encrypted, from: TestConstants.testPeerID1)
|
let decrypted = try bobManager.decrypt(encrypted, from: bobPeerID)
|
||||||
XCTAssertEqual(decrypted, testMessage)
|
#expect(decrypted == testMessage)
|
||||||
|
|
||||||
// Alice clears her session (simulating decryption failure)
|
// Alice clears her session (simulating decryption failure)
|
||||||
aliceManager.removeSession(for: TestConstants.testPeerID2)
|
aliceManager.removeSession(for: alicePeerID)
|
||||||
|
|
||||||
// Alice initiates new handshake despite Bob having valid session
|
// Alice initiates new handshake despite Bob having valid session
|
||||||
let newHandshake1 = try aliceManager.initiateHandshake(with: TestConstants.testPeerID2)
|
let newHandshake1 = try aliceManager.initiateHandshake(with: alicePeerID)
|
||||||
|
|
||||||
// Bob should accept the new handshake even though he has a valid session
|
// Bob should accept the new handshake even though he has a valid session
|
||||||
let newHandshake2 = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: newHandshake1)
|
let newHandshake2 = try bobManager.handleIncomingHandshake(from: bobPeerID, message: newHandshake1)
|
||||||
XCTAssertNotNil(newHandshake2, "Bob should accept handshake despite having valid session")
|
#expect(newHandshake2 != nil, "Bob should accept handshake despite having valid session")
|
||||||
|
|
||||||
// Complete the handshake
|
// Complete the handshake
|
||||||
let newHandshake3 = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: newHandshake2!)
|
let newHandshake3 = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake2!)
|
||||||
XCTAssertNotNil(newHandshake3)
|
#expect(newHandshake3 != nil)
|
||||||
_ = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: newHandshake3!)
|
_ = try bobManager.handleIncomingHandshake(from: bobPeerID, message: newHandshake3!)
|
||||||
|
|
||||||
// Verify new sessions work
|
// Verify new sessions work
|
||||||
let testMessage2 = "New session works".data(using: .utf8)!
|
let testMessage2 = "New session works".data(using: .utf8)!
|
||||||
let encrypted2 = try aliceManager.encrypt(testMessage2, for: TestConstants.testPeerID2)
|
let encrypted2 = try aliceManager.encrypt(testMessage2, for: alicePeerID)
|
||||||
let decrypted2 = try bobManager.decrypt(encrypted2, from: TestConstants.testPeerID1)
|
let decrypted2 = try bobManager.decrypt(encrypted2, from: bobPeerID)
|
||||||
XCTAssertEqual(decrypted2, testMessage2)
|
#expect(decrypted2 == testMessage2)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testNonceDesynchronizationCausesRehandshake() throws {
|
@Test func nonceDesynchronizationCausesRehandshake() throws {
|
||||||
// Test that nonce desynchronization leads to proper re-handshake
|
// Test that nonce desynchronization leads to proper re-handshake
|
||||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
||||||
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
||||||
@@ -468,89 +479,43 @@ final class NoiseProtocolTests: XCTestCase {
|
|||||||
|
|
||||||
// Exchange messages normally
|
// Exchange messages normally
|
||||||
for i in 0..<5 {
|
for i in 0..<5 {
|
||||||
let msg = try aliceManager.encrypt("Message \(i)".data(using: .utf8)!, for: TestConstants.testPeerID2)
|
let msg = try aliceManager.encrypt("Message \(i)".data(using: .utf8)!, for: alicePeerID)
|
||||||
_ = try bobManager.decrypt(msg, from: TestConstants.testPeerID1)
|
_ = try bobManager.decrypt(msg, from: bobPeerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simulate desynchronization - Alice sends messages that Bob doesn't receive
|
// Simulate desynchronization - Alice sends messages that Bob doesn't receive
|
||||||
for i in 0..<3 {
|
for i in 0..<3 {
|
||||||
_ = try aliceManager.encrypt("Lost message \(i)".data(using: .utf8)!, for: TestConstants.testPeerID2)
|
_ = try aliceManager.encrypt("Lost message \(i)".data(using: .utf8)!, for: alicePeerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// With nonce carried in packet, decryption should not throw here
|
// With nonce carried in packet, decryption should not throw here
|
||||||
let desyncMessage = try aliceManager.encrypt("This now succeeds".data(using: .utf8)!, for: TestConstants.testPeerID2)
|
let desyncMessage = try aliceManager.encrypt("This now succeeds".data(using: .utf8)!, for: alicePeerID)
|
||||||
XCTAssertNoThrow(try bobManager.decrypt(desyncMessage, from: TestConstants.testPeerID1))
|
#expect(throws: Never.self) {
|
||||||
|
try bobManager.decrypt(desyncMessage, from: bobPeerID)
|
||||||
|
}
|
||||||
|
|
||||||
// Bob clears session and initiates new handshake
|
// Bob clears session and initiates new handshake
|
||||||
bobManager.removeSession(for: TestConstants.testPeerID1)
|
bobManager.removeSession(for: bobPeerID)
|
||||||
let rehandshake1 = try bobManager.initiateHandshake(with: TestConstants.testPeerID1)
|
let rehandshake1 = try bobManager.initiateHandshake(with: bobPeerID)
|
||||||
|
|
||||||
// Alice should accept despite having a "valid" (but desynced) session
|
// Alice should accept despite having a "valid" (but desynced) session
|
||||||
let rehandshake2 = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: rehandshake1)
|
let rehandshake2 = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: rehandshake1)
|
||||||
XCTAssertNotNil(rehandshake2, "Alice should accept handshake to fix desync")
|
#expect(rehandshake2 != nil, "Alice should accept handshake to fix desync")
|
||||||
|
|
||||||
// Complete handshake
|
// Complete handshake
|
||||||
let rehandshake3 = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: rehandshake2!)
|
let rehandshake3 = try bobManager.handleIncomingHandshake(from: bobPeerID, message: rehandshake2!)
|
||||||
XCTAssertNotNil(rehandshake3)
|
#expect(rehandshake3 != nil)
|
||||||
_ = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: rehandshake3!)
|
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: rehandshake3!)
|
||||||
|
|
||||||
// Verify communication works again
|
// Verify communication works again
|
||||||
let testResynced = "Resynced".data(using: .utf8)!
|
let testResynced = "Resynced".data(using: .utf8)!
|
||||||
let encryptedResync = try aliceManager.encrypt(testResynced, for: TestConstants.testPeerID2)
|
let encryptedResync = try aliceManager.encrypt(testResynced, for: alicePeerID)
|
||||||
let decryptedResync = try bobManager.decrypt(encryptedResync, from: TestConstants.testPeerID1)
|
let decryptedResync = try bobManager.decrypt(encryptedResync, from: bobPeerID)
|
||||||
XCTAssertEqual(decryptedResync, testResynced)
|
#expect(decryptedResync == testResynced)
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Performance Tests
|
|
||||||
|
|
||||||
func testHandshakePerformance() throws {
|
|
||||||
measure {
|
|
||||||
do {
|
|
||||||
let alice = NoiseSession(peerID: "bob", role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
|
|
||||||
let bob = NoiseSession(peerID: "alice", role: .responder, keychain: mockKeychain, localStaticKey: bobKey)
|
|
||||||
try performHandshake(initiator: alice, responder: bob)
|
|
||||||
} catch {
|
|
||||||
XCTFail("Handshake failed: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func testEncryptionPerformance() throws {
|
|
||||||
try establishSessions()
|
|
||||||
let message = TestHelpers.generateRandomData(length: 1024)
|
|
||||||
|
|
||||||
measure {
|
|
||||||
do {
|
|
||||||
for _ in 0..<100 {
|
|
||||||
let ciphertext = try aliceSession.encrypt(message)
|
|
||||||
_ = try bobSession.decrypt(ciphertext)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
XCTFail("Encryption/decryption failed: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helper Methods
|
// MARK: - Helper Methods
|
||||||
|
|
||||||
private func establishSessions() throws {
|
|
||||||
aliceSession = NoiseSession(
|
|
||||||
peerID: TestConstants.testPeerID2,
|
|
||||||
role: .initiator,
|
|
||||||
keychain: mockKeychain,
|
|
||||||
localStaticKey: aliceKey
|
|
||||||
)
|
|
||||||
|
|
||||||
bobSession = NoiseSession(
|
|
||||||
peerID: TestConstants.testPeerID1,
|
|
||||||
role: .responder,
|
|
||||||
keychain: mockKeychain,
|
|
||||||
localStaticKey: bobKey
|
|
||||||
)
|
|
||||||
|
|
||||||
try performHandshake(initiator: aliceSession, responder: bobSession)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func performHandshake(initiator: NoiseSession, responder: NoiseSession) throws {
|
private func performHandshake(initiator: NoiseSession, responder: NoiseSession) throws {
|
||||||
let msg1 = try initiator.startHandshake()
|
let msg1 = try initiator.startHandshake()
|
||||||
let msg2 = try responder.processHandshakeMessage(msg1)!
|
let msg2 = try responder.processHandshakeMessage(msg1)!
|
||||||
@@ -559,9 +524,9 @@ final class NoiseProtocolTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func establishManagerSessions(aliceManager: NoiseSessionManager, bobManager: NoiseSessionManager) throws {
|
private func establishManagerSessions(aliceManager: NoiseSessionManager, bobManager: NoiseSessionManager) throws {
|
||||||
let msg1 = try aliceManager.initiateHandshake(with: TestConstants.testPeerID2)
|
let msg1 = try aliceManager.initiateHandshake(with: alicePeerID)
|
||||||
let msg2 = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: msg1)!
|
let msg2 = try bobManager.handleIncomingHandshake(from: bobPeerID, message: msg1)!
|
||||||
let msg3 = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: msg2)!
|
let msg3 = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: msg2)!
|
||||||
_ = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: msg3)
|
_ = try bobManager.handleIncomingHandshake(from: bobPeerID, message: msg3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,20 +5,20 @@
|
|||||||
// Tests for NIP-17 gift-wrapped private messages
|
// Tests for NIP-17 gift-wrapped private messages
|
||||||
//
|
//
|
||||||
|
|
||||||
import XCTest
|
import Testing
|
||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
final class NostrProtocolTests: XCTestCase {
|
struct NostrProtocolTests {
|
||||||
|
|
||||||
func testNIP17MessageRoundTrip() throws {
|
@Test func nip17MessageRoundTrip() throws {
|
||||||
// Create sender and recipient identities
|
// Create sender and recipient identities
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
print("Sender pubkey: \(sender.publicKeyHex)")
|
print("Sender pubkey: \(sender.publicKeyHex)")
|
||||||
print("Recipient pubkey: \(recipient.publicKeyHex)")
|
print("Recipient pubkey: \(recipient.publicKeyHex)")
|
||||||
#endif
|
|
||||||
|
|
||||||
// Create a test message
|
// Create a test message
|
||||||
let originalContent = "Hello from NIP-17 test!"
|
let originalContent = "Hello from NIP-17 test!"
|
||||||
@@ -30,10 +30,8 @@ final class NostrProtocolTests: XCTestCase {
|
|||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
print("Gift wrap created with ID: \(giftWrap.id)")
|
print("Gift wrap created with ID: \(giftWrap.id)")
|
||||||
print("Gift wrap pubkey: \(giftWrap.pubkey)")
|
print("Gift wrap pubkey: \(giftWrap.pubkey)")
|
||||||
#endif
|
|
||||||
|
|
||||||
// Decrypt the gift wrap
|
// Decrypt the gift wrap
|
||||||
let (decryptedContent, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateMessage(
|
let (decryptedContent, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateMessage(
|
||||||
@@ -42,20 +40,18 @@ final class NostrProtocolTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Verify
|
// Verify
|
||||||
XCTAssertEqual(decryptedContent, originalContent)
|
#expect(decryptedContent == originalContent)
|
||||||
XCTAssertEqual(senderPubkey, sender.publicKeyHex)
|
#expect(senderPubkey == sender.publicKeyHex)
|
||||||
|
|
||||||
// Verify timestamp is reasonable (within last minute)
|
// Verify timestamp is reasonable (within last minute)
|
||||||
let messageDate = Date(timeIntervalSince1970: TimeInterval(timestamp))
|
let messageDate = Date(timeIntervalSince1970: TimeInterval(timestamp))
|
||||||
let timeDiff = abs(messageDate.timeIntervalSinceNow)
|
let timeDiff = abs(messageDate.timeIntervalSinceNow)
|
||||||
XCTAssertLessThan(timeDiff, 60, "Message timestamp should be recent")
|
#expect(timeDiff < 60, "Message timestamp should be recent")
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
print("✅ Successfully decrypted message: '\(decryptedContent)' from \(senderPubkey) at \(messageDate)")
|
print("✅ Successfully decrypted message: '\(decryptedContent)' from \(senderPubkey) at \(messageDate)")
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testGiftWrapUsesUniqueEphemeralKeys() throws {
|
@Test func giftWrapUsesUniqueEphemeralKeys() throws {
|
||||||
// Create identities
|
// Create identities
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
@@ -74,11 +70,10 @@ final class NostrProtocolTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Gift wrap pubkeys should be different (unique ephemeral keys)
|
// Gift wrap pubkeys should be different (unique ephemeral keys)
|
||||||
XCTAssertNotEqual(message1.pubkey, message2.pubkey)
|
#expect(message1.pubkey != message2.pubkey)
|
||||||
#if DEBUG
|
|
||||||
print("Message 1 gift wrap pubkey: \(message1.pubkey)")
|
print("Message 1 gift wrap pubkey: \(message1.pubkey)")
|
||||||
print("Message 2 gift wrap pubkey: \(message2.pubkey)")
|
print("Message 2 gift wrap pubkey: \(message2.pubkey)")
|
||||||
#endif
|
|
||||||
|
|
||||||
// Both should decrypt successfully
|
// Both should decrypt successfully
|
||||||
let (content1, _, _) = try NostrProtocol.decryptPrivateMessage(
|
let (content1, _, _) = try NostrProtocol.decryptPrivateMessage(
|
||||||
@@ -90,11 +85,11 @@ final class NostrProtocolTests: XCTestCase {
|
|||||||
recipientIdentity: recipient
|
recipientIdentity: recipient
|
||||||
)
|
)
|
||||||
|
|
||||||
XCTAssertEqual(content1, "Message 1")
|
#expect(content1 == "Message 1")
|
||||||
XCTAssertEqual(content2, "Message 2")
|
#expect(content2 == "Message 2")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDecryptionFailsWithWrongRecipient() throws {
|
@Test func decryptionFailsWithWrongRecipient() throws {
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
let wrongRecipient = try NostrIdentity.generate()
|
let wrongRecipient = try NostrIdentity.generate()
|
||||||
@@ -107,13 +102,20 @@ final class NostrProtocolTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Try to decrypt with wrong recipient
|
// Try to decrypt with wrong recipient
|
||||||
XCTAssertThrowsError(try NostrProtocol.decryptPrivateMessage(
|
if #available(macOS 14.4, iOS 17.4, *) {
|
||||||
|
#expect(throws: CryptoKitError.authenticationFailure) {
|
||||||
|
try NostrProtocol.decryptPrivateMessage(
|
||||||
giftWrap: giftWrap,
|
giftWrap: giftWrap,
|
||||||
recipientIdentity: wrongRecipient
|
recipientIdentity: wrongRecipient
|
||||||
)) { error in
|
)
|
||||||
#if DEBUG
|
}
|
||||||
print("Expected error when decrypting with wrong key: \(error)")
|
} else {
|
||||||
#endif
|
#expect(throws: (any Error).self) {
|
||||||
|
try NostrProtocol.decryptPrivateMessage(
|
||||||
|
giftWrap: giftWrap,
|
||||||
|
recipientIdentity: wrongRecipient
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,10 +127,11 @@ final class NostrProtocolTests: XCTestCase {
|
|||||||
// Build a DELIVERED ack embedded payload (geohash-style, no recipient peer ID)
|
// Build a DELIVERED ack embedded payload (geohash-style, no recipient peer ID)
|
||||||
let messageID = "TEST-MSG-DELIVERED-1"
|
let messageID = "TEST-MSG-DELIVERED-1"
|
||||||
let senderPeerID = "0123456789abcdef" // 8-byte hex peer ID
|
let senderPeerID = "0123456789abcdef" // 8-byte hex peer ID
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else {
|
|
||||||
XCTFail("Failed to embed delivered ack")
|
let embedded = try #require(
|
||||||
return
|
NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID),
|
||||||
}
|
"Failed to embed delivered ack"
|
||||||
|
)
|
||||||
|
|
||||||
// Create NIP-17 gift wrap to recipient (uses NIP-44 v2 internally)
|
// Create NIP-17 gift wrap to recipient (uses NIP-44 v2 internally)
|
||||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||||
@@ -138,7 +141,7 @@ final class NostrProtocolTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Ensure v2 format was used for ciphertext
|
// Ensure v2 format was used for ciphertext
|
||||||
XCTAssertTrue(giftWrap.content.hasPrefix("v2:"))
|
#expect(giftWrap.content.hasPrefix("v2:"))
|
||||||
|
|
||||||
// Decrypt as recipient
|
// Decrypt as recipient
|
||||||
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
|
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
|
||||||
@@ -147,39 +150,37 @@ final class NostrProtocolTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Verify sender is correct
|
// Verify sender is correct
|
||||||
XCTAssertEqual(senderPubkey, sender.publicKeyHex)
|
#expect(senderPubkey == sender.publicKeyHex)
|
||||||
|
|
||||||
// Parse BitChat payload
|
// Parse BitChat payload
|
||||||
XCTAssertTrue(content.hasPrefix("bitchat1:"))
|
#expect(content.hasPrefix("bitchat1:"))
|
||||||
let base64url = String(content.dropFirst("bitchat1:".count))
|
let base64url = String(content.dropFirst("bitchat1:".count))
|
||||||
guard let packetData = Self.base64URLDecode(base64url),
|
let packetData = try #require(Self.base64URLDecode(base64url))
|
||||||
let packet = BitchatPacket.from(packetData) else {
|
let packet = try #require(BitchatPacket.from(packetData), "Failed to decode bitchat packet")
|
||||||
return XCTFail("Failed to decode bitchat packet")
|
|
||||||
}
|
#expect(packet.type == MessageType.noiseEncrypted.rawValue)
|
||||||
XCTAssertEqual(packet.type, MessageType.noiseEncrypted.rawValue)
|
let payload = try #require(NoisePayload.decode(packet.payload), "Failed to decode NoisePayload")
|
||||||
guard let payload = NoisePayload.decode(packet.payload) else {
|
|
||||||
return XCTFail("Failed to decode NoisePayload")
|
|
||||||
}
|
|
||||||
switch payload.type {
|
switch payload.type {
|
||||||
case .delivered:
|
case .delivered:
|
||||||
let mid = String(data: payload.data, encoding: .utf8)
|
let mid = String(data: payload.data, encoding: .utf8)
|
||||||
XCTAssertEqual(mid, messageID)
|
#expect(mid == messageID)
|
||||||
default:
|
default:
|
||||||
XCTFail("Unexpected payload type: \(payload.type)")
|
Issue.record("Unexpected payload type: \(payload.type)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testAckRoundTripNIP44V2_ReadReceipt() throws {
|
@Test func ackRoundTripNIP44V2_ReadReceipt() throws {
|
||||||
// Identities
|
// Identities
|
||||||
let sender = try NostrIdentity.generate()
|
let sender = try NostrIdentity.generate()
|
||||||
let recipient = try NostrIdentity.generate()
|
let recipient = try NostrIdentity.generate()
|
||||||
|
|
||||||
let messageID = "TEST-MSG-READ-1"
|
let messageID = "TEST-MSG-READ-1"
|
||||||
let senderPeerID = "fedcba9876543210" // 8-byte hex peer ID
|
let senderPeerID = "fedcba9876543210" // 8-byte hex peer ID
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else {
|
let embedded = try #require(
|
||||||
XCTFail("Failed to embed read ack")
|
NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID),
|
||||||
return
|
"Failed to embed read ack"
|
||||||
}
|
)
|
||||||
|
|
||||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||||
content: embedded,
|
content: embedded,
|
||||||
@@ -187,30 +188,28 @@ final class NostrProtocolTests: XCTestCase {
|
|||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
XCTAssertTrue(giftWrap.content.hasPrefix("v2:"))
|
#expect(giftWrap.content.hasPrefix("v2:"))
|
||||||
|
|
||||||
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
|
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
|
||||||
giftWrap: giftWrap,
|
giftWrap: giftWrap,
|
||||||
recipientIdentity: recipient
|
recipientIdentity: recipient
|
||||||
)
|
)
|
||||||
XCTAssertEqual(senderPubkey, sender.publicKeyHex)
|
#expect(senderPubkey == sender.publicKeyHex)
|
||||||
|
|
||||||
XCTAssertTrue(content.hasPrefix("bitchat1:"))
|
#expect(content.hasPrefix("bitchat1:"))
|
||||||
let base64url = String(content.dropFirst("bitchat1:".count))
|
let base64url = String(content.dropFirst("bitchat1:".count))
|
||||||
guard let packetData = Self.base64URLDecode(base64url),
|
let packetData = try #require(Self.base64URLDecode(base64url))
|
||||||
let packet = BitchatPacket.from(packetData) else {
|
let packet = try #require(BitchatPacket.from(packetData), "Failed to decode bitchat packet")
|
||||||
return XCTFail("Failed to decode bitchat packet")
|
|
||||||
}
|
#expect(packet.type == MessageType.noiseEncrypted.rawValue)
|
||||||
XCTAssertEqual(packet.type, MessageType.noiseEncrypted.rawValue)
|
let payload = try #require(NoisePayload.decode(packet.payload), "Failed to decode NoisePayload")
|
||||||
guard let payload = NoisePayload.decode(packet.payload) else {
|
|
||||||
return XCTFail("Failed to decode NoisePayload")
|
|
||||||
}
|
|
||||||
switch payload.type {
|
switch payload.type {
|
||||||
case .readReceipt:
|
case .readReceipt:
|
||||||
let mid = String(data: payload.data, encoding: .utf8)
|
let mid = String(data: payload.data, encoding: .utf8)
|
||||||
XCTAssertEqual(mid, messageID)
|
#expect(mid == messageID)
|
||||||
default:
|
default:
|
||||||
XCTFail("Unexpected payload type: \(payload.type)")
|
Issue.record("Unexpected payload type: \(payload.type)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import XCTest
|
import Testing
|
||||||
|
import Foundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
final class NotificationStreamAssemblerTests: XCTestCase {
|
struct NotificationStreamAssemblerTests {
|
||||||
private func makePacket(timestamp: UInt64 = 0x0102030405) -> BitchatPacket {
|
private func makePacket(timestamp: UInt64 = 0x0102030405) -> BitchatPacket {
|
||||||
let sender = Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77])
|
let sender = Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77])
|
||||||
return BitchatPacket(
|
return BitchatPacket(
|
||||||
@@ -15,60 +16,51 @@ final class NotificationStreamAssemblerTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testAssemblesSingleFrameAcrossChunks() {
|
@Test func assemblesSingleFrameAcrossChunks() throws {
|
||||||
var assembler = NotificationStreamAssembler()
|
var assembler = NotificationStreamAssembler()
|
||||||
let packet = makePacket()
|
let packet = makePacket()
|
||||||
guard let frame = packet.toBinaryData(padding: false) else {
|
let frame = try #require(packet.toBinaryData(padding: false), "Failed to encode packet")
|
||||||
return XCTFail("Failed to encode packet")
|
|
||||||
}
|
#expect(BinaryProtocol.decode(frame) != nil)
|
||||||
XCTAssertNotNil(BinaryProtocol.decode(frame))
|
|
||||||
let payloadLen = (Int(frame[12]) << 8) | Int(frame[13])
|
let payloadLen = (Int(frame[12]) << 8) | Int(frame[13])
|
||||||
XCTAssertEqual(payloadLen, packet.payload.count)
|
#expect(payloadLen == packet.payload.count)
|
||||||
|
|
||||||
let splitIndex = min(20, max(1, frame.count / 2))
|
let splitIndex = min(20, max(1, frame.count / 2))
|
||||||
let first = frame.prefix(splitIndex)
|
let first = frame.prefix(splitIndex)
|
||||||
let second = frame.suffix(from: splitIndex)
|
let second = frame.suffix(from: splitIndex)
|
||||||
XCTAssertEqual(first.count + second.count, frame.count)
|
#expect(first.count + second.count == frame.count)
|
||||||
|
|
||||||
var result = assembler.append(first)
|
var result = assembler.append(first)
|
||||||
XCTAssertTrue(result.frames.isEmpty)
|
#expect(result.frames.isEmpty)
|
||||||
XCTAssertTrue(result.droppedPrefixes.isEmpty)
|
#expect(result.droppedPrefixes.isEmpty)
|
||||||
XCTAssertFalse(result.reset)
|
#expect(!result.reset)
|
||||||
|
|
||||||
result = assembler.append(second)
|
result = assembler.append(second)
|
||||||
XCTAssertEqual(result.frames.count, 1)
|
#expect(result.frames.count == 1)
|
||||||
XCTAssertTrue(result.droppedPrefixes.isEmpty)
|
#expect(result.droppedPrefixes.isEmpty)
|
||||||
XCTAssertFalse(result.reset)
|
#expect(!result.reset)
|
||||||
|
|
||||||
guard let frameData = result.frames.first else {
|
let frameData = try #require(result.frames.first, "Missing frame data")
|
||||||
return XCTFail("Missing frame data")
|
#expect(frameData.count == frame.count)
|
||||||
}
|
|
||||||
if frameData.count != frame.count {
|
let decoded = try #require(BinaryProtocol.decode(frameData), "Failed to decode frame")
|
||||||
XCTFail("Frame size mismatch: expected \(frame.count) got \(frameData.count)\nframe=\(Array(frame))\nassembled=\(Array(frameData))")
|
#expect(decoded.type == packet.type)
|
||||||
return
|
#expect(decoded.payload == packet.payload)
|
||||||
}
|
#expect(decoded.senderID == packet.senderID)
|
||||||
guard let decoded = BinaryProtocol.decode(frameData) else {
|
#expect(decoded.timestamp == packet.timestamp)
|
||||||
return XCTFail("Failed to decode frame")
|
|
||||||
}
|
|
||||||
XCTAssertEqual(decoded.type, packet.type)
|
|
||||||
XCTAssertEqual(decoded.payload, packet.payload)
|
|
||||||
XCTAssertEqual(decoded.senderID, packet.senderID)
|
|
||||||
XCTAssertEqual(decoded.timestamp, packet.timestamp)
|
|
||||||
|
|
||||||
var directAssembler = NotificationStreamAssembler()
|
var directAssembler = NotificationStreamAssembler()
|
||||||
let directResult = directAssembler.append(frame)
|
let directResult = directAssembler.append(frame)
|
||||||
XCTAssertEqual(directResult.frames.first?.count, frame.count)
|
#expect(directResult.frames.first?.count == frame.count)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testAssemblesMultipleFramesSequentially() {
|
@Test func assemblesMultipleFramesSequentially() throws {
|
||||||
var assembler = NotificationStreamAssembler()
|
var assembler = NotificationStreamAssembler()
|
||||||
let packet1 = makePacket(timestamp: 0xABC)
|
let packet1 = makePacket(timestamp: 0xABC)
|
||||||
let packet2 = makePacket(timestamp: 0xDEF)
|
let packet2 = makePacket(timestamp: 0xDEF)
|
||||||
|
|
||||||
guard let frame1 = packet1.toBinaryData(padding: false),
|
let frame1 = try #require(packet1.toBinaryData(padding: false), "Failed to encode packet")
|
||||||
let frame2 = packet2.toBinaryData(padding: false) else {
|
let frame2 = try #require(packet2.toBinaryData(padding: false), "Failed to encode packet")
|
||||||
return XCTFail("Failed to encode packets")
|
|
||||||
}
|
|
||||||
|
|
||||||
var combined = Data()
|
var combined = Data()
|
||||||
combined.append(frame1)
|
combined.append(frame1)
|
||||||
@@ -77,35 +69,30 @@ final class NotificationStreamAssemblerTests: XCTestCase {
|
|||||||
let secondChunk = combined.suffix(from: 20)
|
let secondChunk = combined.suffix(from: 20)
|
||||||
|
|
||||||
var result = assembler.append(firstChunk)
|
var result = assembler.append(firstChunk)
|
||||||
XCTAssertTrue(result.frames.isEmpty)
|
#expect(result.frames.isEmpty)
|
||||||
|
|
||||||
result = assembler.append(secondChunk)
|
result = assembler.append(secondChunk)
|
||||||
XCTAssertEqual(result.frames.count, 2)
|
#expect(result.frames.count == 2)
|
||||||
guard let decoded1 = BinaryProtocol.decode(result.frames[0]),
|
|
||||||
let decoded2 = BinaryProtocol.decode(result.frames[1]) else {
|
let decoded1 = try #require(BinaryProtocol.decode(result.frames[0]), "Failed to decode frame")
|
||||||
return XCTFail("Failed to decode frames")
|
let decoded2 = try #require(BinaryProtocol.decode(result.frames[1]), "Failed to decode frame")
|
||||||
}
|
#expect(decoded1.timestamp == packet1.timestamp)
|
||||||
XCTAssertEqual(decoded1.timestamp, packet1.timestamp)
|
#expect(decoded2.timestamp == packet2.timestamp)
|
||||||
XCTAssertEqual(decoded2.timestamp, packet2.timestamp)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDropsInvalidPrefixByte() {
|
@Test func dropsInvalidPrefixByte() throws {
|
||||||
var assembler = NotificationStreamAssembler()
|
var assembler = NotificationStreamAssembler()
|
||||||
let packet = makePacket(timestamp: 0xF00)
|
let packet = makePacket(timestamp: 0xF00)
|
||||||
guard let frame = packet.toBinaryData(padding: false) else {
|
let frame = try #require(packet.toBinaryData(padding: false), "Failed to encode packet")
|
||||||
return XCTFail("Failed to encode packet")
|
|
||||||
}
|
|
||||||
var noisyFrame = Data([0x00])
|
var noisyFrame = Data([0x00])
|
||||||
noisyFrame.append(frame)
|
noisyFrame.append(frame)
|
||||||
|
|
||||||
let result = assembler.append(noisyFrame)
|
let result = assembler.append(noisyFrame)
|
||||||
XCTAssertEqual(result.droppedPrefixes, [0x00])
|
#expect(result.droppedPrefixes == [0x00])
|
||||||
XCTAssertEqual(result.frames.count, 1)
|
#expect(result.frames.count == 1)
|
||||||
XCTAssertFalse(result.reset)
|
#expect(result.reset == false)
|
||||||
|
|
||||||
guard let decoded = BinaryProtocol.decode(result.frames[0]) else {
|
let decoded = try #require(BinaryProtocol.decode(result.frames[0]), "Failed to decode frame after drop")
|
||||||
return XCTFail("Failed to decode frame after drop")
|
#expect(decoded.timestamp == packet.timestamp)
|
||||||
}
|
|
||||||
XCTAssertEqual(decoded.timestamp, packet.timestamp)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,30 +5,29 @@
|
|||||||
// This is free and unencumbered software released into the public domain.
|
// This is free and unencumbered software released into the public domain.
|
||||||
//
|
//
|
||||||
|
|
||||||
import XCTest
|
import Testing
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
final class BinaryProtocolPaddingTests: XCTestCase {
|
struct BinaryProtocolPaddingTests {
|
||||||
func test_padded_vs_unpadded_length() throws {
|
@Test func padded_vs_unpadded_length() throws {
|
||||||
// Use helper to create a small test packet
|
// Use helper to create a small test packet
|
||||||
let packet = TestHelpers.createTestPacket()
|
let packet = TestHelpers.createTestPacket()
|
||||||
guard let padded = BinaryProtocol.encode(packet, padding: true) else { return XCTFail("encode padded") }
|
let padded = try #require(BinaryProtocol.encode(packet, padding: true), "encode padded")
|
||||||
guard let unpadded = BinaryProtocol.encode(packet, padding: false) else { return XCTFail("encode unpadded") }
|
let unpadded = try #require(BinaryProtocol.encode(packet, padding: false), "encode unpadded")
|
||||||
XCTAssertGreaterThanOrEqual(padded.count, unpadded.count, "Padded frame should be >= unpadded")
|
#expect(padded.count >= unpadded.count, "Padded frame should be >= unpadded")
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_decode_padded_and_unpadded_round_trip() throws {
|
@Test func decode_padded_and_unpadded_round_trip() throws {
|
||||||
let packet = TestHelpers.createTestPacket()
|
let packet = TestHelpers.createTestPacket()
|
||||||
// Padded
|
|
||||||
guard let padded = BinaryProtocol.encode(packet, padding: true) else { return XCTFail("encode padded") }
|
let padded = try #require(BinaryProtocol.encode(packet, padding: true), "encode padded")
|
||||||
guard let dec1 = BinaryProtocol.decode(padded) else { return XCTFail("decode padded") }
|
let dec1 = try #require(BinaryProtocol.decode(padded), "decode padded")
|
||||||
XCTAssertEqual(dec1.type, packet.type)
|
#expect(dec1.type == packet.type)
|
||||||
XCTAssertEqual(dec1.payload, packet.payload)
|
#expect(dec1.payload == packet.payload)
|
||||||
// Unpadded
|
|
||||||
guard let unpadded = BinaryProtocol.encode(packet, padding: false) else { return XCTFail("encode unpadded") }
|
let unpadded = try #require(BinaryProtocol.encode(packet, padding: false), "encode unpadded")
|
||||||
guard let dec2 = BinaryProtocol.decode(unpadded) else { return XCTFail("decode unpadded") }
|
let dec2 = try #require(BinaryProtocol.decode(unpadded), "decode unpadded")
|
||||||
XCTAssertEqual(dec2.type, packet.type)
|
#expect(dec2.type == packet.type)
|
||||||
XCTAssertEqual(dec2.payload, packet.payload)
|
#expect(dec2.payload == packet.payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,119 +6,89 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import XCTest
|
import Testing
|
||||||
|
import Foundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
final class BinaryProtocolTests: XCTestCase {
|
struct BinaryProtocolTests {
|
||||||
|
|
||||||
// MARK: - Basic Encoding/Decoding Tests
|
// MARK: - Basic Encoding/Decoding Tests
|
||||||
|
|
||||||
func testBasicPacketEncodingDecoding() throws {
|
@Test func basicPacketEncodingDecoding() throws {
|
||||||
let originalPacket = TestHelpers.createTestPacket()
|
let originalPacket = TestHelpers.createTestPacket()
|
||||||
|
|
||||||
// Encode
|
let encodedData = try #require(BinaryProtocol.encode(originalPacket), "Failed to encode packet")
|
||||||
guard let encodedData = BinaryProtocol.encode(originalPacket) else {
|
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode packet")
|
||||||
XCTFail("Failed to encode packet")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode
|
|
||||||
guard let decodedPacket = BinaryProtocol.decode(encodedData) else {
|
|
||||||
XCTFail("Failed to decode packet")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify
|
// Verify
|
||||||
XCTAssertEqual(decodedPacket.type, originalPacket.type)
|
#expect(decodedPacket.type == originalPacket.type)
|
||||||
XCTAssertEqual(decodedPacket.ttl, originalPacket.ttl)
|
#expect(decodedPacket.ttl == originalPacket.ttl)
|
||||||
XCTAssertEqual(decodedPacket.timestamp, originalPacket.timestamp)
|
#expect(decodedPacket.timestamp == originalPacket.timestamp)
|
||||||
XCTAssertEqual(decodedPacket.payload, originalPacket.payload)
|
#expect(decodedPacket.payload == originalPacket.payload)
|
||||||
|
|
||||||
// Sender ID should match (accounting for padding)
|
// Sender ID should match (accounting for padding)
|
||||||
let originalSenderID = originalPacket.senderID.prefix(BinaryProtocol.senderIDSize)
|
let originalSenderID = originalPacket.senderID.prefix(BinaryProtocol.senderIDSize)
|
||||||
let decodedSenderID = decodedPacket.senderID.trimmingNullBytes()
|
let decodedSenderID = decodedPacket.senderID.trimmingNullBytes()
|
||||||
XCTAssertEqual(decodedSenderID, originalSenderID)
|
#expect(decodedSenderID == originalSenderID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPacketWithRecipient() throws {
|
@Test func packetWithRecipient() throws {
|
||||||
let recipientID = TestConstants.testPeerID2
|
let recipientID = PeerID(str: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789")
|
||||||
let packet = TestHelpers.createTestPacket(recipientID: recipientID)
|
let packet = TestHelpers.createTestPacket(recipientID: recipientID)
|
||||||
|
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with recipient")
|
||||||
// Encode and decode
|
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode packet with recipient")
|
||||||
guard let encodedData = BinaryProtocol.encode(packet),
|
|
||||||
let decodedPacket = BinaryProtocol.decode(encodedData) else {
|
|
||||||
XCTFail("Failed to encode/decode packet with recipient")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify recipient
|
// Verify recipient
|
||||||
XCTAssertNotNil(decodedPacket.recipientID)
|
#expect(decodedPacket.recipientID != nil)
|
||||||
let decodedRecipientID = decodedPacket.recipientID?.trimmingNullBytes()
|
let decodedRecipientID = decodedPacket.recipientID?.trimmingNullBytes()
|
||||||
XCTAssertTrue(String(data: decodedRecipientID!, encoding: .utf8) == recipientID)
|
// TODO: Check if this is intended that the decoding only gets the first 8
|
||||||
|
#expect(String(data: decodedRecipientID!, encoding: .utf8) == "abcdef01")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPacketWithSignature() throws {
|
@Test func packetWithSignature() throws {
|
||||||
let packet = TestHelpers.createTestPacket(
|
let packet = TestHelpers.createTestPacket(signature: TestConstants.testSignature)
|
||||||
signature: TestConstants.testSignature
|
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with signature")
|
||||||
)
|
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode packet with signature")
|
||||||
|
|
||||||
// Encode and decode
|
|
||||||
guard let encodedData = BinaryProtocol.encode(packet),
|
|
||||||
let decodedPacket = BinaryProtocol.decode(encodedData) else {
|
|
||||||
XCTFail("Failed to encode/decode packet with signature")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify signature
|
// Verify signature
|
||||||
XCTAssertNotNil(decodedPacket.signature)
|
#expect(decodedPacket.signature != nil)
|
||||||
XCTAssertEqual(decodedPacket.signature, TestConstants.testSignature)
|
#expect(decodedPacket.signature == TestConstants.testSignature)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Compression Tests
|
// MARK: - Compression Tests
|
||||||
|
|
||||||
func testPayloadCompression() throws {
|
@Test("Create a large, compressible payload above current threshold (2048B)")
|
||||||
// Create a large, compressible payload above current threshold (2048B)
|
func payloadCompression() throws {
|
||||||
let repeatedString = String(repeating: "This is a test message. ", count: 200)
|
let repeatedString = String(repeating: "This is a test message. ", count: 200)
|
||||||
let largePayload = repeatedString.data(using: .utf8)!
|
let largePayload = repeatedString.data(using: .utf8)!
|
||||||
|
|
||||||
let packet = TestHelpers.createTestPacket(payload: largePayload)
|
let packet = TestHelpers.createTestPacket(payload: largePayload)
|
||||||
|
|
||||||
// Encode (should compress)
|
// Encode (should compress)
|
||||||
guard let encodedData = BinaryProtocol.encode(packet) else {
|
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with large payload")
|
||||||
XCTFail("Failed to encode packet with large payload")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// The encoded size should be smaller than uncompressed due to compression
|
// The encoded size should be smaller than uncompressed due to compression
|
||||||
let uncompressedSize = BinaryProtocol.headerSize + BinaryProtocol.senderIDSize + largePayload.count
|
let uncompressedSize = BinaryProtocol.headerSize + BinaryProtocol.senderIDSize + largePayload.count
|
||||||
XCTAssertLessThan(encodedData.count, uncompressedSize)
|
#expect(encodedData.count < uncompressedSize)
|
||||||
|
|
||||||
// Decode and verify
|
// Decode and verify
|
||||||
guard let decodedPacket = BinaryProtocol.decode(encodedData) else {
|
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode compressed packet")
|
||||||
XCTFail("Failed to decode compressed packet")
|
|
||||||
return
|
#expect(decodedPacket.payload == largePayload)
|
||||||
}
|
}
|
||||||
|
|
||||||
XCTAssertEqual(decodedPacket.payload, largePayload)
|
@Test("Small payloads should not be compressed")
|
||||||
}
|
func smallPayloadNoCompression() throws {
|
||||||
|
|
||||||
func testSmallPayloadNoCompression() throws {
|
|
||||||
// Small payloads should not be compressed
|
|
||||||
let smallPayload = "Hi".data(using: .utf8)!
|
let smallPayload = "Hi".data(using: .utf8)!
|
||||||
let packet = TestHelpers.createTestPacket(payload: smallPayload)
|
let packet = TestHelpers.createTestPacket(payload: smallPayload)
|
||||||
|
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode small packet")
|
||||||
guard let encodedData = BinaryProtocol.encode(packet),
|
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode small packet")
|
||||||
let decodedPacket = BinaryProtocol.decode(encodedData) else {
|
#expect(decodedPacket.payload == smallPayload)
|
||||||
XCTFail("Failed to encode/decode small packet")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
XCTAssertEqual(decodedPacket.payload, smallPayload)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Message Padding Tests
|
// MARK: - Message Padding Tests
|
||||||
|
|
||||||
func testMessagePadding() throws {
|
@Test func messagePadding() throws {
|
||||||
let payloads = [
|
let payloads = [
|
||||||
"Short",
|
"Short",
|
||||||
String(repeating: "Medium length message content ", count: 10), // ~300 bytes
|
String(repeating: "Medium length message content ", count: 10), // ~300 bytes
|
||||||
@@ -130,43 +100,32 @@ final class BinaryProtocolTests: XCTestCase {
|
|||||||
|
|
||||||
for payload in payloads {
|
for payload in payloads {
|
||||||
let packet = TestHelpers.createTestPacket(payload: payload.data(using: .utf8)!)
|
let packet = TestHelpers.createTestPacket(payload: payload.data(using: .utf8)!)
|
||||||
|
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet")
|
||||||
guard let encodedData = BinaryProtocol.encode(packet) else {
|
|
||||||
XCTFail("Failed to encode packet")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify padding creates standard block sizes up to configured limit (no 4096 bucket currently)
|
// Verify padding creates standard block sizes up to configured limit (no 4096 bucket currently)
|
||||||
let blockSizes = [256, 512, 1024, 2048]
|
let blockSizes = [256, 512, 1024, 2048]
|
||||||
if encodedData.count <= 2048 {
|
if encodedData.count <= 2048 {
|
||||||
XCTAssertTrue(blockSizes.contains(encodedData.count), "Encoded size \(encodedData.count) is not a standard block size")
|
#expect(blockSizes.contains(encodedData.count), "Encoded size \(encodedData.count) is not a standard block size")
|
||||||
} else {
|
} else {
|
||||||
// For very large payloads we expect no additional padding beyond raw size
|
// For very large payloads we expect no additional padding beyond raw size
|
||||||
XCTAssertGreaterThan(encodedData.count, 2048)
|
#expect(encodedData.count > 2048)
|
||||||
}
|
}
|
||||||
|
|
||||||
encodedSizes.insert(encodedData.count)
|
encodedSizes.insert(encodedData.count)
|
||||||
|
|
||||||
// Verify decoding works
|
// Verify decoding works
|
||||||
guard let decodedPacket = BinaryProtocol.decode(encodedData) else {
|
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode padded packet")
|
||||||
XCTFail("Failed to decode padded packet")
|
#expect(String(data: decodedPacket.payload, encoding: .utf8) == payload)
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
XCTAssertEqual(String(data: decodedPacket.payload, encoding: .utf8), payload)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Different payload sizes (within <=2048) may map to the same bucket depending on compression.
|
// Different payload sizes (within <=2048) may map to the same bucket depending on compression.
|
||||||
// Require at least one padded size to be present.
|
// Require at least one padded size to be present.
|
||||||
XCTAssertGreaterThanOrEqual(encodedSizes.filter { $0 <= 2048 }.count, 1, "Expected at least one padded size up to 2048, got \(encodedSizes)")
|
#expect(encodedSizes.filter { $0 <= 2048 }.count >= 1, "Expected at least one padded size up to 2048, got \(encodedSizes)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testInvalidPKCS7PaddingIsRejected() throws {
|
@Test func invalidPKCS7PaddingIsRejected() throws {
|
||||||
let pkt = TestHelpers.createTestPacket(payload: Data(repeating: 0x41, count: 50)) // small
|
let pkt = TestHelpers.createTestPacket(payload: Data(repeating: 0x41, count: 50)) // small
|
||||||
guard let enc0 = BinaryProtocol.encode(pkt) else {
|
let enc0 = try #require(BinaryProtocol.encode(pkt), "encode failed")
|
||||||
XCTFail("encode failed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Force padding to known block for test stability
|
// Force padding to known block for test stability
|
||||||
var enc = MessagePadding.pad(enc0, toSize: 256)
|
var enc = MessagePadding.pad(enc0, toSize: 256)
|
||||||
let unpadded = MessagePadding.unpad(enc)
|
let unpadded = MessagePadding.unpad(enc)
|
||||||
@@ -177,39 +136,33 @@ final class BinaryProtocolTests: XCTestCase {
|
|||||||
let maybe = BinaryProtocol.decode(enc)
|
let maybe = BinaryProtocol.decode(enc)
|
||||||
// If decode still succeeds (nested pad edge case), at least ensure payload integrity
|
// If decode still succeeds (nested pad edge case), at least ensure payload integrity
|
||||||
if let pkt2 = maybe {
|
if let pkt2 = maybe {
|
||||||
XCTAssertEqual(pkt2.payload, pkt.payload)
|
#expect(pkt2.payload == pkt.payload)
|
||||||
} else {
|
} else {
|
||||||
XCTAssertNil(maybe)
|
#expect(maybe == nil)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If no padding was applied, just assert decode succeeds (nothing to test)
|
// If no padding was applied, just assert decode succeeds (nothing to test)
|
||||||
XCTAssertNotNil(BinaryProtocol.decode(enc))
|
#expect(BinaryProtocol.decode(enc) != nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Message Encoding/Decoding Tests
|
// MARK: - Message Encoding/Decoding Tests
|
||||||
|
|
||||||
func testMessageEncodingDecoding() throws {
|
@Test func messageEncodingDecoding() throws {
|
||||||
let message = TestHelpers.createTestMessage()
|
let message = TestHelpers.createTestMessage()
|
||||||
|
|
||||||
guard let payload = message.toBinaryPayload() else {
|
let payload = try #require(message.toBinaryPayload(), "Failed to encode message to binary")
|
||||||
XCTFail("Failed to encode message to binary")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let decodedMessage = BitchatMessage(payload) else {
|
let decodedMessage = try #require(BitchatMessage(payload), "Failed to decode message from binary")
|
||||||
XCTFail("Failed to decode message from binary")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
XCTAssertEqual(decodedMessage.content, message.content)
|
#expect(decodedMessage.content == message.content)
|
||||||
XCTAssertEqual(decodedMessage.sender, message.sender)
|
#expect(decodedMessage.sender == message.sender)
|
||||||
XCTAssertEqual(decodedMessage.senderPeerID, message.senderPeerID)
|
#expect(decodedMessage.senderPeerID == message.senderPeerID)
|
||||||
XCTAssertEqual(decodedMessage.isPrivate, message.isPrivate)
|
#expect(decodedMessage.isPrivate == message.isPrivate)
|
||||||
|
|
||||||
// Timestamp should be close (within 1 second due to conversion)
|
// Timestamp should be close (within 1 second due to conversion)
|
||||||
let timeDiff = abs(decodedMessage.timestamp.timeIntervalSince(message.timestamp))
|
let timeDiff = abs(decodedMessage.timestamp.timeIntervalSince(message.timestamp))
|
||||||
XCTAssertLessThan(timeDiff, 1.0)
|
#expect(timeDiff < 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPrivateMessageEncoding() throws {
|
func testPrivateMessageEncoding() throws {
|
||||||
@@ -218,30 +171,22 @@ final class BinaryProtocolTests: XCTestCase {
|
|||||||
recipientNickname: TestConstants.testNickname2
|
recipientNickname: TestConstants.testNickname2
|
||||||
)
|
)
|
||||||
|
|
||||||
guard let payload = message.toBinaryPayload(),
|
let payload = try #require(message.toBinaryPayload(), "Failed to encode private message")
|
||||||
let decodedMessage = BitchatMessage(payload) else {
|
let decodedMessage = try #require(BitchatMessage(payload), "Failed to decode private message")
|
||||||
XCTFail("Failed to encode/decode private message")
|
|
||||||
return
|
#expect(decodedMessage.isPrivate)
|
||||||
|
#expect(decodedMessage.recipientNickname == TestConstants.testNickname2)
|
||||||
}
|
}
|
||||||
|
|
||||||
XCTAssertTrue(decodedMessage.isPrivate)
|
@Test func messageWithMentions() throws {
|
||||||
XCTAssertEqual(decodedMessage.recipientNickname, TestConstants.testNickname2)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testMessageWithMentions() throws {
|
|
||||||
let mentions = [TestConstants.testNickname2, TestConstants.testNickname3]
|
let mentions = [TestConstants.testNickname2, TestConstants.testNickname3]
|
||||||
let message = TestHelpers.createTestMessage(mentions: mentions)
|
let message = TestHelpers.createTestMessage(mentions: mentions)
|
||||||
|
let payload = try #require(message.toBinaryPayload(), "Failed to encode message with mentions")
|
||||||
guard let payload = message.toBinaryPayload(),
|
let decodedMessage = try #require(BitchatMessage(payload), "Failed to decode message with mentions")
|
||||||
let decodedMessage = BitchatMessage(payload) else {
|
#expect(decodedMessage.mentions == mentions)
|
||||||
XCTFail("Failed to encode/decode message with mentions")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
XCTAssertEqual(decodedMessage.mentions, mentions)
|
@Test func relayMessageEncoding() throws {
|
||||||
}
|
|
||||||
|
|
||||||
func testRelayMessageEncoding() throws {
|
|
||||||
let message = BitchatMessage(
|
let message = BitchatMessage(
|
||||||
id: UUID().uuidString,
|
id: UUID().uuidString,
|
||||||
sender: TestConstants.testNickname1,
|
sender: TestConstants.testNickname1,
|
||||||
@@ -251,105 +196,77 @@ final class BinaryProtocolTests: XCTestCase {
|
|||||||
originalSender: TestConstants.testNickname3,
|
originalSender: TestConstants.testNickname3,
|
||||||
isPrivate: false,
|
isPrivate: false,
|
||||||
recipientNickname: nil,
|
recipientNickname: nil,
|
||||||
senderPeerID: TestConstants.testPeerID1,
|
|
||||||
mentions: nil
|
mentions: nil
|
||||||
)
|
)
|
||||||
|
let payload = try #require(message.toBinaryPayload(), "Failed to encode relay message")
|
||||||
guard let payload = message.toBinaryPayload(),
|
let decodedMessage = try #require(BitchatMessage(payload), "Failed to decode relay message")
|
||||||
let decodedMessage = BitchatMessage(payload) else {
|
#expect(decodedMessage.isRelay)
|
||||||
XCTFail("Failed to encode/decode relay message")
|
#expect(decodedMessage.originalSender == TestConstants.testNickname3)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
XCTAssertTrue(decodedMessage.isRelay)
|
|
||||||
XCTAssertEqual(decodedMessage.originalSender, TestConstants.testNickname3)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Edge Cases and Error Handling
|
// MARK: - Edge Cases and Error Handling
|
||||||
|
|
||||||
func testInvalidDataDecoding() {
|
@Test("Too small data")
|
||||||
// Too small data
|
func invalidDataDecoding() throws {
|
||||||
let tooSmall = Data(repeating: 0, count: 5)
|
let tooSmall = Data(repeating: 0, count: 5)
|
||||||
XCTAssertNil(BinaryProtocol.decode(tooSmall))
|
#expect(BinaryProtocol.decode(tooSmall) == nil)
|
||||||
|
|
||||||
// Random data
|
// Random data
|
||||||
let random = TestHelpers.generateRandomData(length: 100)
|
let random = TestHelpers.generateRandomData(length: 100)
|
||||||
XCTAssertNil(BinaryProtocol.decode(random))
|
#expect(BinaryProtocol.decode(random) == nil)
|
||||||
|
|
||||||
// Corrupted header
|
// Corrupted header
|
||||||
let packet = TestHelpers.createTestPacket()
|
let packet = TestHelpers.createTestPacket()
|
||||||
guard var encoded = BinaryProtocol.encode(packet) else {
|
var encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode test packet")
|
||||||
XCTFail("Failed to encode test packet")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Corrupt the version byte
|
// Corrupt the version byte
|
||||||
encoded[0] = 0xFF
|
encoded[0] = 0xFF
|
||||||
XCTAssertNil(BinaryProtocol.decode(encoded))
|
#expect(BinaryProtocol.decode(encoded) == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testLargeMessageHandling() throws {
|
@Test("Test maximum size handling")
|
||||||
// Test maximum size handling
|
func largeMessageHandling() throws {
|
||||||
let largeContent = String(repeating: "X", count: 65535) // Max uint16
|
let largeContent = String(repeating: "X", count: 65535) // Max uint16
|
||||||
let message = TestHelpers.createTestMessage(content: largeContent)
|
let message = TestHelpers.createTestMessage(content: largeContent)
|
||||||
|
let payload = try #require(message.toBinaryPayload(), "Failed to handle large message")
|
||||||
guard let payload = message.toBinaryPayload(),
|
let decodedMessage = try #require(BitchatMessage(payload), "Failed to handle large message")
|
||||||
let decodedMessage = BitchatMessage(payload) else {
|
#expect(decodedMessage.content == largeContent)
|
||||||
XCTFail("Failed to handle large message")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
XCTAssertEqual(decodedMessage.content, largeContent)
|
@Test("Test message with empty content")
|
||||||
}
|
func emptyFieldsHandling() throws {
|
||||||
|
|
||||||
func testEmptyFieldsHandling() throws {
|
|
||||||
// Test message with empty content
|
|
||||||
let emptyMessage = TestHelpers.createTestMessage(content: "")
|
let emptyMessage = TestHelpers.createTestMessage(content: "")
|
||||||
|
let payload = try #require(emptyMessage.toBinaryPayload(), "Failed to handle empty message")
|
||||||
guard let payload = emptyMessage.toBinaryPayload(),
|
let decodedMessage = try #require(BitchatMessage(payload), "Failed to handle empty message")
|
||||||
let decodedMessage = BitchatMessage(payload) else {
|
#expect(decodedMessage.content.isEmpty)
|
||||||
XCTFail("Failed to handle empty message")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
XCTAssertEqual(decodedMessage.content, "")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Protocol Version Tests
|
// MARK: - Protocol Version Tests
|
||||||
|
|
||||||
func testProtocolVersionHandling() throws {
|
@Test("Test with supported version (version is always 1 in init)")
|
||||||
// Test with supported version (version is always 1 in init)
|
func protocolVersionHandling() throws {
|
||||||
let packet = TestHelpers.createTestPacket()
|
let packet = TestHelpers.createTestPacket()
|
||||||
|
let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with version")
|
||||||
guard let encoded = BinaryProtocol.encode(packet),
|
let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode packet with version")
|
||||||
let decoded = BinaryProtocol.decode(encoded) else {
|
#expect(decoded.version == 1)
|
||||||
XCTFail("Failed to encode/decode packet with version")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
XCTAssertEqual(decoded.version, 1)
|
@Test("Create packet data with unsupported version")
|
||||||
}
|
func unsupportedProtocolVersion() throws {
|
||||||
|
|
||||||
func testUnsupportedProtocolVersion() throws {
|
|
||||||
// Create packet data with unsupported version
|
|
||||||
let packet = TestHelpers.createTestPacket()
|
let packet = TestHelpers.createTestPacket()
|
||||||
|
var encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode packet")
|
||||||
guard var encoded = BinaryProtocol.encode(packet) else {
|
|
||||||
XCTFail("Failed to encode packet")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Manually change version byte to unsupported value
|
// Manually change version byte to unsupported value
|
||||||
encoded[0] = 99 // Unsupported version
|
encoded[0] = 99 // Unsupported version
|
||||||
|
|
||||||
// Should fail to decode
|
// Should fail to decode
|
||||||
XCTAssertNil(BinaryProtocol.decode(encoded))
|
#expect(BinaryProtocol.decode(encoded) == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Bounds Checking Tests (Crash Prevention)
|
// MARK: - Bounds Checking Tests (Crash Prevention)
|
||||||
|
|
||||||
func testMalformedPacketWithInvalidPayloadLength() throws {
|
@Test("Test the specific crash scenario: payloadLength = 193 (0xc1) but only 30 bytes available")
|
||||||
// Test the specific crash scenario: payloadLength = 193 (0xc1) but only 30 bytes available
|
func malformedPacketWithInvalidPayloadLength() throws {
|
||||||
var malformedData = Data()
|
var malformedData = Data()
|
||||||
|
|
||||||
// Valid header (13 bytes)
|
// Valid header (13 bytes)
|
||||||
@@ -379,20 +296,17 @@ final class BinaryProtocolTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Total data is now 30 bytes, but payloadLength claims 193
|
// Total data is now 30 bytes, but payloadLength claims 193
|
||||||
XCTAssertEqual(malformedData.count, 30)
|
#expect(malformedData.count == 30)
|
||||||
|
|
||||||
// This should not crash - should return nil gracefully
|
// This should not crash - should return nil gracefully
|
||||||
let result = BinaryProtocol.decode(malformedData)
|
let result = BinaryProtocol.decode(malformedData)
|
||||||
XCTAssertNil(result, "Malformed packet with invalid payload length should return nil, not crash")
|
#expect(result == nil, "Malformed packet with invalid payload length should return nil, not crash")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testTruncatedPacketHandling() throws {
|
@Test("Test various truncation scenarios")
|
||||||
// Test various truncation scenarios
|
func truncatedPacketHandling() throws {
|
||||||
let packet = TestHelpers.createTestPacket()
|
let packet = TestHelpers.createTestPacket()
|
||||||
guard let validEncoded = BinaryProtocol.encode(packet) else {
|
let validEncoded = try #require(BinaryProtocol.encode(packet), "Failed to encode test packet")
|
||||||
XCTFail("Failed to encode test packet")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test truncation at various points
|
// Test truncation at various points
|
||||||
let truncationPoints = [0, 5, 10, 15, 20, 25]
|
let truncationPoints = [0, 5, 10, 15, 20, 25]
|
||||||
@@ -400,12 +314,12 @@ final class BinaryProtocolTests: XCTestCase {
|
|||||||
for point in truncationPoints {
|
for point in truncationPoints {
|
||||||
let truncated = validEncoded.prefix(point)
|
let truncated = validEncoded.prefix(point)
|
||||||
let result = BinaryProtocol.decode(truncated)
|
let result = BinaryProtocol.decode(truncated)
|
||||||
XCTAssertNil(result, "Truncated packet at \(point) bytes should return nil, not crash")
|
#expect(result == nil, "Truncated packet at \(point) bytes should return nil, not crash")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMalformedCompressedPacket() throws {
|
@Test("Test compressed packet with invalid original size")
|
||||||
// Test compressed packet with invalid original size
|
func malformedCompressedPacket() throws {
|
||||||
var malformedData = Data()
|
var malformedData = Data()
|
||||||
|
|
||||||
// Valid header
|
// Valid header
|
||||||
@@ -434,11 +348,11 @@ final class BinaryProtocolTests: XCTestCase {
|
|||||||
|
|
||||||
// Should handle this gracefully
|
// Should handle this gracefully
|
||||||
let result = BinaryProtocol.decode(malformedData)
|
let result = BinaryProtocol.decode(malformedData)
|
||||||
XCTAssertNil(result, "Malformed compressed packet should return nil, not crash")
|
#expect(result == nil, "Malformed compressed packet should return nil, not crash")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testExcessivelyLargePayloadLength() throws {
|
@Test("Test packet claiming extremely large payload")
|
||||||
// Test packet claiming extremely large payload
|
func excessivelyLargePayloadLength() throws {
|
||||||
var malformedData = Data()
|
var malformedData = Data()
|
||||||
|
|
||||||
// Valid header
|
// Valid header
|
||||||
@@ -467,11 +381,11 @@ final class BinaryProtocolTests: XCTestCase {
|
|||||||
|
|
||||||
// Should handle this gracefully without trying to allocate massive amounts of memory
|
// Should handle this gracefully without trying to allocate massive amounts of memory
|
||||||
let result = BinaryProtocol.decode(malformedData)
|
let result = BinaryProtocol.decode(malformedData)
|
||||||
XCTAssertNil(result, "Packet with excessive payload length should return nil, not crash")
|
#expect(result == nil, "Packet with excessive payload length should return nil, not crash")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCompressedPacketWithInvalidOriginalSize() throws {
|
@Test("Test compressed packet with unreasonable original size")
|
||||||
// Test compressed packet with unreasonable original size
|
func compressedPacketWithInvalidOriginalSize() throws {
|
||||||
var malformedData = Data()
|
var malformedData = Data()
|
||||||
|
|
||||||
// Valid header
|
// Valid header
|
||||||
@@ -509,11 +423,11 @@ final class BinaryProtocolTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let result = BinaryProtocol.decode(malformedData)
|
let result = BinaryProtocol.decode(malformedData)
|
||||||
XCTAssertNil(result, "Compressed packet with invalid original size should return nil, not crash")
|
#expect(result == nil, "Compressed packet with invalid original size should return nil, not crash")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMaliciousPacketWithIntegerOverflow() throws {
|
@Test("Test packet designed to cause integer overflow")
|
||||||
// Test packet designed to cause integer overflow
|
func maliciousPacketWithIntegerOverflow() throws {
|
||||||
var maliciousData = Data()
|
var maliciousData = Data()
|
||||||
|
|
||||||
// Valid header
|
// Valid header
|
||||||
@@ -548,27 +462,24 @@ final class BinaryProtocolTests: XCTestCase {
|
|||||||
|
|
||||||
// Should handle gracefully without integer overflow issues
|
// Should handle gracefully without integer overflow issues
|
||||||
let result = BinaryProtocol.decode(maliciousData)
|
let result = BinaryProtocol.decode(maliciousData)
|
||||||
XCTAssertNil(result, "Malicious packet designed for integer overflow should return nil, not crash")
|
#expect(result == nil, "Malicious packet designed for integer overflow should return nil, not crash")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPartialHeaderData() throws {
|
@Test("Test packets with incomplete headers")
|
||||||
// Test packets with incomplete headers
|
func partialHeaderData() throws {
|
||||||
let headerSizes = [0, 1, 5, 10, 12] // Various incomplete header sizes
|
let headerSizes = [0, 1, 5, 10, 12] // Various incomplete header sizes
|
||||||
|
|
||||||
for size in headerSizes {
|
for size in headerSizes {
|
||||||
let partialData = Data(repeating: 0x01, count: size)
|
let partialData = Data(repeating: 0x01, count: size)
|
||||||
let result = BinaryProtocol.decode(partialData)
|
let result = BinaryProtocol.decode(partialData)
|
||||||
XCTAssertNil(result, "Partial header data (\(size) bytes) should return nil, not crash")
|
#expect(result == nil, "Partial header data (\(size) bytes) should return nil, not crash")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBoundaryConditions() throws {
|
@Test("Test exact boundary conditions")
|
||||||
// Test exact boundary conditions
|
func boundaryConditions() throws {
|
||||||
let packet = TestHelpers.createTestPacket()
|
let packet = TestHelpers.createTestPacket()
|
||||||
guard let validEncoded = BinaryProtocol.encode(packet) else {
|
let validEncoded = try #require(BinaryProtocol.encode(packet), "Failed to encode test packet")
|
||||||
XCTFail("Failed to encode test packet")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// If truncation only removes padding, decode may still succeed. Compute unpadded size.
|
// If truncation only removes padding, decode may still succeed. Compute unpadded size.
|
||||||
let unpadded = MessagePadding.unpad(validEncoded)
|
let unpadded = MessagePadding.unpad(validEncoded)
|
||||||
@@ -576,7 +487,7 @@ final class BinaryProtocolTests: XCTestCase {
|
|||||||
let cut = max(1, unpadded.count - 10)
|
let cut = max(1, unpadded.count - 10)
|
||||||
let truncatedCore = unpadded.prefix(cut)
|
let truncatedCore = unpadded.prefix(cut)
|
||||||
let result = BinaryProtocol.decode(truncatedCore)
|
let result = BinaryProtocol.decode(truncatedCore)
|
||||||
XCTAssertNil(result, "Truncated core frame should return nil, not crash")
|
#expect(result == nil, "Truncated core frame should return nil, not crash")
|
||||||
|
|
||||||
// Test minimum valid size - create a valid minimal packet
|
// Test minimum valid size - create a valid minimal packet
|
||||||
var minData = Data()
|
var minData = Data()
|
||||||
|
|||||||
@@ -14,11 +14,6 @@ struct TestConstants {
|
|||||||
static let shortTimeout: TimeInterval = 1.0
|
static let shortTimeout: TimeInterval = 1.0
|
||||||
static let longTimeout: TimeInterval = 10.0
|
static let longTimeout: TimeInterval = 10.0
|
||||||
|
|
||||||
static let testPeerID1: PeerID = "PEER1234"
|
|
||||||
static let testPeerID2: PeerID = "PEER5678"
|
|
||||||
static let testPeerID3: PeerID = "PEER9012"
|
|
||||||
static let testPeerID4: PeerID = "PEER3456"
|
|
||||||
|
|
||||||
static let testNickname1 = "Alice"
|
static let testNickname1 = "Alice"
|
||||||
static let testNickname2 = "Bob"
|
static let testNickname2 = "Bob"
|
||||||
static let testNickname3 = "Charlie"
|
static let testNickname3 = "Charlie"
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ final class TestHelpers {
|
|||||||
static func createTestMessage(
|
static func createTestMessage(
|
||||||
content: String = TestConstants.testMessage1,
|
content: String = TestConstants.testMessage1,
|
||||||
sender: String = TestConstants.testNickname1,
|
sender: String = TestConstants.testNickname1,
|
||||||
senderPeerID: PeerID = TestConstants.testPeerID1,
|
senderPeerID: PeerID = PeerID(str: UUID().uuidString),
|
||||||
isPrivate: Bool = false,
|
isPrivate: Bool = false,
|
||||||
recipientNickname: String? = nil,
|
recipientNickname: String? = nil,
|
||||||
mentions: [String]? = nil
|
mentions: [String]? = nil
|
||||||
@@ -51,7 +51,7 @@ final class TestHelpers {
|
|||||||
|
|
||||||
static func createTestPacket(
|
static func createTestPacket(
|
||||||
type: UInt8 = 0x01,
|
type: UInt8 = 0x01,
|
||||||
senderID: PeerID = TestConstants.testPeerID1,
|
senderID: PeerID = PeerID(str: UUID().uuidString),
|
||||||
recipientID: PeerID? = nil,
|
recipientID: PeerID? = nil,
|
||||||
payload: Data = "test payload".data(using: .utf8)!,
|
payload: Data = "test payload".data(using: .utf8)!,
|
||||||
signature: Data? = nil,
|
signature: Data? = nil,
|
||||||
@@ -90,7 +90,7 @@ final class TestHelpers {
|
|||||||
if Date().timeIntervalSince(start) > timeout {
|
if Date().timeIntervalSince(start) > timeout {
|
||||||
throw TestError.timeout
|
throw TestError.timeout
|
||||||
}
|
}
|
||||||
try await Task.sleep(nanoseconds: 10_000_000) // 10ms
|
try await sleep(0.01)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ final class TestHelpers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
group.addTask {
|
group.addTask {
|
||||||
try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
|
try await sleep(1)
|
||||||
throw TestError.timeout
|
throw TestError.timeout
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,14 +121,6 @@ enum TestError: Error {
|
|||||||
case testFailure(String)
|
case testFailure(String)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - PeerID String Helpers
|
func sleep(_ seconds: TimeInterval) async throws {
|
||||||
|
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
||||||
/// Raw String can be passed as PeerID
|
|
||||||
extension PeerID: @retroactive ExpressibleByStringLiteral {
|
|
||||||
public init(stringLiteral value: String) {
|
|
||||||
self.init(str: value)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Interpolated String can be passed as PeerID
|
|
||||||
extension PeerID: @retroactive ExpressibleByStringInterpolation {}
|
|
||||||
|
|||||||
@@ -6,11 +6,11 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import XCTest
|
import Testing
|
||||||
|
import Foundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
final class PeerIDTests: XCTestCase {
|
struct PeerIDTests {
|
||||||
|
|
||||||
private let hex16 = "0011223344556677"
|
private let hex16 = "0011223344556677"
|
||||||
private let hex64 = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
|
private let hex64 = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
|
||||||
|
|
||||||
@@ -22,212 +22,205 @@ final class PeerIDTests: XCTestCase {
|
|||||||
|
|
||||||
// MARK: - Empty prefix
|
// MARK: - Empty prefix
|
||||||
|
|
||||||
func test_init_empty_prefix_with16() {
|
@Test func empty_prefix_with16() {
|
||||||
let peerID = PeerID(str: hex16)
|
let peerID = PeerID(str: hex16)
|
||||||
XCTAssertEqual(peerID.id, hex16)
|
#expect(peerID.id == hex16)
|
||||||
XCTAssertEqual(peerID.bare, hex16)
|
#expect(peerID.bare == hex16)
|
||||||
XCTAssertEqual(peerID.prefix, .empty)
|
#expect(peerID.prefix == .empty)
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_init_empty_prefix_with64() {
|
@Test func empty_prefix_with64() {
|
||||||
let peerID = PeerID(str: hex64)
|
let peerID = PeerID(str: hex64)
|
||||||
XCTAssertEqual(peerID.id, hex64)
|
#expect(peerID.id == hex64)
|
||||||
XCTAssertEqual(peerID.bare, hex64)
|
#expect(peerID.bare == hex64)
|
||||||
XCTAssertEqual(peerID.prefix, .empty)
|
#expect(peerID.prefix == .empty)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Mesh prefix
|
// MARK: - Mesh prefix
|
||||||
|
|
||||||
func test_init_mesh_prefix_with16() {
|
@Test func mesh_prefix_with16() {
|
||||||
let str = "mesh:" + hex16
|
let str = "mesh:" + hex16
|
||||||
let peerID = PeerID(str: str)
|
let peerID = PeerID(str: str)
|
||||||
XCTAssertEqual(peerID.id, str)
|
#expect(peerID.id == str)
|
||||||
XCTAssertEqual(peerID.bare, hex16)
|
#expect(peerID.bare == hex16)
|
||||||
XCTAssertEqual(peerID.prefix, .mesh)
|
#expect(peerID.prefix == .mesh)
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_init_mesh_prefix_with64() {
|
@Test func mesh_prefix_with64() {
|
||||||
let str = "mesh:" + hex64
|
let str = "mesh:" + hex64
|
||||||
let peerID = PeerID(str: str)
|
let peerID = PeerID(str: str)
|
||||||
XCTAssertEqual(peerID.id, str)
|
#expect(peerID.id == str)
|
||||||
XCTAssertEqual(peerID.bare, hex64)
|
#expect(peerID.bare == hex64)
|
||||||
XCTAssertEqual(peerID.prefix, .mesh)
|
#expect(peerID.prefix == .mesh)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Name prefix
|
// MARK: - Name prefix
|
||||||
|
|
||||||
func test_init_name_prefix() {
|
@Test func name_prefix() {
|
||||||
let str = "name:some_name"
|
let str = "name:some_name"
|
||||||
let peerID = PeerID(str: str)
|
let peerID = PeerID(str: str)
|
||||||
XCTAssertEqual(peerID.id, str)
|
#expect(peerID.id == str)
|
||||||
XCTAssertEqual(peerID.bare, "some_name")
|
#expect(peerID.bare == "some_name")
|
||||||
XCTAssertEqual(peerID.prefix, .name)
|
#expect(peerID.prefix == .name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Noise prefix
|
// MARK: - Noise prefix
|
||||||
|
|
||||||
func test_init_noise_prefix_with16() {
|
@Test func noise_prefix_with16() {
|
||||||
let str = "noise:" + hex16
|
let str = "noise:" + hex16
|
||||||
let peerID = PeerID(str: str)
|
let peerID = PeerID(str: str)
|
||||||
XCTAssertEqual(peerID.id, str)
|
#expect(peerID.id == str)
|
||||||
XCTAssertEqual(peerID.bare, hex16)
|
#expect(peerID.bare == hex16)
|
||||||
XCTAssertEqual(peerID.prefix, .noise)
|
#expect(peerID.prefix == .noise)
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_init_noise_prefix_with64() {
|
@Test func noise_prefix_with64() {
|
||||||
let str = "noise:" + hex64
|
let str = "noise:" + hex64
|
||||||
let peerID = PeerID(str: str)
|
let peerID = PeerID(str: str)
|
||||||
XCTAssertEqual(peerID.id, str)
|
#expect(peerID.id == str)
|
||||||
XCTAssertEqual(peerID.bare, hex64)
|
#expect(peerID.bare == hex64)
|
||||||
XCTAssertEqual(peerID.prefix, .noise)
|
#expect(peerID.prefix == .noise)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - GeoDM prefix
|
// MARK: - GeoDM prefix
|
||||||
|
|
||||||
func test_init_geoDM_prefix_with16() {
|
@Test func geoDM_prefix_with16() {
|
||||||
let str = "nostr_" + hex16
|
let str = "nostr_" + hex16
|
||||||
let peerID = PeerID(str: str)
|
let peerID = PeerID(str: str)
|
||||||
XCTAssertEqual(peerID.id, str)
|
#expect(peerID.id == str)
|
||||||
XCTAssertEqual(peerID.bare, hex16)
|
#expect(peerID.bare == hex16)
|
||||||
XCTAssertEqual(peerID.prefix, .geoDM)
|
#expect(peerID.prefix == .geoDM)
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_init_geoDM_prefix_with64() {
|
@Test func geoDM_prefix_with64() {
|
||||||
let str = "nostr_" + hex64
|
let str = "nostr_" + hex64
|
||||||
let peerID = PeerID(str: str)
|
let peerID = PeerID(str: str)
|
||||||
XCTAssertEqual(peerID.id, str)
|
#expect(peerID.id == str)
|
||||||
XCTAssertEqual(peerID.bare, hex64)
|
#expect(peerID.bare == hex64)
|
||||||
XCTAssertEqual(peerID.prefix, .geoDM)
|
#expect(peerID.prefix == .geoDM)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - GeoChat prefix
|
// MARK: - GeoChat prefix
|
||||||
|
|
||||||
func test_init_geoChat_prefix_with16() {
|
@Test func geoChat_prefix_with16() {
|
||||||
let str = "nostr:" + hex16
|
let str = "nostr:" + hex16
|
||||||
let peerID = PeerID(str: str)
|
let peerID = PeerID(str: str)
|
||||||
XCTAssertEqual(peerID.id, str)
|
#expect(peerID.id == str)
|
||||||
XCTAssertEqual(peerID.bare, hex16)
|
#expect(peerID.bare == hex16)
|
||||||
XCTAssertEqual(peerID.prefix, .geoChat)
|
#expect(peerID.prefix == .geoChat)
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_init_geoChat_prefix_with64() {
|
@Test func geoChat_prefix_with64() {
|
||||||
let str = "nostr:" + hex64
|
let str = "nostr:" + hex64
|
||||||
let peerID = PeerID(str: str)
|
let peerID = PeerID(str: str)
|
||||||
XCTAssertEqual(peerID.id, str)
|
#expect(peerID.id == str)
|
||||||
XCTAssertEqual(peerID.bare, hex64)
|
#expect(peerID.bare == hex64)
|
||||||
XCTAssertEqual(peerID.prefix, .geoChat)
|
#expect(peerID.prefix == .geoChat)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Edge cases
|
// MARK: - Edge cases
|
||||||
|
|
||||||
func test_init_with_unknown_prefix() {
|
@Test func with_unknown_prefix() {
|
||||||
let str = "unknown:" + hex16
|
let str = "unknown:" + hex16
|
||||||
let peerID = PeerID(str: str)
|
let peerID = PeerID(str: str)
|
||||||
// Falls back to .empty
|
// Falls back to .empty
|
||||||
XCTAssertEqual(peerID.id, str)
|
#expect(peerID.id == str)
|
||||||
XCTAssertEqual(peerID.bare, str)
|
#expect(peerID.bare == str)
|
||||||
XCTAssertEqual(peerID.prefix, .empty)
|
#expect(peerID.prefix == .empty)
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_init_with_only_prefix_no_bare() {
|
@Test func with_only_prefix_no_bare() {
|
||||||
let str = "mesh:"
|
let str = "mesh:"
|
||||||
let peerID = PeerID(str: str)
|
let peerID = PeerID(str: str)
|
||||||
XCTAssertEqual(peerID.id, str)
|
#expect(peerID.id == str)
|
||||||
XCTAssertEqual(peerID.bare, "")
|
#expect(peerID.bare == "")
|
||||||
XCTAssertEqual(peerID.prefix, .mesh)
|
#expect(peerID.prefix == .mesh)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - init?(data:)
|
// MARK: - init?(data:)
|
||||||
|
|
||||||
func test_init_data_valid_utf8() {
|
@Test func data_valid_utf8() {
|
||||||
let peerID = PeerID(data: Data(hex16.utf8))
|
let peerID = PeerID(data: Data(hex16.utf8))
|
||||||
XCTAssertNotNil(peerID)
|
#expect(peerID != nil)
|
||||||
XCTAssertEqual(peerID?.bare, hex16)
|
#expect(peerID?.bare == hex16)
|
||||||
XCTAssertEqual(peerID?.prefix, .empty)
|
#expect(peerID?.prefix == .empty)
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_init_data_invalid_utf8() {
|
@Test func data_invalid_utf8() {
|
||||||
// Random invalid UTF8
|
// Random invalid UTF8
|
||||||
let bytes: [UInt8] = [0xFF, 0xFE, 0xFA]
|
let bytes: [UInt8] = [0xFF, 0xFE, 0xFA]
|
||||||
let peerID = PeerID(data: Data(bytes))
|
let peerID = PeerID(data: Data(bytes))
|
||||||
XCTAssertNil(peerID)
|
#expect(peerID == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - init(str: Substring)
|
// MARK: - init(str: Substring)
|
||||||
|
|
||||||
func test_init_substring() {
|
@Test func substring() {
|
||||||
let substring = hex64.prefix(16)
|
let substring = hex64.prefix(16)
|
||||||
let peerID = PeerID(str: substring)
|
let peerID = PeerID(str: substring)
|
||||||
XCTAssertEqual(peerID.id, String(substring))
|
#expect(peerID.id == String(substring))
|
||||||
XCTAssertEqual(peerID.bare, String(substring))
|
#expect(peerID.bare == String(substring))
|
||||||
XCTAssertEqual(peerID.prefix, .empty)
|
#expect(peerID.prefix == .empty)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - init(nostr_ pubKey:)
|
// MARK: - init(nostr_ pubKey:)
|
||||||
|
|
||||||
func test_init_nostrUnderscore_pubKey() {
|
@Test func nostrUnderscore_pubKey() {
|
||||||
let pubKey = hex64
|
let pubKey = hex64
|
||||||
let peerID = PeerID(nostr_: pubKey)
|
let peerID = PeerID(nostr_: pubKey)
|
||||||
XCTAssertEqual(peerID.id, "nostr_\(pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength))")
|
#expect(peerID.id == "nostr_\(pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength))")
|
||||||
XCTAssertEqual(peerID.bare, String(pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength)))
|
#expect(peerID.bare == String(pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength)))
|
||||||
XCTAssertEqual(peerID.prefix, .geoDM)
|
#expect(peerID.prefix == .geoDM)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - init(nostr pubKey:)
|
// MARK: - init(nostr pubKey:)
|
||||||
|
|
||||||
func test_init_nostr_pubKey() {
|
@Test func nostr_pubKey() {
|
||||||
let pubKey = hex64
|
let pubKey = hex64
|
||||||
let peerID = PeerID(nostr: pubKey)
|
let peerID = PeerID(nostr: pubKey)
|
||||||
XCTAssertEqual(peerID.id, "nostr:\(pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength))")
|
#expect(peerID.id == "nostr:\(pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength))")
|
||||||
XCTAssertEqual(peerID.bare, String(pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength)))
|
#expect(peerID.bare == String(pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength)))
|
||||||
XCTAssertEqual(peerID.prefix, .geoChat)
|
#expect(peerID.prefix == .geoChat)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - init(publicKey:)
|
// MARK: - init(publicKey:)
|
||||||
|
|
||||||
func test_init_publicKey_derivesFingerprint() {
|
@Test func publicKey_derivesFingerprint() {
|
||||||
let publicKey = Data(hex64.utf8)
|
let publicKey = Data(hex64.utf8)
|
||||||
let expected = publicKey.sha256Fingerprint().prefix(16)
|
let expected = publicKey.sha256Fingerprint().prefix(16)
|
||||||
let peerID = PeerID(publicKey: publicKey)
|
let peerID = PeerID(publicKey: publicKey)
|
||||||
XCTAssertEqual(peerID.bare, String(expected))
|
#expect(peerID.bare == String(expected))
|
||||||
XCTAssertEqual(peerID.prefix, .empty)
|
#expect(peerID.prefix == .empty)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - toShort()
|
// MARK: - toShort()
|
||||||
|
|
||||||
func test_toShort_whenNoiseKeyExists() {
|
@Test func toShort_whenNoiseKeyExists() {
|
||||||
let peerID = PeerID(str: hex64)
|
let peerID = PeerID(str: hex64)
|
||||||
let short = peerID.toShort()
|
let short = peerID.toShort()
|
||||||
|
|
||||||
// `toShort()` should derive 16-hex peerID
|
|
||||||
let expected = Data(hexString: hex64)!.sha256Fingerprint().prefix(16)
|
let expected = Data(hexString: hex64)!.sha256Fingerprint().prefix(16)
|
||||||
|
#expect(short.bare == String(expected))
|
||||||
XCTAssertEqual(short.bare, String(expected))
|
#expect(short.prefix == .empty)
|
||||||
XCTAssertEqual(short.prefix, .empty)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_toShort_whenNoiseKeyExists_withNoisePrefix() {
|
@Test func toShort_whenNoiseKeyExists_withNoisePrefix() {
|
||||||
let peerID = PeerID(str: "noise:" + hex64)
|
let peerID = PeerID(str: "noise:" + hex64)
|
||||||
let short = peerID.toShort()
|
let short = peerID.toShort()
|
||||||
|
|
||||||
// `toShort()` should derive 16-hex peerID
|
|
||||||
let expected = Data(hexString: hex64)!.sha256Fingerprint().prefix(16)
|
let expected = Data(hexString: hex64)!.sha256Fingerprint().prefix(16)
|
||||||
|
#expect(short.bare == String(expected))
|
||||||
XCTAssertEqual(short.bare, String(expected))
|
#expect(short.prefix == .empty)
|
||||||
XCTAssertEqual(short.prefix, .empty)
|
#expect(peerID.prefix == .noise)
|
||||||
XCTAssertEqual(peerID.prefix, .noise)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_toShort_whenNoNoiseKey() {
|
@Test func toShort_whenNoNoiseKey() {
|
||||||
let peerID = PeerID(str: "some_random_key")
|
let peerID = PeerID(str: "some_random_key")
|
||||||
let short = peerID.toShort()
|
let short = peerID.toShort()
|
||||||
XCTAssertEqual(short, peerID) // unchanged
|
#expect(short == peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Codable
|
// MARK: - Codable
|
||||||
|
|
||||||
func test_codable_emptyPrefix() throws {
|
@Test func codable_emptyPrefix() throws {
|
||||||
struct Dummy: Codable, Equatable {
|
struct Dummy: Codable, Equatable {
|
||||||
let name: String
|
let name: String
|
||||||
let peerID: PeerID
|
let peerID: PeerID
|
||||||
@@ -237,13 +230,13 @@ final class PeerIDTests: XCTestCase {
|
|||||||
let jsonString = "{\"name\":\"some name\",\"peerID\":\"\(str)\"}"
|
let jsonString = "{\"name\":\"some name\",\"peerID\":\"\(str)\"}"
|
||||||
|
|
||||||
let decoded = try JSONDecoder().decode(Dummy.self, from: Data(jsonString.utf8))
|
let decoded = try JSONDecoder().decode(Dummy.self, from: Data(jsonString.utf8))
|
||||||
XCTAssertEqual(decoded.peerID, PeerID(str: str))
|
#expect(decoded.peerID == PeerID(str: str))
|
||||||
|
|
||||||
let encoded = try encoder.encode(decoded)
|
let encoded = try encoder.encode(decoded)
|
||||||
XCTAssertEqual(String(data: encoded, encoding: .utf8), jsonString)
|
#expect(String(data: encoded, encoding: .utf8) == jsonString)
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_codable_withPrefix() throws {
|
@Test func codable_withPrefix() throws {
|
||||||
struct Dummy: Codable, Equatable {
|
struct Dummy: Codable, Equatable {
|
||||||
let peerID: PeerID
|
let peerID: PeerID
|
||||||
}
|
}
|
||||||
@@ -252,193 +245,191 @@ final class PeerIDTests: XCTestCase {
|
|||||||
let jsonString = "{\"peerID\":\"\(str)\"}"
|
let jsonString = "{\"peerID\":\"\(str)\"}"
|
||||||
|
|
||||||
let decoded = try JSONDecoder().decode(Dummy.self, from: Data(jsonString.utf8))
|
let decoded = try JSONDecoder().decode(Dummy.self, from: Data(jsonString.utf8))
|
||||||
XCTAssertEqual(decoded.peerID, PeerID(str: str))
|
#expect(decoded.peerID == PeerID(str: str))
|
||||||
XCTAssertEqual(decoded.peerID.bare, hex16)
|
#expect(decoded.peerID.bare == hex16)
|
||||||
XCTAssertEqual(decoded.peerID.prefix, .geoDM)
|
#expect(decoded.peerID.prefix == .geoDM)
|
||||||
|
|
||||||
let encoded = try encoder.encode(decoded)
|
let encoded = try encoder.encode(decoded)
|
||||||
XCTAssertEqual(String(data: encoded, encoding: .utf8), jsonString)
|
#expect(String(data: encoded, encoding: .utf8) == jsonString)
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_codable_multiplePrefixes() throws {
|
@Test func codable_multiplePrefixes() throws {
|
||||||
// Loop across all Prefix cases (except .empty since already tested)
|
// Loop across all Prefix cases (except .empty since already tested)
|
||||||
for prefix in PeerID.Prefix.allCases where prefix != .empty {
|
for prefix in PeerID.Prefix.allCases where prefix != .empty {
|
||||||
let bare = hex16
|
let bare = hex16
|
||||||
let str = prefix.rawValue + bare
|
let str = prefix.rawValue + bare
|
||||||
|
|
||||||
let decoded = try JSONDecoder().decode(PeerID.self, from: Data("\"\(str)\"".utf8))
|
let decoded = try JSONDecoder().decode(PeerID.self, from: Data("\"\(str)\"".utf8))
|
||||||
XCTAssertEqual(decoded.prefix, prefix)
|
#expect(decoded.prefix == prefix)
|
||||||
XCTAssertEqual(decoded.bare, bare)
|
#expect(decoded.bare == bare)
|
||||||
|
|
||||||
let encoded = try encoder.encode(decoded)
|
let encoded = try encoder.encode(decoded)
|
||||||
XCTAssertEqual(String(data: encoded, encoding: .utf8), "\"\(str)\"")
|
#expect(String(data: encoded, encoding: .utf8) == "\"\(str)\"")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Comparable
|
// MARK: - Comparable
|
||||||
|
|
||||||
func test_comparable_sorting_and_equality() {
|
@Test func comparable_sorting_and_equality() {
|
||||||
let p1 = PeerID(str: "aaa")
|
let p1 = PeerID(str: "aaa")
|
||||||
let p2 = PeerID(str: "bbb")
|
let p2 = PeerID(str: "bbb")
|
||||||
let p3 = PeerID(str: "bbb")
|
let p3 = PeerID(str: "bbb")
|
||||||
|
|
||||||
XCTAssertTrue(p1 < p2)
|
#expect(p1 < p2)
|
||||||
XCTAssertFalse(p2 < p1)
|
#expect(p2 >= p1)
|
||||||
XCTAssertEqual(p2, p3)
|
#expect(p2 == p3)
|
||||||
|
|
||||||
let sorted = [p2, p1].sorted()
|
let sorted = [p2, p1].sorted()
|
||||||
XCTAssertEqual(sorted, [p1, p2])
|
#expect(sorted == [p1, p2])
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_equality() {
|
@Test func equality() {
|
||||||
let string = "aaa"
|
let string = "aaa"
|
||||||
let peerID = PeerID(str: string)
|
let peerID = PeerID(str: string)
|
||||||
let badString = "bbb"
|
let badString = "bbb"
|
||||||
|
|
||||||
// PeerID == String
|
// PeerID == String
|
||||||
XCTAssertTrue(peerID == string)
|
#expect(peerID == string)
|
||||||
XCTAssertTrue(peerID == Optional(string))
|
#expect(peerID == Optional(string))
|
||||||
XCTAssertTrue(Optional(peerID) == string)
|
#expect(Optional(peerID) == string)
|
||||||
XCTAssertTrue(Optional(peerID) == Optional(string))
|
#expect(Optional(peerID) == Optional(string))
|
||||||
|
|
||||||
// PeerID != String
|
// PeerID != String
|
||||||
XCTAssertTrue(peerID != badString)
|
#expect(peerID != badString)
|
||||||
XCTAssertTrue(peerID != Optional(badString))
|
#expect(peerID != Optional(badString))
|
||||||
XCTAssertTrue(Optional(peerID) != badString)
|
#expect(Optional(peerID) != badString)
|
||||||
XCTAssertTrue(Optional(peerID) != Optional(badString))
|
#expect(Optional(peerID) != Optional(badString))
|
||||||
|
|
||||||
// String == PeerID
|
// String == PeerID
|
||||||
XCTAssertTrue(string == peerID)
|
#expect(string == peerID)
|
||||||
XCTAssertTrue(Optional(string) == peerID)
|
#expect(Optional(string) == peerID)
|
||||||
XCTAssertTrue(string == Optional(peerID))
|
#expect(string == Optional(peerID))
|
||||||
XCTAssertTrue(Optional(string) == Optional(peerID))
|
#expect(Optional(string) == Optional(peerID))
|
||||||
|
|
||||||
// String != PeerID
|
// String != PeerID
|
||||||
XCTAssertTrue(badString != peerID)
|
#expect(badString != peerID)
|
||||||
XCTAssertTrue(Optional(badString) != peerID)
|
#expect(Optional(badString) != peerID)
|
||||||
XCTAssertTrue(badString != Optional(peerID))
|
#expect(badString != Optional(peerID))
|
||||||
XCTAssertTrue(Optional(badString) != Optional(peerID))
|
#expect(Optional(badString) != Optional(peerID))
|
||||||
|
|
||||||
|
// Regular PeerID <> PeerID
|
||||||
|
#expect(peerID == PeerID(str: "aaa"))
|
||||||
|
#expect(peerID == Optional(PeerID(str: "aaa")))
|
||||||
|
#expect(PeerID(str: "aaa") == peerID)
|
||||||
|
#expect(Optional(PeerID(str: "aaa")) == Optional(peerID))
|
||||||
|
|
||||||
// Make sure the regular PeerID <> PeerID is not broken
|
#expect(peerID != PeerID(str: "bbb"))
|
||||||
XCTAssertTrue(peerID == PeerID(str: "aaa"))
|
#expect(peerID != Optional(PeerID(str: "bbb")))
|
||||||
XCTAssertTrue(peerID == Optional(PeerID(str: "aaa")))
|
#expect(PeerID(str: "bbb") != peerID)
|
||||||
XCTAssertTrue(PeerID(str: "aaa") == peerID)
|
#expect(Optional(PeerID(str: "bbb")) != Optional(peerID))
|
||||||
XCTAssertTrue(Optional(PeerID(str: "aaa")) == Optional(peerID))
|
|
||||||
|
|
||||||
XCTAssertTrue(peerID != PeerID(str: "bbb"))
|
|
||||||
XCTAssertTrue(peerID != Optional(PeerID(str: "bbb")))
|
|
||||||
XCTAssertTrue(PeerID(str: "bbb") != peerID)
|
|
||||||
XCTAssertTrue(Optional(PeerID(str: "bbb")) != Optional(peerID))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Computed properties
|
// MARK: - Computed properties
|
||||||
|
|
||||||
func test_isEmpty_true_and_false() {
|
@Test func isEmpty_true_and_false() {
|
||||||
XCTAssertTrue(PeerID(str: "").isEmpty)
|
#expect(PeerID(str: "").isEmpty)
|
||||||
XCTAssertFalse(PeerID(str: "abc").isEmpty)
|
#expect(!PeerID(str: "abc").isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_isGeoChat() {
|
@Test func isGeoChat() {
|
||||||
XCTAssertTrue(PeerID(str: "nostr:abcdef").isGeoChat)
|
#expect(PeerID(str: "nostr:abcdef").isGeoChat)
|
||||||
XCTAssertFalse(PeerID(str: "nostr_abcdef").isGeoChat) // different prefix
|
#expect(!PeerID(str: "nostr_abcdef").isGeoChat)
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_isGeoDM() {
|
@Test func isGeoDM() {
|
||||||
XCTAssertTrue(PeerID(str: "nostr_abcdef").isGeoDM)
|
#expect(PeerID(str: "nostr_abcdef").isGeoDM)
|
||||||
XCTAssertFalse(PeerID(str: "nostr:abcdef").isGeoDM)
|
#expect(!PeerID(str: "nostr:abcdef").isGeoDM)
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_toPercentEncoded() {
|
@Test func toPercentEncoded() {
|
||||||
let peerID = PeerID(str: "name:some value/with spaces?")
|
let peerID = PeerID(str: "name:some value/with spaces?")
|
||||||
let encoded = peerID.toPercentEncoded()
|
let encoded = peerID.toPercentEncoded()
|
||||||
// spaces and ? should be percent-encoded in urlPathAllowed
|
// spaces and ? should be percent-encoded in urlPathAllowed
|
||||||
XCTAssertEqual(encoded, "name%3Asome%20value/with%20spaces%3F")
|
#expect(encoded == "name%3Asome%20value/with%20spaces%3F")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Validation
|
// MARK: - Validation
|
||||||
|
|
||||||
func test_accepts_short_hex_peer_id() {
|
@Test func accepts_short_hex_peer_id() {
|
||||||
XCTAssertTrue(PeerID(str: "0011223344556677").isValid)
|
#expect(PeerID(str: "0011223344556677").isValid)
|
||||||
XCTAssertTrue(PeerID(str: "aabbccddeeff0011").isValid)
|
#expect(PeerID(str: "aabbccddeeff0011").isValid)
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_accepts_full_noise_key_hex() {
|
@Test func accepts_full_noise_key_hex() {
|
||||||
let hex64 = String(repeating: "ab", count: 32) // 64 hex chars
|
let hex64 = String(repeating: "ab", count: 32) // 64 hex chars
|
||||||
XCTAssertTrue(PeerID(str: hex64).isValid)
|
#expect(PeerID(str: hex64).isValid)
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_accepts_internal_alnum_dash_underscore() {
|
@Test func accepts_internal_alnum_dash_underscore() {
|
||||||
XCTAssertTrue(PeerID(str: "peer_123-ABC").isValid)
|
#expect(PeerID(str: "peer_123-ABC").isValid)
|
||||||
XCTAssertTrue(PeerID(str: "nostr_user_01").isValid)
|
#expect(PeerID(str: "nostr_user_01").isValid)
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_rejects_invalid_characters() {
|
@Test func rejects_invalid_characters() {
|
||||||
XCTAssertFalse(PeerID(str: "peer!@#").isValid)
|
#expect(!PeerID(str: "peer!@#").isValid)
|
||||||
XCTAssertFalse(PeerID(str: "gggggggggggggggg").isValid) // not hex for short form
|
#expect(!PeerID(str: "gggggggggggggggg").isValid) // not hex for short form
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_rejects_too_long() {
|
@Test func rejects_too_long() {
|
||||||
let tooLong = String(repeating: "a", count: 65)
|
let tooLong = String(repeating: "a", count: 65)
|
||||||
XCTAssertFalse(PeerID(str: tooLong).isValid)
|
#expect(!PeerID(str: tooLong).isValid)
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_isShort() {
|
@Test func isShort() {
|
||||||
XCTAssertTrue(PeerID(str: hex16).isShort)
|
#expect(PeerID(str: hex16).isShort)
|
||||||
XCTAssertFalse(PeerID(str: "abcd").isShort) // wrong length
|
#expect(!PeerID(str: "abcd").isShort) // wrong length
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_isNoiseKeyHex_and_noiseKey() {
|
@Test func isNoiseKeyHex_and_noiseKey() {
|
||||||
let hex64 = String(repeating: "ab", count: 32) // 64 chars valid hex
|
let hex64 = String(repeating: "ab", count: 32) // 64 chars valid hex
|
||||||
let peerID = PeerID(str: hex64)
|
let peerID = PeerID(str: hex64)
|
||||||
XCTAssertTrue(peerID.isNoiseKeyHex)
|
#expect(peerID.isNoiseKeyHex)
|
||||||
XCTAssertNotNil(peerID.noiseKey)
|
#expect(peerID.noiseKey != nil)
|
||||||
|
|
||||||
let prefixedPeerID = PeerID(str: "noise:" + hex64)
|
let prefixedPeerID = PeerID(str: "noise:" + hex64)
|
||||||
XCTAssertTrue(prefixedPeerID.isNoiseKeyHex)
|
#expect(prefixedPeerID.isNoiseKeyHex)
|
||||||
XCTAssertNotNil(prefixedPeerID.noiseKey)
|
#expect(prefixedPeerID.noiseKey != nil)
|
||||||
|
|
||||||
let bad = String(repeating: "z", count: 64) // invalid hex
|
let bad = String(repeating: "z", count: 64) // invalid hex
|
||||||
let badPeerID = PeerID(str: bad)
|
let badPeerID = PeerID(str: bad)
|
||||||
XCTAssertFalse(badPeerID.isNoiseKeyHex)
|
#expect(!badPeerID.isNoiseKeyHex)
|
||||||
XCTAssertNil(badPeerID.noiseKey)
|
#expect(badPeerID.noiseKey == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_prefixes() {
|
@Test func prefixes() {
|
||||||
let hex64 = String(repeating: "a", count: 64)
|
let hex64 = String(repeating: "a", count: 64)
|
||||||
XCTAssertTrue(PeerID(str: "noise:\(hex64)").isValid)
|
#expect(PeerID(str: "noise:\(hex64)").isValid)
|
||||||
XCTAssertTrue(PeerID(str: "nostr:\(hex64)").isValid)
|
#expect(PeerID(str: "nostr:\(hex64)").isValid)
|
||||||
XCTAssertTrue(PeerID(str: "nostr_\(hex64)").isValid)
|
#expect(PeerID(str: "nostr_\(hex64)").isValid)
|
||||||
|
|
||||||
let hex63 = String(repeating: "a", count: 63)
|
let hex63 = String(repeating: "a", count: 63)
|
||||||
XCTAssertTrue(PeerID(str: "noise:\(hex63)").isValid)
|
#expect(PeerID(str: "noise:\(hex63)").isValid)
|
||||||
XCTAssertTrue(PeerID(str: "nostr:\(hex63)").isValid)
|
#expect(PeerID(str: "nostr:\(hex63)").isValid)
|
||||||
XCTAssertTrue(PeerID(str: "nostr_\(hex63)").isValid)
|
#expect(PeerID(str: "nostr_\(hex63)").isValid)
|
||||||
|
|
||||||
let hex16 = String(repeating: "a", count: 16)
|
let hex16 = String(repeating: "a", count: 16)
|
||||||
XCTAssertTrue(PeerID(str: "noise:\(hex16)").isValid)
|
#expect(PeerID(str: "noise:\(hex16)").isValid)
|
||||||
XCTAssertTrue(PeerID(str: "nostr:\(hex16)").isValid)
|
#expect(PeerID(str: "nostr:\(hex16)").isValid)
|
||||||
XCTAssertTrue(PeerID(str: "nostr_\(hex16)").isValid)
|
#expect(PeerID(str: "nostr_\(hex16)").isValid)
|
||||||
|
|
||||||
let hex8 = String(repeating: "a", count: 8)
|
let hex8 = String(repeating: "a", count: 8)
|
||||||
XCTAssertTrue(PeerID(str: "noise:\(hex8)").isValid)
|
#expect(PeerID(str: "noise:\(hex8)").isValid)
|
||||||
XCTAssertTrue(PeerID(str: "nostr:\(hex8)").isValid)
|
#expect(PeerID(str: "nostr:\(hex8)").isValid)
|
||||||
XCTAssertTrue(PeerID(str: "nostr_\(hex8)").isValid)
|
#expect(PeerID(str: "nostr_\(hex8)").isValid)
|
||||||
|
|
||||||
let mesh = "mesh:abcdefg"
|
let mesh = "mesh:abcdefg"
|
||||||
XCTAssertTrue(PeerID(str: "name:\(mesh)").isValid)
|
#expect(PeerID(str: "name:\(mesh)").isValid)
|
||||||
|
|
||||||
let name = "name:some_name"
|
let name = "name:some_name"
|
||||||
XCTAssertTrue(PeerID(str: "name:\(name)").isValid)
|
#expect(PeerID(str: "name:\(name)").isValid)
|
||||||
|
|
||||||
let badName = "name:bad:name"
|
let badName = "name:bad:name"
|
||||||
XCTAssertFalse(PeerID(str: "name:\(badName)").isValid)
|
#expect(!PeerID(str: "name:\(badName)").isValid)
|
||||||
|
|
||||||
// Too long
|
// Too long
|
||||||
let hex65 = String(repeating: "a", count: 65)
|
let hex65 = String(repeating: "a", count: 65)
|
||||||
XCTAssertFalse(PeerID(str: "noise:\(hex65)").isValid)
|
#expect(!PeerID(str: "noise:\(hex65)").isValid)
|
||||||
XCTAssertFalse(PeerID(str: "nostr:\(hex65)").isValid)
|
#expect(!PeerID(str: "nostr:\(hex65)").isValid)
|
||||||
XCTAssertFalse(PeerID(str: "nostr_\(hex65)").isValid)
|
#expect(!PeerID(str: "nostr_\(hex65)").isValid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -276,7 +276,7 @@ private extension SecureLogger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cache the result
|
// Cache the result
|
||||||
cacheQueue.async(flags: .barrier) {
|
cacheQueue.sync {
|
||||||
sanitizationCache.setObject(sanitized as NSString, forKey: key)
|
sanitizationCache.setObject(sanitized as NSString, forKey: key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user