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
↓ peach-browser-74ca2839.tar.gzatlas at this save
files changed8 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 −26manifest.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": {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",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",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 () => {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,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, {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 });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 /**