7410c0da42c1
feat: Carry the sealed duress slot across adoption snapshot (v4.6.0)
- time
- 2026-07-30 09:09 UTC · 2 months ago
- machine
- koh2
- parent
- bcae1b330341
↓ peach-browser-7410c0da.tar.gzatlas at this save
files changed9 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 −4manifest.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": {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",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",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", () => ({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,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+});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 *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 });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 {