3901ccee1835

refactor: remove retired sync transport paths (v4.0.40)

time
2026-07-26 02:46 UTC · 3 months ago
machine
koh2
agent
codex
session
peach relay sync implementation
parent
efefabc50cb3
51 files manifest.firefox.json+2 −3manifest.json+2 −3package.json+1 −1src/background/code-pairing-bg.ts+1 −17src/background/code-pairing.test.ts+42 −58src/background/code-pairing.ts+13 −12src/background/desktop-pairing-mdns.test.ts+0 −99src/background/desktop-pairing-mdns.ts+0 −163src/background/desktop-pairing-probe.ts+0 −68src/background/device-identity.ts+1 −1src/background/messages.ts+4 −21src/background/paired-devices.ts+2 −2src/background/sync-runner.test.ts+7 −7src/background/sync-static.test.ts+0 −1src/background/sync-trigger.desktop.test.ts+0 −127src/background/sync-trigger.ts+4 −6src/background/sync-vault-adapter.ts+2 −2src/background/vault.rotate-sync-identity.test.ts+1 −1src/background/vault.ts+2 −2src/drafts/PCH-02-FIXES/INTEGRATION-CONTRACT.md+4 −22src/drafts/PCH-02-FIXES/PCH-02-002-noise-xx-msg1-hash.patch.md+0 −132src/drafts/PCH-02-FIXES/PCH-02-003-sas-trust-model-comment.patch.md+1 −1src/drafts/PCH-02-FIXES/PCH-02-006-vault-id-privacy-note.patch.md+0 −48src/drafts/PCH-02-FIXES/PCH-02-009-reject-zero-dh.patch.md+0 −78src/drafts/PCH-02-FIXES/README.md+0 −3src/drafts/PCH-02-FIXES/WASM-PROVENANCE.md+1 −1src/drafts/PCH-02-FIXES/noise.reject-zero-dh.test.ts+0 −113src/drafts/TEST-EXPANSION/INTEGRATION-CONTRACT.md+2 −2src/drafts/TEST-EXPANSION/sync.disconnect.test.ts+2 −2src/drafts/TEST-EXPANSION/vault.rotate-sync-identity.test.ts+1 −1src/popup/App.tsx+4 −4src/popup/codex/codex.test.ts+1 −1src/popup/views/PairDevice.test.tsx+23 −22src/popup/views/PairDevice.tsx+2 −9src/popup/views/Settings.tsx+0 −1src/shared/device-manifest.ts+3 −4src/shared/noise-blake2s-kat.test.ts+2 −36src/shared/noise-js.ts+2 −3src/shared/noise-xx.test.ts+0 −149src/shared/noise-xx.ts+0 −268src/shared/noise.reject-zero-dh.test.ts+1 −30src/shared/pairing-code.test.ts+1 −1src/shared/pairing-dial.test.ts+5 −19src/shared/pairing-dial.ts+4 −21src/shared/pairing-handshake.ts+3 −3src/shared/pairing-handshake.vault-id.test.ts+5 −5src/shared/sync-identity.ts+7 −8src/shared/sync-limits.ts+1 −1src/shared/sync-transport.ts+4 −4src/shared/sync-types.ts+3 −8src/shared/vault-adopt.ts+1 −1
manifest.firefox.json+2 −3
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.0.39",4+  "version": "4.0.40",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {@@ -96,8 +96,7 @@9696     "scripting",9797     "clipboardWrite",9898     "alarms",99-    "idle",100-    "mdns"99+    "idle"101100   ],102101   "content_security_policy": {103102     "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'none'"

view file

