7410c0da42c1

feat: Carry the sealed duress slot across adoption snapshot (v4.6.0)

A duress configured on the extension must survive adopting the vault onto a new device. The 2.9.0 inline adoption snapshot shipped entries + tombstones but not the duress slot, so phone-pairing adoption silently dropped a configured decoy (the Codex-restore path already carried it). Wire contract (additive, backward-compatible; coordinated with peach-android): - ADOPT_SNAPSHOT gains optional duress_slot?: string = JSON.stringify of the browser-shaped slot B, identical to the Codex vault_b field. - Populated on the FINAL chunk only, and only when duress is configured (isDuressEnabled); absent otherwise (never ships noise). Old peers ignore it. Changes: - vault-adopt.ts: duress_slot on AdoptSnapshot + isAdoptSnapshot guard; sendVaultSnapshot threads it (final chunk only); receiveVaultSnapshot returns duressSlot. - vault.ts: exportDuressSlotForAdoption (source; no decrypt) and installAdoptedDuressSlot (receiver; same hasValidVaultSchema guard as unlockFromCodex; passthrough write to slot B; no decrypt). - relay-adoption-source-bg.ts: source sends it (omission non-fatal); receiver installs it in an isolated try/catch so a duress-install failure cannot demote a successful entry snapshot to the sync fallback. Preserves browser HOLE-5 (duress vault has no root key / sync identity): duress propagates via vault TRANSFER, not day-to-day sync. Verification (orchestrator-independent): tsc clean; full vitest 3656 passed / 138 files (baseline 3645, +11 tests); Chrome+Firefox+Mobile builds succeed.

