| QWBP | January 2026 | |
| Garcia Monterde | Informational | [Page] |
This document specifies the QR-WebRTC Bootstrap Protocol (QWBP), a binary protocol for establishing WebRTC DataChannel connections using QR codes as the signaling channel. QWBP achieves a 97.79% reduction in signaling payload size compared to standard Session Description Protocol (SDP), enabling serverless peer-to-peer connections through a visual, air-gapped channel.¶
QWBP enables two devices with cameras and displays to establish an encrypted WebRTC DataChannel connection without any server infrastructure. The protocol uses QR codes as a bidirectional signaling channel, requiring only physical proximity between devices.¶
This specification defines:¶
This specification does NOT define:¶
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.¶
All multi-byte integers use network byte order (big-endian) unless otherwise specified.¶
All examples use hexadecimal notation with 0x prefix for bytes and XX:XX colon notation for fingerprints.¶
QWBP Packet: A binary-encoded payload containing the minimal information required to establish a WebRTC connection.¶
Fingerprint: A SHA-256 hash of a device's DTLS certificate, represented as 32 raw bytes.¶
ICE Candidate: A network address (IP and port) where a device can potentially receive connections.¶
Host Candidate: An ICE candidate representing a local network interface address.¶
Server-Reflexive (srflx) Candidate: An ICE candidate representing the public IP address as discovered by a STUN server.¶
mDNS Candidate: An ICE candidate using a Multicast DNS hostname (UUID format) for IP address privacy.¶
Offerer: The peer that creates the WebRTC offer SDP.¶
Answerer: The peer that creates the WebRTC answer SDP in response to an offer.¶
QR Tango: The bidirectional QR scanning dance where both peers scan each other's QR codes.¶
QWBP implements a two-stage connection model:¶
Stage 1: QR Bootstrap (QWBP) ├── Payload size: 55-100 bytes ├── QR Version: 4-5 (33-37 modules) ├── Scan time: <500ms typical └── Result: Encrypted DataChannel Stage 2: Application Protocol ├── Payload size: Unlimited ├── Channel: DataChannel from Stage 1 └── Use cases: Video SDP, file transfer, any data¶
┌─────────────┐ ┌─────────────┐
│ Peer A │ │ Peer B │
└─────────────┘ └─────────────┘
│ │
│ 1. Generate DTLS certificate │
│ 2. Gather ICE candidates │
│ 3. Encode QWBP packet │
│ 4. Display QR code │
│ │
│ ┌───────────────┐ │
│ │ QR Code A │ │
│ │ (55-100 B) │──────────────▶│
│ └───────────────┘ │
│ │
│ │ 5. Scan QR from A
│ │ 6. Generate DTLS certificate
│ │ 7. Gather ICE candidates
│ │ 8. Encode QWBP packet
│ │ 9. Display QR code
│ │
│ ┌───────────────┐ │
│◀──────────────│ QR Code B │ │
│ │ (55-100 B) │ │
│ └───────────────┘ │
│ │
│ 10. Scan QR from B │
│ │
│ 11. Compare fingerprints │ 11. Compare fingerprints
│ A > B → Offerer │ B < A → Answerer
│ │
│ 12. Reuse pending Local Offer │ 12. Rollback pending Local Offer
│ │ 13. Synthesize Remote Offer
│ 13. Synthesize Remote Answer │ 14. Generate Local Answer
│ │
│ 14. setRemoteDescription(answer) │ 15. setRemoteDescription(offer)
│ │ 16. setLocalDescription(answer)
│ │
│◀────────────────────────────────────────│
│ ICE + DTLS Handshake │
│────────────────────────────────────────▶│
│ │
│◀════════════════════════════════════════│
│ DataChannel Established │
│════════════════════════════════════════▶│
¶
Unlike traditional WebRTC signaling where peers exchange different messages (offer vs answer), QWBP uses symmetric "identity cards". Both QR codes contain the same type of information:¶
Roles (offerer/answerer) are determined after both scans complete, based on fingerprint comparison. This eliminates race conditions and allows either peer to scan first.¶
0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Magic | Version | | | (0x51) | (3b + 5b) | | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | | + + | | + Fingerprint + | (32 bytes) | + + | | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | | | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | | + Candidate 1 + | (7 or 19 bytes) | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | | | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Candidate 2 | + (7 or 19 bytes) + | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | | ... | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+¶
+-+-+-+-+-+-+-+-+
|0 1 0 1 0 0 0 1| = 0x51 ('Q' ASCII)
+-+-+-+-+-+-+-+-+
¶
The magic byte MUST be 0x51 (ASCII 'Q' for QWBP).¶
Decoders MUST reject packets not starting with 0x51. This provides fast-fail when scanning non-QWBP QR codes (restaurant menus, URLs, etc.).¶
+-+-+-+-+-+-+-+-+ |Ver:3b |Rsv:5b | +-+-+-+-+-+-+-+-+¶
| Bits | Field | Description |
|---|---|---|
| 0-2 | Version | Protocol version (0-7). Currently only 0 is defined. |
| 3-7 | Reserved | MUST be set to 0. Decoders MUST ignore these bits. |
Version Handling:¶
The fingerprint is the raw 32-byte SHA-256 hash of the device's DTLS certificate.¶
Generation:¶
// Browser: Extract from local SDP
const sdp = await pc.createOffer();
const match = sdp.sdp.match(/a=fingerprint:sha-256 ([A-F0-9:]+)/i);
const hexString = match[1].replace(/:/g, "");
const fingerprint = new Uint8Array(
hexString.match(/.{2}/g).map((b) => parseInt(b, 16))
);
¶
Encoding:¶
Store the 32 bytes directly without any encoding. Do NOT use hex string or colon-separated format.¶
Each ICE candidate is encoded as a variable-length structure:¶
IPv4 Candidate (7 bytes): +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Flags | IPv4 Address | | (1 byte) | (4 bytes) | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | IPv4 (cont)| Port | | (1 byte) | (2 bytes) | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ IPv6/mDNS Candidate (19 bytes): +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Flags | | | (1 byte) | | +-+-+-+-+-+-+-+-+ IPv6 Address or + | mDNS UUID | + (16 bytes) + | | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Port | | (2 bytes) | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+¶
+-+-+-+-+-+-+-+-+ |AF |P|T|TCP|Rsv| +-+-+-+-+-+-+-+-+ 0-1 2 3 4-5 6-7¶
| Bits | Field | Values | Description |
|---|---|---|---|
| 0-1 | Address Family (AF) |
00=IPv4, 01=IPv6, 10=mDNS |
Address type |
| 2 | Protocol (P) |
0=UDP, 1=TCP |
Transport protocol |
| 3 | Candidate Type (T) |
0=host, 1=srflx |
ICE candidate type |
| 4-5 | TCP Type |
00=passive, 01=active, 10=so |
Only valid if P=1 |
| 6-7 | Reserved |
00
|
MUST be 0 |
Address Family Details:¶
00 (IPv4): Next 4 bytes are IPv4 address in network byte order¶
01 (IPv6): Next 16 bytes are IPv6 address in network byte order¶
10 (mDNS): Next 16 bytes are UUID portion of mDNS hostname (RFC 4122 format)¶
mDNS UUID Encoding:¶
Modern browsers (Chrome, Safari) hide local IPs behind mDNS hostnames following the draft-ietf-mmusic-mdns-ice-candidates specification. Per Section 3.1.1, the hostname format is {uuid}.local where the UUID follows RFC 4122. The UUID is 128 bits, matching IPv6 size.¶
Example:¶
a1b2c3d4-e5f6-7890-abcd-ef1234567890.local¶
0xa1b2c3d4e5f67890abcdef1234567890 (16 bytes)¶
TCP Type Values:¶
Per RFC 6544, TCP candidates include a tcptype attribute:¶
For UDP candidates, bits 4-5 SHOULD be 00 and MUST be ignored by decoders.¶
Candidates MUST be encoded in descending priority order:¶
Within each type, order by:¶
Maximum Candidates:¶
Implementations SHOULD include at most 4 candidates (3 host + 1 srflx) to stay within QR size limits. Decoders MUST parse all candidates until end-of-packet.¶
| Configuration | Size Calculation | Total Bytes |
|---|---|---|
| Minimum (1 IPv4) | 2 + 32 + 7 | 41 |
| Typical (3 IPv4 + 1 srflx IPv4) | 2 + 32 + 28 | 62 |
| Maximum (4 IPv6) | 2 + 32 + 76 | 110 |
| Mixed (3 IPv6 + 1 IPv4) | 2 + 32 + 57 + 7 | 98 |
QWBP derives ICE credentials (ufrag and password) from the DTLS fingerprint using HKDF-SHA256 (RFC 5869). This eliminates the need to transmit credentials in the QR code.¶
Each peer derives its OWN credentials from its OWN fingerprint. After scanning, each peer can derive the OTHER peer's expected credentials from the scanned fingerprint.¶
Hash Algorithm: SHA-256 Input Key Material (IKM): 32-byte DTLS fingerprint Salt: Empty (zero-length byte array) Info (ufrag): UTF-8 bytes of "QWBP-ICE-UFRAG-v1" Info (pwd): UTF-8 bytes of "QWBP-ICE-PWD-v1" Output Length (ufrag): 4 bytes Output Length (pwd): 18 bytes¶
Step 1: Extract¶
PRK = HKDF-Extract(salt="", IKM=fingerprint)
= HMAC-SHA256(key="", message=fingerprint)
¶
Note: Empty salt is acceptable because the fingerprint (IKM) is already high-entropy and ephemeral.¶
Step 2: Expand for ufrag¶
ufrag_bytes = HKDF-Expand(PRK, info="QWBP-ICE-UFRAG-v1", L=4) ufrag = base64url_encode(ufrag_bytes) // 6 characters¶
Step 3: Expand for password¶
pwd_bytes = HKDF-Expand(PRK, info="QWBP-ICE-PWD-v1", L=18) pwd = base64url_encode(pwd_bytes) // 24 characters¶
Use base64url encoding (RFC 4648 Section 5) WITHOUT padding:¶
This produces URL-safe strings that satisfy RFC 8839 character requirements for ICE credentials.¶
QWBP derivation produces:¶
Both exceed minimums and use valid characters (base64url is subset of allowed charset, substituting -_ for +/).¶
Input:¶
Fingerprint (hex): E7:3B:38:46:1A:5D:88:B0:C4:2E:9F:7A:1D:6C:3E:8B:
5F:4A:9D:2C:7E:1B:6F:3A:8D:5C:2E:9B:4F:7A:1C:3D
Fingerprint (bytes): 0xe73b38461a5d88b0c42e9f7a1d6c3e8b
5f4a9d2c7e1b6f3a8d5c2e9b4f7a1c3d
¶
Derivation:¶
PRK = HMAC-SHA256("", fingerprint)
= 0x2f8a... (32 bytes)
ufrag_bytes = HKDF-Expand(PRK, "QWBP-ICE-UFRAG-v1", 4)
= 0x7a3c5e9f
ufrag = base64url(0x7a3c5e9f)
= "ejxenw"
pwd_bytes = HKDF-Expand(PRK, "QWBP-ICE-PWD-v1", 18)
= 0x4d2e8a7c... (18 bytes)
pwd = base64url(pwd_bytes)
= "TS6KfB2mN9pQ3rS7wX"
¶
If both peers press "Connect" simultaneously, they might both generate WebRTC offers. The WebRTC state machine cannot process an offer while in "have-local-offer" state, causing connection failure.¶
Traditional solutions require UI coordination ("Press Send on device A, then Receive on device B"). QWBP eliminates this through deterministic role assignment.¶
After both QR codes are scanned, each peer has both fingerprints. Roles are assigned by lexicographic byte comparison:¶
if (localFingerprint > remoteFingerprint) {
role = OFFERER;
} else if (localFingerprint < remoteFingerprint) {
role = ANSWERER;
} else {
// Fingerprints equal - scanning own QR code
throw Error("Cannot connect to self");
}
¶
Comparison Algorithm:¶
function compareFingerprints(a: Uint8Array, b: Uint8Array): number {
for (let i = 0; i < 32; i++) {
if (a[i] > b[i]) return 1;
if (a[i] < b[i]) return -1;
}
return 0; // Equal (error case)
}
¶
This approach guarantees:¶
After scanning and role assignment, each peer uses the remote data to drive the WebRTC state machine to completion.¶
The Offerer (who already has a valid Local Offer from the gathering phase) reconstructs a Remote Answer SDP using the scanned fingerprint and candidates. It sets this as the remote description to establish the connection.¶
The Answerer performs a signaling rollback to clear its pending Local Offer. It then reconstructs a Remote Offer SDP from the scanned data, sets it as the remote description, and generates a valid Local Answer via the WebRTC API.¶
v=0
o=- {session-id} 2 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE 0
a=ice-ufrag:{ufrag}
a=ice-pwd:{pwd}
m=application 9 UDP/DTLS/SCTP webrtc-datachannel
c=IN IP4 0.0.0.0
a=ice-options:trickle
a=fingerprint:sha-256 {fingerprint-hex}
a=setup:{setup-value}
a=mid:0
a=sctp-port:5000
{candidate-lines}
¶
| Field | Source | Format |
|---|---|---|
{session-id}
|
Derived from fingerprint | First 8 bytes of SHA256(fp) as uint64 (big-endian) |
{ufrag}
|
HKDF derivation | 6-character base64url string |
{pwd}
|
HKDF derivation | 24-character base64url string |
{fingerprint-hex}
|
QR payload | Colon-separated hex: AB:CD:EF:...
|
{setup-value}
|
Role | Offer: actpass, Answer: active
|
{candidate-lines}
|
QR payload | Multiple a=candidate: lines |
Generate deterministically from fingerprint to ensure both peers derive the same value:¶
async function generateSessionId(fingerprint: Uint8Array): Promise<string> {
// Hash the fingerprint first
const hash = await crypto.subtle.digest("SHA-256", fingerprint);
const hashBytes = new Uint8Array(hash);
// Use first 8 bytes as big-endian uint64
let id = BigInt(0);
for (let i = 0; i < 8; i++) {
id = (id << 8n) | BigInt(hashBytes[i]);
}
return id.toString();
}
¶
Convert 32 raw bytes to colon-separated uppercase hex:¶
function formatFingerprint(bytes: Uint8Array): string {
return Array.from(bytes)
.map((b) => b.toString(16).toUpperCase().padStart(2, "0"))
.join(":");
}
¶
Output: E7:3B:38:46:1A:5D:88:B0:C4:2E:9F:7A:1D:6C:3E:8B:5F:4A:9D:2C:7E:1B:6F:3A:8D:5C:2E:9B:4F:7A:1C:3D¶
Generate deterministic foundation from candidate data:¶
function generateFoundation(
type: string,
protocol: string,
ip: string,
port: number
): string {
const data = `${type}${protocol}${ip}${port}`;
const hash = sha256(new TextEncoder().encode(data));
return Array.from(hash.slice(0, 4))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
¶
Use RFC 8445 formula with fixed constants:¶
| Candidate Type | Type Preference | Local Preference | Priority |
|---|---|---|---|
| Host UDP | 126 | 65535 | 2122260223 |
| Host TCP | 126 | 49151 | 2105524223 |
| srflx | 100 | 65535 | 1686052607 |
a=candidate:{foundation} 1 {proto} {priority} {ip} {port} typ host
¶
For TCP candidates, append tcptype:¶
a=candidate:{foundation} 1 tcp {priority} {ip} {port} typ host tcptype {tcptype}
¶
a=candidate:{foundation} 1 {proto} {priority} {ip} {port} typ srflx raddr 0.0.0.0 rport 9
¶
The raddr 0.0.0.0 rport 9 placeholder follows the privacy-preserving pattern from mDNS ICE candidates. Implementations MUST NOT assume the related address is meaningful.¶
For mDNS candidates (address family = 10), reconstruct the hostname:¶
function formatMdnsHostname(uuid: Uint8Array): string {
const hex = Array.from(uuid)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
// Format as UUID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
return (
`${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-` +
`${hex.slice(16, 20)}-${hex.slice(20, 32)}.local`
);
}
¶
Candidate line:¶
a=candidate:{foundation} 1 udp {priority} {mdns-hostname} {port} typ host
¶
QWBP Payload (hex):¶
51 00 # Magic + Version e7 3b 38 46 1a 5d 88 b0 c4 2e 9f 7a 1d 6c # Fingerprint 3e 8b 5f 4a 9d 2c 7e 1b 6f 3a 8d 5c 2e 9b # (32 bytes) 4f 7a 1c 3d 00 c0 a8 01 05 d4 31 # Candidate 1: IPv4 host 192.168.1.5:54321 08 c0 a8 01 06 d4 32 # Candidate 2: IPv4 srflx 192.168.1.6:54322¶
Derived Credentials:¶
ufrag: "ejxenw" pwd: "TS6KfB2mN9pQ3rS7wX"¶
Reconstructed Offer SDP:¶
v=0 o=- 16663612290012583088 2 IN IP4 127.0.0.1 s=- t=0 0 a=group:BUNDLE 0 a=ice-ufrag:ejxenw a=ice-pwd:TS6KfB2mN9pQ3rS7wX m=application 9 UDP/DTLS/SCTP webrtc-datachannel c=IN IP4 0.0.0.0 a=ice-options:trickle a=fingerprint:sha-256 E7:3B:38:46:1A:5D:88:B0:C4:2E:9F:7A:1D:6C:3E:8B:5F:4A:9D:2C:7E:1B:6F:3A:8D:5C:2E:9B:4F:7A:1C:3D a=setup:actpass a=mid:0 a=sctp-port:5000 a=candidate:a1b2c3d4 1 udp 2122260223 192.168.1.5 54321 typ host a=candidate:e5f6a7b8 1 udp 1686052607 192.168.1.6 54322 typ srflx raddr 0.0.0.0 rport 9¶
QWBP payloads MUST be encoded using Byte mode (ISO 8859-1). Do NOT:¶
Most QR libraries accept Uint8Array directly for Byte mode encoding.¶
Use Error Correction Level L (7% recovery capacity).¶
Rationale:¶
| Payload Size | QR Version | Modules | Capacity (L) |
|---|---|---|---|
| 41-53 bytes | 3 | 29×29 | 53 bytes |
| 54-78 bytes | 4 | 33×33 | 78 bytes |
| 79-106 bytes | 5 | 37×37 | 106 bytes |
| 107-134 bytes | 6 | 41×41 | 134 bytes |
Typical QWBP payloads (55-100 bytes) fit in Version 4-5.¶
For reliable scanning at arm's length (50cm):¶
Smaller codes require closer camera positioning and are more sensitive to motion blur.¶
Maintain a minimum 4-module white border around the QR code. Most libraries handle this automatically, but verify with custom rendering.¶
┌─────────────────┐
│ IDLE │
└────────┬────────┘
│ initialize()
▼
┌─────────────────┐
│ GATHERING │ Collecting ICE candidates
└────────┬────────┘
│ iceGatheringState='complete'
▼
┌─────────────────┐
│ DISPLAYING │ QR code visible
└────────┬────────┘
│ processScannedPayload()
▼
┌─────────────────┐
│ SCANNED_ONE │ Have remote data, awaiting local scan
└────────┬────────┘
│ other peer scans our QR
▼
┌─────────────────┐
│ CONNECTING │ Role assigned, SDP exchanged
└────────┬────────┘
│ iceConnectionState='connected'
▼
┌─────────────────┐
│ CONNECTED │ DataChannel ready
└─────────────────┘
¶
Implementations MUST reuse the same RTCPeerConnection object used for ICE gathering throughout the connection process. If role assignment determines the local peer is the Answerer, the implementation MUST transition the connection state back to stable (e.g., using setLocalDescription({type: 'rollback'})) before applying the remote offer.¶
Warning: Implementers MUST NOT destroy the existing PeerConnection to create a new one for the Answerer role. Doing so will close the network ports advertised in the generated QR code, causing connection failure.¶
Implementations MUST wait for complete ICE gathering before displaying the QR code:¶
pc.oniceGatheringStateChange = () => {
if (pc.iceGatheringState === "complete") {
// Safe to generate QR now
}
};
¶
This adds 1-2 seconds latency but ensures the QR contains all candidates needed for connection.¶
Implementations MUST enforce a session timeout (default: 30 seconds) starting from when ICE gathering completes and the QR is first displayed.¶
After timeout:¶
This prevents stale QR codes from being used in replay scenarios.¶
When both peers have each other's connection information from the QR codes, both can initiate ICE connectivity checks simultaneously. This enables "hole punching" through single-sided NAT without TURN.¶
QWBP assumes direct peer-to-peer connectivity or STUN-assisted hole punching. For scenarios requiring TURN relay servers (symmetric NAT on both sides, enterprise firewalls), additional configuration is needed.¶
Limitation: TURN credentials (server URL, username, password) cannot be transmitted in the QR code—they would exceed size constraints and expose long-lived secrets.¶
Solution: Applications requiring TURN support MUST pre-configure the same TURN server on both clients through application configuration:¶
const connection = new QWBPConnection({
iceServers: [
{ urls: "stun:stun.l.google.com:19302" },
{
urls: "turn:your-turn-server.example.com:3478",
username: "app-configured-user",
credential: "app-configured-credential",
},
],
});
¶
Recommendations:¶
Connection fallback: If host and srflx candidates fail, ICE will automatically try relay candidates if TURN is configured. The QWBP protocol is unaware of this fallback—it occurs at the WebRTC layer.¶
QWBP's security relies on the optical channel—the screen displaying the QR code.¶
Trust Assumptions:¶
Out of Scope:¶
| Threat | Mitigation |
|---|---|
| Remote attackers | Cannot participate without visual access |
| Source code inspection | Session keys derived from ephemeral certificates |
| Replay attacks | Ephemeral DTLS certificates, session timeout |
| MITM attacks | DTLS fingerprint verification in handshake |
| Credential theft | Credentials derived, not transmitted |
An attacker who photographs both QR codes gains:¶
They could attempt to race the legitimate peers to establish connection. However:¶
Mitigation: Use Short Authentication String (see §10.5)¶
An attacker displays their own QR code, hoping victim scans it instead of legitimate peer.¶
Mitigation: Users should verify they're scanning the expected device's screen. Visual confirmation of the other device displaying a QR is part of the protocol's trust model.¶
QWBP provides forward secrecy through ephemeral DTLS certificates:¶
For high-security applications, implement SAS verification after connection:¶
async function generateSAS(
localFP: Uint8Array,
remoteFP: Uint8Array
): Promise<string> {
// Concatenate fingerprints in consistent order (sorted)
// This ensures both peers compute the same SAS regardless of role
const comparison = compareFingerprints(localFP, remoteFP);
const combined = new Uint8Array(64);
if (comparison >= 0) {
combined.set(localFP, 0);
combined.set(remoteFP, 32);
} else {
combined.set(remoteFP, 0);
combined.set(localFP, 32);
}
// Hash to get SAS material
const hash = await crypto.subtle.digest("SHA-256", combined);
const hashBytes = new Uint8Array(hash);
// Use first 2 bytes as a 4-digit number (0000-9999)
const value = (hashBytes[0] << 8) | hashBytes[1];
return (value % 10000).toString().padStart(4, "0");
}
¶
Users verbally confirm the SAS matches on both devices (e.g., "Does your screen show 4827?"). This catches active MITM attacks where an attacker substitutes their own QR code.¶
Usage:¶
const sas = await connection.getSAS();
console.log(`Verification code: ${sas}`); // "4827"
¶
Q: "Are hardcoded-looking ICE credentials secure?"¶
A: Yes. ICE credentials (ufrag/pwd) authenticate ICE connectivity checks but do NOT encrypt data. The actual encryption happens at the DTLS layer, authenticated by the fingerprint. An attacker with ICE credentials but wrong DTLS certificate cannot establish a connection—the DTLS handshake fails.¶
QWBP's HKDF derivation ensures credentials are:¶
This document has no IANA actions.¶
The magic byte 0x51 is chosen to be:¶
Input:¶
Fingerprint: e73b38461a5d88b0c42e9f7a1d6c3e8b5f4a9d2c7e1b6f3a8d5c2e9b4f7a1c3d Candidate 1: IPv4 host UDP 192.168.1.5:54321¶
Encoded Packet (hex):¶
51 00 e7 3b 38 46 1a 5d 88 b0 c4 2e 9f 7a 1d 6c 3e 8b 5f 4a 9d 2c 7e 1b 6f 3a 8d 5c 2e 9b 4f 7a 1c 3d 00 c0 a8 01 05 d4 31¶
Breakdown:¶
51 Magic byte 'Q' 00 Version 0, reserved bits 0 e7...3d 32-byte fingerprint 00 Flags: IPv4 (00), UDP (0), host (0) c0 a8 01 05 IPv4: 192.168.1.5 d4 31 Port: 54321 (0xD431)¶
Total size: 41 bytes¶
Derived credentials:¶
ufrag: "ejxenw" pwd: "TS6KfB2mN9pQ3rS7wX"¶
Input:¶
Fingerprint: e73b38461a5d88b0c42e9f7a1d6c3e8b5f4a9d2c7e1b6f3a8d5c2e9b4f7a1c3d Candidate 1: IPv4 host UDP 192.168.1.5:54321 Candidate 2: IPv4 host UDP 192.168.1.6:54322 Candidate 3: IPv4 host UDP 10.0.0.100:54323 Candidate 4: IPv4 srflx UDP 203.0.113.50:54324¶
Encoded Packet (hex):¶
51 00 e7 3b 38 46 1a 5d 88 b0 c4 2e 9f 7a 1d 6c 3e 8b 5f 4a 9d 2c 7e 1b 6f 3a 8d 5c 2e 9b 4f 7a 1c 3d 00 c0 a8 01 05 d4 31 00 c0 a8 01 06 d4 32 00 0a 00 00 64 d4 33 08 cb 00 71 32 d4 34¶
Breakdown:¶
Candidate 1: 00 c0a80105 d431 (192.168.1.5:54321, host)
Candidate 2: 00 c0a80106 d432 (192.168.1.6:54322, host)
Candidate 3: 00 0a000064 d433 (10.0.0.100:54323, host)
Candidate 4: 08 cb007132 d434 (203.0.113.50:54324, srflx)
^
Flags: 08 = 0000 1000 = srflx
¶
Total size: 62 bytes (fits in QR Version 4)¶
Input:¶
Candidate: IPv6 host UDP [2001:db8:85a3::8a2e:370:7334]:54321¶
Encoded (19 bytes):¶
01 20 01 0d b8 85 a3 00 00 00 00 8a 2e 03 70 73 34 d4 31 ^ ^----------------------------------------------------------^ ^---^ | IPv6 address (16 bytes) Port Flags: 01 = IPv6, UDP, host¶
Input:¶
Candidate: mDNS host UDP a1b2c3d4-e5f6-7890-abcd-ef1234567890.local:54321¶
Encoded (19 bytes):¶
02 a1 b2 c3 d4 e5 f6 78 90 ab cd ef 12 34 56 78 90 d4 31 ^ ^------------------------------------------------------^ ^---^ | UUID bytes (16 bytes) Port Flags: 02 = mDNS (10), UDP, host¶
Input:¶
Candidate: IPv4 host TCP-passive 192.168.1.5:9000¶
Encoded (7 bytes):¶
04 c0 a8 01 05 23 28 ^ Flags: 04 = 0000 0100 = IPv4, TCP, host, passive¶
Example 1: Peer A is Offerer¶
Peer A fingerprint: e73b38461a5d88b0... Peer B fingerprint: 8a2c5f9100112233... First byte comparison: 0xe7 > 0x8a Result: Peer A = Offerer, Peer B = Answerer¶
Example 2: Peer B is Offerer¶
Peer A fingerprint: 1a2b3c4d5e6f7890... Peer B fingerprint: 9f8e7d6c5b4a3928... First byte comparison: 0x1a < 0x9f Result: Peer A = Answerer, Peer B = Offerer¶
Example 3: Deep comparison needed¶
Peer A fingerprint: aabbccdd00112233... Peer B fingerprint: aabbccdd00112234... Bytes 0-6: Equal Byte 7: 0x33 < 0x34 Result: Peer A = Answerer, Peer B = Offerer¶
async function hkdf(
ikm: Uint8Array,
salt: Uint8Array,
info: Uint8Array,
length: number
): Promise<Uint8Array> {
// Import IKM as raw key material
const ikmKey = await crypto.subtle.importKey(
"raw",
ikm,
{ name: "HKDF" },
false,
["deriveBits"]
);
// Derive bits using HKDF
const derived = await crypto.subtle.deriveBits(
{
name: "HKDF",
hash: "SHA-256",
salt: salt,
info: info,
},
ikmKey,
length * 8 // bits
);
return new Uint8Array(derived);
}
async function deriveCredentials(fingerprint: Uint8Array): Promise<{
ufrag: string;
pwd: string;
}> {
const salt = new Uint8Array(0);
const ufragInfo = new TextEncoder().encode("QWBP-ICE-UFRAG-v1");
const pwdInfo = new TextEncoder().encode("QWBP-ICE-PWD-v1");
const ufragBytes = await hkdf(fingerprint, salt, ufragInfo, 4);
const pwdBytes = await hkdf(fingerprint, salt, pwdInfo, 18);
return {
ufrag: base64urlEncode(ufragBytes),
pwd: base64urlEncode(pwdBytes),
};
}
function base64urlEncode(bytes: Uint8Array): string {
const base64 = btoa(String.fromCharCode(...bytes));
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
¶
const MAGIC = 0x51;
const VERSION = 0x00;
interface QWBPCandidate {
ip: string;
port: number;
type: "host" | "srflx";
protocol: "udp" | "tcp";
tcpType?: "passive" | "active" | "so";
}
function encodePacket(
fingerprint: Uint8Array,
candidates: QWBPCandidate[]
): Uint8Array {
const parts: Uint8Array[] = [];
// Header
parts.push(new Uint8Array([MAGIC, VERSION]));
// Fingerprint
parts.push(fingerprint);
// Candidates
for (const candidate of candidates) {
parts.push(encodeCandidate(candidate));
}
// Concatenate all parts
const totalLength = parts.reduce((sum, p) => sum + p.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const part of parts) {
result.set(part, offset);
offset += part.length;
}
return result;
}
function encodeCandidate(candidate: QWBPCandidate): Uint8Array {
const isIPv6 = candidate.ip.includes(":");
const isMdns = candidate.ip.endsWith(".local");
let addressFamily: number;
let addressBytes: Uint8Array;
if (isMdns) {
addressFamily = 0b10;
addressBytes = parseMdnsUUID(candidate.ip);
} else if (isIPv6) {
addressFamily = 0b01;
addressBytes = parseIPv6(candidate.ip);
} else {
addressFamily = 0b00;
addressBytes = parseIPv4(candidate.ip);
}
const protocol = candidate.protocol === "tcp" ? 1 : 0;
const type = candidate.type === "srflx" ? 1 : 0;
let tcpType = 0;
if (candidate.protocol === "tcp") {
tcpType =
candidate.tcpType === "active" ? 1 : candidate.tcpType === "so" ? 2 : 0;
}
const flags = addressFamily | (protocol << 2) | (type << 3) | (tcpType << 4);
const result = new Uint8Array(1 + addressBytes.length + 2);
result[0] = flags;
result.set(addressBytes, 1);
result[result.length - 2] = (candidate.port >> 8) & 0xff;
result[result.length - 1] = candidate.port & 0xff;
return result;
}
function parseIPv4(ip: string): Uint8Array {
const parts = ip.split(".").map((p) => parseInt(p, 10));
return new Uint8Array(parts);
}
function parseIPv6(ip: string): Uint8Array {
// Handle :: expansion
const parts = ip.split(":");
const result = new Uint8Array(16);
// ... (full IPv6 parsing implementation)
return result;
}
function parseMdnsUUID(hostname: string): Uint8Array {
const uuid = hostname.replace(".local", "").replace(/-/g, "");
const bytes = new Uint8Array(16);
for (let i = 0; i < 16; i++) {
bytes[i] = parseInt(uuid.substr(i * 2, 2), 16);
}
return bytes;
}
¶
interface DecodedPacket {
version: number;
fingerprint: Uint8Array;
candidates: QWBPCandidate[];
}
function decodePacket(data: Uint8Array): DecodedPacket {
if (data.length < 34) {
throw new Error("Packet too short");
}
if (data[0] !== MAGIC) {
throw new Error("Invalid magic byte");
}
const version = data[1] & 0b111;
if (version !== 0) {
throw new Error(`Unknown version: ${version}`);
}
const fingerprint = data.slice(2, 34);
const candidates: QWBPCandidate[] = [];
let offset = 34;
while (offset < data.length) {
const { candidate, bytesRead } = decodeCandidate(data, offset);
candidates.push(candidate);
offset += bytesRead;
}
return { version, fingerprint, candidates };
}
function decodeCandidate(
data: Uint8Array,
offset: number
): { candidate: QWBPCandidate; bytesRead: number } {
const flags = data[offset];
const addressFamily = flags & 0b11;
const protocol = (flags >> 2) & 0b1;
const type = (flags >> 3) & 0b1;
const tcpType = (flags >> 4) & 0b11;
let addressLength: number;
let ip: string;
if (addressFamily === 0b00) {
// IPv4
addressLength = 4;
ip = Array.from(data.slice(offset + 1, offset + 5)).join(".");
} else if (addressFamily === 0b01) {
// IPv6
addressLength = 16;
ip = formatIPv6(data.slice(offset + 1, offset + 17));
} else if (addressFamily === 0b10) {
// mDNS
addressLength = 16;
ip = formatMdns(data.slice(offset + 1, offset + 17));
} else {
throw new Error(`Unknown address family: ${addressFamily}`);
}
const portOffset = offset + 1 + addressLength;
const port = (data[portOffset] << 8) | data[portOffset + 1];
const candidate: QWBPCandidate = {
ip,
port,
type: type === 1 ? "srflx" : "host",
protocol: protocol === 1 ? "tcp" : "udp",
};
if (protocol === 1) {
candidate.tcpType =
tcpType === 1 ? "active" : tcpType === 2 ? "so" : "passive";
}
return {
candidate,
bytesRead: 1 + addressLength + 2,
};
}
function formatIPv6(bytes: Uint8Array): string {
const parts: string[] = [];
for (let i = 0; i < 16; i += 2) {
parts.push(((bytes[i] << 8) | bytes[i + 1]).toString(16));
}
return parts.join(":");
}
function formatMdns(bytes: Uint8Array): string {
const hex = Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return (
`${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-` +
`${hex.slice(16, 20)}-${hex.slice(20, 32)}.local`
);
}
¶