QR verification: live challenge/response over Noise; persistence, offline badges, and UX/perf polish (#510)

* 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 <jackjackbits@users.noreply.github.com>
This commit is contained in:
jack
2025-08-24 23:19:58 +02:00
committed by GitHub
co-authored by jack
parent e6903456ab
commit 5fd9140ffa
13 changed files with 842 additions and 8 deletions
+51 -1
View File
@@ -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)
}
+4
View File
@@ -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)
+9
View File
@@ -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])
}
+185
View File
@@ -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)
}
}