manifest.json+2 −3
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.0.39",4+  "version": "4.0.40",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",@@ -62,8 +62,7 @@6262     "idle",6363     "favicon",6464     "webAuthenticationProxy",65-    "offscreen",66-    "mdns"65+    "offscreen"6766   ],6867   "host_permissions": [6968     "<all_urls>"

view file

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

view file

src/background/code-pairing-bg.ts+1 −17
@@ -20,9 +20,6 @@2020 import { buildLocalIdentity } from "./device-identity";2121 import { computeVaultId } from "../shared/sync-identity";2222 import { isUnlocked, getRootKeyHex } from "./vault";23-import { probeSubnetForPort, wsPortOpen } from "./desktop-pairing-probe";24-import { discoverPeachSync } from "./desktop-pairing-mdns";25-import { getLocalSubnet } from "../shared/local-network";2623 import type { LocalIdentity } from "../shared/pairing-handshake";2724 2825 function hexToBytesLocal(hex: string): Uint8Array {@@ -43,22 +40,9 @@4340   }4441 }4542 46-/** Production dependency set — real probe, WebSocket, identity, and storage. */43+/** Production dependency set — real WebSocket, identity, and storage. */4744 export function productionCodePairingDeps(): CodePairingDeps {4845   return {49-    async findHost(port) {50-      // Localhost first: peach-sync runs on the same machine as the extension.51-      // This works in every browser without mDNS or WebRTC, and is the fastest52-      // path. The daemon binds 0.0.0.0 so 127.0.0.1 is always reachable.53-      if (await wsPortOpen("127.0.0.1", port, 800)) return "127.0.0.1";54-      // mDNS next — instant when chrome.mdns is available (Chrome/Chromium).55-      const discovered = await discoverPeachSync();56-      if (discovered && (await wsPortOpen(discovered.ip, port, 800))) return discovered.ip;57-      // Fallback: /24 subnet probe (Firefox, Chrome without mDNS, or a stale hit).58-      const subnet = await getLocalSubnet();59-      if (!subnet) return null;60-      return probeSubnetForPort(subnet, port);61-    },6246     openSocket: (ip, port) => openPairingSocket(ip, port),6347     async getLocalIdentity(): Promise<LocalIdentity> {6448       if (!isUnlocked()) throw new Error("vault_locked");

view file

src/background/code-pairing.test.ts+42 −58
@@ -1,4 +1,4 @@1-// Code pairing — the PAIR_IDENTITY half, over both code forms.1+// Code pairing — the PAIR_IDENTITY half over an address-bearing pairing code.22 //33 // Driven entirely through the dependency-injection seam (CodePairingDeps and a44 // fake PairingSocket), so no real WebSocket or Noise crypto runs — except the@@ -12,7 +12,7 @@1212   type CodePairingDeps,1313   type CodePairingCallbacks,1414 } from "./code-pairing";15-import { PAIRING_PORT, type PairingSocket } from "../shared/pairing-dial";15+import type { PairingSocket } from "../shared/pairing-dial";1616 1717 // REAL SAS implementation — reused, not re-derived (ground truth §5).1818 import { derivePairingSAS } from "../shared/pairing-handshake";@@ -25,8 +25,10 @@2525 const enc = (o: unknown) => new TextEncoder().encode(JSON.stringify(o));2626 const dec = (b: Uint8Array) => JSON.parse(new TextDecoder().decode(b));2727 28-const HELPER_EPHEMERAL_HEX = "a".repeat(64);29-const HELPER_LONGTERM_HEX = "b".repeat(64);28+const PEER_EPHEMERAL_HEX = "a".repeat(64);29+const PHONE_LONGTERM_HEX = "f".repeat(64);30+const PHONE_TOKEN = Uint8Array.of(0xde, 0xad, 0xbe, 0xef);31+const PHONE_CODE = encodePairingCode("192.168.4.77", 51555, PHONE_TOKEN);3032 3133 const OUR_IDENTITY: LocalIdentity = {3234   deviceId: "ext-local-001",@@ -40,7 +42,7 @@4042 /**4143  * Scripted fake socket. Captures everything sent, and replays a queued list4244  * of inbound frames. A frame of `null` means "the socket closes here with no43- * data" — the peach-sync wrong-code behaviour (no error frame).45+ * data" — the wrong-code behaviour (no error frame).4446  */4547 function makeFakeSocket(inbound: Array<Uint8Array | null>) {4648   const sent: Uint8Array[] = [];@@ -76,7 +78,7 @@7678 7779 // Mock the NK initiator: skip the two handshake messages, hand back a session7880 // whose encrypt/decrypt are identity transforms and whose handshakeHash is the79-// peach-sync KAT prefix (so the SAS test below is end-to-end through the flow).81+// cross-platform KAT prefix (so the SAS test below is end-to-end through the flow).8082 vi.mock("../shared/noise-js", async (orig) => {8183   const actual = await orig<typeof import("../shared/noise-js")>();8284   return {@@ -97,18 +99,17 @@9799   const persisted: PairedDevice[] = [];98100   const sockets: ReturnType<typeof makeFakeSocket>[] = [];99101   const deps: CodePairingDeps = {100-    findHost: vi.fn(async () => "192.168.1.50"),101102     openSocket: vi.fn(async () => {102103       // default: ACK then a PAIR_IDENTITY response103104       const s = makeFakeSocket([104-        enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: HELPER_EPHEMERAL_HEX }),105+        enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: PEER_EPHEMERAL_HEX }),105106         enc({106107           type: "PAIR_IDENTITY",107-          device_id: "desktop-001",108-          device_name: "June's iMac",109-          device_type: "desktop",110-          long_term_public_key_hex: HELPER_LONGTERM_HEX,111-          ws_port: 8765,108+          device_id: "android-001",109+          device_name: "Pixel 8",110+          device_type: "android",111+          long_term_public_key_hex: PHONE_LONGTERM_HEX,112+          ws_port: 49142,112113         }),113114       ]);114115       sockets.push(s);@@ -145,23 +146,20 @@145146 // ── specs ────────────────────────────────────────────────────────────────146147 147148 describe("pairWithCode", () => {148-  it("sends PEACH_HELLO with a 6-digit zero-padded code to port 49142", async () => {149+  it("dials the coded endpoint and sends its token in PEACH_HELLO", async () => {149150     const { deps, sockets } = makeDeps();150151     const { cb } = makeCallbacks();151152 152-    await pairWithCode("123", cb, deps, async () => true);153+    await pairWithCode(PHONE_CODE, cb, deps, async () => true);153154 154-    expect(deps.openSocket).toHaveBeenCalledWith("192.168.1.50", PAIRING_PORT);155+    expect(deps.openSocket).toHaveBeenCalledWith("192.168.4.77", 51555);155156     const firstFrame = dec(sockets[0].sent[0]);156-    expect(firstFrame.type).toBe("PEACH_HELLO");157-    // "123" must be normalised to exactly 6 ASCII digits, zero-padded.158-    expect(firstFrame.code).toBe("000123");159-    expect(firstFrame.code).toMatch(/^\d{6}$/);157+    expect(firstFrame).toEqual({ type: "PEACH_HELLO", token: "deadbeef" });160158   });161159 162160   it("treats a socket close without PEACH_HELLO_ACK as invalid_code", async () => {163-    // peach-sync sends NO error frame on a wrong/expired code — it SILENTLY164-    // CLOSES the socket and rotates the code (pairing.zig:117,119-125). So the161+    // The listener sends NO error frame on a wrong/expired code — it SILENTLY162+    // CLOSES the socket and rotates the code. So the165163     // absence of a PEACH_HELLO_ACK *is* the wrong-code signal. (This replaces166164     // the original brief's bogus PEACH_HELLO_ERROR test — no such frame exists.)167165     const closeImmediately = makeFakeSocket([null]); // close, no data@@ -170,7 +168,7 @@170168     });171169     const { cb, errors, completed } = makeCallbacks();172170 173-    await pairWithCode("654321", cb, deps, async () => true);171+    await pairWithCode(PHONE_CODE, cb, deps, async () => true);174172 175173     expect(errors).toEqual(["invalid_code"]);176174     expect(completed).toHaveLength(0);@@ -192,24 +190,24 @@192190     const { deps } = makeDeps();193191     const { cb, sas } = makeCallbacks();194192 195-    await pairWithCode("000123", cb, deps, async () => true);193+    await pairWithCode(PHONE_CODE, cb, deps, async () => true);196194 197195     expect(sas.value).toBe("895245");198196   });199197 200-  it("persists the device with deviceType 'desktop' on SAS confirm", async () => {198+  it("persists the phone identity on SAS confirm", async () => {201199     const { deps, persisted } = makeDeps();202200     const { cb, completed, errors } = makeCallbacks();203201 204-    await pairWithCode("000123", cb, deps, async () => true);202+    await pairWithCode(PHONE_CODE, cb, deps, async () => true);205203 206204     expect(errors).toHaveLength(0);207205     expect(persisted).toHaveLength(1);208-    expect(persisted[0].deviceType).toBe("desktop");209-    expect(persisted[0].deviceId).toBe("desktop-001");210-    expect(persisted[0].longTermPublicKeyHex).toBe(HELPER_LONGTERM_HEX);206+    expect(persisted[0].deviceType).toBe("android");207+    expect(persisted[0].deviceId).toBe("android-001");208+    expect(persisted[0].longTermPublicKeyHex).toBe(PHONE_LONGTERM_HEX);211209     expect(persisted[0].trustSource).toBe("direct");212-    expect(persisted[0].wsPort).toBe(8765);210+    expect(persisted[0].wsPort).toBe(49142);213211     expect(completed).toHaveLength(1);214212   });215213 @@ -217,7 +215,7 @@217215     const { deps, persisted } = makeDeps();218216     const { cb, errors } = makeCallbacks();219217 220-    await pairWithCode("000123", cb, deps, async () => false);218+    await pairWithCode(PHONE_CODE, cb, deps, async () => false);221219 222220     expect(errors).toEqual(["sas_mismatch"]);223221     expect(persisted).toHaveLength(0);@@ -228,7 +226,7 @@228226     const { deps, sockets } = makeDeps();229227     const { cb } = makeCallbacks();230228 231-    await pairWithCode("000123", cb, deps, async () => true);229+    await pairWithCode(PHONE_CODE, cb, deps, async () => true);232230 233231     // sent[0] = PEACH_HELLO (plaintext); sent[1] = our PAIR_IDENTITY234232     // (encrypt is identity in the mock, so it decodes directly).@@ -237,35 +235,21 @@237235     expect(ourPairIdentity.vault_id).toBe(OUR_IDENTITY.vaultId);238236   });239237 240-  it("reports no_host_found when the probe returns null", async () => {241-    const { deps } = makeDeps({ findHost: vi.fn(async () => null) });242-    const { cb, errors } = makeCallbacks();243-244-    await pairWithCode("000123", cb, deps, async () => true);245-246-    expect(errors).toEqual(["no_host_found"]);247-    expect(deps.openSocket).not.toHaveBeenCalled();248-  });249-250238   it("reports vault_locked when local identity is unavailable", async () => {251239     const { deps } = makeDeps({252240       getLocalIdentity: vi.fn(async () => { throw new Error("vault_locked"); }),253241     });254242     const { cb, errors } = makeCallbacks();255243 256-    await pairWithCode("000123", cb, deps, async () => true);244+    await pairWithCode(PHONE_CODE, cb, deps, async () => true);257245 258246     expect(errors).toEqual(["vault_locked"]);259-    expect(deps.findHost).not.toHaveBeenCalled();247+    expect(deps.openSocket).not.toHaveBeenCalled();260248   });261249 });262250 263251 // ── pairing-code path (the phone is the listener) ──────────────────────────264252 265-const PHONE_TOKEN = Uint8Array.of(0xde, 0xad, 0xbe, 0xef);266-const PHONE_CODE = encodePairingCode("192.168.4.77", 51555, PHONE_TOKEN);267-const PHONE_LONGTERM_HEX = "f".repeat(64);268-269253 /** Deps whose socket replays an Android peer's ACK + PAIR_IDENTITY. */270254 function makePhoneDeps(deviceType: string | undefined = "android") {271255   const identity: Record<string, unknown> = {@@ -273,26 +257,25 @@273257     device_id: "android-001",274258     device_name: "Pixel 8",275259     long_term_public_key_hex: PHONE_LONGTERM_HEX,276-    ws_port: 8765,260+    ws_port: 49142,277261   };278262   identity.device_type = deviceType;279263   return makeDeps({280264     openSocket: vi.fn(async () => makeFakeSocket([281-      enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: HELPER_EPHEMERAL_HEX }),265+      enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: PEER_EPHEMERAL_HEX }),282266       enc(identity),283267     ]).sock),284268   });285269 }286270 287271 describe("pairWithCode — pairing code", () => {288-  it("dials the address in the code instead of probing for a host", async () => {272+  it("dials the address in the code", async () => {289273     const { deps } = makePhoneDeps();290274     const { cb, errors } = makeCallbacks();291275 292276     await pairWithCode(PHONE_CODE, cb, deps, async () => true);293277 294278     expect(errors).toHaveLength(0);295-    expect(deps.findHost).not.toHaveBeenCalled();296279     expect(deps.openSocket).toHaveBeenCalledWith("192.168.4.77", 51555);297280   });298281 @@ -301,14 +284,14 @@301284     const { deps } = makeDeps({302285       openSocket: vi.fn(async () => {303286         const s = makeFakeSocket([304-          enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: HELPER_EPHEMERAL_HEX }),287+          enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: PEER_EPHEMERAL_HEX }),305288           enc({306289             type: "PAIR_IDENTITY",307290             device_id: "android-001",308291             device_name: "Pixel 8",309292             device_type: "android",310293             long_term_public_key_hex: PHONE_LONGTERM_HEX,311-            ws_port: 8765,294+            ws_port: 49142,312295           }),313296         ]);314297         sockets.push(s);@@ -336,17 +319,18 @@336319     expect(persisted[0].deviceId).toBe("android-001");337320     expect(persisted[0].longTermPublicKeyHex).toBe(PHONE_LONGTERM_HEX);338321     expect(persisted[0].trustSource).toBe("direct");339-    expect(persisted[0].wsPort).toBe(8765);322+    expect(persisted[0].wsPort).toBe(49142);340323     expect(completed).toHaveLength(1);341324   });342325 343-  it("falls back to 'android', never 'desktop', when the peer omits its type", async () => {326+  it("rejects a peer that omits its device type", async () => {344327     const { deps, persisted } = makePhoneDeps("");345-    const { cb } = makeCallbacks();328+    const { cb, errors } = makeCallbacks();346329 347330     await pairWithCode(PHONE_CODE, cb, deps, async () => true);348331 349-    expect(persisted[0].deviceType).toBe("android");332+    expect(errors).toEqual(["handshake_failed"]);333+    expect(persisted).toHaveLength(0);350334   });351335 352336   it("caches the coded IP for the next sync round", async () => {

view file

src/background/code-pairing.ts+13 −12
@@ -10,13 +10,11 @@1010 //   3. on confirm -> persist the PairedDevice; on reject -> abort without1111 //      persisting1212 //13-// Two code forms reach here. A pairing code (`A74H-48J3-KI32-OEP4`) is the14-// primary path: the phone listens and the code carries its address. A legacy15-// 6-digit helper code has no address, so the host is probed for.13+// A pairing code (`A74H-48J3-KI32-OEP4`) carries the listener's endpoint and14+// one-time token.1615 1716 import { dialPairingChannel, PAIRING_PORT, type PairingDialDeps } from "../shared/pairing-dial";1817 import type { LocalIdentity } from "../shared/pairing-handshake";19-import { parsePairingCode } from "../shared/pairing-code";2018 import type { DeviceType, PairedDevice } from "../shared/sync-types";2119 2220 /** Default sync port a peer announces; used when ws_port is absent. Same@@ -68,7 +66,13 @@6866   if (p?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY");6967   if (typeof p.device_id !== "string" || !p.device_id) throw new Error("device_id missing");7068   if (typeof p.device_name !== "string") throw new Error("device_name missing");71-  if (typeof p.device_type !== "string") throw new Error("device_type missing");69+  if (70+    p.device_type !== "extension" &&71+    p.device_type !== "android" &&72+    p.device_type !== "ios"73+  ) {74+    throw new Error("device_type invalid");75+  }7276   if (typeof p.long_term_public_key_hex !== "string" ||7377       !/^[0-9a-f]{64}$/.test(p.long_term_public_key_hex)) {7478     throw new Error("long_term_public_key_hex is not 32-byte hex");@@ -129,15 +133,12 @@129133       return;130134     }131135 132-    // ws_port comes from the peer frame, defaulting to 8765 (both peers133-    // announce it, but guard it). The device-type fallback follows the code134-    // form so a phone never lands in the registry labelled "desktop".136+    // ws_port comes from the authenticated peer frame and falls back to the137+    // native listener's fixed pairing/sync port for older peers.135138     const device: PairedDevice = {136139       deviceId: theirFrame.device_id,137140       deviceName: theirFrame.device_name,138-      deviceType:139-        (theirFrame.device_type as DeviceType) ||140-        (parsePairingCode(code) ? "android" : "desktop"),141+      deviceType: theirFrame.device_type,141142       longTermPublicKeyHex: theirFrame.long_term_public_key_hex,142143       signingPublicKeyHex: theirFrame.signing_public_key_hex,143144       trustSource: "direct",@@ -150,7 +151,7 @@150151     }151152     cb.onComplete(device);152153   } catch (err) {153-    if (typeof console !== "undefined") console.error("[desktop-pairing] handshake failed:", err);154+    if (typeof console !== "undefined") console.error("[code-pairing] handshake failed:", err);154155     cb.onError("handshake_failed");155156   } finally {156157     socket.close();

view file

src/background/desktop-pairing-mdns.test.ts+0 −99
@@ -1,99 +0,0 @@1-// Tests for mDNS discovery of the peach-sync desktop helper.2-3-import { describe, expect, it, vi } from "vitest";4-import {5-  discoverPeachSync,6-  parsePeachSyncService,7-  type MdnsApi,8-  type MdnsServiceRecord,9-} from "./desktop-pairing-mdns";10-11-const VALID_RECORD: MdnsServiceRecord = {12-  serviceName: "peach-sync-223e15d3._peach-sync._tcp.local",13-  serviceHostPort: "kyoshi.local:8765",14-  ipAddress: "192.168.4.230",15-  serviceData: ["version=0.4.0", "id=223e15d3", "sync-port=8765", "pair-port=49142"],16-};17-18-describe("parsePeachSyncService", () => {19-  it("maps a full peach-sync record to ip + both ports + version", () => {20-    expect(parsePeachSyncService(VALID_RECORD)).toEqual({21-      ip: "192.168.4.230",22-      syncPort: 8765,23-      pairPort: 49142,24-      version: "0.4.0",25-    });26-  });27-28-  it("ignores records for other service types", () => {29-    expect(30-      parsePeachSyncService({ ...VALID_RECORD, serviceName: "_googlecast._tcp.local" }),31-    ).toBeNull();32-  });33-34-  it("rejects a record with no usable IPv4 address", () => {35-    expect(parsePeachSyncService({ ...VALID_RECORD, ipAddress: undefined })).toBeNull();36-    expect(parsePeachSyncService({ ...VALID_RECORD, ipAddress: "kyoshi.local" })).toBeNull();37-  });38-39-  it("falls back to the SRV port and default pair port when TXT omits them", () => {40-    const svc = parsePeachSyncService({ ...VALID_RECORD, serviceData: [] });41-    expect(svc).toEqual({ ip: "192.168.4.230", syncPort: 8765, pairPort: 49142, version: undefined });42-  });43-44-  it("prefers the TXT sync-port over the SRV host:port", () => {45-    const svc = parsePeachSyncService({46-      ...VALID_RECORD,47-      serviceHostPort: "kyoshi.local:9999",48-      serviceData: ["sync-port=8765", "pair-port=49142"],49-    });50-    expect(svc?.syncPort).toBe(8765);51-  });52-});53-54-describe("discoverPeachSync", () => {55-  it("resolves null when chrome.mdns is unavailable", async () => {56-    await expect(discoverPeachSync({ mdns: null })).resolves.toBeNull();57-  });58-59-  it("resolves the first peach-sync service from a service list", async () => {60-    let captured: ((services: MdnsServiceRecord[]) => void) | null = null;61-    const removeListener = vi.fn();62-    const api: MdnsApi = {63-      onServiceList: {64-        addListener: (cb) => {65-          captured = cb;66-        },67-        removeListener,68-      },69-      forceDiscovery: (cb) => cb(),70-    };71-72-    const promise = discoverPeachSync({ mdns: api, timeoutMs: 1000 });73-    captured!([{ serviceName: "_other._tcp.local" }, VALID_RECORD]);74-75-    await expect(promise).resolves.toEqual({76-      ip: "192.168.4.230",77-      syncPort: 8765,78-      pairPort: 49142,79-      version: "0.4.0",80-    });81-    expect(removeListener).toHaveBeenCalledTimes(1);82-  });83-84-  it("resolves null and unsubscribes when nothing answers before the timeout", async () => {85-    vi.useFakeTimers();86-    try {87-      const removeListener = vi.fn();88-      const api: MdnsApi = {89-        onServiceList: { addListener: () => undefined, removeListener },90-      };91-      const promise = discoverPeachSync({ mdns: api, timeoutMs: 3000 });92-      await vi.advanceTimersByTimeAsync(3000);93-      await expect(promise).resolves.toBeNull();94-      expect(removeListener).toHaveBeenCalledTimes(1);95-    } finally {96-      vi.useRealTimers();97-    }98-  });99-});

view file at the parent

src/background/desktop-pairing-mdns.ts+0 −163
@@ -1,163 +0,0 @@1-// mDNS discovery for the peach-sync desktop helper.2-//3-// peach-sync announces `_peach-sync._tcp.local` on the LAN (PTR + SRV + TXT +4-// A). When Chrome exposes `chrome.mdns`, we discover the helper's IP and ports5-// instantly — no /24 subnet scan, no manual IP entry. The TXT record carries6-// both the sync port and the pairing port, so the browser learns everything7-// from one response.8-//9-// Why no raw-UDP fallback (brief Option B): `chrome.sockets.udp` is a Chrome10-// Apps API and is NOT available to MV3 extensions, so a hand-rolled multicast11-// query can't run here. Discovery therefore degrades to the subnet probe in12-// `desktop-pairing-bg.ts` whenever `chrome.mdns` is absent (notably Firefox).13-14-/** Service type peach-sync advertises (without the trailing `.local`). */15-export const PEACH_SYNC_SERVICE_TYPE = "_peach-sync._tcp";16-17-const DEFAULT_DISCOVERY_TIMEOUT_MS = 3000;18-/** Fallbacks if the TXT record omits a port (it shouldn't — peach-sync sends both). */19-const DEFAULT_SYNC_PORT = 8765;20-const DEFAULT_PAIR_PORT = 49142;21-22-export interface PeachSyncService {23-  ip: string;24-  syncPort: number;25-  pairPort: number;26-  version?: string;27-}28-29-/** Subset of a `chrome.mdns.MDnsService` entry we rely on. */30-export interface MdnsServiceRecord {31-  serviceName?: string;32-  serviceHostPort?: string;33-  ipAddress?: string;34-  serviceData?: string[];35-}36-37-/** Subset of the `chrome.mdns` API surface we use (injectable for tests). */38-export interface MdnsApi {39-  onServiceList: {40-    addListener(cb: (services: MdnsServiceRecord[]) => void): void;41-    removeListener(cb: (services: MdnsServiceRecord[]) => void): void;42-  };43-  forceDiscovery?: (cb: () => void) => void;44-}45-46-export interface DiscoverOptions {47-  timeoutMs?: number;48-  /**49-   * Injection seam for tests. When omitted, the real `chrome.mdns` is used.50-   * Pass `null` explicitly to simulate "API unavailable".51-   */52-  mdns?: MdnsApi | null;53-}54-55-function realMdnsApi(): MdnsApi | null {56-  const api = (globalThis as { chrome?: { mdns?: unknown } }).chrome?.mdns as57-    | MdnsApi58-    | undefined;59-  return api?.onServiceList?.addListener ? api : null;60-}61-62-function isIpv4(value: string): boolean {63-  const parts = value.split(".");64-  if (parts.length !== 4) return false;65-  return parts.every((p) => {66-    if (!/^\d{1,3}$/.test(p)) return false;67-    const n = Number(p);68-    return n >= 0 && n <= 255;69-  });70-}71-72-function parseTxt(serviceData: string[]): Record<string, string> {73-  const out: Record<string, string> = {};74-  for (const entry of serviceData) {75-    const eq = entry.indexOf("=");76-    if (eq <= 0) continue;77-    out[entry.slice(0, eq)] = entry.slice(eq + 1);78-  }79-  return out;80-}81-82-function portFromHostPort(hostPort: string | undefined): number | null {83-  if (!hostPort) return null;84-  const colon = hostPort.lastIndexOf(":");85-  if (colon < 0) return null;86-  return toPort(hostPort.slice(colon + 1));87-}88-89-function toPort(value: string): number | null {90-  const n = Number(value);91-  return Number.isInteger(n) && n > 0 && n <= 65535 ? n : null;92-}93-94-/**95- * Map a raw mDNS record to a `PeachSyncService`, or null if it isn't a usable96- * peach-sync advertisement (wrong service, or no IPv4 address to connect to).97- */98-export function parsePeachSyncService(rec: MdnsServiceRecord): PeachSyncService | null {99-  if (!rec.serviceName || !rec.serviceName.includes(PEACH_SYNC_SERVICE_TYPE)) return null;100-101-  // We need an IPv4 the WebSocket can dial; `.local` hostnames aren't reliably102-  // resolvable from a service worker.103-  const ip = rec.ipAddress ?? "";104-  if (!isIpv4(ip)) return null;105-106-  const txt = parseTxt(rec.serviceData ?? []);107-  const syncPort =108-    (txt["sync-port"] ? toPort(txt["sync-port"]) : null) ??109-    portFromHostPort(rec.serviceHostPort) ??110-    DEFAULT_SYNC_PORT;111-  const pairPort = (txt["pair-port"] ? toPort(txt["pair-port"]) : null) ?? DEFAULT_PAIR_PORT;112-113-  return { ip, syncPort, pairPort, version: txt["version"] };114-}115-116-/**117- * Discover the first peach-sync helper on the LAN via `chrome.mdns`.118- *119- * Resolves null when no helper answers within `timeoutMs`, or when `chrome.mdns`120- * is unavailable (the caller then falls back to the subnet probe).121- */122-export function discoverPeachSync(opts: DiscoverOptions = {}): Promise<PeachSyncService | null> {123-  const api = opts.mdns !== undefined ? opts.mdns : realMdnsApi();124-  if (!api) return Promise.resolve(null);125-  const timeoutMs = opts.timeoutMs ?? DEFAULT_DISCOVERY_TIMEOUT_MS;126-127-  return new Promise((resolve) => {128-    let done = false;129-    let timer: ReturnType<typeof setTimeout> | undefined;130-131-    const finish = (result: PeachSyncService | null) => {132-      if (done) return;133-      done = true;134-      if (timer !== undefined) clearTimeout(timer);135-      try {136-        api.onServiceList.removeListener(listener);137-      } catch {138-        // Listener already gone — fine.139-      }140-      resolve(result);141-    };142-143-    const listener = (services: MdnsServiceRecord[]) => {144-      for (const rec of services) {145-        const svc = parsePeachSyncService(rec);146-        if (svc) {147-          finish(svc);148-          return;149-        }150-      }151-    };152-153-    try {154-      api.onServiceList.addListener(listener);155-      api.forceDiscovery?.(() => undefined);156-    } catch {157-      finish(null);158-      return;159-    }160-161-    timer = setTimeout(() => finish(null), timeoutMs);162-  });163-}

view file at the parent

src/background/desktop-pairing-probe.ts+0 −68
@@ -1,68 +0,0 @@1-// Handshake-free /24 port probe for desktop pairing (Change 5).2-//3-// Unlike `probeSubnetForPeer` (sync-transport.ts), this does NOT run4-// Noise — during code pairing we have no long-term pubkey, and the pairing5-// server on port 49142 speaks the plaintext PEACH_HELLO protocol, not Noise6-// XX. This probe answers only "is a WebSocket server reachable here?"; the7-// 6-digit code + user-confirmed SAS are the actual trust boundary.8-9-const DEFAULT_PROBE_TIMEOUT_MS = 500;10-const DEFAULT_PROBE_BATCH = 16;11-12-export interface PortProbeOptions {13-  batch?: number;14-  timeoutMs?: number;15-  /** Injection seam for tests — defaults to the real WebSocket open check. */16-  probeOne?: (ip: string, port: number, timeoutMs: number) => Promise<boolean>;17-}18-19-/**20- * Scan `subnet` (e.g. "192.168.1") for the first host accepting a WebSocket21- * connection on `port`. Returns the IP string, or null if none answered.22- */23-export async function probeSubnetForPort(24-  subnet: string,25-  port: number,26-  options: PortProbeOptions = {},27-): Promise<string | null> {28-  const batchSize = options.batch ?? DEFAULT_PROBE_BATCH;29-  const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;30-  const probe = options.probeOne ?? wsPortOpen;31-32-  for (let start = 1; start <= 254; start += batchSize) {33-    const end = Math.min(start + batchSize - 1, 254);34-    const ips: string[] = [];35-    for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);36-37-    const results = await Promise.all(38-      ips.map(async (ip) => ((await probe(ip, port, timeoutMs)) ? ip : null)),39-    );40-    const hit = results.find((r): r is string => r !== null);41-    if (hit) return hit;42-  }43-  return null;44-}45-46-/** True if a WebSocket to ws://ip:port/ reaches onopen within timeoutMs. */47-export function wsPortOpen(ip: string, port: number, timeoutMs: number): Promise<boolean> {48-  return new Promise((resolve) => {49-    let ws: WebSocket;50-    let settled = false;51-    const done = (ok: boolean) => {52-      if (settled) return;53-      settled = true;54-      try { ws.close(); } catch { /* ignore */ }55-      resolve(ok);56-    };57-    try {58-      ws = new WebSocket(`ws://${ip}:${port}/`);59-    } catch {60-      resolve(false);61-      return;62-    }63-    const t = setTimeout(() => done(false), timeoutMs);64-    ws.onopen = () => { clearTimeout(t); done(true); };65-    ws.onerror = () => { clearTimeout(t); done(false); };66-    ws.onclose = () => { clearTimeout(t); done(false); };67-  });68-}

view file at the parent

src/background/device-identity.ts+1 −1
@@ -73,7 +73,7 @@7373 7474 /**7575  * Build the public-facing identity record for the local extension. The76- * private key half (used only for ongoing Noise XX sessions) is not76+ * private key half (used only for ongoing Noise NK sessions) is not7777  * exposed by this function — Phase-1 pairing uses ephemeral keys only.7878  */7979 export async function buildLocalIdentity(

view file

src/background/messages.ts+4 −21
@@ -1428,14 +1428,15 @@14281428         return { ok: true };14291429       }14301430 1431-      // ── Desktop helper code pairing (peach-sync) ──────────────────────────1431+      // ── Device code pairing ───────────────────────────────────────────────14321432       // Not Pro-gated (a free device may pair to receive Pro/sync). The whole NK14331433       // pairing runs in the background; the SAS and the terminal result reach the14341434       // popup as CODE_PAIR_SAS / CODE_PAIR_RESULT pushes.14351435       case "PAIR_WITH_CODE": {14361436         if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };14371437         const { code } = (msg.payload ?? {}) as { code?: string };1438-        if (typeof code !== "string" || !/\d/.test(code)) {1438+        const { parsePairingCode } = await import("../shared/pairing-code");1439+        if (typeof code !== "string" || parsePairingCode(code) === null) {14391440           return { ok: false, error: "invalid_payload" };14401441         }14411442         const { startCodePairing, isCodePairingInFlight } = await import(@@ -1455,28 +1456,10 @@14551456 14561457       case "TRIGGER_SYNC": {14571458         const payload = (msg.payload ?? {}) as {1458-          reason?: "vault_change" | "foreground" | "manual" | "mdns";1459+          reason?: "vault_change" | "foreground" | "manual";14591460           force?: boolean;1460-          deviceId?: string;1461-          discoveredIp?: string;1462-          discoveredPort?: number;14631461         };14641462         const reason = payload.reason ?? "foreground";1465-1466-        // mDNS discovery delivers a fresh (ip, port) resolved by the native1467-        // side. Update the cached address before the sync runner tries to1468-        // connect — avoids a subnet probe on the very next round.1469-        if (1470-          reason === "mdns" &&1471-          typeof payload.deviceId === "string" &&1472-          typeof payload.discoveredIp === "string"1473-        ) {1474-          const { findPairedDeviceById, setLastKnownIp } = await import("./paired-devices");1475-          const device = await findPairedDeviceById(payload.deviceId);1476-          if (device?.longTermPublicKeyHex) {1477-            await setLastKnownIp(device.longTermPublicKeyHex, payload.discoveredIp).catch(() => undefined);1478-          }1479-        }14801463 14811464         const { triggerSyncIfReachable } = await import("./sync-trigger");14821465         void triggerSyncIfReachable({ reason, force: payload.force === true });

view file

src/background/paired-devices.ts+2 −2
@@ -17,7 +17,7 @@1717 // The registry has only ever lived in `chrome.storage.local` for released1818 // builds, so no cross-profile evacuation path is needed — the public keys1919 // never leave the device via a third-party sync back-end. Unpairing is20-// local-only; Noise XX rejection on the next connection is the security20+// local-only; Noise NK rejection on the next connection is the security2121 // boundary (see removePairedDeviceById).2222 2323 import type { PairedDevice } from "../shared/sync-types";@@ -84,7 +84,7 @@8484   const devices = await listPairedDevices();8585   const filtered = devices.filter((d) => d.deviceId !== deviceId);8686   // Unpairing is local-only — there is no remote unpair RPC. The remote87-  // device retains this device in its own registry. Noise XX rejection on87+  // device retains this device in its own registry. Noise NK rejection on8888   // the next connection attempt is the security boundary.8989   await chrome.storage.local.set({ [REGISTRY_KEY]: filtered });9090   // Also clear the per-network IP cache for this device.

view file

src/background/sync-runner.test.ts+7 −7
@@ -42,7 +42,7 @@4242     deviceName: "Pixel",4343     deviceType: "android",4444     longTermPublicKeyHex: REMOTE_PUBKEY_HEX,45-    wsPort: 8765,45+    wsPort: 49142,4646     pairedAt: 1700000000000,4747     ...overrides,4848   };@@ -239,7 +239,7 @@239239     const result = await runPromise;240240     expect(result).toEqual({ ok: true, entriesReceived: 0, entriesSent: 0 });241241     expect(openSession).toHaveBeenCalledTimes(1);242-    expect(openSession.mock.calls[0][0]).toEqual({ ip: "192.168.1.42", port: 8765 });242+    expect(openSession.mock.calls[0][0]).toEqual({ ip: "192.168.1.42", port: 49142 });243243     expect(probeSubnet).not.toHaveBeenCalled();244244   });245245 @@ -252,7 +252,7 @@252252     const openSession = vi.fn()253253       .mockRejectedValueOnce(new Error("ECONNREFUSED"))254254       .mockResolvedValueOnce(session);255-    const probeSubnet = vi.fn(async () => ({ ip: "192.168.1.99", port: 8765 }));255+    const probeSubnet = vi.fn(async () => ({ ip: "192.168.1.99", port: 49142 }));256256     const deps: SyncRunnerDeps = {257257       openSession,258258       probeSubnet,@@ -265,7 +265,7 @@265265     await runPeer(peer);266266     await runPromise;267267     expect(openSession).toHaveBeenCalledTimes(2);268-    expect(openSession.mock.calls[1][0]).toEqual({ ip: "192.168.1.99", port: 8765 });268+    expect(openSession.mock.calls[1][0]).toEqual({ ip: "192.168.1.99", port: 49142 });269269     expect(probeSubnet).toHaveBeenCalledTimes(1);270270     expect(await getLastKnownIp(REMOTE_PUBKEY_HEX)).toBe("192.168.1.99");271271   });@@ -303,7 +303,7 @@303303     const openSession = vi.fn(async () => session);304304     const deps: SyncRunnerDeps = {305305       openSession,306-      probeSubnet: vi.fn(async () => ({ ip: "192.168.1.1", port: 8765 })),306+      probeSubnet: vi.fn(async () => ({ ip: "192.168.1.1", port: 49142 })),307307       getSubnet: vi.fn(async () => "192.168.1"),308308       createAdapter: emptyAdapter,309309     };@@ -333,7 +333,7 @@333333     };334334     const deps: SyncRunnerDeps = {335335       openSession: vi.fn(async () => session),336-      probeSubnet: vi.fn(async () => ({ ip: "192.168.1.1", port: 8765 })),336+      probeSubnet: vi.fn(async () => ({ ip: "192.168.1.1", port: 49142 })),337337       getSubnet: vi.fn(async () => "192.168.1"),338338       createAdapter: () => adapter,339339     };@@ -350,7 +350,7 @@350350     const { session, peer } = inMemoryPair();351351     const deps: SyncRunnerDeps = {352352       openSession: vi.fn(async () => session),353-      probeSubnet: vi.fn(async () => ({ ip: "192.168.1.1", port: 8765 })),353+      probeSubnet: vi.fn(async () => ({ ip: "192.168.1.1", port: 49142 })),354354       getSubnet: vi.fn(async () => "192.168.1"),355355       createAdapter: emptyAdapter,356356     };

