// 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. // // The token length is deliberately not fixed: everything after the port is the // token, so the phone can pick anywhere in the 4–10 byte range without a // coordinated change here. 4 bytes of IP + 2 of port + 4 of token is the // 16-character floor; 8 token bytes comes out at 23 characters. // // SECURITY: the token travels in the clear to the phone (see the hello frame in // desktop-pairing.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. const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; const IP_BYTES = 4; const PORT_BYTES = 2; const MIN_TOKEN_BYTES = 4; 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 || bytes.length < IP_BYTES + PORT_BYTES + MIN_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, port, or token. */ 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 < MIN_TOKEN_BYTES) throw new Error(`token must be at least ${MIN_TOKEN_BYTES} bytes`); 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; }