mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-27 06:25:22 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user