diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index acb41262..d010c13a 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -173,6 +173,10 @@ F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */; }; FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136696FC4436A02D98CE6A77 /* KeychainManager.swift */; }; FBC409E105493C491531B59A /* NostrProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */; }; + A1B2C3D4E5F60123456789BA /* MockKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60123456789AA /* MockKeychain.swift */; }; + A1B2C3D4E5F60123456789BB /* MockKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60123456789AA /* MockKeychain.swift */; }; + A1B2C3D4E5F60123456789BC /* MockIdentityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60123456789AB /* MockIdentityManager.swift */; }; + A1B2C3D4E5F60123456789BD /* MockIdentityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60123456789AB /* MockIdentityManager.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -307,6 +311,8 @@ FC75901A0F0073B5BB8356E7 /* TestConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestConstants.swift; sourceTree = ""; }; FDC18D910D6FF2E8B1B6C885 /* SecureIdentityStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureIdentityStateManager.swift; sourceTree = ""; }; FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBLEService.swift; sourceTree = ""; }; + A1B2C3D4E5F60123456789AA /* MockKeychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockKeychain.swift; sourceTree = ""; }; + A1B2C3D4E5F60123456789AB /* MockIdentityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockIdentityManager.swift; sourceTree = ""; }; FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "bitchat-macOS.entitlements"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -464,6 +470,8 @@ children = ( C27328EE574221395B2B8E87 /* MockBluetoothMeshService.swift */, FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */, + A1B2C3D4E5F60123456789AA /* MockKeychain.swift */, + A1B2C3D4E5F60123456789AB /* MockIdentityManager.swift */, ); path = Mocks; sourceTree = ""; @@ -949,6 +957,8 @@ 047502932E547ACC0083520F /* LocationChannelsTests.swift in Sources */, 048A4C2B2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */, 6C803BF930E7E19BE6E99EAA /* MockBLEService.swift in Sources */, + A1B2C3D4E5F60123456789BB /* MockKeychain.swift in Sources */, + A1B2C3D4E5F60123456789BD /* MockIdentityManager.swift in Sources */, 765254F56997F01054699AC0 /* NoiseProtocolTests.swift in Sources */, 968181D255CA7A804340B4DA /* NostrProtocolTests.swift in Sources */, ED83C7AC1E6BEF15389C0132 /* PrivateChatE2ETests.swift in Sources */, @@ -973,6 +983,9 @@ 047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */, 048A4C2C2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */, 3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */, + A1B2C3D4E5F60123456789BA /* MockKeychain.swift in Sources */, + + A1B2C3D4E5F60123456789BC /* MockIdentityManager.swift in Sources */, BC4DC75F4FB823FF40569676 /* NoiseProtocolTests.swift in Sources */, EE8C3ECADAB3083A2687D50B /* NostrProtocolTests.swift in Sources */, 0AE840940F21AFC07C226636 /* PrivateChatE2ETests.swift in Sources */, diff --git a/bitchat/BitchatApp.swift b/bitchat/BitchatApp.swift index db060dcb..812f738f 100644 --- a/bitchat/BitchatApp.swift +++ b/bitchat/BitchatApp.swift @@ -11,7 +11,7 @@ import UserNotifications @main struct BitchatApp: App { - @StateObject private var chatViewModel = ChatViewModel() + @StateObject private var chatViewModel: ChatViewModel #if os(iOS) @Environment(\.scenePhase) var scenePhase @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @@ -23,6 +23,14 @@ struct BitchatApp: App { #endif init() { + let keychain = KeychainManager() + _chatViewModel = StateObject( + wrappedValue: ChatViewModel( + keychain: keychain, + identityManager: SecureIdentityStateManager(keychain) + ) + ) + UNUserNotificationCenter.current().delegate = NotificationDelegate.shared // Warm up georelay directory and refresh if stale (once/day) GeoRelayDirectory.shared.prefetchIfNeeded() diff --git a/bitchat/Identity/SecureIdentityStateManager.swift b/bitchat/Identity/SecureIdentityStateManager.swift index 2fda9a2d..2b0aaab8 100644 --- a/bitchat/Identity/SecureIdentityStateManager.swift +++ b/bitchat/Identity/SecureIdentityStateManager.swift @@ -93,13 +93,51 @@ import Foundation import CryptoKit +protocol SecureIdentityStateManagerProtocol { + // MARK: Secure Loading/Saving + func forceSave() + + // MARK: Social Identity Management + func getSocialIdentity(for fingerprint: String) -> SocialIdentity? + + // MARK: Cryptographic Identities + func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?) + func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] + func updateSocialIdentity(_ identity: SocialIdentity) + + // MARK: Favorites Management + func getFavorites() -> Set + func setFavorite(_ fingerprint: String, isFavorite: Bool) + func isFavorite(fingerprint: String) -> Bool + + // MARK: Blocked Users Management + func isBlocked(fingerprint: String) -> Bool + func setBlocked(_ fingerprint: String, isBlocked: Bool) + + // MARK: Geohash (Nostr) Blocking + func isNostrBlocked(pubkeyHexLowercased: String) -> Bool + func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) + func getBlockedNostrPubkeys() -> Set + + // MARK: Ephemeral Session Management + func registerEphemeralSession(peerID: String, handshakeState: HandshakeState) + func updateHandshakeState(peerID: String, state: HandshakeState) + + // MARK: Cleanup + func clearAllIdentityData() + func removeEphemeralSession(peerID: String) + + // MARK: Verification + func setVerified(fingerprint: String, verified: Bool) + func isVerified(fingerprint: String) -> Bool + func getVerifiedFingerprints() -> Set +} + /// Singleton manager for secure identity state persistence and retrieval. /// Provides thread-safe access to identity mappings with encryption at rest. /// All identity data is stored encrypted in the device Keychain for security. -final class SecureIdentityStateManager { - static let shared = SecureIdentityStateManager() - - private let keychain = KeychainManager.shared +final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { + private let keychain: KeychainManagerProtocol private let cacheKey = "bitchat.identityCache.v2" private let encryptionKeyName = "identityCacheEncryptionKey" @@ -108,9 +146,6 @@ final class SecureIdentityStateManager { private var cryptographicIdentities: [String: CryptographicIdentity] = [:] private var cache: IdentityCache = IdentityCache() - // Pending actions before handshake - private var pendingActions: [String: PendingActions] = [:] - // Thread safety private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent) @@ -122,7 +157,9 @@ final class SecureIdentityStateManager { // Encryption key private let encryptionKey: SymmetricKey - private init() { + init(_ keychain: KeychainManagerProtocol) { + self.keychain = keychain + // Generate or retrieve encryption key from keychain let loadedKey: SymmetricKey @@ -146,9 +183,13 @@ final class SecureIdentityStateManager { loadIdentityCache() } + deinit { + forceSave() + } + // MARK: - Secure Loading/Saving - func loadIdentityCache() { + private func loadIdentityCache() { guard let encryptedData = keychain.getIdentityKey(forKey: cacheKey) else { // No existing cache, start fresh return @@ -164,12 +205,7 @@ final class SecureIdentityStateManager { } } - deinit { - // Force save any pending changes - forceSave() - } - - func saveIdentityCache() { + private func saveIdentityCache() { // Mark that we need to save pendingSave = true @@ -201,25 +237,7 @@ final class SecureIdentityStateManager { // Force immediate save (for app termination) func forceSave() { saveTimer?.invalidate() - if pendingSave { - performSave() - } - } - - // MARK: - Identity Resolution - - func resolveIdentity(peerID: String, claimedNickname: String) -> IdentityHint { - queue.sync { - // Check if we have candidates based on nickname - if let fingerprints = cache.nicknameIndex[claimedNickname] { - if fingerprints.count == 1 { - return .likelyKnown(fingerprint: fingerprints.first!) - } else { - return .ambiguous(candidates: fingerprints) - } - } - return .unknown - } + performSave() } // MARK: - Social Identity Management @@ -301,11 +319,6 @@ final class SecureIdentityStateManager { } } - /// Retrieve cryptographic identity by fingerprint - func getCryptographicIdentity(for fingerprint: String) -> CryptographicIdentity? { - queue.sync { cryptographicIdentities[fingerprint] } - } - /// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] { queue.sync { @@ -315,12 +328,6 @@ final class SecureIdentityStateManager { } } - func getAllSocialIdentities() -> [SocialIdentity] { - queue.sync { - return Array(cache.socialIdentities.values) - } - } - func updateSocialIdentity(_ identity: SocialIdentity) { queue.async(flags: .barrier) { self.cache.socialIdentities[identity.fingerprint] = identity @@ -469,53 +476,6 @@ final class SecureIdentityStateManager { } } - func getHandshakeState(peerID: String) -> HandshakeState? { - queue.sync { - return ephemeralSessions[peerID]?.handshakeState - } - } - - // MARK: - Pending Actions - - func setPendingAction(peerID: String, action: PendingActions) { - queue.async(flags: .barrier) { - self.pendingActions[peerID] = action - } - } - - func applyPendingActions(peerID: String, fingerprint: String) { - queue.async(flags: .barrier) { - guard let actions = self.pendingActions[peerID] else { return } - - // Get or create social identity - var identity = self.cache.socialIdentities[fingerprint] ?? SocialIdentity( - fingerprint: fingerprint, - localPetname: nil, - claimedNickname: "Unknown", - trustLevel: .unknown, - isFavorite: false, - isBlocked: false, - notes: nil - ) - - // Apply pending actions - if let toggleFavorite = actions.toggleFavorite { - identity.isFavorite = toggleFavorite - } - if let trustLevel = actions.setTrustLevel { - identity.trustLevel = trustLevel - } - if let petname = actions.setPetname { - identity.localPetname = petname - } - - // Save updated identity - self.cache.socialIdentities[fingerprint] = identity - self.pendingActions.removeValue(forKey: peerID) - self.saveIdentityCache() - } - } - // MARK: - Cleanup func clearAllIdentityData() { @@ -525,7 +485,6 @@ final class SecureIdentityStateManager { self.cache = IdentityCache() self.ephemeralSessions.removeAll() self.cryptographicIdentities.removeAll() - self.pendingActions.removeAll() // Delete from keychain let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey) @@ -536,7 +495,6 @@ final class SecureIdentityStateManager { func removeEphemeralSession(peerID: String) { queue.async(flags: .barrier) { self.ephemeralSessions.removeValue(forKey: peerID) - self.pendingActions.removeValue(forKey: peerID) } } diff --git a/bitchat/Noise/NoiseProtocol.swift b/bitchat/Noise/NoiseProtocol.swift index c40e3c5c..838599fd 100644 --- a/bitchat/Noise/NoiseProtocol.swift +++ b/bitchat/Noise/NoiseProtocol.swift @@ -490,6 +490,7 @@ final class NoiseSymmetricState { final class NoiseHandshakeState { private let role: NoiseRole private let pattern: NoisePattern + private let keychain: KeychainManagerProtocol private var symmetricState: NoiseSymmetricState // Keys @@ -505,9 +506,16 @@ final class NoiseHandshakeState { private var messagePatterns: [[NoiseMessagePattern]] = [] private var currentPattern = 0 - init(role: NoiseRole, pattern: NoisePattern, localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil, remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil) { + init( + role: NoiseRole, + pattern: NoisePattern, + keychain: KeychainManagerProtocol, + localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil, + remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil + ) { self.role = role self.pattern = pattern + self.keychain = keychain // Initialize static keys if let localKey = localStaticKey { @@ -578,7 +586,7 @@ final class NoiseHandshakeState { var sharedData = shared.withUnsafeBytes { Data($0) } symmetricState.mixKey(sharedData) // Clear sensitive shared secret - KeychainManager.secureClear(&sharedData) + keychain.secureClear(&sharedData) case .es: // DH(ephemeral, static) - direction depends on role @@ -626,7 +634,7 @@ final class NoiseHandshakeState { var sharedData = shared.withUnsafeBytes { Data($0) } symmetricState.mixKey(sharedData) // Clear sensitive shared secret - KeychainManager.secureClear(&sharedData) + keychain.secureClear(&sharedData) } } @@ -714,7 +722,7 @@ final class NoiseHandshakeState { var sharedData = shared.withUnsafeBytes { Data($0) } symmetricState.mixKey(sharedData) // Clear sensitive shared secret - KeychainManager.secureClear(&sharedData) + keychain.secureClear(&sharedData) } else { guard let localStatic = localStaticPrivate, let remoteEphemeral = remoteEphemeralPublic else { @@ -724,7 +732,7 @@ final class NoiseHandshakeState { var sharedData = shared.withUnsafeBytes { Data($0) } symmetricState.mixKey(sharedData) // Clear sensitive shared secret - KeychainManager.secureClear(&sharedData) + keychain.secureClear(&sharedData) } case .se: @@ -737,7 +745,7 @@ final class NoiseHandshakeState { var sharedData = shared.withUnsafeBytes { Data($0) } symmetricState.mixKey(sharedData) // Clear sensitive shared secret - KeychainManager.secureClear(&sharedData) + keychain.secureClear(&sharedData) } else { guard let localEphemeral = localEphemeralPrivate, let remoteStatic = remoteStaticPublic else { @@ -747,7 +755,7 @@ final class NoiseHandshakeState { var sharedData = shared.withUnsafeBytes { Data($0) } symmetricState.mixKey(sharedData) // Clear sensitive shared secret - KeychainManager.secureClear(&sharedData) + keychain.secureClear(&sharedData) } case .ss: diff --git a/bitchat/Noise/NoiseSession.swift b/bitchat/Noise/NoiseSession.swift index 3e654c65..bc8b58d1 100644 --- a/bitchat/Noise/NoiseSession.swift +++ b/bitchat/Noise/NoiseSession.swift @@ -36,6 +36,7 @@ enum NoiseSessionState: Equatable { class NoiseSession { let peerID: String let role: NoiseRole + private let keychain: KeychainManagerProtocol private var state: NoiseSessionState = .uninitialized private var handshakeState: NoiseHandshakeState? private var sendCipher: NoiseCipherState? @@ -52,9 +53,16 @@ class NoiseSession { // Thread safety private let sessionQueue = DispatchQueue(label: "chat.bitchat.noise.session", attributes: .concurrent) - init(peerID: String, role: NoiseRole, localStaticKey: Curve25519.KeyAgreement.PrivateKey, remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil) { + init( + peerID: String, + role: NoiseRole, + keychain: KeychainManagerProtocol, + localStaticKey: Curve25519.KeyAgreement.PrivateKey, + remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil + ) { self.peerID = peerID self.role = role + self.keychain = keychain self.localStaticKey = localStaticKey self.remoteStaticPublicKey = remoteStaticKey } @@ -71,6 +79,7 @@ class NoiseSession { handshakeState = NoiseHandshakeState( role: role, pattern: .XX, + keychain: keychain, localStaticKey: localStaticKey, remoteStaticKey: nil ) @@ -98,6 +107,7 @@ class NoiseSession { handshakeState = NoiseHandshakeState( role: role, pattern: .XX, + keychain: keychain, localStaticKey: localStaticKey, remoteStaticKey: nil ) @@ -230,13 +240,13 @@ class NoiseSession { // Clear sent handshake messages for i in 0.. Void)? var onSessionFailed: ((String, Error) -> Void)? - init(localStaticKey: Curve25519.KeyAgreement.PrivateKey) { + init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) { self.localStaticKey = localStaticKey + self.keychain = keychain } // MARK: - Session Management @@ -269,6 +281,7 @@ final class NoiseSessionManager { let session = SecureNoiseSession( peerID: peerID, role: role, + keychain: keychain, localStaticKey: localStaticKey ) sessions[peerID] = session @@ -320,6 +333,7 @@ final class NoiseSessionManager { let session = SecureNoiseSession( peerID: peerID, role: .initiator, + keychain: keychain, localStaticKey: localStaticKey ) sessions[peerID] = session @@ -370,6 +384,7 @@ final class NoiseSessionManager { let newSession = SecureNoiseSession( peerID: peerID, role: .responder, + keychain: keychain, localStaticKey: localStaticKey ) sessions[peerID] = newSession diff --git a/bitchat/Services/BLEService.swift b/bitchat/Services/BLEService.swift index 6d44a94c..509dbd6f 100644 --- a/bitchat/Services/BLEService.swift +++ b/bitchat/Services/BLEService.swift @@ -88,7 +88,8 @@ final class BLEService: NSObject { var myPeerID: String = "" var myNickname: String = "anon" - private let noiseService = NoiseEncryptionService() + private let noiseService: NoiseEncryptionService + private let identityManager: SecureIdentityStateManagerProtocol private var myPeerIDData: Data = Data() // MARK: - Advertising Privacy @@ -325,7 +326,9 @@ final class BLEService: NSObject { } } - override init() { + init(keychain: KeychainManagerProtocol, identityManager: SecureIdentityStateManagerProtocol) { + noiseService = NoiseEncryptionService(keychain: keychain) + self.identityManager = identityManager super.init() // Derive stable peer ID from Noise static public key fingerprint (first 8 bytes → 16 hex chars) @@ -1548,7 +1551,7 @@ final class BLEService: NSObject { // Derive fingerprint from Noise public key let hash = SHA256.hash(data: announcement.noisePublicKey) let fingerprint = hash.map { String(format: "%02x", $0) }.joined() - SecureIdentityStateManager.shared.upsertCryptographicIdentity( + identityManager.upsertCryptographicIdentity( fingerprint: fingerprint, noisePublicKey: announcement.noisePublicKey, signingPublicKey: announcement.signingPublicKey, @@ -1630,13 +1633,13 @@ final class BLEService: NSObject { // Fallback: verify signature using persisted signing key for this peerID's fingerprint prefix if let signature = packet.signature, let packetData = packet.toBinaryDataForSigning() { // Find candidate identities by peerID prefix (16 hex) - let candidates = SecureIdentityStateManager.shared.getCryptoIdentitiesByPeerIDPrefix(peerID) + let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID) for candidate in candidates { if let signingKey = candidate.signingPublicKey, noiseService.verifySignature(signature, for: packetData, publicKey: signingKey) { accepted = true // Prefer persisted social petname or claimed nickname - if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: candidate.fingerprint) { + if let social = identityManager.getSocialIdentity(for: candidate.fingerprint) { senderNickname = social.localPetname ?? social.claimedNickname } else { senderNickname = "anon" + String(peerID.prefix(4)) diff --git a/bitchat/Services/CommandProcessor.swift b/bitchat/Services/CommandProcessor.swift index f46b56b4..57b27bc4 100644 --- a/bitchat/Services/CommandProcessor.swift +++ b/bitchat/Services/CommandProcessor.swift @@ -20,10 +20,12 @@ enum CommandResult { final class CommandProcessor { weak var chatViewModel: ChatViewModel? weak var meshService: Transport? + private let identityManager: SecureIdentityStateManagerProtocol - init(chatViewModel: ChatViewModel? = nil, meshService: Transport? = nil) { + init(chatViewModel: ChatViewModel? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) { self.chatViewModel = chatViewModel self.meshService = meshService + self.identityManager = identityManager } /// Process a command string @@ -189,7 +191,7 @@ final class CommandProcessor { } // Geohash blocked names (prefer visible display names; fallback to #suffix) - let geoBlocked = Array(SecureIdentityStateManager.shared.getBlockedNostrPubkeys()) + let geoBlocked = Array(identityManager.getBlockedNostrPubkeys()) var geoNames: [String] = [] if let vm = chatViewModel { let visible = vm.visibleGeohashPeople() @@ -213,14 +215,14 @@ final class CommandProcessor { if let peerID = chatViewModel?.getPeerIDForNickname(nickname), let fingerprint = meshService?.getFingerprint(for: peerID) { - if SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) { + if identityManager.isBlocked(fingerprint: fingerprint) { return .success(message: "\(nickname) is already blocked") } // Block the user (mesh/noise identity) - if var identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint) { + if var identity = identityManager.getSocialIdentity(for: fingerprint) { identity.isBlocked = true identity.isFavorite = false - SecureIdentityStateManager.shared.updateSocialIdentity(identity) + identityManager.updateSocialIdentity(identity) } else { let blockedIdentity = SocialIdentity( fingerprint: fingerprint, @@ -231,16 +233,16 @@ final class CommandProcessor { isBlocked: true, notes: nil ) - SecureIdentityStateManager.shared.updateSocialIdentity(blockedIdentity) + identityManager.updateSocialIdentity(blockedIdentity) } return .success(message: "blocked \(nickname). you will no longer receive messages from them") } // Mesh lookup failed; try geohash (Nostr) participant by display name if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) { - if SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: pub) { + if identityManager.isNostrBlocked(pubkeyHexLowercased: pub) { return .success(message: "\(nickname) is already blocked") } - SecureIdentityStateManager.shared.setNostrBlocked(pub, isBlocked: true) + identityManager.setNostrBlocked(pub, isBlocked: true) return .success(message: "blocked \(nickname) in geohash chats") } @@ -257,18 +259,18 @@ final class CommandProcessor { if let peerID = chatViewModel?.getPeerIDForNickname(nickname), let fingerprint = meshService?.getFingerprint(for: peerID) { - if !SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) { + if !identityManager.isBlocked(fingerprint: fingerprint) { return .success(message: "\(nickname) is not blocked") } - SecureIdentityStateManager.shared.setBlocked(fingerprint, isBlocked: false) + identityManager.setBlocked(fingerprint, isBlocked: false) return .success(message: "unblocked \(nickname)") } // Try geohash unblock if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) { - if !SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: pub) { + if !identityManager.isNostrBlocked(pubkeyHexLowercased: pub) { return .success(message: "\(nickname) is not blocked") } - SecureIdentityStateManager.shared.setNostrBlocked(pub, isBlocked: false) + identityManager.setNostrBlocked(pub, isBlocked: false) return .success(message: "unblocked \(nickname) in geohash chats") } return .error(message: "cannot unblock \(nickname): not found") diff --git a/bitchat/Services/KeychainManager.swift b/bitchat/Services/KeychainManager.swift index d5c129e3..a2ca6b3f 100644 --- a/bitchat/Services/KeychainManager.swift +++ b/bitchat/Services/KeychainManager.swift @@ -9,16 +9,23 @@ import Foundation import Security -final class KeychainManager { - static let shared = KeychainManager() +protocol KeychainManagerProtocol { + func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool + func getIdentityKey(forKey key: String) -> Data? + func deleteIdentityKey(forKey key: String) -> Bool + func deleteAllKeychainData() -> Bool + func secureClear(_ data: inout Data) + func secureClear(_ string: inout String) + + func verifyIdentityKeyExists() -> Bool +} + +final class KeychainManager: KeychainManagerProtocol { // Use consistent service name for all keychain items private let service = "chat.bitchat" private let appGroup = "group.chat.bitchat" - private init() {} - - private func isSandboxed() -> Bool { #if os(macOS) // More robust sandbox detection using multiple methods @@ -310,7 +317,7 @@ final class KeychainManager { // MARK: - Security Utilities /// Securely clear sensitive data from memory - static func secureClear(_ data: inout Data) { + func secureClear(_ data: inout Data) { _ = data.withUnsafeMutableBytes { bytes in // Use volatile memset to prevent compiler optimization memset_s(bytes.baseAddress, bytes.count, 0, bytes.count) @@ -319,7 +326,7 @@ final class KeychainManager { } /// Securely clear sensitive string from memory - static func secureClear(_ string: inout String) { + func secureClear(_ string: inout String) { // Convert to mutable data and clear if var data = string.data(using: .utf8) { secureClear(&data) diff --git a/bitchat/Services/NoiseEncryptionService.swift b/bitchat/Services/NoiseEncryptionService.swift index 923575e5..a4757988 100644 --- a/bitchat/Services/NoiseEncryptionService.swift +++ b/bitchat/Services/NoiseEncryptionService.swift @@ -155,6 +155,7 @@ final class NoiseEncryptionService { // Security components private let rateLimiter = NoiseRateLimiter() + private let keychain: KeychainManagerProtocol // Session maintenance private var rekeyTimer: Timer? @@ -181,12 +182,14 @@ final class NoiseEncryptionService { } } - init() { + init(keychain: KeychainManagerProtocol) { + self.keychain = keychain + // Load or create static identity key (ONLY from keychain) let loadedKey: Curve25519.KeyAgreement.PrivateKey // Try to load from keychain - if let identityData = KeychainManager.shared.getIdentityKey(forKey: "noiseStaticKey"), + if let identityData = keychain.getIdentityKey(forKey: "noiseStaticKey"), let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) { loadedKey = key SecureLogger.logKeyOperation(.load, keyType: "noiseStaticKey", success: true) @@ -197,7 +200,7 @@ final class NoiseEncryptionService { let keyData = loadedKey.rawRepresentation // Save to keychain - let saved = KeychainManager.shared.saveIdentityKey(keyData, forKey: "noiseStaticKey") + let saved = keychain.saveIdentityKey(keyData, forKey: "noiseStaticKey") SecureLogger.logKeyOperation(.create, keyType: "noiseStaticKey", success: saved) } @@ -209,7 +212,7 @@ final class NoiseEncryptionService { let loadedSigningKey: Curve25519.Signing.PrivateKey // Try to load from keychain - if let signingData = KeychainManager.shared.getIdentityKey(forKey: "ed25519SigningKey"), + if let signingData = keychain.getIdentityKey(forKey: "ed25519SigningKey"), let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) { loadedSigningKey = key SecureLogger.logKeyOperation(.load, keyType: "ed25519SigningKey", success: true) @@ -220,7 +223,7 @@ final class NoiseEncryptionService { let keyData = loadedSigningKey.rawRepresentation // Save to keychain - let saved = KeychainManager.shared.saveIdentityKey(keyData, forKey: "ed25519SigningKey") + let saved = keychain.saveIdentityKey(keyData, forKey: "ed25519SigningKey") SecureLogger.logKeyOperation(.create, keyType: "ed25519SigningKey", success: saved) } @@ -229,7 +232,7 @@ final class NoiseEncryptionService { self.signingPublicKey = signingKey.publicKey // Initialize session manager - self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey) + self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain) // Set up session callbacks sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in @@ -266,8 +269,8 @@ final class NoiseEncryptionService { /// Clear persistent identity (for panic mode) func clearPersistentIdentity() { // Clear from keychain - let deletedStatic = KeychainManager.shared.deleteIdentityKey(forKey: "noiseStaticKey") - let deletedSigning = KeychainManager.shared.deleteIdentityKey(forKey: "ed25519SigningKey") + let deletedStatic = keychain.deleteIdentityKey(forKey: "noiseStaticKey") + let deletedSigning = keychain.deleteIdentityKey(forKey: "ed25519SigningKey") SecureLogger.logKeyOperation(.delete, keyType: "identity keys", success: deletedStatic && deletedSigning) SecureLogger.warning("Panic mode activated - identity cleared", category: .security) // Stop rekey timer diff --git a/bitchat/Services/NostrTransport.swift b/bitchat/Services/NostrTransport.swift index fc7640c8..65c99af1 100644 --- a/bitchat/Services/NostrTransport.swift +++ b/bitchat/Services/NostrTransport.swift @@ -21,10 +21,15 @@ final class NostrTransport: Transport { private var readQueue: [QueuedRead] = [] private var isSendingReadAcks = false private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval + private let keychain: KeychainManagerProtocol var myPeerID: String { senderPeerID } var myNickname: String { "" } func setNickname(_ nickname: String) { /* not used for Nostr */ } + + init(keychain: KeychainManagerProtocol) { + self.keychain = keychain + } func startServices() { /* no-op */ } func stopServices() { /* no-op */ } @@ -38,11 +43,17 @@ final class NostrTransport: Transport { func getFingerprint(for peerID: String) -> String? { nil } func getNoiseSessionState(for peerID: String) -> LazyHandshakeState { .none } func triggerHandshake(with peerID: String) { /* no-op */ } + // Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation - private static var cachedNoiseService: NoiseEncryptionService = { - NoiseEncryptionService() - }() - func getNoiseService() -> NoiseEncryptionService { Self.cachedNoiseService } + private static var cachedNoiseService: NoiseEncryptionService? + func getNoiseService() -> NoiseEncryptionService { + if let noiseService = Self.cachedNoiseService { + return noiseService + } + let noiseService = NoiseEncryptionService(keychain: keychain) + Self.cachedNoiseService = noiseService + return noiseService + } // Public broadcast not supported over Nostr here func sendMessage(_ content: String, mentions: [String]) { /* no-op */ } diff --git a/bitchat/Services/UnifiedPeerService.swift b/bitchat/Services/UnifiedPeerService.swift index 30e91056..b7082b1c 100644 --- a/bitchat/Services/UnifiedPeerService.swift +++ b/bitchat/Services/UnifiedPeerService.swift @@ -27,14 +27,16 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { private var peerIndex: [String: BitchatPeer] = [:] private var fingerprintCache: [String: String] = [:] // peerID -> fingerprint private let meshService: Transport + private let identityManager: SecureIdentityStateManagerProtocol weak var messageRouter: MessageRouter? private let favoritesService = FavoritesPersistenceService.shared private var cancellables = Set() // MARK: - Initialization - init(meshService: Transport) { + init(meshService: Transport, identityManager: SecureIdentityStateManagerProtocol) { self.meshService = meshService + self.identityManager = identityManager // Subscribe to changes from both services setupSubscriptions() @@ -174,7 +176,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { // Determine reachability based on lastSeen and identity trust let now = Date() let fingerprint = peerInfo.noisePublicKey?.sha256Fingerprint() - let isVerified = fingerprint.map { SecureIdentityStateManager.shared.isVerified(fingerprint: $0) } ?? false + let isVerified = fingerprint.map { identityManager.isVerified(fingerprint: $0) } ?? false let isFav = peerInfo.noisePublicKey.flatMap { favorites[$0]?.isFavorite } ?? false let retention: TimeInterval = (isVerified || isFav) ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds // A peer is reachable if we recently saw them AND we are attached to the mesh @@ -247,7 +249,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { guard let fingerprint = getFingerprint(for: peerID) else { return false } // Check SecureIdentityStateManager for block status - if let identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint) { + if let identity = identityManager.getSocialIdentity(for: fingerprint) { return identity.isBlocked } @@ -324,7 +326,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { guard let fingerprint = getFingerprint(for: peerID) else { return } // Get or create social identity - var identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint) + var identity = identityManager.getSocialIdentity(for: fingerprint) ?? SocialIdentity( fingerprint: fingerprint, localPetname: nil, @@ -347,7 +349,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { } } - SecureIdentityStateManager.shared.updateSocialIdentity(identity) + identityManager.updateSocialIdentity(identity) } /// Get fingerprint for peer ID diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 8605744d..ff537ec1 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -344,13 +344,16 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // MARK: - Services and Storage - var meshService: Transport = BLEService() + let meshService: Transport + let identityManager: SecureIdentityStateManagerProtocol + private var nostrRelayManager: NostrRelayManager? // PeerManager replaced by UnifiedPeerService private var processedNostrEvents = Set() // Simple deduplication private var processedNostrEventOrder: [String] = [] private let maxProcessedNostrEvents = TransportConfig.uiProcessedNostrEventsCap private let userDefaults = UserDefaults.standard + private let keychain: KeychainManagerProtocol private let nicknameKey = "bitchat.nickname" // Location channel state (macOS supports manual geohash selection) @Published private var activeChannel: ChannelID = .mesh @@ -485,7 +488,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // MARK: - Initialization @MainActor - init() { + init( + keychain: KeychainManagerProtocol, + identityManager: SecureIdentityStateManagerProtocol + ) { + self.keychain = keychain + self.identityManager = identityManager + self.meshService = BLEService(keychain: keychain, identityManager: identityManager) + // Load persisted read receipts if let data = UserDefaults.standard.data(forKey: "sentReadReceipts"), let receipts = try? JSONDecoder().decode([String].self, from: data) { @@ -496,10 +506,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { } // Initialize services - self.commandProcessor = CommandProcessor() + self.commandProcessor = CommandProcessor(identityManager: identityManager) self.privateChatManager = PrivateChatManager(meshService: meshService) - self.unifiedPeerService = UnifiedPeerService(meshService: meshService) - let nostrTransport = NostrTransport() + self.unifiedPeerService = UnifiedPeerService(meshService: meshService, identityManager: identityManager) + let nostrTransport = NostrTransport(keychain: keychain) self.messageRouter = MessageRouter(mesh: meshService, nostr: nostrTransport) // Route receipts from PrivateChatManager through MessageRouter self.privateChatManager.messageRouter = self.messageRouter @@ -977,7 +987,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { let messageId = pm.messageID // Send delivery ACK immediately (once per message ID) if !self.sentGeoDeliveryAcks.contains(messageId) { - let nt = NostrTransport() + let nt = NostrTransport(keychain: keychain) nt.senderPeerID = self.meshService.myPeerID nt.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubkey, from: id) self.sentGeoDeliveryAcks.insert(messageId) @@ -1005,7 +1015,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { deliveryStatus: .delivered(to: self.nickname, at: Date()) ) // Respect geohash blocks - if SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: senderPubkey) { + if identityManager.isNostrBlocked(pubkeyHexLowercased: senderPubkey) { return } if self.privateChats[convKey] == nil { self.privateChats[convKey] = [] } @@ -1015,7 +1025,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { if isViewing { // pared back: omit pre-send READ log if !wasReadBefore { - let nt = NostrTransport() + let nt = NostrTransport(keychain: keychain) nt.senderPeerID = self.meshService.myPeerID nt.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubkey, from: id) self.sentReadReceipts.insert(messageId) @@ -1238,7 +1248,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { } // Update identity state manager with handshake completion - SecureIdentityStateManager.shared.updateHandshakeState(peerID: peerID, state: .completed(fingerprint: fingerprintStr)) + identityManager.updateHandshakeState(peerID: peerID, state: .completed(fingerprint: fingerprintStr)) // Update encryption status now that we have the fingerprint updateEncryptionStatus(for: peerID) @@ -1247,9 +1257,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { let peerNicknames = meshService.getPeerNicknames() if let nickname = peerNicknames[peerID], nickname != "Unknown" && nickname != "anon\(peerID.prefix(4))" { // Update or create social identity with the claimed nickname - if var identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprintStr) { + if var identity = identityManager.getSocialIdentity(for: fingerprintStr) { identity.claimedNickname = nickname - SecureIdentityStateManager.shared.updateSocialIdentity(identity) + identityManager.updateSocialIdentity(identity) } else { let newIdentity = SocialIdentity( fingerprint: fingerprintStr, @@ -1260,7 +1270,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { isBlocked: false, notes: nil ) - SecureIdentityStateManager.shared.updateSocialIdentity(newIdentity) + identityManager.updateSocialIdentity(newIdentity) } } @@ -1621,7 +1631,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { self.geoNicknames[event.pubkey.lowercased()] = nick } // If this pubkey is blocked, skip mapping, participants, and timeline - if SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: event.pubkey) { + if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey) { return } // Store mapping for geohash DM initiation @@ -1699,7 +1709,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { SecureLogger.info("GeoDM: recv PM <- sender=\(senderPubkey.prefix(8))… mid=\(messageId.prefix(8))…", category: .session) // Send delivery ACK immediately (even if duplicate), once per messageID if !self.sentGeoDeliveryAcks.contains(messageId) { - let nostrTransport = NostrTransport() + let nostrTransport = NostrTransport(keychain: keychain) nostrTransport.senderPeerID = self.meshService.myPeerID // pared back: omit pre-send log nostrTransport.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubkey, from: id) @@ -1734,7 +1744,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { if isViewing { // pared back: omit pre-send READ log if !wasReadBefore { - let nostrTransport = NostrTransport() + let nostrTransport = NostrTransport(keychain: keychain) nostrTransport.senderPeerID = self.meshService.myPeerID nostrTransport.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubkey, from: id) self.sentReadReceipts.insert(messageId) @@ -1819,7 +1829,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // Prune expired entries map = map.filter { $0.value >= cutoff } // Remove blocked Nostr pubkeys - map = map.filter { !SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: $0.key) } + map = map.filter { !identityManager.isNostrBlocked(pubkeyHexLowercased: $0.key) } geoParticipants[gh] = map // Build display list let people = map @@ -1852,7 +1862,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds) let map = (geoParticipants[gh] ?? [:]) .filter { $0.value >= cutoff } - .filter { !SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: $0.key) } + .filter { !identityManager.isNostrBlocked(pubkeyHexLowercased: $0.key) } let people = map .map { (pub, seen) in GeoPerson(id: pub, displayName: displayNameForNostrPubkey(pub), lastSeen: seen) } .sorted { $0.lastSeen > $1.lastSeen } @@ -1869,12 +1879,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // Geohash block helpers @MainActor func isGeohashUserBlocked(pubkeyHexLowercased: String) -> Bool { - return SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased) + return identityManager.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased) } @MainActor func blockGeohashUser(pubkeyHexLowercased: String, displayName: String) { let hex = pubkeyHexLowercased.lowercased() - SecureIdentityStateManager.shared.setNostrBlocked(hex, isBlocked: true) + identityManager.setNostrBlocked(hex, isBlocked: true) // Remove from participants for all geohashes for (gh, var map) in geoParticipants { @@ -1922,7 +1932,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { } @MainActor func unblockGeohashUser(pubkeyHexLowercased: String, displayName: String) { - SecureIdentityStateManager.shared.setNostrBlocked(pubkeyHexLowercased, isBlocked: false) + identityManager.setNostrBlocked(pubkeyHexLowercased, isBlocked: false) addSystemMessage("unblocked \(displayName) in geohash chats") } @@ -1971,7 +1981,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines) guard !content.isEmpty else { return } // Respect geohash blocks - if SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return } + if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return } // Skip self identity for this geohash if let my = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh), my.publicKeyHex.lowercased() == event.pubkey.lowercased() { return } // Only trigger when there were zero participants in this geohash recently @@ -2123,7 +2133,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { return } // Respect geohash blocks - if SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: recipientHex) { + if identityManager.isNostrBlocked(pubkeyHexLowercased: recipientHex) { if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { privateChats[peerID]?[msgIdx].deliveryStatus = .failed(reason: "user is blocked") } @@ -2141,7 +2151,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { return } SecureLogger.debug("GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)", category: .session) - let nostrTransport = NostrTransport() + let nostrTransport = NostrTransport(keychain: keychain) nostrTransport.senderPeerID = meshService.myPeerID nostrTransport.sendPrivateMessageGeohash(content: content, toRecipientHex: recipientHex, from: id, messageID: messageID) if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { @@ -2799,15 +2809,15 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { meshService.stopServices() // Force save any pending identity changes (verifications, favorites, etc) - SecureIdentityStateManager.shared.forceSave() + identityManager.forceSave() // Verify identity key is still there - _ = KeychainManager.shared.verifyIdentityKeyExists() + _ = keychain.verifyIdentityKeyExists() // No need to force synchronize here // Verify identity key after save - _ = KeychainManager.shared.verifyIdentityKeyExists() + _ = keychain.verifyIdentityKeyExists() } @objc private func appWillTerminate() { @@ -2858,7 +2868,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { for message in messages where message.senderPeerID == peerID && !message.isRelay { if !sentReadReceipts.contains(message.id) { SecureLogger.debug("GeoDM: sending READ for mid=\(message.id.prefix(8))… to=\(recipientHex.prefix(8))…", category: .session) - let nostrTransport = NostrTransport() + let nostrTransport = NostrTransport(keychain: keychain) nostrTransport.senderPeerID = meshService.myPeerID nostrTransport.sendReadReceiptGeohash(message.id, toRecipientHex: recipientHex, from: id) sentReadReceipts.insert(message.id) @@ -3004,7 +3014,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { privateChatManager.unreadMessages.removeAll() // Delete all keychain data (including Noise and Nostr keys) - _ = KeychainManager.shared.deleteAllKeychainData() + _ = keychain.deleteAllKeychainData() // Clear UserDefaults identity data userDefaults.removeObject(forKey: "bitchat.noiseIdentityKey") @@ -3020,14 +3030,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // Clear favorites and peer mappings // Clear through SecureIdentityStateManager instead of directly - SecureIdentityStateManager.shared.clearAllIdentityData() + identityManager.clearAllIdentityData() peerIDToPublicKeyFingerprint.removeAll() // Clear persistent favorites from keychain FavoritesPersistenceService.shared.clearAllFavorites() // Clear identity data from secure storage - SecureIdentityStateManager.shared.clearAllIdentityData() + identityManager.clearAllIdentityData() // Clear autocomplete state autocompleteSuggestions.removeAll() @@ -4253,7 +4263,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // Try to resolve through fingerprint and social identity if let fingerprint = getFingerprint(for: peerID) { - if let identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint) { + if let identity = identityManager.getSocialIdentity(for: fingerprint) { // Prefer local petname if set if let petname = identity.localPetname { return petname @@ -4285,7 +4295,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { guard let fingerprint = getFingerprint(for: peerID) else { return } // Update secure storage with verified status - SecureIdentityStateManager.shared.setVerified(fingerprint: fingerprint, verified: true) + identityManager.setVerified(fingerprint: fingerprint, verified: true) // Update local set for UI verifiedFingerprints.insert(fingerprint) @@ -4297,8 +4307,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { @MainActor func unverifyFingerprint(for peerID: String) { guard let fingerprint = getFingerprint(for: peerID) else { return } - SecureIdentityStateManager.shared.setVerified(fingerprint: fingerprint, verified: false) - SecureIdentityStateManager.shared.forceSave() + identityManager.setVerified(fingerprint: fingerprint, verified: false) + identityManager.forceSave() verifiedFingerprints.remove(fingerprint) updateEncryptionStatus(for: peerID) } @@ -4306,7 +4316,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { @MainActor func loadVerifiedFingerprints() { // Load verified fingerprints directly from secure storage - verifiedFingerprints = SecureIdentityStateManager.shared.getVerifiedFingerprints() + verifiedFingerprints = identityManager.getVerifiedFingerprints() // Log snapshot for debugging persistence let sample = Array(verifiedFingerprints.prefix(TransportConfig.uiFingerprintSampleCount)).map { $0.prefix(8) }.joined(separator: ", ") SecureLogger.info("🔐 Verified loaded: \(verifiedFingerprints.count) [\(sample)]", category: .security) @@ -4506,8 +4516,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { if let fp = getFingerprint(for: peerID) { let short = fp.prefix(8) SecureLogger.info("🔐 Marking verified fingerprint: \(short)", category: .security) - SecureIdentityStateManager.shared.setVerified(fingerprint: fp, verified: true) - SecureIdentityStateManager.shared.forceSave() + identityManager.setVerified(fingerprint: fp, verified: true) + identityManager.forceSave() verifiedFingerprints.insert(fp) let name = unifiedPeerService.getPeer(by: peerID)?.nickname ?? resolveNickname(for: peerID) NotificationService.shared.sendLocalNotification( @@ -4598,7 +4608,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { isConnected = true // Register ephemeral session with identity manager - SecureIdentityStateManager.shared.registerEphemeralSession(peerID: peerID) + identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none) // Intentionally do not resend favorites on reconnect. // We only send our npub when a favorite is toggled on, or if our npub changes. @@ -4623,7 +4633,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { SecureLogger.debug("👋 Peer disconnected: \(peerID)", category: .session) // Remove ephemeral session from identity manager - SecureIdentityStateManager.shared.removeEphemeralSession(peerID: peerID) + identityManager.removeEphemeralSession(peerID: peerID) // If the open PM is tied to this short peer ID, switch UI context to the full Noise key (offline favorite) var derivedStableKeyHex: String? = shortIDToNoiseKey[peerID] @@ -4730,7 +4740,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // Register ephemeral sessions for all connected peers for peerID in peers { - SecureIdentityStateManager.shared.registerEphemeralSession(peerID: peerID) + self.identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none) } // Schedule UI refresh to ensure offline favorites are shown @@ -4882,7 +4892,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { } func isFavorite(fingerprint: String) -> Bool { - return SecureIdentityStateManager.shared.isFavorite(fingerprint: fingerprint) + return identityManager.isFavorite(fingerprint: fingerprint) } // MARK: - Delivery Tracking @@ -5212,7 +5222,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { messageRouter.sendDeliveryAck(messageId, to: key.hexEncodedString()) } else if let id = try? NostrIdentityBridge.getCurrentNostrIdentity() { // Fallback: no Noise mapping yet — send directly to sender's Nostr pubkey - let nt = NostrTransport() + let nt = NostrTransport(keychain: keychain) nt.senderPeerID = meshService.myPeerID nt.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubkey, from: id) SecureLogger.debug("Sent DELIVERED ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(messageId.prefix(8))…", category: .session) @@ -5234,7 +5244,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { messageRouter.sendReadReceipt(receipt, to: key.hexEncodedString()) sentReadReceipts.insert(messageId) } else if let id = try? NostrIdentityBridge.getCurrentNostrIdentity() { - let nt = NostrTransport() + let nt = NostrTransport(keychain: keychain) nt.senderPeerID = meshService.myPeerID nt.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubkey, from: id) sentReadReceipts.insert(messageId) @@ -5613,7 +5623,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // Check geohash (Nostr) blocks using mapping to full pubkey if peerID.hasPrefix("nostr") { if let full = nostrKeyMapping[peerID]?.lowercased() { - if SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: full) { return true } + if identityManager.isNostrBlocked(pubkeyHexLowercased: full) { return true } } } return false diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 921bebe7..dbc4285c 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -1247,15 +1247,15 @@ struct ContentView: View { !fav.peerNickname.isEmpty { return fav.peerNickname } // Fallback: resolve from persisted social identity via fingerprint mapping if headerPeerID.count == 16 { - let candidates = SecureIdentityStateManager.shared.getCryptoIdentitiesByPeerIDPrefix(headerPeerID) + let candidates = viewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID) if let id = candidates.first, - let social = SecureIdentityStateManager.shared.getSocialIdentity(for: id.fingerprint) { + let social = viewModel.identityManager.getSocialIdentity(for: id.fingerprint) { if let pet = social.localPetname, !pet.isEmpty { return pet } if !social.claimedNickname.isEmpty { return social.claimedNickname } } } else if headerPeerID.count == 64, let keyData = Data(hexString: headerPeerID) { let fp = keyData.sha256Fingerprint() - if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) { + if let social = viewModel.identityManager.getSocialIdentity(for: fp) { if let pet = social.localPetname, !pet.isEmpty { return pet } if !social.claimedNickname.isEmpty { return social.claimedNickname } } diff --git a/bitchat/Views/FingerprintView.swift b/bitchat/Views/FingerprintView.swift index b99a3739..a3d2541a 100644 --- a/bitchat/Views/FingerprintView.swift +++ b/bitchat/Views/FingerprintView.swift @@ -53,7 +53,7 @@ struct FingerprintView: View { if peerID.count == 64, let data = Data(hexString: peerID) { if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname } let fp = data.sha256Fingerprint() - if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) { + if let social = viewModel.identityManager.getSocialIdentity(for: fp) { if let pet = social.localPetname, !pet.isEmpty { return pet } if !social.claimedNickname.isEmpty { return social.claimedNickname } } diff --git a/bitchatTests/CommandProcessorTests.swift b/bitchatTests/CommandProcessorTests.swift index 75bbdaf8..071dad96 100644 --- a/bitchatTests/CommandProcessorTests.swift +++ b/bitchatTests/CommandProcessorTests.swift @@ -2,10 +2,23 @@ import XCTest @testable import bitchat final class CommandProcessorTests: XCTestCase { + + 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 func test_slap_notFoundGrammar() { - let processor = CommandProcessor(chatViewModel: nil, meshService: nil) + let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager) let result = processor.process("/slap @system") switch result { case .error(let message): @@ -17,7 +30,7 @@ final class CommandProcessorTests: XCTestCase { @MainActor func test_hug_notFoundGrammar() { - let processor = CommandProcessor(chatViewModel: nil, meshService: nil) + let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager) let result = processor.process("/hug @system") switch result { case .error(let message): @@ -29,7 +42,7 @@ final class CommandProcessorTests: XCTestCase { @MainActor func test_slap_usageMessage() { - let processor = CommandProcessor(chatViewModel: nil, meshService: nil) + let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager) let result = processor.process("/slap") switch result { case .error(let message): @@ -39,4 +52,3 @@ final class CommandProcessorTests: XCTestCase { } } } - diff --git a/bitchatTests/EndToEnd/PrivateChatE2ETests.swift b/bitchatTests/EndToEnd/PrivateChatE2ETests.swift index 2b306024..0c90e5ab 100644 --- a/bitchatTests/EndToEnd/PrivateChatE2ETests.swift +++ b/bitchatTests/EndToEnd/PrivateChatE2ETests.swift @@ -16,6 +16,8 @@ final class PrivateChatE2ETests: XCTestCase { var bob: MockBluetoothMeshService! var charlie: MockBluetoothMeshService! + private var mockKeychain: MockKeychain! + override func setUp() { super.setUp() MockBLEService.resetTestBus() @@ -24,6 +26,7 @@ final class PrivateChatE2ETests: XCTestCase { alice = createMockService(peerID: TestConstants.testPeerID1, nickname: TestConstants.testNickname1) bob = createMockService(peerID: TestConstants.testPeerID2, nickname: TestConstants.testNickname2) charlie = createMockService(peerID: TestConstants.testPeerID3, nickname: TestConstants.testNickname3) + mockKeychain = MockKeychain() // Delivery tracking is now handled internally by BLEService } @@ -32,6 +35,7 @@ final class PrivateChatE2ETests: XCTestCase { alice = nil bob = nil charlie = nil + mockKeychain = nil super.tearDown() } @@ -116,8 +120,8 @@ final class PrivateChatE2ETests: XCTestCase { let aliceKey = Curve25519.KeyAgreement.PrivateKey() let bobKey = Curve25519.KeyAgreement.PrivateKey() - let aliceManager = NoiseSessionManager(localStaticKey: aliceKey) - let bobManager = NoiseSessionManager(localStaticKey: bobKey) + let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) + let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) // Establish encrypted session do { diff --git a/bitchatTests/Fragmentation/FragmentationTests.swift b/bitchatTests/Fragmentation/FragmentationTests.swift index 4f63b029..ba3173b1 100644 --- a/bitchatTests/Fragmentation/FragmentationTests.swift +++ b/bitchatTests/Fragmentation/FragmentationTests.swift @@ -10,6 +10,21 @@ import XCTest @testable import bitchat final class FragmentationTests: XCTestCase { + + private var mockKeychain: MockKeychain! + private var mockIdentityManager: MockIdentityManager! + + override func setUp() { + super.setUp() + mockKeychain = MockKeychain() + mockIdentityManager = MockIdentityManager(mockKeychain) + } + + override func tearDown() { + mockKeychain = nil + mockIdentityManager = nil + super.tearDown() + } private final class CaptureDelegate: BitchatDelegate { var publicMessages: [(peerID: String, nickname: String, content: String)] = [] @@ -75,7 +90,7 @@ final class FragmentationTests: XCTestCase { } func test_reassembly_from_fragments_delivers_public_message() { - let ble = BLEService() + let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager) let capture = CaptureDelegate() ble.delegate = capture @@ -106,7 +121,7 @@ final class FragmentationTests: XCTestCase { } func test_duplicate_fragment_does_not_break_reassembly() { - let ble = BLEService() + let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager) let capture = CaptureDelegate() ble.delegate = capture @@ -132,7 +147,7 @@ final class FragmentationTests: XCTestCase { } func test_invalid_fragment_header_is_ignored() { - let ble = BLEService() + let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager) let capture = CaptureDelegate() ble.delegate = capture diff --git a/bitchatTests/Integration/IntegrationTests.swift b/bitchatTests/Integration/IntegrationTests.swift index 8e16a719..8824f657 100644 --- a/bitchatTests/Integration/IntegrationTests.swift +++ b/bitchatTests/Integration/IntegrationTests.swift @@ -14,6 +14,7 @@ final class IntegrationTests: XCTestCase { var nodes: [String: MockBluetoothMeshService] = [:] var noiseManagers: [String: NoiseSessionManager] = [:] + private var mockKeychain: MockKeychain! override func setUp() { super.setUp() @@ -21,6 +22,7 @@ final class IntegrationTests: XCTestCase { // broadcast propagation across a larger mesh. Integration-only. MockBLEService.resetTestBus() MockBLEService.autoFloodEnabled = true + mockKeychain = MockKeychain() // Create a network of nodes createNode("Alice", peerID: TestConstants.testPeerID1) @@ -34,6 +36,7 @@ final class IntegrationTests: XCTestCase { MockBLEService.autoFloodEnabled = false nodes.removeAll() noiseManagers.removeAll() + mockKeychain = nil super.tearDown() } @@ -307,7 +310,7 @@ final class IntegrationTests: XCTestCase { // Simulate Bob restart by recreating his Noise manager let bobKey = Curve25519.KeyAgreement.PrivateKey() - noiseManagers["Bob"] = NoiseSessionManager(localStaticKey: bobKey) + noiseManagers["Bob"] = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) // Re-establish Noise handshake explicitly via managers do { @@ -593,7 +596,7 @@ final class IntegrationTests: XCTestCase { // Create Noise manager let key = Curve25519.KeyAgreement.PrivateKey() - noiseManagers[name] = NoiseSessionManager(localStaticKey: key) + noiseManagers[name] = NoiseSessionManager(localStaticKey: key, keychain: mockKeychain) } private func connect(_ node1: String, _ node2: String) { diff --git a/bitchatTests/Mocks/MockBLEService.swift b/bitchatTests/Mocks/MockBLEService.swift index eccf1082..ca545c50 100644 --- a/bitchatTests/Mocks/MockBLEService.swift +++ b/bitchatTests/Mocks/MockBLEService.swift @@ -36,6 +36,8 @@ final class MockBLEService: NSObject { var myPeerID: String = "MOCK1234" var myNickname: String = "MockUser" + private let mockKeychain = MockKeychain() + // Test-specific properties var sentMessages: [(message: BitchatMessage, packet: BitchatPacket)] = [] var sentPackets: [BitchatPacket] = [] @@ -272,7 +274,7 @@ final class MockBLEService: NSObject { } func getNoiseService() -> NoiseEncryptionService { - return NoiseEncryptionService() + return NoiseEncryptionService(keychain: mockKeychain) } func getFingerprint(for peerID: String) -> String? { diff --git a/bitchatTests/Mocks/MockIdentityManager.swift b/bitchatTests/Mocks/MockIdentityManager.swift new file mode 100644 index 00000000..10eb9094 --- /dev/null +++ b/bitchatTests/Mocks/MockIdentityManager.swift @@ -0,0 +1,80 @@ +// +// MockIdentityManager.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +@testable import bitchat + +final class MockIdentityManager: SecureIdentityStateManagerProtocol { + private let keychain: KeychainManagerProtocol + + init(_ keychain: KeychainManagerProtocol) { + self.keychain = keychain + } + + func loadIdentityCache() {} + + func saveIdentityCache() {} + + func forceSave() {} + + func getSocialIdentity(for fingerprint: String) -> SocialIdentity? { + nil + } + + func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?) {} + + func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] { + [] + } + + func updateSocialIdentity(_ identity: SocialIdentity) {} + + func getFavorites() -> Set { + Set() + } + + func setFavorite(_ fingerprint: String, isFavorite: Bool) {} + + func isFavorite(fingerprint: String) -> Bool { + false + } + + func isBlocked(fingerprint: String) -> Bool { + false + } + + func setBlocked(_ fingerprint: String, isBlocked: Bool) {} + + func isNostrBlocked(pubkeyHexLowercased: String) -> Bool { + true + } + + func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) {} + + func getBlockedNostrPubkeys() -> Set { + Set() + } + + func registerEphemeralSession(peerID: String, handshakeState: HandshakeState) {} + + func updateHandshakeState(peerID: String, state: HandshakeState) {} + + func clearAllIdentityData() {} + + func removeEphemeralSession(peerID: String) {} + + func setVerified(fingerprint: String, verified: Bool) {} + + func isVerified(fingerprint: String) -> Bool { + true + } + + func getVerifiedFingerprints() -> Set { + Set() + } +} diff --git a/bitchatTests/Mocks/MockKeychain.swift b/bitchatTests/Mocks/MockKeychain.swift new file mode 100644 index 00000000..dfc42e17 --- /dev/null +++ b/bitchatTests/Mocks/MockKeychain.swift @@ -0,0 +1,46 @@ +// +// MockKeychain.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +@testable import bitchat + +final class MockKeychain: KeychainManagerProtocol { + private var storage: [String: Data] = [:] + + func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { + storage[key] = keyData + return true + } + + func getIdentityKey(forKey key: String) -> Data? { + storage[key] + } + + func deleteIdentityKey(forKey key: String) -> Bool { + storage.removeValue(forKey: key) + return true + } + + func deleteAllKeychainData() -> Bool { + storage.removeAll() + return true + } + + func secureClear(_ data: inout Data) { + // + data = Data() + } + + func secureClear(_ string: inout String) { + string = "" + } + + func verifyIdentityKeyExists() -> Bool { + storage["identity_noiseStaticKey"] != nil + } +} diff --git a/bitchatTests/Noise/NoiseProtocolTests.swift b/bitchatTests/Noise/NoiseProtocolTests.swift index ed278b4f..0b7ad2db 100644 --- a/bitchatTests/Noise/NoiseProtocolTests.swift +++ b/bitchatTests/Noise/NoiseProtocolTests.swift @@ -16,16 +16,19 @@ final class NoiseProtocolTests: XCTestCase { var bobKey: Curve25519.KeyAgreement.PrivateKey! var aliceSession: NoiseSession! var bobSession: NoiseSession! + private var mockKeychain: MockKeychain! override func setUp() { super.setUp() aliceKey = Curve25519.KeyAgreement.PrivateKey() bobKey = Curve25519.KeyAgreement.PrivateKey() + mockKeychain = MockKeychain() } override func tearDown() { aliceSession = nil bobSession = nil + mockKeychain = nil super.tearDown() } @@ -36,12 +39,14 @@ final class NoiseProtocolTests: XCTestCase { aliceSession = NoiseSession( peerID: TestConstants.testPeerID2, role: .initiator, + keychain: mockKeychain, localStaticKey: aliceKey ) bobSession = NoiseSession( peerID: TestConstants.testPeerID1, role: .responder, + keychain: mockKeychain, localStaticKey: bobKey ) @@ -80,6 +85,7 @@ final class NoiseProtocolTests: XCTestCase { aliceSession = NoiseSession( peerID: TestConstants.testPeerID2, role: .initiator, + keychain: mockKeychain, localStaticKey: aliceKey ) @@ -144,6 +150,7 @@ final class NoiseProtocolTests: XCTestCase { aliceSession = NoiseSession( peerID: TestConstants.testPeerID2, role: .initiator, + keychain: mockKeychain, localStaticKey: aliceKey ) @@ -157,7 +164,7 @@ final class NoiseProtocolTests: XCTestCase { // MARK: - Session Manager Tests func testSessionManagerBasicOperations() throws { - let manager = NoiseSessionManager(localStaticKey: aliceKey) + let manager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) // Create session let session = manager.createSession(for: TestConstants.testPeerID2, role: .initiator) @@ -174,7 +181,7 @@ final class NoiseProtocolTests: XCTestCase { } func testSessionManagerHandshakeInitiation() throws { - let manager = NoiseSessionManager(localStaticKey: aliceKey) + let manager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) // Initiate handshake let handshakeData = try manager.initiateHandshake(with: TestConstants.testPeerID2) @@ -187,8 +194,8 @@ final class NoiseProtocolTests: XCTestCase { } func testSessionManagerIncomingHandshake() throws { - let aliceManager = NoiseSessionManager(localStaticKey: aliceKey) - let bobManager = NoiseSessionManager(localStaticKey: bobKey) + let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) + let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) // Alice initiates let message1 = try aliceManager.initiateHandshake(with: TestConstants.testPeerID2) @@ -211,8 +218,8 @@ final class NoiseProtocolTests: XCTestCase { } func testSessionManagerEncryptionDecryption() throws { - let aliceManager = NoiseSessionManager(localStaticKey: aliceKey) - let bobManager = NoiseSessionManager(localStaticKey: bobKey) + let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) + let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) // Establish sessions try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) @@ -256,11 +263,11 @@ final class NoiseProtocolTests: XCTestCase { func testSessionIsolation() throws { // Create two separate session pairs - let aliceSession1 = NoiseSession(peerID: "peer1", role: .initiator, localStaticKey: aliceKey) - let bobSession1 = NoiseSession(peerID: "alice1", role: .responder, localStaticKey: bobKey) + let aliceSession1 = NoiseSession(peerID: "peer1", role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey) + let bobSession1 = NoiseSession(peerID: "alice1", role: .responder, keychain: mockKeychain, localStaticKey: bobKey) - let aliceSession2 = NoiseSession(peerID: "peer2", role: .initiator, localStaticKey: aliceKey) - let bobSession2 = NoiseSession(peerID: "alice2", role: .responder, localStaticKey: bobKey) + let aliceSession2 = NoiseSession(peerID: "peer2", role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey) + let bobSession2 = NoiseSession(peerID: "alice2", role: .responder, keychain: mockKeychain, localStaticKey: bobKey) // Establish both pairs try performHandshake(initiator: aliceSession1, responder: bobSession1) @@ -282,8 +289,8 @@ final class NoiseProtocolTests: XCTestCase { func testPeerRestartDetection() throws { // Establish initial sessions - let aliceManager = NoiseSessionManager(localStaticKey: aliceKey) - let bobManager = NoiseSessionManager(localStaticKey: bobKey) + let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) + let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) @@ -295,7 +302,7 @@ final class NoiseProtocolTests: XCTestCase { _ = try aliceManager.decrypt(message2, from: TestConstants.testPeerID2) // Simulate Bob restart by creating new manager with same key - let bobManagerRestarted = NoiseSessionManager(localStaticKey: bobKey) + let bobManagerRestarted = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) // Bob initiates new handshake after restart let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: TestConstants.testPeerID1) @@ -318,8 +325,8 @@ final class NoiseProtocolTests: XCTestCase { func testNonceDesynchronizationRecovery() throws { // Create two sessions - aliceSession = NoiseSession(peerID: TestConstants.testPeerID2, role: .initiator, localStaticKey: aliceKey) - bobSession = NoiseSession(peerID: TestConstants.testPeerID1, role: .responder, localStaticKey: bobKey) + aliceSession = NoiseSession(peerID: TestConstants.testPeerID2, role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey) + bobSession = NoiseSession(peerID: TestConstants.testPeerID1, role: .responder, keychain: mockKeychain, localStaticKey: bobKey) // Establish sessions try performHandshake(initiator: aliceSession, responder: bobSession) @@ -342,8 +349,8 @@ final class NoiseProtocolTests: XCTestCase { func testConcurrentEncryption() throws { // Test thread safety of encryption operations - let aliceManager = NoiseSessionManager(localStaticKey: aliceKey) - let bobManager = NoiseSessionManager(localStaticKey: bobKey) + let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) + let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) @@ -380,8 +387,8 @@ final class NoiseProtocolTests: XCTestCase { func testSessionStaleDetection() throws { // Test that sessions are properly marked as stale - let aliceManager = NoiseSessionManager(localStaticKey: aliceKey) - let bobManager = NoiseSessionManager(localStaticKey: bobKey) + let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) + let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) @@ -394,8 +401,8 @@ final class NoiseProtocolTests: XCTestCase { func testHandshakeAfterDecryptionFailure() throws { // Test that handshake is properly initiated after decryption failure - let aliceManager = NoiseSessionManager(localStaticKey: aliceKey) - let bobManager = NoiseSessionManager(localStaticKey: bobKey) + let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) + let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) // Establish sessions try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) @@ -413,8 +420,8 @@ final class NoiseProtocolTests: XCTestCase { func testHandshakeAlwaysAcceptedWithExistingSession() throws { // Test that handshake is always accepted even with existing valid session - let aliceManager = NoiseSessionManager(localStaticKey: aliceKey) - let bobManager = NoiseSessionManager(localStaticKey: bobKey) + let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) + let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) // Establish sessions try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) @@ -453,8 +460,8 @@ final class NoiseProtocolTests: XCTestCase { func testNonceDesynchronizationCausesRehandshake() throws { // Test that nonce desynchronization leads to proper re-handshake - let aliceManager = NoiseSessionManager(localStaticKey: aliceKey) - let bobManager = NoiseSessionManager(localStaticKey: bobKey) + let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) + let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) // Establish sessions try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) @@ -499,8 +506,8 @@ final class NoiseProtocolTests: XCTestCase { func testHandshakePerformance() throws { measure { do { - let alice = NoiseSession(peerID: "bob", role: .initiator, localStaticKey: aliceKey) - let bob = NoiseSession(peerID: "alice", role: .responder, localStaticKey: bobKey) + 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)") @@ -530,12 +537,14 @@ final class NoiseProtocolTests: XCTestCase { aliceSession = NoiseSession( peerID: TestConstants.testPeerID2, role: .initiator, + keychain: mockKeychain, localStaticKey: aliceKey ) bobSession = NoiseSession( peerID: TestConstants.testPeerID1, role: .responder, + keychain: mockKeychain, localStaticKey: bobKey )