view file

src/background/sync-static.test.ts+0 −1
@@ -74,7 +74,6 @@7474 7575   it("the browser never listens — no pairing server or audio-chirp module survives", () => {7676     const appSrc = src("../popup/App.tsx");77-    expect(appSrc).not.toContain("startMDNSListener");7877     expect(appSrc).not.toContain("openPairingServer");7978     expect(appSrc).not.toContain("chirp-pair-capacitor");8079     // Adoption dials out like everything else now.

view file

src/background/sync-trigger.desktop.test.ts+0 −127
@@ -1,127 +0,0 @@1-// Change 6 — desktop peers participate in sync sessions.2-//3-// Relocated from drafts/DESKTOP-PAIRING/06-sync-coordinator-desktop.md.4-// Spec 1 asserts the host filter (sync-trigger.ts:104) includes "desktop".5-// Spec 2 asserts the browser's SYNC_HELLO carries a vaultId equal to the6-// desktop helper's stored vault_id (== computeVaultId of the same master key),7-// which is what peach-sync compares byte-for-byte (sync_coordinator.zig:141).8-9-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";10-11-import {12-  triggerSyncIfReachable,13-  setSyncRunner,14-  _resetSyncTriggerForTest,15-} from "./sync-trigger";16-import { upsertPairedDevice } from "./paired-devices";17-import { computeVaultId } from "../shared/sync-identity";18-import { runSyncInitiator, type SyncVaultAdapter } from "./sync-coordinator";19-import type { SyncSession } from "../shared/sync-transport";20-import type { SyncMessage } from "../shared/sync-types";21-import type { PairedDevice } from "../shared/sync-types";22-23-// Minimal chrome.storage.local fake — paired-devices and sync-trigger both read24-// through it. Re-initialised each beforeEach so state never leaks across specs.25-function makeStorageStub(): void {26-  const store: Record<string, unknown> = {};27-  (globalThis as unknown as { chrome: unknown }).chrome = {28-    storage: {29-      local: {30-        get: async (keys?: string | string[]) => {31-          if (!keys) return { ...store };32-          const list = Array.isArray(keys) ? keys : [keys];33-          const out: Record<string, unknown> = {};34-          for (const k of list) if (k in store) out[k] = store[k];35-          return out;36-        },37-        set: async (obj: Record<string, unknown>) => { Object.assign(store, obj); },38-        remove: async (keys: string | string[]) => {39-          for (const k of Array.isArray(keys) ? keys : [keys]) delete store[k];40-        },41-      },42-    },43-    runtime: { lastError: undefined },44-  };45-}46-47-const MASTER = Uint8Array.from(Array.from({ length: 32 }, (_, i) => (i * 7 + 1) & 0xff));48-49-const DESKTOP: PairedDevice = {50-  deviceId: "desktop-001",51-  deviceName: "June's iMac",52-  deviceType: "desktop",53-  longTermPublicKeyHex: "a".repeat(64),54-  trustSource: "direct",55-  wsPort: 8765,56-  pairedAt: 1700000000000,57-};58-59-beforeEach(() => {60-  makeStorageStub();61-  _resetSyncTriggerForTest();62-});63-64-afterEach(() => {65-  vi.restoreAllMocks();66-});67-68-describe("Change 6 — desktop peers in sync", () => {69-  it("desktop peer is included in sync session attempts", async () => {70-    await upsertPairedDevice(DESKTOP);71-    const seen: string[] = [];72-    setSyncRunner(async (device) => {73-      seen.push(device.deviceId);74-      return { ok: true, entriesReceived: 0, entriesSent: 0 };75-    });76-77-    await triggerSyncIfReachable({ reason: "manual" });78-79-    // Before Change 6, the filter (sync-trigger.ts:104) only matched80-    // android|ios, so the runner was never called for a desktop host.81-    expect(seen).toEqual([DESKTOP.deviceId]);82-  });83-84-  it("SYNC_HELLO vaultId matches the desktop helper's stored vault_id", async () => {85-    // The helper stores exactly the vault_id the browser sent at pairing86-    // (peach-sync pairing.zig:212), which is computeVaultId(masterKey). At87-    // sync time the browser sends SYNC_HELLO { vaultId: computeVaultId(...) }.88-    // peach-sync compares the two byte-for-byte (sync_coordinator.zig:141).89-    const ourVaultId = await computeVaultId(MASTER);90-91-    // Capture frames the coordinator sends; reply just enough to reach the92-    // equal-version SYNC_DONE shortcut so the round completes cleanly.93-    const sent: SyncMessage[] = [];94-    const inbound: SyncMessage[] = [95-      { type: "SYNC_HELLO", vaultId: ourVaultId, deviceId: "desktop-001", version: 7, deviceTime: Date.now() },96-      { type: "SYNC_DONE", entriesReceived: 0, entriesSent: 0 },97-    ];98-    let rx = 0;99-    const session: SyncSession = {100-      send: (bytes: Uint8Array) => { sent.push(JSON.parse(new TextDecoder().decode(bytes))); },101-      receive: async () => new TextEncoder().encode(JSON.stringify(inbound[rx++])),102-      close: async () => {},103-    } as unknown as SyncSession;104-105-    const adapter: SyncVaultAdapter = {106-      computeVaultId: async () => ourVaultId,107-      getOurDeviceId: async () => "ext-local",108-      getLocalVersion: async () => 7, // equal version → SYNC_DONE shortcut109-      buildEntryManifest: async () => ({}),110-      buildTombstoneManifest: async () => ({}),111-      getEncryptedEntries: async () => ({}),112-      applyEntries: async () => {},113-      applyTombstones: async () => {},114-    };115-116-    const outcome = await runSyncInitiator(session, adapter);117-118-    const hello = sent.find((m) => m.type === "SYNC_HELLO") as119-      | { type: "SYNC_HELLO"; vaultId: string }120-      | undefined;121-    expect(hello).toBeTruthy();122-    // This is the exact value peach-sync compares against opts.vault_id.123-    expect(hello!.vaultId).toBe(ourVaultId);124-    expect(hello!.vaultId).toMatch(/^[0-9a-f]{64}$/);125-    expect(outcome.status).toBe("completed");126-  });127-});

view file at the parent

src/background/sync-trigger.ts+4 −6
@@ -2,8 +2,8 @@22 //33 // SEV-1-A fix: the sync coordinator + transport were complete but44 // never invoked from production code paths. This module is the single5-// entry point for the four trigger conditions (vault change, popup6-// foreground, manual "Sync Now", and mDNS peer discovery) and is the5+// entry point for the three trigger conditions (vault change, popup6+// foreground, and manual "Sync Now") and is the77 // only place that calls `runSyncInitiator` outside tests.88 //99 // The actual per-device session runner is injected so the orchestrator@@ -19,7 +19,7 @@1919 } from "./paired-devices";2020 import type { PairedDevice } from "../shared/sync-types";2121 22-export type SyncTriggerReason = "vault_change" | "foreground" | "manual" | "mdns";22+export type SyncTriggerReason = "vault_change" | "foreground" | "manual";2323 2424 export type SyncRunStatus = "idle" | "syncing" | "error";2525 @@ -111,9 +111,7 @@111111     // pairing code. Excluding it left confirmed devices permanently unsynced.112112     const hosts = devices.filter(113113       (d) =>114-        (d.deviceType === "android" ||115-          d.deviceType === "ios" ||116-          d.deviceType === "desktop") &&114+        (d.deviceType === "android" || d.deviceType === "ios") &&117115         (d.trustSource === "direct" || d.trustSource === "vouched"),118116     );119117     if (hosts.length === 0) return;

view file

src/background/sync-vault-adapter.ts+2 −2
@@ -13,7 +13,7 @@1313 // into another: decryptEntryOrNull rejects any envelope whose decrypted1414 // entry.id does not match the map key it arrived under. Both sides hold the1515 // same syncMasterKey (same vault) so keys match without prior negotiation. The16-// Noise XX session encrypts the transport on top — the envelope is16+// Noise NK session encrypts the transport on top — the envelope is1717 // belt-and-braces so a transport-layer leak doesn't expose plaintext entries.1818 //1919 // Device-manifest exchange (v2 brief §4.3) is plumbed here. The extension is@@ -363,7 +363,7 @@363363   if (!isUnlocked()) throw new VaultLockedError();364364 }365365 366-// Sync derives everything — vault_id, the Noise XX static key, the Ed25519366+// Sync derives everything — vault_id, the Noise NK static key, the Ed25519367367 // manifest signing key, and the per-entry envelope keys — from the vault's368368 // stable root key, NOT the password-derived key. This keeps paired devices'369369 // identities and envelope keys constant across a master-password change on

view file

src/background/vault.rotate-sync-identity.test.ts+1 −1
@@ -118,7 +118,7 @@118118 119119   // T-016c: paired devices are cleared after rotation.120120   // After rotation, the old paired devices must no longer appear in storage —121-  // their Noise XX static keys are derived from the old root key and cannot121+  // their Noise NK static keys are derived from the old root key and cannot122122   // be used to authenticate against the new sync identity.123123   //124124   // Bug this catches: forgetting to call clearPairedDevices() after rotation

view file

src/background/vault.ts+2 −2
@@ -1576,7 +1576,7 @@15761576  * change — so it is INVARIANT across password rotation, unlike getDerivedKeyHex.15771577  *15781578  * This is the correct input for sync identity derivation (vault_id, the Noise1579- * XX static key, the Ed25519 manifest signing key, and per-entry envelope1579+ * NK static key, the Ed25519 manifest signing key, and per-entry envelope15801580  * keys): paired devices keep the same identity and can still decrypt each15811581  * other's entries after either side rotates its master password (SEV-2-B).15821582  *@@ -2902,7 +2902,7 @@29022902 /**29032903  * Rotate the vault's sync identity by generating a FRESH ROOT KEY and29042904  * re-wrapping everything under it (MV-8). Because every sync credential —2905- * vault_id, the Noise XX static keypair, the Ed25519 manifest signing key,2905+ * vault_id, the Noise NK static keypair, the Ed25519 manifest signing key,29062906  * and the per-entry envelope keys — derives from the root key, rotation29072907  * makes a lost/stolen device's retained key material useless. All paired29082908  * devices must re-pair afterwards.

view file

src/drafts/PCH-02-FIXES/INTEGRATION-CONTRACT.md+4 −22
@@ -13,18 +13,14 @@1313 | `INTEGRATION-CONTRACT.md` | this file — keep as reference (cited by tests) |1414 | `PCH-02-001-vault-patch-sanitizers.patch.md` | edits → `src/background/vault.ts:2375-2388` |1515 | `vault.update-entry-sanitize.test.ts` | → `src/background/vault.update-entry-sanitize.test.ts` |16-| `PCH-02-002-noise-xx-msg1-hash.patch.md` | doc part → `src/shared/noise-js.ts:11-14` + `src/shared/noise-xx.ts:17`; wire part **deferred** |1716 | `PCH-02-003-sas-trust-model-comment.patch.md` | edits → `src/shared/chirp-handshake.ts:125-154` |1817 | `PCH-02-004-exact-pin-deps.patch.md` | edits → `package.json:40-46`; then `bun install` |1918 | `WASM-PROVENANCE.md` | → repo root `WASM-PROVENANCE.md` (new file) |2019 | `PCH-02-005-legacy-pbkdf2-doc.patch.md` | edits → `src/shared/backup.ts:146` + `src/popup/views/Import.tsx` (3 hunks) |21-| `PCH-02-006-vault-id-privacy-note.patch.md` | edits → `src/shared/sync-identity.ts:62-67` |2220 | `PCH-02-007-hibp-add-padding.patch.md` | edits → `src/background/vault.ts:230` |2321 | `vault.hibp-padding.test.ts` | → `src/background/vault.hibp-padding.test.ts` |2422 | `PCH-02-008-binaryreader-bounds.patch.md` | edits → `src/crypto/peachPayload.ts:940-969` (+ `export` seam) |2523 | `peachPayload.binaryreader-bounds.test.ts` | → `src/crypto/peachPayload.binaryreader-bounds.test.ts` |26-| `PCH-02-009-reject-zero-dh.patch.md` | edits → `src/shared/noise-xx.ts` (6 sites) + `src/shared/noise-js.ts` (6 sites) |27-| `noise.reject-zero-dh.test.ts` | → `src/shared/noise.reject-zero-dh.test.ts` |2824 | `PCH-02-010-clipboard-hash-match.patch.md` | **Option A (recommended):** comment → `src/background/clipboard.ts:18-19`. **Option B:** `clipboard.ts` + `offscreen.ts` + `manifest.json` |2925 | `clipboard.hash-match.test.ts` | → `src/background/clipboard.hash-match.test.ts` — **Option B ONLY; do not integrate under Option A** |3026 @@ -36,18 +32,6 @@3632 2. **PCH-02-004 papaparse hunk.** The brief pins 5 packages; the report also3733    cites `papaparse`. The patch includes it as an optional sixth line.3834 39-### ⚠ PCH-02-002 wire-change coordination (MUST land with Android)40-41-The Noise XX msg-1 empty-payload `mixHash` changes every XX handshake42-ciphertext from msg 2 onward. **Do not apply the wire part** until the43-matching peach-android change ships in the same release (PCH-01-002 /44-v3.9.1-android-v2.2.9 model). What lands now: the deferral comment in45-`noise-xx.ts`, the corrected `snow`-interop claim in `noise-js.ts`, and the46-ready-to-apply patch staying in this folder. When the window opens: apply the47-two `mixHash(new Uint8Array(0))` insertions, regenerate the XX vectors in48-`noise-xx.test.ts`/`noise-blake2s-kat.test.ts`, and regenerate the matching49-Android `CrossPlatformKATTest` vectors.50-5135 ### PCH-02-004 exact versions (read from bun.lock 2026-06-12 — pin, don't upgrade)5236 5337 | Package | Pin |@@ -67,11 +51,9 @@6751 | Finding | New tests |6852 |---|---|6953 | PCH-02-001 | 8 |70-| PCH-02-002 | 0 now (KAT regeneration when the wire change lands) |7154 | PCH-02-003 | 0 — doc only (gateOnSAS already pinned by PCH-01-024 test) |7255 | PCH-02-004 | 0 — dependency metadata only |7356 | PCH-02-005 | 0 required (1 optional UI contract case sketched in the patch) |74-| PCH-02-006 | 0 — doc only |7557 | PCH-02-007 | 3 |7658 | PCH-02-008 | 8 |7759 | PCH-02-009 | 5 |@@ -80,14 +62,14 @@8062 8163 ### Documentation-only findings (no tests needed)8264 83-PCH-02-003, PCH-02-005 (comment + notice), PCH-02-006, PCH-02-002 (the part84-landable now), PCH-02-010 under Option A.65+PCH-02-003, PCH-02-005 (comment + notice), and PCH-02-010 under66+Option A.8567 8668 ### Suggested integration order (one koh save each, tests green at every step)8769 8870 1. PCH-02-001 (the Low) + its tests89-2. PCH-02-007, PCH-02-008, PCH-02-009 + tests (independent hardening)90-3. PCH-02-003, PCH-02-005, PCH-02-006 + PCH-02-002 doc part (comments/UI)71+2. PCH-02-007 and PCH-02-008 + tests (independent hardening)72+3. PCH-02-003 and PCH-02-005 (comments/UI)9173 4. PCH-02-004 (package.json + `bun install` + WASM-PROVENANCE.md)9274 5. PCH-02-010 (whichever option is chosen)9375 6. Version bump + build all three targets (`npm run build`)

view file

src/drafts/PCH-02-FIXES/PCH-02-002-noise-xx-msg1-hash.patch.md+0 −132
@@ -1,132 +0,0 @@1-# PCH-02-002 — Noise XX message 1 empty-payload mixHash2-3-**Severity:** Informational4-**Target file:** `src/shared/noise-xx.ts:93-97` (initiator), `184-191` (responder)5-**Also touched:** `src/shared/noise-js.ts:11-14` (doc comment correction — landable NOW)6-**Report:** peach-code-review.md, Finding PCH-02-0027-8-## Decision: DEFER the wire change to the Android coordination window9-10-Per the fix brief's own escape hatch ("if both sides already skip it11-consistently and the practical risk is only interop … defer the wire change to12-the Android coordination window. Do not implement a half-fix.") — that is13-exactly the situation, verified by a complete read of `noise-xx.ts`:14-15-- Both the initiator (msg 1 send, lines 93–96) and the responder (msg 116-  receive, lines 188–191) skip the canonical `MixHash(empty payload)` —17-  **consistently**. Every Peach↔Peach and Peach↔Android XX handshake computes18-  an identical transcript hash `h`.19-- Transport keys derive from the chaining key `ck`, not from `h`; the20-  handshake hash still binds every byte both sides actually exchanged. There21-  is no confidentiality/authenticity weakening — only non-interop with stock22-  Noise stacks (e.g. `snow`), which Peach explicitly does not rely on23-  (`noise-js.ts:5-9`: "interop holds by porting discipline, not by a shared24-  library").25-- Adding the `mixHash(empty)` changes `h` after message 1, which changes the26-  AAD of every subsequent handshake AEAD → **every ciphertext from msg 227-  onward changes**. peach-android must apply the identical change in the same28-  release or all XX sync sessions fail to handshake. This is the same class29-  of coordinated breaking change as PCH-01-002 (BLAKE2s), which shipped in30-  lockstep as browser v3.9.1 / android v2.2.9.31-32-Implementing the browser side alone would be the forbidden half-fix.33-34-## Ready-to-apply patch (apply ONLY in the coordinated window)35-36-Initiator — `noise-xx.ts`, message 1 block:37-38-```typescript39-// BEFORE (lines 93-96)40-  // --- Message 1: -> e ---41-  const e = generateKeyPair();42-  h = mixHash(h, e.publicKey);43-  await send(e.publicKey);44-45-// AFTER46-  // --- Message 1: -> e ---47-  const e = generateKeyPair();48-  h = mixHash(h, e.publicKey);49-  // PCH-02-002: canonical Noise ends every message with EncryptAndHash(payload);50-  // with no symmetric key yet, an empty msg-1 payload is MixHash(empty). The51-  // wire bytes are unchanged (empty payload), only the transcript hash moves.52-  // BREAKING transcript change — ships in lockstep with peach-android.53-  h = mixHash(h, new Uint8Array(0));54-  await send(e.publicKey);55-```56-57-Responder — `noise-xx.ts`, message 1 block:58-59-```typescript60-// BEFORE (lines 187-191)61-  // --- Message 1: <- e ---62-  const msg1 = await receive();63-  if (msg1.length < 32) throw new Error("Noise XX: msg1 too short");64-  const remoteEphemeral = msg1.slice(0, 32);65-  h = mixHash(h, remoteEphemeral);66-67-// AFTER68-  // --- Message 1: <- e ---69-  const msg1 = await receive();70-  if (msg1.length < 32) throw new Error("Noise XX: msg1 too short");71-  const remoteEphemeral = msg1.slice(0, 32);72-  h = mixHash(h, remoteEphemeral);73-  // PCH-02-002: mirror the initiator's empty-payload MixHash (see above).74-  h = mixHash(h, new Uint8Array(0));75-```76-77-The NK pattern (`noise-js.ts`) is already canonical — its msg-1 payload is a78-real AEAD seal because `es` establishes a key before the payload step79-(`noise-js.ts:84-91`); no change there.80-81-### Test impact when the wire change lands82-83-- `src/shared/noise-xx.test.ts` and `src/shared/noise-blake2s-kat.test.ts`:84-  any pinned XX handshake-hash / transcript vectors must be regenerated (the85-  NK vectors are unaffected). Initiator↔responder round-trip tests pass86-  unchanged (both sides move together).87-- Cross-repo: regenerate the XX vectors in peach-android88-  `CrossPlatformKATTest` to the same values, exactly as PCH-01-002 did.89-90-## Landable NOW (no wire impact): doc comment correction in noise-js.ts91-92-The header claim at `noise-js.ts:12` — "A stock `snow` stack would93-interoperate" — is true for NK but over-reads for XX. Correct it so the claim94-cannot mislead a future maintainer:95-96-```typescript97-// BEFORE (noise-js.ts:11-14)98- * PCH-01-002: this implementation derives `h` and HKDF over BLAKE2s to match the99- * named pattern. A stock `snow` stack would interoperate; the Android port must100- * mirror the same hash choices. Any change to the hash, HKDF, or transcript here101- * is a BREAKING wire change and requires a coordinated Android release.102-103-// AFTER104- * PCH-01-002: this implementation derives `h` and HKDF over BLAKE2s to match the105- * named pattern. A stock `snow` stack would interoperate with THIS NK pattern;106- * the XX implementation (noise-xx.ts) omits the canonical msg-1 empty-payload107- * MixHash (PCH-02-002, deferred to an Android-coordinated release), so stock108- * stacks would NOT interoperate with Peach XX until that lands. The Android port109- * must mirror the same hash choices. Any change to the hash, HKDF, or transcript110- * here is a BREAKING wire change and requires a coordinated Android release.111-```112-113-And pin the deferral in `noise-xx.ts` so the omission is visible at the site:114-115-```typescript116-// Insert after noise-xx.ts:17 (end of the header comment block):117-//118-// PCH-02-002 (DEFERRED — Android-coordinated wire change): message 1 omits the119-// canonical empty-payload MixHash. Both Peach sides skip it identically, so120-// Peach↔Peach/Android transcripts agree; only interop with stock Noise stacks121-// is affected. The ready-to-apply fix lives in122-// src/drafts/PCH-02-FIXES/PCH-02-002-noise-xx-msg1-hash.patch.md and MUST land123-// in the same release as the matching peach-android change (PCH-01-002 model).124-```125-126-## Save message (integration, comment-only part)127-128-`docs: PCH-02-002 pin Noise XX msg1 empty-payload mixHash deferral — wire change requires Android coordination; correct noise-js snow-interop claim`129-130-## Save message (coordinated window, wire part)131-132-`fix: PCH-02-002 Noise XX message 1 empty-payload mixHash — NOTE: wire change, ships in lockstep with peach-android`

view file at the parent

src/drafts/PCH-02-FIXES/PCH-02-003-sas-trust-model-comment.patch.md+1 −1
@@ -39,7 +39,7 @@3939 // MUST surface and enforce the identical SAS gate before it persists the peer.4040 // If the app skips or auto-confirms the SAS, the channel is only as strong as4141 // the app's weakest end — confirm the app-side gate against the Android source42-// at kepr.uk/peach-android (verify the NoiseXX/NK SAS enforcement before any42+// at kepr.uk/peach-android (verify the Noise NK SAS enforcement before any4343 // device is persisted). A MITM who intercepts the acoustic chirp and completes4444 // the handshake against a non-enforcing peer can pair without user awareness.4545 ```

view file

src/drafts/PCH-02-FIXES/PCH-02-006-vault-id-privacy-note.patch.md+0 −48
@@ -1,48 +0,0 @@1-# PCH-02-006 — vault_id LAN-linkability privacy note (doc-only)2-3-**Severity:** Informational4-**Target file:** `src/shared/sync-identity.ts:62-67` (`computeVaultId` docblock)5-**Report:** peach-code-review.md, Finding PCH-02-0066-7-## Decision8-9-Documentation only. The property is inherent to peer discovery (peers must10-recognise "same vault" before exchanging entries); the fix is to state the11-privacy consequence where the identifier is minted, with the existing12-mitigation pointers.13-14-## Exact edit — extend the computeVaultId docblock15-16-```typescript17-// BEFORE (sync-identity.ts:62-67)18-/**19- * Stable per-vault identifier carried in the mDNS TXT record and the20- * first WebSocket handshake. Receiver rejects the connection if its21- * own vault_id differs — prevents a paired-but-different-vault peer22- * from corrupting the registry.23- */24-25-// AFTER26-/**27- * Stable per-vault identifier carried in the mDNS TXT record and the28- * first WebSocket handshake. Receiver rejects the connection if its29- * own vault_id differs — prevents a paired-but-different-vault peer30- * from corrupting the registry.31- *32- * PRIVACY NOTE (PCH-02-006): vault_id is stable across sessions and is33- * broadcast over mDNS during LAN sync discovery. A passive observer on the34- * same network can therefore (a) link every device sharing this vault as35- * belonging to one user, and (b) observe when sync activity occurs. This is36- * inherent to the discovery design and is one-way — the master key is not37- * recoverable from the BLAKE3 output. It never leaves the local network.38- * Users who need LAN unlinkability should leave sync disabled; "rotate sync39- * identity" (vault.ts rotateSyncIdentity) re-keys the root key and with it40- * this identifier.41- */42-```43-44-No tests — comment-only change.45-46-## Save message (integration)47-48-`docs: PCH-02-006 document vault_id LAN-linkability privacy note at computeVaultId`

view file at the parent

src/drafts/PCH-02-FIXES/PCH-02-009-reject-zero-dh.patch.md+0 −78
@@ -1,78 +0,0 @@1-# PCH-02-009 — Reject zero X25519 DH outputs in Noise XX and NK2-3-**Severity:** Informational4-**Target files:** `src/shared/noise-xx.ts` (6 DH sites),5-`src/shared/noise-js.ts` (6 DH sites)6-**Report:** peach-code-review.md, Finding PCH-02-0097-8-## Implementation choice (simpler than the brief's sample)9-10-`@stablelib/x25519`'s `sharedKey` already has the exact guard built in:11-`sharedKey(mySecretKey, theirPublicKey, rejectZero = false)` — with12-`rejectZero: true` it throws `"X25519: invalid shared key"` on an all-zero13-output (verified in `node_modules/@stablelib/x25519/lib/x25519.js:606-624`).14-Passing `true` at every call site is equivalent to the brief's manual15-`every(b => b === 0)` check, applies to the raw 32-byte output before it16-reaches `mixKey`/HMAC, and adds no new code path to maintain.17-18-**Not a wire change.** Honest peers never produce an all-zero shared secret19-(the probability for random keys is negligible; only adversarially chosen20-low-order/identity points do). Each side can adopt the check independently —21-no Android coordination required, though mirroring it there is recommended.22-23-## Exact edits — all 12 DH call sites24-25-`noise-xx.ts` — add a rationale comment at the first site, `, true` at all six:26-27-```typescript28-// Line 108 (initiator, ee):29-// BEFORE30-  const ee = sharedKey(e.secretKey, remoteEphemeral);31-// AFTER32-  // PCH-02-009: rejectZero — an all-zero DH output (low-order peer point)33-  // aborts the handshake before the value can enter the key schedule. Applies34-  // at every DH below; honest peers are unaffected.35-  const ee = sharedKey(e.secretKey, remoteEphemeral, true);36-37-// Line 119 (initiator, es):   sharedKey(e.secretKey, remoteStaticPlain, true)38-// Line 140 (initiator, se):   sharedKey(ourStaticPrivateKey, remoteEphemeral, true)39-// Line 196 (responder, ee):   sharedKey(e.secretKey, remoteEphemeral, true)40-// Line 204 (responder, es):   sharedKey(ourStaticPrivateKey, remoteEphemeral, true)41-// Line 231 (responder, se):   sharedKey(e.secretKey, remoteStaticPlain, true)42-```43-44-`noise-js.ts` — same pattern, comment at the first site:45-46-```typescript47-// Line 84  (NK initiator, es):  sharedKey(ephemeral.secretKey, responderStaticPublicKey, true)48-//   with the PCH-02-009 rationale comment above it (as in noise-xx.ts)49-// Line 106 (NK initiator, ee):  sharedKey(ephemeral.secretKey, remoteEphemeralPublic, true)50-// Line 175 (computeAuthProof):  sharedKey(initiatorPrivate, responderPublic, true)51-// Line 248 (NK responder, es):  sharedKey(ourStaticPrivateKey, remoteEphemeralPublic, true)52-// Line 263 (NK responder, ee):  sharedKey(ephemeral.secretKey, remoteEphemeralPublic, true)53-// Line 362 (performResponderAuth): sharedKey(ourPrivateKey, initiatorPublicKey, true)54-```55-56-The two auth-proof sites (175, 362) key an HMAC rather than `mixKey`; the57-same contributory-behaviour argument applies — a zero MAC key derived from an58-attacker-chosen point should abort, not authenticate.59-60-## KAT / existing-test impact61-62-None. The pinned vectors (`noise-js.test.ts` self-vectors,63-`noise-blake2s-kat.test.ts` cross-repo vectors) all use honest keys whose DH64-outputs are non-zero; `rejectZero` only changes behaviour on the degenerate65-inputs the new tests inject.66-67-## Tests68-69-`noise.reject-zero-dh.test.ts` (in this folder) — 5 tests: NK initiator70-aborts against an all-zero (low-order) responder static; NK responder aborts71-on an all-zero msg-1 ephemeral; XX responder aborts on an all-zero msg-172-ephemeral; full NK and XX handshakes over an in-memory transport still73-succeed with honest keys (regression).74-Destination: `src/shared/noise.reject-zero-dh.test.ts`.75-76-## Save message (integration)77-78-`fix: PCH-02-009 reject zero X25519 DH output at all 12 Noise XX/NK call sites via stablelib rejectZero`

view file at the parent

src/drafts/PCH-02-FIXES/README.md+0 −3
@@ -16,14 +16,11 @@1616 | Finding | Severity | Draft | Kind |1717 |---|---|---|---|1818 | PCH-02-001 update-entry sanitizers | Low | `PCH-02-001-vault-patch-sanitizers.patch.md` + `vault.update-entry-sanitize.test.ts` | code + 8 tests |19-| PCH-02-002 Noise XX msg1 hash | Info | `PCH-02-002-noise-xx-msg1-hash.patch.md` | **deferred wire change** (doc part landable now) |2019 | PCH-02-003 SAS trust model | Info | `PCH-02-003-sas-trust-model-comment.patch.md` | doc only |2120 | PCH-02-004 exact-pin deps | Info | `PCH-02-004-exact-pin-deps.patch.md` + `WASM-PROVENANCE.md` | manifest of pins + new doc |2221 | PCH-02-005 legacy PBKDF2 | Info | `PCH-02-005-legacy-pbkdf2-doc.patch.md` | doc + small UI notice |23-| PCH-02-006 vault_id privacy | Info | `PCH-02-006-vault-id-privacy-note.patch.md` | doc only |2422 | PCH-02-007 HIBP padding | Info | `PCH-02-007-hibp-add-padding.patch.md` + `vault.hibp-padding.test.ts` | code + 3 tests |2523 | PCH-02-008 BinaryReader bounds | Info | `PCH-02-008-binaryreader-bounds.patch.md` + `peachPayload.binaryreader-bounds.test.ts` | code + 8 tests |26-| PCH-02-009 reject-zero DH | Info | `PCH-02-009-reject-zero-dh.patch.md` + `noise.reject-zero-dh.test.ts` | code + 5 tests |2724 | PCH-02-010 clipboard hash match | Info | `PCH-02-010-clipboard-hash-match.patch.md` + `clipboard.hash-match.test.ts` | **decision: Option A (doc) recommended / Option B (code + 4 tests + ⚠ permission)** |2825 2926 Integration sequencing, decisions, and the full contract:

view file

src/drafts/PCH-02-FIXES/WASM-PROVENANCE.md+1 −1
@@ -27,7 +27,7 @@2727 2828 | Package | Pinned | Role |2929 |---|---|---|30-| `@stablelib/*` (blake2s, chacha20poly1305, hkdf, hmac, sha256, x25519) | 2.0.1 | Noise XX/NK handshakes and transport encryption for LAN sync |30+| `@stablelib/*` (blake2s, chacha20poly1305, hkdf, hmac, sha256, x25519) | 2.0.1 | Noise NK handshakes and transport encryption for sync |3131 | `@zxcvbn-ts/*` | 3.0.4 / 3.0.2 | Password strength estimation |3232 | `tldts` | 7.0.28 | eTLD+1 derivation for the autofill origin boundary |3333 | `pdfjs-dist` | 5.7.284 | Codex PDF import (hardened per MV-9) |

view file

src/drafts/PCH-02-FIXES/noise.reject-zero-dh.test.ts+0 −113
@@ -1,113 +0,0 @@1-// PCH-02-009 regression tests — every Noise XX/NK DH must reject an all-zero2-// shared secret (low-order / identity peer point) instead of feeding it into3-// the key schedule. Relies on `sharedKey(…, …, true)` from the PCH-02-0094-// patch, which throws "X25519: invalid shared key".5-//6-// DESTINATION: src/shared/noise.reject-zero-dh.test.ts7-// Imports are written in destination form; this file is excluded from the8-// suite while it lives under src/drafts/.9-10-import { describe, expect, it } from "vitest";11-import { generateKeyPair } from "@stablelib/x25519";12-import { noiseNKInitiator, noiseNKResponder } from "./noise-js";13-import { noiseXXInitiator, noiseXXResponder } from "./noise-xx";14-15-// The identity element: X25519 with an all-zero public key yields an all-zero16-// shared secret — the canonical low-order input the fix must reject.17-const ZERO_POINT = new Uint8Array(32);18-19-function queuePair() {20-  const makeQueue = () => {21-    const items: Uint8Array[] = [];22-    const waiters: Array<(m: Uint8Array) => void> = [];23-    return {24-      push(m: Uint8Array) {25-        const w = waiters.shift();26-        if (w) w(m);27-        else items.push(m);28-      },29-      pull(): Promise<Uint8Array> {30-        const m = items.shift();31-        return m ? Promise.resolve(m) : new Promise((r) => waiters.push(r));32-      },33-    };34-  };35-  const aToB = makeQueue();36-  const bToA = makeQueue();37-  return {38-    a: { send: async (m: Uint8Array) => aToB.push(m), receive: () => bToA.pull() },39-    b: { send: async (m: Uint8Array) => bToA.push(m), receive: () => aToB.pull() },40-  };41-}42-43-const deadTransport = {44-  send: async () => undefined,45-  receive: () => new Promise<Uint8Array>(() => undefined), // never resolves46-};47-48-describe("PCH-02-009 — reject zero X25519 DH outputs", () => {49-  it("NK initiator aborts against an all-zero responder static key", async () => {50-    // es = DH(ephemeral, ZERO_POINT) is computed before msg1 is sent, so the51-    // handshake must die before anything touches the transport.52-    await expect(53-      noiseNKInitiator(ZERO_POINT, deadTransport.send, deadTransport.receive),54-    ).rejects.toThrow(/invalid shared key/);55-  });56-57-  it("NK responder aborts on an all-zero msg-1 ephemeral", async () => {58-    const ourStatic = generateKeyPair();59-    // 32 zero bytes of "ephemeral" + 16 bytes of fake AEAD tag = minimum frame.60-    const msg1 = new Uint8Array(48);61-    await expect(62-      noiseNKResponder(63-        ourStatic.secretKey,64-        ourStatic.publicKey,65-        deadTransport.send,66-        async () => msg1,67-      ),68-    ).rejects.toThrow(/invalid shared key/);69-  });70-71-  it("XX responder aborts on an all-zero msg-1 ephemeral", async () => {72-    const ourStatic = generateKeyPair();73-    await expect(74-      noiseXXResponder(75-        ourStatic.secretKey,76-        ourStatic.publicKey,77-        deadTransport.send,78-        async () => ZERO_POINT.slice(),79-      ),80-    ).rejects.toThrow(/invalid shared key/);81-  });82-83-  it("honest NK handshake still completes and transports a frame (regression)", async () => {84-    const responderStatic = generateKeyPair();85-    const { a, b } = queuePair();86-87-    const [initiator, responder] = await Promise.all([88-      noiseNKInitiator(responderStatic.publicKey, a.send, a.receive),89-      noiseNKResponder(responderStatic.secretKey, responderStatic.publicKey, b.send, b.receive),90-    ]);91-92-    const frame = new TextEncoder().encode("peach-nk-frame");93-    expect(responder.decrypt(initiator.encrypt(frame))).toEqual(frame);94-    expect(initiator.handshakeHash).toEqual(responder.handshakeHash);95-  });96-97-  it("honest XX handshake still completes and transports a frame (regression)", async () => {98-    const initiatorStatic = generateKeyPair();99-    const responderStatic = generateKeyPair();100-    const { a, b } = queuePair();101-102-    const [initiator, responder] = await Promise.all([103-      noiseXXInitiator(initiatorStatic.secretKey, initiatorStatic.publicKey, a.send, a.receive),104-      noiseXXResponder(responderStatic.secretKey, responderStatic.publicKey, b.send, b.receive),105-    ]);106-107-    expect(initiator.remoteStaticPublicKey).toEqual(responderStatic.publicKey);108-    expect(responder.remoteStaticPublicKey).toEqual(initiatorStatic.publicKey);109-110-    const frame = new TextEncoder().encode("peach-xx-frame");111-    expect(responder.decrypt(initiator.encrypt(frame))).toEqual(frame);112-  });113-});

view file at the parent

src/drafts/TEST-EXPANSION/INTEGRATION-CONTRACT.md+2 −2
@@ -54,14 +54,14 @@5454 | T-007 | None | `vi.mock("pdfjs-dist")` — testing flag plumbing only |5555 | T-008 | Argon2id, AES-GCM | Via full vault setup |5656 | T-009 | Ed25519 sign/verify | Local `validateLicenseWithKey` re-impl; prod `validateLicense` for structural checks |57-| T-010 | Curve25519 DH (Noise XX) | Via `noiseXXInitiator` + `noiseXXResponder` |57+| T-010 | Curve25519 DH (Noise NK) | Via `noiseNKInitiator` + `noiseNKResponder` |5858 5959 ---6060 6161 ## New Dependencies Added6262 6363 These dependencies are used in the draft tests but are already present in `package.json`:64-- `@stablelib/x25519` — Noise XX keypair generation (T-010, T-020)64+- `@stablelib/x25519` — Noise NK keypair generation (T-010, T-020)6565 - `pako` — deflate/inflate for Codex tests (T-017)6666 - `hash-wasm` — Argon2id in T-005, T-006 (already used by production code)6767 - `otpauth` — TOTP generation (T-004, already used by production code)

view file

src/drafts/TEST-EXPANSION/sync.disconnect.test.ts+2 −2
@@ -8,7 +8,7 @@88 //99 // PCH-01-026 #9 added the explicit ws.close() call in onerror so that a peer1010 // error that is NOT followed by onclose still unblocks the receive() queue.11-// No test verifies that a WebSocket error or close during the Noise XX11+// No test verifies that a WebSocket error or close during the Noise NK1212 // handshake produces a rejected promise (not a hang). A regression that1313 // removes the onerror→close bridge causes the session to hang indefinitely1414 // when the peer disconnects after msg1 but before msg2.@@ -83,7 +83,7 @@8383 describe("T-020: WebSocket sync-disconnect handling", () => {8484   // T-020a: onerror during handshake → session promise rejects, no hang.8585   // PCH-01-026 #9: a WebSocket error fires onerror, which must call ws.close()86-  // to drain the readers queue. Without this, the Noise XX initiator's receive()86+  // to drain the readers queue. Without this, the Noise NK initiator's receive()8787   // would park forever waiting for msg2.8888   //8989   // Bug this catches: removing the onerror → ws.close() bridge causes the

view file

src/drafts/TEST-EXPANSION/vault.rotate-sync-identity.test.ts+1 −1
@@ -118,7 +118,7 @@118118 119119   // T-016c: paired devices are cleared after rotation.120120   // After rotation, the old paired devices must no longer appear in storage —121-  // their Noise XX static keys are derived from the old root key and cannot121+  // their Noise NK static keys are derived from the old root key and cannot122122   // be used to authenticate against the new sync identity.123123   //124124   // Bug this catches: forgetting to call clearPairedDevices() after rotation

view file

src/popup/App.tsx+4 −4
@@ -581,8 +581,8 @@581581         }582582       });583583 584-      // The mDNS peer-discovery trigger went with the Capacitor pairing585-      // plugin. Reconnection is address-cache first, subnet probe second584+      // The retired native peer-discovery trigger went with the old Capacitor585+      // pairing plugin. Reconnection is address-cache first, subnet probe second586586       // (sync-runner.ts); relocating a peer that changed networks is an587587       // Android-side discovery concern now.588588       if (isNativePlatform()) {@@ -1199,8 +1199,8 @@11991199         import("../background/paired-devices"),12001200       ]);12011201 1202-    // No `findHost`: adoption only accepts a pairing code, which carries its1203-    // own address. There is nothing to probe the LAN for.1202+    // Adoption accepts the same pairing code as device pairing; it carries the1203+    // listener address and one-time token.12041204     const dial = await dialPairingChannel(args.code, { openSocket: openPairingSocket });12051205     if (!dial.ok) throw new Error(dial.error);12061206     const { channel, sas, socket, host } = dial.dialled;

