441ed70686f7

feat: Wire sync coordinator entry payloads (v2.13.18)

time
2026-04-30 14:13 UTC · 6 months ago
machine
koh2
parent
ae28e74af922
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/sync-coordinator.test.ts+60 −0src/background/sync-coordinator.ts+49 −2
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.13.17",4+  "version": "2.13.18",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.13.17",4+  "version": "2.13.18",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "2.13.17",3+  "version": "2.13.18",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/sync-coordinator.test.ts+60 −0
@@ -1,6 +1,8 @@11 import { describe, expect, it, vi } from "vitest";22 33 import {4+  applySyncEntriesMessage,5+  buildSyncEntriesMessage,46   buildLocalSyncSnapshot,57   cancelSyncIfVaultLocked,68   checkSyncPreflight,@@ -72,6 +74,64 @@7274       offerToRemote: ["local-only"],7375       conflicts: [],7476     });77+  });78+79+  it("builds encrypted entry messages for requested ids", async () => {80+    const rawEntryKey = new Uint8Array(32).fill(5);81+    const message = await buildSyncEntriesMessage(["b"], rawEntryKey, {82+      isProUser: async () => true,83+      isUnlocked: () => true,84+      getEntries: () => [85+        entry({ id: "a" }),86+        entry({87+          id: "b",88+          passkey: {89+            rpId: "github.com",90+            rpName: "GitHub",91+            credentialId: "credential",92+            publicKeyCose: "public",93+            privateKeyJwk: { kty: "EC", d: "secret" },94+            userHandle: "user",95+            userName: "june",96+            userDisplayName: "June",97+            algorithm: -7,98+            signCount: 1,99+            aaguid: "aaguid",100+            backupEligible: true,101+            backupState: true,102+            createdAt: 1_000,103+          },104+        }),105+      ],106+    });107+108+    expect(message.type).toBe("SYNC_ENTRIES");109+    expect(message.entries).toHaveLength(1);110+    expect(message.entries[0]).toMatchObject({ id: "b" });111+    expect(message.entries[0]?.ciphertext).not.toContain("secret");112+  });113+114+  it("decrypts and applies incoming entry messages through vault merge", async () => {115+    const rawEntryKey = new Uint8Array(32).fill(6);116+    const message = await buildSyncEntriesMessage(["remote"], rawEntryKey, {117+      isProUser: async () => true,118+      isUnlocked: () => true,119+      getEntries: () => [entry({ id: "remote", modifiedAt: 2_000 })],120+    });121+    const restoreEntries = vi.fn(async () => ({ added: 1, updated: 0, skipped: 0 }));122+123+    await expect(124+      applySyncEntriesMessage(message, rawEntryKey, {125+        isProUser: async () => true,126+        isUnlocked: () => true,127+        restoreEntries,128+      })129+    ).resolves.toEqual({ added: 1, updated: 0, skipped: 0 });130+131+    expect(restoreEntries).toHaveBeenCalledWith(132+      [expect.objectContaining({ id: "remote", modifiedAt: 2_000 })],133+      "merge"134+    );75135   });76136 77137   it("sends a stable sync error when a peer asks while the vault is locked", async () => {

view file

src/background/sync-coordinator.ts+49 −2
@@ -7,9 +7,18 @@77  * reviewed on its own.88  */99 10-import { buildSyncManifest, diffSyncManifests } from "../shared/sync-protocol";10+import {11+  buildSyncManifest,12+  diffSyncManifests,13+  selectEntriesForSync,14+} from "../shared/sync-protocol";1115 import type { SyncDiff } from "../shared/sync-protocol";12-import type { SyncManifestEntry } from "../shared/sync-types";16+import {17+  decryptSyncEntry,18+  encryptSyncEntry,19+  importSyncEntryKey,20+} from "../shared/sync-entry-payload";21+import type { SyncManifestEntry, SyncMessage } from "../shared/sync-types";1322 import { encodeSyncMessage } from "../shared/sync-wire";1423 import type { VaultEntry } from "../shared/types";1524 @@ -34,6 +43,10 @@3443   isProUser: () => Promise<boolean>;3544   isUnlocked: () => boolean;3645   getEntries: () => VaultEntry[];46+  restoreEntries: (47+    entries: VaultEntry[],48+    mode: "replace" | "merge"49+  ) => Promise<{ added: number; updated: number; skipped: number }>;3750   sendSyncData: (connectionId: string, data: string) => void;3851 }3952 @@ -47,6 +60,7 @@4760     isProUser: license.isProUser,4861     isUnlocked: vault.isUnlocked,4962     getEntries: vault.getEntries,63+    restoreEntries: vault.restoreEntries,5064     sendSyncData: sync.sendSyncData,5165   };5266 }@@ -83,6 +97,39 @@8397     localManifest: snapshot.manifest,8498     diff: diffSyncManifests(snapshot.manifest, remoteManifest),8599   };100+}101+102+export async function buildSyncEntriesMessage(103+  ids: string[],104+  rawEntryKey: Uint8Array,105+  deps?: Pick<SyncCoordinatorDeps, "isProUser" | "isUnlocked" | "getEntries">106+): Promise<Extract<SyncMessage, { type: "SYNC_ENTRIES" }>> {107+  const readyDeps = deps ?? (await loadDefaultDeps());108+  const preflight = await checkSyncPreflight(readyDeps);109+  if (!preflight.ok) throw new Error(preflight.error);110+111+  const key = await importSyncEntryKey(rawEntryKey);112+  const selected = selectEntriesForSync(readyDeps.getEntries(), ids);113+  return {114+    type: "SYNC_ENTRIES",115+    entries: await Promise.all(selected.map((entry) => encryptSyncEntry(entry, key))),116+  };117+}118+119+export async function applySyncEntriesMessage(120+  message: Extract<SyncMessage, { type: "SYNC_ENTRIES" }>,121+  rawEntryKey: Uint8Array,122+  deps?: Pick<SyncCoordinatorDeps, "isProUser" | "isUnlocked" | "restoreEntries">123+): Promise<{ added: number; updated: number; skipped: number }> {124+  const readyDeps = deps ?? (await loadDefaultDeps());125+  const preflight = await checkSyncPreflight(readyDeps);126+  if (!preflight.ok) throw new Error(preflight.error);127+128+  const key = await importSyncEntryKey(rawEntryKey);129+  const entries = await Promise.all(130+    message.entries.map((payload) => decryptSyncEntry(payload, key))131+  );132+  return readyDeps.restoreEntries(entries, "merge");86133 }87134 88135 export async function cancelSyncIfVaultLocked(

view file