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
↓ peach-browser-3901ccee.tar.gzatlas at this save
files changed51 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 −1manifest.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'"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>"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",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");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 () => {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();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-});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-}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-}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(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 });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.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 };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.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-});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;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 onsrc/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 rotationsrc/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.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`)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`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 ```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`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`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: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) |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-});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)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 thesrc/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 rotationsrc/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;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 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();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> {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 }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 }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 () => {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(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-});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-}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 });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 }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 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.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).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 })();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");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 = {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 */ }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 differentsrc/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 {