view file

src/popup/codex/codex.test.ts+1 −1
@@ -154,7 +154,7 @@154154   it('integrity check fails for tampered data', async () => {155155     const bytes = new Uint8Array([1, 2, 3, 4, 5]);156156     const envelope = await buildChunkEnvelope(bytes, 0, 10, false, 'PX-TEST-1234');157-    const tampered = { ...envelope, d: envelope.d.slice(0, -2) + 'XX' };157+    const tampered = { ...envelope, d: envelope.d.slice(0, -2) + "00" };158158     expect(await verifyChunkIntegrity(tampered)).toBe(false);159159   });160160 

view file

src/popup/views/PairDevice.test.tsx+23 −22
@@ -5,8 +5,7 @@55 // component — so these specs walk enter → connecting → SAS → paired/error,66 // asserting the popup↔background message contract at each step.77 //8-// The input accepts both code forms: the pairing code a phone displays9-// (pairing-code.ts) and the legacy 6-digit desktop-helper code.8+// The input accepts the address-bearing pairing code a phone displays.109 //1110 // chrome messaging is mocked the way the popup uses it:1211 //   - shared/bridge `sendMessage` is mocked (popup → bg)@@ -53,6 +52,7 @@5352 });5453 5554 describe("PairDevice — code pairing", () => {55+  const VALID_CODE = "A74H-48J3-KI32-OEP4";5656   const cleanups: Array<() => void> = [];5757   afterEach(() => {5858     while (cleanups.length) cleanups.pop()!();@@ -111,12 +111,13 @@111111     expect(buttonByText(container, "Connect")!.disabled).toBe(false);112112   });113113 114-  it("leaves a short legacy code ungrouped and strips unusable characters", () => {114+  it("groups partial input and strips unusable characters", () => {115115     const { container } = mount();116116     gotoCodeEntry(container);117117     const input = codeInput(container);118118     inputText(input, "12_34/56");119-    expect(input.value).toBe("123456");119+    expect(input.value).toBe("1234-56");120+    expect(buttonByText(container, "Connect")!.disabled).toBe(true);120121   });121122 122123   it("keeps Connect disabled for input that is neither code form", () => {@@ -130,31 +131,31 @@130131   it("Connect sends PAIR_WITH_CODE with the typed code, then searches", async () => {131132     const { container } = mount();132133     gotoCodeEntry(container);133-    inputText(codeInput(container), "428913");134+    inputText(codeInput(container), VALID_CODE);134135     const connect = buttonByText(container, "Connect")!;135136     expect(connect.disabled).toBe(false);136137     connect.click();137138     await Promise.resolve();138139     expect(mockSendMessage).toHaveBeenCalledWith({139140       type: "PAIR_WITH_CODE",140-      payload: { code: "428913" },141+      payload: { code: VALID_CODE },141142     });142143     expect(container.textContent).toContain("Connecting…");143144   });144145 145-  it("Connect stays disabled until 6 digits are entered", () => {146+  it("Connect stays disabled until the full pairing code is entered", () => {146147     const { container } = mount();147148     gotoCodeEntry(container);148-    inputText(codeInput(container), "123");149+    inputText(codeInput(container), "A74H48J3");149150     expect(buttonByText(container, "Connect")!.disabled).toBe(true);150-    inputText(codeInput(container), "123456");151+    inputText(codeInput(container), VALID_CODE);151152     expect(buttonByText(container, "Connect")!.disabled).toBe(false);152153   });153154 154155   it("shows the SAS on a CODE_PAIR_SAS push, with Confirm/Cancel", async () => {155156     const { container } = mount();156157     gotoCodeEntry(container);157-    inputText(codeInput(container), "428913");158+    inputText(codeInput(container), VALID_CODE);158159     buttonByText(container, "Connect")!.click();159160     await Promise.resolve();160161 @@ -169,7 +170,7 @@169170   it("Confirm sends CODE_PAIR_CONFIRM { confirmed: true }", async () => {170171     const { container } = mount();171172     gotoCodeEntry(container);172-    inputText(codeInput(container), "428913");173+    inputText(codeInput(container), VALID_CODE);173174     buttonByText(container, "Connect")!.click();174175     await Promise.resolve();175176     pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });@@ -185,7 +186,7 @@185186   it("Cancel on the SAS gate sends { confirmed: false } and shows mismatch copy", async () => {186187     const { container } = mount();187188     gotoCodeEntry(container);188-    inputText(codeInput(container), "428913");189+    inputText(codeInput(container), VALID_CODE);189190     buttonByText(container, "Connect")!.click();190191     await Promise.resolve();191192     pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });@@ -202,12 +203,12 @@202203   it("shows the combined 'not found or expired' copy on invalid_code", async () => {203204     const { container } = mount();204205     gotoCodeEntry(container);205-    inputText(codeInput(container), "000000");206+    inputText(codeInput(container), VALID_CODE);206207     buttonByText(container, "Connect")!.click();207208     await Promise.resolve();208209 209-    // peach-sync cannot distinguish wrong vs expired on the wire (ground truth210-    // §0.3) — the background collapses both to invalid_code via a socket close.210+    // Wrong vs expired is indistinguishable on the wire; the background211+    // collapses both to invalid_code via a socket close.211212     pushFromBackground("CODE_PAIR_RESULT", { status: "error", error: "invalid_code" });212213 213214     expect(container.textContent).toContain("Code not found or expired");@@ -217,7 +218,7 @@217218   it("shows the 'couldn't reach your phone' copy on no_host_found", async () => {218219     const { container } = mount();219220     gotoCodeEntry(container);220-    inputText(codeInput(container), "111111");221+    inputText(codeInput(container), VALID_CODE);221222     buttonByText(container, "Connect")!.click();222223     await Promise.resolve();223224     pushFromBackground("CODE_PAIR_RESULT", { status: "error", error: "no_host_found" });@@ -225,10 +226,10 @@225226   });226227 227228   it("renders Paired! and the device name on a complete result", async () => {228-    const device: PairedDevice = { deviceId: "d1", deviceName: "June's iMac", deviceType: "desktop", pairedAt: 1700000000000 };229+    const device: PairedDevice = { deviceId: "d1", deviceName: "June's Pixel", deviceType: "android", pairedAt: 1700000000000 };229230     const { container } = mount();230231     gotoCodeEntry(container);231-    inputText(codeInput(container), "428913");232+    inputText(codeInput(container), VALID_CODE);232233     buttonByText(container, "Connect")!.click();233234     await Promise.resolve();234235     pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });@@ -237,17 +238,17 @@237238 238239     pushFromBackground("CODE_PAIR_RESULT", { status: "complete", device });239240     expect(container.textContent).toContain("Paired!");240-    expect(container.textContent).toContain("Connected to June's iMac");241+    expect(container.textContent).toContain("Connected to June's Pixel");241242   });242243 243244   it("auto-dismisses back to settings after the paired state settles", async () => {244245     vi.useFakeTimers();245246     try {246-      const device: PairedDevice = { deviceId: "d1", deviceName: "Mac", deviceType: "desktop", pairedAt: 0 };247+      const device: PairedDevice = { deviceId: "d1", deviceName: "Pixel", deviceType: "android", pairedAt: 0 };247248       const onBack = vi.fn();248249       const { container } = mount(onBack);249250       gotoCodeEntry(container);250-      inputText(codeInput(container), "428913");251+      inputText(codeInput(container), VALID_CODE);251252       buttonByText(container, "Connect")!.click();252253       await Promise.resolve();253254       pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });@@ -267,7 +268,7 @@267268     startResponse = { ok: false, error: "vault_locked" };268269     const { container } = mount();269270     gotoCodeEntry(container);270-    inputText(codeInput(container), "428913");271+    inputText(codeInput(container), VALID_CODE);271272     buttonByText(container, "Connect")!.click();272273     await Promise.resolve();273274     await Promise.resolve();

