1d6e63e6bfd3
feat: add atomic vault creation from adopted root key (v4.0.14)
- time
- 2026-06-19 19:45 UTC · 4 months ago
- machine
- koh2
- agent
- gpt-5
- session
- vault adoption integration
- parent
- 8d7e39854a6b
↓ peach-browser-1d6e63e6.tar.gzatlas at this save
files changed8 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.create-vault-from-root-key.test.ts+83 −0src/background/messages.ts+19 −0src/background/vault-createVaultFromRootKey.test.ts+159 −0src/background/vault.ts+131 −0src/shared/types.ts+4 −0manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22 "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33 "manifest_version": 3,44 "name": "Peach Password Manager",5- "version": "4.0.13",5+ "version": "4.0.14",66 "description": "Zero-knowledge password manager",77 "browser_specific_settings": {88 "gecko": {manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.0.13",4+ "version": "4.0.14",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": "4.0.13",3+ "version": "4.0.14",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/background/messages.create-vault-from-root-key.test.ts+83 −0
@@ -0,0 +1,83 @@1+import { beforeEach, describe, expect, it, vi } from "vitest";2+import { computeVaultId } from "../shared/sync-identity";3+4+async function dispatch(message: { type: string; payload?: unknown }) {5+ vi.resetModules();6+ const messages = await import("./messages");7+ return messages.handleMessage(8+ message as never,9+ { url: chrome.runtime.getURL("popup.html") } as chrome.runtime.MessageSender,10+ );11+}12+13+describe("CREATE_VAULT_FROM_ROOT_KEY message", () => {14+ beforeEach(() => {15+ vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);16+ vi.stubGlobal("chrome", {17+ runtime: {18+ id: "ext",19+ getURL: (path = "") => `chrome-extension://ext/${path}`,20+ },21+ tabs: {22+ query: vi.fn((_query: unknown, callback?: (tabs: Array<{ id?: number }>) => void) => {23+ if (typeof callback === "function") callback([]);24+ return Promise.resolve([]);25+ }),26+ sendMessage: vi.fn(async () => ({ ok: true })),27+ },28+ storage: {29+ local: {30+ get: vi.fn(async () => ({})),31+ set: vi.fn(),32+ remove: vi.fn(),33+ },34+ session: { get: vi.fn(async () => ({})), set: vi.fn(), remove: vi.fn() },35+ },36+ alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },37+ idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },38+ });39+ });40+41+ it("T-A06: succeeds with a valid receiver password and root key", async () => {42+ const res = await dispatch({43+ type: "CREATE_VAULT_FROM_ROOT_KEY",44+ payload: { password: "receiver-local-passphrase-456", rootKeyHex: "44".repeat(32) },45+ }) as { ok: boolean; vaultId?: string };46+47+ expect(res.ok).toBe(true);48+ expect(res.vaultId).toMatch(/^[0-9a-f]{64}$/);49+ });50+51+ it("T-A07: fails gracefully for invalid rootKeyHex", async () => {52+ const res = await dispatch({53+ type: "CREATE_VAULT_FROM_ROOT_KEY",54+ payload: { password: "receiver-local-passphrase-456", rootKeyHex: "nope" },55+ }) as { ok: boolean; error?: string };56+57+ expect(res.ok).toBe(false);58+ expect(res.error).toContain("ADOPT_INVALID_ROOT_KEY");59+ });60+61+ it("T-A08: is vault-less allowed", async () => {62+ const hasBefore = await dispatch({ type: "HAS_VAULT" }) as { ok: boolean; data?: boolean };63+ expect(hasBefore.data).toBe(false);64+65+ const create = await dispatch({66+ type: "CREATE_VAULT_FROM_ROOT_KEY",67+ payload: { password: "receiver-local-passphrase-456", rootKeyHex: "55".repeat(32) },68+ }) as { ok: boolean };69+70+ expect(create.ok).toBe(true);71+ });72+73+ it("T-A09: returns the vault id computed from the adopted root key", async () => {74+ const rootKeyHex = "66".repeat(32);75+ const expected = await computeVaultId(new Uint8Array(Buffer.from(rootKeyHex, "hex")));76+ const res = await dispatch({77+ type: "CREATE_VAULT_FROM_ROOT_KEY",78+ payload: { password: "receiver-local-passphrase-456", rootKeyHex },79+ }) as { ok: boolean; vaultId?: string };80+81+ expect(res).toEqual({ ok: true, vaultId: expected });82+ });83+});src/background/messages.ts+19 −0
@@ -297,6 +297,25 @@297297 onVaultUnlocked();298298 return { ok: true };299299 }300+ case "CREATE_VAULT_FROM_ROOT_KEY": {301+ const { password, rootKeyHex } = (msg.payload ?? {}) as {302+ password?: unknown;303+ rootKeyHex?: unknown;304+ };305+ if (typeof password !== "string" || password.length === 0 || typeof rootKeyHex !== "string") {306+ return { ok: false, error: "invalid_payload" };307+ }308+309+ try {310+ const { vaultId } = await vault.createVaultFromRootKey(password, rootKeyHex);311+ await setupAutoLock();312+ broadcastToContentScripts({ type: "VAULT_UNLOCKED" });313+ onVaultUnlocked();314+ return { ok: true, vaultId };315+ } catch (err) {316+ return { ok: false, error: err instanceof Error ? err.message : String(err) };317+ }318+ }300319 case "UNLOCK": {301320 const { password } = msg.payload as { password: string };302321 const ok = await vault.unlock(password);src/background/vault-createVaultFromRootKey.test.ts+159 −0
@@ -0,0 +1,159 @@1+import { beforeEach, describe, expect, it, vi } from "vitest";2+import { computeVaultId } from "../shared/sync-identity";3+import type { StoredVault } from "../shared/types";4+5+type StorageShape = Record<string, unknown>;6+7+function makeChromeStorageMock(): { storage: StorageShape; chrome: typeof chrome } {8+ const storage: StorageShape = {};9+ const chromeMock = {10+ storage: {11+ local: {12+ get: vi.fn(async (key?: string | string[]) => {13+ if (!key) return { ...storage };14+ if (typeof key === "string") return { [key]: storage[key] };15+ return key.reduce<StorageShape>((acc, k) => {16+ acc[k] = storage[k];17+ return acc;18+ }, {});19+ }),20+ set: vi.fn(async (next: StorageShape) => {21+ Object.assign(storage, next);22+ }),23+ remove: vi.fn(async (keys: string | string[]) => {24+ for (const k of Array.isArray(keys) ? keys : [keys]) delete storage[k];25+ }),26+ },27+ session: { get: vi.fn(async () => ({})), set: vi.fn(), remove: vi.fn() },28+ },29+ alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },30+ idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },31+ } as unknown as typeof chrome;32+ return { storage, chrome: chromeMock };33+}34+35+describe("vault adoption: createVaultFromRootKey", () => {36+ const SOURCE_PASSWORD = "source-adoption-passphrase-123";37+ const RECEIVER_PASSWORD = "receiver-local-passphrase-456";38+39+ beforeEach(() => {40+ vi.resetModules();41+ const mock = makeChromeStorageMock();42+ vi.stubGlobal("chrome", mock.chrome);43+ vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("no network"); }));44+ });45+46+ it("T-A01: round-trips an existing root key under a different local password", async () => {47+ const sourceVault = await import("./vault");48+ await sourceVault.createVault(SOURCE_PASSWORD);49+ const rootKeyHex = sourceVault.getRootKeyHex();50+ expect(rootKeyHex).toMatch(/^[0-9a-f]{64}$/);51+ const sourceVaultId = await computeVaultId(new Uint8Array(Buffer.from(rootKeyHex!, "hex")));52+53+ vi.resetModules();54+ const mock = makeChromeStorageMock();55+ vi.stubGlobal("chrome", mock.chrome);56+ const receiverVault = await import("./vault");57+ const result = await receiverVault.createVaultFromRootKey(RECEIVER_PASSWORD, rootKeyHex!);58+59+ expect(result.vaultId).toBe(sourceVaultId);60+ receiverVault.lock();61+ expect(await receiverVault.unlock(RECEIVER_PASSWORD)).toBe(true);62+ expect(receiverVault.getRootKeyHex()).toBe(rootKeyHex);63+ });64+65+ it("T-A02: recovery words match the source vault because they derive from the same root key prefix", async () => {66+ const sourceVault = await import("./vault");67+ await sourceVault.createVault(SOURCE_PASSWORD);68+ const rootKeyHex = sourceVault.getRootKeyHex()!;69+ const sourceWords = await sourceVault.getRecoveryWords();70+71+ vi.resetModules();72+ const mock = makeChromeStorageMock();73+ vi.stubGlobal("chrome", mock.chrome);74+ const receiverVault = await import("./vault");75+ await receiverVault.createVaultFromRootKey(RECEIVER_PASSWORD, rootKeyHex);76+77+ expect(await receiverVault.getRecoveryWords()).toEqual(sourceWords);78+ });79+80+ it.each([81+ ["63 chars", "a".repeat(63)],82+ ["65 chars", "a".repeat(65)],83+ ["uppercase", "A".repeat(64)],84+ ["non-hex", "g".repeat(64)],85+ ["empty", ""],86+ ])("T-A03: rejects invalid root key input: %s", async (_label, rootKeyHex) => {87+ const vault = await import("./vault");88+ await expect(vault.createVaultFromRootKey(RECEIVER_PASSWORD, rootKeyHex))89+ .rejects.toThrow(/ADOPT_INVALID_ROOT_KEY/);90+ });91+92+ it("T-A04: zeroes the decoded root key buffer on success and failure", async () => {93+ const fillSpy = vi.spyOn(Uint8Array.prototype, "fill");94+ const vault = await import("./vault");95+ await vault.createVaultFromRootKey(RECEIVER_PASSWORD, "11".repeat(32));96+ expect(fillSpy).toHaveBeenCalledWith(0);97+98+ vi.resetModules();99+ const mock = makeChromeStorageMock();100+ const storageSet = mock.chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;101+ storageSet.mockRejectedValueOnce(new Error("quota"));102+ vi.stubGlobal("chrome", mock.chrome);103+ const failingVault = await import("./vault");104+105+ fillSpy.mockClear();106+ await expect(failingVault.createVaultFromRootKey(RECEIVER_PASSWORD, "22".repeat(32))).rejects.toThrow();107+ expect(fillSpy).toHaveBeenCalledWith(0);108+ });109+110+ it("T-A05: adopted vault supports normal entry writes and keeps the original vault id", async () => {111+ const vault = await import("./vault");112+ const rootKeyHex = "33".repeat(32);113+ const { vaultId } = await vault.createVaultFromRootKey(RECEIVER_PASSWORD, rootKeyHex);114+ const entry = await vault.addEntry({115+ type: "login",116+ title: "Example",117+ url: "https://example.com",118+ username: "june",119+ password: "secret",120+ });121+122+ expect(vault.getEntries()).toContainEqual(expect.objectContaining({ id: entry.id, title: "Example" }));123+ expect(vaultId).toBe(await computeVaultId(new Uint8Array(Buffer.from(rootKeyHex, "hex"))));124+ });125+126+ it("T-A30: storage failure leaves the receiver vault-less with no partial slot", async () => {127+ const mock = makeChromeStorageMock();128+ const storageSet = mock.chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;129+ storageSet.mockRejectedValueOnce(new Error("quota"));130+ vi.stubGlobal("chrome", mock.chrome);131+ const vault = await import("./vault");132+133+ await expect(vault.createVaultFromRootKey(RECEIVER_PASSWORD, "77".repeat(32)))134+ .rejects.toThrow(/STORAGE_QUOTA_EXCEEDED|quota/);135+136+ expect(mock.storage.peach_vault_a).toBeUndefined();137+ expect(mock.storage.peach_vault_b).toBeUndefined();138+ expect(await vault.hasVault()).toBe(false);139+ expect(vault.isUnlocked()).toBe(false);140+ });141+142+ it("T-A30: commits slot A, slot B, and timestamp together", async () => {143+ const mock = makeChromeStorageMock();144+ vi.stubGlobal("chrome", mock.chrome);145+ const vault = await import("./vault");146+147+ await vault.createVaultFromRootKey(RECEIVER_PASSWORD, "88".repeat(32));148+149+ const storageSet = mock.chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;150+ const commitCall = storageSet.mock.calls.find(([payload]) => (151+ Boolean((payload as StorageShape).peach_vault_a) &&152+ Boolean((payload as StorageShape).peach_vault_b)153+ ));154+ expect(commitCall).toBeDefined();155+ expect((commitCall![0] as StorageShape).peach_vault_a).toMatchObject({ version: 1 } satisfies Partial<StoredVault>);156+ expect((commitCall![0] as StorageShape).peach_vault_b).toMatchObject({ version: 1 } satisfies Partial<StoredVault>);157+ expect((commitCall![0] as StorageShape).peach_last_modified_at).toEqual(expect.any(String));158+ });159+});src/background/vault.ts+131 −0
@@ -12,6 +12,7 @@1212 import { deriveKey, deriveKeyRaw, generateSalt } from "../crypto/argon2";1313 import { extractDomain } from "../shared/domain";1414 import { clampSyncTimestamp } from "../shared/sync-limits";15+import { computeVaultId } from "../shared/sync-identity";1516 import { recordSyncAlerts, type SyncAlert } from "./sync-alerts";1617 import {1718 rootKeyToWords,@@ -341,6 +342,41 @@341342 };342343 }343344 345+async function wrapExistingRootKeyForRecovery(rootKeyBytes: Uint8Array): Promise<{346+ recoverySalt: string;347+ wrappedRootKey: string;348+ wrappedNonce: string;349+ wrappedTag: string;350+}> {351+ if (rootKeyBytes.length !== 32) {352+ throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");353+ }354+355+ const words = await rootKeyToWords(rootKeyBytes);356+ const wordMaterial = await wordsToRootKeyMaterial(words);357+ const recoverySalt = crypto.getRandomValues(new Uint8Array(16));358+ const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);359+ const nonce = crypto.getRandomValues(new Uint8Array(12));360+ const encoded = new TextEncoder().encode(hex(rootKeyBytes));361+ const raw = await crypto.subtle.encrypt(362+ {363+ name: "AES-GCM",364+ iv: nonce,365+ additionalData: new Uint8Array([1]).buffer as ArrayBuffer,366+ tagLength: 128,367+ },368+ recoveryKey,369+ encoded,370+ );371+ const rawU8 = new Uint8Array(raw);372+ return {373+ recoverySalt: hex(recoverySalt),374+ wrappedRootKey: hex(rawU8.slice(0, rawU8.length - 16)),375+ wrappedNonce: hex(nonce),376+ wrappedTag: hex(rawU8.slice(rawU8.length - 16)),377+ };378+}379+344380 /**345381 * Unwrap the root key using recovery words. Called during recovery flow when346382 * the user provides their 12-word phrase and a new password.@@ -579,6 +615,26 @@579615 }580616 }581617 618+async function commitCreatedVaultSlots(slotA: StoredVault, slotB: StoredVault): Promise<void> {619+ const raw = await chrome.storage.local.get("peach_last_modified_at");620+ const previous = Number(621+ (raw as Record<string, unknown>).peach_last_modified_at,622+ );623+ const next = Math.max(624+ Date.now(),625+ Number.isFinite(previous) ? previous + 1 : 0,626+ );627+ try {628+ await chrome.storage.local.set({629+ [SLOT_A]: slotA,630+ [SLOT_B]: slotB,631+ peach_last_modified_at: String(next),632+ });633+ } catch (err) {634+ throw mapStorageError(err);635+ }636+}637+582638 // Monotonic clock for per-entry modifiedAt / deletedAt timestamps. Guards the583639 // sync layer's last-write-wins merge against wall-clock regressions (NTP step,584640 // manual clock change, DST glitch): a backwards Date.now() must never produce@@ -1004,6 +1060,81 @@10041060 await stampLocalVaultMutation();10051061 10061062 touchAutoLock();1063+}1064+1065+/**1066+ * Creates a local vault from an existing vault root key received through the1067+ * SAS-gated adoption protocol. The password is local to this device; it wraps1068+ * the adopted root key at rest and does not need to match the source device.1069+ */1070+export async function createVaultFromRootKey(1071+ password: string,1072+ rootKeyHex: string,1073+): Promise<{ vaultId: string }> {1074+ if (typeof password !== "string" || password.length === 0) {1075+ throw new Error("ADOPT_INVALID_PASSWORD");1076+ }1077+ if (typeof rootKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(rootKeyHex)) {1078+ throw new Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars");1079+ }1080+1081+ const rootKeyBytes = unhex(rootKeyHex);1082+ if (rootKeyBytes.length !== 32) {1083+ rootKeyBytes.fill(0);1084+ throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");1085+ }1086+1087+ let nextDerivedKey: Uint8Array | null = null;1088+ let sessionRootKey: Uint8Array | null = null;1089+ try {1090+ await ensureMigrated();1091+1092+ const nextEntries: VaultEntry[] = [];1093+ const nextDuressConfigured = false;1094+ const vaultId = await computeVaultId(rootKeyBytes);1095+ const salt = generateSalt();1096+ nextDerivedKey = await deriveKey(password, salt);1097+ const recovery = await wrapExistingRootKeyForRecovery(rootKeyBytes);1098+1099+ sessionRootKey = new Uint8Array(rootKeyBytes);1100+ const plaintext = encodePayload(nextEntries, nextDuressConfigured, 0);1101+ const { nonce, ciphertext, tag } = await encrypt(nextDerivedKey, plaintext, "slot_a_v1");1102+ const pwRoot = await wrapRootKeyForDerivedKey(nextDerivedKey, sessionRootKey);1103+ const slotA: StoredVault = {1104+ version: 1,1105+ salt: hex(salt),1106+ nonce,1107+ ciphertext,1108+ tag,1109+ recovery_salt: recovery.recoverySalt,1110+ recovery_wrapped_root_key: recovery.wrappedRootKey,1111+ recovery_nonce: recovery.wrappedNonce,1112+ recovery_tag: recovery.wrappedTag,1113+ ...pwRoot,1114+ };1115+ const slotB = generateNoiseBlob(ciphertext.length / 2);1116+ await commitCreatedVaultSlots(slotA, slotB);1117+1118+ const previousDerivedKey = derivedKey;1119+ const previousRootKey = rootKey;1120+ derivedKey = nextDerivedKey;1121+ rootKey = sessionRootKey;1122+ nextDerivedKey = null;1123+ sessionRootKey = null;1124+ entries = nextEntries;1125+ vaultMode = "real";1126+ duressConfigured = nextDuressConfigured;1127+ buildDomainIndexSync(entries);1128+ if (previousDerivedKey && previousDerivedKey !== derivedKey) zeroKey(previousDerivedKey);1129+ if (previousRootKey && previousRootKey !== rootKey) previousRootKey.fill(0);1130+ touchAutoLock();1131+1132+ return { vaultId };1133+ } finally {1134+ rootKeyBytes.fill(0);1135+ if (nextDerivedKey) zeroKey(nextDerivedKey);1136+ if (sessionRootKey) sessionRootKey.fill(0);1137+ }10071138 }10081139 10091140 export async function hasVault(): Promise<boolean> {src/shared/types.ts+4 −0
@@ -281,6 +281,7 @@281281 | "AUTOFILL_QUERY"282282 | "COPY"283283 | "CREATE_VAULT"284+ | "CREATE_VAULT_FROM_ROOT_KEY"284285 | "GET_ENTRY_FOR_FILL"285286 | "GET_ENTRY_FOR_EDIT"286287 | "CHECK_ALL_BREACHES"@@ -537,12 +538,15 @@537538 | "message_empty"538539 | "network_error"539540 | "operation_failed"541+ | "adopt_invalid_root_key"542+ | "adopt_vault_id_mismatch"540543 | "unknown_message_type";541544 542545 export interface MessageResponse {543546 ok: boolean;544547 data?: unknown;545548 error?: string;549+ vaultId?: string;546550 unlocked?: boolean;547551 // Set on UNLOCK responses when the vault decrypted but failed schema548552 // validation (M7/COR-007). The popup may offer a history-restore flow.