74ca2839ec4f

fix: Fail oversized duress before snapshot send (v4.6.5)

time
2026-07-30 23:10 UTC · 2 months ago
machine
koh2
parent
4c15b8de49bb
8 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/relay-adoption-bg.test.ts+60 −0src/background/relay-adoption-bg.ts+6 −0src/shared/vault-adopt-snapshot.test.ts+20 −0src/shared/vault-adopt.test.ts+10 −2src/shared/vault-adopt.ts+19 −26
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.6.4",4+  "version": "4.6.5",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

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

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "4.6.4",3+  "version": "4.6.5",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/relay-adoption-bg.test.ts+60 −0
@@ -15,6 +15,7 @@1515   localIdentity: vi.fn(),1616   rollbackVault: vi.fn(),1717   storageSet: vi.fn(),18+  createSyncAdapter: vi.fn(),1819   sasDecisions: [] as Array<{ id: string; ok: boolean }>,1920 }));2021 @@ -95,6 +96,10 @@9596   triggerSyncIfReachable: mocks.triggerSync,9697 }));9798 99+vi.mock("./sync-vault-adapter", () => ({100+  createSyncVaultAdapter: mocks.createSyncAdapter,101+}));102+98103 import {99104   cancelRelayAdoptionSource,100105   startRelayAdoptionSource,@@ -158,6 +163,14 @@158163   });159164   mocks.getRootKeyHex.mockReturnValue("c".repeat(64));160165   mocks.upsert.mockResolvedValue(undefined);166+  mocks.createSyncAdapter.mockReturnValue({167+    buildEntryManifest: vi.fn(async () => ({})),168+    getEncryptedEntries: vi.fn(async () => ({})),169+    buildTombstoneManifest: vi.fn(async () => ({})),170+    applyEntries: vi.fn(async () => undefined),171+    applyTombstones: vi.fn(async () => undefined),172+    buildOutgoingDeviceManifest: vi.fn(async () => null),173+  });161174   Object.defineProperty(globalThis, "chrome", {162175     configurable: true,163176     value: {@@ -273,6 +286,53 @@273286         }274287       });275288     expect(sentTypes).toContain("ADOPT_COMPLETE");289+  });290+291+  it("fails adoption without sending snapshot frames when duress is oversized", async () => {292+    const socket = socketWithFrames([293+      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },294+      {295+        type: "ADOPT_REQUEST",296+        device_id: "request-only-label",297+        device_name: "June's phone",298+        device_type: "android",299+        snapshot_capable: true,300+      },301+      { type: "ADOPT_CONFIRM", confirmed: true },302+      {303+        type: "ADOPT_IDENTITY",304+        device_id: "android-receiver",305+        device_name: "June's phone",306+        device_type: "android",307+        long_term_public_key_hex: "d".repeat(64),308+      },309+      { type: "ADOPT_APPLIED" },310+    ]);311+    mocks.openSocket.mockResolvedValue(socket);312+    const { exportDuressSlotForAdoption } = await import("./vault");313+    vi.mocked(exportDuressSlotForAdoption).mockResolvedValueOnce(314+      "x".repeat(768 * 1024),315+    );316+317+    await startRelayAdoptionSource();318+    await waitForPush("ADOPTION_SOURCE_RESULT");319+320+    expect(mocks.pushes).toContainEqual({321+      type: "ADOPTION_SOURCE_RESULT",322+      payload: {323+        status: "error",324+        error: "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE",325+      },326+    });327+    expect(mocks.pushes).not.toContainEqual(expect.objectContaining({328+      type: "ADOPTION_SOURCE_RESULT",329+      payload: expect.objectContaining({ status: "complete" }),330+    }));331+    const sentTypes = socket.send.mock.calls.map(([bytes]) =>332+      JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,333+    );334+    expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");335+    expect(mocks.triggerSync).not.toHaveBeenCalled();276336   });277337 278338   it("fails closed when the popup rejects the SAS", async () => {

view file

src/background/relay-adoption-bg.ts+6 −0
@@ -307,6 +307,12 @@307307           }308308         }309309       } catch (snapshotError) {310+        if (311+          snapshotError instanceof Error &&312+          snapshotError.message === "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE"313+        ) {314+          throw snapshotError;315+        }310316         console.info(311317           "[Peach] inline snapshot send failed (receiver will use sync pipeline):",312318           snapshotError,

view file

src/shared/vault-adopt-snapshot.test.ts+20 −0
@@ -59,6 +59,26 @@5959     expect(result.tombstones).toEqual(tombstones);6060   });6161 62+  it("rejects oversized duress before sending any snapshot frame", async () => {63+    const [source] = memoryTransportPair();64+    let frames = 0;65+    const countingSource: PairingTransport = {66+      send: (msg) => {67+        frames++;68+        return source.send(msg);69+      },70+      receive: () => source.receive(),71+    };72+73+    await expect(sendVaultSnapshot(74+      countingSource,75+      { e1: "cipher1" },76+      {},77+      "x".repeat(768 * 1024),78+    )).rejects.toThrow("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");79+    expect(frames).toBe(0);80+  });81+6282   it("rejects out-of-order chunks", async () => {6383     const [source, receiver] = memoryTransportPair();6484     const send = sendFrame(source, {

view file

src/shared/vault-adopt.test.ts+10 −2
@@ -789,7 +789,7 @@789789     expect(result.duressSlot).toBe(duressJson);790790     // The inline cap = MAX_SNAPSHOT_CHUNKS - 1 = 199 entries, so 199 of 200791791     // entries are inline; the rest are deferred to sync.792-    expect(Object.keys(result.entries).length).toBeLessThan(200);792+    expect(Object.keys(result.entries).length).toBe(199);793793     expect(result.tombstones).toEqual({ "dead-0": 1 });794794   });795795 @@ -851,6 +851,14 @@851851     expect(observed[1].hasDuress).toBe(true);852852     expect(observed[1].entriesKeys).toBe(0);853853     expect(observed[1].size).toBeLessThan(CEILING);854-    expect(observed[0].size + observed[1].size).toBeGreaterThan(CEILING);854+    const legacyCombinedSize = new TextEncoder().encode(JSON.stringify({855+      type: "ADOPT_SNAPSHOT",856+      chunk_index: 0,857+      chunk_count: 1,858+      entries,859+      tombstones,860+      duress_slot: duressJson,861+    })).length;862+    expect(legacyCombinedSize).toBeGreaterThan(CEILING);855863   });856864 });

view file

src/shared/vault-adopt.ts+19 −26
@@ -362,7 +362,25 @@362362   const entriesChunkCount = Math.min(chunks.length, entryCap);363363   const sendDuress = includeDuress;364364   const totalChunks = entriesChunkCount + (sendDuress ? 1 : 0);365+  const duressFrame: AdoptSnapshot | null = sendDuress366+    ? {367+        type: "ADOPT_SNAPSHOT",368+        chunk_index: entriesChunkCount,369+        chunk_count: totalChunks,370+        entries: {},371+        tombstones,372+        duress_slot: duressSlot as string,373+      }374+    : null;365375 376+  if (377+    duressFrame &&378+    new TextEncoder().encode(JSON.stringify(duressFrame)).length >379+      SNAPSHOT_FRAME_WIRE_CEILING380+  ) {381+    throw new Error("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");382+  }383+366384   for (let index = 0; index < entriesChunkCount; index++) {367385     const isFinalEntries = index === entriesChunkCount - 1;368386     const frame: AdoptSnapshot = {@@ -377,32 +395,7 @@377395     await sendFrame(channel, frame);378396   }379397 380-  if (sendDuress) {381-    // Pre-check: a single duress frame that exceeds the wire ceiling cannot be382-    // sent and would fail the receiver's size check. Throw loudly so the383-    // caller can split duress out of band instead of triggering a silent384-    // "adoption succeeded but duress vanished" path. Sync cannot recover385-    // duress — the spec is a hard stop.386-    const duressEnvelope = JSON.stringify({387-      type: "ADOPT_SNAPSHOT",388-      chunk_index: entriesChunkCount,389-      chunk_count: totalChunks,390-      entries: {},391-      tombstones,392-      duress_slot: duressSlot,393-    });394-    if (new TextEncoder().encode(duressEnvelope).length > SNAPSHOT_FRAME_WIRE_CEILING) {395-      throw new Error("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");396-    }397-    await sendFrame(channel, {398-      type: "ADOPT_SNAPSHOT",399-      chunk_index: entriesChunkCount,400-      chunk_count: totalChunks,401-      entries: {},402-      tombstones,403-      duress_slot: duressSlot as string,404-    });405-  }398+  if (duressFrame) await sendFrame(channel, duressFrame);406399 }407400 408401 /**

view file