view file

src/popup/views/PairDevice.tsx+2 −9
@@ -95,11 +95,7 @@9595   const [pairedDevice, setPairedDevice] = createSignal<PairedDevice | undefined>(undefined);9696   const [busy, setBusy] = createSignal(false);9797 98-  // Two accepted forms: the pairing code a phone displays, and the legacy99-  // 6-digit desktop-helper code. `parsePairingCode` returning null just means100-  // "not that form", so the check falls through to the digit shape.101-  const codeReady = () =>102-    parsePairingCode(code()) !== null || /^\d{6}$/.test(code());98+  const codeReady = () => parsePairingCode(code()) !== null;10399 104100   function startFlow(): void {105101     setCode("");@@ -112,10 +108,7 @@112108 113109   function onCodeInput(value: string): void {114110     const cleaned = value.toUpperCase().replace(/[^0-9A-Z]/g, "").slice(0, 32);115-    // Group once the input outgrows a 6-digit helper code, so a pairing code116-    // regains its dashes as it is typed without mangling the short form. Length117-    // rather than character class, so an all-digit pairing code still groups.118-    setCode(cleaned.length > 6 ? formatPairingCode(cleaned) : cleaned);111+    setCode(formatPairingCode(cleaned));119112   }120113 121114   async function handleConnect(): Promise<void> {

