pairing-code.ts

4.6 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// The token length is deliberately not fixed: everything after the port is the18// token, so the phone can pick anywhere in the 4–10 byte range without a19// coordinated change here. 4 bytes of IP + 2 of port + 4 of token is the20// 16-character floor; 8 token bytes comes out at 23 characters.21//22// SECURITY: the token travels in the clear to the phone (see the hello frame in23// desktop-pairing.ts) and is NOT the security boundary — the user-compared SAS24// after the Noise NK handshake is. The token's job is to stop an unrelated host25// on the same LAN from consuming the phone's pairing window.2627const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";28const IP_BYTES = 4;29const PORT_BYTES = 2;30const MIN_TOKEN_BYTES = 4;3132export interface PairingCode {33  ip: string;34  port: number;35  /** The one-time token, hex-encoded — presented verbatim in the hello frame. */36  tokenHex: string;37}3839/**40 * Decode a typed pairing code. Returns null for anything that is not a41 * well-formed code — the caller treats null as "not a pairing code" rather42 * than as an error, so other code formats can share the same input field.43 */44export function parsePairingCode(input: string): PairingCode | null {45  const bytes = decodeCrockford(input);46  if (!bytes || bytes.length < IP_BYTES + PORT_BYTES + MIN_TOKEN_BYTES) return null;47  const port = (bytes[4] << 8) | bytes[5];48  if (port === 0) return null;49  return {50    ip: `${bytes[0]}.${bytes[1]}.${bytes[2]}.${bytes[3]}`,51    port,52    tokenHex: toHex(bytes.subarray(IP_BYTES + PORT_BYTES)),53  };54}5556/** Build a pairing code. Throws on an unencodable address, port, or token. */57export function encodePairingCode(ip: string, port: number, token: Uint8Array): string {58  const octets = ip.split(".");59  if (octets.length !== IP_BYTES) throw new Error("ip must be dotted-quad IPv4");60  const bytes = new Uint8Array(IP_BYTES + PORT_BYTES + token.length);61  for (let i = 0; i < IP_BYTES; i++) {62    const n = Number(octets[i]);63    if (!Number.isInteger(n) || n < 0 || n > 255) throw new Error(`invalid IPv4 octet: ${octets[i]}`);64    bytes[i] = n;65  }66  if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`invalid port: ${port}`);67  bytes[4] = (port >> 8) & 0xff;68  bytes[5] = port & 0xff;69  if (token.length < MIN_TOKEN_BYTES) throw new Error(`token must be at least ${MIN_TOKEN_BYTES} bytes`);70  bytes.set(token, IP_BYTES + PORT_BYTES);71  return group(encodeCrockford(bytes));72}7374/**75 * Normalise partial user input for display in the code field: drop the76 * characters Crockford has no place for, uppercase, and re-group in fours.77 * Applied on every keystroke, so it must tolerate an incomplete code.78 */79export function formatPairingCode(input: string): string {80  return group(input.toUpperCase().replace(/[^0-9A-Z]/g, ""));81}8283function group(chars: string): string {84  return chars.replace(/(.{4})(?=.)/g, "$1-");85}8687function decodeCrockford(input: string): Uint8Array | null {88  const clean = input89    .toUpperCase()90    .replace(/[\s-]/g, "")91    .replace(/[ILO]/g, (c) => (c === "O" ? "0" : "1"));92  if (clean.length === 0) return null;9394  // Trailing bits that don't complete a byte are padding and are dropped.95  const out = new Uint8Array(Math.floor((clean.length * 5) / 8));96  let acc = 0;97  let bits = 0;98  let o = 0;99  for (const ch of clean) {100    const v = ALPHABET.indexOf(ch);101    if (v < 0) return null;102    acc = (acc << 5) | v;103    bits += 5;104    if (bits >= 8) {105      bits -= 8;106      out[o++] = (acc >> bits) & 0xff;107    }108  }109  return out;110}111112function encodeCrockford(bytes: Uint8Array): string {113  let s = "";114  let acc = 0;115  let bits = 0;116  for (const b of bytes) {117    acc = (acc << 8) | b;118    bits += 8;119    while (bits >= 5) {120      bits -= 5;121      s += ALPHABET[(acc >> bits) & 0x1f];122    }123  }124  if (bits > 0) s += ALPHABET[(acc << (5 - bits)) & 0x1f];125  return s;126}127128function toHex(bytes: Uint8Array): string {129  let s = "";130  for (const b of bytes) s += b.toString(16).padStart(2, "0");131  return s;132}