From ad4103bacc2582bc7c94fb8c445c90a1792a8e85 Mon Sep 17 00:00:00 2001 From: Islam <2553451+qalandarov@users.noreply.github.com> Date: Tue, 14 Oct 2025 11:26:18 +0100 Subject: [PATCH] Refactor Nostr ID Bridge & Keychain Helper (#796) * Extract each type to a separate file * Nostr ID Bridge: Convert static func/vars to instance * `KeychainHelper` behind a protocol to easily mock * Update tests with ID Bridge and MockKeychainHelper --------- Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com> --- bitchat/BitchatApp.swift | 6 +- bitchat/Nostr/Bech32.swift | 135 ++++++++ bitchat/Nostr/KeychainHelper.swift | 50 +++ bitchat/Nostr/NostrIdentity.swift | 308 ------------------ bitchat/Nostr/NostrIdentityBridge.swift | 135 ++++++++ bitchat/Services/BLEService.swift | 10 +- bitchat/Services/CommandProcessor.swift | 2 +- .../FavoritesPersistenceService.swift | 13 +- bitchat/Services/LocationNotesManager.swift | 4 +- bitchat/Services/NostrTransport.swift | 12 +- bitchat/Services/UnifiedPeerService.swift | 10 +- bitchat/ViewModels/ChatViewModel.swift | 79 ++--- .../Views/Components/TextMessageView.swift | 1 + bitchat/Views/GeohashPeopleList.swift | 2 +- bitchat/Views/VerificationViews.swift | 2 +- .../Fragmentation/FragmentationTests.swift | 20 +- bitchatTests/LocationChannelsTests.swift | 5 +- bitchatTests/Mocks/MockKeychain.swift | 18 + 18 files changed, 442 insertions(+), 370 deletions(-) create mode 100644 bitchat/Nostr/Bech32.swift create mode 100644 bitchat/Nostr/KeychainHelper.swift create mode 100644 bitchat/Nostr/NostrIdentityBridge.swift diff --git a/bitchat/BitchatApp.swift b/bitchat/BitchatApp.swift index 9c9becfb..0cfe5dfc 100644 --- a/bitchat/BitchatApp.swift +++ b/bitchat/BitchatApp.swift @@ -26,11 +26,15 @@ struct BitchatApp: App { @NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate #endif + private let idBridge = NostrIdentityBridge() + init() { let keychain = KeychainManager() + let idBridge = self.idBridge _chatViewModel = StateObject( wrappedValue: ChatViewModel( keychain: keychain, + idBridge: idBridge, identityManager: SecureIdentityStateManager(keychain) ) ) @@ -50,7 +54,7 @@ struct BitchatApp: App { VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService()) // Prewarm Nostr identity and QR to make first VERIFY sheet fast 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) } #if os(iOS) diff --git a/bitchat/Nostr/Bech32.swift b/bitchat/Nostr/Bech32.swift new file mode 100644 index 00000000..dc798560 --- /dev/null +++ b/bitchat/Nostr/Bech32.swift @@ -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[..= 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 + } +} diff --git a/bitchat/Nostr/KeychainHelper.swift b/bitchat/Nostr/KeychainHelper.swift new file mode 100644 index 00000000..01de3dc4 --- /dev/null +++ b/bitchat/Nostr/KeychainHelper.swift @@ -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) + } +} diff --git a/bitchat/Nostr/NostrIdentity.swift b/bitchat/Nostr/NostrIdentity.swift index 6aa52757..72fb7bbf 100644 --- a/bitchat/Nostr/NostrIdentity.swift +++ b/bitchat/Nostr/NostrIdentity.swift @@ -1,50 +1,5 @@ import Foundation -import CryptoKit 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 struct NostrIdentity: Codable { @@ -103,266 +58,3 @@ struct NostrIdentity: Codable { 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.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[..= 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 diff --git a/bitchat/Nostr/NostrIdentityBridge.swift b/bitchat/Nostr/NostrIdentityBridge.swift new file mode 100644 index 00000000..64a1a616 --- /dev/null +++ b/bitchat/Nostr/NostrIdentityBridge.swift @@ -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.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) + } +} diff --git a/bitchat/Services/BLEService.swift b/bitchat/Services/BLEService.swift index 9a105985..07628f2d 100644 --- a/bitchat/Services/BLEService.swift +++ b/bitchat/Services/BLEService.swift @@ -94,6 +94,7 @@ final class BLEService: NSObject { private var noiseService: NoiseEncryptionService private let identityManager: SecureIdentityStateManagerProtocol private let keychain: KeychainManagerProtocol + private let idBridge: NostrIdentityBridge private var myPeerIDData: Data = Data() // MARK: - Advertising Privacy @@ -198,8 +199,13 @@ final class BLEService: NSObject { // MARK: - Initialization - init(keychain: KeychainManagerProtocol, identityManager: SecureIdentityStateManagerProtocol) { + init( + keychain: KeychainManagerProtocol, + idBridge: NostrIdentityBridge, + identityManager: SecureIdentityStateManagerProtocol + ) { self.keychain = keychain + self.idBridge = idBridge noiseService = NoiseEncryptionService(keychain: keychain) self.identityManager = identityManager super.init() @@ -613,7 +619,7 @@ final class BLEService: NSObject { var content = isFavorite ? "[FAVORITED]" : "[UNFAVORITED]" // Add our Nostr public key if available - if let myNostrIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() { + if let myNostrIdentity = try? idBridge.getCurrentNostrIdentity() { content += ":" + myNostrIdentity.npub SecureLogger.debug("📝 Sending favorite notification with Nostr npub: \(myNostrIdentity.npub)", category: .session) } diff --git a/bitchat/Services/CommandProcessor.swift b/bitchat/Services/CommandProcessor.swift index 736274ae..01e7c849 100644 --- a/bitchat/Services/CommandProcessor.swift +++ b/bitchat/Services/CommandProcessor.swift @@ -104,7 +104,7 @@ final class CommandProcessor { case .location(let ch): // Geohash context: show visible geohash participants (exclude self) 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 if let me = myHex { return person.id.lowercased() != me } return true diff --git a/bitchat/Services/FavoritesPersistenceService.swift b/bitchat/Services/FavoritesPersistenceService.swift index fd71e4b7..e892285e 100644 --- a/bitchat/Services/FavoritesPersistenceService.swift +++ b/bitchat/Services/FavoritesPersistenceService.swift @@ -26,6 +26,7 @@ final class FavoritesPersistenceService: ObservableObject { private static let storageKey = "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 mutualFavorites: Set = [] @@ -35,7 +36,8 @@ final class FavoritesPersistenceService: ObservableObject { static let shared = FavoritesPersistenceService() - private init() { + init(keychain: KeychainHelperProtocol = KeychainHelper()) { + self.keychain = keychain loadFavorites() // Update mutual favorites when favorites change @@ -196,7 +198,7 @@ final class FavoritesPersistenceService: ObservableObject { saveFavorites() // Delete from keychain directly - KeychainHelper.delete( + keychain.delete( key: Self.storageKey, service: Self.keychainService ) @@ -216,10 +218,11 @@ final class FavoritesPersistenceService: ObservableObject { let data = try encoder.encode(relationships) // Store in keychain for security - KeychainHelper.save( + keychain.save( key: Self.storageKey, data: data, - service: Self.keychainService + service: Self.keychainService, + accessible: nil ) // Successfully saved favorites @@ -231,7 +234,7 @@ final class FavoritesPersistenceService: ObservableObject { private func loadFavorites() { // Loading favorites from keychain - guard let data = KeychainHelper.load( + guard let data = keychain.load( key: Self.storageKey, service: Self.keychainService ) else { diff --git a/bitchat/Services/LocationNotesManager.swift b/bitchat/Services/LocationNotesManager.swift index fba6f993..5c828a01 100644 --- a/bitchat/Services/LocationNotesManager.swift +++ b/bitchat/Services/LocationNotesManager.swift @@ -14,6 +14,8 @@ struct LocationNotesDependencies { var sendEvent: SendEvent var deriveIdentity: (_ geohash: String) throws -> NostrIdentity var now: () -> Date + + private static let idBridge = NostrIdentityBridge() static let live = LocationNotesDependencies( relayLookup: { geohash, count in @@ -35,7 +37,7 @@ struct LocationNotesDependencies { NostrRelayManager.shared.sendEvent(event, to: relays) }, deriveIdentity: { geohash in - try NostrIdentityBridge.deriveIdentity(forGeohash: geohash) + try idBridge.deriveIdentity(forGeohash: geohash) }, now: { Date() } ) diff --git a/bitchat/Services/NostrTransport.swift b/bitchat/Services/NostrTransport.swift index a7ab3375..35607700 100644 --- a/bitchat/Services/NostrTransport.swift +++ b/bitchat/Services/NostrTransport.swift @@ -16,9 +16,11 @@ final class NostrTransport: Transport { private var isSendingReadAcks = false private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval private let keychain: KeychainManagerProtocol + private let idBridge: NostrIdentityBridge - init(keychain: KeychainManagerProtocol) { + init(keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge) { self.keychain = keychain + self.idBridge = idBridge } // MARK: - Transport Protocol Conformance @@ -65,7 +67,7 @@ final class NostrTransport: Transport { func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { Task { @MainActor in 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) // Convert recipient npub -> hex (x-only) let recipientHex: String @@ -102,7 +104,7 @@ final class NostrTransport: Transport { func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { Task { @MainActor in guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return } - guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return } + guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return } let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)" SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session) // Convert recipient npub -> hex @@ -129,7 +131,7 @@ final class NostrTransport: Transport { func sendDeliveryAck(for messageID: String, to peerID: PeerID) { Task { @MainActor in guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return } - guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return } + 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) let recipientHex: String do { @@ -212,7 +214,7 @@ extension NostrTransport { let item = readQueue.removeFirst() Task { @MainActor in 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) // Convert recipient npub -> hex let recipientHex: String diff --git a/bitchat/Services/UnifiedPeerService.swift b/bitchat/Services/UnifiedPeerService.swift index 16168cfd..f21973f8 100644 --- a/bitchat/Services/UnifiedPeerService.swift +++ b/bitchat/Services/UnifiedPeerService.swift @@ -27,6 +27,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { private var peerIndex: [PeerID: BitchatPeer] = [:] private var fingerprintCache: [PeerID: String] = [:] private let meshService: Transport + private let idBridge: NostrIdentityBridge private let identityManager: SecureIdentityStateManagerProtocol weak var messageRouter: MessageRouter? private let favoritesService = FavoritesPersistenceService.shared @@ -34,8 +35,13 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { // MARK: - Initialization - init(meshService: Transport, identityManager: SecureIdentityStateManagerProtocol) { + init( + meshService: Transport, + idBridge: NostrIdentityBridge, + identityManager: SecureIdentityStateManagerProtocol + ) { self.meshService = meshService + self.idBridge = idBridge self.identityManager = identityManager // Subscribe to changes from both services @@ -285,7 +291,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { var peerNostrKey = peer.nostrPublicKey if peerNostrKey == nil { // Try to get from NostrIdentityBridge association - peerNostrKey = NostrIdentityBridge.getNostrPublicKey(for: peer.noisePublicKey) + peerNostrKey = idBridge.getNostrPublicKey(for: peer.noisePublicKey) } // Add favorite diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 35fca3b2..2a1a5062 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -346,6 +346,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // MARK: - Services and Storage let meshService: Transport + let idBridge: NostrIdentityBridge let identityManager: SecureIdentityStateManagerProtocol private var nostrRelayManager: NostrRelayManager? @@ -493,11 +494,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { @MainActor init( keychain: KeychainManagerProtocol, + idBridge: NostrIdentityBridge, identityManager: SecureIdentityStateManagerProtocol ) { self.keychain = keychain + self.idBridge = idBridge self.identityManager = identityManager - self.meshService = BLEService(keychain: keychain, identityManager: identityManager) + self.meshService = BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager) // Load persisted read receipts if let data = UserDefaults.standard.data(forKey: "sentReadReceipts"), @@ -511,8 +514,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // Initialize services self.commandProcessor = CommandProcessor(identityManager: identityManager) self.privateChatManager = PrivateChatManager(meshService: meshService) - self.unifiedPeerService = UnifiedPeerService(meshService: meshService, identityManager: identityManager) - let nostrTransport = NostrTransport(keychain: keychain) + self.unifiedPeerService = UnifiedPeerService(meshService: meshService, idBridge: idBridge, identityManager: identityManager) + let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge) self.messageRouter = MessageRouter(mesh: meshService, nostr: nostrTransport) // Route receipts from PrivateChatManager through MessageRouter self.privateChatManager.messageRouter = self.messageRouter @@ -715,7 +718,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { guard let self = self else { return } Task { @MainActor in guard case .location(let ch) = self.activeChannel, - let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) else { return } + let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) else { return } let key = id.publicKeyHex.lowercased() let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash } @@ -941,7 +944,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { NostrRelayManager.shared.unsubscribe(id: dmSub); geoDmSubscriptionID = nil } - if let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { + if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { let dmSub = "geo-dm-\(ch.geohash)" geoDmSubscriptionID = dmSub let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)) @@ -961,7 +964,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { processedNostrEvents.insert(event.id) if let gh = currentGeohash, - let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh), + let myGeoIdentity = try? idBridge.deriveIdentity(forGeohash: gh), myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() { // Skip very recent self-echo from relay, but allow older events (e.g., after app restart) let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) @@ -993,7 +996,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { let key = event.pubkey.lowercased() // Do not mark our own key from historical events; rely on manager.teleported for self let isSelf: Bool = { - if let gh = currentGeohash, let my = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh) { + if let gh = currentGeohash, let my = try? idBridge.deriveIdentity(forGeohash: gh) { return my.publicKeyHex.lowercased() == key } return false @@ -1125,7 +1128,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { private func sendDeliveryAckIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) { guard !sentGeoDeliveryAcks.contains(messageId) else { return } - let nt = NostrTransport(keychain: keychain) + let nt = NostrTransport(keychain: keychain, idBridge: idBridge) nt.senderPeerID = meshService.myPeerID nt.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubKey, from: id) sentGeoDeliveryAcks.insert(messageId) @@ -1133,7 +1136,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { private func sendReadReceiptIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) { guard !sentReadReceipts.contains(messageId) else { return } - let nt = NostrTransport(keychain: keychain) + let nt = NostrTransport(keychain: keychain, idBridge: idBridge) nt.senderPeerID = meshService.myPeerID nt.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubKey, from: id) sentReadReceipts.insert(messageId) @@ -1255,7 +1258,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { } let finalNickname = nickname ?? "Unknown" - let nostrKey = currentStatus?.peerNostrPublicKey ?? NostrIdentityBridge.getNostrPublicKey(for: noisePublicKey) + let nostrKey = currentStatus?.peerNostrPublicKey ?? idBridge.getNostrPublicKey(for: noisePublicKey) FavoritesPersistenceService.shared.addFavorite( peerNoisePublicKey: noisePublicKey, @@ -1422,7 +1425,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { var displaySender = nickname var localSenderPeerID = meshService.myPeerID if case .location(let ch) = activeChannel, - let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { + let myGeoIdentity = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { let suffix = String(myGeoIdentity.publicKeyHex.suffix(4)) displaySender = nickname + "#" + suffix localSenderPeerID = PeerID(nostr: myGeoIdentity.publicKeyHex) @@ -1483,7 +1486,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { @MainActor private func sendGeohash(ch: GeohashChannel, content: String) { do { - let identity = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) + let identity = try idBridge.deriveIdentity(forGeohash: ch.geohash) let event = try NostrProtocol.createEphemeralGeohashEvent( content: content, @@ -1579,7 +1582,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { currentGeohash = ch.geohash // Ensure self appears immediately in the people list; mark teleported state only when truly teleported - if let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { + if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { recordGeoParticipant(pubkeyHex: id.publicKeyHex) let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash } @@ -1623,7 +1626,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { } let isSelf: Bool = { - if let gh = currentGeohash, let my = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh) { + if let gh = currentGeohash, let my = try? idBridge.deriveIdentity(forGeohash: gh) { return my.publicKeyHex.lowercased() == event.pubkey.lowercased() } return false @@ -1699,7 +1702,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { @MainActor private func subscribeToGeoChat(_ ch: GeohashChannel) { - guard let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) else { return } + guard let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) else { return } let dmSub = "geo-dm-\(ch.geohash)" geoDmSubscriptionID = dmSub @@ -1901,12 +1904,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { if let mapped = nostrKeyMapping[peerID]?.lowercased(), let gh = currentGeohash, - let myIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh) { + let myIdentity = try? idBridge.deriveIdentity(forGeohash: gh) { if mapped == myIdentity.publicKeyHex.lowercased() { return true } } if let gh = currentGeohash, - let myIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh) { + let myIdentity = try? idBridge.deriveIdentity(forGeohash: gh) { let myLower = myIdentity.publicKeyHex.lowercased() let shortLen = TransportConfig.nostrShortKeyDisplayLength let shortKey = "nostr:" + myLower.prefix(shortLen) @@ -2084,7 +2087,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { 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 } + if let my = try? idBridge.deriveIdentity(forGeohash: gh), my.publicKeyHex.lowercased() == event.pubkey.lowercased() { return } // Only trigger when there were zero participants in this geohash recently guard existingCount == 0 else { return } @@ -2158,7 +2161,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { private func displayNameForNostrPubkey(_ pubkeyHex: String) -> String { let suffix = String(pubkeyHex.suffix(4)) // If this is our per-geohash identity, use our nickname - if let gh = currentGeohash, let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh) { + if let gh = currentGeohash, let myGeoIdentity = try? idBridge.deriveIdentity(forGeohash: gh) { if myGeoIdentity.publicKeyHex.lowercased() == pubkeyHex.lowercased() { return nickname + "#" + suffix } @@ -2344,7 +2347,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // Send via Nostr using per-geohash identity do { - let id = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) + let id = try idBridge.deriveIdentity(forGeohash: ch.geohash) // Prevent messaging ourselves if recipientHex.lowercased() == id.publicKeyHex.lowercased() { if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { @@ -2355,7 +2358,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(keychain: keychain) + let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge) nostrTransport.senderPeerID = meshService.myPeerID nostrTransport.sendPrivateMessageGeohash(content: content, toRecipientHex: recipientHex, from: id, messageID: messageID) if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { @@ -2919,7 +2922,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { case .location(let ch): Task { @MainActor in do { - let identity = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) + let identity = try idBridge.deriveIdentity(forGeohash: ch.geohash) let event = try NostrProtocol.createEphemeralGeohashEvent( content: screenshotMessage, geohash: ch.geohash, @@ -3020,12 +3023,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { if peerID.hasPrefix("nostr_"), let recipientHex = nostrKeyMapping[peerID], case .location(let ch) = LocationChannelManager.shared.selectedChannel, - let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { + let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { let messages = privateChats[peerID] ?? [] 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(keychain: keychain) + let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge) nostrTransport.senderPeerID = meshService.myPeerID nostrTransport.sendReadReceiptGeohash(message.id, toRecipientHex: recipientHex, from: id) sentReadReceipts.insert(message.id) @@ -3218,7 +3221,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { nostrRelayManager = nil // Clear Nostr identity associations - NostrIdentityBridge.clearAllAssociations() + idBridge.clearAllAssociations() // Disconnect from all peers and clear persistent identity // This will force creation of a new identity (new fingerprint) on next launch @@ -3263,7 +3266,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { tokens.insert("\(nick)#\(suffix)") } // Optionally exclude self nick#abcd from suggestions - if let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { + if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { let myToken = nickname + "#" + String(id.publicKeyHex.suffix(4)) tokens.remove(myToken) } @@ -3314,7 +3317,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { if let spid = message.senderPeerID?.id { // In geohash channels, compare against our per-geohash nostr short ID if case .location(let ch) = activeChannel, spid.hasPrefix("nostr:") { - if let myGeo = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { + if let myGeo = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { return spid == "nostr:\(myGeo.publicKeyHex.prefix(TransportConfig.nostrShortKeyDisplayLength))" } } @@ -3492,7 +3495,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { let (mBase, mSuffix) = matchText.splitSuffix() // Determine if this mention targets me (resolves with optional suffix per active channel) let mySuffix: String? = { - if case .location(let ch) = activeChannel, let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { + if case .location(let ch) = activeChannel, let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { return String(id.publicKeyHex.suffix(4)) } return String(meshService.myPeerID.id.prefix(4)) @@ -4068,7 +4071,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { let entry = (isDark ? nostrPaletteDark[pubkeyHexLowercased] : nostrPaletteLight[pubkeyHexLowercased]) let myHex: String? = { if case .location(let ch) = LocationChannelManager.shared.selectedChannel, - let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { + let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { return id.publicKeyHex.lowercased() } return nil @@ -4090,7 +4093,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // Build seeds map from currently visible geohash people (excluding self) let myHex: String? = { if case .location(let ch) = LocationChannelManager.shared.selectedChannel, - let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { + let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { return id.publicKeyHex.lowercased() } return nil @@ -5011,7 +5014,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { if case .location(let ch) = activeChannel { Task { @MainActor in do { - let identity = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) + let identity = try idBridge.deriveIdentity(forGeohash: ch.geohash) let event = try NostrProtocol.createEphemeralGeohashEvent( content: content, geohash: ch.geohash, @@ -5041,7 +5044,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { @MainActor private func setupNostrMessageHandling() { - guard let currentIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { + guard let currentIdentity = try? idBridge.getCurrentNostrIdentity() else { SecureLogger.warning("⚠️ No Nostr identity available for message handling", category: .session) return } @@ -5079,7 +5082,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { @MainActor private func processNostrMessage(_ giftWrap: NostrEvent) { - guard let currentIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return } + guard let currentIdentity = try? idBridge.getCurrentNostrIdentity() else { return } do { let (content, senderPubkey, rumorTimestamp) = try NostrProtocol.decryptPrivateMessage( @@ -5298,9 +5301,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { if let key { SecureLogger.debug("Sending DELIVERED ack for \(message.id.prefix(8))… via router", category: .session) messageRouter.sendDeliveryAck(message.id, to: PeerID(hexData: key)) - } else if let id = try? NostrIdentityBridge.getCurrentNostrIdentity() { + } else if let id = try? idBridge.getCurrentNostrIdentity() { // Fallback: no Noise mapping yet — send directly to sender's Nostr pubkey - let nt = NostrTransport(keychain: keychain) + let nt = NostrTransport(keychain: keychain, idBridge: idBridge) nt.senderPeerID = meshService.myPeerID nt.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: id) SecureLogger.debug("Sent DELIVERED ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…", category: .session) @@ -5320,8 +5323,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { SecureLogger.debug("Viewing chat; sending READ ack for \(message.id.prefix(8))… via router", category: .session) messageRouter.sendReadReceipt(receipt, to: PeerID(hexData: key)) sentReadReceipts.insert(message.id) - } else if let id = try? NostrIdentityBridge.getCurrentNostrIdentity() { - let nt = NostrTransport(keychain: keychain) + } else if let id = try? idBridge.getCurrentNostrIdentity() { + let nt = NostrTransport(keychain: keychain, idBridge: idBridge) nt.senderPeerID = meshService.myPeerID nt.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: id) sentReadReceipts.insert(message.id) @@ -6156,7 +6159,7 @@ private func checkForMentions(_ message: BitchatMessage) { #if os(iOS) switch activeChannel { case .location(let ch): - if let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { + if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { let d = String(id.publicKeyHex.suffix(4)) tokens.append(nickname + "#" + d) } diff --git a/bitchat/Views/Components/TextMessageView.swift b/bitchat/Views/Components/TextMessageView.swift index d4390973..442e6908 100644 --- a/bitchat/Views/Components/TextMessageView.swift +++ b/bitchat/Views/Components/TextMessageView.swift @@ -91,6 +91,7 @@ struct TextMessageView: View { .environmentObject( ChatViewModel( keychain: keychain, + idBridge: NostrIdentityBridge(), identityManager: SecureIdentityStateManager(keychain) ) ) diff --git a/bitchat/Views/GeohashPeopleList.swift b/bitchat/Views/GeohashPeopleList.swift index 945ed73c..dabdcc02 100644 --- a/bitchat/Views/GeohashPeopleList.swift +++ b/bitchat/Views/GeohashPeopleList.swift @@ -28,7 +28,7 @@ struct GeohashPeopleList: View { } else { let myHex: String? = { 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 nil diff --git a/bitchat/Views/VerificationViews.swift b/bitchat/Views/VerificationViews.swift index 76765e3f..40ac301e 100644 --- a/bitchat/Views/VerificationViews.swift +++ b/bitchat/Views/VerificationViews.swift @@ -292,7 +292,7 @@ struct VerificationSheetView: View { private var boxColor: Color { Color.gray.opacity(0.1) } 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) ?? "" } diff --git a/bitchatTests/Fragmentation/FragmentationTests.swift b/bitchatTests/Fragmentation/FragmentationTests.swift index 7a3260f0..f18cae8d 100644 --- a/bitchatTests/Fragmentation/FragmentationTests.swift +++ b/bitchatTests/Fragmentation/FragmentationTests.swift @@ -15,15 +15,21 @@ struct FragmentationTests { private let mockKeychain: MockKeychain private let mockIdentityManager: MockIdentityManager + private let idBridge: NostrIdentityBridge init() { mockKeychain = MockKeychain() mockIdentityManager = MockIdentityManager(mockKeychain) + idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) } @Test("Reassembly from fragments delivers a public message") func reassemblyFromFragmentsDeliversPublicMessage() async throws { - let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager) + let ble = BLEService( + keychain: mockKeychain, + idBridge: idBridge, + identityManager: mockIdentityManager + ) let capture = CaptureDelegate() ble.delegate = capture @@ -55,7 +61,11 @@ struct FragmentationTests { @Test("Duplicate fragment does not break reassembly") func duplicateFragmentDoesNotBreakReassembly() async throws { - let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager) + let ble = BLEService( + keychain: mockKeychain, + idBridge: idBridge, + identityManager: mockIdentityManager + ) let capture = CaptureDelegate() ble.delegate = capture @@ -85,7 +95,11 @@ struct FragmentationTests { @Test("Invalid fragment header is ignored") func invalidFragmentHeaderIsIgnored() async throws { - let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager) + let ble = BLEService( + keychain: mockKeychain, + idBridge: idBridge, + identityManager: mockIdentityManager + ) let capture = CaptureDelegate() ble.delegate = capture diff --git a/bitchatTests/LocationChannelsTests.swift b/bitchatTests/LocationChannelsTests.swift index cd7b22d6..f3954eb5 100644 --- a/bitchatTests/LocationChannelsTests.swift +++ b/bitchatTests/LocationChannelsTests.swift @@ -37,9 +37,10 @@ final class LocationChannelsTests: XCTestCase { func testPerGeohashIdentityDeterministic() throws { // Derive twice for same geohash; should be identical + let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) let gh = "u4pruy" - let id1 = try NostrIdentityBridge.deriveIdentity(forGeohash: gh) - let id2 = try NostrIdentityBridge.deriveIdentity(forGeohash: gh) + let id1 = try idBridge.deriveIdentity(forGeohash: gh) + let id2 = try idBridge.deriveIdentity(forGeohash: gh) XCTAssertEqual(id1.publicKeyHex, id2.publicKeyHex) } } diff --git a/bitchatTests/Mocks/MockKeychain.swift b/bitchatTests/Mocks/MockKeychain.swift index dfc42e17..f8718c82 100644 --- a/bitchatTests/Mocks/MockKeychain.swift +++ b/bitchatTests/Mocks/MockKeychain.swift @@ -44,3 +44,21 @@ final class MockKeychain: KeychainManagerProtocol { 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) + } +}