view file

src/popup/views/Settings.tsx+0 −1
@@ -289,7 +289,6 @@289289     switch (t) {290290       case "android": return "Android";291291       case "ios": return "iPhone";292-      case "desktop": return "Desktop helper";293292       case "extension": return "Browser";294293       default: return "Device";295294     }

view file

src/shared/device-manifest.ts+3 −4
@@ -3,7 +3,7 @@33 // A device manifest is a list of paired-device entries, signed by the44 // issuer's long-term Ed25519 signing key (`deriveSyncSigningKeypair`).55 // Manifests are exchanged after the entry sync completes during a normal6-// `SyncSession`, encrypted under Noise XX along with everything else.6+// `SyncSession`, encrypted under Noise NK along with everything else.77 //88 // Purpose: propagate trust between mobile devices without requiring99 // additional audio pairing sessions. The extension is the trust anchor and@@ -18,7 +18,7 @@1818 //     against the issuer's stored signing pubkey.1919 //   - Freshness: `issuedAt` must be within DEVICE_MANIFEST_FRESHNESS_MS2020 //     of the receiver's clock; older manifests are silently discarded.21-//   - Vault binding: the manifest is exchanged over Noise XX which has21+//   - Vault binding: the manifest is exchanged over Noise NK which has2222 //     already proven the peer holds the right vault-derived static key.2323 2424 import {@@ -135,8 +135,7 @@135135     if (136136       d.deviceType !== "extension" &&137137       d.deviceType !== "android" &&138-      d.deviceType !== "ios" &&139-      d.deviceType !== "desktop"138+      d.deviceType !== "ios"140139     ) {141140       throw new DeviceManifestError("malformed", "device.deviceType invalid");142141     }

view file

src/shared/noise-blake2s-kat.test.ts+2 −36
@@ -1,6 +1,5 @@11 import { beforeEach, describe, expect, it, vi } from "vitest";22 import { generateKeyPairFromSeed } from "@stablelib/x25519";3-import { initHash as xxInitHash, hkdf2 as xxHkdf2 } from "./noise-xx";43 import { initHash as nkInitHash, hkdf2 as nkHkdf2 } from "./noise-js";54 65 // PCH-01-002: the Noise handshakes derive `h` (protocol-name init hash) and the@@ -60,55 +59,22 @@6059   const CK = new Uint8Array(32).map((_, i) => i); // 0x00..0x1f6160   const IKM = new Uint8Array(32).map((_, i) => i + 32); // 0x20..0x3f6261 63-  it("initHash is BLAKE2s-256 of the XX protocol name", () => {64-    expect(hex(xxInitHash())).toBe(65-      "1ceedd81c5f458b225923dc2507787bf156f9251fc17c45af63263a929fc1ed2",66-    );67-  });68-6962   it("initHash is BLAKE2s-256 of the NK protocol name", () => {7063     expect(hex(nkInitHash())).toBe(7164       "e01688d376ef280a27a8af5c559024a02017585b3763763e4f3c7118a979b09e",7265     );7366   });7467 75-  it("hkdf2 (HKDF-BLAKE2s) matches the frozen vector and is identical XX vs NK", () => {76-    const [k1, k2] = xxHkdf2(CK, IKM);68+  it("hkdf2 (HKDF-BLAKE2s) matches the frozen vector", () => {69+    const [k1, k2] = nkHkdf2(CK, IKM);7770     expect(hex(k1)).toBe("6a96444e20e8d4c1cee974416acae1c10b3c92886010e54ed94dafb2c3b80ea0");7871     expect(hex(k2)).toBe("57af120b0de7acbe7907ec149c5ae870a2dbb74232b65777ba4123f1f7f888f5");79-    // The hkdf2 logic is duplicated in noise-xx.ts and noise-js.ts — they must agree.80-    const [n1, n2] = nkHkdf2(CK, IKM);81-    expect(hex(n1)).toBe(hex(k1));82-    expect(hex(n2)).toBe(hex(k2));8372   });8473 });8574 8675 describe("PCH-01-002 — Noise handshakes derive over BLAKE2s", () => {8776   beforeEach(() => {8877     (globalThis as { __kpCounter?: number }).__kpCounter = 0;89-  });90-91-  it("noise_xx_handshake_hash_is_blake2s_derived", async () => {92-    const { noiseXXInitiator, noiseXXResponder } = await import("./noise-xx");93-    const alice = generateKeyPairFromSeed(new Uint8Array(32).fill(0xa1));94-    const bob = generateKeyPairFromSeed(new Uint8Array(32).fill(0xb2));95-    const [tA, tB] = memoryPair();96-97-    const [a, b] = await Promise.all([98-      noiseXXInitiator(alice.secretKey, alice.publicKey, tA.send, tA.receive),99-      noiseXXResponder(bob.secretKey, bob.publicKey, tB.send, tB.receive),100-    ]);101-102-    // Both directions agree (consistency) ...103-    expect(hex(a.handshakeHash)).toBe(hex(b.handshakeHash));104-    // ... and equal the pinned BLAKE2s + canonical msg1-empty-hash vector.105-    expect(hex(a.handshakeHash)).toBe(106-      "505b6dcb59838e5d163f4fd4897cfff87c20ecca23aa993b416206aba25ee71c",107-    );108-109-    // Transport keys are also BLAKE2s-derived; a frame must still round-trip.110-    const ct = a.encrypt(new TextEncoder().encode("hello"));111-    expect(new TextDecoder().decode(b.decrypt(ct))).toBe("hello");11278   });11379 11480   it("noise_nk_handshake_hash_is_blake2s_derived", async () => {

view file

