d81f77dc4b6c

fix: unify sync transcript and enforce cross-client gate (v5.2.24)

time
2026-09-09 00:48 UTC · 1 month ago
machine
koh2
agent
gpt-5
session
close sync protocol gaps
parent
68c8cfc69519
11 files fixtures/golden/sync-entry-envelope-v1.json+7 −0manifest.firefox.json+1 −1manifest.json+1 −1package.json+2 −1scripts/check-cross-client-sync.mjs+70 −0scripts/package-source.test.ts+1 −0scripts/package-source.ts+1 −0src/background/sync-coordinator.test.ts+91 −22src/background/sync-coordinator.ts+12 −81src/shared/sync-entry-envelope.kat.test.ts+53 −0src/shared/sync-types.ts+2 −2
fixtures/golden/sync-entry-envelope-v1.json+7 −0
@@ -0,0 +1,7 @@1+{2+  "masterKeyHex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",3+  "entryId": "11111111-2222-3333-4444-555555555555",4+  "nonceHex": "000102030405060708090a0b",5+  "plaintext": "{\"id\":\"11111111-2222-3333-4444-555555555555\",\"password\":\"Vault V4 🔐\"}",6+  "envelopeBase64": "AAECAwQFBgcICQoLeOxZkqP1ZwXwhy/q2ieiS8iT5dOZ23+uIu8ni+gvmGhs9R8X6t0qJuqssLeVdaX+oBXba0+FEdhXLMKhULD7WbEMokpegODwME+i1oEKk/8oxgxK4im8jw=="7+}

view file

manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.2.23",4+  "version": "5.2.24",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -2,7 +2,7 @@22   "manifest_version": 3,33   "minimum_chrome_version": "116",44   "name": "Peach Password Manager",5-  "version": "5.2.23",5+  "version": "5.2.24",66   "description": "Zero-knowledge password manager",77   "action": {88     "default_popup": "src/popup/index.html",

view file

package.json+2 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "5.2.23",3+  "version": "5.2.24",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",@@ -18,6 +18,7 @@1818     "lint:fix": "biome check --write src/",1919     "typecheck": "tsc --noEmit",2020     "test": "vitest run",21+    "test:sync:cross-client": "node scripts/check-cross-client-sync.mjs",2122     "test:watch": "vitest",2223     "test:e2e": "npm run build:chrome && playwright test tests/vault-lifecycle.spec.ts tests/entry-crud.spec.ts tests/search.spec.ts tests/settings.spec.ts tests/lock-screen.spec.ts tests/vault-security.spec.ts tests/sync-alerts.spec.ts tests/pb26016-sender-context.spec.ts",2324     "test:passkeys": "npm run build:e2e && playwright test tests/passkeys.spec.ts",

view file

scripts/check-cross-client-sync.mjs+70 −0
@@ -0,0 +1,70 @@1+import { execFileSync } from "node:child_process";2+import { existsSync, readFileSync } from "node:fs";3+import { dirname, resolve } from "node:path";4+import { fileURLToPath } from "node:url";5+6+const browserRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");7+const androidRoot = resolve(browserRoot, "../peach-android");8+const localJavaHome = "/opt/homebrew/opt/openjdk@17";9+const javaHome = process.env.JAVA_HOME ??10+  (existsSync(localJavaHome) ? localJavaHome : undefined);11+const browserCoordinator = readFileSync(12+  resolve(browserRoot, "src/background/sync-coordinator.ts"),13+  "utf8",14+);15+const androidCoordinator = readFileSync(16+  resolve(androidRoot, "app/src/main/java/com/peachpasswords/sync/SyncCoordinator.kt"),17+  "utf8",18+);19+20+if (browserCoordinator.includes('helloIn.version === localVersion')) {21+  throw new Error("Browser still contains the forbidden equal-version fast path");22+}23+if (!browserCoordinator.includes("helloIn.deviceManifestVersion === DEVICE_MANIFEST_VERSION")) {24+  throw new Error("Browser does not negotiate device manifests from SYNC_HELLO");25+}26+if (!androidCoordinator.includes("val exchangeDeviceManifest = vault.negotiatesDeviceManifest(hello)")) {27+  throw new Error("Android does not negotiate device manifests from SYNC_HELLO");28+}29+30+execFileSync(31+  "npm",32+  [33+    "test", "--", "--run",34+    "src/background/sync-coordinator.test.ts",35+    "src/shared/sync-padding.test.ts",36+    "src/shared/rendezvous.test.ts",37+    "src/shared/device-manifest.test.ts",38+    "src/crypto/peachPayload.test.ts",39+    "src/shared/sync-entry-envelope.kat.test.ts",40+  ],41+  { cwd: browserRoot, stdio: "inherit" },42+);43+execFileSync(44+  resolve(androidRoot, "gradlew"),45+  [46+    "--no-configuration-cache",47+    "testDebugUnitTest",48+    "--tests",49+    "com.peachpasswords.sync.SyncCoordinatorTest",50+    "--tests",51+    "com.peachpasswords.sync.LockedSyncSpoolModelTest",52+    "--tests",53+    "com.peachpasswords.sync.SyncPaddingTest",54+    "--tests",55+    "com.peachpasswords.sync.DeviceManifestSupportTest",56+    "--tests",57+    "com.peachpasswords.CrossPlatformKATTest",58+    "--tests",59+    "com.peachpasswords.vault.PeachScriptTest",60+    "--tests",61+    "com.peachpasswords.sync.SyncEntryEnvelopeKatTest",62+  ],63+  {64+    cwd: androidRoot,65+    env: { ...process.env, ...(javaHome ? { JAVA_HOME: javaHome } : {}) },66+    stdio: "inherit",67+  },68+);69+70+console.log("Cross-client sync protocol gate passed.");

