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 <noreply@anthropic.com>
This commit is contained in:
jack
2026-06-11 15:29:07 +02:00
co-authored by Claude Fable 5
parent ed86ed1065
commit 8a867a17a1
16 changed files with 205 additions and 99 deletions
+1 -1
View File
@@ -105,7 +105,7 @@ final class AppRuntime: ObservableObject {
started = true started = true
NotificationDelegate.shared.runtime = self NotificationDelegate.shared.runtime = self
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService()) VerificationService.shared.configure(with: chatViewModel.meshService)
announceInitialTorStatusIfNeeded() announceInitialTorStatusIfNeeded()
Task(priority: .utility) { [weak self] in Task(priority: .utility) { [weak self] in
+45 -6
View File
@@ -401,10 +401,17 @@ final class BLEService: NSObject {
} }
// MARK: Identity // MARK: Identity
var myPeerID = PeerID(str: "") /// Derived from the Noise identity fingerprint; rotated only via
var myNickname: String = "anon" /// `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) { func setNickname(_ nickname: String) {
self.myNickname = nickname self.myNickname = nickname
// Send announce to notify peers of nickname change (force send) // Send announce to notify peers of nickname change (force send)
@@ -573,8 +580,40 @@ final class BLEService: NSObject {
initiateNoiseHandshake(with: peerID) initiateNoiseHandshake(with: peerID)
} }
func getNoiseService() -> NoiseEncryptionService { // MARK: Noise identity/session access (narrow Transport wrappers)
return noiseService
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 { func getCurrentBluetoothState() -> CBManagerState {
+3 -11
View File
@@ -142,17 +142,9 @@ final class NostrTransport: Transport, @unchecked Sendable {
func getFingerprint(for peerID: PeerID) -> String? { nil } func getFingerprint(for peerID: PeerID) -> String? { nil }
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none } func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
func triggerHandshake(with peerID: PeerID) { /* no-op */ } func triggerHandshake(with peerID: PeerID) { /* no-op */ }
// Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation // Nostr does not use Noise sessions here; the inert Transport defaults
private static var cachedNoiseService: NoiseEncryptionService? // for the noise* identity hooks apply.
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 // Public broadcast not supported over Nostr here
func sendMessage(_ content: String, mentions: [String]) { /* no-op */ } func sendMessage(_ content: String, mentions: [String]) { /* no-op */ }
+39 -11
View File
@@ -8,6 +8,7 @@
import BitLogger import BitLogger
import BitFoundation import BitFoundation
import Combine
import Foundation import Foundation
import SwiftUI import SwiftUI
@@ -16,8 +17,16 @@ import SwiftUI
/// `ConversationStore` (docs/CONVERSATION-STORE-DESIGN.md); the /// `ConversationStore` (docs/CONVERSATION-STORE-DESIGN.md); the
/// `privateChats` / `unreadMessages` properties below are read-only views /// `privateChats` / `unreadMessages` properties below are read-only views
/// derived from it. /// derived from it.
@MainActor
final class PrivateChatManager: ObservableObject { 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 private var selectedPeerFingerprint: String? = nil
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
@@ -27,13 +36,30 @@ final class PrivateChatManager: ObservableObject {
weak var messageRouter: MessageRouter? weak var messageRouter: MessageRouter?
// Peer service for looking up peer info during consolidation // Peer service for looking up peer info during consolidation
weak var unifiedPeerService: UnifiedPeerService? weak var unifiedPeerService: UnifiedPeerService?
/// Single source of truth for message state; injected by the /// Single source of truth for message and selection state; injected by
/// bootstrapper (`wireServiceGraph`). /// the bootstrapper (`wireServiceGraph`).
var conversationStore: ConversationStore? var conversationStore: ConversationStore? {
didSet { bindSelectionMirror() }
}
init(meshService: Transport? = nil, conversationStore: ConversationStore? = nil) { init(meshService: Transport? = nil, conversationStore: ConversationStore? = nil) {
self.meshService = meshService self.meshService = meshService
self.conversationStore = conversationStore 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) // 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 @MainActor
func startChat(with peerID: PeerID) { 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 // Store fingerprint for persistence across reconnections
if let fingerprint = meshService?.getFingerprint(for: peerID) { if let fingerprint = meshService?.getFingerprint(for: peerID) {
@@ -203,14 +233,12 @@ final class PrivateChatManager: ObservableObject {
// Mark messages as read // Mark messages as read
markAsRead(from: peerID) 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() { func endChat() {
selectedPeer = nil conversationStore?.setSelectedPrivatePeer(nil)
selectedPeerFingerprint = nil selectedPeerFingerprint = nil
} }
+34 -1
View File
@@ -61,7 +61,27 @@ protocol Transport: AnyObject {
func getFingerprint(for peerID: PeerID) -> String? func getFingerprint(for peerID: PeerID) -> String?
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState
func triggerHandshake(with peerID: PeerID) 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 // Messaging
func sendMessage(_ content: String, mentions: [String]) func sendMessage(_ content: String, mentions: [String])
@@ -85,6 +105,19 @@ protocol Transport: AnyObject {
} }
extension Transport { 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 sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {} func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
+14 -12
View File
@@ -4,9 +4,11 @@ import Foundation
final class VerificationService { final class VerificationService {
static let shared = VerificationService() static let shared = VerificationService()
// Injected Noise service from the running transport (do NOT create new BLEService) // Injected running transport (do NOT create new BLEService). Noise
private var noise: NoiseEncryptionService? // identity operations go through the transport's narrow noise* wrappers
func configure(with noise: NoiseEncryptionService) { self.noise = noise } // 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 /// Encapsulates the data encoded into a verification QR
struct VerificationQR: Codable { 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 { if let c = Cache.last, c.nick == nickname, c.npub == npub, Date().timeIntervalSince(c.builtAt) < 60 {
return c.value return c.value
} }
guard let noise = noise else { return nil } guard let transport = transport else { return nil }
let noiseKey = noise.getStaticPublicKeyData().hexEncodedString() let noiseKey = transport.noiseStaticPublicKeyData().hexEncodedString()
let signKey = noise.getSigningPublicKeyData().hexEncodedString() let signKey = transport.noiseSigningPublicKeyData().hexEncodedString()
let ts = Int64(Date().timeIntervalSince1970) let ts = Int64(Date().timeIntervalSince1970)
var nonce = Data(count: 16) var nonce = Data(count: 16)
_ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) } _ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
let nonceB64 = nonce.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") 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 payload = VerificationQR(v: 1, noiseKeyHex: noiseKey, signKeyHex: signKey, npub: npub, nickname: nickname, ts: ts, nonceB64: nonceB64, sigHex: "")
let msg = payload.canonicalBytes() 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, let signed = VerificationQR(v: payload.v,
noiseKeyHex: payload.noiseKeyHex, noiseKeyHex: payload.noiseKeyHex,
signKeyHex: payload.signKeyHex, signKeyHex: payload.signKeyHex,
@@ -108,8 +110,8 @@ final class VerificationService {
if now - Double(qr.ts) > maxAge { return nil } if now - Double(qr.ts) > maxAge { return nil }
// Verify signature using embedded ed25519 signKey // Verify signature using embedded ed25519 signKey
guard let sig = Data(hexString: qr.sigHex), let signKey = Data(hexString: qr.signKeyHex) else { return nil } guard let sig = Data(hexString: qr.sigHex), let signKey = Data(hexString: qr.signKeyHex) else { return nil }
guard let noise = noise else { return nil } guard let transport = transport else { return nil }
let ok = noise.verifySignature(sig, for: qr.canonicalBytes(), publicKey: signKey) let ok = transport.noiseVerifySignature(sig, for: qr.canonicalBytes(), publicKey: signKey)
return ok ? qr : nil return ok ? qr : nil
} }
@@ -133,7 +135,7 @@ final class VerificationService {
let nk = noiseKeyHex.data(using: .utf8) ?? Data() let nk = noiseKeyHex.data(using: .utf8) ?? Data()
msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255)) msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255))
msg.append(nonceA) 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() var tlv = Data()
tlv.append(0x01); tlv.append(UInt8(min(nk.count, 255))); tlv.append(nk.prefix(255)) 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)) 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() let nk = noiseKeyHex.data(using: .utf8) ?? Data()
msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255)) msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255))
msg.append(nonceA) msg.append(nonceA)
guard let noise = noise, let pub = Data(hexString: signerPublicKeyHex) else { return false } guard let transport = transport, let pub = Data(hexString: signerPublicKeyHex) else { return false }
return noise.verifySignature(signature, for: msg, publicKey: pub) return transport.noiseVerifySignature(signature, for: msg, publicKey: pub)
} }
} }
@@ -45,9 +45,9 @@ protocol ChatPeerIdentityContext: AnyObject {
/// `peerID`'s chat. (Single mutation path into the owner's /// `peerID`'s chat. (Single mutation path into the owner's
/// `sentReadReceipts`; this coordinator never touches the raw set.) /// `sentReadReceipts`; this coordinator never touches the raw set.)
func syncReadReceiptsForSentMessages(for peerID: PeerID) 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 beginPrivateChatSession(with peerID: PeerID)
func synchronizeConversationSelectionStore()
func markPrivateMessagesAsRead(from peerID: PeerID) func markPrivateMessagesAsRead(from peerID: PeerID)
// MARK: Unified peer service // MARK: Unified peer service
@@ -154,15 +154,19 @@ extension ChatViewModel: ChatPeerIdentityContext {
} }
func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool { 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 { 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 { func noiseIdentityFingerprint() -> String {
meshService.getNoiseService().getIdentityFingerprint() meshService.noiseIdentityFingerprint()
} }
func setStoredFingerprint(_ fingerprint: String, for peerID: PeerID) { func setStoredFingerprint(_ fingerprint: String, for peerID: PeerID) {
@@ -372,7 +376,6 @@ final class ChatPeerIdentityCoordinator {
context.selectedPrivateChatFingerprint = nil context.selectedPrivateChatFingerprint = nil
} }
context.beginPrivateChatSession(with: peerID) context.beginPrivateChatSession(with: peerID)
context.synchronizeConversationSelectionStore()
context.markPrivateMessagesAsRead(from: peerID) context.markPrivateMessagesAsRead(from: peerID)
} }
@@ -564,7 +567,11 @@ private extension ChatPeerIdentityCoordinator {
@MainActor @MainActor
func migrateNoiseKeyUpdate(oldPeerID: PeerID, newPeerID: PeerID) { 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) SecureLogger.info("📱 Updating private chat peer ID due to key change: \(oldPeerID) -> \(newPeerID)", category: .session)
} else if !context.privateMessages(for: oldPeerID).isEmpty { } else if !context.privateMessages(for: oldPeerID).isEmpty {
SecureLogger.debug("📱 Migrating private chat messages from \(oldPeerID) to \(newPeerID)", category: .session) SecureLogger.debug("📱 Migrating private chat messages from \(oldPeerID) to \(newPeerID)", category: .session)
@@ -572,7 +579,7 @@ private extension ChatPeerIdentityCoordinator {
migrateChatState(from: oldPeerID, to: newPeerID) migrateChatState(from: oldPeerID, to: newPeerID)
if context.selectedPrivateChatPeer == oldPeerID { if wasSelected {
context.selectedPrivateChatPeer = newPeerID context.selectedPrivateChatPeer = newPeerID
} }
@@ -96,7 +96,7 @@ extension ChatViewModel: ChatTransportEventContext {
} }
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? {
meshService.getNoiseService().getPeerPublicKeyData(peerID) meshService.noiseSessionPublicKeyData(for: peerID)
} }
func flushRouterOutbox(for peerID: PeerID) { func flushRouterOutbox(for peerID: PeerID) {
@@ -98,13 +98,14 @@ extension ChatViewModel: ChatVerificationContext {
onPeerAuthenticated: @escaping (PeerID, String) -> Void, onPeerAuthenticated: @escaping (PeerID, String) -> Void,
onHandshakeRequired: @escaping (PeerID) -> Void onHandshakeRequired: @escaping (PeerID) -> Void
) { ) {
let noiseService = meshService.getNoiseService() meshService.installNoiseSessionCallbacks(
noiseService.onPeerAuthenticated = onPeerAuthenticated onPeerAuthenticated: onPeerAuthenticated,
noiseService.onHandshakeRequired = onHandshakeRequired onHandshakeRequired: onHandshakeRequired
)
} }
func noiseStaticPublicKeyData() -> Data { func noiseStaticPublicKeyData() -> Data {
meshService.getNoiseService().getStaticPublicKeyData() meshService.noiseStaticPublicKeyData()
} }
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
+10 -14
View File
@@ -204,7 +204,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
} else { } else {
privateChatManager.endChat() privateChatManager.endChat()
} }
synchronizeConversationSelectionStore()
} }
} }
/// Read-only derived view of the store's unread direct conversations. /// 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 } if let mapped = peerIdentityStore.stablePeerID(forShortID: shortPeerID) { return mapped }
// Fallback: derive from active Noise session if available // Fallback: derive from active Noise session if available
if shortPeerID.id.count == 16, if shortPeerID.id.count == 16,
let key = meshService.getNoiseService().getPeerPublicKeyData(shortPeerID) { let key = meshService.noiseSessionPublicKeyData(for: shortPeerID) {
let stable = PeerID(hexData: key) let stable = PeerID(hexData: key)
peerIdentityStore.setStablePeerID(stable, forShortID: shortPeerID) peerIdentityStore.setStablePeerID(stable, forShortID: shortPeerID)
return stable return stable
@@ -536,10 +535,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
/// Moves the open private chat to `newPeerID` when the current selection is /// 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 /// 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 @MainActor
func handOffSelectedPrivateChat(from oldPeerIDs: [PeerID], to newPeerID: PeerID) { 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 selectedPrivateChatPeer = newPeerID
} }
@@ -803,7 +809,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
.store(in: &cancellables) .store(in: &cancellables)
ChatViewModelBootstrapper(viewModel: self).configure() ChatViewModelBootstrapper(viewModel: self).configure()
synchronizeConversationSelectionStore()
} }
// MARK: - Deinitialization // MARK: - Deinitialization
@@ -1140,8 +1145,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
bleService.resetIdentityForPanic(currentNickname: nickname) bleService.resetIdentityForPanic(currentNickname: nickname)
} }
synchronizeConversationSelectionStore()
// No need to force UserDefaults synchronization // No need to force UserDefaults synchronization
// Reinitialize Nostr with new identity // Reinitialize Nostr with new identity
@@ -1262,13 +1265,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// MARK: - Message Handling // 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. /// Invalidates the derived `messages` cache and notifies observers.
/// (Formerly pulled the channel's timeline into a stored `messages` /// (Formerly pulled the channel's timeline into a stored `messages`
/// array; `messages` is now derived from the `ConversationStore`, so /// array; `messages` is now derived from the `ConversationStore`, so
@@ -108,17 +108,9 @@ private extension ChatViewModelBootstrapper {
.store(in: &viewModel.cancellables) .store(in: &viewModel.cancellables)
// Private message state flows through the single-writer // Private message state flows through the single-writer
// `ConversationStore` intents and its `changes` subject; only the // `ConversationStore` intents and its `changes` subject; selection
// selection still originates in `PrivateChatManager`. // is owned by the store too (`PrivateChatManager.selectedPeer` is a
viewModel.privateChatManager.$selectedPeer // read-only mirror), so no selection bridge is needed here.
.receive(on: DispatchQueue.main)
.sink { [weak viewModel] _ in
Task { @MainActor [weak viewModel] in
viewModel?.synchronizeConversationSelectionStore()
}
}
.store(in: &viewModel.cancellables)
viewModel.participantTracker.objectWillChange viewModel.participantTracker.objectWillChange
.sink { [weak viewModel] _ in .sink { [weak viewModel] _ in
viewModel?.objectWillChange.send() viewModel?.objectWillChange.send()
@@ -192,8 +184,6 @@ private extension ChatViewModelBootstrapper {
if viewModel.hasTrackedPrivateChatSelection { if viewModel.hasTrackedPrivateChatSelection {
viewModel.updatePrivateChatPeerIfNeeded() viewModel.updatePrivateChatPeerIfNeeded()
} }
viewModel.synchronizeConversationSelectionStore()
} }
} }
.store(in: &viewModel.cancellables) .store(in: &viewModel.cancellables)
@@ -66,7 +66,6 @@ private final class MockChatPeerIdentityContext: ChatPeerIdentityContext {
private(set) var consolidatedPeers: [(peerID: PeerID, peerNickname: String)] = [] private(set) var consolidatedPeers: [(peerID: PeerID, peerNickname: String)] = []
private(set) var syncedReadReceiptPeers: [PeerID] = [] private(set) var syncedReadReceiptPeers: [PeerID] = []
private(set) var begunChatSessions: [PeerID] = [] private(set) var begunChatSessions: [PeerID] = []
private(set) var selectionStoreSyncCount = 0
private(set) var markedReadPeers: [PeerID] = [] private(set) var markedReadPeers: [PeerID] = []
@discardableResult @discardableResult
@@ -83,7 +82,6 @@ private final class MockChatPeerIdentityContext: ChatPeerIdentityContext {
begunChatSessions.append(peerID) begunChatSessions.append(peerID)
} }
func synchronizeConversationSelectionStore() { selectionStoreSyncCount += 1 }
func markPrivateMessagesAsRead(from peerID: PeerID) { markedReadPeers.append(peerID) } func markPrivateMessagesAsRead(from peerID: PeerID) { markedReadPeers.append(peerID) }
// Unified peer service // Unified peer service
@@ -283,7 +281,6 @@ struct ChatPeerIdentityCoordinatorContextTests {
#expect(context.storedFingerprints.map(\.fingerprint) == ["fp-alice"]) #expect(context.storedFingerprints.map(\.fingerprint) == ["fp-alice"])
#expect(context.selectedPrivateChatFingerprint == "fp-alice") #expect(context.selectedPrivateChatFingerprint == "fp-alice")
#expect(context.begunChatSessions == [peerID]) #expect(context.begunChatSessions == [peerID])
#expect(context.selectionStoreSyncCount == 1)
#expect(context.markedReadPeers == [peerID]) #expect(context.markedReadPeers == [peerID])
// Established session: no second handshake. // Established session: no second handshake.
+1 -7
View File
@@ -34,9 +34,7 @@ final class MockBLEService: NSObject {
weak var delegate: BitchatDelegate? weak var delegate: BitchatDelegate?
var myPeerID = PeerID(str: "MOCK1234") var myPeerID = PeerID(str: "MOCK1234")
var myNickname: String = "MockUser" var myNickname: String = "MockUser"
private let mockKeychain = MockKeychain()
// Test-specific properties // Test-specific properties
var sentMessages: [(message: BitchatMessage, packet: BitchatPacket)] = [] var sentMessages: [(message: BitchatMessage, packet: BitchatPacket)] = []
var sentPackets: [BitchatPacket] = [] var sentPackets: [BitchatPacket] = []
@@ -240,10 +238,6 @@ final class MockBLEService: NSObject {
delegate?.didUpdatePeerList([]) delegate?.didUpdatePeerList([])
} }
func getNoiseService() -> NoiseEncryptionService {
return NoiseEncryptionService(keychain: mockKeychain)
}
func getFingerprint(for peerID: String) -> String? { func getFingerprint(for peerID: String) -> String? {
return nil return nil
} }
+28 -2
View File
@@ -109,8 +109,34 @@ final class MockTransport: Transport {
triggeredHandshakes.append(peerID) triggeredHandshakes.append(peerID)
} }
func getNoiseService() -> NoiseEncryptionService { // Noise identity wrappers backed by a mock-keychain encryption service
NoiseEncryptionService(keychain: mockKeychain) // (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 // MARK: - Messaging
-2
View File
@@ -21,7 +21,6 @@ private final class DefaultTransportProbe: Transport {
let subject = CurrentValueSubject<[TransportPeerSnapshot], Never>([]) let subject = CurrentValueSubject<[TransportPeerSnapshot], Never>([])
let myPeerID = PeerID(str: "0011223344556677") let myPeerID = PeerID(str: "0011223344556677")
var myNickname = "Tester" var myNickname = "Tester"
private let keychain = MockKeychain()
private(set) var sentMessages: [(content: String, mentions: [String])] = [] private(set) var sentMessages: [(content: String, mentions: [String])] = []
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
@@ -40,7 +39,6 @@ private final class DefaultTransportProbe: Transport {
func getFingerprint(for peerID: PeerID) -> String? { nil } func getFingerprint(for peerID: PeerID) -> String? { nil }
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none } func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
func triggerHandshake(with peerID: PeerID) {} func triggerHandshake(with peerID: PeerID) {}
func getNoiseService() -> NoiseEncryptionService { NoiseEncryptionService(keychain: keychain) }
func sendMessage(_ content: String, mentions: [String]) { sentMessages.append((content, mentions)) } func sendMessage(_ content: String, mentions: [String]) { sentMessages.append((content, mentions)) }
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {} func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {}
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {} func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {}
@@ -98,10 +98,13 @@ final class VerificationServiceTests: XCTestCase {
} }
private func makeService() -> (VerificationService, NoiseEncryptionService) { 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() let service = VerificationService()
service.configure(with: noise) service.configure(with: transport)
return (service, noise) return (service, transport.mockNoiseService)
} }
private func makeSignedQR( private func makeSignedQR(