441ed70686f7
feat: Wire sync coordinator entry payloads (v2.13.18)
- time
- 2026-04-30 14:13 UTC · 6 months ago
- machine
- koh2
- parent
- ae28e74af922
↓ peach-browser-441ed706.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/sync-coordinator.test.ts+60 −0src/background/sync-coordinator.ts+49 −2manifest.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": {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",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": {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 () => {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(