time
2026-07-30 09:09 UTC · 2 months ago
machine
koh2
parent
bcae1b330341
9 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/relay-adoption-source-bg.test.ts+2 −0src/background/relay-adoption-source-bg.ts+20 −2src/background/vault.duress-adoption.test.ts+119 −0src/background/vault.ts+38 −0src/shared/vault-adopt-snapshot.test.ts+95 −0src/shared/vault-adopt.ts+18 −4
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.5.1",4+  "version": "4.6.0",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.5.1",5+  "version": "4.6.0",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.5.1",3+  "version": "4.6.0",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/relay-adoption-source-bg.test.ts+2 −0
@@ -75,6 +75,8 @@7575   getRootKeyHex: mocks.getRootKeyHex,7676   createVaultFromRootKey: mocks.createVault,7777   rollbackAdoptedVault: mocks.rollbackVault,78+  exportDuressSlotForAdoption: vi.fn(async () => null),79+  installAdoptedDuressSlot: vi.fn(async () => undefined),7880 }));7981 8082 vi.mock("../shared/sync-identity", () => ({

view file

src/background/relay-adoption-source-bg.ts+20 −2
@@ -36,7 +36,7 @@3636 import { buildLocalIdentity, getDeviceName, getLocalPairingIdentity } from "./device-identity";3737 import { getSyncSettings, relayInForce } from "./sync-settings";3838 import { activateLicense, getLicenseKey, isProUser } from "./license";39-import { createVaultFromRootKey, getRootKeyHex, isUnlocked, rollbackAdoptedVault } from "./vault";39+import { createVaultFromRootKey, exportDuressSlotForAdoption, getRootKeyHex, installAdoptedDuressSlot, isUnlocked, rollbackAdoptedVault } from "./vault";4040 import { computeVaultId } from "../shared/sync-identity";4141 import { INITIAL_SYNC_PENDING_DEVICE_KEY } from "../shared/initial-sync";4242 import { storageRemove, storageSet } from "../shared/storage";@@ -284,7 +284,13 @@284284         const entries =285285           entryIds.length > 0 ? await adapter.getEncryptedEntries(entryIds) : {};286286         const tombstones = await adapter.buildTombstoneManifest();287-        await sendVaultSnapshot(channel, entries, tombstones);287+        let duressSlot: string | null = null;288+        try {289+          duressSlot = await exportDuressSlotForAdoption();290+        } catch {291+          // Duress omission must not fail the whole snapshot.292+        }293+        await sendVaultSnapshot(channel, entries, tombstones, duressSlot);288294 289295         const applied = await withTimeout(receiveFrame(channel), 15_000);290296         if (isAdoptSnapshotApplied(applied)) {@@ -451,6 +457,18 @@451457       const adapter = createSyncVaultAdapter();452458       await adapter.applyEntries(snapshot.entries);453459       await adapter.applyTombstones(snapshot.tombstones);460+      // Duress install is isolated: a failure here must NOT demote an461+      // otherwise-successful entry snapshot to the sync fallback.462+      if (snapshot.duressSlot) {463+        try {464+          await installAdoptedDuressSlot(snapshot.duressSlot);465+        } catch (duressError) {466+          console.info(467+            "[Peach] adopted duress slot install failed (non-fatal):",468+            duressError,469+          );470+        }471+      }454472       await sendFrame(channel, {455473         type: "ADOPT_SNAPSHOT_APPLIED",456474         entries_received: Object.keys(snapshot.entries).length,

view file

src/background/vault.duress-adoption.test.ts+119 −0
@@ -0,0 +1,119 @@1+import { beforeEach, describe, expect, it, vi } from "vitest";2+import type { StoredVault } from "../shared/types";3+4+type StorageShape = Record<string, unknown>;5+6+describe("vault duress adoption helpers", () => {7+  let storage: StorageShape;8+9+  beforeEach(() => {10+    vi.resetModules();11+    storage = {};12+13+    const chromeMock = {14+      storage: {15+        local: {16+          get: vi.fn(async (key?: string | string[]) => {17+            if (!key) return storage;18+            if (typeof key === "string") {19+              return { [key]: storage[key] };20+            }21+            return key.reduce<Record<string, unknown>>((result, entry) => {22+              result[entry] = storage[entry];23+              return result;24+            }, {});25+          }),26+          set: vi.fn(async (next: Record<string, unknown>) => {27+            Object.assign(storage, next);28+          }),29+          remove: vi.fn(async (keys: string | string[]) => {30+            const list = Array.isArray(keys) ? keys : [keys];31+            for (const key of list) {32+              delete storage[key];33+            }34+          }),35+        },36+      },37+      alarms: {38+        clear: vi.fn(),39+        create: vi.fn(),40+        onAlarm: { addListener: vi.fn() },41+      },42+      idle: {43+        setDetectionInterval: vi.fn(),44+        onStateChanged: { addListener: vi.fn() },45+      },46+    } as unknown as typeof chrome;47+48+    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;49+50+    vi.stubGlobal("fetch", vi.fn(async () => {51+      throw new Error("network disabled in tests");52+    }));53+  });54+55+  it("exportDuressSlotForAdoption returns null when duress is not configured", async () => {56+    const vault = await import("./vault");57+    await vault.createVault("correct horse battery staple");58+    expect(await vault.isDuressEnabled()).toBe(false);59+    expect(await vault.exportDuressSlotForAdoption()).toBeNull();60+  });61+62+  it("exportDuressSlotForAdoption returns JSON of slot B when duress is configured", async () => {63+    const vault = await import("./vault");64+    await vault.createVault("correct horse battery staple");65+    await vault.setupDuress("decoy-password-67890");66+    expect(await vault.isDuressEnabled()).toBe(true);67+68+    const result = await vault.exportDuressSlotForAdoption();69+    expect(result).not.toBeNull();70+71+    const parsed = JSON.parse(result!) as StoredVault;72+    expect(parsed.version).toBe(1);73+    expect(typeof parsed.salt).toBe("string");74+    expect(typeof parsed.ciphertext).toBe("string");75+    expect(typeof parsed.tag).toBe("string");76+  });77+78+  it("installAdoptedDuressSlot writes slot B with matching sealed bytes", async () => {79+    const vault = await import("./vault");80+    await vault.createVault("correct horse battery staple");81+    await vault.setupDuress("decoy-password-67890");82+83+    const sourceJson = await vault.exportDuressSlotForAdoption();84+    expect(sourceJson).not.toBeNull();85+86+    // Reset to simulate a fresh receiver vault (no duress configured yet)87+    vi.resetModules();88+    storage = { ...storage }; // keep existing storage89+    const vault2 = await import("./vault");90+91+    // Install the source's duress slot on the receiver92+    await vault2.installAdoptedDuressSlot(sourceJson!);93+94+    // Verify the stored slot B matches the source exactly (passthrough)95+    const receiverSlotB = storage.peach_vault_b as StoredVault;96+    const sourceParsed = JSON.parse(sourceJson!) as StoredVault;97+    expect(receiverSlotB.version).toBe(sourceParsed.version);98+    expect(receiverSlotB.salt).toBe(sourceParsed.salt);99+    expect(receiverSlotB.nonce).toBe(sourceParsed.nonce);100+    expect(receiverSlotB.ciphertext).toBe(sourceParsed.ciphertext);101+    expect(receiverSlotB.tag).toBe(sourceParsed.tag);102+  });103+104+  it("installAdoptedDuressSlot rejects invalid JSON", async () => {105+    const vault = await import("./vault");106+    await vault.createVault("correct horse battery staple");107+    await expect(108+      vault.installAdoptedDuressSlot("not-json"),109+    ).rejects.toThrow("invalid_duress_slot_shape");110+  });111+112+  it("installAdoptedDuressSlot rejects a valid JSON object with wrong shape", async () => {113+    const vault = await import("./vault");114+    await vault.createVault("correct horse battery staple");115+    await expect(116+      vault.installAdoptedDuressSlot(JSON.stringify({ version: 2, foo: "bar" })),117+    ).rejects.toThrow("invalid_duress_slot_shape");118+  });119+});

view file

src/background/vault.ts+38 −0
@@ -1498,6 +1498,44 @@14981498   }14991499 }15001500 1501+// --- Adoption duress-slot helpers (passthrough, no decrypt) ---1502+1503+/**1504+ * Adoption SOURCE helper: returns the sealed slot B as a JSON string when1505+ * duress is configured and slot B exists, so the inline snapshot can ship it1506+ * to the receiver. Returns null otherwise. Never decrypts.1507+ */1508+export async function exportDuressSlotForAdoption(): Promise<string | null> {1509+  if (!(await isDuressEnabled())) return null;1510+  await ensureMigrated();1511+  const slotB = await loadSlot(SLOT_B);1512+  if (!slotB) return null;1513+  return JSON.stringify(slotB);1514+}1515+1516+/**1517+ * Adoption RECEIVER helper: installs a duress slot received from a source1518+ * device. Validates the StoredVault shape (same guard as unlockFromCodex)1519+ * and writes it to slot B. Throws on invalid shape so the caller can treat1520+ * it as a non-fatal snapshot failure. Never decrypts.1521+ */1522+export async function installAdoptedDuressSlot(json: string): Promise<void> {1523+  let parsed: StoredVault;1524+  try {1525+    const raw = JSON.parse(json) as unknown;1526+    if (!hasValidVaultSchema(raw)) {1527+      throw new Error("invalid_duress_slot_shape");1528+    }1529+    parsed = raw;1530+  } catch (err) {1531+    if (err instanceof SyntaxError) {1532+      throw new Error("invalid_duress_slot_shape");1533+    }1534+    throw err;1535+  }1536+  await writeSlot(SLOT_B, parsed);1537+}1538+15011539 /**15021540  * Restore the vault from a parsed Peach Codex payload.15031541  *

view file

src/shared/vault-adopt-snapshot.test.ts+95 −0
@@ -145,4 +145,99 @@145145     const first = await receiveFrame(sourceChannel);146146     expect(first).toMatchObject({ type: "ADOPT_REQUEST", snapshot_capable: true });147147   });148+149+  it("round-trips a duress slot on the final chunk (single-chunk vault)", async () => {150+    const [source, receiver] = memoryTransportPair();151+    const duressJson = JSON.stringify({152+      version: 1,153+      salt: "aa".repeat(16),154+      nonce: "bb".repeat(12),155+      ciphertext: "cc".repeat(32),156+      tag: "dd".repeat(16),157+    });158+    const [result] = await Promise.all([159+      receiveVaultSnapshot(receiver),160+      sendVaultSnapshot(source, { e1: "cipher1" }, { t1: 100 }, duressJson),161+    ]);162+    expect(result.entries).toEqual({ e1: "cipher1" });163+    expect(result.tombstones).toEqual({ t1: 100 });164+    expect(result.duressSlot).toBe(duressJson);165+  });166+167+  it("round-trips a duress slot across multiple chunks (final chunk only)", async () => {168+    const [source, receiver] = memoryTransportPair();169+    const big = "x".repeat(200 * 1024);170+    const entries: Record<string, string> = {};171+    for (let i = 0; i < 5; i++) entries[`id-${i}`] = big;172+    const duressJson = JSON.stringify({173+      version: 1,174+      salt: "11".repeat(16),175+      nonce: "22".repeat(12),176+      ciphertext: "33".repeat(32),177+      tag: "44".repeat(16),178+    });179+    const [result] = await Promise.all([180+      receiveVaultSnapshot(receiver),181+      sendVaultSnapshot(source, entries, { dead: 1 }, duressJson),182+    ]);183+    expect(result.entries).toEqual(entries);184+    expect(result.duressSlot).toBe(duressJson);185+  });186+187+  it("omits duress_slot when no duress is configured (null)", async () => {188+    const [source, receiver] = memoryTransportPair();189+    const [result] = await Promise.all([190+      receiveVaultSnapshot(receiver),191+      sendVaultSnapshot(source, { e1: "v1" }, {}),192+    ]);193+    expect(result.duressSlot).toBeNull();194+  });195+196+  it("isAdoptSnapshot accepts frames with valid duress_slot string", () => {197+    expect(198+      isAdoptSnapshot({199+        type: "ADOPT_SNAPSHOT",200+        chunk_index: 0,201+        chunk_count: 1,202+        entries: {},203+        tombstones: {},204+        duress_slot: '{"version":1}',205+      }),206+    ).toBe(true);207+  });208+209+  it("isAdoptSnapshot accepts frames without duress_slot", () => {210+    expect(211+      isAdoptSnapshot({212+        type: "ADOPT_SNAPSHOT",213+        chunk_index: 0,214+        chunk_count: 1,215+        entries: {},216+        tombstones: {},217+      }),218+    ).toBe(true);219+  });220+221+  it("isAdoptSnapshot rejects frames with non-string duress_slot", () => {222+    expect(223+      isAdoptSnapshot({224+        type: "ADOPT_SNAPSHOT",225+        chunk_index: 0,226+        chunk_count: 1,227+        entries: {},228+        tombstones: {},229+        duress_slot: 42,230+      }),231+    ).toBe(false);232+    expect(233+      isAdoptSnapshot({234+        type: "ADOPT_SNAPSHOT",235+        chunk_index: 0,236+        chunk_count: 1,237+        entries: {},238+        tombstones: {},239+        duress_slot: { version: 1 },240+      }),241+    ).toBe(false);242+  });148243 });

view file

src/shared/vault-adopt.ts+18 −4
@@ -79,6 +79,12 @@7979   entries: Record<string, string>;8080   /** entryId → deletion timestamp; populated on the final chunk only. */8181   tombstones: Record<string, number>;82+  /**83+   * Sealed duress (slot B) StoredVault as JSON.stringify of the browser84+   * shape — identical to the Codex vault_b field. Populated on the final85+   * chunk only, and only when duress is actually configured; absent otherwise.86+   */87+  duress_slot?: string;8288 }8389 8490 /** Receiver acknowledgement sent after all snapshot chunks are applied. */@@ -237,6 +243,7 @@237243   ) return false;238244   if (!isStringRecord(value.entries)) return false;239245   if (!isNumberRecord(value.tombstones)) return false;246+  if (value.duress_slot !== undefined && typeof value.duress_slot !== "string") return false;240247   return true;241248 }242249 @@ -290,6 +297,7 @@290297   channel: PairingTransport,291298   entries: Record<string, string>,292299   tombstones: Record<string, number>,300+  duressSlot: string | null = null,293301 ): Promise<void> {294302   const chunks: Array<Record<string, string>> = [];295303   let current: Record<string, string> = {};@@ -313,13 +321,17 @@313321   const chunkCount = Math.min(chunks.length, MAX_SNAPSHOT_CHUNKS);314322   for (let index = 0; index < chunkCount; index++) {315323     const isFinal = index === chunkCount - 1;316-    await sendFrame(channel, {324+    const frame: AdoptSnapshot = {317325       type: "ADOPT_SNAPSHOT",318326       chunk_index: index,319327       chunk_count: chunkCount,320328       entries: chunks[index],321329       tombstones: isFinal ? tombstones : {},322-    });330+    };331+    if (isFinal && duressSlot) {332+      frame.duress_slot = duressSlot;333+    }334+    await sendFrame(channel, frame);323335   }324336 }325337 @@ -332,9 +344,10 @@332344  */333345 export async function receiveVaultSnapshot(334346   channel: PairingTransport,335-): Promise<{ entries: Record<string, string>; tombstones: Record<string, number> }> {347+): Promise<{ entries: Record<string, string>; tombstones: Record<string, number>; duressSlot: string | null }> {336348   const entries: Record<string, string> = {};337349   const tombstones: Record<string, number> = {};350+  let duressSlot: string | null = null;338351   let chunkCount = -1;339352   let received = 0;340353   while (chunkCount === -1 || received < chunkCount) {@@ -357,9 +370,10 @@357370     }358371     Object.assign(entries, frame.entries);359372     Object.assign(tombstones, frame.tombstones);373+    if (frame.duress_slot) duressSlot = frame.duress_slot;360374     received++;361375   }362-  return { entries, tombstones };376+  return { entries, tombstones, duressSlot };363377 }364378 365379 export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {

view file