63f03f2256df

refactor: drop pre-live compatibility formats (v5.0.0)

time
2026-08-29 21:02 UTC · 1 month ago
machine
koh2
parent
68b85733a708
40 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/aes.ts+3 −17src/background/codex-handlers.ts+1 −1src/background/device-identity.test.ts+0 −12src/background/device-identity.ts+1 −13src/background/messages.ts+1 −2src/background/recovery-words.test.ts+0 −36src/background/sync-settings.test.ts+1 −6src/background/sync-settings.ts+4 −12src/background/vault.session-escrow.test.ts+1 −1src/background/vault.test.ts+0 −91src/background/vault.ts+48 −327src/crypto/argon2.test.ts+1 −15src/crypto/argon2.ts+0 −12src/crypto/argon2id.kat.test.ts+1 −16src/crypto/peachPayload.test.ts+25 −36src/crypto/peachPayload.ts+17 −80src/popup/codex/codex.adversarial.test.ts+8 −22src/popup/codex/generate.ts+1 −1src/popup/codex/helpers.ts+3 −7src/popup/codex/pdf-import.test.ts+2 −11src/popup/codex/pdf-import.ts+3 −7src/popup/codex/restore.test.ts+4 −4src/popup/codex/restore.ts+11 −27src/popup/views/CodexRestoreView.test.tsx+1 −1src/popup/views/Health.tsx+1 −1src/popup/views/Import.tsx+12 −84src/popup/views/Lock.tsx+1 −4src/popup/views/contracts.test.tsx+1 −52src/popup/views/vault-search.test.ts+3 −3src/popup/views/vault-search.ts+0 −1src/shared/backup.nfc-fallback.test.ts+0 −133src/shared/backup.test.ts+0 −105src/shared/backup.ts+0 −307src/shared/codex-types.ts+12 −16src/shared/import.test.ts+7 −244src/shared/import.ts+26 −140src/shared/types.ts+6 −63
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.10.43",4+  "version": "5.0.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.10.43",5+  "version": "5.0.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.10.43",3+  "version": "5.0.0",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/aes.ts+3 −17
@@ -6,11 +6,8 @@66  * redirect its imports here. All ciphertext in the vault slots is produced77  * by `encrypt`/`decrypt` below with a fresh random 96-bit nonce.88  *9- * AAD (additionalData) carries an optional aadTag string for slot-scope10- * domain separation (L15/SEC-009). The legacy single-byte version AAD is11- * still accepted on decrypt-fallback so pre-L15 vaults open without a12- * migration write — callers that want hardened AAD just pass an aadTag13- * (e.g. "slot_a_v1") on both encrypt and decrypt.9+ * AAD (additionalData) carries an aadTag string for slot-scope domain10+ * separation (L15/SEC-009). Current vaults always use an explicit tag.1411  */1512 1613 const VERSION_BYTE = new Uint8Array([1]);@@ -92,18 +89,7 @@9289       await decryptWithAadBytes(keyBytes, nonce, ciphertext, tag, aad),9390     );9491 95-  try {96-    return await tryDecrypt(aadFor(aadTag));97-  } catch (err) {98-    // L15 migration: legacy ciphertext was sealed with VERSION_BYTE only.99-    // If a tagged decrypt fails, fall back so pre-L15 vaults still open.100-    // The caller is responsible for re-sealing with the new AAD on next101-    // write (which happens naturally — every vault mutation calls encrypt).102-    if (aadTag) {103-      return await tryDecrypt(VERSION_BYTE);104-    }105-    throw err;106-  }92+  return await tryDecrypt(aadFor(aadTag));10793 }10894 10995 async function decryptWithAadBytes(

view file

src/background/codex-handlers.ts+1 −1
@@ -115,7 +115,7 @@115115   const slotB = slotBRaw ? JSON.stringify(slotBRaw) : null;116116   const lastModified = typeof lastModRaw === "string" ? lastModRaw : null;117117 118-  // Recovery words exist only on v2.12+ vaults that have completed migration.118+  // Recovery words are present on every supported vault.119119   // A missing root key throws; treat that as "no words available" rather than120120   // a hard error so the Codex can still print without them.121121   let recoveryWords: string[] | null = null;

view file

src/background/device-identity.test.ts+0 −12
@@ -51,18 +51,6 @@5151     (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;5252   });5353 54-  it("migrates_legacy_sync_id_to_local_and_removes_from_sync", async () => {55-    sync[DEVICE_ID_KEY] = "legacy-roamed-id";56-    const { getOrCreateDeviceId } = await import("./device-identity");57-58-    const id = await getOrCreateDeviceId();59-60-    expect(id).toBe("legacy-roamed-id");61-    expect(local[DEVICE_ID_KEY]).toBe("legacy-roamed-id");62-    expect(syncRemove).toHaveBeenCalledWith(DEVICE_ID_KEY);63-    expect(sync[DEVICE_ID_KEY]).toBeUndefined();64-  });65-6654   it("fresh_install_writes_id_to_local_only", async () => {6755     const { getOrCreateDeviceId } = await import("./device-identity");6856 

view file

src/background/device-identity.ts+1 −13
@@ -3,8 +3,7 @@33 // `deviceId` is a stable UUID v4 generated on first call and persisted in44 // `chrome.storage.local`. It is deliberately device-local and must NOT roam:55 // it is the only symmetry-breaker in the sync-identity HKDF (see PCH-01-004),6-// so a shared id across profiles would collide derived sync keys. A first-read7-// migration moves any legacy value out of `chrome.storage.sync`.6+// so a shared id across profiles would collide derived sync keys.87 //98 // `deviceName` defaults to a short, friendly browser+OS description and109 // can be overridden by the user from Settings → Sync.@@ -43,17 +42,6 @@4342   const local = await chrome.storage.local.get(DEVICE_ID_KEY);4443   const localId = (local as Record<string, unknown>)[DEVICE_ID_KEY];4544   if (typeof localId === "string" && localId.length > 0) return localId;46-47-  // First-read migration: adopt a value previously stored in sync, then evict it48-  // from sync so a sibling profile cannot re-roam it back. Idempotent — once the49-  // id lives in local, this branch never runs again.50-  const synced = await chrome.storage.sync.get(DEVICE_ID_KEY);51-  const syncedId = (synced as Record<string, unknown>)[DEVICE_ID_KEY];52-  if (typeof syncedId === "string" && syncedId.length > 0) {53-    await chrome.storage.local.set({ [DEVICE_ID_KEY]: syncedId });54-    await chrome.storage.sync.remove(DEVICE_ID_KEY);55-    return syncedId;56-  }5745 5846   const fresh = generateUuidV4();5947   await chrome.storage.local.set({ [DEVICE_ID_KEY]: fresh });

view file

src/background/messages.ts+1 −2
@@ -389,7 +389,6 @@389389           const words = await vault.getRecoveryWords();390390           return { ok: true, data: words };391391         } catch {392-          // pre-v2.12 vault that hasn't migrated393392           return { ok: false, error: "recovery_not_setup" };394393         }395394       }@@ -780,7 +779,7 @@780779         if (seed) {781780           try {782781             // PCH-01-019: honour stored algorithm/digits/period (defaults apply783-            // when these are undefined, i.e. legacy bare-seed entries).782+            // when these are undefined, i.e. RFC-default bare-seed entries).784783             const generated = generateTOTP(seed, totpAlgorithm, totpDigits, totpPeriod);785784             totpCode = generated.code;786785             totpRemaining = generated.remaining;

view file

src/background/recovery-words.test.ts+0 −36
@@ -485,41 +485,5 @@485485       expect(after).toEqual(original);486486     });487487 488-    it('pre-v2.12 vault (no recovery escrow) gets recovery words after first unlock', async () => {489-      // Build a slot A that has *no* recovery_* fields and *no* pw_root_*.490-      // The migration path in unlock() should populate both.491-      await setupUnlockedVault();492-      const slot = rawSlotA();493-      const legacy: StoredVault = {494-        version: slot.version,495-        salt: slot.salt,496-        nonce: slot.nonce,497-        ciphertext: slot.ciphertext,498-        tag: slot.tag,499-      };500-      storage.peach_vault_a = legacy;501-      await lockVault();502-503-      const unlocked = await dispatch({504-        type: 'UNLOCK',505-        payload: { password: 'test-pass-12345' },506-      });507-      expect(unlocked.ok).toBe(true);508-509-      const result = await dispatch({ type: 'GET_RECOVERY_WORDS', payload: { password: 'test-pass-12345' } });510-      expect(result.ok).toBe(true);511-      const words = (result as { data: string[] }).data;512-      expect(words).toHaveLength(12);513-514-      // Migration must surface the recovery-pending flag so the popup515-      // re-presents the words for confirmation.516-      expect(storage.peach_recovery_pending).toBe('1');517-518-      // Slot A must now have both wrappings persisted.519-      const slotAfter = storage.peach_vault_a as StoredVault;520-      expect(slotAfter.recovery_salt).toBeTruthy();521-      expect(slotAfter.recovery_wrapped_root_key).toBeTruthy();522-      expect(slotAfter.pw_root_ciphertext).toBeTruthy();523-    });524488   });525489 });

view file

src/background/sync-settings.test.ts+1 −6
@@ -34,14 +34,9 @@3434   };3535 });3636 37-describe("sync settings migration", () => {37+describe("sync settings", () => {3838   it("defaults to public relay with nothing stored", async () => {3939     expect(await getSyncSettings()).toEqual({ mode: "public", relayUrl: DEFAULT_RELAY_URL });40-  });41-42-  it("maps the retired lanOnly flag to off, never to public", async () => {43-    store.peach_sync_lan_only_v1 = true;44-    expect((await getSyncSettings()).mode).toBe("off");4540   });4641 4742   it("maps a configured custom relay with no stored mode to private", async () => {

view file

src/background/sync-settings.ts+4 −12
@@ -26,15 +26,12 @@2626 2727 const RELAY_URL_KEY = "peach_relay_url_v1";2828 const SYNC_MODE_KEY = "peach_sync_mode_v1";29-/** Retired `lanOnly` flag, read once for migration and never written again. */30-const LEGACY_LAN_ONLY_KEY = "peach_sync_lan_only_v1";3129 3230 export async function getSyncSettings(): Promise<SyncSettings> {3331   try {3432     const values = (await chrome.storage.local.get([3533       RELAY_URL_KEY,3634       SYNC_MODE_KEY,37-      LEGACY_LAN_ONLY_KEY,3835     ])) as Record<string, unknown>;3936 4037     const storedUrl =@@ -47,16 +44,11 @@4744       return { mode: stored, relayUrl: relayFor(stored, storedUrl) };4845     }4946 50-    // Migration. Someone who set lanOnly chose "no relay involved", and LAN is51-    // now gone, so the honest landing spot is `off` rather than silently52-    // enrolling them in a relay they opted out of. Everyone else lands on53-    // public/private by whether they had configured a custom URL.47+    // A missing mode is a fresh install or an incomplete local store. Infer the48+    // current mode from the configured relay; retired LAN settings are no49+    // longer interpreted.5450     const mode: SyncMode =55-      values[LEGACY_LAN_ONLY_KEY] === true56-        ? "off"57-        : storedUrl === DEFAULT_RELAY_URL58-          ? "public"59-          : "private";51+      storedUrl === DEFAULT_RELAY_URL ? "public" : "private";6052     return { mode, relayUrl: relayFor(mode, storedUrl) };6153   } catch {6254     return { mode: "public", relayUrl: DEFAULT_RELAY_URL };

view file

src/background/vault.session-escrow.test.ts+1 −1
@@ -72,7 +72,7 @@7272     // — sync identity derives from rootKey, not derivedKey. The previous7373     // restoreFromSession only set rootKey when the slot had recovery_salt +7474     // recovery_wrapped_root_key; for any vault that hadn't been through75-    // migrateToRecovery, rootKey was silently null and the runner returned75+    // the recovery bootstrap, rootKey was silently null and the runner returned7676     // VAULT_LOCKED. The fix is to always derive rootKey from pw_root_* (which7777     // are present on every vault since createVault).7878     const v1 = await import("./vault");

view file

src/background/vault.test.ts+0 −91
@@ -257,30 +257,6 @@257257     expect(await vault.isDuressEnabled()).toBe(false);258258   });259259 260-  it("migrates legacy peach_vault / peach_decoy_vault / peach_duress schema", async () => {261-    // Arrange: set up storage as if from pre-A-2 (v1.4.24 and earlier).262-    const firstVault = await import("./vault");263-    await firstVault.createVault("legacy-password-abcde");264-    const legacySlotA = storage.peach_vault_a as StoredVault;265-    storage.peach_vault = legacySlotA;266-    delete storage.peach_vault_a;267-    delete storage.peach_vault_b;268-    storage.peach_duress = { enabled: true };269-    storage.peach_decoy_vault = legacySlotA; // reuse shape for test270-    vi.resetModules();271-272-    // Act: fresh module load triggers migration on next vault access.273-    const vault = await import("./vault");274-    await vault.hasVault();275-276-    // Assert: legacy keys removed, new keys present.277-    expect(storage.peach_vault).toBeUndefined();278-    expect(storage.peach_decoy_vault).toBeUndefined();279-    expect(storage.peach_duress).toBeUndefined();280-    expect(storage.peach_vault_a).toBeDefined();281-    expect(storage.peach_vault_b).toBeDefined();282-  });283-284260   it("soft deletes entries while keeping deletedAt in encrypted storage", async () => {285261     const vault = await import("./vault");286262 @@ -1094,24 +1070,6 @@10941070     expect(vault.getRootKeyHex()).toBe(rootBefore);10951071   });10961072 1097-  it("unlock_with_key_succeeds_without_escrow (PCH-01-009)", async () => {1098-    const vault = await import("./vault");1099-    await vault.createVault("legacy-pass-12345");1100-    const keyHex = vault.getDerivedKeyHex();1101-    vault.lock();1102-    // Strip the recovery escrow to mimic a pre-v2.12 slot — unlock must still1103-    // succeed (non-fatal), leaving rootKey null.1104-    const slot = storage.peach_vault_a as Record<string, unknown>;1105-    delete slot.recovery_salt;1106-    delete slot.recovery_wrapped_root_key;1107-    delete slot.pw_root_ciphertext;1108-    delete slot.pw_root_nonce;1109-    delete slot.pw_root_tag;1110-    const r = await vault.unlockWithKey(keyHex);1111-    expect(r.ok).toBe(true);1112-    expect(vault.getRootKeyHex()).toBeNull();1113-  });1114-11151073   it("duress keeps both slots equal length at rest, even as the real vault grows (audit MEDIUM)", async () => {11161074     const vault = await import("./vault");11171075     await vault.createVault("duress-real-pass-aaaa");@@ -1346,35 +1304,6 @@13461304       expect(vault.getEntries().some((e) => e.id === "e4")).toBe(false);13471305     });13481306 1349-    it("migrates a pre-v2.11 flat-passkey backup entry into the passkey sub-object", async () => {1350-      const vault = await import("./vault");1351-      await vault.createVault("correct horse battery staple");1352-      await vault.restoreEntries(1353-        [1354-          {1355-            id: "e5",1356-            type: "passkey",1357-            title: "Legacy Passkey",1358-            createdAt: 1,1359-            modifiedAt: 2,1360-            rpId: "example.com",1361-            rpName: "Example",1362-            credentialId: "cred-123",1363-            publicKeyCose: "cose-blob",1364-            userHandle: "uh",1365-            userName: "june",1366-            userDisplayName: "June",1367-            algorithm: -7,1368-          } as unknown as import("../shared/types").VaultEntry,1369-        ],1370-        "replace",1371-      );1372-      const restored = vault.getEntries().find((e) => e.id === "e5")!;1373-      expect(restored).toBeDefined();1374-      expect(restored.passkey?.credentialId).toBe("cred-123");1375-      // Flat legacy fields are not kept as top-level keys.1376-      expect((restored as unknown as Record<string, unknown>).credentialId).toBeUndefined();1377-    });13781307   });13791308 13801309   describe("MV-8 rotateSyncIdentity", () => {@@ -1450,26 +1379,6 @@14501379       expect(await vault.unlock(PW_NFC)).toBe(true);14511380     });14521381 1453-    it("a legacy vault sealed with raw non-NFC bytes still unlocks and is healed to NFC", async () => {1454-      const vault = await import("./vault");1455-      const { deriveKeyRaw, generateSalt } = await import("../crypto/argon2");1456-      await vault.createVault("base-password-12345");1457-1458-      // Simulate a pre-LV-1 vault: re-seal slot A under the RAW (un-normalized)1459-      // NFD bytes, which the NFC chokepoint would never reproduce.1460-      const rawSalt = generateSalt();1461-      const rawKey = await deriveKeyRaw(PW_NFD, rawSalt);1462-      await vault.changePasswordWithNewKey(rawSalt, rawKey);1463-      vault.lock();1464-1465-      // Normal NFC derive can't open the raw seal — the legacy-raw fallback must.1466-      expect(await vault.unlock(PW_NFD)).toBe(true);1467-1468-      // Healed: the slot is now NFC-sealed, so a second unlock takes the1469-      // normal path (and the NFC form opens it too).1470-      vault.lock();1471-      expect(await vault.unlock(PW_NFC)).toBe(true);1472-    });14731382   });14741383 14751384   describe("LV-7 updateSettings proto-pollution filter", () => {

view file

src/background/vault.ts+48 −327
@@ -9,7 +9,7 @@99  * Projects.md §3.2–§3.6.1010  */1111 12-import { deriveKey, deriveKeyRaw, generateSalt } from "../crypto/argon2";12+import { deriveKey, generateSalt } from "../crypto/argon2";1313 import { extractDomain } from "../shared/domain";1414 import { clampSyncTimestamp } from "../shared/sync-limits";1515 import { computeVaultId } from "../shared/sync-identity";@@ -25,7 +25,6 @@2525 import type {2626   AccentColorName,2727   Canary,28-  LegacyVaultEntry,2928   ManualSummonRecord,3029   PasskeyData,3130   SavedIdentity,@@ -50,11 +49,6 @@5049 const SLOT_A = "peach_vault_a";5150 const SLOT_B = "peach_vault_b";5251 53-// Legacy keys, migrated away on first access.54-const LEGACY_MAIN = "peach_vault";55-const LEGACY_DECOY = "peach_decoy_vault";56-const LEGACY_DURESS_FLAG = "peach_duress";57-5852 // Plaintext is padded to the next multiple of this size before encryption.5953 // This coarsens ciphertext-length leakage into 1 KiB buckets.6054 const PAD_BLOCK = 1024;@@ -93,8 +87,6 @@9387 // this stays false because we never see slot A's plaintext.9488 let duressConfigured = false;9589 96-let migrationRan = false;97-9890 // Set to a corruption reason during the last unlock() attempt when both slots9991 // decrypted but failed schema validation. Consumed and cleared by10092 // consumeLastUnlockFailureReason() so messages.ts can surface vault_corrupted@@ -588,7 +580,7 @@588580   // Refresh pw_root_* under the new key so deriveRootKeyFromDerivedKey works589581   // on the next unlock (H2/COR-006). If rootKey is missing — recovery flow590582   // should have populated it — fall back silently rather than blowing up the591-  // recovery path; migrateToRecovery will rehydrate on the next unlock.583+  // recovery path; the escrow is already present on every supported vault.592584   if (rootKey) {593585     const slotAfter = await loadSlot(SLOT_A);594586     if (slotAfter) {@@ -621,10 +613,7 @@621613  * the user re-entering their recovery phrase: the same root key bytes —622614  * and therefore the same 12 words — survive every unlock.623615  *624- * Returns null when the pw_root fields are absent (pre-v2.12 vault that625- * hasn't been migrated yet) or when decryption fails (wrong derived key,626- * tampered ciphertext). On null the caller should fall through to627- * migrateToRecovery() to populate the fields.616+ * Returns null when the escrow is absent or decryption fails.628617  */629618 async function deriveRootKeyFromDerivedKey(630619   derivedKeyBytes: Uint8Array,@@ -661,54 +650,10 @@661650   };662651 }663652 664-/**665- * For vaults created before v2.12 (no recovery_salt/recovery_wrapped_root_key),666- * generate the root key, wrap it under the recovery words, and persist the667- * recovery escrow into slot A. The caller must show the recovery words to the668- * user before calling this — once called, those words are the vault's recovery669- * path. This runs once, on the first unlock of an upgraded vault.670- */671-export async function migrateToRecovery(): Promise<void> {672-  if (!derivedKey || vaultMode !== "real") {673-    throw new Error("vault must be unlocked in real mode");674-  }675-  const slotA = await loadSlot(SLOT_A);676-  if (!slotA) throw new Error("missing slot A");677-  const hasRecoveryEscrow =678-    !!slotA.recovery_salt && !!slotA.recovery_wrapped_root_key;679-  const hasPwRoot = !!slotA.pw_root_ciphertext && !!slotA.pw_root_nonce;680-  if (hasRecoveryEscrow && hasPwRoot) return; // already done681-682-  // If recovery escrow already exists but pw_root doesn't, we cannot recover683-  // the original rootKey without the user's words. The least-surprising path684-  // is to rotate to a fresh root key — the user's old recovery words become685-  // invalid but we surface peach_recovery_pending so the popup re-presents686-  // the words. The unlock-path callers all set that flag.687-  const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =688-    await generateAndWrapRootKey();689-  rootKey = unhex(rawRootKey);690-691-  const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);692-  const nextSlotA: StoredVault = {693-    ...slotA,694-    recovery_salt: recoverySalt,695-    recovery_wrapped_root_key: wrappedRootKey,696-    recovery_nonce: wrappedNonce,697-    recovery_tag: wrappedTag,698-    ...pwRoot,699-  };700-  await writeSlot(SLOT_A, nextSlotA);701-  await stampLocalVaultMutation();702-}703-704-/**705- * Returns the 12 recovery words for the current session's root key.706- * Must be called while the vault is unlocked. Throws if recovery has not707- * been set up (pre-v2.12 vault that hasn't migrated yet).708- */653+/** Returns the 12 recovery words for the current session's root key. */709654 export async function getRecoveryWords(): Promise<string[]> {710655   if (!rootKey) {711-    throw new Error("recovery not set up — vault may need migration");656+    throw new Error("recovery not set up");712657   }713658   return rootKeyToWords(rootKey);714659 }@@ -800,59 +745,6 @@800745   });801746 }802747 803-/**804- * One-shot migration from the pre-A-2 schema (peach_vault + peach_decoy_vault805- * + peach_duress flag) to the slot-neutral schema (peach_vault_a +806- * peach_vault_b). Runs at most once per session; safe to call repeatedly.807- */808-async function ensureMigrated(): Promise<void> {809-  if (migrationRan) return;810-  migrationRan = true;811-812-  const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];813-  const raw = await chrome.storage.local.get(keys);814-  const slotAExists = raw[SLOT_A] !== undefined;815-  const slotBExists = raw[SLOT_B] !== undefined;816-  const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;817-  const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;818-819-  // Fresh install with nothing in storage — nothing to do; createVault() sets up both slots.820-  if (!slotAExists && !legacyMain) return;821-822-  // Already migrated.823-  if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;824-825-  let didMigrate = false;826-  if (legacyMain && !slotAExists) {827-    await writeSlot(SLOT_A, legacyMain);828-    didMigrate = true;829-  }830-831-  // If slot B isn't set, populate it: use legacy decoy when present, else noise.832-  if (!slotBExists) {833-    const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;834-    if (legacyDecoy) {835-      await writeSlot(SLOT_B, legacyDecoy);836-      didMigrate = true;837-    } else if (target) {838-      await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));839-      didMigrate = true;840-    }841-  }842-843-  const toRemove: string[] = [];844-  if (legacyMain) toRemove.push(LEGACY_MAIN);845-  if (legacyDecoy) toRemove.push(LEGACY_DECOY);846-  if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);847-  if (toRemove.length > 0) {848-    await chrome.storage.local.remove(toRemove);849-    didMigrate = true;850-  }851-  if (didMigrate) {852-    await stampLocalVaultMutation();853-  }854-}855-856748 // ---------------------------------------------------------------------------857749 // Padding + noise helpers858750 // ---------------------------------------------------------------------------@@ -922,12 +814,6 @@922814   entries: VaultEntry[];923815   tombstones: Map<string, number>;924816   duressConfigured: boolean;925-  /**926-   * True when the on-disk payload needs a one-time rewrite: either an entry927-   * had a legacy shape or the payload predates the tombstone ledger. The928-   * unlock path persists the normalized shape immediately.929-   */930-  migratedFromLegacy: boolean;931817 };932818 933819 function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {@@ -975,55 +861,23 @@975861   }976862 }977863 978-function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {979-  if ("created" in raw || "updated" in raw) return true;980-  const flat = raw as Partial<VaultEntry>;981-  if (flat.type === "passkey") return true;982-  // Flat rpId without a passkey sub-object means pre-v2.11.0 schema.983-  if (flat.rpId !== undefined && flat.passkey === undefined) return true;984-  return false;985-}986-987864 function decodePayload(plain: string): DecryptedPayload {988-  const parsed = JSON.parse(plain) as989-    | Array<VaultEntry | LegacyVaultEntry>990-    | VaultPayload;991-992-  const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];993-  const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);994-995-  const normalized = rawEntries.map(normalizeEntry);996-  const { merged } = mergePasskeyOnlyIntoLogins(normalized);997-  const decodedTombstones = decodeTombstones(998-    Array.isArray(parsed) ? undefined : parsed.tombstones,999-    normalized,1000-  );1001-1002-  if (Array.isArray(parsed)) {1003-    return {1004-      entries: normalized,1005-      tombstones: decodedTombstones,1006-      duressConfigured: false,1007-      migratedFromLegacy: true, // pre-A-2 array shape always needs save1008-    };865+  const parsed = JSON.parse(plain) as VaultPayload;866+  if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.entries)) {867+    throw new Error("invalid vault payload");1009868   }1010-869+  const normalized = parsed.entries.map(normalizeEntry);1011870   return {1012871     entries: normalized,1013-    tombstones: decodedTombstones,872+    tombstones: decodeTombstones(parsed.tombstones, normalized),1014873     duressConfigured: parsed.duressConfigured === true,1015-    migratedFromLegacy:1016-      flatMigrationNeeded || merged || !Object.hasOwn(parsed, "tombstones"),1017874   };1018875 }1019876 1020877 /**1021- * One-time consolidation pass run alongside the legacy passkey migration.878+ * Consolidate duplicate passkey records after registration.1022879  *1023- * Pre-v2.11.0 the Chrome passkey path always created a fresh `type:"passkey"`1024- * entry per registration — so users who tested registration multiple times1025- * accumulated several passkey-bearing entries for the same RP, and the1026- * migration to the unified shape carried that duplication forward. This880+ * Multiple registrations for one RP can leave duplicate passkey records. This1027881  * pass collapses the duplicates:1028882  *1029883  *   1. Group passkey-bearing (and not deleted) entries by `passkey.rpId`.@@ -1208,8 +1062,6 @@12081062 }12091063 12101064 export async function createVault(password: string): Promise<void> {1211-  await ensureMigrated();1212-12131065   const salt = generateSalt();12141066   derivedKey = await deriveKey(password, salt);12151067   entries = [];@@ -1277,7 +1129,6 @@12771129   let nextDerivedKey: Uint8Array | null = null;12781130   let sessionRootKey: Uint8Array | null = null;12791131   try {1280-    await ensureMigrated();12811132     if ((await loadSlot(SLOT_A)) !== null) {12821133       throw new Error("ADOPT_VAULT_ALREADY_EXISTS");12831134     }@@ -1332,7 +1183,6 @@13321183 }13331184 13341185 export async function hasVault(): Promise<boolean> {1335-  await ensureMigrated();13361186   return (await loadSlot(SLOT_A)) !== null;13371187 }13381188 @@ -1349,8 +1199,6 @@13491199   if (!derivedKey || vaultMode !== "real") {13501200     throw new Error("vault must be unlocked in real mode");13511201   }1352-  await ensureMigrated();1353-13541202   // Encrypt the decoy payload (empty entries) under the duress key. Pad to13551203   // at least the current slot A ciphertext size so the two slots look the13561204   // same at rest at setup time.@@ -1387,8 +1235,6 @@13871235   if (!derivedKey || vaultMode !== "real") {13881236     throw new Error("vault must be unlocked in real mode");13891237   }1390-  await ensureMigrated();1391-13921238   // Overwrite slot B with fresh noise that matches slot A's size. The slot13931239   // remains occupied so its mere existence does not signal that duress was13941240   // ever configured.@@ -1408,7 +1254,6 @@14081254 // password by guessing it, and the asymmetry in error timing is acceptable14091255 // because duress mode is opt-in and the user knows their own decoy key.14101256 export async function verifyMainPassword(password: string): Promise<boolean> {1411-  await ensureMigrated();14121257   const slotA = await loadSlot(SLOT_A);14131258   if (!slotA) return false;14141259   const key = await deriveKey(password, unhex(slotA.salt));@@ -1420,39 +1265,15 @@14201265 }14211266 14221267 export async function unlock(password: string): Promise<boolean> {1423-  // LV-1: the normal path NFC-normalizes inside deriveKey. If that fails and1424-  // the supplied password is not already in NFC form, the vault may have been1425-  // sealed (pre-LV-1) with the raw, un-normalized bytes — retry with those.1426-  if (await attemptUnlockWithDerive(password, deriveKey)) return true;1427-1428-  const nfc = password.normalize("NFC");1429-  if (password === nfc) return false; // ASCII/NFC password — no legacy form to try1430-1431-  if (await attemptUnlockWithDerive(password, deriveKeyRaw)) {1432-    // Heal: re-seal the real slot under the NFC-normalized key so every1433-    // future unlock takes the normal path. Only the real vault is re-sealed;1434-    // a non-NFC duress password keeps working through this fallback.1435-    if (vaultMode === "real") {1436-      try {1437-        const newSalt = generateSalt();1438-        const newKey = await deriveKey(password, newSalt);1439-        await changePasswordWithNewKey(newSalt, newKey);1440-      } catch (healErr) {1441-        console.warn("[Peach] LV-1 NFC heal failed:", healErr);1442-      }1443-    }1444-    return true;1445-  }1446-  return false;1268+  return attemptUnlockWithDerive(password, deriveKey);14471269 }14481270 14491271 async function attemptUnlockWithDerive(14501272   password: string,14511273   derive: typeof deriveKey,14521274 ): Promise<boolean> {1453-  await ensureMigrated();1454-1455-  // Load both slots in parallel. Both always exist post-migration; the1275+  // Load both current-format slots in parallel. Both exist on a supported1276+  // install; the14561277   // parallel load and the parallel derivation below keep wall-clock time14571278   // constant regardless of which slot's passphrase (real or decoy) the14581279   // caller supplied, so a timing side channel cannot distinguish the@@ -1494,38 +1315,14 @@14941315     touchAutoLock();14951316     if (keyB) zeroKey(keyB);14961317 1497-    // slotA is non-null when payloadA is truthy (keyA was derived from slotA).1498-    if (slotA) {1499-      const needsRecoverySetup =1500-        !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;1501-      if (!needsRecoverySetup) {1502-        rootKey = await deriveRootKeyFromDerivedKey(keyA, slotA);1503-      }1504-      // Pre-v2.12 vault, or a vault whose pw_root_* fields haven't been written1505-      // yet: generate the recovery escrow and seed pw_root_* so subsequent1506-      // unlocks can rehydrate rootKey without the user's recovery words.1507-      // Best-effort — a failure here must not block unlock.1508-      if (needsRecoverySetup || !rootKey) {1509-        try {1510-          await migrateToRecovery();1511-          if (needsRecoverySetup) {1512-            // String "1" matches the popup's existing peach_recovery_pending1513-            // check; surfaced so the user re-confirms recovery words after a1514-            // legacy-vault unlock (H2/COR-006).1515-            await chrome.storage.local.set({ peach_recovery_pending: '1' });1516-          }1517-        } catch (migrationErr) {1518-          console.warn('[Peach] recovery migration failed:', migrationErr);1519-        }1520-      }1318+    // Current vaults always carry a password-wrapped root key. Older vaults1319+    // without this escrow are outside the supported storage format.1320+    rootKey = slotA ? await deriveRootKeyFromDerivedKey(keyA, slotA) : null;1321+    if (!rootKey) {1322+      lock();1323+      return false;15211324     }15221325 1523-    // If the payload had a pre-v2.11.0 shape (legacy passkey type, flat1524-    // passkey fields, or pre-A-2 created/updated keys), rewrite it now so1525-    // the next save uses the new format and migration doesn't re-run forever.1526-    if (payloadA.migratedFromLegacy) {1527-      await persistSlotA();1528-    }15291326     await lockedSyncUnlockHook?.().catch((error) => {15301327       console.warn("[Peach sync] locked inbox drain failed:", error);15311328     });@@ -1608,20 +1405,13 @@16081405   await loadSettings();16091406   touchAutoLock();16101407 1611-  // The rootKey is what identity/sync derivations use. deriveRootKeyFromDerivedKey1612-  // pulls it from the pw_root_* fields on the slot — set on every createVault +1613-  // re-set on every password change (H2/COR-006). The previous gate on1614-  // recovery_salt + recovery_wrapped_root_key silently left rootKey unset for1615-  // any vault that hadn't been through migrateToRecovery, which broke background1616-  // sync: the SW re-woke from the alarm, isUnlocked() was true (the session key1617-  // restored derivedKey + entries), but getRootKeyHex() returned null and the1618-  // runner returned VAULT_LOCKED. The fix is to always try the pw_root path1619-  // when the slot has those fields — the recovery fields are independent.1408+  // Rehydrate the root key used by sync identity derivation from the password1409+  // escrow stored on the current vault format.16201410   if (entry.mode === "real") {1621-    try {1622-      rootKey = await deriveRootKeyFromDerivedKey(key, slot);1623-    } catch {1624-      /* non-fatal — sync identity will still skip on the runner's null-rootKey check */1411+    rootKey = await deriveRootKeyFromDerivedKey(key, slot);1412+    if (!rootKey) {1413+      lock();1414+      return false;16251415     }16261416   }16271417 @@ -1686,15 +1476,10 @@16861476     await loadSettings();16871477     touchAutoLock();16881478 1689-    // PCH-01-009: the biometric path skips Argon2id but must still rehydrate the1690-    // root key, exactly like attemptUnlockWithDerive and restoreFromSession.1691-    // Without it, rootKey stays null after a biometric unlock and sync +1692-    // GET_RECOVERY_WORDS report the open vault as locked. Guarded and non-fatal:1693-    // a missing/legacy escrow must not fail the unlock itself.1694-    if (stored.recovery_salt && stored.recovery_wrapped_root_key) {1695-      try {1696-        rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1697-      } catch { /* non-fatal — unlock still succeeds without rootKey */ }1479+    rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1480+    if (!rootKey) {1481+      lock();1482+      return { ok: false, error: "invalid_key" };16981483     }16991484     return { ok: true, entries };17001485   } catch {@@ -1711,7 +1496,6 @@17111496  */17121497 export async function exportDuressSlotForAdoption(): Promise<string | null> {17131498   if (!(await isDuressEnabled())) return null;1714-  await ensureMigrated();17151499   const slotB = await loadSlot(SLOT_B);17161500   if (!slotB) return null;17171501   return JSON.stringify(slotB);@@ -1850,8 +1634,8 @@18501634  * other's entries after either side rotates its master password (SEV-2-B).18511635  *18521636  * Returns null rather than throwing so sync callers degrade to VAULT_LOCKED1853- * instead of crashing; rootKey is populated on every real-mode unlock via1854- * deriveRootKeyFromDerivedKey / migrateToRecovery.1637+ * instead of crashing; rootKey is populated on every supported real-mode1638+ * unlock via deriveRootKeyFromDerivedKey.18551639  */18561640 export function getRootKeyHex(): string | null {18571641   return rootKey ? hex(rootKey) : null;@@ -2021,10 +1805,8 @@20211805   const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;20221806   const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;20231807 2024-  // normalizeEntry whitelists every known VaultEntry field and runs the2025-  // same legacy-passkey migration as the on-disk read path — pre-v2.11.02026-  // backups with flat passkey fields still import correctly because the2027-  // legacy flat fields are themselves whitelisted VaultEntry members.1808+  // normalizeEntry whitelists every known VaultEntry field before the entry1809+  // reaches the live vault.20281810   const normalized = normalizeEntry({20291811     ...entry,20301812     id,@@ -2631,8 +2413,8 @@26312413 ): Promise<void> {26322414   if (!derivedKey) throw new Error("vault_locked");26332415 2634-  // Reconcile direct soft-delete callers (clear/deduplicate and legacy2635-  // migration) while restoring the previous map if storage rejects the save.2416+  // Reconcile direct soft-delete callers while restoring the previous map if2417+  // storage rejects the save.26362418   const previousTombstones = tombstones;26372419   tombstones = new Map(tombstones);26382420   reconcileTombstones();@@ -2667,11 +2449,7 @@26672449 26682450 function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {26692451   const title = entry.title.trim();2670-  // Accept legacy `type:"passkey"` drafts but rewrite to "login" — the new2671-  // model is "any entry, optionally with a passkey sub-object." Imports and2672-  // any in-flight callers from before v2.11.0 keep working without throwing.2673-  let type: VaultEntry["type"] = entry.type ?? "login";2674-  if (type === "passkey") type = "login";2452+  const type: VaultEntry["type"] = entry.type ?? "login";26752453 26762454   if (!title) throw new Error("title is required");26772455 @@ -2791,8 +2569,7 @@27912569   }27922570 27932571   if ("passkey" in patch) {2794-    // Passkey sub-object replaces all the legacy flat passkey fields. A2795-    // patch with `passkey: undefined` removes the credential from the entry2572+    // A patch with `passkey: undefined` removes the credential from the entry27962573     // (used by "remove passkey" without deleting the whole entry).27972574     if (patch.passkey === undefined || patch.passkey === null) {27982575       nextPatch.passkey = undefined;@@ -2837,17 +2614,13 @@28372614   return nextPatch;28382615 }28392616 2840-function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {2841-  const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;2842-  const modifiedAt =2843-    "modifiedAt" in entry ? entry.modifiedAt : entry.updated;2844-2617+function normalizeEntry(entry: VaultEntry): VaultEntry {28452618   const base: VaultEntry = {28462619     id: entry.id,2847-    type: "type" in entry ? entry.type : "login",2620+    type: entry.type,28482621     title: entry.title,28492622     username: entry.username,2850-    email: "email" in entry ? entry.email : undefined,2623+    email: entry.email,28512624     password: entry.password,28522625     url: entry.url,28532626     additionalUrls:@@ -2873,12 +2646,12 @@28732646       "passwordHistory" in entry && Array.isArray(entry.passwordHistory)28742647         ? entry.passwordHistory28752648         : undefined,2876-    canary: "canary" in entry ? entry.canary : undefined,2877-    passkey: "passkey" in entry ? entry.passkey : undefined,2878-    createdAt,2879-    modifiedAt,2649+    canary: entry.canary,2650+    passkey: entry.passkey,2651+    createdAt: entry.createdAt,2652+    modifiedAt: entry.modifiedAt,28802653     deletedAt: entry.deletedAt,2881-    tags: "tags" in entry ? entry.tags : undefined,2654+    tags: entry.tags,28822655     cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,28832656     cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,28842657     expMonth: "expMonth" in entry ? entry.expMonth : undefined,@@ -2889,58 +2662,6 @@28892662     last4: "last4" in entry ? entry.last4 : undefined,28902663   };28912664 2892-  return migrateLegacyPasskey(base, entry);2893-}2894-2895-/**2896- * v2.11.0 read migration. Pre-v2.11.0 passkey entries used `type:"passkey"`2897- * with flat top-level fields (rpId, credentialId, etc.). This converts them2898- * to the new shape — `type:"login"` with a `passkey` sub-object — so all2899- * downstream consumers can rely on the new model. The result is rewritten2900- * to disk on next vault save (hasLegacyShape detects the un-migrated case).2901- */2902-function migrateLegacyPasskey(2903-  base: VaultEntry,2904-  raw: VaultEntry | LegacyVaultEntry2905-): VaultEntry {2906-  if (base.passkey) return base;2907-  const flat = raw as Partial<VaultEntry>;2908-  const isPasskeyType = base.type === "passkey";2909-  const hasFlatPasskeyFields =2910-    typeof flat.rpId === "string" &&2911-    typeof flat.credentialId === "string" &&2912-    typeof flat.publicKeyCose === "string" &&2913-    typeof flat.userHandle === "string" &&2914-    typeof flat.userName === "string";2915-  if (!isPasskeyType && !hasFlatPasskeyFields) return base;2916-  if (!hasFlatPasskeyFields) return base;2917-2918-  const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;2919-  base.passkey = {2920-    rpId: flat.rpId!,2921-    rpName: flat.rpName ?? flat.rpId!,2922-    credentialId: flat.credentialId!,2923-    publicKeyCose: flat.publicKeyCose!,2924-    privateKeyJwk: flat.privateKeyJwk,2925-    userHandle: flat.userHandle!,2926-    userName: flat.userName!,2927-    userDisplayName: flat.userDisplayName ?? flat.userName!,2928-    algorithm,2929-    signCount: flat.signCount ?? 0,2930-    aaguid: flat.aaguid ?? "",2931-    backupEligible: flat.backupEligible === true,2932-    backupState: flat.backupState === true,2933-    lastUsedAt: flat.lastUsedAt,2934-    browser: flat.browser,2935-    transports: flat.transports,2936-    createdAt: base.createdAt,2937-  };2938-  // Promote to "login" so the unified Entry view renders. Username gets2939-  // populated from the passkey's userName so the entry is searchable in the2940-  // existing autofill flow without a separate code path.2941-  if (isPasskeyType) base.type = "login";2942-  if (!base.username) base.username = flat.userName;2943-  if (!base.url) base.url = `https://${flat.rpId}`;29442665   return base;29452666 }29462667 @@ -3192,7 +2913,7 @@31922913  * CONSEQUENCE THE UI MUST SURFACE: the 12 recovery words also derive from31932914  * the root key, so rotation INVALIDATES the old recovery phrase. The flow31942915  * sets peach_recovery_pending so the popup re-presents the new words for3195- * confirmation, same as the migrateToRecovery rotate path.2916+ * confirmation, same as the initial recovery-word confirmation path.31962917  */31972918 export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {31982919   if (!derivedKey || vaultMode !== "real") {

view file

src/crypto/argon2.test.ts+1 −15
@@ -1,6 +1,6 @@11 import { describe, expect, it } from "vitest";22 3-import { deriveKey, deriveKeyRaw, generateSalt } from "./argon2";3+import { deriveKey, generateSalt } from "./argon2";44 55 // Same glyph "café" decomposed (e + U+0301) and composed (U+00E9). Both are66 // derived from one base literal via normalize(), so the stored byte form of the@@ -39,20 +39,6 @@3939     const salt = generateSalt();4040     expect(CAFE_NFC).not.toBe(CAFE_NFD);4141     expect(await deriveKey(CAFE_NFC, salt)).toEqual(await deriveKey(CAFE_NFD, salt));42-  });43-44-  it("LV-1: deriveKeyRaw does NOT normalize (composed vs decomposed differ)", async () => {45-    const salt = generateSalt();46-    expect(await deriveKeyRaw(CAFE_NFC, salt)).not.toEqual(47-      await deriveKeyRaw(CAFE_NFD, salt),48-    );49-  });50-51-  it("LV-1: ASCII passwords are unaffected — deriveKey equals deriveKeyRaw", async () => {52-    const salt = generateSalt();53-    expect(await deriveKey("correct-pass-12345", salt)).toEqual(54-      await deriveKeyRaw("correct-pass-12345", salt),55-    );5642   });5743 5844   it("generateSalt returns 16 bytes", () => {

view file

src/crypto/argon2.ts+0 −12
@@ -32,18 +32,6 @@3232   return argon2idDerive(password.normalize("NFC"), salt);3333 }3434 35-/**36- * LV-1 legacy fallback ONLY: derive WITHOUT normalization, for unlocking a37- * vault sealed before NFC normalization existed with a non-NFC password.38- * Every normal path uses {@link deriveKey}. Do not use this elsewhere.39- */40-export async function deriveKeyRaw(41-  password: string,42-  salt: Uint8Array43-): Promise<Uint8Array> {44-  return argon2idDerive(password, salt);45-}46-4735 // Per-vault salt: 16 bytes from the OS CSPRNG. A fresh salt is generated at4836 // vault creation and on every changePassword. Prevents cross-user4937 // precomputation attacks against the same master passphrase.

view file

src/crypto/argon2id.kat.test.ts+1 −16
@@ -36,7 +36,7 @@3636 //     console.log(Buffer.from(h).toString("hex"));3737 3838 import { describe, expect, it } from "vitest";39-import { deriveKey, deriveKeyRaw } from "./argon2";39+import { deriveKey } from "./argon2";4040 4141 // ---------------------------------------------------------------------------4242 // KAT vector@@ -120,19 +120,4 @@120120     expect(keyComposed).toEqual(keyDecomposed);121121   });122122 123-  // T-019f: deriveKeyRaw (legacy LV-1 fallback) does NOT apply NFC normalization.124-  // The raw path is used only to unlock pre-LV-1 vaults. It must produce a125-  // different key from deriveKey for NFD inputs.126-  it("T-019f: deriveKeyRaw and deriveKey differ for NFD passwords (raw skips NFC)", async () => {127-    const salt = new Uint8Array(16).fill(0x66);128-    const nfdPassword = "café"; // NFD form — NFC would normalize to U+00E9129-    if (nfdPassword === nfdPassword.normalize("NFC")) {130-      // If the runtime auto-normalizes string literals, this test can't131-      // distinguish the two paths. Skip rather than silently pass.132-      return;133-    }134-    const keyNormalized = await deriveKey(nfdPassword, salt);135-    const keyRaw = await deriveKeyRaw(nfdPassword, salt);136-    expect(keyNormalized).not.toEqual(keyRaw);137-  });138123 });

view file

src/crypto/peachPayload.test.ts+25 −36
@@ -60,19 +60,22 @@6060     id: crypto.randomUUID(),6161     type: "passkey",6262     title: "github passkey",63-    rpId: "github.com",64-    rpName: "GitHub",65-    credentialId: "abc123",66-    publicKeyCose: "a401020326...",67-    privateKeyJwk: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" },68-    userHandle: "user-handle",69-    userName: "june",70-    userDisplayName: "June",71-    algorithm: -7,72-    signCount: 5,73-    aaguid: "AAAAAAAAAAAAAAAAAAAAAA",74-    backupEligible: true,75-    backupState: false,63+    passkey: {64+      rpId: "github.com",65+      rpName: "GitHub",66+      credentialId: "abc123",67+      publicKeyCose: "a401020326...",68+      privateKeyJwk: { kty: "EC", crv: "P-256", x: "x", y: "y", d: "d" },69+      userHandle: "user-handle",70+      userName: "june",71+      userDisplayName: "June",72+      algorithm: -7,73+      signCount: 5,74+      aaguid: "AAAAAAAAAAAAAAAAAAAAAA",75+      backupEligible: true,76+      backupState: false,77+      createdAt: 1700000000000,78+    },7679     createdAt: 1700000000000,7780     modifiedAt: 1700000000000,7881     ...over,@@ -152,12 +155,12 @@152155 153156     const gotPk = out.entries.find((e) => e.id === passkey.id)!;154157     expect(gotPk.type).toBe("passkey");155-    expect(gotPk.rpId).toBe("github.com");156-    expect(gotPk.privateKeyJwk).toEqual(passkey.privateKeyJwk);157-    expect(gotPk.algorithm).toBe(-7);158-    expect(gotPk.signCount).toBe(5);159-    expect(gotPk.backupEligible).toBe(true);160-    expect(gotPk.backupState).toBe(false);158+    expect(gotPk.passkey?.rpId).toBe("github.com");159+    expect(gotPk.passkey?.privateKeyJwk).toEqual(passkey.passkey?.privateKeyJwk);160+    expect(gotPk.passkey?.algorithm).toBe(-7);161+    expect(gotPk.passkey?.signCount).toBe(5);162+    expect(gotPk.passkey?.backupEligible).toBe(true);163+    expect(gotPk.passkey?.backupState).toBe(false);161164   });162165 163166   test("passkey-bearing login entry round-trips ALL login fields (SEV-1-B)", async () => {@@ -211,11 +214,9 @@211214     expect(got.passwordChangedAt).toBe(entry.passwordChangedAt);212215     expect(got.breach).toBe(2);213216     expect(got.password).toBe(entry.password);214-    // The passkey credential still round-trips (decoded as the flat legacy215-    // shape; vault import re-nests it into the passkey sub-object).216-    expect(got.credentialId).toBe("dGVzdC1pZA");217-    expect(got.signCount).toBe(42);218-    expect(got.privateKeyJwk).toEqual(entry.passkey!.privateKeyJwk);217+    expect(got.passkey?.credentialId).toBe("dGVzdC1pZA");218+    expect(got.passkey?.signCount).toBe(42);219+    expect(got.passkey?.privateKeyJwk).toEqual(entry.passkey!.privateKeyJwk);219220   });220221 221222   test("string table deduplicates shared domains", async () => {@@ -421,18 +422,6 @@421422       settings: {},422423       passphrase: NFC_PASS,423424     });424-    const out = await parsePayload(env, NFD_PASS);425-    expect(out.entries[0].title).toBe("github");426-  });427-428-  test("legacy_raw_fallback_opens_old_backup", async () => {429-    // Mint a pre-fix export sealed under the RAW (un-normalized) NFD bytes via430-    // the test seam, then confirm post-fix parsePayload opens it through the431-    // raw fallback (normalized key fails, raw key succeeds).432-    const env = await buildPayload(433-      { entries: [loginEntry()], settings: {}, passphrase: NFD_PASS },434-      { raw: true },435-    );436425     const out = await parsePayload(env, NFD_PASS);437426     expect(out.entries[0].title).toBe("github");438427   });

view file

src/crypto/peachPayload.ts+17 −80
@@ -78,12 +78,8 @@7878   return padded;7979 }8080 81-// v2 format byte (prepended inside the plaintext so old clients see BAD_MAGIC82-// and refuse gracefully; new clients detect v2 by this leading byte).83-const FORMAT_V2 = 0x02;84-// v3 format byte. Same envelope as v2 (length-prefixed inner + bucket85-// padding) but login entries carry an extra `flags2` byte and an optional86-// email field. Decoders fall back to v2 behavior for older payloads.81+// Current format byte. Login entries carry an extra `flags2` byte and an82+// optional email field.8783 const FORMAT_V3 = 0x03;8884 8985 const ARGON2_MEMORY_KIB = 65536; // 64 MiB@@ -155,10 +151,6 @@155151  */156152 export async function buildPayload(157153   input: BuildPayloadInput,158-  // PCH-01-001 test seam only: `raw:true` seals under the un-normalized159-  // passphrase bytes to mint a pre-fix (legacy) envelope. Never used by160-  // production callers — the build path always normalizes.161-  opts: { raw?: boolean } = {},162154 ): Promise<Uint8Array> {163155   const sections: Uint8Array[] = [];164156 @@ -204,7 +196,7 @@204196 205197   const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));206198   const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN));207-  const key = await deriveKey(input.passphrase, salt, opts);199+  const key = await deriveKey(input.passphrase, salt);208200   try {209201     const { ciphertext, tag } = await aesGcmEncrypt(key, nonce, wrapped);210202     return concat([salt, nonce, ciphertext, tag]);@@ -237,40 +229,18 @@237229   try {238230     decrypted = await aesGcmDecrypt(key, nonce, ciphertext, tag);239231   } catch {240-    // PCH-01-001: an export sealed before this fix under a non-NFC passphrase241-    // derives a different key. Retry once with the raw bytes — mirrors242-    // backup.ts. Only attempt the fallback when normalization actually changed243-    // the string (an all-ASCII passphrase has nothing to fall back to).244-    if (passphrase === passphrase.normalize("NFC")) {245-      throw new Error("INVALID_PASSPHRASE");246-    }247-    const rawKey = await deriveKey(passphrase, salt, { raw: true });248-    try {249-      decrypted = await aesGcmDecrypt(rawKey, nonce, ciphertext, tag);250-    } catch {251-      throw new Error("INVALID_PASSPHRASE");252-    } finally {253-      rawKey.fill(0);254-    }232+    throw new Error("INVALID_PASSPHRASE");255233   } finally {256234     key.fill(0);257235   }258236 259-  // Detect padded format: leading byte FORMAT_V2 or FORMAT_V3 followed by260-  // 4-byte inner length. v1 (no leading byte) is decrypted plaintext.261-  let inner: Uint8Array;262-  let formatVersion: number = 1;263-  if (264-    decrypted.length >= 5 &&265-    (decrypted[0] === FORMAT_V2 || decrypted[0] === FORMAT_V3)266-  ) {267-    formatVersion = decrypted[0];268-    const innerLen = new DataView(decrypted.buffer, decrypted.byteOffset, decrypted.byteLength).getUint32(1, true);269-    if (5 + innerLen > decrypted.length) throw new Error("PAYLOAD_TOO_SHORT");270-    inner = decrypted.subarray(5, 5 + innerLen);271-  } else {272-    inner = decrypted; // v1 format — no length prefix, no padding237+  // Current envelopes always carry the version byte and length prefix.238+  if (decrypted.length < 5 || decrypted[0] !== FORMAT_V3) {239+    throw new Error("UNSUPPORTED_VERSION");273240   }241+  const innerLen = new DataView(decrypted.buffer, decrypted.byteOffset, decrypted.byteLength).getUint32(1, true);242+  if (5 + innerLen > decrypted.length) throw new Error("PAYLOAD_TOO_SHORT");243+  const inner = decrypted.subarray(5, 5 + innerLen);274244 275245   if (inner.length < HEADER_LEN + CHECKSUM_LEN) throw new Error("PAYLOAD_TOO_SHORT");276246 @@ -318,7 +288,7 @@318288 319289     switch (type) {320290       case SECTION_ENTRIES:321-        out.entries = decodeEntriesSection(data, formatVersion);291+        out.entries = decodeEntriesSection(data, FORMAT_V3);322292         break;323293       case SECTION_SETTINGS:324294         out.settings = decodeJsonSection<VaultSettings>(data);@@ -586,33 +556,12 @@586556     }587557   } else if (type === ENTRY_TYPE_PASSKEY) {588558     // JWK + COSE + opaque IDs do not pack cleanly. Inline a JSON blob.589-    // v2.11+ stores the passkey in a nested sub-object; pre-v2.11 used flat fields.590559     const pk = entry.passkey;591-    const flat = entry as unknown as Record<string, unknown>;560+    if (!pk) throw new Error("INVALID_PASSKEY_ENTRY");592561     const blob = encodeJsonSection({593-      rpId:             pk ? pk.rpId             : flat["rpId"],594-      rpName:           pk ? pk.rpName           : flat["rpName"],595-      credentialId:     pk ? pk.credentialId     : flat["credentialId"],596-      publicKeyCose:    pk ? pk.publicKeyCose    : flat["publicKeyCose"],597-      privateKeyJwk:    pk ? pk.privateKeyJwk    : flat["privateKeyJwk"],598-      userHandle:       pk ? pk.userHandle       : flat["userHandle"],599-      userName:         pk ? pk.userName         : flat["userName"],600-      userDisplayName:  pk ? pk.userDisplayName  : flat["userDisplayName"],601-      algorithm:        pk ? pk.algorithm        : flat["algorithm"],602-      signCount:        pk ? pk.signCount        : flat["signCount"],603-      aaguid:           pk ? pk.aaguid           : flat["aaguid"],604-      backupEligible:   pk ? pk.backupEligible   : flat["backupEligible"],605-      backupState:      pk ? pk.backupState      : flat["backupState"],606-      lastUsedAt:       pk ? pk.lastUsedAt       : flat["lastUsedAt"],607-      // Preserve the FULL login field set for passkey-bearing login entries.608-      // A v2.11+ passkey hangs off a normal login (type:"login" + passkey609-      // sub-object), so it can carry a password, TOTP seed, notes, tags, email,610-      // canary, additional URLs, and the password-age stamp — all of which were611-      // silently dropped on export before this (SEV-1-B). The blob is JSON, so612-      // adding keys is backward-compatible: old decoders ignore unknown keys613-      // and old exports simply lack them. Only emit these when `pk` is set;614-      // pre-v2.11 flat passkey entries had no login fields to preserve.615-      ...(pk ? {562+      passkey: pk,563+      // Preserve the full login field set for passkey-bearing login entries.564+      ...({616565         ...(entry.password          !== undefined ? { password:          entry.password } : {}),617566         ...(entry.url               !== undefined ? { url:               entry.url } : {}),618567         ...(entry.username          !== undefined ? { username:          entry.username } : {}),@@ -624,7 +573,7 @@624573         ...(entry.passwordChangedAt !== undefined ? { passwordChangedAt: entry.passwordChangedAt } : {}),625574         ...(entry.breach            !== undefined ? { breach:            entry.breach } : {}),626575         ...(entry.additionalUrls    !== undefined ? { additionalUrls:    entry.additionalUrls } : {}),627-      } : {}),576+      }),628577     });629578     writer.u32(blob.length);630579     writer.bytes(blob);@@ -789,12 +738,6 @@789738       "passkey", "username", "email", "password", "url", "additionalUrls",790739       "totp", "notes", "tags", "passwordChangedAt", "passwordHistory",791740       "canary", "deletedAt", "breach",792-      // legacy flat passkey fields (pre-v2.11.0) — consumed by793-      // migrateLegacyPasskey, then dropped on next save794-      "rpId", "rpName", "credentialId", "publicKeyCose", "privateKeyJwk",795-      "userHandle", "userName", "userDisplayName", "algorithm", "signCount",796-      "aaguid", "backupEligible", "backupState", "lastUsedAt", "browser",797-      "transports",798741     ] as const;799742     for (const k of allow) {800743       if (k in obj) (picked as Record<string, unknown>)[k] = obj[k];@@ -986,14 +929,8 @@986929 async function deriveKey(987930   passphrase: string,988931   salt: Uint8Array,989-  opts: { raw?: boolean } = {},990932 ): Promise<Uint8Array> {991-  // PCH-01-001 / LV-1 parity: NFC-normalize the export passphrase to match the992-  // master-password KDF chokepoint (argon2.ts). A composed-vs-decomposed mismatch993-  // (macOS NFD dead-keys vs. Android NFC) otherwise derives a different key and994-  // makes a valid backup unrestorable. `raw:true` is used ONLY by the parse995-  // fallback to open pre-fix exports sealed under un-normalized bytes.996-  const pw = opts.raw ? passphrase : passphrase.normalize("NFC");933+  const pw = passphrase.normalize("NFC");997934   return argon2id({998935     password: pw,999936     salt,

view file

src/popup/codex/codex.adversarial.test.ts+8 −22
@@ -63,7 +63,7 @@6363   const vaultFingerprint = (6464     await sha256Hex(new TextEncoder().encode(vaultBlob))6565   )66-    .slice(0, 16)66+    .slice(0, 32)6767     .toUpperCase();6868   return {6969     version: 2,@@ -136,12 +136,12 @@136136   }137137 138138   const missingFieldInputs = [139-    JSON.stringify({ v: 2 }),140-    JSON.stringify({ v: 2, c: VERIFY_CODE }),141-    JSON.stringify({ v: 2, c: VERIFY_CODE, i: 0 }),142-    JSON.stringify({ v: 2, c: VERIFY_CODE, i: 0, t: 5 }),143-    JSON.stringify({ v: 2, c: VERIFY_CODE, i: 0, t: 5, p: false }),144-    JSON.stringify({ v: 2, c: VERIFY_CODE, i: 0, t: 5, p: false, d: "x" }),139+    JSON.stringify({ v: 3 }),140+    JSON.stringify({ v: 3, c: VERIFY_CODE }),141+    JSON.stringify({ v: 3, c: VERIFY_CODE, i: 0 }),142+    JSON.stringify({ v: 3, c: VERIFY_CODE, i: 0, t: 5 }),143+    JSON.stringify({ v: 3, c: VERIFY_CODE, i: 0, t: 5, p: false }),144+    JSON.stringify({ v: 3, c: VERIFY_CODE, i: 0, t: 5, p: false, d: "x" }),145145   ];146146   for (const raw of missingFieldInputs) {147147     it(`parse_error for missing fields: ${raw.slice(0, 60)}`, () => {@@ -509,7 +509,7 @@509509     // recomputed hash so the integrity check passes.510510     const evil = envs[0]!;511511     const fakeBytes = crypto.getRandomValues(new Uint8Array(CHUNK_SIZE));512-    const fakeHash = (await sha256Hex(fakeBytes)).slice(0, 16);512+    const fakeHash = (await sha256Hex(fakeBytes)).slice(0, 32);513513     const fakeEnv: ChunkEnvelope = {514514       ...evil,515515       d: bytesToBase64Url(fakeBytes),@@ -635,14 +635,6 @@635635     expect(await verifyChunkIntegrity(env)).toBe(true);636636   });637637 638-  it("verifyChunkIntegrity still passes for legacy 16-char hash", async () => {639-    const bytes = new Uint8Array([1, 2, 3]);640-    const fullHash = await sha256Hex(bytes);641-    const env = await buildChunkEnvelope(bytes, 0, 5, false, "PX-TEST-1234");642-    const legacy = { ...env, h: fullHash.slice(0, 16) };643-    expect(await verifyChunkIntegrity(legacy)).toBe(true);644-  });645-646638   it("verifyChunkIntegrity rejects tampered 32-char hash", async () => {647639     const bytes = new Uint8Array([1, 2, 3]);648640     const env = await buildChunkEnvelope(bytes, 0, 5, false, "PX-TEST-1234");@@ -650,10 +642,4 @@650642     expect(await verifyChunkIntegrity(tampered)).toBe(false);651643   });652644 653-  it("verifyChunkIntegrity rejects tampered 16-char legacy hash", async () => {654-    const bytes = new Uint8Array([1, 2, 3]);655-    const env = await buildChunkEnvelope(bytes, 0, 5, false, "PX-TEST-1234");656-    const tampered = { ...env, h: "X".repeat(16) };657-    expect(await verifyChunkIntegrity(tampered)).toBe(false);658-  });659645 });

view file

src/popup/codex/generate.ts+1 −1
@@ -303,7 +303,7 @@303303   const combined = JSON.stringify({ a: slotA, b: slotB ?? null });304304   const vaultBlob = bytesToBase64Url(new TextEncoder().encode(combined));305305 306-  // AUD-004: 32 hex chars = 128 bits (was 16 chars / 64 bits).306+  // 32 hex chars = 128 bits.307307   const fingerprint = (await sha256Hex(new TextEncoder().encode(vaultBlob))).slice(0, 32);308308 309309   const timestamp = lastModified ? Number.parseInt(lastModified, 10) : Date.now();

view file

src/popup/codex/helpers.ts+3 −7
@@ -67,7 +67,7 @@6767 /**6868  * Return uppercase hex of SHA-256(bytes). Used for both the per-chunk integrity6969  * hash (32-char prefix, 128 bits) and the vault fingerprint (32-char prefix,70- * 128 bits). AUD-004: prefixes were extended from 64 to 128 bits.70+ * 128 bits).7171  */7272 export async function sha256Hex(bytes: Uint8Array): Promise<string> {7373   // Copy into a fresh ArrayBuffer so the input never reaches SubtleCrypto as@@ -90,7 +90,7 @@9090  * Build a ChunkEnvelope for one data or parity chunk. The envelope's `h`9191  * field is the first 32 hex chars (128 bits) of SHA-256(rawChunkBytes), so9292  * per-chunk corruption introduced after generation (smudge, scan glitch) is93- * detectable at scan time. AUD-004 raised the prefix from 64 to 128 bits.93+ * detectable at scan time.9494  *9595  * `index` / `total` are within-stripe; `stripe` / `stripeCount` describe the9696  * stripe layout for v3. Generators always emit v3 envelopes — even when there@@ -125,11 +125,7 @@125125  * surface a CodexError.126126  */127127 export async function verifyChunkIntegrity(envelope: ChunkEnvelope): Promise<boolean> {128-  // AUD-004: accept only the documented prefix lengths — 16 (legacy v1) and129-  // 32 (current v2). An empty or truncated hash must not verify true via a130-  // zero-length prefix match, and an oversize hash cannot be a valid SHA-256131-  // prefix at all.132-  if (envelope.h.length !== 16 && envelope.h.length !== 32) return false;128+  if (envelope.h.length !== 32) return false;133129   const bytes = base64UrlToBytes(envelope.d);134130   const fullHash = await sha256Hex(bytes);135131   const expected = fullHash.slice(0, envelope.h.length);

view file

src/popup/codex/pdf-import.test.ts+2 −11
@@ -68,17 +68,8 @@6868     expect(parseChunkEnvelope(JSON.stringify(v3))).toEqual(v3);6969   });7070 71-  it("parses a v2 envelope (legacy single-stripe)", () => {72-    const v2 = {73-      v: 2,74-      c: "PX-TEST-1234",75-      i: 0,76-      t: 15,77-      p: false,78-      d: "AAAA",79-      h: "ABCDEF1234567890ABCDEF1234567890",80-    };81-    expect(parseChunkEnvelope(JSON.stringify(v2))).toEqual(v2);71+  it("rejects a v2 envelope", () => {72+    expect(parseChunkEnvelope(JSON.stringify({ v: 2, c: "PX-TEST-1234" }))).toBeNull();8273   });8374 8475   it("rejects a v3 envelope missing stripe metadata", () => {

view file

src/popup/codex/pdf-import.ts+3 −7
@@ -666,10 +666,7 @@666666  * non-Peach payload — the same defensive contract as `restore.ts/parseRawScan`667667  * but inlined here so this module stays decoupled from the camera scan path.668668  *669- * Accepts v2 (legacy single-stripe) and v3 (current multi-stripe) envelopes.670- * The generator has emitted v3 envelopes since the multi-stripe rollout —671- * earlier revisions of this function only accepted v2, which silently672- * rejected every envelope produced by current builds.669+ * Accepts current v3 envelopes. Older paper formats are rejected.673670  */674671 export function parseChunkEnvelope(raw: string): ChunkEnvelope | null {675672   let parsed: unknown;@@ -681,7 +678,7 @@681678   if (typeof parsed !== "object" || parsed === null) return null;682679   const obj = parsed as Record<string, unknown>;683680 684-  if (obj.v !== 2 && obj.v !== 3) return null;681+  if (obj.v !== 3) return null;685682 686683   const baseShapeOk =687684     typeof obj.c === "string" &&@@ -692,8 +689,7 @@692689     typeof obj.h === "string";693690   if (!baseShapeOk) return null;694691 695-  // v3 envelopes must also carry stripe metadata.696-  if (obj.v === 3 && (!Number.isInteger(obj.s) || !Number.isInteger(obj.S))) {692+  if (!Number.isInteger(obj.s) || !Number.isInteger(obj.S)) {697693     return null;698694   }699695 

view file

src/popup/codex/restore.test.ts+4 −4
@@ -80,7 +80,7 @@8080   const vaultFingerprint = (8181     await sha256Hex(new TextEncoder().encode(vaultBlob))8282   )83-    .slice(0, 16)83+    .slice(0, 32)8484     .toUpperCase();8585   return {8686     version: 2,@@ -243,9 +243,9 @@243243     });244244   });245245 246-  it("returns parse_error for a v2 object with missing fields", () => {246+  it("returns wrong_version for a v2 object", () => {247247     expect(parseRawScan(JSON.stringify({ v: 2, c: "PX" }))).toEqual({248-      error: "parse_error",248+      error: "wrong_version",249249     });250250   });251251 });@@ -301,7 +301,7 @@301301     const payload = await makePayload();302302     const tampered: CodexPayload = {303303       ...payload,304-      vaultFingerprint: "DEADBEEFDEADBEEF",304+      vaultFingerprint: "DEADBEEFDEADBEEFDEADBEEFDEADBEEF",305305     };306306     const envelopes = await buildEnvelopesForPayload(tampered);307307 

view file

src/popup/codex/restore.ts+11 −27
@@ -67,7 +67,7 @@6767  */6868 export interface ChunkBufferContext {6969   verifyCode: string;70-  /** Total stripes in this Codex (always 1 for legacy v=2 envelopes). */70+  /** Total stripes in this Codex. */7171   stripeCount: number;7272   /** Running total chunks (data + parity) across observed stripes. */7373   total: number;@@ -96,12 +96,10 @@9696    * `{ accepted: false, reason }` for any rejection. The buffer never throws —9797    * the scanner UI needs to keep running across bad frames.9898    *99-   * Accepts v2 (legacy single-stripe) and v3 (multi-stripe) envelopes. v2 is100-   * treated as a v3 envelope with `s=0, S=1` so the rest of the pipeline can101-   * be stripe-aware without branching.99+   * Accepts current v3 envelopes and rejects older paper formats.102100    */103101   async add(envelope: ChunkEnvelope): Promise<ChunkAddResult> {104-    if (envelope.v !== 2 && envelope.v !== 3) {102+    if (envelope.v !== 3) {105103       return { accepted: false, reason: "parse_error" };106104     }107105 @@ -119,8 +117,8 @@119117       return { accepted: false, reason: "bad_integrity" };120118     }121119 122-    const stripe = envelope.v === 3 ? (envelope.s ?? 0) : 0;123-    const stripeCount = envelope.v === 3 ? (envelope.S ?? 1) : 1;120+    const stripe = envelope.s;121+    const stripeCount = envelope.S;124122 125123     // PCH-01-011: `S` is attacker-controlled. Reject an out-of-range stripe126124     // count before it can pin this.stripeCount and drive O(S) allocation in@@ -272,9 +270,7 @@272270  * fires on every successful decode — most decodes are noise (other QR codes,273271  * stickers, old Codex prints) — so the caller must not assume any structure.274272  *275- * Accepts v2 (legacy single-stripe) and v3 (multi-stripe) envelopes. v2276- * envelopes are returned as-is — downstream stripe handling treats missing277- * `s` / `S` as `0` / `1` respectively.273+ * Accepts current v3 envelopes. Older paper formats are rejected.278274  */279275 export function parseRawScan(raw: string): ParseRawScanResult {280276   let parsed: unknown;@@ -290,7 +286,7 @@290286 291287   const obj = parsed as Record<string, unknown>;292288 293-  if (obj.v !== 2 && obj.v !== 3) {289+  if (obj.v !== 3) {294290     if ("v" in obj) return { error: "wrong_version" };295291     return { error: "parse_error" };296292   }@@ -307,11 +303,8 @@307303     return { error: "parse_error" };308304   }309305 310-  // v3-only fields must be shaped correctly when present.311-  if (obj.v === 3) {312-    if (!Number.isInteger(obj.s) || !Number.isInteger(obj.S)) {313-      return { error: "parse_error" };314-    }306+  if (!Number.isInteger(obj.s) || !Number.isInteger(obj.S)) {307+    return { error: "parse_error" };315308   }316309 317310   return obj as unknown as ChunkEnvelope;@@ -342,16 +335,7 @@342335   // uppercase hex (see helpers.ts), so the `.toUpperCase()` is belt-and-343336   // suspenders against a future helpers.ts change.344337   //345-  // AUD-004: legacy Codexes printed before v2.50.2 carry a 16-char (64-bit)346-  // fingerprint; current Codexes carry 32 chars (128 bits). Slice by the347-  // stored length so both verify without an envelope-version bump.348-  const fingerprintLen = payload.vaultFingerprint.length;349-  // AUD-004 / PCH-01-013: the declared fingerprint length is attacker-350-  // controlled. Without this gate, an empty declared fingerprint passes351-  // trivially (slice(0,0) === "") and a 1-char one passes with p=1/16. Accept352-  // only the two documented widths: 16 (legacy 64-bit) and 32 (current 128-bit).353-  // Mirrors verifyChunkIntegrity in helpers.ts.354-  if (fingerprintLen !== 16 && fingerprintLen !== 32) {338+  if (payload.vaultFingerprint.length !== 32) {355339     throw new CodexError(356340       "RECONSTRUCTION_FINGERPRINT_MISMATCH",357341       "Reconstructed vault fingerprint has an invalid length. " +@@ -359,7 +343,7 @@359343     );360344   }361345   const fingerprint = (await sha256Hex(new TextEncoder().encode(payload.vaultBlob)))362-    .slice(0, fingerprintLen)346+    .slice(0, 32)363347     .toUpperCase();364348   if (fingerprint !== payload.vaultFingerprint) {365349     throw new CodexError(

view file

src/popup/views/CodexRestoreView.test.tsx+1 −1
@@ -49,7 +49,7 @@4949   }5050   const vaultBlob = bytesToBase64Url(blobBytes);5151   const vaultFingerprint = (await sha256Hex(new TextEncoder().encode(vaultBlob)))52-    .slice(0, 16)52+    .slice(0, 32)5353     .toUpperCase();5454   return {5555     version: 2,

view file

src/popup/views/Health.tsx+1 −1
@@ -453,7 +453,7 @@453453                     <div class="peach-health-row-info">454454                       <span class="peach-health-row-name">{item.entry.title}</span>455455                       <span class="peach-health-row-meta">456-                        {item.status} • {item.entry.rpId} • {formatRelativeTime(Date.now() - item.lastActivity)}456+                        {item.status} • {item.entry.passkey?.rpId} • {formatRelativeTime(Date.now() - item.lastActivity)}457457                       </span>458458                     </div>459459                     <button

view file

src/popup/views/Import.tsx+12 −84
@@ -198,7 +198,7 @@198198         <div class="peach-settings-group peach-export-card">199199           <p class="peach-settings-label">backup file</p>200200           <p class="peach-export-card-copy">201-            A Peach JSON export, or a file from Bitwarden, Proton Pass,201+            A file from Bitwarden, Proton Pass,202202             1Password, or a browser CSV (Chrome, Safari, Edge).203203           </p>204204           <button@@ -397,7 +397,7 @@397397 }398398 399399 // ---------------------------------------------------------------------------400-// File flow (Bitwarden / 1Password / Peach JSON / etc.)400+// File flow (Bitwarden / 1Password / etc.)401401 // ---------------------------------------------------------------------------402402 403403 function FileImport(props: {@@ -413,8 +413,6 @@413413   const needsMasterPassword = () => isOnboarding;414414 415415   const [preview, setPreview] = createSignal<ImportParseResult | null>(null);416-  const [rawImport, setRawImport] = createSignal<string | null>(null);417-  const [backupPassword, setBackupPassword] = createSignal("");418416   const [masterPassword, setMasterPassword] = createSignal("");419417   const [masterConfirm, setMasterConfirm] = createSignal("");420418   const [importing, setImporting] = createSignal(false);@@ -423,10 +421,8 @@423421   const [awaitingReplaceConfirm, setAwaitingReplaceConfirm] = createSignal(false);424422   const [importProgress, setImportProgress] = createSignal<{ current: number; total: number } | null>(null);425423   const [duplicateWarning, setDuplicateWarning] = createSignal<number>(0);426-  const [legacyKdfNotice, setLegacyKdfNotice] = createSignal(false);427424   const [existingCount, setExistingCount] = createSignal<number>(0);428425 429-  const needsBackupPassword = createMemo(() => preview()?.requiresPassword ?? false);430426   const masterPasswordReady = createMemo(431427     () => masterPassword().length >= 8 && masterPassword() === masterConfirm()432428   );@@ -439,14 +435,14 @@439435   }440436   void refreshExistingCount();441437 442-  async function parseSource(text: string, password?: string) {438+  async function parseSource(text: string) {443439     try {444-      const result = await parseImportSource(text, password);440+      const result = await parseImportSource(text);445441       setPreview(result);446442       setError(null);447443       if (result.format === "unknown") {448444         setError(449-          "We couldn't recognise this file format. Peach supports: Peach JSON backups, Bitwarden JSON/CSV, Proton Pass JSON/CSV, 1Password JSON, and generic browser CSV exports."445+          "We couldn't recognise this file format. Peach supports: Bitwarden JSON/CSV, Proton Pass JSON/CSV, 1Password JSON, and generic browser CSV exports."450446         );451447       }452448       return result;@@ -464,11 +460,8 @@464460     if (!file) return;465461 466462     setPreview(null);467-    setRawImport(null);468-    setBackupPassword("");469463     setError(null);470464     setAwaitingReplaceConfirm(false);471-    setLegacyKdfNotice(false);472465 473466     const text = await file.text();474467 @@ -489,16 +482,9 @@489482       return;490483     }491484 492-    setRawImport(text);493485     await parseSource(text);494486   }495487 496-  async function handleUnlockBackup() {497-    const raw = rawImport();498-    if (!raw || !backupPassword().trim()) return;499-    await parseSource(raw, backupPassword());500-  }501-502488   async function countDuplicates(incoming: ImportParseResult["entries"]): Promise<number> {503489     if (incoming.length === 0) return 0;504490     const res = await sendMessage({ type: "GET_ENTRIES" });@@ -519,11 +505,6 @@519505     const currentPreview = preview();520506     if (!currentPreview) return;521507 522-    if (needsBackupPassword() && !backupPassword().trim()) {523-      setError("Enter the backup password used for this export.");524-      return;525-    }526-527508     if (needsMasterPassword()) {528509       if (masterPassword().length < 8) {529510         setError("Master password must be at least 8 characters.");@@ -543,7 +524,6 @@543524     setImporting(true);544525     setError(null);545526     setDuplicateWarning(0);546-    setLegacyKdfNotice(false);547527     setImportProgress(null);548528 549529     try {@@ -599,12 +579,7 @@599579         throw new Error("NO_ENTRIES_IMPORTED");600580       }601581 602-      if (currentPreview.licenseKey) {603-        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: currentPreview.licenseKey } }).catch(() => {});604-      }605-606582       setDuplicateWarning(dupes);607-      setLegacyKdfNotice(currentPreview.format === "peach-json");608583       await props.onImport();609584     } catch (err) {610585       const message = err instanceof Error ? err.message : String(err);@@ -634,11 +609,10 @@634609           <p class="peach-settings-label">choose a file</p>635610           <p class="peach-export-card-copy">636611             Bitwarden, 1Password, Proton Pass, Chrome / Edge / Safari CSV, FIDO637-            credential-exchange passkeys, or a Peach JSON backup.612+              credential-exchange passkeys.638613           </p>639614 640615           <div class="peach-provider-chips">641-            <span class="peach-provider-chip">Peach JSON</span>642616             <span class="peach-provider-chip">FIDO passkeys</span>643617             <span class="peach-provider-chip">Proton Pass</span>644618             <span class="peach-provider-chip">Bitwarden</span>@@ -654,43 +628,8 @@654628           />655629         </div>656630 657-        <Show when={needsBackupPassword()}>631+        <Show when={preview() && preview()!.format !== "unknown"}>658632           <div class="peach-settings-group">659-            <p class="peach-settings-label">unlock backup</p>660-            <p class="peach-export-card-copy">661-              Enter the password used when you created this encrypted Peach JSON662-              backup.663-            </p>664-            <input665-              class="peach-restore-input"666-              type="password"667-              placeholder="backup password"668-              value={backupPassword()}669-              onInput={(event) =>670-                setBackupPassword((event.target as HTMLInputElement).value)671-              }672-              onBlur={() => { if (backupPassword().trim()) void handleUnlockBackup(); }}673-              onKeyDown={(event) => {674-                if (event.key === "Enter") {675-                  event.preventDefault();676-                  void handleUnlockBackup();677-                }678-              }}679-            />680-            <div class="peach-settings-actions">681-              <button682-                class="peach-btn peach-btn--subtle peach-btn--sm"683-                onClick={() => void handleUnlockBackup()}684-                disabled={!backupPassword().trim()}685-              >686-                Decrypt →687-              </button>688-            </div>689-          </div>690-        </Show>691-692-        <Show when={preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>693-          <div class="peach-settings-group">694633             <p class="peach-settings-label">{preview()!.label}</p>695634             <div class="peach-import-summary">696635               Found <strong>{preview()!.entries.length}</strong>{" "}@@ -722,7 +661,7 @@722661           </div>723662         </Show>724663 725-        <Show when={!isOnboarding && preview() && !needsBackupPassword() && preview()!.format !== "unknown" && preview()!.entries.length > 0}>664+        <Show when={!isOnboarding && preview() && preview()!.format !== "unknown" && preview()!.entries.length > 0}>726665           <div class="peach-settings-group">727666             <p class="peach-settings-label">merge or replace</p>728667             <label class="peach-import-choice">@@ -763,7 +702,7 @@763702           </div>764703         </Show>765704 766-        <Show when={needsMasterPassword() && preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>705+        <Show when={needsMasterPassword() && preview() && preview()!.format !== "unknown"}>767706           <div class="peach-settings-group">768707             <OnboardingMasterPasswordFields769708               idPrefix="backup"@@ -799,16 +738,8 @@799738           </div>800739         </Show>801740 802-        <Show when={legacyKdfNotice() && !importing()}>741+        <Show when={preview() && preview()!.format !== "unknown"}>803742           <div class="peach-settings-group">804-            <div class="peach-restore-warning">805-              This backup was created with Peach's older encryption format (PBKDF2). It imported fine — but we recommend exporting a fresh backup now so your paper/file backups use the current, stronger format.806-            </div>807-          </div>808-        </Show>809-810-        <Show when={preview() && !needsBackupPassword() && preview()!.format !== "unknown"}>811-          <div class="peach-settings-group">812743             <div class="peach-settings-actions">813744               <button814745                 class="peach-btn peach-btn--subtle peach-btn--sm"@@ -885,16 +816,13 @@885816   // UI-7 / MV-5: oversized-import errors carry a variable detail after the886817   // code, so match the prefix and never surface the raw string.887818   if (error.startsWith("IMPORT_TOO_LARGE")) {888-    return "This file is too large to import (limit: 10 MiB for backups, 25 MiB for Codex PDFs). If this is a real Peach backup, please report it — backups never approach this size.";819+    return "This file is too large to import (limit: 10 MiB for backups, 25 MiB for Codex PDFs). If this is a real PeachScript backup, please report it — backups never approach this size.";889820   }890821   switch (error) {891822     case "UNSUPPORTED_IMPORT_FORMAT":892-      return "Unrecognised file format. Use CSV, Bitwarden JSON, Proton Pass JSON/CSV, or a Peach encrypted backup.";893-    case "INVALID_EXPORT_PASSWORD":823+      return "Unrecognised file format. Use CSV, Bitwarden JSON, Proton Pass JSON/CSV, or a PeachScript backup.";894824     case "INVALID_PASSPHRASE":895825       return "Wrong backup password. This is the password you chose when you created the backup.";896-    case "INVALID_PEACH_EXPORT":897-      return "This Peach backup file looks corrupted or incomplete.";898826     case "INVALID_PEACHSCRIPT_CHARACTER":899827       return "This doesn't look like PeachScript. Make sure you copied the entire backup string.";900828     case "PAYLOAD_TOO_SHORT":

view file

src/popup/views/Lock.tsx+1 −4
@@ -965,7 +965,7 @@965965       setAnimating(null);966966       const code = err instanceof Error ? err.message : String(err);967967       setError(968-        code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"968+        code === "INVALID_PASSPHRASE"969969           ? "That passphrase didn't work."970970           : code === "CREATE_VAULT_FAILED"971971             ? "Couldn't create a new local vault."@@ -1129,9 +1129,6 @@11291129 11301130       const res = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: parsed.entries } });11311131       if (!(res as { ok: boolean }).ok) throw new Error("IMPORT_FAILED");1132-      if (parsed.licenseKey) {1133-        await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: parsed.licenseKey } }).catch(() => {});1134-      }11351132       await props.onSuccess();11361133     } catch (err) {11371134       const code = err instanceof Error ? err.message : String(err);

view file

src/popup/views/contracts.test.tsx+1 −52
@@ -90,7 +90,6 @@9090       entries: [],9191       totalItems: 0,9292       skippedItems: 0,93-      requiresPassword: false,9493     });9594 9695     vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {@@ -160,7 +159,6 @@160159       ],161160       totalItems: 1,162161       skippedItems: 0,163-      requiresPassword: false,164162       ...overrides,165163     };166164   }@@ -1456,7 +1454,7 @@14561454     findButton(container, "Import 1").click();14571455     await flushView();14581456 1459-    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);1457+    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}');14601458     expect(sendMessageMock).toHaveBeenCalledWith({14611459       type: "BATCH_ADD_ENTRIES",14621460       payload: {@@ -1578,53 +1576,4 @@15781576     expect(onImport).toHaveBeenCalled();15791577   });15801578 1581-  it("Import encrypted backups reparse with the backup password before import", async () => {1582-    parseImportSourceMock1583-      .mockResolvedValueOnce(1584-        sampleImportResult({1585-          format: "peach-json",1586-          label: "Peach encrypted backup",1587-          entries: [],1588-          totalItems: 0,1589-          requiresPassword: true,1590-        })1591-      )1592-      .mockResolvedValueOnce(1593-        sampleImportResult({1594-          format: "peach-json",1595-          label: "Peach encrypted backup",1596-        })1597-      );1598-    sendMessageMock.mockImplementation(async (msg: unknown) => {1599-      const type = (msg as { type?: string }).type;1600-      if (type === "GET_ENTRIES") return { ok: true, data: [] };1601-      if (type === "BATCH_ADD_ENTRIES") {1602-        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1603-      }1604-      return { ok: true };1605-    });1606-    const onImport = vi.fn(async () => undefined);1607-    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1608-    await flushView();1609-    sendMessageMock.mockClear();1610-1611-    await selectImportFile(container, '{"type":"peach-export"}');1612-    const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;1613-    inputText(password, "backup-passphrase");1614-    pressEnter(password);1615-    await flushView();1616-    await flushView();1617-1618-    expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);1619-    expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");1620-1621-    findButton(container, "Import 1").click();1622-    await flushView();1623-1624-    expect(sendMessageMock).toHaveBeenCalledWith({1625-      type: "BATCH_ADD_ENTRIES",1626-      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1627-    });1628-    expect(onImport).toHaveBeenCalled();1629-  });16301579 });

view file

src/popup/views/vault-search.test.ts+3 −3
@@ -36,7 +36,7 @@3636   it("sorts recent entries by last-used timestamps", () => {3737     const entries = [3838       entry({ id: "created-only", title: "Created only", modifiedAt: 10 }),39-      entry({ id: "login-used", title: "Login used", lastUsedAt: 300, modifiedAt: 20 }),39+      entry({ id: "login-used", title: "Login used", passkey: basePasskey({ lastUsedAt: 300 }), modifiedAt: 20 }),4040       entry({ id: "passkey-used", title: "Passkey used", passkey: basePasskey({ lastUsedAt: 200 }) }),4141     ];4242 @@ -98,14 +98,14 @@9898         title: "Alpha",9999         username: "alpha-user",100100         modifiedAt: 10,101-        lastUsedAt: 100,101+        passkey: basePasskey({ lastUsedAt: 100 }),102102       }),103103       entry({104104         id: "newer",105105         title: "Alpha",106106         username: "alpha-user",107107         modifiedAt: 20,108-        lastUsedAt: 200,108+        passkey: basePasskey({ lastUsedAt: 200 }),109109       }),110110       entry({111111         id: "fallback",

view file

src/popup/views/vault-search.ts+0 −1
@@ -31,7 +31,6 @@3131 3232 export function getVaultEntryLastUsedAt(entry: VaultEntry): number {3333   return entry.passkey?.lastUsedAt34-    ?? entry.lastUsedAt3534     ?? entry.modifiedAt3635     ?? entry.createdAt3736     ?? 0;

view file

src/shared/backup.nfc-fallback.test.ts+0 −133
@@ -1,133 +0,0 @@1-// T-006: backup/import NFC raw-fallback decrypt (LV-1 / PCH-01-001 sibling)2-//3-// Why this test exists: backup.ts:96–111 and import.ts:96–111 both contain4-// the LV-1 "raw retry" pattern: if NFC-normalized decryption fails, retry5-// with the un-normalized password bytes. This fallback ensures users with6-// non-NFC-normalized passwords (common on some input methods) can still7-// restore old backups created before the LV-1 NFC-normalize fix.8-//9-// This decrypt path was never directly exercised — the gap was flagged in the10-// coverage analysis §4 as a data-loss risk on the disaster path (backup.ts11-// specifically is the canonical PCH-01-001 pattern source).12-13-import { describe, expect, it } from "vitest";14-import {15-  exportEntriesToPeachJson,16-  importEntriesFromPeachJson,17-  derivePeachExportKey,18-} from "./backup";19-import { encrypt } from "../background/aes";20-import type { VaultEntry } from "./types";21-22-// Use explicit Unicode escapes so the byte sequences are unambiguous regardless23-// of how the source file is saved or read. String literals with composed/decomposed24-// characters may be silently normalized by editors or the JS runtime.25-// NFD: c, a, f + e (U+0065) + combining acute accent (U+0301) = two code points for é26-const NFD_PASSWORD = "café";27-// NFC: c, a, f + precomposed é (U+00E9) = one code point28-const NFC_PASSWORD = "café";29-30-// Sanity-check the assumptions at module load time.31-if ((NFD_PASSWORD as string) === NFC_PASSWORD) {32-  throw new Error("Test assumption broken: NFD and NFC passwords are identical on this platform");33-}34-if (NFD_PASSWORD.normalize("NFC") !== NFC_PASSWORD) {35-  throw new Error("Test assumption broken: NFD normalize(NFC) !== NFC");36-}37-38-function sampleEntries(): VaultEntry[] {39-  return [40-    {41-      id: "test-entry-1",42-      type: "login",43-      title: "Test Site",44-      username: "[email protected]",45-      password: "correct-horse-battery",46-      url: "https://example.com",47-      createdAt: 1700000000000,48-      modifiedAt: 1700000000000,49-    },50-  ];51-}52-53-describe("T-006: backup/import NFC raw-fallback decrypt", () => {54-  // T-006a: normal path — export sealed with NFC password → import with NFC password.55-  // This is the standard post-LV-1 happy path.56-  it("T-006a: export sealed with NFC password → import with NFC password succeeds", async () => {57-    const envelope = await exportEntriesToPeachJson(sampleEntries(), NFC_PASSWORD);58-    const result = await importEntriesFromPeachJson(JSON.stringify(envelope), NFC_PASSWORD);59-    expect(result.entries).toHaveLength(1);60-    expect(result.entries[0].title).toBe("Test Site");61-  });62-63-  // T-006b: NFD-sealed export opened with NFD password — the raw fallback path.64-  // This simulates an old backup created before LV-1, where the password was65-  // stored/used in NFD form (e.g. macOS dead-key typed "café" as e + combining accent).66-  // The import must succeed via the raw fallback:67-  //   1. NFC-normalize NFD_PASSWORD → NFC_PASSWORD → derive key → decrypt fails (sealed with NFD)68-  //   2. Fallback: use NFD_PASSWORD raw → derive key → decrypt succeeds69-  it("T-006b: export sealed with NFD password (raw) → import with NFD password succeeds via fallback", async () => {70-    // Build the envelope manually using the raw (un-normalized) NFD password.71-    const salt = crypto.getRandomValues(new Uint8Array(16));72-    const keyBytes = await derivePeachExportKey(NFD_PASSWORD, salt, { raw: true });73-    const inner = { entries: sampleEntries() };74-    const { nonce, ciphertext, tag } = await encrypt(keyBytes, JSON.stringify(inner));75-76-    function toHex(buf: Uint8Array): string {77-      return Array.from(buf).map(b => b.toString(16).padStart(2, "0")).join("");78-    }79-80-    const envelope = JSON.stringify({81-      version: 2,82-      type: "peach-export",83-      kdf: "PBKDF2-SHA256",84-      salt: toHex(salt),85-      nonce,86-      ciphertext,87-      tag,88-    });89-90-    // Importing with the NFD password triggers: first NFC-normalize attempt fails91-    // (sealed with raw NFD), then the raw fallback succeeds.92-    const result = await importEntriesFromPeachJson(envelope, NFD_PASSWORD);93-    expect(result.entries).toHaveLength(1);94-    expect(result.entries[0].title).toBe("Test Site");95-  });96-97-  // T-006c: NFC-sealed export opened with NFD password.98-  // Passing an NFD password to the importer: the first attempt NFC-normalizes it99-  // (producing NFC), which matches the sealed key → success without needing fallback.100-  it("T-006c: export sealed with NFC password → import with NFD password succeeds", async () => {101-    const envelope = await exportEntriesToPeachJson(sampleEntries(), NFC_PASSWORD);102-    // The importer NFC-normalizes the passphrase on the first attempt,103-    // so NFD_PASSWORD.normalize("NFC") === NFC_PASSWORD → succeeds directly.104-    const result = await importEntriesFromPeachJson(JSON.stringify(envelope), NFD_PASSWORD);105-    expect(result.entries).toHaveLength(1);106-    expect(result.entries[0].title).toBe("Test Site");107-  });108-109-  // T-006d: completely wrong password → both NFC and raw attempts fail.110-  // The fallback must not open the vault with a wrong password.111-  it("T-006d: completely wrong password → fails (neither NFC nor raw match)", async () => {112-    const envelope = await exportEntriesToPeachJson(sampleEntries(), NFC_PASSWORD);113-    await expect(114-      importEntriesFromPeachJson(JSON.stringify(envelope), "wrong-password-entirely"),115-    ).rejects.toThrow(/INVALID_EXPORT_PASSWORD/);116-  });117-118-  // T-006e: explicit NFD vs NFC byte sequences produce the same decryption result.119-  // U+0065 U+0301 (NFD "é") vs U+00E9 (NFC "é") must both open a NFC-sealed vault.120-  // This is the canonical PCH-01-001 / LV-1 guarantee.121-  it("T-006e: NFD and NFC forms of the same password both open the same vault", async () => {122-    const envelope = await exportEntriesToPeachJson(sampleEntries(), NFC_PASSWORD);123-    const envelopeStr = JSON.stringify(envelope);124-125-    // Both the NFC and NFD forms of the password must open the same vault.126-    const resultNfc = await importEntriesFromPeachJson(envelopeStr, NFC_PASSWORD);127-    const resultNfd = await importEntriesFromPeachJson(envelopeStr, NFD_PASSWORD);128-129-    expect(resultNfc.entries[0].title).toBe("Test Site");130-    expect(resultNfd.entries[0].title).toBe("Test Site");131-    expect(resultNfc.entries[0].password).toBe(resultNfd.entries[0].password);132-  });133-});

view file at the parent

src/shared/backup.test.ts+0 −105
@@ -1,105 +0,0 @@1-import { describe, expect, it } from "vitest";2-3-import {4-  exportEntriesToPeachJson,5-  importEntriesFromPeachJson,6-  type PeachExportEnvelope,7-} from "./backup";8-9-describe("shared backup", () => {10-  const sampleEntries = [11-    {12-      id: "login-1",13-      type: "login" as const,14-      title: "GitHub",15-      username: "june",16-      password: "secret123",17-      url: "https://github.com",18-      notes: "2fa enabled",19-      createdAt: 1,20-      modifiedAt: 1,21-    },22-  ];23-24-  it("importEntriesFromPeachJson decrypts entries with the correct password", async () => {25-    const envelope = await exportEntriesToPeachJson(sampleEntries, "export-pass");26-27-    const result = await importEntriesFromPeachJson(JSON.stringify(envelope), "export-pass");28-    expect(result.entries).toMatchObject([29-      {30-        type: "login",31-        title: "GitHub",32-        username: "june",33-        password: "secret123",34-        url: "https://github.com",35-      },36-    ]);37-  });38-39-  it("importEntriesFromPeachJson rejects the wrong password", async () => {40-    const envelope = await exportEntriesToPeachJson(sampleEntries, "export-pass");41-42-    await expect(43-      importEntriesFromPeachJson(JSON.stringify(envelope), "wrong-pass")44-    ).rejects.toThrow("INVALID_EXPORT_PASSWORD");45-  });46-47-  it("importEntriesFromPeachJson rejects malformed JSON", async () => {48-    await expect(importEntriesFromPeachJson("{not json", "export-pass")).rejects.toThrow(49-      "INVALID_PEACH_EXPORT"50-    );51-  });52-53-  it("export includes trashed entries; import preserves deletedAt", async () => {54-    const trashedAt = 1_700_000_010_000;55-    const entries = [56-      ...sampleEntries,57-      {58-        id: "login-2",59-        type: "login" as const,60-        title: "Old Service",61-        username: "june",62-        password: "oldpass",63-        url: "https://old.example.com",64-        createdAt: 1,65-        modifiedAt: 1,66-        deletedAt: trashedAt,67-      },68-    ];69-70-    const envelope = await exportEntriesToPeachJson(entries, "export-pass");71-    const { entries: imported } = await importEntriesFromPeachJson(JSON.stringify(envelope), "export-pass");72-73-    expect(imported).toHaveLength(2);74-75-    const active = imported.filter((e) => !e.deletedAt);76-    const trashed = imported.filter((e) => !!e.deletedAt);77-78-    expect(active).toHaveLength(1);79-    expect(trashed).toHaveLength(1);80-    expect(trashed[0].title).toBe("Old Service");81-    expect(trashed[0].deletedAt).toBe(trashedAt);82-  });83-84-  it("importEntriesFromPeachJson rejects envelopes with missing required fields", async () => {85-    const envelope: Partial<PeachExportEnvelope> = {86-      version: 2,87-      type: "peach-export",88-      kdf: "PBKDF2-SHA256",89-      salt: "00",90-      nonce: "00",91-      ciphertext: "00",92-    };93-94-    await expect(95-      importEntriesFromPeachJson(JSON.stringify(envelope), "export-pass")96-    ).rejects.toThrow("INVALID_PEACH_EXPORT");97-  });98-99-  it("MV-5: importEntriesFromPeachJson rejects an over-limit raw string with IMPORT_TOO_LARGE", async () => {100-    const huge = "x".repeat(11 * 1024 * 1024); // 11 MiB > 10 MiB cap101-    await expect(importEntriesFromPeachJson(huge, "export-pass")).rejects.toThrow(102-      /IMPORT_TOO_LARGE/,103-    );104-  });105-});

view file at the parent

src/shared/backup.ts+0 −307
@@ -1,307 +0,0 @@1-import { decrypt, encrypt } from "../background/aes";2-import { IMPORT_LIMITS, importTooLarge } from "./import-limits";3-import type { PeachLicenseExport, VaultEntry } from "./types";4-5-/**6- * Encrypted Peach JSON envelope (legacy v2). v2.5.0 removed the7- * encrypted-JSON export option from the UI in favour of PeachScript and the8- * QR matrix code (see src/crypto/peachPayload.ts). The import path remains9- * so users can still restore older `.json` backups, and the export helper10- * below is kept private to tests that exercise the import path with11- * round-trip fixtures.12- */13-export interface PeachExportEnvelope {14-  version: 2;15-  type: "peach-export";16-  kdf: "PBKDF2-SHA256";17-  salt: string;18-  nonce: string;19-  ciphertext: string;20-  tag: string;21-}22-23-export type ImportableEntry = Omit<VaultEntry, "id" | "createdAt" | "modifiedAt">;24-25-/** @internal — test fixture generator only. No UI code path calls this. */26-export async function exportEntriesToPeachJson(27-  entries: VaultEntry[],28-  password: string,29-  license?: PeachLicenseExport30-): Promise<PeachExportEnvelope> {31-  const salt = crypto.getRandomValues(new Uint8Array(16));32-  const key = await derivePeachExportKey(password, salt);33-  const inner: { entries: VaultEntry[]; license?: PeachLicenseExport } = { entries };34-  if (license) inner.license = license;35-  const { nonce, ciphertext, tag } = await encrypt(key, JSON.stringify(inner));36-37-  return {38-    version: 2,39-    type: "peach-export",40-    kdf: "PBKDF2-SHA256",41-    salt: toHex(salt),42-    nonce,43-    ciphertext,44-    tag,45-  };46-}47-48-export interface PeachJsonImportResult {49-  entries: ImportableEntry[];50-  licenseKey?: string;51-  licenseActivatedAt?: number;52-}53-54-export async function importEntriesFromPeachJson(55-  raw: string,56-  password: string57-): Promise<PeachJsonImportResult> {58-  // MV-5: defense in depth — parseImportSource caps first when this is59-  // reached through it, but this function is also exported directly.60-  if (raw.length > IMPORT_LIMITS.MAX_JSON_BYTES) {61-    throw importTooLarge(62-      `peach export ${raw.length} chars exceeds ${IMPORT_LIMITS.MAX_JSON_BYTES}`,63-    );64-  }65-  let envelope: Partial<PeachExportEnvelope>;66-  try {67-    envelope = JSON.parse(raw) as Partial<PeachExportEnvelope>;68-  } catch {69-    throw new Error("INVALID_PEACH_EXPORT");70-  }71-72-  if (73-    envelope.type !== "peach-export" ||74-    envelope.kdf !== "PBKDF2-SHA256" ||75-    envelope.version !== 2 ||76-    !envelope.salt ||77-    !envelope.nonce ||78-    !envelope.ciphertext ||79-    !envelope.tag80-  ) {81-    throw new Error("INVALID_PEACH_EXPORT");82-  }83-84-  let plaintext: string;85-  try {86-    const key = await derivePeachExportKey(password, fromHex(envelope.salt));87-    plaintext = await decrypt(88-      key,89-      envelope.nonce,90-      envelope.ciphertext,91-      envelope.tag92-    );93-  } catch {94-    // LV-1: an export sealed before NFC normalization with a non-NFC95-    // passphrase derives a different key. Retry once with the raw bytes.96-    if (password === password.normalize("NFC")) {97-      throw new Error("INVALID_EXPORT_PASSWORD");98-    }99-    try {100-      const key = await derivePeachExportKey(password, fromHex(envelope.salt), {101-        raw: true,102-      });103-      plaintext = await decrypt(104-        key,105-        envelope.nonce,106-        envelope.ciphertext,107-        envelope.tag108-      );109-    } catch {110-      throw new Error("INVALID_EXPORT_PASSWORD");111-    }112-  }113-114-  let parsed: unknown;115-  try {116-    parsed = JSON.parse(plaintext);117-  } catch {118-    throw new Error("INVALID_PEACH_EXPORT");119-  }120-121-  // Handle both old format (plain entries array) and new format ({ entries, license? })122-  let rawEntries: unknown[];123-  let licenseKey: string | undefined;124-  let licenseActivatedAt: number | undefined;125-126-  if (Array.isArray(parsed)) {127-    rawEntries = parsed;128-  } else if (parsed && typeof parsed === "object" && Array.isArray((parsed as { entries?: unknown }).entries)) {129-    const obj = parsed as { entries: unknown[]; license?: PeachLicenseExport };130-    rawEntries = obj.entries;131-    if (obj.license && typeof obj.license.key === "string") {132-      licenseKey = obj.license.key;133-      licenseActivatedAt = obj.license.activatedAt;134-    }135-  } else {136-    throw new Error("INVALID_PEACH_EXPORT");137-  }138-139-  const entries = rawEntries140-    .map(normalizeImportedEntry)141-    .filter((entry): entry is ImportableEntry => entry !== null);142-143-  return { entries, ...(licenseKey ? { licenseKey, licenseActivatedAt } : {}) };144-}145-146-/**147- * LEGACY IMPORT KDF (PCH-02-005):148- * This path derives with PBKDF2-SHA256 (250,000 iterations) for backwards149- * compatibility with `.json` exports sealed before the Argon2id migration.150- * The encrypted-JSON export UI was removed in v2.5.0, so this helper is151- * import-only, plus test fixture generation.152- *153- * PBKDF2 has no memory-hardness and is materially weaker than the current154- * Argon2id envelope (m=64 MiB, t=3, p=4) against GPU/ASIC attackers at equal155- * wall-clock cost. Do not use this helper for any new format. Users importing156- * an old `.json` backup are prompted to export a fresh backup afterwards.157- */158-export async function derivePeachExportKey(159-  password: string,160-  salt: Uint8Array,161-  opts: { raw?: boolean } = {}162-): Promise<Uint8Array> {163-  // LV-1: NFC-normalize the export passphrase to match the master-password164-  // KDF chokepoint. `raw:true` is used only by the import fallback to open165-  // pre-LV-1 exports sealed under un-normalized bytes.166-  const pw = opts.raw ? password : password.normalize("NFC");167-  const material = await crypto.subtle.importKey(168-    "raw",169-    new TextEncoder().encode(pw),170-    "PBKDF2",171-    false,172-    ["deriveBits"]173-  );174-  const bits = await crypto.subtle.deriveBits(175-    {176-      name: "PBKDF2",177-      salt: salt.buffer as ArrayBuffer,178-      iterations: 250000,179-      hash: "SHA-256",180-    },181-    material,182-    256183-  );184-  return new Uint8Array(bits);185-}186-187-function normalizeImportedEntry(value: unknown): ImportableEntry | null {188-  if (!value || typeof value !== "object") return null;189-190-  const candidate = value as Partial<VaultEntry> & {191-    name?: string;192-    note?: string;193-    email?: string;194-  };195-196-  const deletedAt =197-    typeof candidate.deletedAt === "number" ? candidate.deletedAt : undefined;198-199-  if (candidate.type === "note") {200-    const title = firstNonEmptyString(201-      candidate.title,202-      candidate.name,203-      "Untitled"204-    );205-    if (!title) return null;206-    return {207-      type: "note",208-      title,209-      notes: firstNonEmptyString(candidate.notes, candidate.note) || undefined,210-      tags: Array.isArray(candidate.tags) ? candidate.tags : undefined,211-      deletedAt,212-    };213-  }214-215-  if (candidate.type === "passkey") {216-    if (217-      typeof candidate.rpId !== "string" ||218-      typeof candidate.rpName !== "string" ||219-      typeof candidate.credentialId !== "string" ||220-      typeof candidate.publicKeyCose !== "string" ||221-      !candidate.privateKeyJwk ||222-      typeof candidate.userHandle !== "string" ||223-      typeof candidate.userName !== "string" ||224-      typeof candidate.userDisplayName !== "string" ||225-      (candidate.algorithm !== -7 && candidate.algorithm !== -8)226-    ) {227-      return null;228-    }229-230-    return {231-      type: "passkey",232-      title: firstNonEmptyString(candidate.title, candidate.rpName, candidate.rpId),233-      rpId: candidate.rpId,234-      rpName: candidate.rpName,235-      credentialId: candidate.credentialId,236-      publicKeyCose: candidate.publicKeyCose,237-      privateKeyJwk: candidate.privateKeyJwk,238-      userHandle: candidate.userHandle,239-      userName: candidate.userName,240-      userDisplayName: candidate.userDisplayName,241-      algorithm: candidate.algorithm,242-      signCount: typeof candidate.signCount === "number" ? candidate.signCount : 0,243-      aaguid:244-        typeof candidate.aaguid === "string" ? candidate.aaguid : "AAAAAAAAAAAAAAAAAAAAAA",245-      backupEligible: candidate.backupEligible !== false,246-      backupState: candidate.backupState === true,247-      lastUsedAt:248-        typeof candidate.lastUsedAt === "number" ? candidate.lastUsedAt : undefined,249-      deletedAt,250-    };251-  }252-253-  const password =254-    typeof candidate.password === "string" ? candidate.password : "";255-  if (!password) return null;256-257-  const title = firstNonEmptyString(258-    candidate.title,259-    candidate.name,260-    candidate.url,261-    candidate.username,262-    candidate.email,263-    "Untitled"264-  );265-266-  return {267-    type: "login",268-    title,269-    username: firstNonEmptyString(candidate.username, candidate.email, ""),270-    password,271-    url: firstNonEmptyString(candidate.url, ""),272-    notes: firstNonEmptyString(candidate.notes, candidate.note) || undefined,273-    totp: firstNonEmptyString(candidate.totp) || undefined,274-    breach:275-      typeof candidate.breach === "number" && candidate.breach > 0276-        ? candidate.breach277-        : undefined,278-    canary: candidate.canary ?? undefined,279-    tags: Array.isArray(candidate.tags) ? candidate.tags : undefined,280-    deletedAt,281-  };282-}283-284-function firstNonEmptyString(285-  ...values: Array<string | null | undefined>286-): string {287-  for (const value of values) {288-    if (typeof value === "string" && value.trim()) {289-      return value.trim();290-    }291-  }292-  return "";293-}294-295-function toHex(buf: Uint8Array): string {296-  return Array.from(buf)297-    .map((byte) => byte.toString(16).padStart(2, "0"))298-    .join("");299-}300-301-function fromHex(input: string): Uint8Array {302-  const out = new Uint8Array(input.length / 2);303-  for (let i = 0; i < input.length; i += 2) {304-    out[i / 2] = parseInt(input.slice(i, i + 2), 16);305-  }306-  return out;307-}

view file at the parent

src/shared/codex-types.ts+12 −16
@@ -13,8 +13,7 @@1313  *  - tests (`src/popup/codex/*.test.ts`)1414  *1515  * Bump `version` (and `ChunkEnvelope.v`) only on breaking changes to the16- * on-paper format. A v2 Codex must always be decodable by future Peach17- * versions.16+ * on-paper format.1817  */1918 2019 import type { VaultEntry } from "./types";@@ -31,7 +30,7 @@3130   timestamp: number;3231   /** Visible entry count for user confirmation at restore time. */3332   entryCount: number;34-  /** First 32 chars (128 bits) of SHA-256(vaultBlob), uppercase hex. Verifies reconstruction integrity. AUD-004: was 16 chars; legacy Codexes still verify. */33+  /** First 32 chars (128 bits) of SHA-256(vaultBlob), uppercase hex. Verifies reconstruction integrity. */3534   vaultFingerprint: string;3635   /** When this Codex PDF was generated. Printed in the footer. */3736   generatedAt: number;@@ -44,22 +43,19 @@4443  * Field names are intentionally single letters to keep the encoded JSON4544  * compact — every byte saved shrinks the printed page count.4645  *47- * Format history:48- *  - v2: single Reed-Solomon block; `i` / `t` are global.49- *  - v3: payload is split into N independent RS stripes so totals can exceed50- *    the GF(2⁸) 256-shard limit. `s` is the 0-based stripe index, `S` is the51- *    total number of stripes, and `i` / `t` are now within-stripe. A v252- *    envelope is equivalent to a v3 envelope with `s=0, S=1`.46+ * The current format splits the payload into N independent RS stripes so47+ * totals can exceed the GF(2⁸) 256-shard limit. `s` is the 0-based stripe48+ * index, `S` is the total number of stripes, and `i` / `t` are within-stripe.5349  */5450 export interface ChunkEnvelope {55-  /** Envelope format version. v2 = single-stripe; v3 = multi-stripe. */56-  v: 2 | 3;51+  /** Envelope format version. */52+  v: 3;5753   /** First 12 chars of `verifyCode` ("PX-XXXX-XXXX"). Lets the scanner reject foreign Codexes. */5854   c: string;59-  /** Stripe index, 0-based. v2 envelopes omit this; treat as 0. */60-  s?: number;61-  /** Total stripes in this Codex. v2 envelopes omit this; treat as 1. */62-  S?: number;55+  /** Stripe index, 0-based. */56+  s: number;57+  /** Total stripes in this Codex. */58+  S: number;6359   /** Chunk index within the stripe, 0-based. */6460   i: number;6561   /** Total chunk count in this stripe (data + parity). */@@ -68,7 +64,7 @@6864   p: boolean;6965   /** Chunk data, base64url-encoded. */7066   d: string;71-  /** First 32 chars (128 bits) of SHA-256(rawChunkBytes), hex. Catches per-chunk corruption with ≤2^-128 collision odds. AUD-004: was 16 chars; legacy Codexes still verify. */67+  /** First 32 chars (128 bits) of SHA-256(rawChunkBytes), hex. Catches per-chunk corruption with ≤2^-128 collision odds. */7268   h: string;7369 }7470 

view file

src/shared/import.test.ts+7 −244
@@ -4,22 +4,8 @@44   base64urlEncode,55   generatePasskeyKeypair,66 } from "../background/passkey-crypto";7-import {8-  exportEntriesToPeachJson,9-  importEntriesFromPeachJson,10-  type ImportableEntry,11-} from "./backup";127 import { detectImportFormat, parse1PasswordJSON, parseImportSource } from "./import";138 14-const SAMPLE_IMPORT_ENTRY: ImportableEntry = {15-  title: "GitHub",16-  username: "june",17-  password: "secret123",18-  url: "https://github.com",19-  notes: "2fa enabled",20-  totp: "otpauth://totp/Peach:GitHub?secret=JBSWY3DPEHPK3PXP&issuer=Peach",21-};22-239 describe("shared import parsing", () => {2410   it("parses Bitwarden JSON login exports", async () => {2511     const result = await parseImportSource(@@ -157,89 +143,6 @@157143     });158144   });159145 160-  it("requires a password before decrypting Peach backups", async () => {161-    const envelope = await exportEntriesToPeachJson([162-      {163-        ...SAMPLE_IMPORT_ENTRY,164-        id: "1",165-        createdAt: 1,166-        modifiedAt: 1,167-      },168-    ], "export-pass");169-170-    const locked = await parseImportSource(JSON.stringify(envelope));171-    expect(locked.format).toBe("peach-json");172-    expect(locked.requiresPassword).toBe(true);173-174-    const unlocked = await parseImportSource(175-      JSON.stringify(envelope),176-      "export-pass"177-    );178-    expect(unlocked.entries).toHaveLength(1);179-    expect(unlocked.entries[0]).toMatchObject({180-      title: "GitHub",181-      username: "june",182-      password: "secret123",183-      url: "https://github.com",184-    });185-  });186-187-  it("rejects the wrong password for Peach backups", async () => {188-    const envelope = await exportEntriesToPeachJson([189-      {190-        ...SAMPLE_IMPORT_ENTRY,191-        id: "1",192-        createdAt: 1,193-        modifiedAt: 1,194-      },195-    ], "export-pass");196-197-    await expect(198-      importEntriesFromPeachJson(JSON.stringify(envelope), "wrong-pass")199-    ).rejects.toThrow("INVALID_EXPORT_PASSWORD");200-  });201-202-  it("restores passkeys from Peach encrypted backups", async () => {203-    const generated = await generatePasskeyKeypair(-7);204-    const envelope = await exportEntriesToPeachJson([205-      {206-        id: "passkey-1",207-        type: "passkey",208-        title: "WebAuthn.io",209-        rpId: "webauthn.io",210-        rpName: "WebAuthn.io",211-        credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),212-        publicKeyCose: base64urlEncode(generated.publicKeyCose),213-        privateKeyJwk: generated.privateKeyJwk,214-        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),215-        userName: "june",216-        userDisplayName: "June",217-        algorithm: -7,218-        signCount: 2,219-        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",220-        backupEligible: true,221-        backupState: false,222-        createdAt: 1,223-        modifiedAt: 1,224-      },225-    ], "export-pass");226-227-    const { entries: restored } = await importEntriesFromPeachJson(228-      JSON.stringify(envelope),229-      "export-pass"230-    );231-232-    expect(restored).toHaveLength(1);233-    expect(restored[0]).toMatchObject({234-      type: "passkey",235-      title: "WebAuthn.io",236-      rpId: "webauthn.io",237-      userName: "june",238-      algorithm: -7,239-      signCount: 2,240-    });241-  });242-243146   it("parses credential exchange passkey imports", async () => {244147     const generated = await generatePasskeyKeypair(-7);245148     const privateKey = await crypto.subtle.importKey(@@ -286,104 +189,18 @@286189     expect(result.format).toBe("credential-exchange-json");287190     expect(result.entries).toHaveLength(1);288191     expect(result.entries[0]).toMatchObject({289-      type: "passkey",192+      type: "login",290193       title: "WebAuthn.io",291-      rpId: "webauthn.io",292-      userName: "june",293-      userDisplayName: "June",294-      signCount: 0,295-    });296-  });297-298-  it("parses a valid peach-vault export and returns correct ImportableEntry[]", async () => {299-    const result = await parseImportSource(300-      JSON.stringify({301-        exportFormat: "peach-vault",302-        exportFormatVersion: 2,303-        exportedAt: "2025-01-15T10:00:00.000Z",304-        exportedAtMs: 1736935200000,305-        entryCount: 2,306-        passkeyPrivateKeysWrapped: false,307-        entries: [308-          {309-            id: "550e8400-e29b-41d4-a716-446655440000",310-            type: "login",311-            modified: 1736935100000,312-            created: 1736930000000,313-            name: "GitHub",314-            favorite: false,315-            tags: [],316-            login: {317-              urls: ["https://github.com"],318-              username: "june",319-              password: "supersecret",320-              totp: {321-                secret: "BASE32SECRET",322-                algorithm: "SHA1",323-                digits: 6,324-                period: 30,325-              },326-              customFields: [],327-            },328-          },329-        ],330-      })331-    );332-333-    expect(result.format).toBe("peach-legacy-json");334-    expect(result.label).toBe("Peach (legacy)");335-    expect(result.entries).toHaveLength(1);336-    expect(result.entries[0]).toMatchObject({337-      title: "GitHub",338-      url: "https://github.com",339194       username: "june",340-      password: "supersecret",341-      totp: "BASE32SECRET",342-      notes: undefined,195+      passkey: {196+        rpId: "webauthn.io",197+        userName: "june",198+        userDisplayName: "June",199+        signCount: 0,200+      },343201     });344-expect(result.totalItems).toBe(1);345-    expect(result.skippedItems).toBe(0);346202   });347203 348-  it("handles missing optional fields without throwing", async () => {349-    const result = await parseImportSource(350-      JSON.stringify({351-        exportFormat: "peach-vault",352-        exportFormatVersion: 2,353-        exportedAt: "2025-01-15T10:00:00.000Z",354-        exportedAtMs: 1736935200000,355-        entryCount: 1,356-        passkeyPrivateKeysWrapped: false,357-        entries: [358-          {359-            id: "1",360-            type: "login",361-            modified: 1736935100000,362-            created: 1736930000000,363-            name: "Minimal Login",364-            favorite: false,365-            tags: [],366-            login: {367-              username: "june",368-              password: "secret",369-            },370-          },371-        ],372-      })373-    );374-375-    expect(result.format).toBe("peach-legacy-json");376-    expect(result.entries).toHaveLength(1);377-    expect(result.entries[0]).toMatchObject({378-      title: "Minimal Login",379-      url: "",380-      username: "june",381-      password: "secret",382-      totp: undefined,383-      notes: undefined,384-    });385-  });386-387204   it("falls back gracefully on malformed JSON", async () => {388205     const result = await parseImportSource("not valid json at all");389206     expect(result.format).toBe("unknown");@@ -405,19 +222,6 @@405222         })406223       )407224     ).toBe("protonpass-json");408-    expect(409-      detectImportFormat(410-        JSON.stringify({411-          type: "peach-export",412-          version: 2,413-          kdf: "PBKDF2-SHA256",414-          salt: "00",415-          nonce: "00",416-          ciphertext: "00",417-          tag: "00",418-        })419-      )420-    ).toBe("peach-json");421225     expect(detectImportFormat("name,url,username,password\nGitHub,https://github.com,june,secret")).toBe("csv");422226     expect(detectImportFormat("totally unrecognized")).toBe("unknown");423227   });@@ -464,47 +268,6 @@464268         notes: "from 1password",465269       },466270     ]);467-  });468-469-  it("parseImportSource returns format 'peach-legacy-json' with correct entries", async () => {470-    const result = await parseImportSource(471-      JSON.stringify({472-        exportFormat: "peach-vault",473-        exportFormatVersion: 2,474-        exportedAt: "2025-01-15T10:00:00.000Z",475-        exportedAtMs: 1736935200000,476-        entryCount: 1,477-        passkeyPrivateKeysWrapped: false,478-        entries: [479-          {480-            id: "1",481-            type: "login",482-            modified: 1736935100000,483-            created: 1736930000000,484-            name: "Test Login",485-            favorite: false,486-            tags: [],487-            login: {488-              urls: ["https://example.com"],489-              username: "user",490-              password: "pass123",491-              totp: { secret: "TEST123", algorithm: "SHA1", digits: 6, period: 30 },492-              customFields: [],493-              notes: "A note",494-            },495-          },496-        ],497-      })498-    );499-500-    expect(result.format).toBe("peach-legacy-json");501-    expect(result.entries).toHaveLength(1);502-    expect(result.entries[0].title).toBe("Test Login");503-    expect(result.entries[0].username).toBe("user");504-    expect(result.entries[0].password).toBe("pass123");505-    expect(result.entries[0].url).toBe("https://example.com");506-    expect(result.entries[0].totp).toBe("TEST123");507-    expect(result.entries[0].notes).toBe("A note");508271   });509272 510273   it("MV-5: parseImportSource rejects an over-limit raw string with IMPORT_TOO_LARGE", async () => {

view file

src/shared/import.ts+26 −140
@@ -14,16 +14,16 @@1414 } from "../background/passkey-crypto";1515 import { parseOtpauthURI } from "../crypto/totp";1616 import { IMPORT_LIMITS, importTooLarge } from "./import-limits";17-import { importEntriesFromPeachJson, type ImportableEntry } from "./backup";17+import type { VaultEntry } from "./types";1818 19+export type ImportableEntry = Omit<VaultEntry, "id" | "createdAt" | "modifiedAt">;20+1921 export type ImportFormat =2022   | "csv"2123   | "1password-json"2224   | "bitwarden-json"2325   | "credential-exchange-json"2426   | "protonpass-json"25-  | "peach-json"26-  | "peach-legacy-json"2727   | "unknown";2828 2929 export interface ImportParseResult {@@ -32,15 +32,10 @@3232   entries: ImportableEntry[];3333   totalItems: number;3434   skippedItems: number;35-  requiresPassword: boolean;36-  /** License key from the backup, present only on Pro encrypted JSON exports. */37-  licenseKey?: string;38-  licenseActivatedAt?: number;3935 }4036 4137 export async function parseImportSource(42-  raw: string,43-  password?: string38+  raw: string4439 ): Promise<ImportParseResult> {4540   // MV-5: bound the input before any JSON.parse / CSV parse allocates4641   // proportional structures. Covers file picks AND pasted strings.@@ -60,7 +55,6 @@6055         entries,6156         totalItems,6257         skippedItems: Math.max(0, totalItems - entries.length),63-        requiresPassword: false,6458       };6559     }6660     case "1password-json": {@@ -71,7 +65,6 @@7165         entries,7266         totalItems: entries.length,7367         skippedItems: 0,74-        requiresPassword: false,7568       };7669     }7770     case "bitwarden-json": {@@ -82,7 +75,6 @@8275         entries,8376         totalItems,8477         skippedItems: Math.max(0, totalItems - entries.length),85-        requiresPassword: false,8678       };8779     }8880     case "credential-exchange-json": {@@ -93,7 +85,6 @@9385         entries,9486         totalItems,9587         skippedItems: Math.max(0, totalItems - entries.length),96-        requiresPassword: false,9788       };9889     }9990     case "protonpass-json": {@@ -104,43 +95,8 @@10495         entries,10596         totalItems,10697         skippedItems: Math.max(0, totalItems - entries.length),107-        requiresPassword: false,10898       };10999     }110-    case "peach-legacy-json": {111-      const { entries, totalItems, skippedItems } = parsePeachLegacyJSON(raw);112-      return {113-        format,114-        label: "Peach (legacy)",115-        entries,116-        totalItems,117-        skippedItems,118-        requiresPassword: false,119-      };120-    }121-    case "peach-json": {122-      if (!password) {123-        return {124-          format,125-          label: "Peach encrypted backup",126-          entries: [],127-          totalItems: 0,128-          skippedItems: 0,129-          requiresPassword: true,130-        };131-      }132-133-      const result = await importEntriesFromPeachJson(raw, password);134-      return {135-        format,136-        label: "Peach encrypted backup",137-        entries: result.entries,138-        totalItems: result.entries.length,139-        skippedItems: 0,140-        requiresPassword: false,141-        ...(result.licenseKey ? { licenseKey: result.licenseKey, licenseActivatedAt: result.licenseActivatedAt } : {}),142-      };143-    }144100     default:145101       return {146102         format: "unknown",@@ -148,7 +104,6 @@148104         entries: [],149105         totalItems: 0,150106         skippedItems: 0,151-        requiresPassword: false,152107       };153108   }154109 }@@ -158,9 +113,6 @@158113   if (trimmed.startsWith("{") || trimmed.startsWith("[")) {159114     try {160115       const data = JSON.parse(trimmed);161-      if (hasPeachBackupShape(data)) return "peach-json";162-      if ((data as any)?.exportFormat === "peach-vault")163-        return "peach-legacy-json";164116       if (hasCredentialExchangeShape(data)) return "credential-exchange-json";165117       if (has1PasswordShape(data)) return "1password-json";166118       if (hasBitwardenShape(data)) return "bitwarden-json";@@ -420,22 +372,28 @@420372         try {421373           const pkcs8 = base64urlDecode(credential.key);422374           const imported = await importPasskeyPrivateKey(pkcs8);375+          const createdAt = Date.now();423376           entries.push({424-            type: "passkey",377+            type: "login",425378             title: firstNonEmptyString(item.title, credential.rpId),426-            rpId: credential.rpId,427-            rpName: firstNonEmptyString(item.title, credential.rpId),428-            credentialId: credential.credentialId,429-            publicKeyCose: base64urlEncode(imported.publicKeyCose),430-            privateKeyJwk: imported.privateKeyJwk,431-            userHandle: credential.userHandle,432-            userName: credential.username,433-            userDisplayName: credential.userDisplayName,434-            algorithm: imported.algorithm,435-            signCount: 0,436-            aaguid: "AAAAAAAAAAAAAAAAAAAAAA",437-            backupEligible: true,438-            backupState: false,379+            username: credential.username,380+            url: `https://${credential.rpId}`,381+            passkey: {382+              rpId: credential.rpId,383+              rpName: firstNonEmptyString(item.title, credential.rpId),384+              credentialId: credential.credentialId,385+              publicKeyCose: base64urlEncode(imported.publicKeyCose),386+              privateKeyJwk: imported.privateKeyJwk,387+              userHandle: credential.userHandle,388+              userName: credential.username,389+              userDisplayName: credential.userDisplayName,390+              algorithm: imported.algorithm,391+              signCount: 0,392+              aaguid: "AAAAAAAAAAAAAAAAAAAAAA",393+              backupEligible: true,394+              backupState: false,395+              createdAt,396+            },439397           });440398         } catch {441399           // Skip passkeys whose private key we cannot import into WebCrypto.@@ -447,56 +405,6 @@447405   return { entries, totalItems };448406 }449407 450-function parsePeachLegacyJSON(raw: string): {451-  entries: ImportableEntry[];452-  totalItems: number;453-  skippedItems: number;454-} {455-  const data = JSON.parse(raw) as {456-    exportFormat: string;457-    entries: Array<{458-      id: string;459-      type: string;460-      modified: number;461-      created: number;462-      name: string;463-      favorite: boolean;464-      tags: string[];465-      login?: {466-        urls?: string[];467-        username?: string;468-        password?: string;469-        totp?: {470-          secret: string;471-          algorithm: string;472-          digits: number;473-          period: number;474-        };475-        customFields?: unknown[];476-        notes?: string;477-      };478-    }>;479-  };480-481-  const totalItems = data.entries.length;482-  const filtered = data.entries.filter((e) => e.type === "login");483-  const skippedItems = totalItems - filtered.length;484-485-  return {486-    totalItems,487-    skippedItems,488-    entries: filtered.map((entry) => ({489-      type: "login" as const,490-      title: entry.name ?? "Untitled",491-      url: entry.login?.urls?.[0] ?? "",492-      username: entry.login?.username ?? "",493-      password: entry.login?.password ?? "",494-      totp: entry.login?.totp?.secret ?? undefined,495-      notes: entry.login?.notes ?? undefined,496-    })),497-  };498-}499-500408 function has1PasswordShape(value: unknown): boolean {501409   if (!value || typeof value !== "object") return false;502410   const v = value as { accounts?: unknown[]; items?: unknown[] };@@ -532,28 +440,6 @@532440   );533441 }534442 535-function hasPeachBackupShape(value: unknown): boolean {536-  if (!value || typeof value !== "object") return false;537-  const envelope = value as Partial<{538-    type: string;539-    version: number;540-    kdf: string;541-    salt: string;542-    nonce: string;543-    ciphertext: string;544-    tag: string;545-  }>;546-  return (547-    envelope.type === "peach-export" &&548-    envelope.version === 2 &&549-    envelope.kdf === "PBKDF2-SHA256" &&550-    typeof envelope.salt === "string" &&551-    typeof envelope.nonce === "string" &&552-    typeof envelope.ciphertext === "string" &&553-    typeof envelope.tag === "string"554-  );555-}556-557443 function pickFirstField(558444   row: Record<string, string>,559445   candidates: string[]@@ -575,8 +461,8 @@575461   const totp = parsed?.secret ?? value.trim();576462   if (!totp) return undefined;577463   if (!parsed) return { totp };578-  // PCH-01-019: persist only non-default params so legacy bare-seed entries579-  // stay byte-identical and keep generating the same codes.464+  // Persist only non-default params so bare-seed entries stay compact while465+  // generating the RFC-default codes.580466   return {581467     totp,582468     totpAlgorithm: parsed.algorithm && parsed.algorithm !== "SHA1" ? parsed.algorithm : undefined,

view file

src/shared/types.ts+6 −63
@@ -69,11 +69,7 @@6969 7070 export interface VaultEntry {7171   id: string; // crypto.randomUUID()72-  /**73-   * Entry type. As of v2.11.0 passkeys are stored on `type:"login"` entries74-   * via the `passkey` sub-object — `"passkey"` here is a legacy value still75-   * accepted on read but rewritten to `"login"` on next vault save.76-   */72+  /** Entry type. Passkey credentials are carried in the nested `passkey` field. */7773   type?: "login" | "note" | "passkey" | "card";7874   title: string;7975   username?: string; // only for login entries@@ -90,9 +86,7 @@9086   totp?: string; // base32 seed, stored encrypted — only for login entries9187   /**9288    * Optional TOTP parameters parsed from an otpauth:// URI. Absent fields fall93-   * back to RFC defaults (SHA-1 / 6 digits / 30-second period), so legacy entries94-   * carrying only a bare `totp` seed keep generating identical codes95-   * (PCH-01-019). Login entries only.89+   * back to RFC defaults (SHA-1 / 6 digits / 30-second period). Login entries only.9690    */9791   totpAlgorithm?: "SHA1" | "SHA256" | "SHA512";9892   totpDigits?: number;@@ -101,8 +95,7 @@10195   breach?: number; // HIBP exposure count; 0 or undefined = clean — only for login entries10296   /**10397    * When the password field was last changed. Set on first-write of `password`104-   * and refreshed on every subsequent change. Defaults to `createdAt` when105-   * legacy entries are read by the advanced health analyser.98+   * and refreshed on every subsequent change.10699    */107100   passwordChangedAt?: number;108101   /**@@ -120,26 +113,6 @@120113   tags?: string[];121114   /** Passkey credential attached to this entry, if any. */122115   passkey?: PasskeyData;123-  // ── Legacy passkey flat fields (pre-v2.11.0). Populated by vault read124-  // migration so that the old import/sanitize paths keep typing; new code125-  // should always read `entry.passkey?.*` instead. The migration drops these126-  // on next vault save. Do not introduce new readers of these fields.127-  rpId?: string;128-  rpName?: string;129-  credentialId?: string;130-  publicKeyCose?: string;131-  privateKeyJwk?: JsonWebKey;132-  userHandle?: string;133-  userName?: string;134-  userDisplayName?: string;135-  algorithm?: -7 | -8;136-  signCount?: number;137-  aaguid?: string;138-  backupEligible?: boolean;139-  backupState?: boolean;140-  lastUsedAt?: number;141-  browser?: "chrome" | "firefox";142-  transports?: string[];143116   // Card entry fields144117   cardholderName?: string;145118   cardNumber?: string;       // full PAN, stored encrypted in vault@@ -151,50 +124,21 @@151124   last4?: string;152125 }153126 154-/**155- * Returns true when an entry has a passkey credential attached. After v2.11.0156- * migration this is the canonical check; pre-migration entries with a flat157- * `rpId`/`credentialId` shape get rewritten by the vault read pass before158- * any consumer sees them.159- */127+/** Returns true when an entry has a passkey credential attached. */160128 export function hasPasskey(entry: VaultEntry): boolean {161129   return entry.passkey !== undefined && entry.passkey.credentialId.length > 0;162130 }163131 164-/**165- * Returns true when an entry is "passkey-only" — has a passkey credential166- * but no password to fill into a form. Login fields hide for these entries167- * and the inline autofill selector filters them out (signing in via the168- * passkey is a separate WebAuthn flow). Username may still be populated169- * (the migration mirrors passkey.userName onto the entry's username so the170- * domain-index/search keep working) and is not part of this discriminator.171- */132+/** Returns true when an entry is passkey-only and has no password to fill. */172133 export function isPasskeyOnly(entry: VaultEntry): boolean {173134   // An entry is "passkey only" when it holds SOLELY a passkey credential —174135   // no password, no TOTP, and no dedicated email. Username is intentionally175-  // excluded (the migration copies passkey.userName onto username for search).136+  // excluded because it is useful for searching passkey credentials.176137   // Entries with passkey + TOTP or passkey + email are NOT passkey-only: they177138   // carry fillable credentials and must show the full login-fields UI.178139   return hasPasskey(entry) && !entry.password && !entry.totp && !entry.email;179140 }180141 181-export interface LegacyVaultEntry {182-  id: string;183-  title: string;184-  username: string;185-  password: string;186-  url: string;187-  totp?: string;188-  totpAlgorithm?: "SHA1" | "SHA256" | "SHA512";189-  totpDigits?: number;190-  totpPeriod?: number;191-  notes?: string;192-  breach?: number;193-  created: number;194-  updated: number;195-  deletedAt?: number;196-}197-198142 export interface PendingPasskeyRequest {199143   requestId: number;200144   type: "create" | "get";@@ -230,7 +174,6 @@230174   ciphertext: string; // hex --- AES-256-GCM output231175   tag: string; // hex --- 16-byte GCM auth tag232176   // Recovery escrow: root key wrapped under recovery words via AES-256-GCM.233-  // Absent on vaults created before v2.12 (migrateToRecovery adds them on first unlock).234177   recovery_salt?: string; // hex --- 16 bytes, CSPRNG, HKDF salt for recovery key235178   recovery_wrapped_root_key?: string; // hex --- AES-256-GCM ciphertext of 32-byte root key236179   recovery_nonce?: string; // hex --- 12 bytes, fresh nonce for the above

view file