63f03f2256df
refactor: drop pre-live compatibility formats (v5.0.0)
- time
- 2026-08-29 21:02 UTC · 1 month ago
- machine
- koh2
- parent
- 68b85733a708
↓ peach-browser-63f03f22.tar.gzatlas at this save
files changed40 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 −63manifest.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": {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",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",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(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;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 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 });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;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 });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 () => {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 };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");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", () => {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") {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", () => {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.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 });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 });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,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 });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();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);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", () => {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 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 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(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,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 <buttonsrc/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":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);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 });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",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;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-});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-});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-}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 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 () => {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,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