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
7 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 −10
manifest.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": {

view file

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",

view file

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",

view file

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 

view file

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 }),

view file

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 

view file

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 }

view file