mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 */ }
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user