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
↓ peach-browser-d81f77dc.tar.gzatlas at this save
files changed11 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 −2fixtures/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+}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": {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",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",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.");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",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",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 })();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 );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+});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 }