src/shared/noise-js.ts+2 −3
@@ -9,8 +9,7 @@99  * vectors only (a regression guard against accidental changes on the JS side).1010  *1111  * PCH-01-002: this implementation derives `h` and HKDF over BLAKE2s to match the12- * named pattern. PCH-02-002 brought the XX hand-port back to the canonical13- * empty-payload msg1 transcript too, so a stock Noise stack should interoperate.12+ * named pattern, so a stock Noise stack should interoperate.1413  * The Android port must mirror these hash choices and transcript steps. Any1514  * change to the hash, HKDF, or transcript here is a BREAKING wire change and1615  * requires a coordinated Android release.@@ -226,7 +225,7 @@226225  * handshake messages and gets back a session for transport-phase frames.227226  *228227  * Symmetric with the Android hand-port; no shared Rust implementation remains.229- * An Android-as-host pairing session can be driven by the desktop initiator and228+ * An Android-as-host pairing session can be driven by the browser initiator and230229  * vice versa as long as both derive over BLAKE2s (PCH-01-002).231230  */232231 export async function noiseNKResponder(

view file

src/shared/noise-xx.test.ts+0 −149
@@ -1,149 +0,0 @@1-import { describe, expect, it } from "vitest";2-import { generateKeyPair } from "@stablelib/x25519";3-import { noiseXXInitiator, noiseXXResponder } from "./noise-xx";4-5-function memoryPair() {6-  const a: Uint8Array[] = [];7-  const b: Uint8Array[] = [];8-  const aR: Array<(v: Uint8Array) => void> = [];9-  const bR: Array<(v: Uint8Array) => void> = [];10-  const tA = {11-    async send(m: Uint8Array) {12-      const r = bR.shift();13-      if (r) r(m); else b.push(m);14-    },15-    receive(): Promise<Uint8Array> {16-      const v = a.shift();17-      if (v) return Promise.resolve(v);18-      return new Promise<Uint8Array>((resolve) => aR.push(resolve));19-    },20-  };21-  const tB = {22-    async send(m: Uint8Array) {23-      const r = aR.shift();24-      if (r) r(m); else a.push(m);25-    },26-    receive(): Promise<Uint8Array> {27-      const v = b.shift();28-      if (v) return Promise.resolve(v);29-      return new Promise<Uint8Array>((resolve) => bR.push(resolve));30-    },31-  };32-  return [tA, tB] as const;33-}34-35-describe("Noise XX mutual authentication", () => {36-  it("completes the three-message handshake and exchanges static keys", async () => {37-    const alice = generateKeyPair();38-    const bob = generateKeyPair();39-    const [tA, tB] = memoryPair();40-41-    const aPromise = noiseXXInitiator(alice.secretKey, alice.publicKey, tA.send, tA.receive);42-    const bPromise = noiseXXResponder(bob.secretKey, bob.publicKey, tB.send, tB.receive);43-44-    const [a, b] = await Promise.all([aPromise, bPromise]);45-    expect(a.remoteStaticPublicKey).toEqual(bob.publicKey);46-    expect(b.remoteStaticPublicKey).toEqual(alice.publicKey);47-    expect(a.handshakeHash).toEqual(b.handshakeHash);48-  });49-50-  it("transport frames decrypt in both directions and are bound to the handshake", async () => {51-    const alice = generateKeyPair();52-    const bob = generateKeyPair();53-    const [tA, tB] = memoryPair();54-55-    const [a, b] = await Promise.all([56-      noiseXXInitiator(alice.secretKey, alice.publicKey, tA.send, tA.receive),57-      noiseXXResponder(bob.secretKey, bob.publicKey, tB.send, tB.receive),58-    ]);59-60-    const ct1 = a.encrypt(new TextEncoder().encode("hello bob"));61-    expect(new TextDecoder().decode(b.decrypt(ct1))).toBe("hello bob");62-63-    const ct2 = b.encrypt(new TextEncoder().encode("hello alice"));64-    expect(new TextDecoder().decode(a.decrypt(ct2))).toBe("hello alice");65-66-    // Tampering with one byte breaks the MAC.67-    const ct3 = a.encrypt(new TextEncoder().encode("not delivered"));68-    ct3[0] ^= 1;69-    expect(() => b.decrypt(ct3)).toThrow();70-  });71-72-  it("an attacker who swaps the responder static cannot complete msg2 auth", async () => {73-    const alice = generateKeyPair();74-    const bobReal = generateKeyPair();75-    const bobImpostor = generateKeyPair();76-    const [tA, tB] = memoryPair();77-78-    // Responder uses impostor private key but advertises real pubkey to initiator.79-    // Real XX has no out-of-band pubkey check; this test verifies the registry80-    // check that the caller MUST perform on `remoteStaticPublicKey`.81-    const aPromise = noiseXXInitiator(alice.secretKey, alice.publicKey, tA.send, tA.receive);82-    const bPromise = noiseXXResponder(bobImpostor.secretKey, bobImpostor.publicKey, tB.send, tB.receive);83-    const [a] = await Promise.all([aPromise, bPromise]);84-    // The "swap" is the caller seeing a remote static they did not expect.85-    expect(a.remoteStaticPublicKey).not.toEqual(bobReal.publicKey);86-    expect(a.remoteStaticPublicKey).toEqual(bobImpostor.publicKey);87-  });88-89-  // PCH-01-003: the pre-msg3 verifier must abort BEFORE the initiator transmits90-  // its own static (msg3), so a probed-but-unexpected LAN host never learns our91-  // long-term sync identity.92-  it("initiator_aborts_before_msg3_on_unexpected_responder", async () => {93-    const alice = generateKeyPair();94-    const bob = generateKeyPair();95-    const expected = generateKeyPair(); // the peer we WANTED — not bob96-    const [tA, tB] = memoryPair();97-98-    let sendCount = 0;99-    const countingSend = async (m: Uint8Array) => {100-      sendCount++;101-      await tA.send(m);102-    };103-104-    const aPromise = noiseXXInitiator(105-      alice.secretKey,106-      alice.publicKey,107-      countingSend,108-      tA.receive,109-      (remoteStatic) => {110-        const matches =111-          remoteStatic.length === expected.publicKey.length &&112-          remoteStatic.every((v, i) => v === expected.publicKey[i]);113-        if (!matches) throw new Error("paired-device static key mismatch");114-      },115-    );116-    // Honest responder advertising bob's static; it will hang waiting for msg3117-    // that never arrives, so we don't await it.118-    const bPromise = noiseXXResponder(bob.secretKey, bob.publicKey, tB.send, tB.receive);119-    bPromise.catch(() => {});120-121-    await expect(aPromise).rejects.toThrow("paired-device static key mismatch");122-    // Load-bearing assertion: only msg1 was sent. msg3 (our static) never left.123-    expect(sendCount).toBe(1);124-  });125-126-  it("matching_pin_completes_handshake_and_sends_msg3", async () => {127-    const alice = generateKeyPair();128-    const bob = generateKeyPair();129-    const [tA, tB] = memoryPair();130-131-    let sendCount = 0;132-    const countingSend = async (m: Uint8Array) => {133-      sendCount++;134-      await tA.send(m);135-    };136-137-    const [a] = await Promise.all([138-      noiseXXInitiator(alice.secretKey, alice.publicKey, countingSend, tA.receive, (remoteStatic) => {139-        const matches = remoteStatic.every((v, i) => v === bob.publicKey[i]);140-        if (!matches) throw new Error("paired-device static key mismatch");141-      }),142-      noiseXXResponder(bob.secretKey, bob.publicKey, tB.send, tB.receive),143-    ]);144-145-    expect(a.remoteStaticPublicKey).toEqual(bob.publicKey);146-    // msg1 + msg3 both sent on the honest path — handshake completed.147-    expect(sendCount).toBe(2);148-  });149-});

view file at the parent

src/shared/noise-xx.ts+0 −268
@@ -1,268 +0,0 @@1-// Noise XX (mutual auth) — used for every Phase-2 WebSocket connection2-// after pairing has stamped each side into the other's3-// paired-device registry. Both peers hold a long-term X25519 static key4-// (the sync identity); XX is the canonical pattern when both parties want5-// to authenticate each other.6-//7-// Pattern: Noise_XX_25519_ChaChaPoly_BLAKE2s — same primitives as the8-// NK pattern in `noise-js.ts`, so the helpers are duplicated minimally9-// (initHash / mixHash / hkdf2) to keep this file self-contained.10-//11-//   -> e12-//   <- e, ee, s, es13-//   -> s, se14-//15-// After msg 3, the symmetric `hkdf2(ck, "")` splits into two ChaCha20-16-// Poly1305 transport keys, one per direction. Counter nonces start at 017-// and increment per frame.18-19-import { generateKeyPair, sharedKey } from "@stablelib/x25519";20-import { ChaCha20Poly1305 } from "@stablelib/chacha20poly1305";21-import { BLAKE2s } from "@stablelib/blake2s";22-import { HMAC } from "@stablelib/hmac";23-24-const PROTOCOL_NAME = "Noise_XX_25519_ChaChaPoly_BLAKE2s";25-26-export function initHash(): Uint8Array {27-  const nameBytes = new TextEncoder().encode(PROTOCOL_NAME);28-  if (nameBytes.length <= 32) {29-    const h = new Uint8Array(32);30-    h.set(nameBytes);31-    return h;32-  }33-  // PCH-01-002: the named pattern hash is BLAKE2s; the protocol-name init hash34-  // MUST use it too (Noise spec §5.2), not SHA-256. BLAKE2s digest is 32 bytes,35-  // matching mixHash output width.36-  return new BLAKE2s(32).update(nameBytes).digest();37-}38-39-function mixHash(h: Uint8Array, data: Uint8Array): Uint8Array {40-  const blake = new BLAKE2s(32);41-  blake.update(h);42-  blake.update(data);43-  return blake.digest();44-}45-46-export function hkdf2(ck: Uint8Array, ikm: Uint8Array): [Uint8Array, Uint8Array] {47-  // PCH-01-002: HKDF must run over the named hash (BLAKE2s), not SHA-256.48-  // @stablelib's HKDF reuses one HMAC across output blocks and BLAKE2s's49-  // restoreState() never clears its `finished` flag, so `new HKDF(BLAKE2s, …)`50-  // throws "can't update because hash was finished". Run standard HKDF-Expand51-  // (RFC 5869, empty info) with a FRESH HMAC-BLAKE2s per block instead — this is52-  // byte-identical to BouncyCastle `HKDFBytesGenerator(Blake2sDigest(256))` on53-  // the peach-android side, so the two stay in wire lockstep.54-  const prk = new HMAC(BLAKE2s, ck).update(ikm).digest(); // Extract (salt=ck)55-  const k1 = new HMAC(BLAKE2s, prk).update(Uint8Array.of(1)).digest();56-  const k2 = new HMAC(BLAKE2s, prk).update(k1).update(Uint8Array.of(2)).digest();57-  return [k1, k2];58-}59-60-function nonceBytes(n: bigint): Uint8Array {61-  const buf = new Uint8Array(12);62-  new DataView(buf.buffer).setBigUint64(4, n, true);63-  return buf;64-}65-66-export interface NoiseXXSession {67-  encrypt(plaintext: Uint8Array): Uint8Array;68-  decrypt(ciphertext: Uint8Array): Uint8Array;69-  remoteStaticPublicKey: Uint8Array;70-  handshakeHash: Uint8Array;71-}72-73-/**74- * Drive the XX initiator. The responder's static key is learned in75- * message 2; we hand it back so the caller can verify it against the76- * paired-device registry.77- */78-export async function noiseXXInitiator(79-  ourStaticPrivateKey: Uint8Array,80-  ourStaticPublicKey: Uint8Array,81-  send: (msg: Uint8Array) => Promise<void>,82-  receive: () => Promise<Uint8Array>,83-  // PCH-01-003: called once the responder's static is authenticated (end of84-  // msg2) and BEFORE msg3 — which carries OUR static — is sent. Throwing here85-  // aborts the handshake without disclosing our long-term identity to an86-  // unexpected peer. Honest callers pass a pin check; omitting it preserves the87-  // old behaviour.88-  verifyRemoteStatic?: (remoteStaticPublicKey: Uint8Array) => void,89-): Promise<NoiseXXSession> {90-  let h: Uint8Array = initHash();91-  let ck: Uint8Array = h.slice();92-93-  // --- Message 1: -> e ---94-  const e = generateKeyPair();95-  h = mixHash(h, e.publicKey);96-  // PCH-02-002: canonical Noise still runs EncryptAndHash(empty) for msg1.97-  // No bytes are added to the wire; the transcript hash moves in lockstep with98-  // peach-android.99-  h = mixHash(h, new Uint8Array(0));100-  await send(e.publicKey);101-102-  // --- Message 2: <- e, ee, s, es ---103-  const msg2 = await receive();104-  if (msg2.length < 32 + 32 + 16 + 16) {105-    throw new Error("Noise XX: msg2 too short");106-  }107-  const remoteEphemeral = msg2.slice(0, 32);108-  const encryptedStatic = msg2.slice(32, 32 + 32 + 16);109-  const encryptedPayload = msg2.slice(32 + 32 + 16);110-111-  h = mixHash(h, remoteEphemeral);112-  // PCH-02-009: reject all-zero DH outputs from low-order peer points before113-  // they enter the key schedule. Honest peers are unaffected.114-  const ee = sharedKey(e.secretKey, remoteEphemeral, true);115-  const [ck1, k1] = hkdf2(ck, ee);116-  ck = ck1;117-118-  const cipherStatic = new ChaCha20Poly1305(k1);119-  const remoteStaticPlain = cipherStatic.open(nonceBytes(0n), encryptedStatic, h);120-  if (!remoteStaticPlain || remoteStaticPlain.length !== 32) {121-    throw new Error("Noise XX: msg2 static auth failed");122-  }123-  h = mixHash(h, encryptedStatic);124-125-  const es = sharedKey(e.secretKey, remoteStaticPlain, true);126-  const [ck2, k2] = hkdf2(ck, es);127-  ck = ck2;128-129-  const cipherPayload = new ChaCha20Poly1305(k2);130-  const payloadPlain = cipherPayload.open(nonceBytes(0n), encryptedPayload, h);131-  if (!payloadPlain) {132-    throw new Error("Noise XX: msg2 payload auth failed");133-  }134-  h = mixHash(h, encryptedPayload);135-136-  // PCH-01-003: pin the responder static BEFORE we transmit our own static in137-  // msg3. `remoteStaticPlain` was authenticated above (msg2). If the caller's138-  // check throws, we return without sending msg3 — our identity stays private.139-  if (verifyRemoteStatic) verifyRemoteStatic(remoteStaticPlain);140-141-  // --- Message 3: -> s, se ---142-  const cipherOurStatic = new ChaCha20Poly1305(k2);143-  const encryptedOurStatic = cipherOurStatic.seal(nonceBytes(1n), ourStaticPublicKey, h);144-  h = mixHash(h, encryptedOurStatic);145-146-  const se = sharedKey(ourStaticPrivateKey, remoteEphemeral, true);147-  const [ck3, k3] = hkdf2(ck, se);148-  ck = ck3;149-150-  const cipherFinal = new ChaCha20Poly1305(k3);151-  const encryptedFinalPayload = cipherFinal.seal(nonceBytes(0n), new Uint8Array(0), h);152-  h = mixHash(h, encryptedFinalPayload);153-154-  const msg3 = new Uint8Array(encryptedOurStatic.length + encryptedFinalPayload.length);155-  msg3.set(encryptedOurStatic, 0);156-  msg3.set(encryptedFinalPayload, encryptedOurStatic.length);157-  await send(msg3);158-159-  // --- Transport keys: split (k_init_send, k_resp_send) ---160-  const [initSend, respSend] = hkdf2(ck, new Uint8Array(0));161-  const sendCipher = new ChaCha20Poly1305(initSend);162-  const recvCipher = new ChaCha20Poly1305(respSend);163-  let sendNonce = 0n;164-  let recvNonce = 0n;165-166-  return {167-    encrypt(plaintext) {168-      return sendCipher.seal(nonceBytes(sendNonce++), plaintext, new Uint8Array(0));169-    },170-    decrypt(ciphertext) {171-      const result = recvCipher.open(nonceBytes(recvNonce++), ciphertext, new Uint8Array(0));172-      if (!result) throw new Error("Noise XX: decryption failed");173-      return result;174-    },175-    remoteStaticPublicKey: remoteStaticPlain,176-    handshakeHash: h.slice(),177-  };178-}179-180-/**181- * Drive the XX responder. The initiator's static key is learned in182- * message 3; we hand it back on success.183- */184-export async function noiseXXResponder(185-  ourStaticPrivateKey: Uint8Array,186-  ourStaticPublicKey: Uint8Array,187-  send: (msg: Uint8Array) => Promise<void>,188-  receive: () => Promise<Uint8Array>,189-): Promise<NoiseXXSession> {190-  let h: Uint8Array = initHash();191-  let ck: Uint8Array = h.slice();192-193-  // --- Message 1: <- e ---194-  const msg1 = await receive();195-  if (msg1.length < 32) throw new Error("Noise XX: msg1 too short");196-  const remoteEphemeral = msg1.slice(0, 32);197-  h = mixHash(h, remoteEphemeral);198-  // PCH-02-002: mirror the initiator's empty-payload MixHash for msg1.199-  h = mixHash(h, new Uint8Array(0));200-201-  // --- Message 2: -> e, ee, s, es ---202-  const e = generateKeyPair();203-  h = mixHash(h, e.publicKey);204-  const ee = sharedKey(e.secretKey, remoteEphemeral, true);205-  const [ck1, k1] = hkdf2(ck, ee);206-  ck = ck1;207-208-  const cipherStatic = new ChaCha20Poly1305(k1);209-  const encryptedOurStatic = cipherStatic.seal(nonceBytes(0n), ourStaticPublicKey, h);210-  h = mixHash(h, encryptedOurStatic);211-212-  const es = sharedKey(ourStaticPrivateKey, remoteEphemeral, true);213-  const [ck2, k2] = hkdf2(ck, es);214-  ck = ck2;215-216-  const cipherPayload = new ChaCha20Poly1305(k2);217-  const encryptedPayload = cipherPayload.seal(nonceBytes(0n), new Uint8Array(0), h);218-  h = mixHash(h, encryptedPayload);219-220-  const msg2 = new Uint8Array(e.publicKey.length + encryptedOurStatic.length + encryptedPayload.length);221-  msg2.set(e.publicKey, 0);222-  msg2.set(encryptedOurStatic, e.publicKey.length);223-  msg2.set(encryptedPayload, e.publicKey.length + encryptedOurStatic.length);224-  await send(msg2);225-226-  // --- Message 3: <- s, se ---227-  const msg3 = await receive();228-  if (msg3.length < 32 + 16 + 16) throw new Error("Noise XX: msg3 too short");229-  const encryptedRemoteStatic = msg3.slice(0, 32 + 16);230-  const encryptedFinalPayload = msg3.slice(32 + 16);231-232-  const cipherTheirStatic = new ChaCha20Poly1305(k2);233-  const remoteStaticPlain = cipherTheirStatic.open(nonceBytes(1n), encryptedRemoteStatic, h);234-  if (!remoteStaticPlain || remoteStaticPlain.length !== 32) {235-    throw new Error("Noise XX: msg3 static auth failed");236-  }237-  h = mixHash(h, encryptedRemoteStatic);238-239-  const se = sharedKey(e.secretKey, remoteStaticPlain, true);240-  const [ck3, k3] = hkdf2(ck, se);241-  ck = ck3;242-243-  const cipherFinal = new ChaCha20Poly1305(k3);244-  const finalPayloadPlain = cipherFinal.open(nonceBytes(0n), encryptedFinalPayload, h);245-  if (!finalPayloadPlain) {246-    throw new Error("Noise XX: msg3 final payload auth failed");247-  }248-  h = mixHash(h, encryptedFinalPayload);249-250-  const [initSend, respSend] = hkdf2(ck, new Uint8Array(0));251-  const sendCipher = new ChaCha20Poly1305(respSend);252-  const recvCipher = new ChaCha20Poly1305(initSend);253-  let sendNonce = 0n;254-  let recvNonce = 0n;255-256-  return {257-    encrypt(plaintext) {258-      return sendCipher.seal(nonceBytes(sendNonce++), plaintext, new Uint8Array(0));259-    },260-    decrypt(ciphertext) {261-      const result = recvCipher.open(nonceBytes(recvNonce++), ciphertext, new Uint8Array(0));262-      if (!result) throw new Error("Noise XX: decryption failed");263-      return result;264-    },265-    remoteStaticPublicKey: remoteStaticPlain,266-    handshakeHash: h.slice(),267-  };268-}

view file at the parent

src/shared/noise.reject-zero-dh.test.ts+1 −30
@@ -1,11 +1,10 @@1-// PCH-02-009 regression tests — every Noise XX/NK DH must reject an all-zero1+// PCH-02-009 regression tests — every Noise NK DH must reject an all-zero22 // shared secret (low-order / identity peer point) instead of feeding it into33 // the key schedule.44 55 import { describe, expect, it } from "vitest";66 import { generateKeyPair } from "@stablelib/x25519";77 import { noiseNKInitiator, noiseNKResponder } from "./noise-js";8-import { noiseXXInitiator, noiseXXResponder } from "./noise-xx";98 109 const ZERO_POINT = new Uint8Array(32);1110 @@ -58,18 +57,6 @@5857     ).rejects.toThrow(/invalid shared key/);5958   });6059 61-  it("XX responder aborts on an all-zero msg-1 ephemeral", async () => {62-    const ourStatic = generateKeyPair();63-    await expect(64-      noiseXXResponder(65-        ourStatic.secretKey,66-        ourStatic.publicKey,67-        deadTransport.send,68-        async () => ZERO_POINT.slice(),69-      ),70-    ).rejects.toThrow(/invalid shared key/);71-  });72-7360   it("honest NK handshake still completes and transports a frame (regression)", async () => {7461     const responderStatic = generateKeyPair();7562     const { a, b } = queuePair();@@ -84,20 +71,4 @@8471     expect(initiator.handshakeHash).toEqual(responder.handshakeHash);8572   });8673 87-  it("honest XX handshake still completes and transports a frame (regression)", async () => {88-    const initiatorStatic = generateKeyPair();89-    const responderStatic = generateKeyPair();90-    const { a, b } = queuePair();91-92-    const [initiator, responder] = await Promise.all([93-      noiseXXInitiator(initiatorStatic.secretKey, initiatorStatic.publicKey, a.send, a.receive),94-      noiseXXResponder(responderStatic.secretKey, responderStatic.publicKey, b.send, b.receive),95-    ]);96-97-    expect(initiator.remoteStaticPublicKey).toEqual(responderStatic.publicKey);98-    expect(responder.remoteStaticPublicKey).toEqual(initiatorStatic.publicKey);99-100-    const frame = new TextEncoder().encode("peach-xx-frame");101-    expect(responder.decrypt(initiator.encrypt(frame))).toEqual(frame);102-  });10374 });

view file

src/shared/pairing-code.test.ts+1 −1
@@ -46,7 +46,7 @@4646   });4747 4848   it("preserves the full port range", () => {49-    for (const port of [1, 8765, 49142, 65535]) {49+    for (const port of [1, 49142, 51555, 65535]) {5050       const code = encodePairingCode("192.168.4.230", port, token4);5151       expect(parsePairingCode(code)?.port).toBe(port);5252     }

view file

src/shared/pairing-dial.test.ts+5 −19
@@ -1,6 +1,6 @@11 import { beforeEach, describe, expect, it, vi } from "vitest";22 3-import { dialPairingChannel, PAIRING_PORT, type PairingSocket } from "./pairing-dial";3+import { dialPairingChannel, type PairingSocket } from "./pairing-dial";44 import { encodePairingCode } from "./pairing-code";55 66 const enc = (o: unknown) => new TextEncoder().encode(JSON.stringify(o));@@ -59,14 +59,12 @@5959 beforeEach(() => { vi.clearAllMocks(); });6060 6161 describe("dialPairingChannel", () => {62-  it("dials the coded endpoint and never probes for a host", async () => {62+  it("dials the coded endpoint", async () => {6363     const openSocket = vi.fn(async () => ackThen().sock);64-    const findHost = vi.fn(async () => "10.0.0.9");6564 66-    const result = await dialPairingChannel(CODE, { findHost, openSocket });65+    const result = await dialPairingChannel(CODE, { openSocket });6766 6867     expect(result.ok).toBe(true);69-    expect(findHost).not.toHaveBeenCalled();7068     expect(openSocket).toHaveBeenCalledWith("192.168.4.77", 51555);7169   });7270 @@ -79,24 +77,12 @@7977     expect(result.ok && result.dialled.host).toBe("192.168.4.77");8078   });8179 82-  it("probes for a host and sends a padded code for a legacy 6-digit code", async () => {83-    const s = ackThen();84-    const findHost = vi.fn(async () => "10.0.0.9");85-    const openSocket = vi.fn(async () => s.sock);86-87-    await dialPairingChannel("123", { findHost, openSocket });88-89-    expect(findHost).toHaveBeenCalledWith(PAIRING_PORT);90-    expect(openSocket).toHaveBeenCalledWith("10.0.0.9", PAIRING_PORT);91-    expect(dec(s.sent[0])).toEqual({ type: "PEACH_HELLO", code: "000123" });92-  });93-94-  it("has nowhere to go for an addressless code when no probe is supplied", async () => {80+  it("rejects an addressless legacy code before opening a socket", async () => {9581     const openSocket = vi.fn(async () => ackThen().sock);9682 9783     const result = await dialPairingChannel("123456", { openSocket });9884 99-    expect(result).toEqual({ ok: false, error: "no_host_found" });85+    expect(result).toEqual({ ok: false, error: "invalid_code" });10086     expect(openSocket).not.toHaveBeenCalled();10187   });10288 

view file

src/shared/pairing-dial.ts+4 −21
@@ -17,7 +17,7 @@1717 import { derivePairingSAS, type PairingTransport } from "./pairing-handshake";1818 import { parsePairingCode } from "./pairing-code";1919 20-/** The pairing-only port a legacy helper code implies. */20+/** Default pairing port used by native listeners. */2121 export const PAIRING_PORT = 49142;2222 2323 export type PairingDialError =@@ -39,12 +39,6 @@3939 }4040 4141 export interface PairingDialDeps {42-  /**43-   * Legacy 6-digit codes carry no address, so a host has to be found. Omit it44-   * where only pairing codes are accepted — a code without an address then45-   * simply has nowhere to go.46-   */47-  findHost?(port: number): Promise<string | null>;4842   openSocket(ip: string, port: number): Promise<PairingSocket>;4943 }5044 @@ -73,13 +67,9 @@7367   deps: PairingDialDeps,7468 ): Promise<DialResult> {7569   const pairing = parsePairingCode(code);70+  if (!pairing) return { ok: false, error: "invalid_code" };71+  const { ip: host, port } = pairing;7672 77-  // A pairing code already carries an endpoint; a legacy helper code does not,78-  // so the /24 has to be probed for it.79-  const port = pairing?.port ?? PAIRING_PORT;80-  const host = pairing?.ip ?? (await deps.findHost?.(PAIRING_PORT)) ?? null;81-  if (!host) return { ok: false, error: "no_host_found" };82-8373   let sock: PairingSocket;8474   try {8575     sock = await deps.openSocket(host, port);@@ -88,14 +78,7 @@8878   }8979 9080   try {91-    // The legacy helper compares the code byte-for-byte against a [6]u8, so92-    // that form is normalised to exactly 6 zero-padded ASCII digits (ground93-    // truth §2); a pairing token is sent as the hex it decoded to.94-    sock.send(encodeJson(95-      pairing96-        ? { type: "PEACH_HELLO", token: pairing.tokenHex }97-        : { type: "PEACH_HELLO", code: String(code).replace(/\D/g, "").padStart(6, "0").slice(-6) },98-    ));81+    sock.send(encodeJson({ type: "PEACH_HELLO", token: pairing.tokenHex }));9982 10083     // A wrong or expired code gets NO error frame — the listener silently10184     // closes and rotates. That silence IS the wrong-code signal.

view file

src/shared/pairing-handshake.ts+3 −3
@@ -27,7 +27,7 @@2727 //      WebSocket sessions (`shared/sync-transport.ts`) run Noise NK again,2828 //      this time against the long-term static key exchanged in step 2,2929 //      followed by a fresh PAIR_IDENTITY exchange (see30-//      HANDSHAKE_STANDARDIZE_ON_NK.md; ongoing sync does NOT use Noise XX).30+//      HANDSHAKE_STANDARDIZE_ON_NK.md; ongoing sync uses the same pattern).3131 3232 import { noiseNKInitiator, noiseNKResponder } from "./noise-js";3333 import type { DeviceType, PairedDevice } from "./sync-types";@@ -56,11 +56,11 @@5656   deviceType: DeviceType;5757   longTermPublicKeyHex: string;5858   signingPublicKeyHex?: string;59-  /** Server-only field — Android/iOS/desktop sync hosts announce their port. */59+  /** Server-only field — Android/iOS sync hosts announce their port. */6060   wsPort?: number;6161   /**6262    * Stable per-vault id (`computeVaultId`, sync-identity.ts). Sent in the63-   * outbound PAIR_IDENTITY so a desktop helper can record which vault it63+   * outbound PAIR_IDENTITY so a native peer can record which vault it6464    * paired to. Optional: the caller omits it when the vault is locked (no6565    * master key). Privacy: this is the LAN-linkable id — see the PRIVACY NOTE6666    * at sync-identity.ts:62 (PCH-02-006).

view file

src/shared/pairing-handshake.vault-id.test.ts+5 −5
@@ -7,7 +7,7 @@77 //88 // Strategy: stand in as the NK responder ourselves and decrypt the first99 // transport frame the initiator sends — the browser is the NK initiator in the10-// desktop code-pairing flow (_GROUND-TRUTH §3), so this exercises the exact10+// browser-to-phone code-pairing flow, so this exercises the exact1111 // outbound path.1212 1313 import { describe, expect, it } from "vitest";@@ -67,11 +67,11 @@6767     // Send a minimal valid PAIR_IDENTITY back so the initiator resolves.6868     const back = JSON.stringify({6969       type: "PAIR_IDENTITY",70-      device_id: "desktop-001",71-      device_name: "peach-desktop",72-      device_type: "desktop",70+      device_id: "android-001",71+      device_name: "Pixel 8",72+      device_type: "android",7373       long_term_public_key_hex: "a".repeat(64),74-      ws_port: 8765,74+      ws_port: 49142,7575     });7676     await t2.send(session.encrypt(new TextEncoder().encode(back)));7777   })();

view file

src/shared/sync-identity.ts+7 −8
@@ -60,17 +60,16 @@6060 }6161 6262 /**63- * Stable per-vault identifier carried in the mDNS TXT record and the64- * first WebSocket handshake. Receiver rejects the connection if its63+ * Stable per-vault identifier carried inside encrypted pairing and sync64+ * frames. Receiver rejects the connection if its6565  * own vault_id differs — prevents a paired-but-different-vault peer6666  * from corrupting the registry.6767  *68- * PRIVACY NOTE (PCH-02-006): vault_id is stable across sessions and broadcast69- * over mDNS during LAN sync discovery. A passive observer on the same network70- * can link every device sharing this vault and observe sync activity. This is71- * inherent to discovery and one-way — the master key is not recoverable from72- * the BLAKE3 output. Users who need LAN unlinkability should leave sync73- * disabled; "rotate sync identity" re-keys the root key and this identifier.68+ * PRIVACY NOTE (PCH-02-006): vault_id is stable across sessions but is no69+ * longer broadcast by discovery; it travels only after Noise is established.70+ * It remains a one-way identifier — the master key is not recoverable from71+ * the BLAKE3 output. "Rotate sync identity" re-keys the root key and this72+ * identifier.7473  */7574 export async function computeVaultId(masterKey: Uint8Array): Promise<string> {7675   if (masterKey.length === 0) throw new Error("masterKey must not be empty");

view file

src/shared/sync-limits.ts+1 −1
@@ -26,7 +26,7 @@2626 2727 /**2828  * MV-3: hard resource bounds on the sync receive path. A paired/vouched29- * peer (or anything that passes the Noise XX static-key pin) must not be29+ * peer (or anything that passes the Noise NK static-key pin) must not be3030  * able to OOM the service worker with one frame or one giant manifest.3131  */3232 export const SYNC_LIMITS = {

view file

src/shared/sync-transport.ts+4 −4
@@ -1,16 +1,16 @@11 // WebSocket sync transport — standardized on Noise NK (see22 // HANDSHAKE_STANDARDIZE_ON_NK.md). Android's ongoing-sync listener33 // (SyncReconnectListener.kt) is a Noise NK responder on the same fixed port4-// (49142) used for pairing; there is no separate Noise XX responder.4+// (49142) used for pairing; there is one listener and one handshake pattern.55 //66 // The extension is always the WebSocket client; Android (or future iOS /7-// desktop sync hosts) is always the server. Each binary WebSocket message7+// native sync hosts) is always the server. Each binary WebSocket message88 // carries exactly one Noise frame — no length prefix, no fragmentation.99 //1010 // On connect:1111 //   1. Open ws://<ip>:<port>/1212 //   2. Run Noise NK as the initiator against the paired device's known13-//      long-term static public key. Unlike XX, NK authenticates only the13+//      long-term static public key. NK authenticates only the1414 //      responder during the handshake — there is no initiator static1515 //      keypair in the crypto itself.1616 //   3. Exchange PAIR_IDENTITY frames over the encrypted channel (same frame@@ -174,7 +174,7 @@174174   try {175175     // NK's responder static key is a handshake INPUT, not something learned176176     // mid-handshake — a wrong key simply fails msg2 decryption below, so177-    // there is no separate mismatch check to run first (unlike XX).177+    // there is no separate mismatch check to run first.178178     session = await noiseNKInitiator(hexToPubkey(expectedRemotePubkeyHex), send, receive);179179   } catch (err) {180180     try { ws.close(); } catch { /* ignore */ }

view file

src/shared/sync-types.ts+3 −8
@@ -8,22 +8,17 @@88 // Device types99 // ---------------------------------------------------------------------------1010 11-// "desktop" — speculative scaffolding for a possible future desktop app. There12-// is no daemon running anywhere; the only real peers are this extension and13-// Android. Do not build against it (see the browser implementation doc §6).14-export type DeviceType = "extension" | "android" | "ios" | "desktop";11+export type DeviceType = "extension" | "android" | "ios";1512 1613 // Numeric tags for DeviceType on the wire — not the string form.1714 export const DEVICE_TYPE_EXTENSION = 0x01 as const;1815 export const DEVICE_TYPE_ANDROID = 0x02 as const;1916 export const DEVICE_TYPE_IOS = 0x03 as const;20-export const DEVICE_TYPE_DESKTOP = 0x04 as const;2117 2218 export type DeviceTypeByte =2319   | typeof DEVICE_TYPE_EXTENSION2420   | typeof DEVICE_TYPE_ANDROID25-  | typeof DEVICE_TYPE_IOS26-  | typeof DEVICE_TYPE_DESKTOP;21+  | typeof DEVICE_TYPE_IOS;2722 2823 // ---------------------------------------------------------------------------2924 // Long-term paired device registry@@ -134,7 +129,7 @@134129 135130 /**136131  * One device entry inside a signed manifest. `longTermPublicKeyHex` is the137- * X25519 sync identity key the receiver will use for Noise XX, exactly as132+ * X25519 sync identity key the receiver will use for Noise NK, exactly as138133  * if the entry had been audio-paired locally. `signingPublicKeyHex` is the139134  * peer's Ed25519 manifest-verification key — needed so a vouched mobile140135  * can in turn verify manifests issued by the same vault from a different

view file

src/shared/vault-adopt.ts+1 −1
@@ -204,7 +204,7 @@204204 }205205 206206 function isDeviceType(value: unknown): value is DeviceType {207-  return value === "extension" || value === "android" || value === "ios" || value === "desktop";207+  return value === "extension" || value === "android" || value === "ios";208208 }209209 210210 function hexToBytes(hex: string): Uint8Array {

view file