Files
bitchat/bitchat/ViewModels/ChatVerificationCoordinator.swift
T
jackandClaude Fable 5 8a867a17a1 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>
2026-06-11 15:29:07 +02:00

366 lines
15 KiB
Swift

import BitFoundation
import BitLogger
import Foundation
import Security
/// The narrow surface `ChatVerificationCoordinator` needs from its owner.
///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
/// minimal context it actually uses instead of holding an `unowned` back-ref
/// to the whole `ChatViewModel`. This keeps the coordinator independently
/// testable (see `ChatVerificationCoordinatorContextTests`) and makes its true
/// dependencies explicit.
@MainActor
protocol ChatVerificationContext: AnyObject {
// MARK: Fingerprints & verification state
func getFingerprint(for peerID: PeerID) -> String?
/// The UI-facing verified-fingerprint set (peer identity store backed).
var verifiedFingerprints: Set<String> { get set }
/// The persisted verified-fingerprint set from the identity manager.
func persistedVerifiedFingerprints() -> Set<String>
/// Persists the verified flag in the identity manager.
func setIdentityVerified(fingerprint: String, verified: Bool)
/// Updates the UI-facing verified flag in the peer identity store.
func setStoredVerified(_ fingerprint: String, verified: Bool)
func isVerifiedFingerprint(_ fingerprint: String) -> Bool
func saveIdentityState()
// MARK: Encryption status
func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID)
func updateEncryptionStatus(for peerID: PeerID)
func invalidateEncryptionCache(for peerID: PeerID?)
/// Signals that verification state changed so observers refresh (e.g. `objectWillChange.send()`).
func notifyUIChanged()
// MARK: Peers
var unifiedPeers: [BitchatPeer] { get }
var unifiedFavorites: [BitchatPeer] { get }
/// The peer's current entry in the unified peer service, if known.
func unifiedPeer(for peerID: PeerID) -> BitchatPeer?
func unifiedFingerprint(for peerID: PeerID) -> String?
func resolveNickname(for peerID: PeerID) -> String
func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID?
func cacheStablePeerID(_ stablePeerID: PeerID, for shortPeerID: PeerID)
// MARK: Noise sessions & verification transport
/// Installs the Noise service's session callbacks (single registration point).
func installNoiseSessionCallbacks(
onPeerAuthenticated: @escaping (PeerID, String) -> Void,
onHandshakeRequired: @escaping (PeerID) -> Void
)
/// Resolves the peer's Noise static key from the active Noise session, if any.
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data?
/// Our own Noise static public key.
func noiseStaticPublicKeyData() -> Data
func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool
func triggerHandshake(with peerID: PeerID)
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
// MARK: Notifications (shared with `ChatNostrContext`)
/// Posts a generic local user notification.
func postLocalNotification(title: String, body: String, identifier: String)
}
extension ChatViewModel: ChatVerificationContext {
// `getFingerprint(for:)`, `verifiedFingerprints`, `saveIdentityState()`,
// `updateEncryptionStatus(for:)`, `invalidateEncryptionCache(for:)`,
// `notifyUIChanged()`, `unifiedPeer(for:)`, `unifiedFingerprint(for:)`,
// `isVerifiedFingerprint(_:)`, `setEncryptionStatus(_:for:)`,
// `resolveNickname(for:)`, `cachedStablePeerID(for:)`,
// `cacheStablePeerID(_:for:)`, `noiseSessionPublicKeyData(for:)`,
// `hasEstablishedNoiseSession(with:)`, and `triggerHandshake(with:)` are
// shared requirements with the other contexts or satisfied by existing
// `ChatViewModel` members. The members below flatten nested service
// accesses into intent-named calls.
func persistedVerifiedFingerprints() -> Set<String> {
identityManager.getVerifiedFingerprints()
}
func setIdentityVerified(fingerprint: String, verified: Bool) {
identityManager.setVerified(fingerprint: fingerprint, verified: verified)
}
func setStoredVerified(_ fingerprint: String, verified: Bool) {
peerIdentityStore.setVerified(fingerprint, verified: verified)
}
var unifiedPeers: [BitchatPeer] {
unifiedPeerService.peers
}
var unifiedFavorites: [BitchatPeer] {
unifiedPeerService.favorites
}
func installNoiseSessionCallbacks(
onPeerAuthenticated: @escaping (PeerID, String) -> Void,
onHandshakeRequired: @escaping (PeerID) -> Void
) {
meshService.installNoiseSessionCallbacks(
onPeerAuthenticated: onPeerAuthenticated,
onHandshakeRequired: onHandshakeRequired
)
}
func noiseStaticPublicKeyData() -> Data {
meshService.noiseStaticPublicKeyData()
}
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA)
}
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
meshService.sendVerifyResponse(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA)
}
func postLocalNotification(title: String, body: String, identifier: String) {
NotificationService.shared.sendLocalNotification(title: title, body: body, identifier: identifier)
}
}
@MainActor
final class ChatVerificationCoordinator {
struct PendingVerification {
let noiseKeyHex: String
let signKeyHex: String
let nonceA: Data
let startedAt: Date
var sent: Bool
}
private unowned let context: any ChatVerificationContext
private var pendingQRVerifications: [PeerID: PendingVerification] = [:]
private var lastVerifyNonceByPeer: [PeerID: Data] = [:]
private var lastInboundVerifyChallengeAt: [String: Date] = [:]
private var lastMutualToastAt: [String: Date] = [:]
init(context: any ChatVerificationContext) {
self.context = context
}
func verifyFingerprint(for peerID: PeerID) {
guard let fingerprint = context.getFingerprint(for: peerID) else { return }
context.setIdentityVerified(fingerprint: fingerprint, verified: true)
context.saveIdentityState()
context.setStoredVerified(fingerprint, verified: true)
context.updateEncryptionStatus(for: peerID)
}
func unverifyFingerprint(for peerID: PeerID) {
guard let fingerprint = context.getFingerprint(for: peerID) else { return }
context.setIdentityVerified(fingerprint: fingerprint, verified: false)
context.saveIdentityState()
context.setStoredVerified(fingerprint, verified: false)
context.updateEncryptionStatus(for: peerID)
}
func loadVerifiedFingerprints() {
context.verifiedFingerprints = context.persistedVerifiedFingerprints()
let sample = Array(context.verifiedFingerprints.prefix(TransportConfig.uiFingerprintSampleCount))
.map { $0.prefix(8) }
.joined(separator: ", ")
SecureLogger.info("🔐 Verified loaded: \(context.verifiedFingerprints.count) [\(sample)]", category: .security)
let offlineFavorites = context.unifiedFavorites.filter { !$0.isConnected }
for favorite in offlineFavorites {
let fingerprint = context.unifiedFingerprint(for: favorite.peerID)
let isVerified = fingerprint.flatMap { context.isVerifiedFingerprint($0) } ?? false
let shortFingerprint = fingerprint?.prefix(8) ?? "nil"
SecureLogger.info(
"⭐️ Favorite offline: \(favorite.nickname) fp=\(shortFingerprint) verified=\(isVerified)",
category: .security
)
}
context.invalidateEncryptionCache(for: nil)
context.notifyUIChanged()
}
func setupNoiseCallbacks() {
context.installNoiseSessionCallbacks(
onPeerAuthenticated: { [weak self] peerID, fingerprint in
DispatchQueue.main.async { [weak self] in
guard let self else { return }
SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security)
if self.context.isVerifiedFingerprint(fingerprint) {
self.context.setEncryptionStatus(.noiseVerified, for: peerID)
} else {
self.context.setEncryptionStatus(.noiseSecured, for: peerID)
}
self.context.invalidateEncryptionCache(for: peerID)
if self.context.cachedStablePeerID(for: peerID) == nil,
let keyData = self.context.noiseSessionPublicKeyData(for: peerID) {
let stablePeerID = PeerID(hexData: keyData)
self.context.cacheStablePeerID(stablePeerID, for: peerID)
SecureLogger.debug(
"🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stablePeerID.id.prefix(8))…",
category: .session
)
}
if var pending = self.pendingQRVerifications[peerID], pending.sent == false {
self.context.sendVerifyChallenge(
to: peerID,
noiseKeyHex: pending.noiseKeyHex,
nonceA: pending.nonceA
)
pending.sent = true
self.pendingQRVerifications[peerID] = pending
SecureLogger.debug("📤 Sent deferred verify challenge to \(peerID) after handshake", category: .security)
}
}
},
onHandshakeRequired: { [weak self] peerID in
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.context.setEncryptionStatus(.noiseHandshaking, for: peerID)
self.context.invalidateEncryptionCache(for: peerID)
}
}
)
}
func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {
let targetNoise = qr.noiseKeyHex.lowercased()
guard let peer = context.unifiedPeers.first(where: {
$0.noisePublicKey.hexEncodedString().lowercased() == targetNoise
}) else {
return false
}
let peerID = peer.peerID
if pendingQRVerifications[peerID] != nil {
return true
}
var nonce = Data(count: 16)
_ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
var pending = PendingVerification(
noiseKeyHex: qr.noiseKeyHex,
signKeyHex: qr.signKeyHex,
nonceA: nonce,
startedAt: Date(),
sent: false
)
pendingQRVerifications[peerID] = pending
if context.hasEstablishedNoiseSession(with: peerID) {
context.sendVerifyChallenge(to: peerID, noiseKeyHex: qr.noiseKeyHex, nonceA: nonce)
pending.sent = true
pendingQRVerifications[peerID] = pending
} else {
context.triggerHandshake(with: peerID)
}
return true
}
func handleVerifyChallengePayload(from peerID: PeerID, payload: Data) {
guard let challenge = VerificationService.shared.parseVerifyChallenge(payload) else { return }
let myNoiseHex = context.noiseStaticPublicKeyData()
.hexEncodedString()
.lowercased()
guard challenge.noiseKeyHex.lowercased() == myNoiseHex else { return }
guard lastVerifyNonceByPeer[peerID] != challenge.nonceA else { return }
lastVerifyNonceByPeer[peerID] = challenge.nonceA
if let fingerprint = context.getFingerprint(for: peerID) {
lastInboundVerifyChallengeAt[fingerprint] = Date()
if context.isVerifiedFingerprint(fingerprint) {
maybeSendMutualVerificationNotification(
fingerprint: fingerprint,
peerID: peerID,
title: "Mutual verification",
bodyName: context.unifiedPeer(for: peerID)?.nickname
?? context.resolveNickname(for: peerID),
notificationPrefix: "verify-mutual"
)
}
}
context.sendVerifyResponse(
to: peerID,
noiseKeyHex: challenge.noiseKeyHex,
nonceA: challenge.nonceA
)
}
func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) {
guard let response = VerificationService.shared.parseVerifyResponse(payload),
let pending = pendingQRVerifications[peerID],
response.noiseKeyHex.lowercased() == pending.noiseKeyHex.lowercased(),
response.nonceA == pending.nonceA else { return }
let isValid = VerificationService.shared.verifyResponseSignature(
noiseKeyHex: response.noiseKeyHex,
nonceA: response.nonceA,
signature: response.signature,
signerPublicKeyHex: pending.signKeyHex
)
guard isValid else { return }
pendingQRVerifications.removeValue(forKey: peerID)
guard let fingerprint = context.getFingerprint(for: peerID) else { return }
let shortFingerprint = fingerprint.prefix(8)
SecureLogger.info("🔐 Marking verified fingerprint: \(shortFingerprint)", category: .security)
context.setIdentityVerified(fingerprint: fingerprint, verified: true)
context.saveIdentityState()
context.setStoredVerified(fingerprint, verified: true)
let peerName = context.unifiedPeer(for: peerID)?.nickname
?? context.resolveNickname(for: peerID)
context.postLocalNotification(
title: "Verified",
body: "You verified \(peerName)",
identifier: "verify-success-\(peerID)-\(UUID().uuidString)"
)
if let challengeTime = lastInboundVerifyChallengeAt[fingerprint],
Date().timeIntervalSince(challengeTime) < 600 {
maybeSendMutualVerificationNotification(
fingerprint: fingerprint,
peerID: peerID,
title: "Mutual verification",
bodyName: peerName,
notificationPrefix: "verify-mutual"
)
}
context.updateEncryptionStatus(for: peerID)
}
}
private extension ChatVerificationCoordinator {
func maybeSendMutualVerificationNotification(
fingerprint: String,
peerID: PeerID,
title: String,
bodyName: String,
notificationPrefix: String
) {
let now = Date()
let lastToast = lastMutualToastAt[fingerprint] ?? .distantPast
guard now.timeIntervalSince(lastToast) > 60 else { return }
lastMutualToastAt[fingerprint] = now
context.postLocalNotification(
title: title,
body: "You and \(bodyName) verified each other",
identifier: "\(notificationPrefix)-\(peerID)-\(UUID().uuidString)"
)
}
}