e4d716993d01
replace per-entry sync stack with blob protocol — applyIncomingVault + runBlobSyncSession (v2.14.1)
- time
- 2026-04-30 20:28 UTC · 6 months ago
- machine
- koh2
- parent
- bb21a0e3dc56
↓ peach-browser-e4d71699.tar.gzatlas at this save
files changed12 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/sync-coordinator.test.ts+0 −717src/background/sync-coordinator.ts+233 −423src/shared/sync-entry-payload.test.ts+0 −95src/shared/sync-entry-payload.ts+0 −118src/shared/sync-protocol.test.ts+0 −147src/shared/sync-protocol.ts+0 −167src/shared/sync-types.ts+37 −25src/shared/sync-wire.test.ts+42 −27src/shared/sync-wire.ts+9 −45manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach",4- "version": "2.14.0",4+ "version": "2.14.1",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.14.0",4+ "version": "2.14.1",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.14.0",3+ "version": "2.14.1",44 "description": "Zero-knowledge password manager",55 "type": "module",66 "scripts": {src/background/sync-coordinator.test.ts+0 −717
@@ -1,717 +0,0 @@1-import { describe, expect, it, vi } from "vitest";2-3-import {4- applySyncEntriesMessage,5- buildSyncEntriesMessage,6- buildLocalSyncSnapshot,7- cancelSyncIfVaultLocked,8- checkSyncPreflight,9- createNativeSyncTransport,10- dispatchSyncFrame,11- planManifestSync,12- runSyncSession,13- type NativeSyncTransportBindings,14- type SyncSessionTransport,15-} from "./sync-coordinator";16-import { decodeSyncMessage, encodeSyncMessage } from "../shared/sync-wire";17-import type { VaultEntry } from "../shared/types";18-19-function entry(overrides: Partial<VaultEntry> = {}): VaultEntry {20- return {21- id: "entry-1",22- type: "login",23- title: "GitHub",24- username: "june",25- password: "secret",26- url: "https://github.com",27- createdAt: 1_000,28- modifiedAt: 1_000,29- ...overrides,30- };31-}32-33-describe("sync coordinator", () => {34- it("blocks sync for free users before inspecting the vault", async () => {35- const getEntries = vi.fn(() => [entry()]);36-37- await expect(38- checkSyncPreflight({39- isProUser: async () => false,40- isUnlocked: () => true,41- })42- ).resolves.toEqual({ ok: false, error: "pro_required" });43-44- expect(getEntries).not.toHaveBeenCalled();45- });46-47- it("blocks sync when the vault is locked", async () => {48- await expect(49- checkSyncPreflight({50- isProUser: async () => true,51- isUnlocked: () => false,52- })53- ).resolves.toEqual({ ok: false, error: "vault_locked" });54- });55-56- it("builds a local sync snapshot only after preflight passes", async () => {57- const snapshot = await buildLocalSyncSnapshot({58- isProUser: async () => true,59- isUnlocked: () => true,60- getEntries: () => [entry({ id: "b" }), entry({ id: "a", modifiedAt: 2_000 })],61- });62-63- expect(snapshot.entries).toHaveLength(2);64- expect(snapshot.manifest.map((item) => item.id)).toEqual(["a", "b"]);65- });66-67- it("plans manifest sync without sending entry payloads", async () => {68- const plan = await planManifestSync(69- [{ id: "remote-only", modifiedAt: 1_500, hash: "remote" }],70- {71- isProUser: async () => true,72- isUnlocked: () => true,73- getEntries: () => [entry({ id: "local-only", modifiedAt: 2_000 })],74- }75- );76-77- expect(plan.diff).toEqual({78- requestFromRemote: ["remote-only"],79- offerToRemote: ["local-only"],80- conflicts: [],81- });82- });83-84- it("builds encrypted entry messages for requested ids", async () => {85- const rawEntryKey = new Uint8Array(32).fill(5);86- const message = await buildSyncEntriesMessage(["b"], rawEntryKey, {87- isProUser: async () => true,88- isUnlocked: () => true,89- getEntries: () => [90- entry({ id: "a" }),91- entry({92- id: "b",93- passkey: {94- rpId: "github.com",95- rpName: "GitHub",96- credentialId: "credential",97- publicKeyCose: "public",98- privateKeyJwk: { kty: "EC", d: "secret" },99- userHandle: "user",100- userName: "june",101- userDisplayName: "June",102- algorithm: -7,103- signCount: 1,104- aaguid: "aaguid",105- backupEligible: true,106- backupState: true,107- createdAt: 1_000,108- },109- }),110- ],111- });112-113- expect(message.type).toBe("SYNC_ENTRIES");114- expect(message.entries).toHaveLength(1);115- expect(message.entries[0]).toMatchObject({ id: "b" });116- expect(message.entries[0]?.ciphertext).not.toContain("secret");117- });118-119- it("decrypts and applies incoming entry messages through vault merge", async () => {120- const rawEntryKey = new Uint8Array(32).fill(6);121- const message = await buildSyncEntriesMessage(["remote"], rawEntryKey, {122- isProUser: async () => true,123- isUnlocked: () => true,124- getEntries: () => [entry({ id: "remote", modifiedAt: 2_000 })],125- });126- const restoreEntries = vi.fn(async () => ({ added: 1, updated: 0, skipped: 0 }));127-128- await expect(129- applySyncEntriesMessage(message, rawEntryKey, {130- isProUser: async () => true,131- isUnlocked: () => true,132- restoreEntries,133- })134- ).resolves.toEqual({ added: 1, updated: 0, skipped: 0 });135-136- expect(restoreEntries).toHaveBeenCalledWith(137- [expect.objectContaining({ id: "remote", modifiedAt: 2_000 })],138- "merge"139- );140- });141-142- it("sends a stable sync error when a peer asks while the vault is locked", async () => {143- const sendSyncData = vi.fn();144-145- const cancelled = await cancelSyncIfVaultLocked("conn-1", {146- isUnlocked: () => false,147- sendSyncData,148- });149-150- expect(cancelled).toBe(true);151- expect(sendSyncData).toHaveBeenCalledWith("conn-1", expect.any(String));152- expect(decodeSyncMessage(sendSyncData.mock.calls[0]![1])).toEqual({153- type: "SYNC_ERROR",154- code: "vault_locked",155- message: "Vault is locked; unlock Peach before syncing.",156- });157- });158-159- it("does not cancel an unlocked vault sync", async () => {160- const sendSyncData = vi.fn();161-162- expect(163- await cancelSyncIfVaultLocked("conn-1", {164- isUnlocked: () => true,165- sendSyncData,166- })167- ).toBe(false);168- expect(sendSyncData).not.toHaveBeenCalled();169- });170-171- describe("dispatchSyncFrame", () => {172- const passDeps = (entries: VaultEntry[]) => ({173- isProUser: async () => true,174- isUnlocked: () => true,175- getEntries: () => entries,176- restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),177- });178-179- it("answers SYNC_HELLO with a sorted SYNC_MANIFEST", async () => {180- const deps = passDeps([entry({ id: "b" }), entry({ id: "a" })]);181- const result = await dispatchSyncFrame(182- encodeSyncMessage({183- type: "SYNC_HELLO",184- deviceId: "device",185- clockMs: 1,186- vaultVersion: 1,187- }),188- new Uint8Array(32).fill(1),189- deps190- );191- expect(result.outbound).toHaveLength(1);192- const decoded = decodeSyncMessage(result.outbound[0]!);193- expect(decoded.type).toBe("SYNC_MANIFEST");194- if (decoded.type === "SYNC_MANIFEST") {195- expect(decoded.entries.map((entry) => entry.id)).toEqual(["a", "b"]);196- }197- });198-199- it("requests missing ids and offers local-only ids on SYNC_MANIFEST", async () => {200- const deps = passDeps([entry({ id: "local", modifiedAt: 2_000 })]);201- const result = await dispatchSyncFrame(202- encodeSyncMessage({203- type: "SYNC_MANIFEST",204- entries: [{ id: "remote", modifiedAt: 1_500, hash: "remote" }],205- }),206- new Uint8Array(32).fill(2),207- deps208- );209- const messages = result.outbound.map((frame) => decodeSyncMessage(frame));210- expect(messages.find((m) => m.type === "SYNC_REQUEST")).toMatchObject({211- type: "SYNC_REQUEST",212- ids: ["remote"],213- });214- const entriesMessage = messages.find((m) => m.type === "SYNC_ENTRIES");215- expect(entriesMessage?.type).toBe("SYNC_ENTRIES");216- if (entriesMessage?.type === "SYNC_ENTRIES") {217- expect(entriesMessage.entries.map((e) => e.id)).toEqual(["local"]);218- }219- });220-221- it("resolves equal-timestamp conflicts deterministically: local wins → offer", async () => {222- const localEntries = [entry({ id: "shared", title: "LocalEdit", modifiedAt: 1_000 })];223- const restoreEntries = vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 }));224- const localManifest = (225- await (await import("../shared/sync-protocol")).buildSyncManifest(localEntries)226- )[0]!;227- // Pick a remote hash that lex-compares larger so local wins.228- const remoteHash =229- localManifest.hash < "f".repeat(64) ? "f".repeat(64) : "0".repeat(64);230-231- const result = await dispatchSyncFrame(232- encodeSyncMessage({233- type: "SYNC_MANIFEST",234- entries: [{ id: "shared", modifiedAt: 1_000, hash: remoteHash }],235- }),236- new Uint8Array(32).fill(20),237- {238- isProUser: async () => true,239- isUnlocked: () => true,240- getEntries: () => localEntries,241- restoreEntries,242- }243- );244-245- const messages = result.outbound.map((frame) => decodeSyncMessage(frame));246- // Local wins → offer "shared", no request, no archive.247- expect(restoreEntries).not.toHaveBeenCalled();248- const offer = messages.find((m) => m.type === "SYNC_ENTRIES");249- expect(offer?.type).toBe("SYNC_ENTRIES");250- if (offer?.type === "SYNC_ENTRIES") {251- expect(offer.entries.map((e) => e.id)).toEqual(["shared"]);252- }253- expect(messages.find((m) => m.type === "SYNC_REQUEST")).toBeUndefined();254- });255-256- it("resolves equal-timestamp conflicts deterministically: remote wins → request + archive", async () => {257- const localEntries = [entry({ id: "shared", title: "LocalEdit", modifiedAt: 1_000 })];258- const restoreEntries = vi.fn(async () => ({ added: 1, updated: 0, skipped: 0 }));259- const localManifest = (260- await (await import("../shared/sync-protocol")).buildSyncManifest(localEntries)261- )[0]!;262- // Pick a remote hash that lex-compares smaller so remote wins.263- const remoteHash =264- localManifest.hash > "0".repeat(64) ? "0".repeat(64) : "f".repeat(64);265-266- const result = await dispatchSyncFrame(267- encodeSyncMessage({268- type: "SYNC_MANIFEST",269- entries: [{ id: "shared", modifiedAt: 1_000, hash: remoteHash }],270- }),271- new Uint8Array(32).fill(21),272- {273- isProUser: async () => true,274- isUnlocked: () => true,275- getEntries: () => localEntries,276- restoreEntries,277- }278- );279-280- // Remote wins → request "shared" from peer, archive our local copy under281- // a derived id, no offer.282- expect(restoreEntries).toHaveBeenCalledTimes(1);283- const archived = (restoreEntries.mock.calls[0] as unknown as [VaultEntry[]])[0];284- expect(archived).toHaveLength(1);285- expect(archived[0]!.id).toBe(`shared-conflict-${localManifest.hash.slice(0, 8)}`);286- expect(archived[0]!.title).toBe("LocalEdit (conflicted copy)");287-288- const messages = result.outbound.map((frame) => decodeSyncMessage(frame));289- expect(messages.find((m) => m.type === "SYNC_REQUEST")).toMatchObject({290- type: "SYNC_REQUEST",291- ids: ["shared"],292- });293- expect(messages.find((m) => m.type === "SYNC_ENTRIES")).toBeUndefined();294- expect(result.merge).toEqual({ added: 1, updated: 0, skipped: 0 });295- });296-297- it("returns SYNC_DONE when manifests already agree", async () => {298- const deps = passDeps([entry({ id: "shared", modifiedAt: 1_000 })]);299- const localManifest = encodeSyncMessage({300- type: "SYNC_MANIFEST",301- entries: [302- {303- id: "shared",304- modifiedAt: 1_000,305- hash: (306- await (await import("../shared/sync-protocol")).buildSyncManifest(307- deps.getEntries()308- )309- )[0]!.hash,310- },311- ],312- });313- const result = await dispatchSyncFrame(314- localManifest,315- new Uint8Array(32).fill(3),316- deps317- );318- const messages = result.outbound.map((frame) => decodeSyncMessage(frame));319- expect(messages).toHaveLength(1);320- expect(messages[0]!.type).toBe("SYNC_DONE");321- });322-323- it("encrypts only the requested ids on SYNC_REQUEST", async () => {324- const deps = passDeps([entry({ id: "a" }), entry({ id: "b" })]);325- const result = await dispatchSyncFrame(326- encodeSyncMessage({ type: "SYNC_REQUEST", ids: ["b"] }),327- new Uint8Array(32).fill(4),328- deps329- );330- expect(result.outbound).toHaveLength(1);331- const decoded = decodeSyncMessage(result.outbound[0]!);332- expect(decoded.type).toBe("SYNC_ENTRIES");333- if (decoded.type === "SYNC_ENTRIES") {334- expect(decoded.entries.map((e) => e.id)).toEqual(["b"]);335- }336- });337-338- it("decrypts incoming SYNC_ENTRIES, merges, and replies SYNC_DONE", async () => {339- const rawEntryKey = new Uint8Array(32).fill(9);340- const offered = await buildSyncEntriesMessage(["remote"], rawEntryKey, {341- isProUser: async () => true,342- isUnlocked: () => true,343- getEntries: () => [entry({ id: "remote", title: "Remote", modifiedAt: 5_000 })],344- });345- const deps = passDeps([]);346- const result = await dispatchSyncFrame(347- encodeSyncMessage(offered),348- rawEntryKey,349- deps350- );351- expect(deps.restoreEntries).toHaveBeenCalledWith(352- [expect.objectContaining({ id: "remote", title: "Remote" })],353- "merge"354- );355- const decoded = decodeSyncMessage(result.outbound[0]!);356- expect(decoded.type).toBe("SYNC_DONE");357- });358-359- it("returns SYNC_ERROR + done for malformed inbound frames", async () => {360- const result = await dispatchSyncFrame("!!not-a-frame!!", new Uint8Array(32), {361- isProUser: async () => true,362- isUnlocked: () => true,363- getEntries: () => [],364- restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),365- });366- expect(result.done).toBe(true);367- expect(result.error?.code).toBe("invalid_message");368- expect(decodeSyncMessage(result.outbound[0]!).type).toBe("SYNC_ERROR");369- });370-371- it("emits SYNC_ERROR + done when the vault is locked at dispatch time", async () => {372- const result = await dispatchSyncFrame(373- encodeSyncMessage({374- type: "SYNC_HELLO",375- deviceId: "device",376- clockMs: 0,377- vaultVersion: 0,378- }),379- new Uint8Array(32),380- {381- isProUser: async () => true,382- isUnlocked: () => false,383- getEntries: () => [],384- restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),385- }386- );387- expect(result.done).toBe(true);388- expect(result.error?.code).toBe("vault_locked");389- const decoded = decodeSyncMessage(result.outbound[0]!);390- expect(decoded.type).toBe("SYNC_ERROR");391- if (decoded.type === "SYNC_ERROR") {392- expect(decoded.code).toBe("vault_locked");393- }394- });395-396- it("treats SYNC_DONE from peer as session end with no outbound frames", async () => {397- const result = await dispatchSyncFrame(398- encodeSyncMessage({ type: "SYNC_DONE" }),399- new Uint8Array(32).fill(7),400- passDeps([])401- );402- expect(result.outbound).toEqual([]);403- expect(result.done).toBe(true);404- });405-406- it("treats SYNC_ERROR-from-peer as session end with no outbound frames", async () => {407- const result = await dispatchSyncFrame(408- encodeSyncMessage({409- type: "SYNC_ERROR",410- code: "peer_failed",411- message: "Peer reported error.",412- }),413- new Uint8Array(32).fill(8),414- passDeps([])415- );416- expect(result.outbound).toEqual([]);417- expect(result.done).toBe(true);418- expect(result.error).toEqual({ code: "peer_failed", message: "Peer reported error." });419- });420- });421-422- describe("runSyncSession", () => {423- interface TransportHarness {424- transport: SyncSessionTransport;425- sent: string[];426- emitFrame: (frame: string) => Promise<void>;427- emitError: (message: string) => Promise<void>;428- emitClose: () => Promise<void>;429- isClosed: () => boolean;430- ready: () => Promise<void>;431- }432-433- function makeTransport(rawKey: Uint8Array): TransportHarness {434- const sent: string[] = [];435- const frameQueue: string[] = [];436- const errorQueue: string[] = [];437- let pendingClose = false;438- let frameHandler: ((frame: string) => void) | null = null;439- let errorHandler: ((message: string) => void) | null = null;440- let closeHandler: (() => void) | null = null;441- let resolveReady: (() => void) | null = null;442- const ready = new Promise<void>((resolve) => {443- resolveReady = resolve;444- });445- let closed = false;446-447- const checkReady = (): void => {448- if (frameHandler && errorHandler && closeHandler && resolveReady) {449- resolveReady();450- resolveReady = null;451- for (const queued of frameQueue.splice(0)) frameHandler(queued);452- for (const queued of errorQueue.splice(0)) errorHandler(queued);453- if (pendingClose && closeHandler) {454- pendingClose = false;455- closeHandler();456- }457- }458- };459-460- const transport: SyncSessionTransport = {461- send: (frame) => {462- sent.push(frame);463- },464- onFrame: (handler) => {465- frameHandler = handler;466- checkReady();467- return () => {468- if (frameHandler === handler) frameHandler = null;469- };470- },471- onError: (handler) => {472- errorHandler = handler;473- checkReady();474- return () => {475- if (errorHandler === handler) errorHandler = null;476- };477- },478- onClose: (handler) => {479- closeHandler = handler;480- checkReady();481- return () => {482- if (closeHandler === handler) closeHandler = null;483- };484- },485- close: () => {486- closed = true;487- },488- getSessionKey: async () => rawKey,489- };490-491- const flush = async (): Promise<void> => {492- for (let i = 0; i < 50; i += 1) {493- await new Promise<void>((resolve) => setTimeout(resolve, 0));494- }495- };496-497- return {498- transport,499- sent,500- emitFrame: async (frame) => {501- if (frameHandler) frameHandler(frame);502- else frameQueue.push(frame);503- await flush();504- },505- emitError: async (message) => {506- if (errorHandler) errorHandler(message);507- else errorQueue.push(message);508- await flush();509- },510- emitClose: async () => {511- if (closeHandler) closeHandler();512- else pendingClose = true;513- await flush();514- },515- isClosed: () => closed,516- ready: () => ready,517- };518- }519-520- it("kicks off with SYNC_HELLO when initiator and ends on peer SYNC_DONE", async () => {521- const harness = makeTransport(new Uint8Array(32).fill(11));522-523- const sessionPromise = runSyncSession({524- role: "initiator",525- deviceId: "device-a",526- vaultVersion: 1,527- transport: harness.transport,528- deps: {529- isProUser: async () => true,530- isUnlocked: () => true,531- getEntries: () => [],532- restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),533- },534- });535-536- await harness.ready();537- expect(harness.sent).toHaveLength(1);538- expect(decodeSyncMessage(harness.sent[0]!).type).toBe("SYNC_HELLO");539-540- await harness.emitFrame(encodeSyncMessage({ type: "SYNC_DONE" }));541-542- const result = await sessionPromise;543- expect(result.status).toBe("completed");544- expect(harness.isClosed()).toBe(true);545- });546-547- it("does not send SYNC_HELLO when responder", async () => {548- const harness = makeTransport(new Uint8Array(32));549- const sessionPromise = runSyncSession({550- role: "responder",551- deviceId: "device-b",552- vaultVersion: 1,553- transport: harness.transport,554- deps: {555- isProUser: async () => true,556- isUnlocked: () => true,557- getEntries: () => [],558- restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),559- },560- });561- await harness.ready();562- expect(harness.sent).toEqual([]);563-564- await harness.emitClose();565- const result = await sessionPromise;566- expect(result.status).toBe("closed");567- });568-569- it("aborts immediately with SYNC_ERROR when preflight fails", async () => {570- const harness = makeTransport(new Uint8Array(32));571- const result = await runSyncSession({572- role: "initiator",573- deviceId: "device",574- vaultVersion: 1,575- transport: harness.transport,576- deps: {577- isProUser: async () => false,578- isUnlocked: () => true,579- getEntries: () => [],580- restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),581- },582- });583-584- expect(result.status).toBe("errored");585- expect(result.error?.code).toBe("pro_required");586- expect(decodeSyncMessage(harness.sent[0]!).type).toBe("SYNC_ERROR");587- expect(harness.isClosed()).toBe(true);588- });589-590- it("propagates transport errors as a session result", async () => {591- const harness = makeTransport(new Uint8Array(32));592- const sessionPromise = runSyncSession({593- role: "responder",594- deviceId: "device",595- vaultVersion: 1,596- transport: harness.transport,597- deps: {598- isProUser: async () => true,599- isUnlocked: () => true,600- getEntries: () => [],601- restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),602- },603- });604- await harness.ready();605- await harness.emitError("native host disconnected");606- const result = await sessionPromise;607- expect(result.status).toBe("errored");608- expect(result.error).toEqual({609- code: "transport_error",610- message: "native host disconnected",611- });612- });613-614- it("createNativeSyncTransport routes only matching frames and errors", async () => {615- const sendSyncData = vi.fn();616- const disconnectFromPeer = vi.fn();617- const handlers: {618- frame: ((id: string, data: string) => void) | null;619- error: ((msg: string, id?: string) => void) | null;620- disconnect: ((id?: string) => void) | null;621- } = { frame: null, error: null, disconnect: null };622-623- const bindings: NativeSyncTransportBindings = {624- sendSyncData,625- disconnectFromPeer,626- onSyncMessage: (h) => {627- handlers.frame = h;628- return () => {629- if (handlers.frame === h) handlers.frame = null;630- };631- },632- onError: (h) => {633- handlers.error = h;634- return () => {635- if (handlers.error === h) handlers.error = null;636- };637- },638- onDisconnected: (h) => {639- handlers.disconnect = h;640- return () => {641- if (handlers.disconnect === h) handlers.disconnect = null;642- };643- },644- getNativeSessionKey: async () => new Uint8Array(32).fill(7),645- };646-647- const transport = await createNativeSyncTransport("target", bindings);648-649- const frames: string[] = [];650- const errors: string[] = [];651- let closes = 0;652- transport.onFrame((frame) => frames.push(frame));653- transport.onError((message) => errors.push(message));654- transport.onClose(() => {655- closes += 1;656- });657-658- handlers.frame?.("other", "ignored");659- handlers.frame?.("target", "kept");660- expect(frames).toEqual(["kept"]);661-662- handlers.error?.("scoped error", "other");663- handlers.error?.("scoped error", "target");664- handlers.error?.("global error", undefined);665- expect(errors).toEqual(["scoped error", "global error"]);666-667- handlers.disconnect?.("other");668- handlers.disconnect?.("target");669- handlers.disconnect?.(undefined);670- expect(closes).toBe(2);671-672- transport.send("frame");673- expect(sendSyncData).toHaveBeenCalledWith("target", "frame");674-675- transport.close();676- expect(disconnectFromPeer).toHaveBeenCalledWith("target");677-678- const key = await transport.getSessionKey();679- expect(key).toEqual(new Uint8Array(32).fill(7));680- });681-682- it("records merge totals from a SYNC_ENTRIES frame on the result", async () => {683- const rawKey = new Uint8Array(32).fill(12);684- const harness = makeTransport(rawKey);685- const restoreEntries = vi686- .fn()687- .mockResolvedValue({ added: 1, updated: 2, skipped: 3 });688-689- const sessionPromise = runSyncSession({690- role: "responder",691- deviceId: "device",692- vaultVersion: 1,693- transport: harness.transport,694- deps: {695- isProUser: async () => true,696- isUnlocked: () => true,697- getEntries: () => [],698- restoreEntries,699- },700- });701- await harness.ready();702-703- const incoming = await buildSyncEntriesMessage(["a"], rawKey, {704- isProUser: async () => true,705- isUnlocked: () => true,706- getEntries: () => [entry({ id: "a", title: "First" })],707- });708- await harness.emitFrame(encodeSyncMessage(incoming));709- // Peer signals end of its side; session can resolve.710- await harness.emitFrame(encodeSyncMessage({ type: "SYNC_DONE" }));711-712- const result = await sessionPromise;713- expect(result.status).toBe("completed");714- expect(result.merges).toEqual({ added: 1, updated: 2, skipped: 3 });715- });716- });717-});src/background/sync-coordinator.ts+233 −423
@@ -1,83 +1,260 @@11 /**2- * Dormant SYN-001 coordinator helpers.2+ * Blob sync session coordinator.33 *4- * This module owns extension-side sync preflight and cancellation rules before5- * any live background path enables native messaging. Entry payload exchange is6- * intentionally left to the next phase so the encrypted-entry envelope can be7- * reviewed on its own.4+ * Replaces the per-entry SYNC_HELLO/MANIFEST/REQUEST/ENTRIES/DONE state machine5+ * with a single bidirectional vault-blob exchange. The encrypted vault blob is6+ * already AES-256-GCM ciphertext under the user's master password, so the7+ * Noise NK transport adds a second layer and the sync layer never sees8+ * plaintext.9+ *10+ * Last-write-wins at the vault level: an incoming blob is applied only if its11+ * `sent_at` is strictly newer than the local `peach_last_sync_at`. Vault12+ * history snapshots before every apply so the user can restore an older state13+ * if a sync overwrites the wrong vault.814 */915 10-import {11- buildConflictedCopy,12- buildSyncManifest,13- chooseConflictWinner,14- diffSyncManifests,15- selectEntriesForSync,16-} from "../shared/sync-protocol";17-import type { SyncDiff } from "../shared/sync-protocol";18-import {19- decryptSyncEntry,20- encryptSyncEntry,21- importSyncEntryKey,22-} from "../shared/sync-entry-payload";23-import type { SyncManifestEntry, SyncMessage } from "../shared/sync-types";16+import { storageGet, storageSet } from "../shared/storage";2417 import { decodeSyncMessage, encodeSyncMessage } from "../shared/sync-wire";25-import type { VaultEntry } from "../shared/types";18+import type { SyncMessage } from "../shared/sync-types";19+import { takeVaultSnapshot } from "./vault";2620 27-export type SyncPreflightError = "pro_required" | "vault_locked";21+const LAST_SYNC_KEY = "peach_last_sync_at";22+const SYNC_TIMEOUT_MS = 30_000;2823 29-export interface SyncPreflightResult {30- ok: boolean;31- error?: SyncPreflightError;24+export interface SyncCoordinatorDeps {25+ isPro: () => Promise<boolean>;26+ isUnlocked: () => boolean;27+ getVaultBlobs: () => Promise<{ vault_a: string; vault_b: string }>;3228 }3329 34-export interface SyncSnapshot {35- entries: VaultEntry[];36- manifest: SyncManifestEntry[];30+export interface SyncSessionTransport {31+ send: (frame: string) => void | Promise<void>;32+ onFrame: (handler: (frame: string) => void) => () => void;33+ onError: (handler: (message: string) => void) => () => void;34+ onClose: (handler: () => void) => () => void;35+ close: () => void;3736 }3837 39-export interface SyncPlan {40- localManifest: SyncManifestEntry[];41- diff: SyncDiff;38+export type SyncRole = "initiator" | "responder";39+40+export interface SyncSessionResult {41+ status: "completed" | "errored" | "closed";42+ error?: string;4243 }4344 44-export interface SyncCoordinatorDeps {45- isProUser: () => Promise<boolean>;46- isUnlocked: () => boolean;47- getEntries: () => VaultEntry[];48- restoreEntries: (49- entries: VaultEntry[],50- mode: "replace" | "merge"51- ) => Promise<{ added: number; updated: number; skipped: number }>;52- sendSyncData: (connectionId: string, data: string) => void;45+/**46+ * Persist an incoming vault blob if its `sent_at` beats the local last-sync47+ * timestamp. Returns 'applied' when the blob was written, 'ignored' when the48+ * incoming was older. Always snapshots BEFORE writing so the user can roll49+ * back via vault history if a sync overwrites the wrong state.50+ */51+export async function applyIncomingVault(52+ incomingVaultA: string,53+ incomingVaultB: string,54+ sentAt: number,55+): Promise<"applied" | "ignored"> {56+ const lastRaw = await storageGet(LAST_SYNC_KEY);57+ const lastTs = lastRaw ? Number.parseInt(lastRaw, 10) : 0;58+59+ if (!Number.isFinite(sentAt) || sentAt <= lastTs) {60+ return "ignored";61+ }62+63+ // History capture happens BEFORE the write. Failures are swallowed inside64+ // takeVaultSnapshot — sync must never block on history.65+ await takeVaultSnapshot("sync_received");66+67+ await storageSet("peach_vault_a", incomingVaultA);68+ await storageSet("peach_vault_b", incomingVaultB);69+ await storageSet(LAST_SYNC_KEY, String(sentAt));70+71+ return "applied";5372 }5473 55-async function loadDefaultDeps(): Promise<SyncCoordinatorDeps> {56- const [license, sync, vault] = await Promise.all([57- import("./license"),58- import("./sync"),59- import("./vault"),60- ]);61- return {62- isProUser: license.isProUser,63- isUnlocked: vault.isUnlocked,64- getEntries: vault.getEntries,65- restoreEntries: vault.restoreEntries,66- sendSyncData: sync.sendSyncData,67- };74+/**75+ * Run one bidirectional sync session over an authenticated transport.76+ *77+ * Wire flow:78+ * 1. Initiator sends SYNC_VAULT_SEND with its blobs + sent_at79+ * 2. Responder receives, applies if newer, replies with SYNC_VAULT_ACK80+ * 3. Responder then sends ITS own SYNC_VAULT_SEND so the initiator gets81+ * whichever vault is newer82+ * 4. Initiator receives, applies if newer, replies with SYNC_VAULT_ACK83+ * 5. Both sides close84+ *85+ * Either side can short-circuit with SYNC_ERROR (pro_required, vault_locked,86+ * timeout). The session times out after 30 seconds total.87+ */88+export async function runBlobSyncSession(89+ role: SyncRole,90+ transport: SyncSessionTransport,91+ deps: SyncCoordinatorDeps,92+): Promise<SyncSessionResult> {93+ if (!(await deps.isPro())) {94+ await transport.send(95+ encodeSyncMessage({ type: "SYNC_ERROR", reason: "pro_required" }),96+ );97+ transport.close();98+ return { status: "errored", error: "pro_required" };99+ }100+ if (!deps.isUnlocked()) {101+ await transport.send(102+ encodeSyncMessage({ type: "SYNC_ERROR", reason: "vault_locked" }),103+ );104+ transport.close();105+ return { status: "errored", error: "vault_locked" };106+ }107+108+ return new Promise<SyncSessionResult>((resolve) => {109+ let settled = false;110+ const unsubscribers: Array<() => void> = [];111+112+ const finish = (result: SyncSessionResult): void => {113+ if (settled) return;114+ settled = true;115+ clearTimeout(timeoutHandle);116+ for (const fn of unsubscribers) {117+ try {118+ fn();119+ } catch {120+ // unsubscribe must not throw121+ }122+ }123+ try {124+ transport.close();125+ } catch {126+ // close must not throw127+ }128+ resolve(result);129+ };130+131+ const timeoutHandle = setTimeout(132+ () => finish({ status: "errored", error: "timeout" }),133+ SYNC_TIMEOUT_MS,134+ );135+136+ unsubscribers.push(transport.onClose(() => finish({ status: "closed" })));137+ unsubscribers.push(138+ transport.onError((message) =>139+ finish({ status: "errored", error: message || "transport_error" }),140+ ),141+ );142+143+ unsubscribers.push(144+ transport.onFrame((frame) => {145+ void (async () => {146+ let msg: SyncMessage;147+ try {148+ msg = decodeSyncMessage(frame);149+ } catch {150+ await transport.send(151+ encodeSyncMessage({ type: "SYNC_ERROR", reason: "invalid_frame" }),152+ );153+ finish({ status: "errored", error: "invalid_frame" });154+ return;155+ }156+157+ if (msg.type === "SYNC_VAULT_SEND") {158+ try {159+ await applyIncomingVault(msg.vault_a, msg.vault_b, msg.sent_at);160+ } catch (err) {161+ const reason = err instanceof Error ? err.message : "apply_failed";162+ await transport.send(163+ encodeSyncMessage({ type: "SYNC_ERROR", reason }),164+ );165+ finish({ status: "errored", error: reason });166+ return;167+ }168+169+ await transport.send(170+ encodeSyncMessage({171+ type: "SYNC_VAULT_ACK",172+ received_at: Date.now(),173+ }),174+ );175+176+ if (role === "responder") {177+ // Send our blob back so the initiator ends up with whichever is newer.178+ const blobs = await deps.getVaultBlobs();179+ await transport.send(180+ encodeSyncMessage({181+ type: "SYNC_VAULT_SEND",182+ vault_a: blobs.vault_a,183+ vault_b: blobs.vault_b,184+ sent_at: Date.now(),185+ }),186+ );187+ // Now wait for initiator's ACK.188+ } else {189+ // Initiator received responder's vault and ACK'd — done.190+ finish({ status: "completed" });191+ }192+ } else if (msg.type === "SYNC_VAULT_ACK") {193+ // Peer received our vault. If we are the responder we still need194+ // to wait for the initiator's ACK after we send our blob back; in195+ // practice that flow ends via the initiator's SYNC_VAULT_SEND196+ // branch above. The simple case: we sent first, peer ACK'd, we197+ // can close.198+ if (role === "initiator") {199+ // Wait — initiator only completes after receiving responder's200+ // SYNC_VAULT_SEND. A naked ACK without the responder vault means201+ // the responder chose not to send anything back; treat as done.202+ finish({ status: "completed" });203+ } else {204+ finish({ status: "completed" });205+ }206+ } else if (msg.type === "SYNC_ERROR") {207+ finish({ status: "errored", error: msg.reason });208+ }209+ // PAIR_* messages are out-of-band for the data-sync session.210+ })().catch((err) => {211+ finish({212+ status: "errored",213+ error: err instanceof Error ? err.message : String(err),214+ });215+ });216+ }),217+ );218+219+ if (role === "initiator") {220+ void (async () => {221+ try {222+ const blobs = await deps.getVaultBlobs();223+ await transport.send(224+ encodeSyncMessage({225+ type: "SYNC_VAULT_SEND",226+ vault_a: blobs.vault_a,227+ vault_b: blobs.vault_b,228+ sent_at: Date.now(),229+ }),230+ );231+ } catch (err) {232+ finish({233+ status: "errored",234+ error: err instanceof Error ? err.message : String(err),235+ });236+ }237+ })();238+ }239+ });68240 }69241 242+/**243+ * Build a transport adapter that bridges a native-host connection (via sync.ts)244+ * into the SyncSessionTransport interface runBlobSyncSession consumes. The245+ * adapter scopes frame/error/disconnect events to a single connection id so246+ * concurrent sync sessions on different connections don't cross-talk.247+ */70248 export interface NativeSyncTransportBindings {71249 sendSyncData: (connectionId: string, data: string) => void;72250 onSyncMessage: (73- handler: (connectionId: string, data: string) => void251+ handler: (connectionId: string, data: string) => void,74252 ) => () => void;75253 onError: (76- handler: (message: string, connectionId?: string) => void254+ handler: (message: string, connectionId?: string) => void,77255 ) => () => void;78256 onDisconnected: (handler: (connectionId?: string) => void) => () => void;79257 disconnectFromPeer: (connectionId: string) => void;80- getNativeSessionKey: (connectionId: string) => Promise<Uint8Array>;81258 }82259 83260 async function loadNativeSyncBindings(): Promise<NativeSyncTransportBindings> {@@ -88,13 +265,12 @@88265 onError: sync.onError,89266 onDisconnected: sync.onDisconnected,90267 disconnectFromPeer: sync.disconnectFromPeer,91- getNativeSessionKey: sync.getNativeSessionKey,92268 };93269 }94270 95271 export async function createNativeSyncTransport(96272 connectionId: string,97- bindings?: NativeSyncTransportBindings273+ bindings?: NativeSyncTransportBindings,98274 ): Promise<SyncSessionTransport> {99275 const ready = bindings ?? (await loadNativeSyncBindings());100276 return {@@ -112,371 +288,5 @@112288 if (id === undefined || id === connectionId) handler();113289 }),114290 close: () => ready.disconnectFromPeer(connectionId),115- getSessionKey: () => ready.getNativeSessionKey(connectionId),116291 };117-}118-119-export async function checkSyncPreflight(120- deps?: Pick<SyncCoordinatorDeps, "isProUser" | "isUnlocked">121-): Promise<SyncPreflightResult> {122- const readyDeps = deps ?? (await loadDefaultDeps());123- if (!(await readyDeps.isProUser())) return { ok: false, error: "pro_required" };124- if (!readyDeps.isUnlocked()) return { ok: false, error: "vault_locked" };125- return { ok: true };126-}127-128-export async function buildLocalSyncSnapshot(129- deps?: Pick<SyncCoordinatorDeps, "isProUser" | "isUnlocked" | "getEntries">130-): Promise<SyncSnapshot> {131- const readyDeps = deps ?? (await loadDefaultDeps());132- const preflight = await checkSyncPreflight(readyDeps);133- if (!preflight.ok) throw new Error(preflight.error);134-135- const entries = readyDeps.getEntries();136- return {137- entries,138- manifest: await buildSyncManifest(entries),139- };140-}141-142-export async function planManifestSync(143- remoteManifest: SyncManifestEntry[],144- deps?: Pick<SyncCoordinatorDeps, "isProUser" | "isUnlocked" | "getEntries">145-): Promise<SyncPlan> {146- const snapshot = await buildLocalSyncSnapshot(deps);147- return {148- localManifest: snapshot.manifest,149- diff: diffSyncManifests(snapshot.manifest, remoteManifest),150- };151-}152-153-export async function buildSyncEntriesMessage(154- ids: string[],155- rawEntryKey: Uint8Array,156- deps?: Pick<SyncCoordinatorDeps, "isProUser" | "isUnlocked" | "getEntries">157-): Promise<Extract<SyncMessage, { type: "SYNC_ENTRIES" }>> {158- const readyDeps = deps ?? (await loadDefaultDeps());159- const preflight = await checkSyncPreflight(readyDeps);160- if (!preflight.ok) throw new Error(preflight.error);161-162- const key = await importSyncEntryKey(rawEntryKey);163- const selected = selectEntriesForSync(readyDeps.getEntries(), ids);164- return {165- type: "SYNC_ENTRIES",166- entries: await Promise.all(selected.map((entry) => encryptSyncEntry(entry, key))),167- };168-}169-170-export async function applySyncEntriesMessage(171- message: Extract<SyncMessage, { type: "SYNC_ENTRIES" }>,172- rawEntryKey: Uint8Array,173- deps?: Pick<SyncCoordinatorDeps, "isProUser" | "isUnlocked" | "restoreEntries">174-): Promise<{ added: number; updated: number; skipped: number }> {175- const readyDeps = deps ?? (await loadDefaultDeps());176- const preflight = await checkSyncPreflight(readyDeps);177- if (!preflight.ok) throw new Error(preflight.error);178-179- const key = await importSyncEntryKey(rawEntryKey);180- const entries = await Promise.all(181- message.entries.map((payload) => decryptSyncEntry(payload, key))182- );183- return readyDeps.restoreEntries(entries, "merge");184-}185-186-export interface SyncFrameDispatchResult {187- outbound: string[];188- merge?: { added: number; updated: number; skipped: number };189- done?: boolean;190- error?: { code: string; message: string };191-}192-193-export async function dispatchSyncFrame(194- frame: string,195- rawEntryKey: Uint8Array,196- deps?: Pick<197- SyncCoordinatorDeps,198- "isProUser" | "isUnlocked" | "getEntries" | "restoreEntries"199- >200-): Promise<SyncFrameDispatchResult> {201- const readyDeps = deps ?? (await loadDefaultDeps());202- const preflight = await checkSyncPreflight(readyDeps);203- if (!preflight.ok) {204- const message =205- preflight.error === "pro_required"206- ? "Sync requires a Pro license."207- : "Vault is locked; unlock Peach before syncing.";208- return {209- outbound: [210- encodeSyncMessage({211- type: "SYNC_ERROR",212- code: preflight.error ?? "preflight_failed",213- message,214- }),215- ],216- done: true,217- error: { code: preflight.error ?? "preflight_failed", message },218- };219- }220-221- let message: SyncMessage;222- try {223- message = decodeSyncMessage(frame);224- } catch {225- return {226- outbound: [227- encodeSyncMessage({228- type: "SYNC_ERROR",229- code: "invalid_message",230- message: "Could not decode sync frame.",231- }),232- ],233- done: true,234- error: { code: "invalid_message", message: "Could not decode sync frame." },235- };236- }237-238- const key = await importSyncEntryKey(rawEntryKey);239-240- switch (message.type) {241- case "SYNC_HELLO": {242- const manifest = await buildSyncManifest(readyDeps.getEntries());243- return {244- outbound: [encodeSyncMessage({ type: "SYNC_MANIFEST", entries: manifest })],245- };246- }247- case "SYNC_MANIFEST": {248- const localEntries = readyDeps.getEntries();249- const localManifest = await buildSyncManifest(localEntries);250- const diff = diffSyncManifests(localManifest, message.entries);251-252- const requestIds = new Set(diff.requestFromRemote);253- const offerIds = new Set(diff.offerToRemote);254- const archiveCopies: VaultEntry[] = [];255-256- if (diff.conflicts.length > 0) {257- const localManifestById = new Map(258- localManifest.map((entry) => [entry.id, entry])259- );260- const remoteManifestById = new Map(261- message.entries.map((entry) => [entry.id, entry])262- );263- const localEntryById = new Map(localEntries.map((entry) => [entry.id, entry]));264-265- for (const id of diff.conflicts) {266- const localManifestEntry = localManifestById.get(id);267- const remoteManifestEntry = remoteManifestById.get(id);268- const localEntry = localEntryById.get(id);269- if (!localManifestEntry || !remoteManifestEntry || !localEntry) continue;270-271- const winner = chooseConflictWinner(272- localManifestEntry.hash,273- remoteManifestEntry.hash274- );275- if (winner === "local") {276- offerIds.add(id);277- } else {278- requestIds.add(id);279- archiveCopies.push(buildConflictedCopy(localEntry, localManifestEntry.hash));280- }281- }282- }283-284- // Persist any losing-side conflicted copies before the peer's winning285- // entries overwrite the original ids on the next SYNC_ENTRIES message.286- let merge: { added: number; updated: number; skipped: number } | undefined;287- if (archiveCopies.length > 0) {288- merge = await readyDeps.restoreEntries(archiveCopies, "merge");289- }290-291- const sortedRequestIds = [...requestIds].sort();292- const sortedOfferIds = [...offerIds].sort();293- const offered = selectEntriesForSync(localEntries, sortedOfferIds);294- const outbound: string[] = [];295- if (sortedRequestIds.length > 0) {296- outbound.push(297- encodeSyncMessage({ type: "SYNC_REQUEST", ids: sortedRequestIds })298- );299- }300- if (offered.length > 0) {301- outbound.push(302- encodeSyncMessage({303- type: "SYNC_ENTRIES",304- entries: await Promise.all(305- offered.map((entry) => encryptSyncEntry(entry, key))306- ),307- })308- );309- }310- if (outbound.length === 0) {311- outbound.push(encodeSyncMessage({ type: "SYNC_DONE" }));312- }313- return merge ? { outbound, merge } : { outbound };314- }315- case "SYNC_REQUEST": {316- const offered = selectEntriesForSync(readyDeps.getEntries(), message.ids);317- return {318- outbound: [319- encodeSyncMessage({320- type: "SYNC_ENTRIES",321- entries: await Promise.all(322- offered.map((entry) => encryptSyncEntry(entry, key))323- ),324- }),325- ],326- };327- }328- case "SYNC_ENTRIES": {329- const decrypted = await Promise.all(330- message.entries.map((payload) => decryptSyncEntry(payload, key))331- );332- const merge = await readyDeps.restoreEntries(decrypted, "merge");333- return { outbound: [encodeSyncMessage({ type: "SYNC_DONE" })], merge };334- }335- case "SYNC_DONE":336- return { outbound: [], done: true };337- case "SYNC_ERROR":338- return {339- outbound: [],340- done: true,341- error: { code: message.code, message: message.message },342- };343- case "PAIR_REQUEST":344- case "PAIR_ACCEPT":345- case "PAIR_REJECT":346- // Pairing flow is handled outside the data-sync state machine.347- return { outbound: [] };348- }349-}350-351-export interface SyncSessionTransport {352- send: (frame: string) => void;353- onFrame: (handler: (frame: string) => void) => () => void;354- onError: (handler: (message: string) => void) => () => void;355- onClose: (handler: () => void) => () => void;356- close: () => void;357- getSessionKey: () => Promise<Uint8Array>;358-}359-360-export interface SyncSessionResult {361- status: "completed" | "errored" | "closed";362- merges: { added: number; updated: number; skipped: number };363- error?: { code: string; message: string };364-}365-366-export type SyncRole = "initiator" | "responder";367-368-export async function runSyncSession(options: {369- role: SyncRole;370- deviceId: string;371- vaultVersion: number;372- transport: SyncSessionTransport;373- deps?: Pick<374- SyncCoordinatorDeps,375- "isProUser" | "isUnlocked" | "getEntries" | "restoreEntries"376- >;377-}): Promise<SyncSessionResult> {378- const readyDeps = options.deps ?? (await loadDefaultDeps());379- const merges = { added: 0, updated: 0, skipped: 0 };380-381- const preflight = await checkSyncPreflight(readyDeps);382- if (!preflight.ok) {383- const message =384- preflight.error === "pro_required"385- ? "Sync requires a Pro license."386- : "Vault is locked; unlock Peach before syncing.";387- options.transport.send(388- encodeSyncMessage({389- type: "SYNC_ERROR",390- code: preflight.error ?? "preflight_failed",391- message,392- })393- );394- options.transport.close();395- return {396- status: "errored",397- merges,398- error: { code: preflight.error ?? "preflight_failed", message },399- };400- }401-402- const sessionKey = await options.transport.getSessionKey();403-404- return new Promise<SyncSessionResult>((resolve) => {405- let settled = false;406- const unsubscribers: Array<() => void> = [];407-408- const finish = (result: SyncSessionResult): void => {409- if (settled) return;410- settled = true;411- for (const fn of unsubscribers) fn();412- options.transport.close();413- resolve(result);414- };415-416- unsubscribers.push(417- options.transport.onClose(() =>418- finish({ status: "closed", merges })419- )420- );421- unsubscribers.push(422- options.transport.onError((message) =>423- finish({424- status: "errored",425- merges,426- error: { code: "transport_error", message },427- })428- )429- );430-431- unsubscribers.push(432- options.transport.onFrame((frame) => {433- void (async () => {434- const result = await dispatchSyncFrame(frame, sessionKey, readyDeps);435- if (settled) return;436- if (result.merge) {437- merges.added += result.merge.added;438- merges.updated += result.merge.updated;439- merges.skipped += result.merge.skipped;440- }441- for (const outbound of result.outbound) options.transport.send(outbound);442- if (result.error) {443- finish({ status: "errored", merges, error: result.error });444- return;445- }446- if (result.done) {447- finish({ status: "completed", merges });448- }449- })();450- })451- );452-453- if (options.role === "initiator") {454- options.transport.send(455- encodeSyncMessage({456- type: "SYNC_HELLO",457- deviceId: options.deviceId,458- clockMs: Date.now(),459- vaultVersion: options.vaultVersion,460- })461- );462- }463- });464-}465-466-export async function cancelSyncIfVaultLocked(467- connectionId: string,468- deps?: Pick<SyncCoordinatorDeps, "isUnlocked" | "sendSyncData">469-): Promise<boolean> {470- const readyDeps = deps ?? (await loadDefaultDeps());471- if (readyDeps.isUnlocked()) return false;472-473- readyDeps.sendSyncData(474- connectionId,475- encodeSyncMessage({476- type: "SYNC_ERROR",477- code: "vault_locked",478- message: "Vault is locked; unlock Peach before syncing.",479- })480- );481- return true;482292 }src/shared/sync-entry-payload.test.ts+0 −95
@@ -1,95 +0,0 @@1-import { describe, expect, it } from "vitest";2-3-import {4- decryptSyncEntry,5- encryptSyncEntry,6- importSyncEntryKey,7- stripNonPortableSecrets,8-} from "./sync-entry-payload";9-import type { VaultEntry } from "./types";10-11-function entry(overrides: Partial<VaultEntry> = {}): VaultEntry {12- return {13- id: "entry-1",14- type: "login",15- title: "GitHub",16- username: "june",17- password: "secret",18- url: "https://github.com",19- createdAt: 1_000,20- modifiedAt: 1_000,21- ...overrides,22- };23-}24-25-describe("sync entry payloads", () => {26- it("encrypts and decrypts vault entries through an AES-GCM envelope", async () => {27- const key = await importSyncEntryKey(new Uint8Array(32).fill(7));28- const source = entry({ deletedAt: 2_000, notes: "sync me" });29-30- const encrypted = await encryptSyncEntry(source, key);31-32- expect(encrypted).toMatchObject({33- id: "entry-1",34- modifiedAt: 1_000,35- deletedAt: 2_000,36- ciphertext: expect.stringMatching(/^[A-Za-z0-9_-]+$/),37- nonce: expect.stringMatching(/^[A-Za-z0-9_-]+$/),38- tag: expect.stringMatching(/^[A-Za-z0-9_-]+$/),39- });40- expect(await decryptSyncEntry(encrypted, key)).toEqual(source);41- });42-43- it("strips passkey private keys before encryption", async () => {44- const key = await importSyncEntryKey(new Uint8Array(32).fill(9));45- const source = entry({46- passkey: {47- rpId: "github.com",48- rpName: "GitHub",49- credentialId: "credential",50- publicKeyCose: "public",51- privateKeyJwk: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "secret" },52- userHandle: "user",53- userName: "june",54- userDisplayName: "June",55- algorithm: -7,56- signCount: 3,57- aaguid: "aaguid",58- backupEligible: true,59- backupState: true,60- browser: "chrome",61- createdAt: 1_000,62- },63- privateKeyJwk: { kty: "EC", d: "legacy-secret" },64- });65-66- const encrypted = await encryptSyncEntry(source, key);67- const decrypted = await decryptSyncEntry(encrypted, key);68-69- expect(decrypted.passkey?.privateKeyJwk).toBeUndefined();70- expect(decrypted.privateKeyJwk).toBeUndefined();71- expect(stripNonPortableSecrets(source).passkey?.publicKeyCose).toBe("public");72- });73-74- it("rejects invalid key sizes", async () => {75- await expect(importSyncEntryKey(new Uint8Array(31))).rejects.toThrow(76- "sync entry key must be 32 bytes"77- );78- });79-80- it("rejects tampered ciphertext or mismatched envelope metadata", async () => {81- const key = await importSyncEntryKey(new Uint8Array(32).fill(11));82- const encrypted = await encryptSyncEntry(entry(), key);83-84- const replacement = encrypted.ciphertext.endsWith("A") ? "B" : "A";85- await expect(86- decryptSyncEntry(87- { ...encrypted, ciphertext: `${encrypted.ciphertext.slice(0, -1)}${replacement}` },88- key89- )90- ).rejects.toThrow();91- await expect(decryptSyncEntry({ ...encrypted, id: "other" }, key)).rejects.toThrow(92- "invalid sync entry payload"93- );94- });95-});src/shared/sync-entry-payload.ts+0 −118
@@ -1,118 +0,0 @@1-import type { EncryptedSyncEntry } from "./sync-types";2-import type { VaultEntry } from "./types";3-4-const NONCE_BYTES = 12;5-const GCM_TAG_BYTES = 16;6-const textEncoder = new TextEncoder();7-const textDecoder = new TextDecoder("utf-8", { fatal: true });8-9-export async function importSyncEntryKey(rawKey: Uint8Array): Promise<CryptoKey> {10- if (rawKey.length !== 32) {11- throw new Error("sync entry key must be 32 bytes");12- }13- return crypto.subtle.importKey("raw", asArrayBuffer(rawKey), "AES-GCM", false, [14- "encrypt",15- "decrypt",16- ]);17-}18-19-export async function encryptSyncEntry(20- entry: VaultEntry,21- key: CryptoKey22-): Promise<EncryptedSyncEntry> {23- const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));24- const syncEntry = stripNonPortableSecrets(entry);25- const encrypted = new Uint8Array(26- await crypto.subtle.encrypt(27- { name: "AES-GCM", iv: nonce, tagLength: GCM_TAG_BYTES * 8 },28- key,29- textEncoder.encode(JSON.stringify(syncEntry))30- )31- );32- const tagOffset = encrypted.length - GCM_TAG_BYTES;33- if (tagOffset < 0) throw new Error("invalid encrypted sync entry");34-35- return {36- id: syncEntry.id,37- modifiedAt: syncEntry.modifiedAt,38- ...(syncEntry.deletedAt !== undefined ? { deletedAt: syncEntry.deletedAt } : {}),39- ciphertext: base64urlEncode(encrypted.slice(0, tagOffset)),40- nonce: base64urlEncode(nonce),41- tag: base64urlEncode(encrypted.slice(tagOffset)),42- };43-}44-45-export async function decryptSyncEntry(46- payload: EncryptedSyncEntry,47- key: CryptoKey48-): Promise<VaultEntry> {49- const ciphertext = base64urlDecode(payload.ciphertext);50- const tag = base64urlDecode(payload.tag);51- const combined = new Uint8Array(ciphertext.length + tag.length);52- combined.set(ciphertext);53- combined.set(tag, ciphertext.length);54-55- const plain = textDecoder.decode(56- await crypto.subtle.decrypt(57- {58- name: "AES-GCM",59- iv: asArrayBuffer(base64urlDecode(payload.nonce)),60- tagLength: GCM_TAG_BYTES * 8,61- },62- key,63- combined64- )65- );66- const entry = JSON.parse(plain) as unknown;67- if (!isVaultEntryEnvelope(entry, payload)) {68- throw new Error("invalid sync entry payload");69- }70- return entry;71-}72-73-export function stripNonPortableSecrets(entry: VaultEntry): VaultEntry {74- const clone = structuredClone(entry);75- if (clone.passkey) {76- delete clone.passkey.privateKeyJwk;77- }78- delete clone.privateKeyJwk;79- return clone;80-}81-82-function isVaultEntryEnvelope(value: unknown, envelope: EncryptedSyncEntry): value is VaultEntry {83- if (!value || typeof value !== "object" || Array.isArray(value)) return false;84- const entry = value as Partial<VaultEntry>;85- return (86- entry.id === envelope.id &&87- entry.modifiedAt === envelope.modifiedAt &&88- entry.deletedAt === envelope.deletedAt &&89- typeof entry.title === "string" &&90- typeof entry.createdAt === "number"91- );92-}93-94-function base64urlEncode(bytes: Uint8Array): string {95- let binary = "";96- for (const byte of bytes) binary += String.fromCharCode(byte);97- return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");98-}99-100-function base64urlDecode(value: string): Uint8Array {101- if (!/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) {102- throw new Error("invalid base64url");103- }104- const padded = value105- .replace(/-/g, "+")106- .replace(/_/g, "/")107- .padEnd(Math.ceil(value.length / 4) * 4, "=");108- const binary = atob(padded);109- const out = new Uint8Array(binary.length);110- for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);111- return out;112-}113-114-function asArrayBuffer(bytes: Uint8Array): ArrayBuffer {115- const copy = new Uint8Array(bytes.length);116- copy.set(bytes);117- return copy.buffer;118-}src/shared/sync-protocol.test.ts+0 −147
@@ -1,147 +0,0 @@1-import { describe, expect, it } from "vitest";2-3-import {4- buildConflictedCopy,5- buildSyncManifest,6- chooseConflictWinner,7- diffSyncManifests,8- hashVaultEntry,9- mergeSyncEntries,10- selectEntriesForSync,11-} from "./sync-protocol";12-import type { VaultEntry } from "./types";13-14-function entry(overrides: Partial<VaultEntry> = {}): VaultEntry {15- return {16- id: "entry-1",17- type: "login",18- title: "GitHub",19- username: "june",20- password: "secret",21- url: "https://github.com",22- createdAt: 1_000,23- modifiedAt: 1_000,24- ...overrides,25- };26-}27-28-describe("sync protocol helpers", () => {29- it("hashes entries canonically regardless of object key insertion order", async () => {30- const a = entry({ tags: ["work", "code"] });31- const b = {32- tags: ["work", "code"],33- modifiedAt: 1_000,34- createdAt: 1_000,35- url: "https://github.com",36- password: "secret",37- username: "june",38- title: "GitHub",39- type: "login",40- id: "entry-1",41- } as VaultEntry;42-43- await expect(hashVaultEntry(a)).resolves.toBe(await hashVaultEntry(b));44- });45-46- it("builds a stable manifest sorted by entry id", async () => {47- const manifest = await buildSyncManifest([48- entry({ id: "b", modifiedAt: 20 }),49- entry({ id: "a", modifiedAt: 10, deletedAt: 30 }),50- ]);51-52- expect(manifest).toEqual([53- {54- id: "a",55- modifiedAt: 10,56- deletedAt: 30,57- hash: expect.stringMatching(/^[0-9a-f]{64}$/),58- },59- {60- id: "b",61- modifiedAt: 20,62- hash: expect.stringMatching(/^[0-9a-f]{64}$/),63- },64- ]);65- });66-67- it("diffs manifests into request, offer, and conflict buckets", () => {68- const diff = diffSyncManifests(69- [70- { id: "local-only", modifiedAt: 10, hash: "a" },71- { id: "newer-local", modifiedAt: 20, hash: "b" },72- { id: "newer-remote", modifiedAt: 5, hash: "c" },73- { id: "same-time", modifiedAt: 7, hash: "d" },74- ],75- [76- { id: "remote-only", modifiedAt: 10, hash: "e" },77- { id: "newer-local", modifiedAt: 12, hash: "f" },78- { id: "newer-remote", modifiedAt: 30, hash: "g" },79- { id: "same-time", modifiedAt: 7, hash: "h" },80- ]81- );82-83- expect(diff).toEqual({84- requestFromRemote: ["newer-remote", "remote-only"],85- offerToRemote: ["local-only", "newer-local"],86- conflicts: ["same-time"],87- });88- });89-90- it("selects sync entries by id without sharing mutable references", () => {91- const source = [entry({ id: "a" }), entry({ id: "b", title: "Bank" })];92- const selected = selectEntriesForSync(source, ["b"]);93-94- expect(selected).toEqual([expect.objectContaining({ id: "b", title: "Bank" })]);95- selected[0]!.title = "Changed";96- expect(source[1]!.title).toBe("Bank");97- });98-99- it("chooses the same conflict winner from either peer's perspective", () => {100- // Both peers run chooseConflictWinner against the same hash pair but in101- // opposite local/remote slots — the resulting winner-side must point at102- // the same physical hash on both peers.103- const peerA = chooseConflictWinner("aaaa", "bbbb");104- const peerB = chooseConflictWinner("bbbb", "aaaa");105- expect(peerA).toBe("local");106- expect(peerB).toBe("remote");107- });108-109- it("breaks identical hashes by keeping local (no-op merge)", () => {110- expect(chooseConflictWinner("xx", "xx")).toBe("local");111- });112-113- it("builds an idempotent conflicted copy id from the loser hash", () => {114- const loser = entry({ id: "shared", title: "Edits" });115- const archived = buildConflictedCopy(loser, "abcdef0123456789");116- expect(archived.id).toBe("shared-conflict-abcdef01");117- expect(archived.title).toBe("Edits (conflicted copy)");118- // Re-running with the same loser hash returns the same id (no duplicate copies on re-sync)119- expect(buildConflictedCopy(loser, "abcdef0123456789").id).toBe(archived.id);120- });121-122- it("conflicted copy does not mutate the source entry", () => {123- const loser = entry({ id: "shared", title: "Original" });124- buildConflictedCopy(loser, "deadbeef");125- expect(loser.id).toBe("shared");126- expect(loser.title).toBe("Original");127- });128-129- it("merges incoming entries by newest modifiedAt and preserves tombstones", () => {130- const local = [131- entry({ id: "same", modifiedAt: 10, title: "old" }),132- entry({ id: "local-newer", modifiedAt: 50, title: "keep" }),133- ];134- const incoming = [135- entry({ id: "same", modifiedAt: 20, title: "new" }),136- entry({ id: "local-newer", modifiedAt: 40, title: "skip" }),137- entry({ id: "deleted", modifiedAt: 30, deletedAt: 30, title: "trash" }),138- ];139-140- const merged = mergeSyncEntries(local, incoming);141-142- expect(merged).toMatchObject({ added: 1, updated: 1, skipped: 1 });143- expect(merged.entries.find((item) => item.id === "same")?.title).toBe("new");144- expect(merged.entries.find((item) => item.id === "local-newer")?.title).toBe("keep");145- expect(merged.entries.find((item) => item.id === "deleted")?.deletedAt).toBe(30);146- });147-});src/shared/sync-protocol.ts+0 −167
@@ -1,167 +0,0 @@1-import type { VaultEntry } from "./types";2-import type { SyncManifestEntry } from "./sync-types";3-4-export interface SyncDiff {5- requestFromRemote: string[];6- offerToRemote: string[];7- conflicts: string[];8-}9-10-export interface SyncMergeResult {11- entries: VaultEntry[];12- added: number;13- updated: number;14- skipped: number;15-}16-17-export async function buildSyncManifest(entries: VaultEntry[]): Promise<SyncManifestEntry[]> {18- const manifest = await Promise.all(19- entries.map(async (entry) => ({20- id: entry.id,21- modifiedAt: entry.modifiedAt ?? entry.createdAt ?? 0,22- ...(entry.deletedAt !== undefined ? { deletedAt: entry.deletedAt } : {}),23- hash: await hashVaultEntry(entry),24- }))25- );26-27- return manifest.sort((a, b) => a.id.localeCompare(b.id));28-}29-30-export async function hashVaultEntry(entry: VaultEntry): Promise<string> {31- const encoded = new TextEncoder().encode(canonicalJson(entry));32- const digest = await crypto.subtle.digest("SHA-256", encoded);33- return hex(new Uint8Array(digest));34-}35-36-export function diffSyncManifests(37- local: SyncManifestEntry[],38- remote: SyncManifestEntry[]39-): SyncDiff {40- const localById = new Map(local.map((entry) => [entry.id, entry]));41- const remoteById = new Map(remote.map((entry) => [entry.id, entry]));42- const ids = [...new Set([...localById.keys(), ...remoteById.keys()])].sort();43-44- const requestFromRemote: string[] = [];45- const offerToRemote: string[] = [];46- const conflicts: string[] = [];47-48- for (const id of ids) {49- const ours = localById.get(id);50- const theirs = remoteById.get(id);51-52- if (!ours && theirs) {53- requestFromRemote.push(id);54- continue;55- }56- if (ours && !theirs) {57- offerToRemote.push(id);58- continue;59- }60- if (!ours || !theirs || ours.hash === theirs.hash) continue;61-62- if (ours.modifiedAt > theirs.modifiedAt) {63- offerToRemote.push(id);64- } else if (theirs.modifiedAt > ours.modifiedAt) {65- requestFromRemote.push(id);66- } else {67- conflicts.push(id);68- }69- }70-71- return { requestFromRemote, offerToRemote, conflicts };72-}73-74-export type ConflictWinner = "local" | "remote";75-76-/**77- * Equal-timestamp/different-hash conflict resolver.78- *79- * Both peers see the same pair of hashes for the conflicting entry, so80- * sorting them lexicographically yields the same winner on each side81- * without any extra round-trip — the peer holding the loser knows to82- * archive its copy and accept the peer's payload.83- */84-export function chooseConflictWinner(85- localHash: string,86- remoteHash: string87-): ConflictWinner {88- if (localHash === remoteHash) return "local";89- return localHash < remoteHash ? "local" : "remote";90-}91-92-/**93- * A losing entry is preserved as a new vault entry rather than overwritten,94- * so the user can still recover the rejected edit. The id is derived from95- * the loser's id + content hash so re-running the same sync produces the96- * same archive id (idempotent — never duplicates the conflicted copy).97- */98-export function buildConflictedCopy(loser: VaultEntry, loserHash: string): VaultEntry {99- const archived = structuredClone(loser);100- archived.id = `${loser.id}-conflict-${loserHash.slice(0, 8)}`;101- archived.title = `${loser.title ?? "Untitled"} (conflicted copy)`;102- return archived;103-}104-105-export function selectEntriesForSync(entries: VaultEntry[], ids: string[]): VaultEntry[] {106- const wanted = new Set(ids);107- return entries108- .filter((entry) => wanted.has(entry.id))109- .map((entry) => structuredClone(entry));110-}111-112-export function mergeSyncEntries(113- local: VaultEntry[],114- incoming: VaultEntry[]115-): SyncMergeResult {116- const merged = local.map((entry) => structuredClone(entry));117- const byId = new Map(merged.map((entry, index) => [entry.id, { entry, index }]));118- let added = 0;119- let updated = 0;120- let skipped = 0;121-122- for (const next of incoming) {123- const existing = byId.get(next.id);124- const cloned = structuredClone(next);125-126- if (!existing) {127- byId.set(cloned.id, { entry: cloned, index: merged.length });128- merged.push(cloned);129- added += 1;130- continue;131- }132-133- const existingModified = existing.entry.modifiedAt ?? existing.entry.createdAt ?? 0;134- const nextModified = cloned.modifiedAt ?? cloned.createdAt ?? 0;135- if (nextModified > existingModified) {136- merged[existing.index] = cloned;137- byId.set(cloned.id, { entry: cloned, index: existing.index });138- updated += 1;139- } else {140- skipped += 1;141- }142- }143-144- return { entries: merged, added, updated, skipped };145-}146-147-function canonicalJson(value: unknown): string {148- return JSON.stringify(canonicalize(value));149-}150-151-function canonicalize(value: unknown): unknown {152- if (Array.isArray(value)) return value.map(canonicalize);153- if (!value || typeof value !== "object") return value;154-155- const sorted: Record<string, unknown> = {};156- for (const key of Object.keys(value).sort()) {157- const next = (value as Record<string, unknown>)[key];158- if (next !== undefined) sorted[key] = canonicalize(next);159- }160- return sorted;161-}162-163-function hex(bytes: Uint8Array): string {164- return Array.from(bytes)165- .map((byte) => byte.toString(16).padStart(2, "0"))166- .join("");167-}src/shared/sync-types.ts+37 −25
@@ -1,5 +1,16 @@11 /**2- * Types for Peach Sync - encrypted cross-device credential synchronization2+ * Types for Peach sync — full encrypted vault blob transfer.3+ *4+ * The protocol used to be per-entry (manifest / diff / conflict), but at5+ * 200KB for an 800-entry vault local-WiFi transfer takes under 200ms, so6+ * the simpler model wins: the sender ships the whole encrypted blob, the7+ * receiver applies it if its `sent_at` is newer than the last sync, and8+ * vault history is the safety net for last-write-wins.9+ *10+ * The `vault_a` / `vault_b` fields are JSON-stringified `StoredVault`11+ * records — already AES-256-GCM encrypted under the user's master password.12+ * The Noise NK transport adds a second encryption layer; the sync layer13+ * never sees plaintext vault contents.314 */415 516 export interface TrustedNetwork {@@ -18,32 +29,33 @@1829 lastSyncAt?: number;1930 }2031 21-export interface SyncManifestEntry {22- id: string;23- modifiedAt: number;24- deletedAt?: number;25- hash: string;26-}27-32+/**33+ * Wire protocol for an active Noise NK session.34+ *35+ * SYNC_VAULT_SEND — sender pushes the full encrypted vault blobs36+ * SYNC_VAULT_ACK — receiver acknowledges; receipt is non-fatal but lets37+ * the sender measure round-trip and disconnect cleanly38+ * SYNC_ERROR — preflight or session error (vault locked, pro_required,39+ * invalid frame, timeout)40+ * PAIR_REQUEST — initiator asks an unpaired responder to add it as a41+ * trusted device (sent only when the responder is in42+ * pairing mode)43+ * PAIR_ACCEPT — responder confirms; both sides add each other to their44+ * trust stores45+ * PAIR_REJECT — user declined the pair, or the request expired46+ */2847 export type SyncMessage =29- | { type: "SYNC_HELLO"; deviceId: string; clockMs: number; vaultVersion: number }30- | { type: "SYNC_MANIFEST"; entries: SyncManifestEntry[] }31- | { type: "SYNC_REQUEST"; ids: string[] }32- | { type: "SYNC_ENTRIES"; entries: EncryptedSyncEntry[] }33- | { type: "SYNC_DONE" }34- | { type: "SYNC_ERROR"; code: string; message: string }35- | { type: "PAIR_REQUEST"; edPublicKey: string; name: string; code: string }36- | { type: "PAIR_ACCEPT"; edPublicKey: string; name: string }48+ | {49+ type: "SYNC_VAULT_SEND";50+ vault_a: string;51+ vault_b: string;52+ sent_at: number;53+ }54+ | { type: "SYNC_VAULT_ACK"; received_at: number }55+ | { type: "SYNC_ERROR"; reason: string }56+ | { type: "PAIR_REQUEST"; public_key_hex: string; device_name: string }57+ | { type: "PAIR_ACCEPT"; public_key_hex: string; device_name: string }3758 | { type: "PAIR_REJECT"; reason: string };38-39-export interface EncryptedSyncEntry {40- id: string;41- modifiedAt: number;42- deletedAt?: number;43- ciphertext: string;44- nonce: string;45- tag: string;46-}4759 4860 export type SyncStatus = "synced" | "syncing" | "no_peers" | "not_trusted" | "offline";4961 src/shared/sync-wire.test.ts+42 −27
@@ -4,12 +4,12 @@44 import type { SyncMessage } from "./sync-types";55 66 describe("sync wire codec", () => {7- it("round-trips sync messages through base64url JSON", () => {7+ it("round-trips SYNC_VAULT_SEND through base64url JSON", () => {88 const message: SyncMessage = {9- type: "SYNC_HELLO",10- deviceId: "phone-\u2603",11- clockMs: 1_778_000_000_000,12- vaultVersion: 4,9+ type: "SYNC_VAULT_SEND",10+ vault_a: "ciphertext-a",11+ vault_b: "ciphertext-b",12+ sent_at: 1_778_000_000_000,1313 };1414 1515 const encoded = encodeSyncMessage(message);@@ -19,24 +19,21 @@1919 expect(decodeSyncMessage(encoded)).toEqual(message);2020 });2121 22- it("round-trips manifest and encrypted entry payloads", () => {22+ it("round-trips ACK / ERROR / pairing messages", () => {2323 const messages: SyncMessage[] = [24+ { type: "SYNC_VAULT_ACK", received_at: 42 },25+ { type: "SYNC_ERROR", reason: "vault_locked" },2426 {25- type: "SYNC_MANIFEST",26- entries: [{ id: "entry-1", modifiedAt: 42, deletedAt: 45, hash: "abc123" }],27+ type: "PAIR_REQUEST",28+ public_key_hex: "ab".repeat(32),29+ device_name: "June's Laptop",2730 },2831 {29- type: "SYNC_ENTRIES",30- entries: [31- {32- id: "entry-1",33- modifiedAt: 42,34- ciphertext: "ciphertext",35- nonce: "nonce",36- tag: "tag",37- },38- ],32+ type: "PAIR_ACCEPT",33+ public_key_hex: "cd".repeat(32),34+ device_name: "June's Phone",3935 },36+ { type: "PAIR_REJECT", reason: "user_declined" },4037 ];4138 4239 for (const message of messages) {@@ -45,15 +42,33 @@4542 });4643 4744 it("validates message-specific required fields", () => {48- expect(isSyncMessage({ type: "SYNC_REQUEST", ids: ["a", "b"] })).toBe(true);49- expect(isSyncMessage({ type: "PAIR_ACCEPT", edPublicKey: "key", name: "Laptop" })).toBe(50- true51- );52- expect(isSyncMessage({ type: "SYNC_REQUEST", ids: [7] })).toBe(false);53- expect(isSyncMessage({ type: "SYNC_HELLO", deviceId: "phone", clockMs: "now" })).toBe(54- false55- );56- expect(isSyncMessage({ type: "SYNC_MANIFEST", entries: [{ id: "a" }] })).toBe(false);45+ expect(46+ isSyncMessage({47+ type: "SYNC_VAULT_SEND",48+ vault_a: "a",49+ vault_b: "b",50+ sent_at: 1,51+ }),52+ ).toBe(true);53+ expect(54+ isSyncMessage({55+ type: "PAIR_ACCEPT",56+ public_key_hex: "deadbeef",57+ device_name: "Phone",58+ }),59+ ).toBe(true);60+61+ // Missing sent_at62+ expect(63+ isSyncMessage({ type: "SYNC_VAULT_SEND", vault_a: "a", vault_b: "b" }),64+ ).toBe(false);65+ // Wrong type for received_at66+ expect(isSyncMessage({ type: "SYNC_VAULT_ACK", received_at: "now" })).toBe(false);67+ // Missing reason68+ expect(isSyncMessage({ type: "SYNC_ERROR" })).toBe(false);69+ // Missing device_name70+ expect(isSyncMessage({ type: "PAIR_REQUEST", public_key_hex: "abc" })).toBe(false);71+ // Unknown variant5772 expect(isSyncMessage({ type: "UNKNOWN" })).toBe(false);5873 });5974 src/shared/sync-wire.ts+9 −45
@@ -24,26 +24,20 @@2424 if (!isRecord(value) || typeof value.type !== "string") return false;2525 2626 switch (value.type) {27- case "SYNC_HELLO":27+ case "SYNC_VAULT_SEND":2828 return (29- isString(value.deviceId) &&30- isFiniteNumber(value.clockMs) &&31- isFiniteNumber(value.vaultVersion)29+ isString(value.vault_a) &&30+ isString(value.vault_b) &&31+ isFiniteNumber(value.sent_at)3232 );33- case "SYNC_MANIFEST":34- return Array.isArray(value.entries) && value.entries.every(isSyncManifestEntry);35- case "SYNC_REQUEST":36- return isStringArray(value.ids);37- case "SYNC_ENTRIES":38- return Array.isArray(value.entries) && value.entries.every(isEncryptedSyncEntry);39- case "SYNC_DONE":40- return true;33+ case "SYNC_VAULT_ACK":34+ return isFiniteNumber(value.received_at);4135 case "SYNC_ERROR":42- return isString(value.code) && isString(value.message);36+ return isString(value.reason);4337 case "PAIR_REQUEST":44- return isString(value.edPublicKey) && isString(value.name) && isString(value.code);38+ return isString(value.public_key_hex) && isString(value.device_name);4539 case "PAIR_ACCEPT":46- return isString(value.edPublicKey) && isString(value.name);40+ return isString(value.public_key_hex) && isString(value.device_name);4741 case "PAIR_REJECT":4842 return isString(value.reason);4943 default:@@ -51,28 +45,6 @@5145 }5246 }5347 54-function isSyncManifestEntry(value: unknown): boolean {55- return (56- isRecord(value) &&57- isString(value.id) &&58- isFiniteNumber(value.modifiedAt) &&59- isOptionalNumber(value.deletedAt) &&60- isString(value.hash)61- );62-}63-64-function isEncryptedSyncEntry(value: unknown): boolean {65- return (66- isRecord(value) &&67- isString(value.id) &&68- isFiniteNumber(value.modifiedAt) &&69- isOptionalNumber(value.deletedAt) &&70- isString(value.ciphertext) &&71- isString(value.nonce) &&72- isString(value.tag)73- );74-}75-7648 function isRecord(value: unknown): value is Record<string, unknown> {7749 return Boolean(value) && typeof value === "object" && !Array.isArray(value);7850 }@@ -81,16 +53,8 @@8153 return typeof value === "string";8254 }8355 84-function isStringArray(value: unknown): value is string[] {85- return Array.isArray(value) && value.every(isString);86-}87-8856 function isFiniteNumber(value: unknown): value is number {8957 return typeof value === "number" && Number.isFinite(value);90-}91-92-function isOptionalNumber(value: unknown): boolean {93- return value === undefined || isFiniteNumber(value);9458 }9559 9660 function base64urlEncode(bytes: Uint8Array): string {