From 5fd9140ffaba00c52473ad54d45ee946eed2920c Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Sun, 24 Aug 2025 23:19:58 +0200 Subject: [PATCH] QR verification: live challenge/response over Noise; persistence, offline badges, and UX/perf polish (#510) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * QR verification scaffold: add Noise verify payload types, VerificationService with QR schema/signing, placeholder MyQR/Scan views, and UI entry points in header * QR: fix VerificationQR mutability (sigHex var) and remove duplicate Data hex helpers to resolve redeclaration; wire signed payload assembly * QR: render actual QR images with CoreImage; add copy button; keep scanner placeholder for now * QR: fix SwiftUI modifiers — apply .interpolation(.none) and .resizable() to platform Image inside ImageWrapper; remove from wrapper usage * QR: add iOS camera scanner using AVFoundation; integrate into Scan view; add NSCameraUsageDescription to Info.plist * QR: make NoisePayloadType exhaustive in ChatViewModel switches by ignoring verifyChallenge/verifyResponse for now (placeholder) * QR verification: speed + persistence + UX - Inject live Noise into VerificationService; prewarm QR on app start - Keep camera active; remove intermediate responder toast - One-shot/dupe guards and deferred send on handshake - Persist verified status immediately; standardize fingerprint (SHA-256) - Show verified badge for offline favorites; mutual verification toast - VERIFY sheet styling to match peer sheet; UI polish - Logs to diagnose verified load + favorites mapping --------- Co-authored-by: jack --- bitchat.xcodeproj/project.pbxproj | 12 + bitchat/BitchatApp.swift | 7 + bitchat/Info.plist | 2 + bitchat/Protocols/BitchatProtocol.swift | 5 + bitchat/Services/BLEService.swift | 52 ++- bitchat/Services/CommandProcessor.swift | 4 + bitchat/Services/Transport.swift | 9 + bitchat/Services/VerificationService.swift | 185 +++++++++++ bitchat/ViewModels/ChatViewModel.swift | 158 +++++++++- bitchat/Views/ContentView.swift | 25 ++ bitchat/Views/FingerprintView.swift | 19 +- bitchat/Views/MeshPeerList.swift | 25 +- bitchat/Views/VerificationViews.swift | 347 +++++++++++++++++++++ 13 files changed, 842 insertions(+), 8 deletions(-) create mode 100644 bitchat/Services/VerificationService.swift create mode 100644 bitchat/Views/VerificationViews.swift diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index 9e63eb6b..117e8629 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -142,6 +142,10 @@ FBC409E105493C491531B59A /* NostrProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */; }; A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */; }; A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */; }; + AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */; }; + AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */; }; + AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */; }; + AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -261,6 +265,8 @@ FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBLEService.swift; sourceTree = ""; }; FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "bitchat-macOS.entitlements"; sourceTree = ""; }; A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XChaCha20Poly1305Compat.swift; sourceTree = ""; }; + AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationService.swift; sourceTree = ""; }; + AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationViews.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -431,6 +437,7 @@ A55126E93155456CAA8D6656 /* Views */ = { isa = PBXGroup; children = ( + AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */, 047502B22E55FED60083520F /* GeohashPeopleList.swift */, 047502B32E55FED60083520F /* MeshPeerList.swift */, 0475028E2E5417660083520F /* LocationChannelsSheet.swift */, @@ -504,6 +511,7 @@ D98A3186D7E4C72E35BDF7FE /* Services */ = { isa = PBXGroup; children = ( + AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */, 047502B82E560F690083520F /* RelayController.swift */, 0475028B2E54171C0083520F /* LocationChannelManager.swift */, 049BD3B02E51F319001A566B /* MessageRouter.swift */, @@ -724,6 +732,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */, + AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */, A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */, AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */, 9B51E9B63A3EA59B1A7874BD /* BinaryEncodingUtils.swift in Sources */, @@ -779,6 +789,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */, + AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */, A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */, ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */, AFB6AEFCABBE97441CB3102B /* BinaryEncodingUtils.swift in Sources */, diff --git a/bitchat/BitchatApp.swift b/bitchat/BitchatApp.swift index b2297061..cf156194 100644 --- a/bitchat/BitchatApp.swift +++ b/bitchat/BitchatApp.swift @@ -31,6 +31,13 @@ struct BitchatApp: App { .environmentObject(chatViewModel) .onAppear { NotificationDelegate.shared.chatViewModel = chatViewModel + // Inject live Noise service into VerificationService to avoid creating new BLE instances + VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService()) + // Prewarm Nostr identity and QR to make first VERIFY sheet fast + DispatchQueue.global(qos: .utility).async { + let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub + _ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub) + } #if os(iOS) appDelegate.chatViewModel = chatViewModel #elseif os(macOS) diff --git a/bitchat/Info.plist b/bitchat/Info.plist index f8cd5912..7f5a6464 100644 --- a/bitchat/Info.plist +++ b/bitchat/Info.plist @@ -37,6 +37,8 @@ bitchat uses Bluetooth to discover and connect with other bitchat users nearby. NSLocationWhenInUseUsageDescription bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared. + NSCameraUsageDescription + bitchat uses the camera to scan QR codes to verify peers. UIBackgroundModes bluetooth-central diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index ca15b859..9b3838ac 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -155,12 +155,17 @@ enum NoisePayloadType: UInt8 { case privateMessage = 0x01 // Private chat message case readReceipt = 0x02 // Message was read case delivered = 0x03 // Message was delivered + // Verification (QR-based OOB binding) + case verifyChallenge = 0x10 // Verification challenge + case verifyResponse = 0x11 // Verification response var description: String { switch self { case .privateMessage: return "privateMessage" case .readReceipt: return "readReceipt" case .delivered: return "delivered" + case .verifyChallenge: return "verifyChallenge" + case .verifyResponse: return "verifyResponse" } } } diff --git a/bitchat/Services/BLEService.swift b/bitchat/Services/BLEService.swift index f18dab52..8a0bcdfc 100644 --- a/bitchat/Services/BLEService.swift +++ b/bitchat/Services/BLEService.swift @@ -508,11 +508,51 @@ final class BLEService: NSObject { func sendBroadcastAnnounce() { sendAnnounce() } + + // MARK: - QR Verification over Noise + func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data) { + let payload = VerificationService.shared.buildVerifyChallenge(noiseKeyHex: noiseKeyHex, nonceA: nonceA) + sendNoisePayload(payload, to: peerID) + } + + func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data) { + guard let payload = VerificationService.shared.buildVerifyResponse(noiseKeyHex: noiseKeyHex, nonceA: nonceA) else { return } + sendNoisePayload(payload, to: peerID) + } + + private func sendNoisePayload(_ typedPayload: Data, to peerID: String) { + guard noiseService.hasSession(with: peerID) else { + // Lazy-handshake path: queue? For now, initiate handshake and drop + initiateNoiseHandshake(with: peerID) + return + } + do { + let encrypted = try noiseService.encrypt(typedPayload, for: peerID) + let packet = BitchatPacket( + type: MessageType.noiseEncrypted.rawValue, + senderID: Data(hexString: myPeerID) ?? Data(), + recipientID: Data(hexString: peerID), + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: encrypted, + signature: nil, + ttl: messageTTL + ) + if DispatchQueue.getSpecific(key: messageQueueKey) != nil { + broadcastPacket(packet) + } else { + messageQueue.async { [weak self] in self?.broadcastPacket(packet) } + } + } catch { + SecureLogger.log("Failed to send verification payload: \(error)", category: SecureLogger.noise, level: .error) + } + } func getPeerFingerprint(_ peerID: String) -> String? { return collectionsQueue.sync { if let publicKey = peers[peerID]?.noisePublicKey { - return publicKey.hexEncodedString() + // Use the same fingerprinting method as NoiseEncryptionService/UnifiedPeerService (SHA-256 of raw key) + let hash = SHA256.hash(data: publicKey) + return hash.map { String(format: "%02x", $0) }.joined() } return nil } @@ -1383,6 +1423,16 @@ final class BLEService: NSObject { notifyUI { [weak self] in self?.delegate?.didReceiveNoisePayload(from: peerID, type: .readReceipt, payload: Data(payloadData), timestamp: ts) } + case .verifyChallenge: + let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) + notifyUI { [weak self] in + self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyChallenge, payload: Data(payloadData), timestamp: ts) + } + case .verifyResponse: + let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) + notifyUI { [weak self] in + self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyResponse, payload: Data(payloadData), timestamp: ts) + } default: SecureLogger.log("⚠️ Unknown noise payload type: \(payloadType)", category: SecureLogger.noise, level: .warning) } diff --git a/bitchat/Services/CommandProcessor.swift b/bitchat/Services/CommandProcessor.swift index 7064ee6a..2b1e1cba 100644 --- a/bitchat/Services/CommandProcessor.swift +++ b/bitchat/Services/CommandProcessor.swift @@ -165,8 +165,12 @@ class CommandProcessor { let geoBlocked = Array(SecureIdentityStateManager.shared.getBlockedNostrPubkeys()) var geoNames: [String] = [] if let vm = chatViewModel { + #if os(iOS) let visible = vm.visibleGeohashPeople() let visibleIndex = Dictionary(uniqueKeysWithValues: visible.map { ($0.id.lowercased(), $0.displayName) }) + #else + let visibleIndex: [String: String] = [:] + #endif for pk in geoBlocked { if let name = visibleIndex[pk.lowercased()] { geoNames.append(name) diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index 0685cfac..74686d8a 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -46,11 +46,20 @@ protocol Transport: AnyObject { func sendBroadcastAnnounce() func sendDeliveryAck(for messageID: String, to peerID: String) + // QR verification (optional for transports) + func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data) + func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data) + // Peer snapshots (for non-UI services) var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get } func currentPeerSnapshots() -> [TransportPeerSnapshot] } +extension Transport { + func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data) {} + func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data) {} +} + protocol TransportPeerEventsDelegate: AnyObject { @MainActor func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot]) } diff --git a/bitchat/Services/VerificationService.swift b/bitchat/Services/VerificationService.swift new file mode 100644 index 00000000..56b4caeb --- /dev/null +++ b/bitchat/Services/VerificationService.swift @@ -0,0 +1,185 @@ +import Foundation +import CryptoKit + +/// QR verification scaffolding: schema, signing, and basic challenge/response helpers. +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 } + + /// Encapsulates the data encoded into a verification QR + struct VerificationQR: Codable { + let v: Int + let noiseKeyHex: String + let signKeyHex: String + let npub: String? + let nickname: String + let ts: Int64 + let nonceB64: String + var sigHex: String + + static let context = "bitchat-verify-v1" + + /// Canonical bytes used for signature (deterministic ordering) + func canonicalBytes() -> Data { + var out = Data() + func appendField(_ s: String) { + let d = s.data(using: .utf8) ?? Data() + out.append(UInt8(min(d.count, 255))) + out.append(d.prefix(255)) + } + appendField(Self.context) + appendField(String(v)) + appendField(noiseKeyHex.lowercased()) + appendField(signKeyHex.lowercased()) + appendField(npub ?? "") + appendField(nickname) + appendField(String(ts)) + appendField(nonceB64) + return out + } + + func toURLString() -> String { + var comps = URLComponents() + comps.scheme = "bitchat" + comps.host = "verify" + comps.queryItems = [ + URLQueryItem(name: "v", value: String(v)), + URLQueryItem(name: "noise", value: noiseKeyHex), + URLQueryItem(name: "sign", value: signKeyHex), + URLQueryItem(name: "nick", value: nickname), + URLQueryItem(name: "ts", value: String(ts)), + URLQueryItem(name: "nonce", value: nonceB64), + URLQueryItem(name: "sig", value: sigHex) + ] + (npub != nil ? [URLQueryItem(name: "npub", value: npub)] : []) + return comps.string ?? "" + } + + static func fromURL(_ url: URL) -> VerificationQR? { + guard url.scheme == "bitchat", url.host == "verify", + let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems else { return nil } + func val(_ name: String) -> String? { items.first(where: { $0.name == name })?.value } + guard let vStr = val("v"), let v = Int(vStr), + let noise = val("noise"), let sign = val("sign"), + let nick = val("nick"), let tsStr = val("ts"), let ts = Int64(tsStr), + let nonce = val("nonce"), let sig = val("sig") else { return nil } + return VerificationQR(v: v, noiseKeyHex: noise, signKeyHex: sign, npub: val("npub"), nickname: nick, ts: ts, nonceB64: nonce, sigHex: sig) + } + } + + // MARK: - Public API + + /// Build a signed QR string for the current identity + func buildMyQRString(nickname: String, npub: String?) -> String? { + // Simple short-lived cache to speed up sheet opening + struct Cache { static var last: (nick: String, npub: String?, builtAt: Date, value: String)? } + 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() + 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 } + let signed = VerificationQR(v: payload.v, + noiseKeyHex: payload.noiseKeyHex, + signKeyHex: payload.signKeyHex, + npub: payload.npub, + nickname: payload.nickname, + ts: payload.ts, + nonceB64: payload.nonceB64, + sigHex: sig.map { String(format: "%02x", $0) }.joined()) + let out = signed.toURLString() + Cache.last = (nickname, npub, Date(), out) + return out + } + + /// Verify a scanned QR and return the parsed payload if valid (signature + freshness checks) + func verifyScannedQR(_ urlString: String, maxAge: TimeInterval = 5 * 60) -> VerificationQR? { + guard let url = URL(string: urlString), let qr = VerificationQR.fromURL(url) else { return nil } + // Freshness + let now = Date().timeIntervalSince1970 + 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) + return ok ? qr : nil + } + + // MARK: - Noise payloads (scaffold only) + + func buildVerifyChallenge(noiseKeyHex: String, nonceA: Data) -> Data { + // TLV: [0x01 len noiseKeyHex ascii] [0x02 len nonceA] + var tlv = Data() + let n0: [UInt8] = [0x01, UInt8(min(noiseKeyHex.count, 255))] + tlv.append(contentsOf: n0) + tlv.append(noiseKeyHex.data(using: .utf8)!.prefix(255)) + tlv.append(0x02) + tlv.append(UInt8(min(nonceA.count, 255))) + tlv.append(nonceA.prefix(255)) + return NoisePayload(type: .verifyChallenge, data: tlv).encode() + } + + func buildVerifyResponse(noiseKeyHex: String, nonceA: Data) -> Data? { + // Sign context: verify-response | noiseKeyHex | nonceA + var msg = Data("bitchat-verify-resp-v1".utf8) + 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 } + 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)) + tlv.append(0x03); tlv.append(UInt8(min(sig.count, 255))); tlv.append(sig.prefix(255)) + return NoisePayload(type: .verifyResponse, data: tlv).encode() + } + + func parseVerifyChallenge(_ data: Data) -> (noiseKeyHex: String, nonceA: Data)? { + var idx = 0 + func take(_ n: Int) -> Data? { + guard idx + n <= data.count else { return nil } + let d = data[idx..<(idx+n)] + idx += n + return Data(d) + } + // Expect type already stripped; we receive only TLV here + // TLV 0x01 noiseKeyHex + guard let t1 = take(1), t1[0] == 0x01, let l1 = take(1), let s1 = take(Int(l1[0])), + let noiseStr = String(data: s1, encoding: .utf8) else { return nil } + // TLV 0x02 nonceA + guard let t2 = take(1), t2[0] == 0x02, let l2 = take(1), let nA = take(Int(l2[0])) else { return nil } + return (noiseStr, nA) + } + + func parseVerifyResponse(_ data: Data) -> (noiseKeyHex: String, nonceA: Data, signature: Data)? { + var idx = 0 + func take(_ n: Int) -> Data? { + guard idx + n <= data.count else { return nil } + let d = data[idx..<(idx+n)] + idx += n + return Data(d) + } + guard let t1 = take(1), t1[0] == 0x01, let l1 = take(1), let s1 = take(Int(l1[0])), + let noiseStr = String(data: s1, encoding: .utf8) else { return nil } + guard let t2 = take(1), t2[0] == 0x02, let l2 = take(1), let nA = take(Int(l2[0])) else { return nil } + guard let t3 = take(1), t3[0] == 0x03, let l3 = take(1), let sig = take(Int(l3[0])) else { return nil } + return (noiseStr, nA, sig) + } + + func verifyResponseSignature(noiseKeyHex: String, nonceA: Data, signature: Data, signerPublicKeyHex: String) -> Bool { + var msg = Data("bitchat-verify-resp-v1".utf8) + 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) + } +} diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index da2d8dac..4bffa640 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -412,6 +412,22 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Delivery tracking private var cancellables = Set() + // MARK: - QR Verification (pending state) + private struct PendingVerification { + let noiseKeyHex: String + let signKeyHex: String + let nonceA: Data + let startedAt: Date + var sent: Bool + } + private var pendingQRVerifications: [String: PendingVerification] = [:] // peerID -> pending + // Last handled challenge nonce per peer to avoid duplicate responses + private var lastVerifyNonceByPeer: [String: Data] = [:] + // Track when we last received a verify challenge from a peer (fingerprint-keyed) + private var lastInboundVerifyChallengeAt: [String: Date] = [:] // key: fingerprint + // Throttle mutual verification toasts per fingerprint + private var lastMutualToastAt: [String: Date] = [:] // key: fingerprint + // MARK: - Public message batching (UI perf) // Buffer incoming public messages and flush in small batches to reduce UI invalidations private var publicBuffer: [BitchatMessage] = [] @@ -450,6 +466,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } } } + + // Throttle verification response toasts per peer to avoid spam + private var lastVerifyToastAt: [String: Date] = [:] // Track processed Nostr ACKs to avoid duplicate processing private var processedNostrAcks: Set = [] // "messageId:ackType:senderPubkey" format @@ -871,6 +890,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate { category: SecureLogger.session, level: .warning) } } + case .verifyChallenge, .verifyResponse: + // QR verification payloads over Nostr are not supported; ignore in geohash DMs + break } } } catch { } @@ -3726,10 +3748,35 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Update encryption status after verification updateEncryptionStatus(for: peerID) } + + @MainActor + func unverifyFingerprint(for peerID: String) { + guard let fingerprint = getFingerprint(for: peerID) else { return } + SecureIdentityStateManager.shared.setVerified(fingerprint: fingerprint, verified: false) + SecureIdentityStateManager.shared.forceSave() + verifiedFingerprints.remove(fingerprint) + updateEncryptionStatus(for: peerID) + } + @MainActor func loadVerifiedFingerprints() { // Load verified fingerprints directly from secure storage verifiedFingerprints = SecureIdentityStateManager.shared.getVerifiedFingerprints() + // Log snapshot for debugging persistence + let sample = Array(verifiedFingerprints.prefix(3)).map { $0.prefix(8) }.joined(separator: ", ") + SecureLogger.log("🔐 Verified loaded: \(verifiedFingerprints.count) [\(sample)]", category: SecureLogger.security, level: .info) + // Also log any offline favorites and whether we consider them verified + let offlineFavorites = unifiedPeerService.favorites.filter { !$0.isConnected } + for fav in offlineFavorites { + let fp = unifiedPeerService.getFingerprint(for: fav.id) + let isVer = fp.flatMap { verifiedFingerprints.contains($0) } ?? false + let fpShort = fp?.prefix(8) ?? "nil" + SecureLogger.log("⭐️ Favorite offline: \(fav.nickname) fp=\(fpShort) verified=\(isVer)", category: SecureLogger.security, level: .info) + } + // Invalidate cached encryption statuses so offline favorites can show verified badges immediately + invalidateEncryptionCache() + // Trigger UI refresh of peer list + objectWillChange.send() } private func setupNoiseCallbacks() { @@ -3763,6 +3810,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate { category: SecureLogger.session, level: .debug) } + // If a QR verification is pending but not sent yet, send it now that session is authenticated + if var pending = self.pendingQRVerifications[peerID], pending.sent == false { + self.meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: pending.noiseKeyHex, nonceA: pending.nonceA) + pending.sent = true + self.pendingQRVerifications[peerID] = pending + SecureLogger.log("📤 Sent deferred verify challenge to \(peerID) after handshake", category: SecureLogger.security, level: .debug) + } + // Schedule UI update // UI will update automatically } @@ -3866,6 +3921,72 @@ class ChatViewModel: ObservableObject, BitchatDelegate { objectWillChange.send() } } + case .verifyChallenge: + // Parse and respond + guard let tlv = VerificationService.shared.parseVerifyChallenge(payload) else { return } + // Ensure intended for our noise key + let myNoiseHex = meshService.getNoiseService().getStaticPublicKeyData().hexEncodedString().lowercased() + guard tlv.noiseKeyHex.lowercased() == myNoiseHex else { return } + // Deduplicate: ignore if we've already responded to this nonce for this peer + if let last = lastVerifyNonceByPeer[peerID], last == tlv.nonceA { return } + lastVerifyNonceByPeer[peerID] = tlv.nonceA + // Record inbound challenge time keyed by stable fingerprint if available + if let fp = getFingerprint(for: peerID) { + lastInboundVerifyChallengeAt[fp] = Date() + // If we've already verified this fingerprint locally, treat this as mutual and toast immediately (responder side) + if verifiedFingerprints.contains(fp) { + let now = Date() + let last = lastMutualToastAt[fp] ?? .distantPast + if now.timeIntervalSince(last) > 60 { // 1-minute throttle + lastMutualToastAt[fp] = now + let name = unifiedPeerService.getPeer(by: peerID)?.nickname ?? resolveNickname(for: peerID) + NotificationService.shared.sendLocalNotification( + title: "Mutual verification", + body: "You and \(name) verified each other", + identifier: "verify-mutual-\(peerID)-\(UUID().uuidString)" + ) + } + } + } + meshService.sendVerifyResponse(to: peerID, noiseKeyHex: tlv.noiseKeyHex, nonceA: tlv.nonceA) + // Silent response: no toast needed on responder + case .verifyResponse: + guard let resp = VerificationService.shared.parseVerifyResponse(payload) else { return } + // Check pending for this peer + guard let pending = pendingQRVerifications[peerID] else { return } + guard resp.noiseKeyHex.lowercased() == pending.noiseKeyHex.lowercased(), resp.nonceA == pending.nonceA else { return } + // Verify signature with expected sign key + let ok = VerificationService.shared.verifyResponseSignature(noiseKeyHex: resp.noiseKeyHex, nonceA: resp.nonceA, signature: resp.signature, signerPublicKeyHex: pending.signKeyHex) + if ok { + pendingQRVerifications.removeValue(forKey: peerID) + if let fp = getFingerprint(for: peerID) { + let short = fp.prefix(8) + SecureLogger.log("🔐 Marking verified fingerprint: \(short)", category: SecureLogger.security, level: .info) + SecureIdentityStateManager.shared.setVerified(fingerprint: fp, verified: true) + SecureIdentityStateManager.shared.forceSave() + verifiedFingerprints.insert(fp) + let name = unifiedPeerService.getPeer(by: peerID)?.nickname ?? resolveNickname(for: peerID) + NotificationService.shared.sendLocalNotification( + title: "Verified", + body: "You verified \(name)", + identifier: "verify-success-\(peerID)-\(UUID().uuidString)" + ) + // If we also recently responded to their challenge, flag mutual and toast (initiator side) + if let t = lastInboundVerifyChallengeAt[fp], Date().timeIntervalSince(t) < 600 { + let now = Date() + let lastToast = lastMutualToastAt[fp] ?? .distantPast + if now.timeIntervalSince(lastToast) > 60 { + lastMutualToastAt[fp] = now + NotificationService.shared.sendLocalNotification( + title: "Mutual verification", + body: "You and \(name) verified each other", + identifier: "verify-mutual-\(peerID)-\(UUID().uuidString)" + ) + } + } + updateEncryptionStatus(for: peerID) + } + } } } } @@ -3891,6 +4012,36 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } } + // MARK: - QR Verification API + @MainActor + func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool { + // Find a matching peer by Noise key + let targetNoise = qr.noiseKeyHex.lowercased() + guard let peer = unifiedPeerService.peers.first(where: { $0.noisePublicKey.hexEncodedString().lowercased() == targetNoise }) else { + return false + } + let peerID = peer.id + // If we already have a pending verification with this peer, don't send another + if pendingQRVerifications[peerID] != nil { + return true + } + // Generate nonceA + 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 Noise session is established, send immediately; otherwise trigger handshake and send on auth + let noise = meshService.getNoiseService() + if noise.hasEstablishedSession(with: peerID) { + meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: qr.noiseKeyHex, nonceA: nonce) + pending.sent = true + pendingQRVerifications[peerID] = pending + } else { + meshService.triggerHandshake(with: peerID) + } + return true + } + // Mention parsing moved from BLE – use the existing non-optional helper below // MARK: - Peer Connection Events @@ -4552,6 +4703,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate { privateChats[targetPeerID]?[idx].deliveryStatus = .read(by: peerName, at: Date()) objectWillChange.send() } + case .verifyChallenge, .verifyResponse: + // Ignore verification payloads arriving via Nostr path for now + break } } catch { @@ -4904,10 +5058,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // MARK: - Geohash Nickname Resolution (for /block in geohash) @MainActor func nostrPubkeyForDisplayName(_ name: String) -> String? { - // Look up current visible geohash participants for an exact displayName match + // Look up current visible geohash participants for an exact displayName match (iOS only) + #if os(iOS) for p in visibleGeohashPeople() { if p.displayName == name { return p.id } } + #endif return nil } diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 3cc526f0..cbc01efd 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -58,6 +58,7 @@ struct ContentView: View { @State private var scrollThrottleTimer: Timer? @State private var autocompleteDebounceTimer: Timer? @State private var showLocationChannelsSheet = false + @State private var showVerifySheet = false @State private var expandedMessageIDs: Set = [] // Window sizes for rendering (infinite scroll up) @State private var windowCountPublic: Int = 300 @@ -934,6 +935,24 @@ struct ContentView: View { .font(.system(size: 16, weight: .bold, design: .monospaced)) .foregroundColor(textColor) Spacer() + // Show QR only on mesh channel's peer list + #if os(iOS) + if case .mesh = locationManager.selectedChannel { + Button(action: { showVerifySheet = true }) { + Image(systemName: "qrcode") + .font(.system(size: 14)) + } + .buttonStyle(.plain) + .help("Verification: show my QR or scan a friend") + } + #else + Button(action: { showVerifySheet = true }) { + Image(systemName: "qrcode") + .font(.system(size: 14)) + } + .buttonStyle(.plain) + .help("Verification: show my QR or scan a friend") + #endif } .frame(height: 44) // Match header height .padding(.horizontal, 12) @@ -1224,6 +1243,8 @@ struct ContentView: View { .accessibilityHidden(true) } .foregroundColor(headerCountColor) + + // QR moved to the PEOPLE header in the sidebar when on mesh channel } .onTapGesture { withAnimation(.easeInOut(duration: 0.2)) { @@ -1231,6 +1252,10 @@ struct ContentView: View { sidebarDragOffset = 0 } } + .sheet(isPresented: $showVerifySheet) { + VerificationSheetView(isPresented: $showVerifySheet) + .environmentObject(viewModel) + } } .frame(height: 44) .padding(.horizontal, 12) diff --git a/bitchat/Views/FingerprintView.swift b/bitchat/Views/FingerprintView.swift index 93cd5659..fe8dabdb 100644 --- a/bitchat/Views/FingerprintView.swift +++ b/bitchat/Views/FingerprintView.swift @@ -32,8 +32,9 @@ struct FingerprintView: View { Spacer() - Button("DONE") { - dismiss() + Button(action: { dismiss() }) { + Image(systemName: "xmark") + .font(.system(size: 14, weight: .semibold)) } .foregroundColor(textColor) } @@ -165,6 +166,20 @@ struct FingerprintView: View { .cornerRadius(8) } .buttonStyle(PlainButtonStyle()) + } else { + Button(action: { + viewModel.unverifyFingerprint(for: peerID) + dismiss() + }) { + Text("REMOVE VERIFICATION") + .font(.system(size: 14, weight: .bold, design: .monospaced)) + .foregroundColor(.white) + .padding(.horizontal, 20) + .padding(.vertical, 10) + .background(Color.red) + .cornerRadius(8) + } + .buttonStyle(PlainButtonStyle()) } } .padding(.top) diff --git a/bitchat/Views/MeshPeerList.swift b/bitchat/Views/MeshPeerList.swift index 7b1559ac..4b0c8dc1 100644 --- a/bitchat/Views/MeshPeerList.swift +++ b/bitchat/Views/MeshPeerList.swift @@ -85,10 +85,27 @@ struct MeshPeerList: View { .help("Blocked") } - if let icon = item.enc.icon, !isMe { - Image(systemName: icon) - .font(.system(size: 10)) - .foregroundColor(baseColor) + if !isMe { + if peer.isConnected { + if let icon = item.enc.icon { + Image(systemName: icon) + .font(.system(size: 10)) + .foregroundColor(baseColor) + } + } else { + // Offline: prefer showing verified badge from persisted fingerprints + if let fp = viewModel.getFingerprint(for: peer.id), + viewModel.verifiedFingerprints.contains(fp) { + Image(systemName: "checkmark.seal.fill") + .font(.system(size: 10)) + .foregroundColor(baseColor) + } else if let icon = item.enc.icon { + // Fallback to whatever status says (likely lock if we had a past session) + Image(systemName: icon) + .font(.system(size: 10)) + .foregroundColor(baseColor) + } + } } Spacer() diff --git a/bitchat/Views/VerificationViews.swift b/bitchat/Views/VerificationViews.swift new file mode 100644 index 00000000..b5fa3a42 --- /dev/null +++ b/bitchat/Views/VerificationViews.swift @@ -0,0 +1,347 @@ +import SwiftUI +import CoreImage +import CoreImage.CIFilterBuiltins +#if os(iOS) +import UIKit +#else +import AppKit +#endif + +/// Placeholder view to display the user's verification QR payload as text. +struct MyQRView: View { + let qrString: String + @Environment(\.colorScheme) var colorScheme + private var boxColor: Color { Color.gray.opacity(0.1) } + + var body: some View { + VStack(spacing: 12) { + Text("scan to verify me") + .font(.system(size: 16, weight: .bold, design: .monospaced)) + + VStack(spacing: 10) { + QRCodeImage(data: qrString, size: 240) + .accessibilityLabel("verification qr code") + + // Non-scrolling, fully visible URL (wraps across lines) + Text(qrString) + .font(.system(size: 11, design: .monospaced)) + .textSelection(.enabled) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .padding(8) + .background(boxColor) + .cornerRadius(8) + } + .padding() + .frame(maxWidth: .infinity) + .background(boxColor) + .cornerRadius(8) + } + .padding() + } +} + +// Render a QR code image for a given string using CoreImage +struct QRCodeImage: View { + let data: String + let size: CGFloat + + private let context = CIContext() + private let filter = CIFilter.qrCodeGenerator() + + var body: some View { + Group { + if let image = generateImage() { + ImageWrapper(image: image) + .frame(width: size, height: size) + } else { + RoundedRectangle(cornerRadius: 8) + .stroke(Color.gray.opacity(0.5), lineWidth: 1) + .frame(width: size, height: size) + .overlay( + Text("qr unavailable") + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(.gray) + ) + } + } + } + + private func generateImage() -> CGImage? { + let inputData = Data(data.utf8) + filter.message = inputData + filter.correctionLevel = "M" + guard let outputImage = filter.outputImage else { return nil } + let scale = max(1, Int(size / 32)) + let transformed = outputImage.transformed(by: CGAffineTransform(scaleX: CGFloat(scale), y: CGFloat(scale))) + return context.createCGImage(transformed, from: transformed.extent) + } +} + +// Platform-specific wrapper to display CGImage in SwiftUI +struct ImageWrapper: View { + let image: CGImage + var body: some View { + #if os(iOS) + let ui = UIImage(cgImage: image) + return Image(uiImage: ui) + .interpolation(.none) + .resizable() + #else + let ns = NSImage(cgImage: image, size: .zero) + return Image(nsImage: ns) + .interpolation(.none) + .resizable() + #endif + } +} + +/// Placeholder scanner UI; real camera scanning will be added later. +struct QRScanView: View { + @EnvironmentObject var viewModel: ChatViewModel + var isActive: Bool = true + @State private var input = "" + @State private var result: String = "" // not shown for iOS scanner + @State private var lastValid: String = "" + var body: some View { + VStack(alignment: .leading, spacing: 12) { + #if os(iOS) + CameraScannerView(isActive: isActive) { code in + if let qr = VerificationService.shared.verifyScannedQR(code) { + let ok = viewModel.beginQRVerification(with: qr) + if !ok { /* already pending; continue scanning */ } + lastValid = code + } else { + // ignore invalid reads; continue scanning + } + } + .frame(height: 260) + .clipShape(RoundedRectangle(cornerRadius: 8)) + #else + Text("paste qr content to validate:") + .font(.system(size: 14, weight: .medium, design: .monospaced)) + TextEditor(text: $input) + .frame(height: 100) + .border(Color.gray.opacity(0.4)) + Button("validate") { + if let qr = VerificationService.shared.verifyScannedQR(input) { + let ok = viewModel.beginQRVerification(with: qr) + result = ok ? "verification requested for \(qr.nickname)" : "could not find matching peer" + } else { + result = "invalid or expired qr payload" + } + } + .buttonStyle(.bordered) + #endif + // No status text under camera per design + Spacer() + } + .padding() + } +} + +#if os(iOS) +import AVFoundation + +struct CameraScannerView: UIViewRepresentable { + typealias UIViewType = PreviewView + var isActive: Bool + var onCode: (String) -> Void + + func makeUIView(context: Context) -> PreviewView { + let view = PreviewView() + context.coordinator.setup(sessionOwner: view, onCode: onCode) + context.coordinator.setActive(isActive) + return view + } + + func updateUIView(_ uiView: PreviewView, context: Context) { + context.coordinator.setActive(isActive) + } + + func makeCoordinator() -> Coordinator { Coordinator() } + + final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate { + private var onCode: ((String) -> Void)? + private weak var owner: PreviewView? + private let session = AVCaptureSession() + private var isRunning = false + private var permissionGranted = false + private var desiredActive = false + + func setup(sessionOwner: PreviewView, onCode: @escaping (String) -> Void) { + self.owner = sessionOwner + self.onCode = onCode + session.beginConfiguration() + session.sessionPreset = .high + guard let device = AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: device), + session.canAddInput(input) else { return } + session.addInput(input) + let output = AVCaptureMetadataOutput() + guard session.canAddOutput(output) else { return } + session.addOutput(output) + output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) + if output.availableMetadataObjectTypes.contains(.qr) { + output.metadataObjectTypes = [.qr] + } + session.commitConfiguration() + sessionOwner.videoPreviewLayer.session = session + // Request permission and start + AVCaptureDevice.requestAccess(for: .video) { granted in + self.permissionGranted = granted + if granted && self.desiredActive && !self.isRunning { + self.setActive(true) + } + } + } + + func setActive(_ active: Bool) { + desiredActive = active + guard permissionGranted else { return } + if active && !isRunning { + isRunning = true + DispatchQueue.global(qos: .userInitiated).async { + if !self.session.isRunning { self.session.startRunning() } + } + } else if !active && isRunning { + isRunning = false + DispatchQueue.global(qos: .userInitiated).async { + if self.session.isRunning { self.session.stopRunning() } + } + } + } + + func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { + for obj in metadataObjects { + guard let m = obj as? AVMetadataMachineReadableCodeObject, + m.type == .qr, + let str = m.stringValue else { continue } + onCode?(str) + } + } + } + + final class PreviewView: UIView { + override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self } + var videoPreviewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer } + override init(frame: CGRect) { + super.init(frame: frame) + videoPreviewLayer.videoGravity = .resizeAspectFill + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + } +} +#endif + +// Combined sheet: shows my QR by default with a button to scan instead +struct VerificationSheetView: View { + @EnvironmentObject var viewModel: ChatViewModel + @Binding var isPresented: Bool + @State private var showingScanner = false + @Environment(\.colorScheme) var colorScheme + + private var backgroundColor: Color { colorScheme == .dark ? Color.black : Color.white } + private var accentColor: Color { colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) } + private var boxColor: Color { Color.gray.opacity(0.1) } + + private func myQRString() -> String { + let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub + return VerificationService.shared.buildMyQRString(nickname: viewModel.nickname, npub: npub) ?? "" + } + + var body: some View { + VStack(spacing: 0) { + // Top header (always at top) + HStack { + Text("VERIFY") + .font(.system(size: 14, weight: .bold, design: .monospaced)) + .foregroundColor(accentColor) + Spacer() + Button(action: { + showingScanner = false + isPresented = false + }) { + Image(systemName: "xmark") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(accentColor) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 8) + + Divider() + + // Content area + Group { + if showingScanner { + VStack(alignment: .leading, spacing: 12) { + Text("scan a friend's qr") + .font(.system(size: 16, weight: .bold, design: .monospaced)) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + .foregroundColor(accentColor) + #if os(iOS) + QRScanView(isActive: showingScanner) + .environmentObject(viewModel) + .frame(height: 280) + .clipShape(RoundedRectangle(cornerRadius: 10)) + #else + QRScanView() + .environmentObject(viewModel) + #endif + } + .padding() + .frame(maxWidth: .infinity) + .background(boxColor) + .cornerRadius(8) + } else { + let qr = myQRString() + MyQRView(qrString: qr) + } + } + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + + // Centered controls moved up + VStack(spacing: 10) { + if showingScanner { + Button(action: { showingScanner = false }) { + Label("show my qr", systemImage: "qrcode") + .font(.system(size: 13, design: .monospaced)) + } + .buttonStyle(.bordered) + } else { + Button(action: { showingScanner = true }) { + Label("scan someone else's qr", systemImage: "camera.viewfinder") + .font(.system(size: 13, weight: .medium, design: .monospaced)) + } + .buttonStyle(.bordered) + .tint(.gray) + } + + // Optional: Remove verification for selected peer (if verified) + if let pid = viewModel.selectedPrivateChatPeer, + let fp = viewModel.getFingerprint(for: pid), + viewModel.verifiedFingerprints.contains(fp) { + Button(action: { viewModel.unverifyFingerprint(for: pid) }) { + Label("remove verification", systemImage: "minus.circle") + .font(.system(size: 12, design: .monospaced)) + } + .buttonStyle(.bordered) + .tint(.gray) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + } + .background(backgroundColor) + #if os(iOS) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + #endif + .onDisappear { showingScanner = false } + } +}