From 652deab8a511f5adf49a0e8a6b63163678731727 Mon Sep 17 00:00:00 2001 From: jack Date: Sun, 24 Aug 2025 18:06:22 +0200 Subject: [PATCH] QR verification scaffold: add Noise verify payload types, VerificationService with QR schema/signing, placeholder MyQR/Scan views, and UI entry points in header --- bitchat.xcodeproj/project.pbxproj | 12 ++ bitchat/Protocols/BitchatProtocol.swift | 5 + bitchat/Services/VerificationService.swift | 151 +++++++++++++++++++++ bitchat/Views/ContentView.swift | 25 ++++ bitchat/Views/VerificationViews.swift | 55 ++++++++ 5 files changed, 248 insertions(+) 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/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/VerificationService.swift b/bitchat/Services/VerificationService.swift new file mode 100644 index 00000000..67ef0345 --- /dev/null +++ b/bitchat/Services/VerificationService.swift @@ -0,0 +1,151 @@ +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() + }() + + /// 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 + let 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? { + 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 } + var signed = payload + signed.sigHex = sig.hexEncodedString() + return signed.toURLString() + } + + /// 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 } + 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 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() + } +} + +private extension Data { + func hexEncodedString() -> String { + self.map { String(format: "%02x", $0) }.joined() + } + init?(hexString: String) { + let len = hexString.count + guard len % 2 == 0 else { return nil } + var data = Data(capacity: len/2) + var i = hexString.startIndex + while i < hexString.endIndex { + let j = hexString.index(i, offsetBy: 2) + if let b = UInt8(hexString[i.. = [] // Window sizes for rendering (infinite scroll up) @State private var windowCountPublic: Int = 300 @@ -1224,6 +1226,21 @@ struct ContentView: View { .accessibilityHidden(true) } .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") } .onTapGesture { withAnimation(.easeInOut(duration: 0.2)) { @@ -1231,6 +1248,14 @@ 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() + } } .frame(height: 44) .padding(.horizontal, 12) diff --git a/bitchat/Views/VerificationViews.swift b/bitchat/Views/VerificationViews.swift new file mode 100644 index 00000000..5042532e --- /dev/null +++ b/bitchat/Views/VerificationViews.swift @@ -0,0 +1,55 @@ +import SwiftUI + +/// Placeholder view to display the user's verification QR payload as text. +struct MyQRView: View { + let qrString: String + var body: some View { + VStack(spacing: 12) { + Text("Scan to verify me") + .font(.system(size: 16, weight: .bold, design: .monospaced)) + // Placeholder box where a future QR image will go + RoundedRectangle(cornerRadius: 8) + .stroke(Color.gray.opacity(0.5), lineWidth: 1) + .frame(width: 220, height: 220) + .overlay(Text("QR PREVIEW\ncoming soon").font(.system(size: 12, design: .monospaced)).multilineTextAlignment(.center).foregroundColor(.gray)) + ScrollView { + Text(qrString) + .font(.system(size: 10, design: .monospaced)) + .textSelection(.enabled) + .padding(8) + .background(Color.gray.opacity(0.1)) + .cornerRadius(6) + }.frame(maxHeight: 120) + } + .padding() + } +} + +/// Placeholder scanner UI; real camera scanning will be added later. +struct QRScanView: View { + @State private var input = "" + @State private var result: String = "" + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Paste QR content to simulate scan:") + .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" + } else { + result = "Invalid or expired QR payload" + } + } + .buttonStyle(.bordered) + Text(result) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(result.contains("Valid") ? .green : .orange) + Spacer() + } + .padding() + } +} +