QR verification scaffold: add Noise verify payload types, VerificationService with QR schema/signing, placeholder MyQR/Scan views, and UI entry points in header

This commit is contained in:
jack
2025-08-24 18:06:22 +02:00
parent e6903456ab
commit 652deab8a5
5 changed files with 248 additions and 0 deletions
+12
View File
@@ -142,6 +142,10 @@
FBC409E105493C491531B59A /* NostrProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */; }; FBC409E105493C491531B59A /* NostrProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */; };
A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */; }; A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */; };
A1B2C3D54E5F60718293A4B6 /* 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 */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -261,6 +265,8 @@
FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBLEService.swift; sourceTree = "<group>"; }; FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBLEService.swift; sourceTree = "<group>"; };
FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "bitchat-macOS.entitlements"; sourceTree = "<group>"; }; FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "bitchat-macOS.entitlements"; sourceTree = "<group>"; };
A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XChaCha20Poly1305Compat.swift; sourceTree = "<group>"; }; A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XChaCha20Poly1305Compat.swift; sourceTree = "<group>"; };
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationService.swift; sourceTree = "<group>"; };
AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationViews.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -431,6 +437,7 @@
A55126E93155456CAA8D6656 /* Views */ = { A55126E93155456CAA8D6656 /* Views */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */,
047502B22E55FED60083520F /* GeohashPeopleList.swift */, 047502B22E55FED60083520F /* GeohashPeopleList.swift */,
047502B32E55FED60083520F /* MeshPeerList.swift */, 047502B32E55FED60083520F /* MeshPeerList.swift */,
0475028E2E5417660083520F /* LocationChannelsSheet.swift */, 0475028E2E5417660083520F /* LocationChannelsSheet.swift */,
@@ -504,6 +511,7 @@
D98A3186D7E4C72E35BDF7FE /* Services */ = { D98A3186D7E4C72E35BDF7FE /* Services */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */,
047502B82E560F690083520F /* RelayController.swift */, 047502B82E560F690083520F /* RelayController.swift */,
0475028B2E54171C0083520F /* LocationChannelManager.swift */, 0475028B2E54171C0083520F /* LocationChannelManager.swift */,
049BD3B02E51F319001A566B /* MessageRouter.swift */, 049BD3B02E51F319001A566B /* MessageRouter.swift */,
@@ -724,6 +732,8 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */,
AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */,
A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */, A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */,
AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */, AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */,
9B51E9B63A3EA59B1A7874BD /* BinaryEncodingUtils.swift in Sources */, 9B51E9B63A3EA59B1A7874BD /* BinaryEncodingUtils.swift in Sources */,
@@ -779,6 +789,8 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */,
AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */,
A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */, A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */,
ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */, ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */,
AFB6AEFCABBE97441CB3102B /* BinaryEncodingUtils.swift in Sources */, AFB6AEFCABBE97441CB3102B /* BinaryEncodingUtils.swift in Sources */,
+5
View File
@@ -155,12 +155,17 @@ enum NoisePayloadType: UInt8 {
case privateMessage = 0x01 // Private chat message case privateMessage = 0x01 // Private chat message
case readReceipt = 0x02 // Message was read case readReceipt = 0x02 // Message was read
case delivered = 0x03 // Message was delivered case delivered = 0x03 // Message was delivered
// Verification (QR-based OOB binding)
case verifyChallenge = 0x10 // Verification challenge
case verifyResponse = 0x11 // Verification response
var description: String { var description: String {
switch self { switch self {
case .privateMessage: return "privateMessage" case .privateMessage: return "privateMessage"
case .readReceipt: return "readReceipt" case .readReceipt: return "readReceipt"
case .delivered: return "delivered" case .delivered: return "delivered"
case .verifyChallenge: return "verifyChallenge"
case .verifyResponse: return "verifyResponse"
} }
} }
} }
+151
View File
@@ -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..<j], radix: 16) { data.append(b) } else { return nil }
i = j
}
self = data
}
}
+25
View File
@@ -58,6 +58,8 @@ struct ContentView: View {
@State private var scrollThrottleTimer: Timer? @State private var scrollThrottleTimer: Timer?
@State private var autocompleteDebounceTimer: Timer? @State private var autocompleteDebounceTimer: Timer?
@State private var showLocationChannelsSheet = false @State private var showLocationChannelsSheet = false
@State private var showMyQRSheet = false
@State private var showScanQRSheet = false
@State private var expandedMessageIDs: Set<String> = [] @State private var expandedMessageIDs: Set<String> = []
// Window sizes for rendering (infinite scroll up) // Window sizes for rendering (infinite scroll up)
@State private var windowCountPublic: Int = 300 @State private var windowCountPublic: Int = 300
@@ -1224,6 +1226,21 @@ struct ContentView: View {
.accessibilityHidden(true) .accessibilityHidden(true)
} }
.foregroundColor(headerCountColor) .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 { .onTapGesture {
withAnimation(.easeInOut(duration: 0.2)) { withAnimation(.easeInOut(duration: 0.2)) {
@@ -1231,6 +1248,14 @@ struct ContentView: View {
sidebarDragOffset = 0 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) .frame(height: 44)
.padding(.horizontal, 12) .padding(.horizontal, 12)
+55
View File
@@ -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()
}
}