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
NotificationDelegate.shared.runtime = self
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
VerificationService.shared.configure(with: chatViewModel.meshService)
announceInitialTorStatusIfNeeded()
Task(priority: .utility) { [weak self] in
+43 -4
View File
@@ -402,9 +402,16 @@ 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 {
+2 -10
View File
@@ -143,16 +143,8 @@ final class NostrTransport: Transport, @unchecked Sendable {
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 */ }
+39 -11
View File
@@ -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<String> = [] // 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
}
+34 -1
View File
@@ -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) {}
+14 -12
View File
@@ -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)
}
}
@@ -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
}
@@ -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) {
@@ -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) {
+10 -14
View File
@@ -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
@@ -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)
@@ -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.
-6
View File
@@ -35,8 +35,6 @@ final class MockBLEService: NSObject {
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
}
+28 -2
View File
@@ -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
-2
View File
@@ -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) {}
@@ -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(