4022468191e3

add Crockford Base32 pairing-code codec: IPv4 + port + variable-length token, glyph-folding decode (v4.0.23)

time
2026-07-24 21:34 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
pairing code transport
parent
8ada195aad56
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/shared/pairing-code.test.ts+80 −0src/shared/pairing-code.ts+132 −0
manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22   "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33   "manifest_version": 3,44   "name": "Peach Password Manager",5-  "version": "4.0.20",5+  "version": "4.0.23",66   "description": "Zero-knowledge password manager",77   "browser_specific_settings": {88     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.0.20",4+  "version": "4.0.23",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "4.0.20",3+  "version": "4.0.23",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/shared/pairing-code.test.ts+80 −0
@@ -0,0 +1,80 @@1+import { describe, it, expect } from "vitest";2+import { encodePairingCode, formatPairingCode, parsePairingCode } from "./pairing-code";3+4+const token4 = Uint8Array.of(0xde, 0xad, 0xbe, 0xef);5+const token8 = Uint8Array.of(0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef);6+7+describe("pairing-code", () => {8+  it("round-trips a 4-byte token as exactly 16 characters", () => {9+    const code = encodePairingCode("192.168.1.42", 49142, token4);10+    expect(code.replace(/-/g, "")).toHaveLength(16);11+    expect(code).toMatch(/^[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}$/);12+    expect(parsePairingCode(code)).toEqual({13+      ip: "192.168.1.42",14+      port: 49142,15+      tokenHex: "deadbeef",16+    });17+  });18+19+  it("round-trips an 8-byte token without a coordinated length change", () => {20+    const code = encodePairingCode("10.0.0.1", 8765, token8);21+    expect(parsePairingCode(code)).toEqual({22+      ip: "10.0.0.1",23+      port: 8765,24+      tokenHex: "0123456789abcdef",25+    });26+  });27+28+  it("preserves the full port range", () => {29+    for (const port of [1, 8765, 49142, 65535]) {30+      const code = encodePairingCode("192.168.4.230", port, token4);31+      expect(parsePairingCode(code)?.port).toBe(port);32+    }33+  });34+35+  it("folds the ambiguous glyphs I, L and O on the way in", () => {36+    const code = encodePairingCode("192.168.1.42", 49142, token4);37+    const ambiguous = code.replace(/0/g, "O").replace(/1/g, "I");38+    expect(parsePairingCode(ambiguous)).toEqual(parsePairingCode(code));39+  });40+41+  it("accepts a code typed without dashes, in lowercase, with stray spaces", () => {42+    const code = encodePairingCode("192.168.1.42", 49142, token4);43+    const sloppy = ` ${code.replace(/-/g, " ").toLowerCase()} `;44+    expect(parsePairingCode(sloppy)).toEqual(parsePairingCode(code));45+  });46+47+  it("rejects codes that are too short to carry a token", () => {48+    // 4 IP bytes + 2 port bytes and nothing else — 10 base32 characters.49+    expect(parsePairingCode("ABCDEFGHJK")).toBeNull();50+    expect(parsePairingCode("")).toBeNull();51+    expect(parsePairingCode("---")).toBeNull();52+  });53+54+  it("rejects characters outside Crockford's alphabet", () => {55+    // U is excluded by Crockford and is NOT folded, unlike I/L/O.56+    expect(parsePairingCode("UUUU-UUUU-UUUU-UUUU")).toBeNull();57+    expect(parsePairingCode("A74H-48J3-KI32-OEP!")).toBeNull();58+  });59+60+  it("rejects a zero port", () => {61+    // An all-zero payload is long enough but decodes to 0.0.0.0:0 — a code62+    // that parsed would send the dialler at an unroutable endpoint.63+    expect(parsePairingCode("0000-0000-0000-0000")).toBeNull();64+  });65+66+  it("refuses to encode an out-of-range address, port, or short token", () => {67+    expect(() => encodePairingCode("192.168.1.256", 49142, token4)).toThrow();68+    expect(() => encodePairingCode("192.168.1", 49142, token4)).toThrow();69+    expect(() => encodePairingCode("192.168.1.42", 0, token4)).toThrow();70+    expect(() => encodePairingCode("192.168.1.42", 70000, token4)).toThrow();71+    expect(() => encodePairingCode("192.168.1.42", 49142, Uint8Array.of(1, 2, 3))).toThrow();72+  });73+74+  it("regroups partial input as the user types", () => {75+    expect(formatPairingCode("a74h48")).toBe("A74H-48");76+    expect(formatPairingCode("A74H-48J3-KI32-OEP4")).toBe("A74H-48J3-KI32-OEP4");77+    expect(formatPairingCode("a74h 48j3")).toBe("A74H-48J3");78+    expect(formatPairingCode("")).toBe("");79+  });80+});

view file

src/shared/pairing-code.ts+132 −0
@@ -0,0 +1,132 @@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.26+27+const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";28+const IP_BYTES = 4;29+const PORT_BYTES = 2;30+const MIN_TOKEN_BYTES = 4;31+32+export 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+}38+39+/**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+ */44+export 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+}55+56+/** Build a pairing code. Throws on an unencodable address, port, or token. */57+export 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+}73+74+/**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+ */79+export function formatPairingCode(input: string): string {80+  return group(input.toUpperCase().replace(/[^0-9A-Z]/g, ""));81+}82+83+function group(chars: string): string {84+  return chars.replace(/(.{4})(?=.)/g, "$1-");85+}86+87+function 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;93+94+  // 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+}111+112+function 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+}127+128+function toHex(bytes: Uint8Array): string {129+  let s = "";130+  for (const b of bytes) s += b.toString(16).padStart(2, "0");131+  return s;132+}

view file