From 8a867a17a12ca9e30610c4fab7d5f521b61679e4 Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 11 Jun 2026 15:29:07 +0200 Subject: [PATCH] Remove noise service exposure; single-owner selection state Transport callers no longer reach the raw NoiseEncryptionService: getNoiseService() is deleted in favor of narrow purpose-named Transport methods (session public key, identity fingerprint, static/signing keys, sign/verify, callback installation). VerificationService now reaches crypto through the transport, so it can no longer pin a stale service across a panic reset. myPeerID/myNickname become private(set); the existing setNickname mutator is the sole nickname path. ConversationStore is now the sole owner of private-chat selection: PrivateChatManager.selectedPeer is a published read-only mirror, and startChat/endChat mutate through the store intent. The bridge method and its five call sites are deleted, removing a latent bug where a stale manager selection pushed back into the store could resurrect a just-removed conversation. Co-Authored-By: Claude Fable 5 --- bitchat/App/AppRuntime.swift | 2 +- bitchat/Services/BLE/BLEService.swift | 51 ++++++++++++++++--- bitchat/Services/NostrTransport.swift | 14 ++--- bitchat/Services/PrivateChatManager.swift | 50 ++++++++++++++---- bitchat/Services/Transport.swift | 35 ++++++++++++- bitchat/Services/VerificationService.swift | 26 +++++----- .../ChatPeerIdentityCoordinator.swift | 23 ++++++--- .../ChatTransportEventCoordinator.swift | 2 +- .../ChatVerificationCoordinator.swift | 9 ++-- bitchat/ViewModels/ChatViewModel.swift | 24 ++++----- .../ChatViewModelBootstrapper.swift | 16 ++---- ...tPeerIdentityCoordinatorContextTests.swift | 3 -- bitchatTests/Mocks/MockBLEService.swift | 8 +-- bitchatTests/Mocks/MockTransport.swift | 30 ++++++++++- bitchatTests/ProtocolContractTests.swift | 2 - .../Services/VerificationServiceTests.swift | 9 ++-- 16 files changed, 205 insertions(+), 99 deletions(-) diff --git a/bitchat/App/AppRuntime.swift b/bitchat/App/AppRuntime.swift index 071115d5..a706cfac 100644 --- a/bitchat/App/AppRuntime.swift +++ b/bitchat/App/AppRuntime.swift @@ -105,7 +105,7 @@ final class AppRuntime: ObservableObject { started = true NotificationDelegate.shared.runtime = self - VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService()) + VerificationService.shared.configure(with: chatViewModel.meshService) announceInitialTorStatusIfNeeded() Task(priority: .utility) { [weak self] in diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index d5c0635e..7d8f3779 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -401,10 +401,17 @@ final class BLEService: NSObject { } // MARK: Identity - - var myPeerID = PeerID(str: "") - var myNickname: String = "anon" - + + /// Derived from the Noise identity fingerprint; rotated only via + /// `refreshPeerIdentity()` (e.g. panic reset). Externally read-only — + /// no out-of-band mutation may bypass that derivation. + private(set) var myPeerID = PeerID(str: "") + /// Externally read-only; mutate via `setNickname(_:)`, which also + /// broadcasts the change to peers. + private(set) var myNickname: String = "anon" + + /// Sole mutator for `myNickname`: updates the stored value and force-sends + /// an announce so peers learn the new name. func setNickname(_ nickname: String) { self.myNickname = nickname // Send announce to notify peers of nickname change (force send) @@ -573,8 +580,40 @@ final class BLEService: NSObject { initiateNoiseHandshake(with: peerID) } - func getNoiseService() -> NoiseEncryptionService { - return noiseService + // MARK: Noise identity/session access (narrow Transport wrappers) + + func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { + noiseService.getPeerPublicKeyData(peerID) + } + + func noiseIdentityFingerprint() -> String { + noiseService.getIdentityFingerprint() + } + + func noiseStaticPublicKeyData() -> Data { + noiseService.getStaticPublicKeyData() + } + + func noiseSigningPublicKeyData() -> Data { + noiseService.getSigningPublicKeyData() + } + + func noiseSignData(_ data: Data) -> Data? { + noiseService.signData(data) + } + + func noiseVerifySignature(_ signature: Data, for data: Data, publicKey: Data) -> Bool { + noiseService.verifySignature(signature, for: data, publicKey: publicKey) + } + + func installNoiseSessionCallbacks( + onPeerAuthenticated: @escaping (PeerID, String) -> Void, + onHandshakeRequired: @escaping (PeerID) -> Void + ) { + // `onPeerAuthenticated` is additive (the encryption service keeps an + // array of handlers); `onHandshakeRequired` is a single slot. + noiseService.onPeerAuthenticated = onPeerAuthenticated + noiseService.onHandshakeRequired = onHandshakeRequired } func getCurrentBluetoothState() -> CBManagerState { diff --git a/bitchat/Services/NostrTransport.swift b/bitchat/Services/NostrTransport.swift index d38adfc4..380593fb 100644 --- a/bitchat/Services/NostrTransport.swift +++ b/bitchat/Services/NostrTransport.swift @@ -142,17 +142,9 @@ final class NostrTransport: Transport, @unchecked Sendable { func getFingerprint(for peerID: PeerID) -> String? { nil } func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none } func triggerHandshake(with peerID: PeerID) { /* no-op */ } - - // Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation - 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 - } + + // Nostr does not use Noise sessions here; the inert Transport defaults + // for the noise* identity hooks apply. // Public broadcast not supported over Nostr here func sendMessage(_ content: String, mentions: [String]) { /* no-op */ } diff --git a/bitchat/Services/PrivateChatManager.swift b/bitchat/Services/PrivateChatManager.swift index 11c50d60..4633698e 100644 --- a/bitchat/Services/PrivateChatManager.swift +++ b/bitchat/Services/PrivateChatManager.swift @@ -8,6 +8,7 @@ import BitLogger import BitFoundation +import Combine import Foundation import SwiftUI @@ -16,8 +17,16 @@ import SwiftUI /// `ConversationStore` (docs/CONVERSATION-STORE-DESIGN.md); the /// `privateChats` / `unreadMessages` properties below are read-only views /// derived from it. +@MainActor final class PrivateChatManager: ObservableObject { - @Published var selectedPeer: PeerID? = nil + /// Read-only mirror of `ConversationStore.selectedPrivatePeerID` — the + /// store is the sole owner of conversation selection. Kept `@Published` + /// so existing observers (`objectWillChange` forwarding into + /// `ChatViewModel`) keep firing on selection changes. Mutate via + /// `startChat(with:)` / `endChat()`, which route through the store's + /// `setSelectedPrivatePeer` intent. + @Published private(set) var selectedPeer: PeerID? = nil + private var selectedPeerMirrorCancellable: AnyCancellable? = nil private var selectedPeerFingerprint: String? = nil var sentReadReceipts: Set = [] // Made accessible for ChatViewModel @@ -27,13 +36,30 @@ final class PrivateChatManager: ObservableObject { weak var messageRouter: MessageRouter? // Peer service for looking up peer info during consolidation weak var unifiedPeerService: UnifiedPeerService? - /// Single source of truth for message state; injected by the - /// bootstrapper (`wireServiceGraph`). - var conversationStore: ConversationStore? + /// Single source of truth for message and selection state; injected by + /// the bootstrapper (`wireServiceGraph`). + var conversationStore: ConversationStore? { + didSet { bindSelectionMirror() } + } init(meshService: Transport? = nil, conversationStore: ConversationStore? = nil) { self.meshService = meshService self.conversationStore = conversationStore + bindSelectionMirror() // didSet does not fire during init + } + + /// Keeps `selectedPeer` in lock-step with the store's selection axis + /// (including store-internal handoffs such as conversation migration). + private func bindSelectionMirror() { + guard let store = conversationStore else { + selectedPeerMirrorCancellable = nil + return + } + selectedPeerMirrorCancellable = store.$selectedPrivatePeerID + .sink { [weak self] peerID in + guard let self, self.selectedPeer != peerID else { return } + self.selectedPeer = peerID + } } // MARK: - Derived message state (read-only compat views) @@ -191,10 +217,14 @@ final class PrivateChatManager: ObservableObject { } } - /// Start a private chat with a peer + /// Start a private chat with a peer. Selection is mutated through the + /// store's intent (the store owns it); the manager keeps its side + /// effects (fingerprint tracking, read receipts, unread clearing). @MainActor func startChat(with peerID: PeerID) { - selectedPeer = peerID + // Also creates the conversation if needed and updates the derived + // `selectedConversationID`; `selectedPeer` mirrors the change. + conversationStore?.setSelectedPrivatePeer(peerID) // Store fingerprint for persistence across reconnections if let fingerprint = meshService?.getFingerprint(for: peerID) { @@ -203,14 +233,12 @@ final class PrivateChatManager: ObservableObject { // Mark messages as read markAsRead(from: peerID) - - // Initialize chat if needed - conversationStore?.conversation(for: .directPeer(peerID)) } - /// End the current private chat + /// End the current private chat (selection returns to the active public + /// channel's conversation). func endChat() { - selectedPeer = nil + conversationStore?.setSelectedPrivatePeer(nil) selectedPeerFingerprint = nil } diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index 72c6d542..36ca832b 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -61,7 +61,27 @@ protocol Transport: AnyObject { func getFingerprint(for peerID: PeerID) -> String? func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState func triggerHandshake(with peerID: PeerID) - func getNoiseService() -> NoiseEncryptionService + + // Noise identity/session access. Narrow, purpose-named wrappers so the + // underlying NoiseEncryptionService (and its peer-binding/session + // orchestration) is never exposed outside the transport. + /// The remote static public key of the Noise session with `peerID`, if established. + func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? + /// Fingerprint of our own Noise static identity key. + func noiseIdentityFingerprint() -> String + /// Our Noise static public key (Curve25519 key agreement). + func noiseStaticPublicKeyData() -> Data + /// Our Noise signing public key (Ed25519). + func noiseSigningPublicKeyData() -> Data + /// Signs `data` with our Noise signing key. + func noiseSignData(_ data: Data) -> Data? + /// Verifies an Ed25519 `signature` over `data` against `publicKey`. + func noiseVerifySignature(_ signature: Data, for data: Data, publicKey: Data) -> Bool + /// Registers session-lifecycle callbacks (peer authenticated / handshake required). + func installNoiseSessionCallbacks( + onPeerAuthenticated: @escaping (PeerID, String) -> Void, + onHandshakeRequired: @escaping (PeerID) -> Void + ) // Messaging func sendMessage(_ content: String, mentions: [String]) @@ -85,6 +105,19 @@ protocol Transport: AnyObject { } extension Transport { + // Noise identity hooks default to inert for transports that do not carry + // Noise sessions (e.g. NostrTransport). + func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { nil } + func noiseIdentityFingerprint() -> String { "" } + func noiseStaticPublicKeyData() -> Data { Data() } + func noiseSigningPublicKeyData() -> Data { Data() } + func noiseSignData(_ data: Data) -> Data? { nil } + func noiseVerifySignature(_ signature: Data, for data: Data, publicKey: Data) -> Bool { false } + func installNoiseSessionCallbacks( + onPeerAuthenticated: @escaping (PeerID, String) -> Void, + onHandshakeRequired: @escaping (PeerID) -> Void + ) {} + func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {} diff --git a/bitchat/Services/VerificationService.swift b/bitchat/Services/VerificationService.swift index cf3ab4de..ae983cca 100644 --- a/bitchat/Services/VerificationService.swift +++ b/bitchat/Services/VerificationService.swift @@ -4,9 +4,11 @@ import Foundation final class VerificationService { static let shared = VerificationService() - // Injected Noise service from the running transport (do NOT create new BLEService) - private var noise: NoiseEncryptionService? - func configure(with noise: NoiseEncryptionService) { self.noise = noise } + // Injected running transport (do NOT create new BLEService). Noise + // identity operations go through the transport's narrow noise* wrappers + // so the raw NoiseEncryptionService is never exposed. + private var transport: Transport? + func configure(with transport: Transport) { self.transport = transport } /// Encapsulates the data encoded into a verification QR struct VerificationQR: Codable { @@ -77,16 +79,16 @@ final class VerificationService { if let c = Cache.last, c.nick == nickname, c.npub == npub, Date().timeIntervalSince(c.builtAt) < 60 { return c.value } - guard let noise = noise else { return nil } - let noiseKey = noise.getStaticPublicKeyData().hexEncodedString() - let signKey = noise.getSigningPublicKeyData().hexEncodedString() + guard let transport = transport else { return nil } + let noiseKey = transport.noiseStaticPublicKeyData().hexEncodedString() + let signKey = transport.noiseSigningPublicKeyData().hexEncodedString() let ts = Int64(Date().timeIntervalSince1970) var nonce = Data(count: 16) _ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) } let nonceB64 = nonce.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") let payload = VerificationQR(v: 1, noiseKeyHex: noiseKey, signKeyHex: signKey, npub: npub, nickname: nickname, ts: ts, nonceB64: nonceB64, sigHex: "") let msg = payload.canonicalBytes() - guard let sig = noise.signData(msg) else { return nil } + guard let sig = transport.noiseSignData(msg) else { return nil } let signed = VerificationQR(v: payload.v, noiseKeyHex: payload.noiseKeyHex, signKeyHex: payload.signKeyHex, @@ -108,8 +110,8 @@ final class VerificationService { if now - Double(qr.ts) > maxAge { return nil } // Verify signature using embedded ed25519 signKey guard let sig = Data(hexString: qr.sigHex), let signKey = Data(hexString: qr.signKeyHex) else { return nil } - guard let noise = noise else { return nil } - let ok = noise.verifySignature(sig, for: qr.canonicalBytes(), publicKey: signKey) + guard let transport = transport else { return nil } + let ok = transport.noiseVerifySignature(sig, for: qr.canonicalBytes(), publicKey: signKey) return ok ? qr : nil } @@ -133,7 +135,7 @@ final class VerificationService { let nk = noiseKeyHex.data(using: .utf8) ?? Data() msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255)) msg.append(nonceA) - guard let noise = noise, let sig = noise.signData(msg) else { return nil } + guard let transport = transport, let sig = transport.noiseSignData(msg) else { return nil } var tlv = Data() tlv.append(0x01); tlv.append(UInt8(min(nk.count, 255))); tlv.append(nk.prefix(255)) tlv.append(0x02); tlv.append(UInt8(min(nonceA.count, 255))); tlv.append(nonceA.prefix(255)) @@ -178,7 +180,7 @@ final class VerificationService { let nk = noiseKeyHex.data(using: .utf8) ?? Data() msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255)) msg.append(nonceA) - guard let noise = noise, let pub = Data(hexString: signerPublicKeyHex) else { return false } - return noise.verifySignature(signature, for: msg, publicKey: pub) + guard let transport = transport, let pub = Data(hexString: signerPublicKeyHex) else { return false } + return transport.noiseVerifySignature(signature, for: msg, publicKey: pub) } } diff --git a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift index 645ac1ab..46e0787a 100644 --- a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift @@ -45,9 +45,9 @@ protocol ChatPeerIdentityContext: AnyObject { /// `peerID`'s chat. (Single mutation path into the owner's /// `sentReadReceipts`; this coordinator never touches the raw set.) func syncReadReceiptsForSentMessages(for peerID: PeerID) - /// Re-targets the private chat session in the chat manager (no store-sync side effects). + /// Re-targets the private chat session: selection mutates through the + /// `ConversationStore` intent (the store owns selection). func beginPrivateChatSession(with peerID: PeerID) - func synchronizeConversationSelectionStore() func markPrivateMessagesAsRead(from peerID: PeerID) // MARK: Unified peer service @@ -154,15 +154,19 @@ extension ChatViewModel: ChatPeerIdentityContext { } func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool { - meshService.getNoiseService().hasEstablishedSession(with: peerID) + if case .established = meshService.getNoiseSessionState(for: peerID) { return true } + return false } func hasNoiseSession(with peerID: PeerID) -> Bool { - meshService.getNoiseService().hasSession(with: peerID) + switch meshService.getNoiseSessionState(for: peerID) { + case .established, .handshaking: return true + case .none, .handshakeQueued, .failed: return false + } } func noiseIdentityFingerprint() -> String { - meshService.getNoiseService().getIdentityFingerprint() + meshService.noiseIdentityFingerprint() } func setStoredFingerprint(_ fingerprint: String, for peerID: PeerID) { @@ -372,7 +376,6 @@ final class ChatPeerIdentityCoordinator { context.selectedPrivateChatFingerprint = nil } context.beginPrivateChatSession(with: peerID) - context.synchronizeConversationSelectionStore() context.markPrivateMessagesAsRead(from: peerID) } @@ -564,7 +567,11 @@ private extension ChatPeerIdentityCoordinator { @MainActor func migrateNoiseKeyUpdate(oldPeerID: PeerID, newPeerID: PeerID) { - if context.selectedPrivateChatPeer == oldPeerID { + // Capture before the migration: the store hands its selection off to + // `newPeerID` during `migrateChatState`, and the manager's selection + // mirrors the store, so the old peer ID is no longer selected after. + let wasSelected = context.selectedPrivateChatPeer == oldPeerID + if wasSelected { SecureLogger.info("📱 Updating private chat peer ID due to key change: \(oldPeerID) -> \(newPeerID)", category: .session) } else if !context.privateMessages(for: oldPeerID).isEmpty { SecureLogger.debug("📱 Migrating private chat messages from \(oldPeerID) to \(newPeerID)", category: .session) @@ -572,7 +579,7 @@ private extension ChatPeerIdentityCoordinator { migrateChatState(from: oldPeerID, to: newPeerID) - if context.selectedPrivateChatPeer == oldPeerID { + if wasSelected { context.selectedPrivateChatPeer = newPeerID } diff --git a/bitchat/ViewModels/ChatTransportEventCoordinator.swift b/bitchat/ViewModels/ChatTransportEventCoordinator.swift index f803c89b..31c38f7b 100644 --- a/bitchat/ViewModels/ChatTransportEventCoordinator.swift +++ b/bitchat/ViewModels/ChatTransportEventCoordinator.swift @@ -96,7 +96,7 @@ extension ChatViewModel: ChatTransportEventContext { } func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { - meshService.getNoiseService().getPeerPublicKeyData(peerID) + meshService.noiseSessionPublicKeyData(for: peerID) } func flushRouterOutbox(for peerID: PeerID) { diff --git a/bitchat/ViewModels/ChatVerificationCoordinator.swift b/bitchat/ViewModels/ChatVerificationCoordinator.swift index 356fbd7f..e82ebd35 100644 --- a/bitchat/ViewModels/ChatVerificationCoordinator.swift +++ b/bitchat/ViewModels/ChatVerificationCoordinator.swift @@ -98,13 +98,14 @@ extension ChatViewModel: ChatVerificationContext { onPeerAuthenticated: @escaping (PeerID, String) -> Void, onHandshakeRequired: @escaping (PeerID) -> Void ) { - let noiseService = meshService.getNoiseService() - noiseService.onPeerAuthenticated = onPeerAuthenticated - noiseService.onHandshakeRequired = onHandshakeRequired + meshService.installNoiseSessionCallbacks( + onPeerAuthenticated: onPeerAuthenticated, + onHandshakeRequired: onHandshakeRequired + ) } func noiseStaticPublicKeyData() -> Data { - meshService.getNoiseService().getStaticPublicKeyData() + meshService.noiseStaticPublicKeyData() } func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 2a15d59d..2cb65804 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -204,7 +204,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele } else { privateChatManager.endChat() } - synchronizeConversationSelectionStore() } } /// Read-only derived view of the store's unread direct conversations. @@ -243,7 +242,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele if let mapped = peerIdentityStore.stablePeerID(forShortID: shortPeerID) { return mapped } // Fallback: derive from active Noise session if available if shortPeerID.id.count == 16, - let key = meshService.getNoiseService().getPeerPublicKeyData(shortPeerID) { + let key = meshService.noiseSessionPublicKeyData(for: shortPeerID) { let stable = PeerID(hexData: key) peerIdentityStore.setStablePeerID(stable, forShortID: shortPeerID) return stable @@ -536,10 +535,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele /// Moves the open private chat to `newPeerID` when the current selection is /// one of the peer IDs being migrated away (side-effectful: re-targets the - /// private chat session and resyncs the conversation stores). + /// private chat session — fingerprint refresh, read receipts). + /// + /// Note: when this runs after a store `migrateConversation`, the store has + /// already handed the selection itself off to `newPeerID` (and the manager + /// mirrors it), so a selection that reads `newPeerID` is also re-targeted + /// to run the session side effects. Selections on unrelated peers are + /// untouched. @MainActor func handOffSelectedPrivateChat(from oldPeerIDs: [PeerID], to newPeerID: PeerID) { - guard oldPeerIDs.contains(where: { selectedPrivateChatPeer == $0 }) else { return } + guard oldPeerIDs.contains(where: { selectedPrivateChatPeer == $0 }) + || selectedPrivateChatPeer == newPeerID else { return } selectedPrivateChatPeer = newPeerID } @@ -803,7 +809,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele .store(in: &cancellables) ChatViewModelBootstrapper(viewModel: self).configure() - synchronizeConversationSelectionStore() } // MARK: - Deinitialization @@ -1140,8 +1145,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele bleService.resetIdentityForPanic(currentNickname: nickname) } - synchronizeConversationSelectionStore() - // No need to force UserDefaults synchronization // Reinitialize Nostr with new identity @@ -1262,13 +1265,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // MARK: - Message Handling - /// Pushes the private-chat manager's selection into the store, which - /// derives `selectedConversationID` from it and the active channel. - @MainActor - func synchronizeConversationSelectionStore() { - conversations.setSelectedPrivatePeer(privateChatManager.selectedPeer) - } - /// Invalidates the derived `messages` cache and notifies observers. /// (Formerly pulled the channel's timeline into a stored `messages` /// array; `messages` is now derived from the `ConversationStore`, so diff --git a/bitchat/ViewModels/ChatViewModelBootstrapper.swift b/bitchat/ViewModels/ChatViewModelBootstrapper.swift index 6e5d6027..ac919c1f 100644 --- a/bitchat/ViewModels/ChatViewModelBootstrapper.swift +++ b/bitchat/ViewModels/ChatViewModelBootstrapper.swift @@ -108,17 +108,9 @@ private extension ChatViewModelBootstrapper { .store(in: &viewModel.cancellables) // Private message state flows through the single-writer - // `ConversationStore` intents and its `changes` subject; only the - // selection still originates in `PrivateChatManager`. - viewModel.privateChatManager.$selectedPeer - .receive(on: DispatchQueue.main) - .sink { [weak viewModel] _ in - Task { @MainActor [weak viewModel] in - viewModel?.synchronizeConversationSelectionStore() - } - } - .store(in: &viewModel.cancellables) - + // `ConversationStore` intents and its `changes` subject; selection + // is owned by the store too (`PrivateChatManager.selectedPeer` is a + // read-only mirror), so no selection bridge is needed here. viewModel.participantTracker.objectWillChange .sink { [weak viewModel] _ in viewModel?.objectWillChange.send() @@ -192,8 +184,6 @@ private extension ChatViewModelBootstrapper { if viewModel.hasTrackedPrivateChatSelection { viewModel.updatePrivateChatPeerIfNeeded() } - - viewModel.synchronizeConversationSelectionStore() } } .store(in: &viewModel.cancellables) diff --git a/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift index ebceeb30..b756999b 100644 --- a/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift +++ b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift @@ -66,7 +66,6 @@ private final class MockChatPeerIdentityContext: ChatPeerIdentityContext { private(set) var consolidatedPeers: [(peerID: PeerID, peerNickname: String)] = [] private(set) var syncedReadReceiptPeers: [PeerID] = [] private(set) var begunChatSessions: [PeerID] = [] - private(set) var selectionStoreSyncCount = 0 private(set) var markedReadPeers: [PeerID] = [] @discardableResult @@ -83,7 +82,6 @@ private final class MockChatPeerIdentityContext: ChatPeerIdentityContext { begunChatSessions.append(peerID) } - func synchronizeConversationSelectionStore() { selectionStoreSyncCount += 1 } func markPrivateMessagesAsRead(from peerID: PeerID) { markedReadPeers.append(peerID) } // Unified peer service @@ -283,7 +281,6 @@ struct ChatPeerIdentityCoordinatorContextTests { #expect(context.storedFingerprints.map(\.fingerprint) == ["fp-alice"]) #expect(context.selectedPrivateChatFingerprint == "fp-alice") #expect(context.begunChatSessions == [peerID]) - #expect(context.selectionStoreSyncCount == 1) #expect(context.markedReadPeers == [peerID]) // Established session: no second handshake. diff --git a/bitchatTests/Mocks/MockBLEService.swift b/bitchatTests/Mocks/MockBLEService.swift index e4fb6ae5..7437a8be 100644 --- a/bitchatTests/Mocks/MockBLEService.swift +++ b/bitchatTests/Mocks/MockBLEService.swift @@ -34,9 +34,7 @@ final class MockBLEService: NSObject { weak var delegate: BitchatDelegate? var myPeerID = PeerID(str: "MOCK1234") var myNickname: String = "MockUser" - - private let mockKeychain = MockKeychain() - + // Test-specific properties var sentMessages: [(message: BitchatMessage, packet: BitchatPacket)] = [] var sentPackets: [BitchatPacket] = [] @@ -240,10 +238,6 @@ final class MockBLEService: NSObject { delegate?.didUpdatePeerList([]) } - func getNoiseService() -> NoiseEncryptionService { - return NoiseEncryptionService(keychain: mockKeychain) - } - func getFingerprint(for peerID: String) -> String? { return nil } diff --git a/bitchatTests/Mocks/MockTransport.swift b/bitchatTests/Mocks/MockTransport.swift index 5ab7d7dc..0cdbe7a0 100644 --- a/bitchatTests/Mocks/MockTransport.swift +++ b/bitchatTests/Mocks/MockTransport.swift @@ -109,8 +109,34 @@ final class MockTransport: Transport { triggeredHandshakes.append(peerID) } - func getNoiseService() -> NoiseEncryptionService { - NoiseEncryptionService(keychain: mockKeychain) + // Noise identity wrappers backed by a mock-keychain encryption service + // (mirrors the previous `getNoiseService()` placeholder behavior: a real + // identity, but no peer sessions). Exposed so tests can assert against + // the same identity the wrappers use. + private(set) lazy var mockNoiseService = NoiseEncryptionService(keychain: mockKeychain) + + func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { + mockNoiseService.getPeerPublicKeyData(peerID) + } + + func noiseIdentityFingerprint() -> String { + mockNoiseService.getIdentityFingerprint() + } + + func noiseStaticPublicKeyData() -> Data { + mockNoiseService.getStaticPublicKeyData() + } + + func noiseSigningPublicKeyData() -> Data { + mockNoiseService.getSigningPublicKeyData() + } + + func noiseSignData(_ data: Data) -> Data? { + mockNoiseService.signData(data) + } + + func noiseVerifySignature(_ signature: Data, for data: Data, publicKey: Data) -> Bool { + mockNoiseService.verifySignature(signature, for: data, publicKey: publicKey) } // MARK: - Messaging diff --git a/bitchatTests/ProtocolContractTests.swift b/bitchatTests/ProtocolContractTests.swift index 27541f47..61e2d717 100644 --- a/bitchatTests/ProtocolContractTests.swift +++ b/bitchatTests/ProtocolContractTests.swift @@ -21,7 +21,6 @@ private final class DefaultTransportProbe: Transport { let subject = CurrentValueSubject<[TransportPeerSnapshot], Never>([]) let myPeerID = PeerID(str: "0011223344556677") var myNickname = "Tester" - private let keychain = MockKeychain() private(set) var sentMessages: [(content: String, mentions: [String])] = [] var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { @@ -40,7 +39,6 @@ private final class DefaultTransportProbe: Transport { func getFingerprint(for peerID: PeerID) -> String? { nil } func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none } func triggerHandshake(with peerID: PeerID) {} - func getNoiseService() -> NoiseEncryptionService { NoiseEncryptionService(keychain: keychain) } func sendMessage(_ content: String, mentions: [String]) { sentMessages.append((content, mentions)) } func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {} func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {} diff --git a/bitchatTests/Services/VerificationServiceTests.swift b/bitchatTests/Services/VerificationServiceTests.swift index 575b1781..b8f688d3 100644 --- a/bitchatTests/Services/VerificationServiceTests.swift +++ b/bitchatTests/Services/VerificationServiceTests.swift @@ -98,10 +98,13 @@ final class VerificationServiceTests: XCTestCase { } private func makeService() -> (VerificationService, NoiseEncryptionService) { - let noise = NoiseEncryptionService(keychain: MockKeychain()) + // The service consumes Noise identity operations through the + // Transport's narrow noise* wrappers; the mock transport's backing + // encryption service is returned for direct assertions. + let transport = MockTransport() let service = VerificationService() - service.configure(with: noise) - return (service, noise) + service.configure(with: transport) + return (service, transport.mockNoiseService) } private func makeSignedQR(