How QR-Based OOB Binding Powers the Verification and Vouching System in Bitchat
Bitchat employs a QR-based out-of-band (OOB) binding system where users exchange cryptographically signed identity payloads via QR codes, complete a Noise-session challenge to bind the identity to the mesh transport, and establish trust through a capped vouching mechanism that propagates across the network.
The permissionlesstech/bitchat repository implements a decentralized mesh chat protocol where initial trust establishment relies on physical proximity or secure side channels. By using QR-based OOB binding, the system transports cryptographic fingerprints and public keys outside the mesh network, preventing man-in-the-middle attacks during the critical first contact phase.
Generating the Signed QR Payload
The verification flow begins when a user generates a scannable QR code containing their cryptographic identity. In VerificationService.swift, the buildMyQRString(nickname:npub:) method constructs a VerificationQR struct that bundles the user's Noise key, signing key, NIP-05 identifier, and a fresh timestamp.
// In bitchat/Services/VerificationService.swift
func buildMyQRString(nickname: String, npub: String) -> String {
let timestamp = Date().timeIntervalSince1970
let nonce = CryptoUtils.randomNonce()
let payload = "\(noiseKey)\(signingKey)\(npub)\(timestamp)\(nonce)"
let signature = try! signingKey.sign(payload.data(using: .utf8)!)
let qrData = VerificationQR(
noiseKey: noiseKey.publicKey,
signKey: signingKey.publicKey,
nip05: nickname,
timestamp: timestamp,
nonce: nonce,
signature: signature
)
// Encode to bitchat://verify URL scheme
return "bitchat://verify?\(qrData.toBase64())"
}
The resulting string uses the custom bitchat://verify URL scheme, allowing the app to handle these links as universal verification intents. The signature covers all fields including the random nonce, ensuring the payload is non-transferable and freshly minted.
Scanning and Cryptographic Validation
When a peer scans the QR code—either through the camera interface defined in VerificationViews.swift or by pasting the string—the VerificationService.verifyScannedQR(_:) method parses and validates the payload. This function acts as the security gatekeeper by verifying the cryptographic signature and enforcing temporal freshness.
// In bitchat/Services/VerificationService.swift
func verifyScannedQR(_ qrString: String) -> VerificationQR? {
guard let url = URL(string: qrString),
url.scheme == "bitchat",
url.host == "verify",
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let base64Data = components.query,
let jsonData = Data(base64Encoded: base64Data) else {
return nil
}
guard let qr = try? JSONDecoder().decode(VerificationQR.self, from: jsonData),
qr.timestamp > Date().timeIntervalSince1970 - TransportConfig.verificationQRMaxAgeSeconds,
qr.verifySignature(using: qr.signKey) else {
return nil
}
return qr
}
The method rejects any QR where the timestamp exceeds TransportConfig.verificationQRMaxAgeSeconds (defaulting to 300 seconds), effectively mitigating replay attacks using captured codes.
Coordinating the Verification Session
Once validated, the UI layer hands the VerificationQR object to the mesh layer through ChatViewModel.beginQRVerification(with:). This triggers ChatVerificationCoordinator, which maintains a PendingVerification registry keyed by the remote peer's PeerID to prevent duplicate verification attempts for the same identity.
Noise-Session Challenge Protocol
Before recording trust, the coordinator must cryptographically bind the QR identity to the actual mesh transport session. It issues a verification challenge consisting of a random nonce sent over the established Noise encrypted channel. The remote peer proves possession of the private Noise key embedded in the QR payload by returning the signed nonce.
// In bitchat/ViewModels/ChatVerificationCoordinator.swift
func beginQRVerification(with qrData: VerificationQR) {
let peerID = PeerID(from: qrData.noiseKey)
// Prevent duplicate pending verifications
guard pendingVerifications[peerID] == nil else { return }
pendingVerifications[peerID] = PendingVerification(
qrData: qrData,
challengeNonce: CryptoUtils.randomBytes(32),
timestamp: Date()
)
// Send challenge over Noise session
sendChallenge(nonce: pendingVerifications[peerID]!.challengeNonce, to: peerID)
}
Only upon successful verification of the challenge response does the system proceed to establish the vouch, ensuring that the identity presented in the QR code actually controls the active transport session.
Recording and Managing Vouches
Upon successful challenge completion, ChatVerificationCoordinator invokes SecureIdentityStateManager.recordVouch(voucheeFingerprint:voucherFingerprint:timestamp:). This method implements the core trust logic with strict anti-gaming protections.
Anti-Spam Controls and Vouch Caps
The identity manager enforces three critical constraints: it rejects self-vouches where the voucher and vouchee fingerprints match; it validates that the voucher is itself already verified; and it caps the number of stored vouches per identity using maxVouchersPerVouchee.
// In bitchat/Identity/SecureIdentityStateManager.swift
func recordVouch(voucheeFingerprint: String,
voucherFingerprint: String,
timestamp: TimeInterval) -> Bool {
// Prevent self-vouching
guard voucheeFingerprint != voucherFingerprint else { return false }
// Reject stale or future timestamps
let now = Date().timeIntervalSince1970
guard abs(now - timestamp) < TransportConfig.vouchTimestampTolerance else { return false }
// Require voucher to be pre-verified
guard isVerified(voucherFingerprint) else { return false }
// Enforce cap on vouchers per vouchee (FIFO eviction)
var vouches = vouchesByVouchee[voucheeFingerprint] ?? []
if vouches.count >= maxVouchersPerVouchee {
vouches.sort { $0.timestamp < $1.timestamp }
vouches.removeFirst() // Evict oldest
}
let vouch = VouchRecord(
voucher: voucherFingerprint,
timestamp: timestamp,
signature: sign("\(voucheeFingerprint)\(voucherFingerprint)\(timestamp)")
)
vouches.append(vouch)
vouchesByVouchee[voucheeFingerprint] = vouches
// Update derived trust flag
updateVouchedStatus(for: voucheeFingerprint)
return true
}
When a peer accumulates sufficient vouches, the isVouched flag transitions to true, elevating their trust level from casual to trusted within the local worldview.
Propagating Trust Through Gossip
Trust established through direct QR scanning is not limited to the two participating nodes. The GossipSyncManager includes vouch attestations in the periodic sync packets broadcast to neighboring peers. Recipients verify the signatures on these attestations and merge them into their local SecureIdentityStateManager, gradually converging on a shared trust graph without requiring centralized authorities.
// In bitchat/Sync/GossipSyncManager.swift
func prepareGossipPayload() -> GossipPacket {
let recentVouches = identityManager.getRecentVouches(since: lastSyncTimestamp)
return GossipPacket(
vouchAttestations: recentVouches.map { $0.toAttestation() },
timestamp: Date().timeIntervalSince1970
)
}
Persisting Verification State
All vouch records survive application restarts through IdentityCache.swift, which serializes the vouchesByVouchee dictionary and the isVouched flags to encrypted on-device storage. On launch, SecureIdentityStateManager decodes this cache, restoring the complete trust graph without requiring users to re-scan QR codes or re-verify previously established relationships.
Summary
- QR payload generation: The
VerificationServicecreates signedbitchat://verifyURLs containing Noise keys, NIP-05 identifiers, and timestamps to prevent replay attacks. - Cryptographic validation: Scanning triggers
verifyScannedQR, which validates signatures and enforces the 5-minute freshness window viaTransportConfig.verificationQRMaxAgeSeconds. - Session binding:
ChatVerificationCoordinatorissues a Noise-session challenge to cryptographically bind the QR identity to the mesh transport layer, preventing impersonation. - Trust establishment: Valid verifications invoke
recordVouchinSecureIdentityStateManager, which enforces anti-spam caps (maxVouchersPerVouchee) and prevents self-vouching. - Network propagation: Vouches spread through
GossipSyncManager, allowing decentralized trust elevation across the mesh without central coordination. - State persistence: The
IdentityCachemaintains the vouch graph across app restarts, ensuring trust relationships survive device reboots.
Frequently Asked Questions
How does Bitchat prevent replay attacks using QR codes?
The verifyScannedQR method in VerificationService.swift rejects any payload where the timestamp exceeds TransportConfig.verificationQRMaxAgeSeconds (default 300 seconds). This short validity window ensures that intercepted QR codes cannot be reused by attackers after the brief verification window closes, rendering stolen codes useless for impersonation.
What limits prevent users from gaming the vouching system?
SecureIdentityStateManager.swift implements several safeguards: it rejects self-vouches where the voucher and vouchee share the same fingerprint, caps each identity at maxVouchersPerVouchee recent attestations (evicting older entries), and only accepts vouches from peers that are themselves verified. These constraints prevent Sybil attacks and reputation inflation through sock-puppet accounts.
How is the QR identity cryptographically bound to the mesh session?
After QR validation, ChatVerificationCoordinator.swift initiates a Noise-session challenge by sending a random nonce over the encrypted transport. The remote peer must return this nonce signed with the Noise private key matching the public key embedded in the QR payload. This challenge-response proves possession of the private key and binds the out-of-band identity to the in-band session, preventing identity substitution after the QR scan.
Where is verification state stored between app launches?
All vouch records reside in IdentityCache.swift, which serializes the vouchesByVouchee dictionary to encrypted local storage. On startup, SecureIdentityStateManager decodes this cache, restoring the complete trust graph including isVouched flags and voucher timestamps without requiring network resynchronization or repeated QR scans.
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 →