ab3f2d5e6a51
pin the pairing token at 4 bytes so a code is 16 characters in 4 groups, refuse any other size at encode, and keep decode lenient for older 8-byte peers (v4.0.30)
- time
- 2026-07-24 23:38 UTC · 3 months ago
- machine
- koh2
- agent
- claude-opus-5
- session
- pairing code length
- parent
- f4dd1094715d
↓ peach-browser-ab3f2d5e.tar.gzatlas at this save
files changed7 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/desktop-pairing.test.ts+2 −2src/popup/views/AdoptVaultView.test.tsx+1 −1src/shared/pairing-code.test.ts+31 −11src/shared/pairing-code.ts+46 −10manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.0.29",4+ "version": "4.0.30",55 "description": "Zero-knowledge password manager",66 "browser_specific_settings": {77 "gecko": {manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.0.29",4+ "version": "4.0.30",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.29",3+ "version": "4.0.30",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/background/desktop-pairing.test.ts+2 −2
@@ -270,7 +270,7 @@270270 271271 // ── pairing-code path (the phone is the listener) ──────────────────────────272272 273-const PHONE_TOKEN = Uint8Array.of(0xde, 0xad, 0xbe, 0xef, 0x01, 0x02, 0x03, 0x04);273+const PHONE_TOKEN = Uint8Array.of(0xde, 0xad, 0xbe, 0xef);274274 const PHONE_CODE = encodePairingCode("192.168.4.77", 51555, PHONE_TOKEN);275275 const PHONE_LONGTERM_HEX = "f".repeat(64);276276 @@ -329,7 +329,7 @@329329 330330 const hello = dec(sockets[0].sent[0]);331331 expect(hello.type).toBe("PEACH_HELLO");332- expect(hello.token).toBe("deadbeef01020304");332+ expect(hello.token).toBe("deadbeef");333333 expect(hello.code).toBeUndefined();334334 });335335 src/popup/views/AdoptVaultView.test.tsx+1 −1
@@ -7,7 +7,7 @@77 import { flushView, inputText, renderView } from "./view-test-utils";88 import { encodePairingCode } from "../../shared/pairing-code";99 10-const PAIRING_CODE = encodePairingCode("192.168.4.77", 51555, Uint8Array.of(1, 2, 3, 4, 5, 6, 7, 8));10+const PAIRING_CODE = encodePairingCode("192.168.4.77", 51555, Uint8Array.of(1, 2, 3, 4));1111 1212 vi.mock("@zxcvbn-ts/core", () => ({1313 zxcvbn: (value: string) => ({ score: value.length >= 16 ? 4 : 1 }),src/shared/pairing-code.test.ts+31 −11
@@ -1,14 +1,19 @@11 import { describe, it, expect } from "vitest";2-import { encodePairingCode, formatPairingCode, parsePairingCode } from "./pairing-code";2+import {3+ PAIRING_TOKEN_BYTES,4+ encodePairingCode,5+ formatPairingCode,6+ parsePairingCode,7+} from "./pairing-code";38 49 const token4 = Uint8Array.of(0xde, 0xad, 0xbe, 0xef);5-const token8 = Uint8Array.of(0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef);610 711 describe("pairing-code", () => {8- it("round-trips a 4-byte token as exactly 16 characters", () => {12+ // GOLDEN VECTOR — Android's PairingCode.kt is pinned against this exact13+ // output. Changing it is a coordinated cross-repo change, never a local edit.14+ it("golden vector: 192.168.1.42:49142 + deadbeef -> R2M0-2ANZ-YVFA-VFQF", () => {915 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}$/);16+ expect(code).toBe("R2M0-2ANZ-YVFA-VFQF");1217 expect(parsePairingCode(code)).toEqual({1318 ip: "192.168.1.42",1419 port: 49142,@@ -16,12 +21,27 @@1621 });1722 });1823 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",24+ it("is exactly 16 characters in 4 groups — the whole point of a 4-byte token", () => {25+ const code = encodePairingCode("192.168.1.42", 49142, token4);26+ expect(code.replace(/-/g, "")).toHaveLength(16);27+ expect(code).toMatch(/^[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}$/);28+ expect(PAIRING_TOKEN_BYTES).toBe(4);29+ });30+31+ it("refuses to encode a token of any other size", () => {32+ // An 8-byte token is what produced the 23-character, 6-group codes; Base3233+ // gives 5 bits per character, so there is no encoding fix — only this gate.34+ for (const n of [3, 5, 6, 8, 10]) {35+ expect(() => encodePairingCode("192.168.1.42", 49142, new Uint8Array(n))).toThrow(/exactly 4 bytes/);36+ }37+ });38+39+ it("still decodes an older 8-byte-token code so a stale peer can pair", () => {40+ // Real code captured from an Android emulator build before the fix.41+ expect(parsePairingCode("1800-43XF-HPH0-2EP9-A3PR-7P0")).toEqual({42+ ip: "10.0.2.15",43+ port: 44941,44+ tokenHex: "a2013ac950ed83d8",2545 });2646 });2747 src/shared/pairing-code.ts+46 −10
@@ -14,20 +14,45 @@1414 // O -> 0, so a code misread off a phone screen (or a doc example that used the1515 // ambiguous glyphs) still resolves to the right bytes.1616 //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.17+// LENGTH — this is the whole reason the token is 4 bytes and not 8.2118 //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 SAS19+// 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 SAS2435 // 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.36+// 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).2640 2741 const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";2842 const IP_BYTES = 4;2943 const PORT_BYTES = 2;44+45+/**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+ */50+export const PAIRING_TOKEN_BYTES = 4;51+52+// 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.3054 const MIN_TOKEN_BYTES = 4;55+const MAX_TOKEN_BYTES = 16;3156 3257 export interface PairingCode {3358 ip: string;@@ -43,7 +68,9 @@4368 */4469 export function parsePairingCode(input: string): PairingCode | null {4570 const bytes = decodeCrockford(input);46- if (!bytes || bytes.length < IP_BYTES + PORT_BYTES + MIN_TOKEN_BYTES) return null;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;4774 const port = (bytes[4] << 8) | bytes[5];4875 if (port === 0) return null;4976 return {@@ -53,7 +80,11 @@5380 };5481 }5582 56-/** Build a pairing code. Throws on an unencodable address, port, or token. */83+/**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+ */5788 export function encodePairingCode(ip: string, port: number, token: Uint8Array): string {5889 const octets = ip.split(".");5990 if (octets.length !== IP_BYTES) throw new Error("ip must be dotted-quad IPv4");@@ -66,7 +97,12 @@6697 if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`invalid port: ${port}`);6798 bytes[4] = (port >> 8) & 0xff;6899 bytes[5] = port & 0xff;69- if (token.length < MIN_TOKEN_BYTES) throw new Error(`token must be at least ${MIN_TOKEN_BYTES} bytes`);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+ }70106 bytes.set(token, IP_BYTES + PORT_BYTES);71107 return group(encodeCrockford(bytes));72108 }