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
8 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 −0
manifest.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": {

view file

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",

view file

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",

view file

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+});

view file

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);

view file

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+});

view file

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> {

view file

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.

view file