// Pairing code — the compact join code the phone displays and the user types // into the browser. // // Payload: IPv4 (4 bytes) | port (2 bytes, big-endian) | token (>= 4 bytes), // encoded in Crockford's Base32 and displayed in groups of four: // // A74H-48J3-KI32-OEP4 // // The browser only ever DECODES. The phone is the listener and the only side // that generates codes; `encodePairingCode` exists so the wire format has one // authoritative definition and so the round trip is testable. // // Crockford's alphabet omits I, L, O and U. Decoding still folds I/L -> 1 and // O -> 0, so a code misread off a phone screen (or a doc example that used the // ambiguous glyphs) still resolves to the right bytes. // // LENGTH — this is the whole reason the token is 4 bytes and not 8. // // Base32 carries 5 bits per character, so the code length is fixed arithmetic, // not something an encoder can optimise: // // token 4B -> 10B payload -> 80 bits -> 16 chars -> 4 groups <- target // token 6B -> 12B payload -> 96 bits -> 20 chars -> 5 groups // token 8B -> 14B payload -> 112 bits -> 23 chars -> 6 groups // // The original spec asked for BOTH an 8–10 byte token AND ~16 characters. Those // are not simultaneously satisfiable: 16 characters is exactly 80 bits, and // IP (32) + port (16) leaves exactly 32 bits. A 4-byte token is not a // compromise, it is the only token size that fits four groups. If you widen the // token, the code gets longer — there is no encoding trick that avoids it. // // SECURITY: 32 bits is sufficient here and the entropy is not what protects // pairing. The token travels in the clear to the phone (see the hello frame in // pairing-dial.ts) and is NOT the security boundary — the user-compared SAS // after the Noise NK handshake is. The token's job is to stop an unrelated host // on the same LAN from consuming the phone's pairing window during a 5–10 // minute, single-use window. That does assume the LISTENER closes on a wrong // token rather than allowing unbounded guessing — which it does today (a wrong // token gets a silent close and the code rotates). const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; const IP_BYTES = 4; const PORT_BYTES = 2; /** * The canonical token size. Encoding anything else is refused so both sides * keep deriving from one format — this is the value Android's golden vector is * pinned against. */ export const PAIRING_TOKEN_BYTES = 4; // Decoding stays deliberately lenient about token length so a peer on an older // build (8-byte tokens, 23 characters) still pairs instead of hard-failing. const MIN_TOKEN_BYTES = 4; const MAX_TOKEN_BYTES = 16; export interface PairingCode { ip: string; port: number; /** The one-time token, hex-encoded — presented verbatim in the hello frame. */ tokenHex: string; } /** * Decode a typed pairing code. Returns null for anything that is not a * well-formed code — the caller treats null as "not a pairing code" rather * than as an error, so other code formats can share the same input field. */ export function parsePairingCode(input: string): PairingCode | null { const bytes = decodeCrockford(input); if (!bytes) return null; const tokenBytes = bytes.length - IP_BYTES - PORT_BYTES; if (tokenBytes < MIN_TOKEN_BYTES || tokenBytes > MAX_TOKEN_BYTES) return null; const port = (bytes[4] << 8) | bytes[5]; if (port === 0) return null; return { ip: `${bytes[0]}.${bytes[1]}.${bytes[2]}.${bytes[3]}`, port, tokenHex: toHex(bytes.subarray(IP_BYTES + PORT_BYTES)), }; } /** * Build a pairing code. Throws on an unencodable address or port, or on a token * that is not exactly `PAIRING_TOKEN_BYTES` — a longer token silently produces * a longer code, which is the bug this strictness exists to prevent. */ export function encodePairingCode(ip: string, port: number, token: Uint8Array): string { const octets = ip.split("."); if (octets.length !== IP_BYTES) throw new Error("ip must be dotted-quad IPv4"); const bytes = new Uint8Array(IP_BYTES + PORT_BYTES + token.length); for (let i = 0; i < IP_BYTES; i++) { const n = Number(octets[i]); if (!Number.isInteger(n) || n < 0 || n > 255) throw new Error(`invalid IPv4 octet: ${octets[i]}`); bytes[i] = n; } if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`invalid port: ${port}`); bytes[4] = (port >> 8) & 0xff; bytes[5] = port & 0xff; if (token.length !== PAIRING_TOKEN_BYTES) { throw new Error( `token must be exactly ${PAIRING_TOKEN_BYTES} bytes (got ${token.length}); ` + "every extra byte adds 1.6 characters to the code", ); } bytes.set(token, IP_BYTES + PORT_BYTES); return group(encodeCrockford(bytes)); } /** * Normalise partial user input for display in the code field: drop the * characters Crockford has no place for, uppercase, and re-group in fours. * Applied on every keystroke, so it must tolerate an incomplete code. */ export function formatPairingCode(input: string): string { return group(input.toUpperCase().replace(/[^0-9A-Z]/g, "")); } function group(chars: string): string { return chars.replace(/(.{4})(?=.)/g, "$1-"); } function decodeCrockford(input: string): Uint8Array | null { const clean = input .toUpperCase() .replace(/[\s-]/g, "") .replace(/[ILO]/g, (c) => (c === "O" ? "0" : "1")); if (clean.length === 0) return null; // Trailing bits that don't complete a byte are padding and are dropped. const out = new Uint8Array(Math.floor((clean.length * 5) / 8)); let acc = 0; let bits = 0; let o = 0; for (const ch of clean) { const v = ALPHABET.indexOf(ch); if (v < 0) return null; acc = (acc << 5) | v; bits += 5; if (bits >= 8) { bits -= 8; out[o++] = (acc >> bits) & 0xff; } } return out; } function encodeCrockford(bytes: Uint8Array): string { let s = ""; let acc = 0; let bits = 0; for (const b of bytes) { acc = (acc << 8) | b; bits += 8; while (bits >= 5) { bits -= 5; s += ALPHABET[(acc >> bits) & 0x1f]; } } if (bits > 0) s += ALPHABET[(acc << (5 - bits)) & 0x1f]; return s; } function toHex(bytes: Uint8Array): string { let s = ""; for (const b of bytes) s += b.toString(16).padStart(2, "0"); return s; }