view file

scripts/package-source.test.ts+1 −0
@@ -17,6 +17,7 @@1717 const REQUIRED_FILES = [1818   "src/shared/noise-core-wasm/peach_noise_core_bg.wasm",1919   "fixtures/golden/peach-payload-v4.json",20+  "fixtures/golden/sync-entry-envelope-v1.json",2021   "crypto-core/Cargo.toml",2122   "crypto-core/Cargo.lock",2223   "crypto-core/src/lib.rs",

view file

scripts/package-source.ts+1 −0
@@ -48,6 +48,7 @@4848 const INCLUDE_DIRS = ["src", "scripts", "public", "crypto-core"];4949 const INCLUDE_FILES = [5050   "fixtures/golden/peach-payload-v4.json",51+  "fixtures/golden/sync-entry-envelope-v1.json",5152   "manifest.json",5253   "manifest.firefox.json",5354   "package.json",

view file

src/background/sync-coordinator.test.ts+91 −22
@@ -5,7 +5,11 @@55   runSyncResponder,66   type SyncVaultAdapter,77 } from "./sync-coordinator";8-import type { SyncMessage, SignedDeviceManifest } from "../shared/sync-types";8+import {9+  DEVICE_MANIFEST_VERSION,10+  type SyncMessage,11+  type SignedDeviceManifest,12+} from "../shared/sync-types";913 import type { SyncSession } from "../shared/sync-transport";1014 import { buildSignedDeviceManifest } from "../shared/device-manifest";1115 import { deriveSyncSigningKeypair } from "../shared/sync-identity";@@ -279,26 +283,30 @@279283     await initPromise;280284   });281285 282-  it("skips manifest exchange and returns completed when versions are equal", async () => {283-    const [session, peer] = inMemoryPair();284-    const vault = makeVaultAdapter({ localVersion: 10 });286+  it("exchanges entry manifests when vault versions are equal", async () => {287+    const [dialerSession, waiterSession] = symmetricSessionPair();288+    const dialer = makeVaultAdapter({289+      deviceId: "dialer",290+      localVersion: 10,291+      entries: { a: 10 },292+      ciphertext: { a: "cipher-a" },293+    });294+    const waiter = makeVaultAdapter({295+      deviceId: "waiter",296+      localVersion: 10,297+      entries: { b: 11 },298+      ciphertext: { b: "cipher-b" },299+    });285300 286-    const initPromise = runSyncInitiator(session, vault);301+    const [dialerOutcome, waiterOutcome] = await Promise.all([302+      runSyncInitiator(dialerSession, dialer),303+      runSyncResponder(waiterSession, waiter),304+    ]);287305 288-    await peer.receive(); // SYNC_HELLO289-    await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 10, deviceTime: Date.now(), protocolVersion: 2 });290-291-    // Coordinator should send SYNC_DONE without any manifest exchange.292-    const done = await peer.receive();293-    expect(done.type).toBe("SYNC_DONE");294-295-    // No SYNC_MANIFEST was sent — peer's queue only has the DONE.296-    await peer.send({ type: "SYNC_DONE" });297-298-    const outcome = await initPromise;299-    expect(outcome.status).toBe("completed");300-    expect(outcome.entriesReceived).toBe(0);301-    expect(outcome.entriesSent).toBe(0);306+    expect(dialerOutcome.status).toBe("completed");307+    expect(waiterOutcome.status).toBe("completed");308+    expect(dialer.appliedEntries).toEqual([{ b: "cipher-b" }]);309+    expect(waiter.appliedEntries).toEqual([{ a: "cipher-a" }]);302310   });303311 304312   it("exchanges device graphs at equal entry versions when both peers advertise support", async () => {@@ -347,6 +355,60 @@347355     expect(waiterApplied).toEqual([dialerManifest]);348356   });349357 358+  it("negotiates the device-manifest step from both HELLOs in every role and version case", async () => {359+    for (const initiatorSupports of [false, true]) {360+      for (const responderSupports of [false, true]) {361+        for (const equalVersions of [false, true]) {362+          const [initiatorSession, responderSession] = symmetricSessionPair();363+          const appliedByInitiator: SignedDeviceManifest[] = [];364+          const appliedByResponder: SignedDeviceManifest[] = [];365+          const manifest = (issuerDeviceId: string): SignedDeviceManifest => ({366+            body: {367+              manifestVersion: DEVICE_MANIFEST_VERSION,368+              issuerDeviceId,369+              issuedAt: 1,370+              devices: [],371+              revoked: [],372+            },373+            signatureHex: "11".repeat(64),374+          });375+          const adapter = (376+            deviceId: string,377+            localVersion: number,378+            supports: boolean,379+            applied: SignedDeviceManifest[],380+          ): SyncVaultAdapter => ({381+            ...makeVaultAdapter({ deviceId, localVersion }),382+            ...(supports ? {383+              async buildOutgoingDeviceManifest() { return manifest(deviceId); },384+              async applyIncomingDeviceManifest(incoming: SignedDeviceManifest) {385+                applied.push(incoming);386+              },387+            } : {}),388+          });389+          const initiator = adapter("initiator", 7, initiatorSupports, appliedByInitiator);390+          const responder = adapter(391+            "responder",392+            equalVersions ? 7 : 9,393+            responderSupports,394+            appliedByResponder,395+          );396+397+          const [initiatorOutcome, responderOutcome] = await Promise.all([398+            runSyncInitiator(initiatorSession, initiator),399+            runSyncResponder(responderSession, responder),400+          ]);401+          const bothSupport = initiatorSupports && responderSupports;402+403+          expect(initiatorOutcome.status).toBe("completed");404+          expect(responderOutcome.status).toBe("completed");405+          expect(appliedByInitiator).toHaveLength(bothSupport ? 1 : 0);406+          expect(appliedByResponder).toHaveLength(bothSupport ? 1 : 0);407+        }408+      }409+    }410+  });411+350412   it("logs a clock-skew warning but does not abort when clocks differ by > 120s", async () => {351413     const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);352414     const [session, peer] = inMemoryPair();@@ -561,7 +623,7 @@561623     const initPromise = runSyncInitiator(session, vault);562624     const peerProtocol = (async () => {563625       await peer.receive(); // hello564-      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 3, deviceTime: Date.now(), protocolVersion: 2 });626+      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 3, deviceTime: Date.now(), protocolVersion: 2, deviceManifestVersion: 4 });565627       await peer.receive(); // manifest566628       await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });567629       await peer.receive(); // request@@ -598,7 +660,7 @@598660     const initPromise = runSyncInitiator(session, vault);599661     const peerProtocol = (async () => {600662       await peer.receive();601-      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 3, deviceTime: Date.now(), protocolVersion: 2 });663+      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 3, deviceTime: Date.now(), protocolVersion: 2, deviceManifestVersion: 4 });602664       await peer.receive();603665       await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });604666       await peer.receive();@@ -650,7 +712,14 @@650712     void (async () => {651713       await peer.receive(); // hello652714       await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 5, deviceTime: Date.now(), protocolVersion: 2 });653-      await peer.receive(); // our done715+      await peer.receive(); // manifest716+      await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });717+      await peer.receive(); // request718+      await peer.send({ type: "SYNC_REQUEST", entryIds: [] });719+      await peer.receive(); // unconditional entries720+      await peer.receive(); // tombstones721+      await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });722+      await peer.receive(); // done654723       // No SYNC_DONE echo — Android just hangs up.655724       await peer.closeInitiatorReceive();656725     })();

view file

src/background/sync-coordinator.ts+12 −81
@@ -160,43 +160,9 @@160160       console.warn(`[sync] clock skew detected: local=${localTime} remote=${helloIn.deviceTime ?? "unknown"}`);161161     }162162 163-    // Equal entry versions do not imply equal device graphs: paired-device164-    // introductions and revocations live outside the vault version counter.165-    // Exchange the signed graph when both peers advertise support, while an166-    // older peer that omits the capability retains the legacy DONE fast path.167-    if (typeof helloIn.version === "number" && helloIn.version === localVersion) {168-      if (169-        supportsDeviceManifest &&170-        helloIn.deviceManifestVersion === DEVICE_MANIFEST_VERSION171-      ) {172-        stage = "building_device_manifest";173-        const ours = await vault.buildOutgoingDeviceManifest!();174-        await sendMessage(session, {175-          type: "SYNC_DEVICE_MANIFEST",176-          manifest: ours,177-        });178-        stage = "waiting_for_device_graph";179-        const graphIn = await receiveMessage(session, timer);180-        if (graphIn.type !== "SYNC_DEVICE_MANIFEST") {181-          return errorOutcome(182-            result,183-            `expected SYNC_DEVICE_MANIFEST, got ${graphIn.type}`,184-          );185-        }186-        if (graphIn.manifest) {187-          await vault.applyIncomingDeviceManifest!(188-            graphIn.manifest,189-            session.remoteStaticPublicKey,190-          );191-        }192-      }193-      await sendMessage(session, { type: "SYNC_DONE", entriesReceived: 0, entriesSent: 0 });194-      stage = "waiting_for_device_done";195-      const closing = await awaitClosingDone(session, timer);196-      if (closing.error) return errorOutcome(result, closing.error);197-      result.status = "completed";198-      return result;199-    }163+    const exchangeDeviceManifest =164+      supportsDeviceManifest &&165+      helloIn.deviceManifestVersion === DEVICE_MANIFEST_VERSION;200166 201167     stage = "building_local_manifest";202168     const ourEntries = await vault.buildEntryManifest();@@ -266,9 +232,9 @@266232     // bypasses the step. The exchange is symmetric on the wire; the267233     // production adapter accepts only a directly paired issuer whose stored268234     // long-term key matches the Noise-authenticated remote static key.269-    if (vault.buildOutgoingDeviceManifest && vault.applyIncomingDeviceManifest) {235+    if (exchangeDeviceManifest) {270236       stage = "building_device_manifest";271-      const ours = await vault.buildOutgoingDeviceManifest();237+      const ours = await vault.buildOutgoingDeviceManifest!();272238       await sendMessage(session, { type: "SYNC_DEVICE_MANIFEST", manifest: ours });273239       stage = "waiting_for_device_graph";274240       const manifestIn = await receiveMessage(session, timer);@@ -276,7 +242,7 @@276242         return errorOutcome(result, `expected SYNC_DEVICE_MANIFEST, got ${manifestIn.type}`);277243       }278244       if (manifestIn.manifest) {279-        await vault.applyIncomingDeviceManifest(245+        await vault.applyIncomingDeviceManifest!(280246           manifestIn.manifest,281247           session.remoteStaticPublicKey,282248         );@@ -364,44 +330,9 @@364330       );365331     }366332 367-    if (helloIn.version === localVersion) {368-      if (369-        supportsDeviceManifest &&370-        helloIn.deviceManifestVersion === DEVICE_MANIFEST_VERSION371-      ) {372-        stage = "waiting_for_device_graph";373-        const graphIn = await receiveMessage(session, timer);374-        if (graphIn.type !== "SYNC_DEVICE_MANIFEST") {375-          return errorOutcome(376-            result,377-            `expected SYNC_DEVICE_MANIFEST, got ${graphIn.type}`,378-          );379-        }380-        const ours = await vault.buildOutgoingDeviceManifest!();381-        await sendMessage(session, {382-          type: "SYNC_DEVICE_MANIFEST",383-          manifest: ours,384-        });385-        if (graphIn.manifest) {386-          await vault.applyIncomingDeviceManifest!(387-            graphIn.manifest,388-            session.remoteStaticPublicKey,389-          );390-        }391-      }392-      stage = "waiting_for_device_done";393-      const done = await receiveMessage(session, timer);394-      if (done.type !== "SYNC_DONE") {395-        return errorOutcome(result, `expected SYNC_DONE, got ${done.type}`);396-      }397-      await sendMessage(session, {398-        type: "SYNC_DONE",399-        entriesReceived: 0,400-        entriesSent: 0,401-      });402-      result.status = "completed";403-      return result;404-    }333+    const exchangeDeviceManifest =334+      supportsDeviceManifest &&335+      helloIn.deviceManifestVersion === DEVICE_MANIFEST_VERSION;405336 406337     const ourEntries = await vault.buildEntryManifest();407338     const ourTombstones = await vault.buildTombstoneManifest();@@ -463,7 +394,7 @@463394     });464395     result.tombstonesSent = Object.keys(newerTombstonesForPeer).length;465396 466-    if (vault.buildOutgoingDeviceManifest && vault.applyIncomingDeviceManifest) {397+    if (exchangeDeviceManifest) {467398       stage = "waiting_for_device_graph";468399       const graphIn = await receiveMessage(session, timer);469400       if (graphIn.type !== "SYNC_DEVICE_MANIFEST") {@@ -472,13 +403,13 @@472403           `expected SYNC_DEVICE_MANIFEST, got ${graphIn.type}`,473404         );474405       }475-      const ours = await vault.buildOutgoingDeviceManifest();406+      const ours = await vault.buildOutgoingDeviceManifest!();476407       await sendMessage(session, {477408         type: "SYNC_DEVICE_MANIFEST",478409         manifest: ours,479410       });480411       if (graphIn.manifest) {481-        await vault.applyIncomingDeviceManifest(412+        await vault.applyIncomingDeviceManifest!(482413           graphIn.manifest,483414           session.remoteStaticPublicKey,484415         );

view file

src/shared/sync-entry-envelope.kat.test.ts+53 −0
@@ -0,0 +1,53 @@1+import { readFileSync } from "node:fs";2+import { HKDF } from "@stablelib/hkdf";3+import { SHA256 } from "@stablelib/sha256";4+import { expect, it } from "vitest";5+6+type Vector = {7+  masterKeyHex: string;8+  entryId: string;9+  nonceHex: string;10+  plaintext: string;11+  envelopeBase64: string;12+};13+14+const vector = JSON.parse(15+  readFileSync("fixtures/golden/sync-entry-envelope-v1.json", "utf8"),16+) as Vector;17+const fromHex = (hex: string) =>18+  Uint8Array.from(hex.match(/../g)!.map((byte) => Number.parseInt(byte, 16)));19+20+it("matches the cross-client sync-entry-v1 envelope vector", async () => {21+  const info = new TextEncoder().encode(`${vector.entryId}\0sync-entry-v1`);22+  const rawKey = new HKDF(23+    SHA256,24+    fromHex(vector.masterKeyHex),25+    new Uint8Array(0),26+    info,27+  ).expand(32);28+  const nonce = fromHex(vector.nonceHex);29+  const additionalData = new TextEncoder().encode("peach-sync-entry-v1");30+  const plaintext = new TextEncoder().encode(vector.plaintext);31+  const key = await crypto.subtle.importKey(32+    "raw",33+    rawKey.buffer as ArrayBuffer,34+    "AES-GCM",35+    false,36+    ["encrypt"],37+  );38+  const ciphertext = new Uint8Array(await crypto.subtle.encrypt(39+    {40+      name: "AES-GCM",41+      iv: nonce.buffer as ArrayBuffer,42+      additionalData: additionalData.buffer as ArrayBuffer,43+      tagLength: 128,44+    },45+    key,46+    plaintext.buffer as ArrayBuffer,47+  ));48+  const envelope = new Uint8Array(12 + ciphertext.length);49+  envelope.set(nonce);50+  envelope.set(ciphertext, 12);51+52+  expect(Buffer.from(envelope).toString("base64")).toBe(vector.envelopeBase64);53+});

view file

src/shared/sync-types.ts+2 −2
@@ -96,8 +96,8 @@9696        */9797       protocolVersion?: number;9898       /**99-       * Advertises equal-version device-graph exchange support. Older peers100-       * omit this field and retain the legacy SYNC_DONE fast path.99+       * Advertises device-graph exchange support. The step runs only when both100+       * peers advertise the same supported version.101101        */102102       deviceManifestVersion?: number;103103     }

view file