# How QR-Based OOB Binding Powers the Verification and Vouching System in Bitchat

> Discover how Bitchat's QR-based OOB binding enables secure verification and vouching. Learn about identity exchange, Noise-session challenges, and network trust propagation.

- Repository: [permissionlesstech/bitchat](https://github.com/permissionlesstech/bitchat)
- Tags: deep-dive
- Published: 2026-08-09

---

**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`](https://github.com/permissionlesstech/bitchat/blob/main/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.

```swift
// 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`](https://github.com/permissionlesstech/bitchat/blob/main/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.

```swift
// 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.

```swift
// 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`.

```swift
// 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.

```swift
// 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`](https://github.com/permissionlesstech/bitchat/blob/main/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 `VerificationService` creates signed `bitchat://verify` URLs 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 via `TransportConfig.verificationQRMaxAgeSeconds`.
- **Session binding**: `ChatVerificationCoordinator` issues a Noise-session challenge to cryptographically bind the QR identity to the mesh transport layer, preventing impersonation.
- **Trust establishment**: Valid verifications invoke `recordVouch` in `SecureIdentityStateManager`, 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 `IdentityCache` maintains 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`](https://github.com/permissionlesstech/bitchat/blob/main/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`](https://github.com/permissionlesstech/bitchat/blob/main/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`](https://github.com/permissionlesstech/bitchat/blob/main/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`](https://github.com/permissionlesstech/bitchat/blob/main/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.