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
↓ peach-browser-40224681.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/shared/pairing-code.test.ts+80 −0src/shared/pairing-code.ts+132 −0manifest.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": {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",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",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+});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+}