pairing-code.ts

6.2 KB · raw

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}