# BitChat Verification Model: How QR-Based Out-of-Band Binding Works

> Discover BitChat's verification model. Learn how QR-based out-of-band binding secures human-readable identities with Noise static keys via encrypted transport.

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

---

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

```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:

1. Retrieves the Noise static public key and Ed25519 signing key from the transport layer (`transport.noiseStaticPublicKeyData()` and `transport.noiseSigningPublicKeyData()`)
2. Generates a 16-byte random nonce with URL-safe base64 encoding
3. Assembles a `VerificationQR` with empty `sigHex`, computes canonical bytes, and signs via `transport.noiseSignData(msg)`
4. Populates `sigHex` with 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`](https://github.com/permissionlesstech/bitchat/blob/main/bitchat/App/VerificationModel.swift) provides the UI access to this functionality:

```swift
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)

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

```swift
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`:

1. Parses the TLV via `parseVerifyResponse`
2. Validates nonce and key match the pending verification entry
3. Verifies the signature via `verifyResponseSignature` against the remote's `signKeyHex`
4. 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

```swift
@ObservedObject var verificationModel: VerificationModel

Button("Show My QR") {
    let qrString = verificationModel.myQRString()
    // Render with QRCodeImage(viewModel: qrString)
}

```

### Handling a Scanned QR

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

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