pairing-code.ts
1// Pairing code — the compact join code the phone displays and the user types2// into the browser.3//4// Payload: IPv4 (4 bytes) | port (2 bytes, big-endian) | token (>= 4 bytes),5// encoded in Crockford's Base32 and displayed in groups of four:6//7// A74H-48J3-KI32-OEP48//9// The browser only ever DECODES. The phone is the listener and the only side10// that generates codes; `encodePairingCode` exists so the wire format has one11// authoritative definition and so the round trip is testable.12//13// Crockford's alphabet omits I, L, O and U. Decoding still folds I/L -> 1 and14// O -> 0, so a code misread off a phone screen (or a doc example that used the15// ambiguous glyphs) still resolves to the right bytes.16//17// LENGTH — this is the whole reason the token is 4 bytes and not 8.18//19// Base32 carries 5 bits per character, so the code length is fixed arithmetic,20// not something an encoder can optimise:21//22// token 4B -> 10B payload -> 80 bits -> 16 chars -> 4 groups <- target23// token 6B -> 12B payload -> 96 bits -> 20 chars -> 5 groups24// token 8B -> 14B payload -> 112 bits -> 23 chars -> 6 groups25//26// The original spec asked for BOTH an 8–10 byte token AND ~16 characters. Those27// are not simultaneously satisfiable: 16 characters is exactly 80 bits, and28// IP (32) + port (16) leaves exactly 32 bits. A 4-byte token is not a29// compromise, it is the only token size that fits four groups. If you widen the30// token, the code gets longer — there is no encoding trick that avoids it.31//32// SECURITY: 32 bits is sufficient here and the entropy is not what protects33// pairing. The token travels in the clear to the phone (see the hello frame in34// pairing-dial.ts) and is NOT the security boundary — the user-compared SAS35// after the Noise NK handshake is. The token's job is to stop an unrelated host36// on the same LAN from consuming the phone's pairing window during a 5–1037// minute, single-use window. That does assume the LISTENER closes on a wrong38// token rather than allowing unbounded guessing — which it does today (a wrong39// token gets a silent close and the code rotates).4041const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";42const IP_BYTES = 4;43const PORT_BYTES = 2;4445/**46 * The canonical token size. Encoding anything else is refused so both sides47 * keep deriving from one format — this is the value Android's golden vector is48 * pinned against.49 */50export const PAIRING_TOKEN_BYTES = 4;5152// Decoding stays deliberately lenient about token length so a peer on an older53// build (8-byte tokens, 23 characters) still pairs instead of hard-failing.54const MIN_TOKEN_BYTES = 4;55const MAX_TOKEN_BYTES = 16;5657export interface PairingCode {58 ip: string;59 port: number;60 /** The one-time token, hex-encoded — presented verbatim in the hello frame. */61 tokenHex: string;62}6364/**65 * Decode a typed pairing code. Returns null for anything that is not a66 * well-formed code — the caller treats null as "not a pairing code" rather67 * than as an error, so other code formats can share the same input field.68 */69export function parsePairingCode(input: string): PairingCode | null {70 const bytes = decodeCrockford(input);71 if (!bytes) return null;72 const tokenBytes = bytes.length - IP_BYTES - PORT_BYTES;73 if (tokenBytes < MIN_TOKEN_BYTES || tokenBytes > MAX_TOKEN_BYTES) return null;74 const port = (bytes[4] << 8) | bytes[5];75 if (port === 0) return null;76 return {77 ip: `${bytes[0]}.${bytes[1]}.${bytes[2]}.${bytes[3]}`,78 port,79 tokenHex: toHex(bytes.subarray(IP_BYTES + PORT_BYTES)),80 };81}8283/**84 * Build a pairing code. Throws on an unencodable address or port, or on a token85 * that is not exactly `PAIRING_TOKEN_BYTES` — a longer token silently produces86 * a longer code, which is the bug this strictness exists to prevent.87 */88export function encodePairingCode(ip: string, port: number, token: Uint8Array): string {89 const octets = ip.split(".");90 if (octets.length !== IP_BYTES) throw new Error("ip must be dotted-quad IPv4");91 const bytes = new Uint8Array(IP_BYTES + PORT_BYTES + token.length);92 for (let i = 0; i < IP_BYTES; i++) {93 const n = Number(octets[i]);94 if (!Number.isInteger(n) || n < 0 || n > 255) throw new Error(`invalid IPv4 octet: ${octets[i]}`);95 bytes[i] = n;96 }97 if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`invalid port: ${port}`);98 bytes[4] = (port >> 8) & 0xff;99 bytes[5] = port & 0xff;100 if (token.length !== PAIRING_TOKEN_BYTES) {101 throw new Error(102 `token must be exactly ${PAIRING_TOKEN_BYTES} bytes (got ${token.length}); ` +103 "every extra byte adds 1.6 characters to the code",104 );105 }106 bytes.set(token, IP_BYTES + PORT_BYTES);107 return group(encodeCrockford(bytes));108}109110/**111 * Normalise partial user input for display in the code field: drop the112 * characters Crockford has no place for, uppercase, and re-group in fours.113 * Applied on every keystroke, so it must tolerate an incomplete code.114 */115export function formatPairingCode(input: string): string {116 return group(input.toUpperCase().replace(/[^0-9A-Z]/g, ""));117}118119function group(chars: string): string {120 return chars.replace(/(.{4})(?=.)/g, "$1-");121}122123function decodeCrockford(input: string): Uint8Array | null {124 const clean = input125 .toUpperCase()126 .replace(/[\s-]/g, "")127 .replace(/[ILO]/g, (c) => (c === "O" ? "0" : "1"));128 if (clean.length === 0) return null;129130 // Trailing bits that don't complete a byte are padding and are dropped.131 const out = new Uint8Array(Math.floor((clean.length * 5) / 8));132 let acc = 0;133 let bits = 0;134 let o = 0;135 for (const ch of clean) {136 const v = ALPHABET.indexOf(ch);137 if (v < 0) return null;138 acc = (acc << 5) | v;139 bits += 5;140 if (bits >= 8) {141 bits -= 8;142 out[o++] = (acc >> bits) & 0xff;143 }144 }145 return out;146}147148function encodeCrockford(bytes: Uint8Array): string {149 let s = "";150 let acc = 0;151 let bits = 0;152 for (const b of bytes) {153 acc = (acc << 8) | b;154 bits += 8;155 while (bits >= 5) {156 bits -= 5;157 s += ALPHABET[(acc >> bits) & 0x1f];158 }159 }160 if (bits > 0) s += ALPHABET[(acc << (5 - bits)) & 0x1f];161 return s;162}163164function toHex(bytes: Uint8Array): string {165 let s = "";166 for (const b of bytes) s += b.toString(16).padStart(2, "0");167 return s;168}