BitChat Verification Model: How QR-Based Out-of-Band Binding Works
BitChat's verification model uses signed QR codes to bind human-readable identities to Noise static keys, followed by an in-band challenge/response handshake over the existing encrypted transport.
This article explains the complete QR-based out-of-band binding flow in permissionlesstech/bitchat, from QR generation through mutual verification. All components are implemented in Swift across three core modules that handle payload encoding, UI coordination, and transport-level challenge exchange.
What Is QR-Based Out-of-Band Verification?
Out-of-band (OOB) verification transfers identity information through a separate channel—here, a visual QR code—rather than through the encrypted communication link itself. This prevents man-in-the-middle attacks on the initial trust establishment, assuming the QR is transferred authentically (in person or through a trusted side channel).
BitChat combines this with an in-band challenge/response over the Noise protocol to cryptographically prove ownership of the keys presented in the QR.
QR Payload Structure and Signing
The foundation of BitChat's verification model is VerificationService.VerificationQR, a Codable struct defined in bitchat/Services/VerificationService.swift:
struct VerificationQR: Codable {
let v: Int // schema version
let noiseKeyHex: String // Noise static public key (hex)
let signKeyHex: String // Ed25519 signing key (hex)
let npub: String? // optional Nostr identifier
let nickname: String // human-readable name
let ts: Int64 // Unix epoch seconds
let nonceB64: String // 16-byte random nonce, base64url-encoded
var sigHex: String // Ed25519 signature over canonical bytes
}
Canonical Serialization for Deterministic Signing
The struct implements canonicalBytes() to ensure signatures are deterministic regardless of JSON encoding variations. This method is critical for preventing signature malleability attacks.
Building a Signed QR URL
The buildMyQRString(nickname:npub:) method in VerificationService performs the following steps:
- Retrieves the Noise static public key and Ed25519 signing key from the transport layer (
transport.noiseStaticPublicKeyData()andtransport.noiseSigningPublicKeyData()) - Generates a 16-byte random nonce with URL-safe base64 encoding
- Assembles a
VerificationQRwith emptysigHex, computes canonical bytes, and signs viatransport.noiseSignData(msg) - Populates
sigHexwith the resulting signature and formats as a custom URL:
bitchat://verify?v=1&noise=...&sign=...&nick=Alice&ts=1725629381&nonce=...&sig=...
The result is cached for 60 seconds to avoid unnecessary re-signing when users repeatedly open the verification sheet.
UI Layer Exposure
VerificationModel.myQRString() in bitchat/App/VerificationModel.swift provides the UI access to this functionality:
func myQRString() -> String {
VerificationService.shared.buildMyQRString(
nickname: identity.nickname,
npub: identity.npub
) ?? ""
}
Scanning and Validating QR Codes
When a user scans a QR code, the validation pipeline runs through three stages:
URL parsing → The fromURL initializer extracts query parameters into a VerificationQR instance
Signature verification → verifyScannedQR(_:) in VerificationService validates the Ed25519 signature against the embedded signKeyHex
Freshness check → The timestamp is compared against TransportConfig.verificationQRMaxAgeSeconds (300 seconds / 5 minutes maximum age)
func verifyScannedPayload(_ payload: String) -> VerificationScanOutcome {
guard let qr = VerificationService.shared.verifyScannedQR(payload)
else { return .invalid }
guard chatViewModel.beginQRVerification(with: qr)
else { return .notFound }
return .requested(qr.nickname)
}
The method returns three possible outcomes:
.requested(nickname)— QR valid, verification initiated.invalid— Signature mismatch, expired timestamp, or malformed URL.notFound— Valid QR, but target peer not present in current mesh
Challenge/Response Verification Over Noise
The ChatVerificationCoordinator in bitchat/ViewModels/ChatVerificationCoordinator.swift orchestrates the cryptographic proof exchange.
Initiating Verification
beginQRVerification(with:) locates the peer by matching noiseKeyHex and stores a PendingVerification entry containing the nonce and remote signing key. It then either:
- Sends a verify-challenge immediately if a Noise session exists
- Triggers a Noise handshake first, queuing the challenge for post-handshake transmission
if context.hasEstablishedNoiseSession(with: peerID) {
context.sendVerifyChallenge(to: peerID, noiseKeyHex: qr.noiseKeyHex, nonceA: nonce)
pending.sent = true
} else {
context.triggerHandshake(with: peerID)
}
Challenge and Response Format
Challenges use a simple TLV (Type-Length-Value) encoding:
| Type | Content |
|---|---|
| 0x01 | Peer's Noise static key (hex) |
| 0x02 | 16-byte nonce (nonceA) |
The challenge is wrapped in a NoisePayload of type .verifyChallenge.
The response (type 0x03) contains an Ed25519 signature over the concatenation of "bitchat-verify-resp-v1", the Noise key, and the nonce—binding the response to the specific verification context.
Response Validation and Trust Establishment
Upon receiving a verify-response, handleVerifyResponsePayload:
- Parses the TLV via
parseVerifyResponse - Validates nonce and key match the pending verification entry
- Verifies the signature via
verifyResponseSignatureagainst the remote'ssignKeyHex - Removes the pending entry, marks the fingerprint verified in the identity store, persists state, and posts a local notification
Verified identities trigger a vouch propagation to all connected verified peers, enabling transitive trust through the mesh.
Security Properties and Protections
BitChat's QR-based out-of-band binding incorporates several defensive mechanisms:
Temporal binding — 5-minute QR expiration prevents replay of captured codes
Nonce uniqueness — Per-peer nonce tracking in pendingQRVerifications prevents challenge replay
Spoofing resistance — handleVerifyChallengePayload verifies that the claimed noiseKeyHex matches the receiver's actual key
Cryptographic binding — The final signature proves possession of the private key corresponding to signKeyHex without revealing it
Code Examples
Generating Your Verification QR
@ObservedObject var verificationModel: VerificationModel
Button("Show My QR") {
let qrString = verificationModel.myQRString()
// Render with QRCodeImage(viewModel: qrString)
}
Handling a Scanned QR
func onQRScanned(_ urlString: String) {
switch verificationModel.verifyScannedPayload(urlString) {
case .requested(let nickname):
print("Verification request sent to \(nickname)")
case .invalid:
print("Invalid or expired QR code")
case .notFound:
print("Peer not found in mesh")
}
}
Manual Testing Flow
// Generate test QR
let qr = try VerificationService.shared.buildMyQRString(
nickname: "TestUser",
npub: "npub1..."
)!
// Simulate remote scan and verification
if let parsed = VerificationService.shared.verifyScannedQR(qr) {
let success = chatViewModel.beginQRVerification(with: parsed)
print("Verification initiated: \(success)")
}
Summary
- QR payloads contain Noise keys, Ed25519 signing keys, timestamps, and nonces—signed canonically to prevent malleability
VerificationServicehandles all cryptographic operations:buildMyQRString,verifyScannedQR, and challenge/response TLV construction- Freshness windows limit QRs to 5 minutes; nonces prevent replay attacks during challenge exchange
- Noise transport carries the actual verification proof, binding the out-of-band identity to the encrypted session
- Mutual verification is detected and announced when both peers complete reciprocal verification flows
Frequently Asked Questions
What happens if a QR code is scanned after it expires?
Expired QRs are rejected during verifyScannedQR. The method compares ts against TransportConfig.verificationQRMaxAgeSeconds (5 minutes) and returns nil if the timestamp is too old, causing the UI to display the .invalid outcome.
Can someone impersonate a peer by copying their QR code?
No. The QR only contains public keys. Actual verification requires the private signing key to produce the challenge response. Copying a QR enables initiation of verification, but the impostor cannot complete the cryptographic proof without the corresponding private key.
Why use both QR (out-of-band) and Noise challenge (in-band)?
The QR solves the initial trust problem: how do you know the public key belongs to the claimed identity? By scanning a QR in person or through a trusted channel, you obtain authentic key material. The subsequent Noise challenge proves the remote party controls those keys, preventing attackers from using intercepted QRs to impersonate victims.
How does BitChat handle verification when peers aren't directly connected?
The coordinator uses triggerHandshake(with:) to establish a Noise session if none exists. The pending verification is stored with sent = false, and the challenge is automatically dispatched once the handshake completes and hasEstablishedNoiseSession becomes true.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →