From 0bc5cbe0b7fda016a9117ab1897532fad496d9af Mon Sep 17 00:00:00 2001 From: jack Date: Sun, 24 Aug 2025 23:11:20 +0200 Subject: [PATCH] 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 --- bitchat/BitchatApp.swift | 7 + bitchat/Services/BLEService.swift | 52 ++++- bitchat/Services/CommandProcessor.swift | 4 + bitchat/Services/Transport.swift | 9 + bitchat/Services/VerificationService.swift | 63 +++++- bitchat/ViewModels/ChatViewModel.swift | 155 ++++++++++++++- bitchat/Views/ContentView.swift | 46 ++--- bitchat/Views/FingerprintView.swift | 19 +- bitchat/Views/MeshPeerList.swift | 25 ++- bitchat/Views/VerificationViews.swift | 215 ++++++++++++++++----- 10 files changed, 508 insertions(+), 87 deletions(-) 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/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 index 846f31c1..56b4caeb 100644 --- a/bitchat/Services/VerificationService.swift +++ b/bitchat/Services/VerificationService.swift @@ -2,15 +2,12 @@ import Foundation import CryptoKit /// QR verification scaffolding: schema, signing, and basic challenge/response helpers. -@MainActor final class VerificationService { static let shared = VerificationService() - private let noise: NoiseEncryptionService = { - // We reuse the singleton inside BLEService normally; for scaffolding we access a cached instance. - // In production, inject the transport's Noise service. - return BLEService().getNoiseService() - }() + // 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 { @@ -76,6 +73,12 @@ final class VerificationService { /// 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) @@ -93,7 +96,9 @@ final class VerificationService { ts: payload.ts, nonceB64: payload.nonceB64, sigHex: sig.map { String(format: "%02x", $0) }.joined()) - return signed.toURLString() + 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) @@ -104,6 +109,7 @@ 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) return ok ? qr : nil } @@ -128,11 +134,52 @@ 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 sig = noise.signData(msg) else { return nil } + 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 5ac10100..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 @@ -3729,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() { @@ -3766,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 } @@ -3869,9 +3921,72 @@ class ChatViewModel: ObservableObject, BitchatDelegate { objectWillChange.send() } } - case .verifyChallenge, .verifyResponse: - // Not yet handled over BLE; ignore - break + 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) + } + } } } } @@ -3897,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 @@ -4913,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 5e6f9760..cbc01efd 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -58,8 +58,7 @@ struct ContentView: View { @State private var scrollThrottleTimer: Timer? @State private var autocompleteDebounceTimer: Timer? @State private var showLocationChannelsSheet = false - @State private var showMyQRSheet = false - @State private var showScanQRSheet = false + @State private var showVerifySheet = false @State private var expandedMessageIDs: Set = [] // Window sizes for rendering (infinite scroll up) @State private var windowCountPublic: Int = 300 @@ -936,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) @@ -1227,20 +1244,7 @@ struct ContentView: View { } .foregroundColor(headerCountColor) - // QR actions - Button(action: { showMyQRSheet = true }) { - Image(systemName: "qrcode") - .font(.system(size: 12)) - } - .buttonStyle(.plain) - .help("Show my verification QR") - - Button(action: { showScanQRSheet = true }) { - Image(systemName: "qrcode.viewfinder") - .font(.system(size: 12)) - } - .buttonStyle(.plain) - .help("Scan a friend's QR to verify") + // QR moved to the PEOPLE header in the sidebar when on mesh channel } .onTapGesture { withAnimation(.easeInOut(duration: 0.2)) { @@ -1248,13 +1252,9 @@ struct ContentView: View { sidebarDragOffset = 0 } } - .sheet(isPresented: $showMyQRSheet) { - let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub - let qr = VerificationService.shared.buildMyQRString(nickname: viewModel.nickname, npub: npub) - MyQRView(qrString: qr ?? "") - } - .sheet(isPresented: $showScanQRSheet) { - QRScanView() + .sheet(isPresented: $showVerifySheet) { + VerificationSheetView(isPresented: $showVerifySheet) + .environmentObject(viewModel) } } .frame(height: 44) 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 index 94099018..b5fa3a42 100644 --- a/bitchat/Views/VerificationViews.swift +++ b/bitchat/Views/VerificationViews.swift @@ -10,36 +10,32 @@ import AppKit /// 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") + Text("scan to verify me") .font(.system(size: 16, weight: .bold, design: .monospaced)) - QRCodeImage(data: qrString, size: 240) - .accessibilityLabel("verification QR code") + VStack(spacing: 10) { + QRCodeImage(data: qrString, size: 240) + .accessibilityLabel("verification qr code") - HStack(spacing: 8) { - Button("Copy Link") { - #if os(iOS) - UIPasteboard.general.string = qrString - #else - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(qrString, forType: .string) - #endif - } - .buttonStyle(.bordered) - } - - ScrollView { + // Non-scrolling, fully visible URL (wraps across lines) Text(qrString) - .font(.system(size: 10, design: .monospaced)) + .font(.system(size: 11, design: .monospaced)) .textSelection(.enabled) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) .padding(8) - .background(Color.gray.opacity(0.1)) - .cornerRadius(6) + .background(boxColor) + .cornerRadius(8) } - .frame(maxHeight: 120) + .padding() + .frame(maxWidth: .infinity) + .background(boxColor) + .cornerRadius(8) } .padding() } @@ -63,7 +59,7 @@ struct QRCodeImage: View { .stroke(Color.gray.opacity(0.5), lineWidth: 1) .frame(width: size, height: size) .overlay( - Text("QR unavailable") + Text("qr unavailable") .font(.system(size: 12, design: .monospaced)) .foregroundColor(.gray) ) @@ -102,49 +98,42 @@ struct ImageWrapper: View { /// 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 = "" + @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) - Text("Scan a friend's QR") - .font(.system(size: 14, weight: .medium, design: .monospaced)) - CameraScannerView { code in + CameraScannerView(isActive: isActive) { code in if let qr = VerificationService.shared.verifyScannedQR(code) { - result = "Valid QR: \(qr.nickname)" + let ok = viewModel.beginQRVerification(with: qr) + if !ok { /* already pending; continue scanning */ } lastValid = code } else { - result = "Invalid or expired QR" + // ignore invalid reads; continue scanning } } .frame(height: 260) .clipShape(RoundedRectangle(cornerRadius: 8)) - .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.gray.opacity(0.3), lineWidth: 1)) #else - Text("Paste QR content to validate:") + 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 _ = VerificationService.shared.verifyScannedQR(input) { - result = "Valid QR payload" + 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" + result = "invalid or expired qr payload" } } .buttonStyle(.bordered) #endif - Text(result) - .font(.system(size: 12, design: .monospaced)) - .foregroundColor(result.contains("Valid") ? .green : .orange) - if !lastValid.isEmpty { - Text(lastValid) - .font(.system(size: 10, design: .monospaced)) - .textSelection(.enabled) - .foregroundColor(.secondary) - } + // No status text under camera per design Spacer() } .padding() @@ -156,15 +145,19 @@ 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) {} + func updateUIView(_ uiView: PreviewView, context: Context) { + context.coordinator.setActive(isActive) + } func makeCoordinator() -> Coordinator { Coordinator() } @@ -172,6 +165,9 @@ struct CameraScannerView: UIViewRepresentable { 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 @@ -193,8 +189,25 @@ struct CameraScannerView: UIViewRepresentable { sessionOwner.videoPreviewLayer.session = session // Request permission and start AVCaptureDevice.requestAccess(for: .video) { granted in - DispatchQueue.main.async { - if granted { self.session.startRunning() } + 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() } } } } @@ -220,3 +233,115 @@ struct CameraScannerView: UIViewRepresentable { } } #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 } + } +}