123456789101112import { deriveKey, generateSalt } from "../crypto/argon2";13import { extractDomain } from "../shared/domain";14import {15 rootKeyToWords,16 wordsToRootKeyMaterial,17 deriveRecoveryKey,18} from "../shared/recovery-words";19import type {20 AccentColorName,21 LegacyVaultEntry,22 ManualSummonRecord,23 SavedIdentity,24 StoredVault,25 VaultEntry,26 VaultHistory,27 VaultSnapshot,28} from "../shared/types";29import { decrypt, encrypt, zeroKey } from "./aes";30import { clearAutoLock, touchAutoLock } from "./alarms";3132type EntryDraft = Omit<33 VaultEntry,34 "id" | "createdAt" | "modifiedAt" | "deletedAt"35>;363738394041const SLOT_A = "peach_vault_a";42const SLOT_B = "peach_vault_b";434445const LEGACY_MAIN = "peach_vault";46const LEGACY_DECOY = "peach_decoy_vault";47const LEGACY_DURESS_FLAG = "peach_duress";48495051const PAD_BLOCK = 1024;5253let derivedKey: Uint8Array | null = null;5455565758let rootKey: Uint8Array | null = null;59let entries: VaultEntry[] = [];60let domainIndex: Map<string, VaultEntry[]> = new Map();61let vaultMode: "real" | "decoy" = "real";62636465let duressConfigured = false;6667let migrationRan = false;6869707172737475767778let lastUnlockFailureReason: string | null = null;7980export function consumeLastUnlockFailureReason(): string | null {81 const r = lastUnlockFailureReason;82 lastUnlockFailureReason = null;83 return r;84}858687888990export type TextSize = "small" | "medium" | "large";9192export interface VaultSettings {93 accentColor?: AccentColorName;94 theme?: "dark" | "light";95 autoLockMinutes?: number;96 clipboardClearSeconds?: number;97 lockOnClose?: boolean;98 breachMonitoring?: boolean;99 passkeyProviderEnabled?: boolean;100 trashRetentionDays?: number;101 manualSummonDomains?: Record<string, ManualSummonRecord>;102 declinedCardFingerprints?: string[];103 104105106107108 textSize?: TextSize;109 110111112113114 reduceMotion?: boolean;115 116117118119120 biometricsEnabled?: boolean;121 122123124125126127128 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;129}130131let settings: VaultSettings = {};132133export function getSettings(): VaultSettings {134 return settings;135}136137export async function ensureSettingsLoaded(): Promise<VaultSettings> {138 await loadSettings();139 return settings;140}141142export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {143 await loadSettings();144 settings = { ...settings, ...patch };145 await persistSettings();146}147148async function persistSettings(): Promise<void> {149 try {150 await chrome.storage.local.set({ peach_settings: settings });151 } catch (err) {152 throw mapStorageError(err);153 }154}155156function mapStorageError(err: unknown): Error {157 const message = err instanceof Error ? err.message : String(err);158 if (/QUOTA_BYTES|quota/i.test(message)) {159 return new Error("STORAGE_QUOTA_EXCEEDED");160 }161 return err instanceof Error ? err : new Error(message);162}163164async function loadSettings(): Promise<void> {165 const result = await chrome.storage.local.get("peach_settings");166 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};167}168169170171172173174function fireSyncTrigger(reason: "vault_change"): void {175 void import("./sync-trigger")176 .then((mod) => mod.triggerSyncIfReachable({ reason }))177 .catch(() => {178 179 });180}181182183184185186export async function checkBreach(password: string): Promise<number> {187 188 189 const encoder = new TextEncoder();190 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));191 const hashHex = Array.from(new Uint8Array(hashBuffer))192 .map((b) => b.toString(16).padStart(2, "0"))193 .join("")194 .toUpperCase();195196 const prefix = hashHex.slice(0, 5);197 const suffix = hashHex.slice(5);198199 try {200 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);201 if (!res.ok) return 0;202 const text = await res.text();203 const match = text.split("\r\n").find((l) => l.startsWith(suffix));204 return match ? parseInt(match.split(":")[1], 10) : 0;205 } catch {206 return 0; 207 }208}209210export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {211 const fingerprint = await cardFingerprint(last4, expMonth, expYear);212 await loadSettings();213 settings = {214 ...settings,215 declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],216 };217 await persistSettings();218}219220export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {221 await loadSettings();222 const fingerprint = await cardFingerprint(last4, expMonth, expYear);223 return (settings.declinedCardFingerprints ?? []).includes(fingerprint);224}225226async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {227 const encoder = new TextEncoder();228 const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));229 return Array.from(new Uint8Array(buf).slice(0, 8))230 .map((b) => b.toString(16).padStart(2, "0"))231 .join("");232}233234function hex(buf: Uint8Array): string {235 return Array.from(buf)236 .map((b) => b.toString(16).padStart(2, "0"))237 .join("");238}239240function unhex(s: string): Uint8Array {241 const out = new Uint8Array(s.length / 2);242 for (let i = 0; i < s.length; i += 2) {243 out[i / 2] = parseInt(s.slice(i, i + 2), 16);244 }245 return out;246}247248249250251252253254255256257258259async function generateAndWrapRootKey(): Promise<{260 rawRootKey: string; 261 recoverySalt: string;262 wrappedRootKey: string;263 wrappedNonce: string;264 wrappedTag: string;265}> {266 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));267268 269 const words = await rootKeyToWords(rootKeyBytes);270 const wordMaterial = await wordsToRootKeyMaterial(words);271 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));272 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);273274 275 276 const nonce = crypto.getRandomValues(new Uint8Array(12));277 const encoded = new TextEncoder().encode(hex(rootKeyBytes));278 const raw = await crypto.subtle.encrypt(279 {280 name: "AES-GCM",281 iv: nonce,282 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,283 tagLength: 128,284 },285 recoveryKey,286 encoded287 );288 289 const rawU8 = new Uint8Array(raw);290 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));291 const tag = hex(rawU8.slice(rawU8.length - 16));292293 return {294 rawRootKey: hex(rootKeyBytes),295 recoverySalt: hex(recoverySalt),296 wrappedRootKey: ciphertext,297 wrappedNonce: hex(nonce),298 wrappedTag: tag,299 };300}301302303304305306307export async function unwrapRootKeyForRecovery(308 wrappedRootKey: string,309 wrappedNonce: string,310 wrappedTag: string,311 recoverySaltHex: string,312 words: string[]313): Promise<void> {314 const wordMaterial = await wordsToRootKeyMaterial(words);315 const recoverySalt = unhex(recoverySaltHex);316 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);317318 319 320 321 322 323 const nonceRaw = unhex(wrappedNonce);324 const ciphertextRaw = unhex(wrappedRootKey);325 const tagRaw = unhex(wrappedTag);326 327 328 const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;329 const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);330 combined.set(ciphertextRaw, 0);331 combined.set(tagRaw, ciphertextRaw.length);332 const raw = await crypto.subtle.decrypt(333 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },334 recoveryKey,335 combined336 );337 const decryptedHex = new TextDecoder().decode(raw);338 rootKey = unhex(decryptedHex);339}340341342343344345346347348349350export async function rewrapRootKeyForPassword(351 newDerivedKey: Uint8Array,352 _newSalt: Uint8Array353): Promise<void> {354 if (!rootKey) throw new Error("root key not set");355 const slotA = await loadSlot(SLOT_A);356 if (!slotA) throw new Error("missing slot A");357 const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);358 await writeSlot(SLOT_A, { ...slotA, ...pwRoot });359}360361362363364365366async function reencryptVaultForNewKey(367 newKey: Uint8Array,368 slotA: StoredVault369): Promise<void> {370 const plaintext = encodePayload(entries, duressConfigured);371 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");372 await writeSlot(SLOT_A, {373 ...slotA,374 nonce,375 ciphertext,376 tag,377 });378}379380381382383384385export async function changePasswordWithNewKey(386 newSalt: Uint8Array,387 newKey: Uint8Array388): Promise<void> {389 const slotA = await loadSlot(SLOT_A);390 if (!slotA) throw new Error("missing slot A");391 392 393 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });394 if (derivedKey) zeroKey(derivedKey);395 derivedKey = newKey;396 397 398 399 400 if (rootKey) {401 const slotAfter = await loadSlot(SLOT_A);402 if (slotAfter) {403 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);404 await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });405 }406 }407 if (!duressConfigured) {408 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));409 }410 await stampLocalVaultMutation();411}412413414415416417418export { deriveKey, generateSalt };419420export async function loadSlotA(): Promise<StoredVault | null> {421 return loadSlot(SLOT_A);422}423424425426427428429430431432433434435436async function deriveRootKeyFromDerivedKey(437 derivedKeyBytes: Uint8Array,438 slotA: StoredVault,439): Promise<Uint8Array | null> {440 if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {441 return null;442 }443 try {444 const rootHex = await decrypt(445 derivedKeyBytes,446 slotA.pw_root_nonce,447 slotA.pw_root_ciphertext,448 slotA.pw_root_tag,449 "pw_root_v1",450 );451 return unhex(rootHex);452 } catch {453 return null;454 }455}456457458459async function wrapRootKeyForDerivedKey(460 derivedKeyBytes: Uint8Array,461 rootKeyBytes: Uint8Array,462): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {463 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");464 return {465 pw_root_ciphertext: ciphertext,466 pw_root_nonce: nonce,467 pw_root_tag: tag,468 };469}470471472473474475476477478export async function migrateToRecovery(): Promise<void> {479 if (!derivedKey || vaultMode !== "real") {480 throw new Error("vault must be unlocked in real mode");481 }482 const slotA = await loadSlot(SLOT_A);483 if (!slotA) throw new Error("missing slot A");484 const hasRecoveryEscrow =485 !!slotA.recovery_salt && !!slotA.recovery_wrapped_root_key;486 const hasPwRoot = !!slotA.pw_root_ciphertext && !!slotA.pw_root_nonce;487 if (hasRecoveryEscrow && hasPwRoot) return; 488489 490 491 492 493 494 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =495 await generateAndWrapRootKey();496 rootKey = unhex(rawRootKey);497498 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);499 const nextSlotA: StoredVault = {500 ...slotA,501 recovery_salt: recoverySalt,502 recovery_wrapped_root_key: wrappedRootKey,503 recovery_nonce: wrappedNonce,504 recovery_tag: wrappedTag,505 ...pwRoot,506 };507 await writeSlot(SLOT_A, nextSlotA);508 await stampLocalVaultMutation();509}510511512513514515516export async function getRecoveryWords(): Promise<string[]> {517 if (!rootKey) {518 throw new Error("recovery not set up — vault may need migration");519 }520 return rootKeyToWords(rootKey);521}522523async function loadSlot(key: string): Promise<StoredVault | null> {524 const result = await chrome.storage.local.get(key);525 return (result as Record<string, StoredVault | undefined>)[key] ?? null;526}527528async function writeSlot(key: string, vault: StoredVault): Promise<void> {529 try {530 await chrome.storage.local.set({ [key]: vault });531 } catch (err) {532 throw mapStorageError(err);533 }534}535536537538539540541542543let lastEntryTimestamp = 0;544function monotonicEntryTimestamp(currentModifiedAt?: number): number {545 const base = Math.max(546 Date.now(),547 (currentModifiedAt ?? 0) + 1,548 lastEntryTimestamp + 1,549 );550 lastEntryTimestamp = base;551 return base;552}553554async function stampLocalVaultMutation(): Promise<void> {555 const raw = await chrome.storage.local.get("peach_last_modified_at");556 const previous = Number(557 (raw as Record<string, unknown>).peach_last_modified_at,558 );559 const next = Math.max(560 Date.now(),561 Number.isFinite(previous) ? previous + 1 : 0,562 );563 await chrome.storage.local.set({564 peach_last_modified_at: String(next),565 });566}567568569570571572573async function ensureMigrated(): Promise<void> {574 if (migrationRan) return;575 migrationRan = true;576577 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];578 const raw = await chrome.storage.local.get(keys);579 const slotAExists = raw[SLOT_A] !== undefined;580 const slotBExists = raw[SLOT_B] !== undefined;581 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;582 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;583584 585 if (!slotAExists && !legacyMain) return;586587 588 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;589590 let didMigrate = false;591 if (legacyMain && !slotAExists) {592 await writeSlot(SLOT_A, legacyMain);593 didMigrate = true;594 }595596 597 if (!slotBExists) {598 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;599 if (legacyDecoy) {600 await writeSlot(SLOT_B, legacyDecoy);601 didMigrate = true;602 } else if (target) {603 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));604 didMigrate = true;605 }606 }607608 const toRemove: string[] = [];609 if (legacyMain) toRemove.push(LEGACY_MAIN);610 if (legacyDecoy) toRemove.push(LEGACY_DECOY);611 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);612 if (toRemove.length > 0) {613 await chrome.storage.local.remove(toRemove);614 didMigrate = true;615 }616 if (didMigrate) {617 await stampLocalVaultMutation();618 }619}620621622623624625626627628629630631function padPlaintext(json: string): string {632 const encoded = new TextEncoder().encode(json);633 const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);634 const padLen = target - encoded.length;635 return json + " ".repeat(padLen);636}637638639640641642643644645646647648const CSPRNG_CHUNK = 65_536;649650function fillRandom(buf: Uint8Array): Uint8Array {651 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {652 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);653 const chunk = new Uint8Array(end - offset);654 crypto.getRandomValues(chunk);655 buf.set(chunk, offset);656 }657 return buf;658}659660function generateNoiseBlob(ciphertextBytes: number): StoredVault {661 const salt = crypto.getRandomValues(new Uint8Array(16));662 const nonce = crypto.getRandomValues(new Uint8Array(12));663 const cipher = fillRandom(new Uint8Array(ciphertextBytes));664 const tag = crypto.getRandomValues(new Uint8Array(16));665 return {666 version: 1,667 salt: hex(salt),668 nonce: hex(nonce),669 ciphertext: hex(cipher),670 tag: hex(tag),671 };672}673674675676677678type VaultPayload = {679 entries: VaultEntry[];680 duressConfigured?: boolean;681};682683type DecryptedPayload = {684 entries: VaultEntry[];685 duressConfigured: boolean;686 687688689690691692693 migratedFromLegacy: boolean;694};695696function encodePayload(entries: VaultEntry[], duress: boolean): string {697 const envelope: VaultPayload = { entries, duressConfigured: duress };698 return padPlaintext(JSON.stringify(envelope));699}700701function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {702 if ("created" in raw || "updated" in raw) return true;703 const flat = raw as Partial<VaultEntry>;704 if (flat.type === "passkey") return true;705 706 if (flat.rpId !== undefined && flat.passkey === undefined) return true;707 return false;708}709710function decodePayload(plain: string): DecryptedPayload {711 const parsed = JSON.parse(plain) as712 | Array<VaultEntry | LegacyVaultEntry>713 | VaultPayload;714715 const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];716 const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);717718 const normalized = rawEntries.map(normalizeEntry);719 const { merged } = mergePasskeyOnlyIntoLogins(normalized);720721 if (Array.isArray(parsed)) {722 return {723 entries: normalized,724 duressConfigured: false,725 migratedFromLegacy: true, 726 };727 }728729 return {730 entries: normalized,731 duressConfigured: parsed.duressConfigured === true,732 migratedFromLegacy: flatMigrationNeeded || merged,733 };734}735736737738739740741742743744745746747748749750751752753754755756757758759function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {760 let merged = false;761 const now = Date.now();762763 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);764 const byRpId = new Map<string, VaultEntry[]>();765 for (const e of candidates) {766 const rpId = e.passkey!.rpId;767 const list = byRpId.get(rpId);768 if (list) list.push(e);769 else byRpId.set(rpId, [e]);770 }771772 for (const [rpId, group] of byRpId) {773 if (group.length === 0) continue;774 group.sort((a, b) => {775 const aT =776 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;777 const bT =778 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;779 return bT - aT;780 });781 const winner = group[0]!;782783 784 785 786 const loginTarget = entries.find((e) => {787 if (e.deletedAt || e.passkey || e.type !== "login") return false;788 if (e.url && extractDomain(e.url) === rpId) return true;789 for (const extra of e.additionalUrls ?? []) {790 if (extractDomain(extra) === rpId) return true;791 }792 return false;793 });794795 if (loginTarget) {796 loginTarget.passkey = winner.passkey;797 loginTarget.modifiedAt = now;798 for (const dup of group) {799 if (!dup.deletedAt) {800 dup.deletedAt = now;801 dup.modifiedAt = now;802 }803 }804 merged = true;805 } else if (group.length > 1) {806 for (let i = 1; i < group.length; i += 1) {807 const dup = group[i]!;808 if (!dup.deletedAt) {809 dup.deletedAt = now;810 dup.modifiedAt = now;811 }812 }813 merged = true;814 }815 }816817 return { merged };818}819820821822823824825826function hasValidVaultSchema(v: unknown): v is StoredVault {827 if (typeof v !== "object" || v === null) return false;828 const r = v as Record<string, unknown>;829 return (830 r.version === 1 &&831 typeof r.salt === "string" &&832 typeof r.nonce === "string" &&833 typeof r.ciphertext === "string" &&834 typeof r.tag === "string"835 );836}837838export type TryDecryptResult =839 | { status: "ok"; payload: DecryptedPayload }840 | { status: "wrong_key" }841 | { status: "corrupted"; reason: string };842843844845846847848export async function tryDecryptSlot(849 vault: StoredVault,850 key: Uint8Array,851 aadTag?: string,852): Promise<DecryptedPayload | null> {853 const res = await tryDecryptSlotResult(vault, key, aadTag);854 return res.status === "ok" ? res.payload : null;855}856857export async function tryDecryptSlotResult(858 vault: StoredVault,859 key: Uint8Array,860 aadTag?: string,861): Promise<TryDecryptResult> {862 if (!hasValidVaultSchema(vault)) {863 return { status: "corrupted", reason: "invalid_slot_schema" };864 }865 let plain: string;866 try {867 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);868 } catch {869 return { status: "wrong_key" };870 }871 try {872 const parsed = JSON.parse(plain) as unknown;873 if (typeof parsed !== "object" || parsed === null) {874 return { status: "corrupted", reason: "invalid_payload_shape" };875 }876 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {877 return { status: "corrupted", reason: "invalid_payload_shape" };878 }879 return { status: "ok", payload: decodePayload(plain) };880 } catch {881 return { status: "corrupted", reason: "invalid_payload_json" };882 }883}884885886887888889function buildDomainIndexSync(all: VaultEntry[]): void {890 domainIndex = new Map();891892 for (const entry of all) {893 if (entry.deletedAt) continue;894 if (entry.type === "note" || entry.type === "card") continue;895896 897 898 899 900 const domains = new Set<string>();901 const primary = extractDomain(entry.url ?? "");902 if (primary) domains.add(primary);903 for (const extra of entry.additionalUrls ?? []) {904 const d = extractDomain(extra);905 if (d) domains.add(d);906 }907 if (domains.size === 0) continue;908909 for (const domain of domains) {910 const bucket = domainIndex.get(domain) ?? [];911 bucket.push(entry);912 domainIndex.set(domain, bucket);913 }914 }915}916917export async function createVault(password: string): Promise<void> {918 await ensureMigrated();919920 const salt = generateSalt();921 derivedKey = await deriveKey(password, salt);922 entries = [];923 vaultMode = "real";924 duressConfigured = false;925 buildDomainIndexSync(entries);926927 928 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =929 await generateAndWrapRootKey();930 rootKey = unhex(rawRootKey);931932 const plaintext = encodePayload(entries, duressConfigured);933 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");934935 936 937 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);938939 const slotA: StoredVault = {940 version: 1,941 salt: hex(salt),942 nonce,943 ciphertext,944 tag,945 recovery_salt: recoverySalt,946 recovery_wrapped_root_key: wrappedRootKey,947 recovery_nonce: wrappedNonce,948 recovery_tag: wrappedTag,949 ...pwRoot,950 };951 await writeSlot(SLOT_A, slotA);952953 954 955 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));956 await stampLocalVaultMutation();957958 touchAutoLock();959}960961export async function hasVault(): Promise<boolean> {962 await ensureMigrated();963 return (await loadSlot(SLOT_A)) !== null;964}965966967968969970971export async function isDuressEnabled(): Promise<boolean> {972 return vaultMode === "real" && duressConfigured;973}974975export async function setupDuress(duressPassword: string): Promise<void> {976 if (!derivedKey || vaultMode !== "real") {977 throw new Error("vault must be unlocked in real mode");978 }979 await ensureMigrated();980981 982 983 984 const slotA = await loadSlot(SLOT_A);985 if (!slotA) throw new Error("missing slot A");986987 const decoySalt = generateSalt();988 const decoyKey = await deriveKey(duressPassword, decoySalt);989 try {990 991 992 const targetBytes = slotA.ciphertext.length / 2;993 let plaintext = encodePayload([], false);994 const encoded = new TextEncoder().encode(plaintext);995 if (encoded.length < targetBytes) {996 plaintext = plaintext + " ".repeat(targetBytes - encoded.length);997 }998 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");999 await writeSlot(SLOT_B, {1000 version: 1,1001 salt: hex(decoySalt),1002 nonce,1003 ciphertext,1004 tag,1005 });1006 } finally {1007 zeroKey(decoyKey);1008 }10091010 1011 1012 duressConfigured = true;1013 await persistSlotA();1014}10151016export async function disableDuress(): Promise<void> {1017 if (!derivedKey || vaultMode !== "real") {1018 throw new Error("vault must be unlocked in real mode");1019 }1020 await ensureMigrated();10211022 1023 1024 1025 const slotA = await loadSlot(SLOT_A);1026 if (slotA) {1027 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1028 }10291030 duressConfigured = false;1031 await persistSlotA();1032}10331034103510361037103810391040export async function verifyMainPassword(password: string): Promise<boolean> {1041 await ensureMigrated();1042 const slotA = await loadSlot(SLOT_A);1043 if (!slotA) return false;1044 const key = await deriveKey(password, unhex(slotA.salt));1045 try {1046 return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1047 } finally {1048 zeroKey(key);1049 }1050}10511052export async function unlock(password: string): Promise<boolean> {1053 await ensureMigrated();10541055 1056 1057 1058 1059 1060 const [slotA, slotB] = await Promise.all([1061 loadSlot(SLOT_A),1062 loadSlot(SLOT_B),1063 ]);10641065 1066 const [keyA, keyB] = await Promise.all([1067 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),1068 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),1069 ]);10701071 1072 1073 1074 const [resultA, resultB] = await Promise.all([1075 keyA && slotA1076 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1077 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1078 keyB && slotB1079 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1080 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1081 ]);1082 const payloadA = resultA.status === "ok" ? resultA.payload : null;1083 const payloadB = resultB.status === "ok" ? resultB.payload : null;10841085 if (payloadA && keyA) {1086 derivedKey = keyA;1087 void saveSessionKey(keyA, "real");1088 vaultMode = "real";1089 entries = payloadA.entries;1090 duressConfigured = payloadA.duressConfigured;1091 buildDomainIndexSync(entries);1092 await loadSettings();1093 touchAutoLock();1094 if (keyB) zeroKey(keyB);10951096 1097 if (slotA) {1098 const needsRecoverySetup =1099 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;1100 if (!needsRecoverySetup) {1101 rootKey = await deriveRootKeyFromDerivedKey(keyA, slotA);1102 }1103 1104 1105 1106 1107 if (needsRecoverySetup || !rootKey) {1108 try {1109 await migrateToRecovery();1110 if (needsRecoverySetup) {1111 1112 1113 1114 await chrome.storage.local.set({ peach_recovery_pending: '1' });1115 }1116 } catch (migrationErr) {1117 console.warn('[Peach] recovery migration failed:', migrationErr);1118 }1119 }1120 }11211122 1123 1124 1125 if (payloadA.migratedFromLegacy) {1126 await persistSlotA();1127 }1128 return true;1129 }11301131 if (payloadB && keyB) {1132 derivedKey = keyB;1133 void saveSessionKey(keyB, "decoy");1134 vaultMode = "decoy";1135 entries = payloadB.entries;1136 duressConfigured = false;1137 buildDomainIndexSync(entries);1138 await loadSettings();1139 touchAutoLock();1140 if (keyA) zeroKey(keyA);1141 return true;1142 }11431144 if (keyA) zeroKey(keyA);1145 if (keyB) zeroKey(keyB);1146 1147 1148 if (resultA.status === "corrupted") {1149 lastUnlockFailureReason = resultA.reason;1150 } else if (resultB.status === "corrupted") {1151 lastUnlockFailureReason = resultB.reason;1152 }1153 return false;1154}11551156115711581159116011611162const SESSION_KEY_STORAGE = "peach_session_dk";11631164async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1165 if (!chrome.storage.session) return;1166 await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1167}11681169async function clearSessionKey(): Promise<void> {1170 if (!chrome.storage.session) return;1171 await chrome.storage.session.remove(SESSION_KEY_STORAGE);1172}11731174117511761177117811791180export async function restoreFromSession(): Promise<boolean> {1181 if (derivedKey !== null) return true;1182 if (!chrome.storage.session) return false;1183 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1184 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1185 if (!entry?.keyHex) return false;11861187 const key = unhex(entry.keyHex);1188 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1189 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1190 const slot = await loadSlot(slotKey);1191 if (!slot) { await clearSessionKey(); return false; }11921193 const result = await tryDecryptSlotResult(slot, key, aadTag);1194 if (result.status !== "ok") { await clearSessionKey(); return false; }11951196 derivedKey = key;1197 vaultMode = entry.mode;1198 entries = result.payload.entries;1199 duressConfigured = entry.mode === "real" ? result.payload.duressConfigured : false;1200 buildDomainIndexSync(entries);1201 await loadSettings();1202 touchAutoLock();12031204 if (entry.mode === "real" && slot.recovery_salt && slot.recovery_wrapped_root_key) {1205 try { rootKey = await deriveRootKeyFromDerivedKey(key, slot); } catch { }1206 }12071208 return true;1209}12101211export function lock(): void {1212 void clearSessionKey();1213 if (derivedKey) {1214 zeroKey(derivedKey);1215 derivedKey = null;1216 }1217 if (rootKey) {1218 rootKey.fill(0);1219 rootKey = null;1220 }12211222 entries = [];1223 domainIndex = new Map();1224 vaultMode = "real";1225 duressConfigured = false;1226 clearAutoLock();1227}12281229export function isUnlocked(): boolean {1230 return derivedKey !== null;1231}1232123312341235export async function unlockWithKey(1236 masterKeyHex: string,1237): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1238 try {1239 const masterKeyBytes = unhex(masterKeyHex);1240 const stored = await loadSlot(SLOT_A);1241 if (!stored) return { ok: false, error: "invalid_key" };1242 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1243 if (!payload) return { ok: false, error: "invalid_key" };12441245 derivedKey = masterKeyBytes;1246 vaultMode = "real";1247 entries = payload.entries;1248 duressConfigured = payload.duressConfigured;1249 buildDomainIndexSync(entries);1250 await loadSettings();1251 touchAutoLock();1252 return { ok: true, entries };1253 } catch {1254 return { ok: false, error: "invalid_key" };1255 }1256}12571258125912601261126212631264126512661267126812691270127112721273127412751276export async function unlockFromCodex(params: {1277 slotA: string; 1278 slotB: string | null; 1279 timestamp: number;1280 masterPassphrase: string;1281}): Promise<1282 | { ok: true; entryCount: number }1283 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1284> {1285 let parsedSlotA: StoredVault;1286 let parsedSlotB: StoredVault | null = null;1287 try {1288 const rawA = JSON.parse(params.slotA) as unknown;1289 if (!hasValidVaultSchema(rawA)) {1290 return { ok: false, reason: "invalid_slot_format" };1291 }1292 parsedSlotA = rawA;1293 if (params.slotB !== null) {1294 const rawB = JSON.parse(params.slotB) as unknown;1295 if (!hasValidVaultSchema(rawB)) {1296 return { ok: false, reason: "invalid_slot_format" };1297 }1298 parsedSlotB = rawB;1299 }1300 } catch {1301 return { ok: false, reason: "invalid_slot_format" };1302 }13031304 1305 1306 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1307 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1308 if (!trialPayload) {1309 zeroKey(trialKey);1310 return { ok: false, reason: "wrong_passphrase" };1311 }1312 1313 1314 zeroKey(trialKey);13151316 1317 1318 await takeVaultSnapshot("restore");13191320 await writeSlot(SLOT_A, parsedSlotA);1321 if (parsedSlotB) {1322 await writeSlot(SLOT_B, parsedSlotB);1323 } else {1324 1325 1326 1327 await writeSlot(1328 SLOT_B,1329 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1330 );1331 }1332 await chrome.storage.local.set({1333 peach_last_modified_at: String(params.timestamp),1334 });13351336 1337 1338 const ok = await unlock(params.masterPassphrase);1339 if (!ok) {1340 1341 1342 1343 return { ok: false, reason: "wrong_passphrase" };1344 }1345 return { ok: true, entryCount: getEntries().length };1346}1347134813491350export function getDerivedKeyHex(): string {1351 if (!derivedKey) throw new Error("vault_not_unlocked");1352 return hex(derivedKey);1353}13541355export function getEntries(): VaultEntry[] {1356 if (!derivedKey) throw new Error("vault_locked");1357 return entries.filter((entry) => !entry.deletedAt);1358}13591360const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;13611362export function getIdentities(): SavedIdentity[] {1363 if (!derivedKey) throw new Error("vault_locked");1364 const active = entries.filter((e) => !e.deletedAt && e.type === "login");13651366 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1367 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();13681369 for (const entry of active) {1370 const domain = extractDomain(entry.url ?? "") ?? "";1371 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;13721373 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1374 const existing = map.get(v);1375 if (!existing || modifiedAt > existing.lastUsed) {1376 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1377 } else {1378 map.set(v, { ...existing, count: existing.count + 1 });1379 }1380 }13811382 const uVal = entry.username?.trim();1383 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);13841385 1386 1387 const eVal = entry.email?.trim();1388 if (eVal) tally(emailMap, eVal);1389 }13901391 const toIdentity = (type: "email" | "username") =>1392 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1393 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1394 });13951396 return [1397 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1398 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1399 ];1400}14011402export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1403 if (!derivedKey) throw new Error("vault_locked");14041405 const now = Date.now();1406 const added: VaultEntry[] = [];1407 for (const draft of drafts) {1408 try {1409 const sanitized = sanitizeEntryDraft(draft);1410 const nextEntry: VaultEntry = {1411 ...sanitized,1412 id: crypto.randomUUID(),1413 createdAt: now,1414 modifiedAt: monotonicEntryTimestamp(),1415 };1416 entries.push(nextEntry);1417 added.push(nextEntry);1418 } catch {1419 1420 }1421 }14221423 if (added.length > 0) {1424 await persistEntries();1425 }14261427 return added;1428}142914301431143214331434143514361437export async function restoreEntries(1438 incoming: VaultEntry[],1439 mode: "replace" | "merge"1440): Promise<{ added: number; updated: number; skipped: number }> {1441 if (!derivedKey) throw new Error("vault_locked");14421443 1444 1445 1446 if (mode === "replace" && incoming.length === 0) {1447 throw new Error("refuse to replace with empty entry set");1448 }14491450 let added = 0;1451 let updated = 0;1452 let skipped = 0;14531454 if (mode === "replace") {1455 entries = [];1456 for (const entry of incoming) {1457 const normalized = sanitizeRestoredEntry(entry);1458 if (!normalized) {1459 skipped += 1;1460 continue;1461 }1462 entries.push(normalized);1463 added += 1;1464 }1465 } else {1466 const byId = new Map<string, VaultEntry>();1467 for (const e of entries) byId.set(e.id, e);14681469 for (const entry of incoming) {1470 const normalized = sanitizeRestoredEntry(entry);1471 if (!normalized) {1472 skipped += 1;1473 continue;1474 }1475 const existing = byId.get(normalized.id);1476 if (!existing) {1477 entries.push(normalized);1478 added += 1;1479 } else if (normalized.modifiedAt > existing.modifiedAt) {1480 const idx = entries.findIndex((e) => e.id === existing.id);1481 if (idx !== -1) {1482 entries[idx] = normalized;1483 updated += 1;1484 }1485 } else {1486 skipped += 1;1487 }1488 }1489 }14901491 await persistEntries("import");1492 return { added, updated, skipped };1493}1494149514961497149814991500function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1501 if (!entry || typeof entry !== "object") return null;1502 const title = typeof entry.title === "string" ? entry.title.trim() : "";1503 if (!title) return null;1504 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1505 const now = Date.now();1506 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1507 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;15081509 1510 1511 1512 const migrated: VaultEntry = migrateLegacyPasskey(1513 {1514 ...entry,1515 id,1516 title,1517 type: entry.type ?? "login",1518 createdAt,1519 modifiedAt,1520 },1521 entry1522 );15231524 1525 1526 if (1527 migrated.type === "login" &&1528 !migrated.passkey &&1529 typeof migrated.password !== "string"1530 ) {1531 return null;1532 }1533 if (migrated.passkey && !migrated.passkey.credentialId) {1534 return null;1535 }15361537 return migrated;1538}153915401541154215431544export async function clearVaultEntries(1545 confirmation: "REPLACE_ALL"1546): Promise<void> {1547 if (confirmation !== "REPLACE_ALL") {1548 throw new Error("clearVaultEntries requires explicit confirmation");1549 }1550 if (!derivedKey) throw new Error("vault_locked");1551 for (const entry of entries) {1552 if (!entry.deletedAt) {1553 const stamp = monotonicEntryTimestamp(entry.modifiedAt);1554 entry.deletedAt = stamp;1555 entry.modifiedAt = stamp;1556 }1557 }1558 await persistEntries();1559}15601561export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1562 if (!derivedKey) throw new Error("vault_locked");15631564 const sanitized = sanitizeEntryDraft(entry);1565 const now = Date.now();1566 const stamp = monotonicEntryTimestamp();1567 const nextEntry: VaultEntry = {1568 ...sanitized,1569 id: crypto.randomUUID(),1570 createdAt: now,1571 modifiedAt: stamp,1572 1573 1574 passwordChangedAt: sanitized.password ? stamp : undefined,1575 };15761577 1578 1579 1580 const rollback = entries;1581 entries = [...entries, nextEntry];1582 try {1583 await persistEntries();1584 } catch (err) {1585 entries = rollback;1586 throw err;1587 }15881589 1590 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1591 checkBreach(nextEntry.password).then(async (count) => {1592 if (count > 0) {1593 const idx = entries.findIndex((e) => e.id === nextEntry.id);1594 if (idx !== -1) {1595 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };1596 await persistEntries();1597 }1598 }1599 });1600 }16011602 fireSyncTrigger("vault_change");16031604 return nextEntry;1605}16061607export async function updateEntry(1608 id: string,1609 patch: Partial<VaultEntry>1610): Promise<void> {1611 if (!derivedKey) throw new Error("vault_locked");16121613 const idx = entries.findIndex((entry) => entry.id === id);1614 if (idx === -1 || entries[idx].deletedAt) {1615 throw new Error("entry_not_found");1616 }16171618 const current = entries[idx];1619 const sanitizedPatch = sanitizeEntryPatch(patch);1620 1621 1622 1623 const passwordChanged =1624 "password" in patch &&1625 typeof sanitizedPatch.password === "string" &&1626 sanitizedPatch.password !== current.password;1627 const stamp = monotonicEntryTimestamp(current.modifiedAt);1628 entries[idx] = {1629 ...current,1630 ...sanitizedPatch,1631 modifiedAt: stamp,1632 ...(passwordChanged ? { passwordChangedAt: stamp } : {}),1633 };1634163516361637 1638 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1639 entries[idx] = current;1640 throw new Error("card number is required for card entries");1641 }16421643 await persistEntries();16441645 1646 1647 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1648 entries[idx] = { ...entries[idx], breach: undefined };1649 await persistEntries();1650 const password = entries[idx].password;1651 if (password) {1652 checkBreach(password).then(async (count) => {1653 const i = entries.findIndex((e) => e.id === id);1654 if (i !== -1 && count > 0) {1655 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };1656 await persistEntries();1657 }1658 });1659 }1660 }16611662 fireSyncTrigger("vault_change");1663}16641665export async function deleteEntry(id: string): Promise<void> {1666 if (!derivedKey) throw new Error("vault_locked");16671668 const entry = entries.find((item) => item.id === id);1669 if (!entry || entry.deletedAt) {1670 throw new Error("entry_not_found");1671 }16721673 1674 1675 1676 const stamp = monotonicEntryTimestamp(entry.modifiedAt);1677 entry.deletedAt = stamp;1678 entry.modifiedAt = stamp;1679 await persistEntries();1680 fireSyncTrigger("vault_change");1681}16821683export function getDeletedEntries(): VaultEntry[] {1684 if (!derivedKey) return [];1685 return entries.filter((e) => !!e.deletedAt);1686}1687168816891690169116921693export async function getVaultVersion(): Promise<number> {1694 try {1695 const raw = await chrome.storage.local.get("peach_last_modified_at");1696 const v = (raw as Record<string, unknown>).peach_last_modified_at;1697 if (typeof v === "number") return v;1698 if (typeof v === "string" && v !== "") {1699 const n = Number.parseInt(v, 10);1700 return Number.isFinite(n) ? n : 0;1701 }1702 return 0;1703 } catch {1704 return 0;1705 }1706}170717081709171017111712171317141715171617171718export async function addEntryDirect(entry: VaultEntry): Promise<void> {1719 if (!derivedKey) throw new Error("vault_locked");1720 entries = [...entries, entry];1721 await persistEntries("sync_received");1722}17231724export async function updateEntryDirect(patch: VaultEntry): Promise<void> {1725 if (!derivedKey) throw new Error("vault_locked");1726 const idx = entries.findIndex((e) => e.id === patch.id);1727 if (idx === -1) throw new Error("entry_not_found");1728 entries[idx] = { ...entries[idx], ...patch };1729 await persistEntries("sync_received");1730}17311732export async function deleteEntryDirect(id: string, deletedAt: number): Promise<void> {1733 if (!derivedKey) throw new Error("vault_locked");1734 const idx = entries.findIndex((e) => e.id === id);1735 if (idx === -1) throw new Error("entry_not_found");1736 entries[idx] = { ...entries[idx], deletedAt, modifiedAt: deletedAt };1737 await persistEntries("sync_received");1738}1739174017411742174317441745174617471748174917501751export async function applyMergedRemoteEntries(1752 remoteEntries: VaultEntry[],1753 remoteTombstones: Record<string, number>,1754): Promise<{ received: number; applied: number; conflicts: number }> {1755 if (!derivedKey) throw new Error("vault_locked");17561757 const received = remoteEntries.length + Object.keys(remoteTombstones).length;1758 let applied = 0;1759 let conflicts = 0;17601761 const byId = new Map<string, VaultEntry>();1762 for (const e of entries) byId.set(e.id, e);17631764 for (const remote of remoteEntries) {1765 const sanitized = sanitizeRestoredEntry(remote);1766 if (!sanitized) continue;1767 const tombstoneTs = remoteTombstones[sanitized.id];1768 if (tombstoneTs !== undefined && tombstoneTs >= sanitized.modifiedAt) {1769 continue;1770 }1771 const local = byId.get(sanitized.id);1772 if (!local) {1773 entries.push(sanitized);1774 byId.set(sanitized.id, sanitized);1775 applied += 1;1776 } else if (sanitized.modifiedAt > local.modifiedAt) {1777 const idx = entries.findIndex((e) => e.id === local.id);1778 if (idx !== -1) {1779 entries[idx] = sanitized;1780 byId.set(sanitized.id, sanitized);1781 applied += 1;1782 if (!local.deletedAt) conflicts += 1;1783 }1784 }1785 }17861787 for (const [id, deletedAt] of Object.entries(remoteTombstones)) {1788 if (typeof deletedAt !== "number" || !Number.isFinite(deletedAt)) continue;1789 const local = byId.get(id);1790 if (!local) continue;1791 if (local.deletedAt && local.deletedAt >= deletedAt) continue;1792 if (deletedAt < local.modifiedAt) continue;1793 const idx = entries.findIndex((e) => e.id === id);1794 if (idx !== -1) {1795 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };1796 applied += 1;1797 }1798 }17991800 if (applied > 0) {1801 await persistEntries("sync_received");1802 buildDomainIndexSync(entries);1803 }1804 return { received, applied, conflicts };1805}18061807export async function restoreEntry(id: string): Promise<void> {1808 if (!derivedKey) throw new Error("vault_locked");1809 const entry = entries.find((e) => e.id === id && e.deletedAt);1810 if (!entry) throw new Error("deleted entry not found");1811 delete entry.deletedAt;1812 entry.modifiedAt = monotonicEntryTimestamp(entry.modifiedAt);1813 await persistEntries();1814 buildDomainIndexSync(entries);1815}18161817export async function purgeEntry(id: string): Promise<void> {1818 if (!derivedKey) throw new Error("vault_locked");1819 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1820 if (idx === -1) throw new Error("deleted entry not found");1821 entries.splice(idx, 1);1822 await persistEntries();1823}182418251826export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1827 if (!derivedKey || retentionMs === 0) return 0;1828 const cutoff = Date.now() - retentionMs;1829 const before = entries.length;1830 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1831 const purged = before - entries.length;1832 if (purged > 0) await persistEntries();1833 return purged;1834}18351836export function queryDomain(domain: string): VaultEntry[] {1837 return domainIndex.get(domain) ?? [];1838}18391840export function getEntryById(id: string): VaultEntry | undefined {1841 return entries.find((entry) => entry.id === id && !entry.deletedAt);1842}18431844async function persistSlotA(1845 reason: VaultSnapshot["reason"] = "local_save",1846): Promise<StoredVault> {1847 if (!derivedKey) throw new Error("vault_locked");18481849 const slotA = await loadSlot(SLOT_A);1850 if (!slotA) throw new Error("missing slot A");18511852 1853 1854 await takeVaultSnapshot(reason);18551856 const plaintext = encodePayload(entries, duressConfigured);1857 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");18581859 1860 const nextSlotA: StoredVault = {1861 ...slotA,1862 nonce,1863 ciphertext,1864 tag,1865 1866 };1867 await writeSlot(SLOT_A, nextSlotA);18681869 1870 1871 1872 if (!duressConfigured) {1873 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1874 }1875 1876 1877 1878 1879 1880 if (reason !== "sync_received") {1881 await stampLocalVaultMutation();1882 }18831884 return nextSlotA;1885}18861887async function persistSlotB(): Promise<void> {1888 if (!derivedKey) throw new Error("vault_locked");18891890 const slotB = await loadSlot(SLOT_B);1891 if (!slotB) throw new Error("missing slot B");18921893 1894 1895 const plaintext = encodePayload(entries, false);1896 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");18971898 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1899 await stampLocalVaultMutation();1900}19011902async function persistEntries(1903 reason: VaultSnapshot["reason"] = "local_save",1904): Promise<void> {1905 if (!derivedKey) throw new Error("vault_locked");19061907 if (vaultMode === "decoy") {1908 await persistSlotB();1909 } else {1910 await persistSlotA(reason);1911 }1912 buildDomainIndexSync(entries);1913}19141915function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1916 if (!Array.isArray(values)) return undefined;1917 const seen = new Set<string>();1918 const cleaned: string[] = [];1919 for (const value of values) {1920 const trimmed = typeof value === "string" ? value.trim() : "";1921 if (!trimmed) continue;1922 const key = trimmed.toLowerCase();1923 if (seen.has(key)) continue;1924 seen.add(key);1925 cleaned.push(trimmed);1926 }1927 return cleaned.length > 0 ? cleaned : undefined;1928}19291930function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1931 const title = entry.title.trim();1932 1933 1934 1935 let type: VaultEntry["type"] = entry.type ?? "login";1936 if (type === "passkey") type = "login";19371938 if (!title) throw new Error("title is required");19391940 if (entry.passkey) {1941 const p = entry.passkey;1942 if (1943 !p.rpId ||1944 !p.credentialId ||1945 !p.publicKeyCose ||1946 !p.userHandle ||1947 !p.userName ||1948 (p.algorithm !== -7 && p.algorithm !== -8)1949 ) {1950 throw new Error("invalid passkey credential");1951 }1952 }1953 if (type === "card") {1954 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1955 throw new Error("cardholder name and card number are required");1956 }1957 }19581959 return {1960 type,1961 title,1962 username: type === "login" ? entry.username?.trim() ?? "" : undefined,1963 email: type === "login" ? entry.email?.trim() || undefined : undefined,1964 password: type === "login" ? entry.password : undefined,1965 url: type === "login" ? entry.url?.trim() ?? "" : undefined,1966 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1967 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1968 notes: entry.notes?.trim() || undefined,1969 breach: type === "login" ? entry.breach : undefined,1970 canary: type === "login" ? entry.canary : undefined,1971 tags: entry.tags,1972 passkey: entry.passkey,1973 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1974 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1975 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1976 expYear: type === "card" ? entry.expYear?.trim() : undefined,1977 cvc: type === "card" ? entry.cvc?.trim() : undefined,1978 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1979 network: type === "card" ? entry.network : undefined,1980 last4: type === "card" ? entry.last4?.trim() : undefined,1981 };1982}19831984function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1985 const nextPatch: Partial<VaultEntry> = {};19861987 if ("type" in patch) {1988 nextPatch.type = patch.type;1989 }19901991 if ("title" in patch) {1992 const title = patch.title?.trim() ?? "";1993 if (!title) throw new Error("title is required");1994 nextPatch.title = title;1995 }19961997 if ("url" in patch) {1998 nextPatch.url = patch.url?.trim() ?? "";1999 }20002001 if ("additionalUrls" in patch) {2002 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2003 const seen = new Set<string>();2004 const cleaned: string[] = [];2005 for (const value of incoming) {2006 const trimmed = typeof value === "string" ? value.trim() : "";2007 if (!trimmed) continue;2008 const key = trimmed.toLowerCase();2009 if (seen.has(key)) continue;2010 seen.add(key);2011 cleaned.push(trimmed);2012 }2013 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2014 }20152016 if ("username" in patch) {2017 nextPatch.username = patch.username?.trim() ?? "";2018 }20192020 if ("email" in patch) {2021 nextPatch.email = patch.email?.trim() || undefined;2022 }20232024 if ("password" in patch) {2025 const password = patch.password ?? "";2026 2027 nextPatch.password = password || undefined;2028 }20292030 if ("totp" in patch) {2031 nextPatch.totp = patch.totp?.trim() || undefined;2032 }20332034 if ("notes" in patch) {2035 nextPatch.notes = patch.notes?.trim() || undefined;2036 }20372038 if ("breach" in patch) {2039 nextPatch.breach = patch.breach;2040 }20412042 if ("canary" in patch) {2043 nextPatch.canary = patch.canary;2044 }20452046 if ("passkey" in patch) {2047 2048 2049 2050 nextPatch.passkey = patch.passkey;2051 }20522053 if ("tags" in patch) {2054 nextPatch.tags = patch.tags;2055 }20562057 if ("cardholderName" in patch) {2058 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2059 }2060 if ("cardNumber" in patch) {2061 const n = patch.cardNumber ?? "";2062 nextPatch.cardNumber = n || undefined;2063 }2064 if ("expMonth" in patch) {2065 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2066 }2067 if ("expYear" in patch) {2068 nextPatch.expYear = patch.expYear?.trim() || undefined;2069 }2070 if ("cvc" in patch) {2071 nextPatch.cvc = patch.cvc?.trim() || undefined;2072 }2073 if ("cardNickname" in patch) {2074 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2075 }2076 if ("network" in patch) {2077 nextPatch.network = patch.network;2078 }2079 if ("last4" in patch) {2080 nextPatch.last4 = patch.last4?.trim() || undefined;2081 }20822083 return nextPatch;2084}20852086function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {2087 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;2088 const modifiedAt =2089 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;20902091 const base: VaultEntry = {2092 id: entry.id,2093 type: "type" in entry ? entry.type : "login",2094 title: entry.title,2095 username: entry.username,2096 email: "email" in entry ? entry.email : undefined,2097 password: entry.password,2098 url: entry.url,2099 additionalUrls:2100 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2101 ? entry.additionalUrls2102 : undefined,2103 totp: entry.totp,2104 notes: entry.notes,2105 breach: entry.breach,2106 2107 2108 2109 passwordChangedAt:2110 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2111 canary: "canary" in entry ? entry.canary : undefined,2112 passkey: "passkey" in entry ? entry.passkey : undefined,2113 createdAt,2114 modifiedAt,2115 deletedAt: entry.deletedAt,2116 tags: "tags" in entry ? entry.tags : undefined,2117 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2118 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2119 expMonth: "expMonth" in entry ? entry.expMonth : undefined,2120 expYear: "expYear" in entry ? entry.expYear : undefined,2121 cvc: "cvc" in entry ? entry.cvc : undefined,2122 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2123 network: "network" in entry ? entry.network : undefined,2124 last4: "last4" in entry ? entry.last4 : undefined,2125 };21262127 return migrateLegacyPasskey(base, entry);2128}212921302131213221332134213521362137function migrateLegacyPasskey(2138 base: VaultEntry,2139 raw: VaultEntry | LegacyVaultEntry2140): VaultEntry {2141 if (base.passkey) return base;2142 const flat = raw as Partial<VaultEntry>;2143 const isPasskeyType = base.type === "passkey";2144 const hasFlatPasskeyFields =2145 typeof flat.rpId === "string" &&2146 typeof flat.credentialId === "string" &&2147 typeof flat.publicKeyCose === "string" &&2148 typeof flat.userHandle === "string" &&2149 typeof flat.userName === "string";2150 if (!isPasskeyType && !hasFlatPasskeyFields) return base;2151 if (!hasFlatPasskeyFields) return base;21522153 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;2154 base.passkey = {2155 rpId: flat.rpId!,2156 rpName: flat.rpName ?? flat.rpId!,2157 credentialId: flat.credentialId!,2158 publicKeyCose: flat.publicKeyCose!,2159 privateKeyJwk: flat.privateKeyJwk,2160 userHandle: flat.userHandle!,2161 userName: flat.userName!,2162 userDisplayName: flat.userDisplayName ?? flat.userName!,2163 algorithm,2164 signCount: flat.signCount ?? 0,2165 aaguid: flat.aaguid ?? "",2166 backupEligible: flat.backupEligible === true,2167 backupState: flat.backupState === true,2168 lastUsedAt: flat.lastUsedAt,2169 browser: flat.browser,2170 transports: flat.transports,2171 createdAt: base.createdAt,2172 };2173 2174 2175 2176 if (isPasskeyType) base.type = "login";2177 if (!base.username) base.username = flat.userName;2178 if (!base.url) base.url = `https://${flat.rpId}`;2179 return base;2180}2181218221832184218521862187function entryDupeKey(e: VaultEntry): string {2188 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2189 2190 2191 2192 2193 if (e.passkey) {2194 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2195 }2196 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2197}219821992200export function countDuplicates(): number {2201 const best = new Map<string, VaultEntry>();2202 let dupeCount = 0;2203 for (const e of entries.filter((e) => !e.deletedAt)) {2204 const key = entryDupeKey(e);2205 const existing = best.get(key);2206 if (!existing) {2207 best.set(key, e);2208 } else {2209 dupeCount++;2210 2211 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2212 best.set(key, e);2213 }2214 }2215 }2216 return dupeCount;2217}221822192220222122222223export async function deduplicateEntries(): Promise<number> {2224 if (!derivedKey) throw new Error("vault_locked");2225 2226 2227 if (vaultMode === "decoy") {2228 throw new Error("deduplicate disabled in decoy mode");2229 }22302231 const best = new Map<string, VaultEntry>();2232 for (const e of entries.filter((en) => !en.deletedAt)) {2233 const key = entryDupeKey(e);2234 const existing = best.get(key);2235 if (!existing) {2236 best.set(key, e);2237 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2238 best.set(key, e);2239 }2240 }22412242 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2243 let removed = 0;2244 entries = entries.map((e) => {2245 if (!e.deletedAt && !keepIds.has(e.id)) {2246 removed++;2247 return { ...e, deletedAt: monotonicEntryTimestamp(e.modifiedAt) };2248 }2249 return e;2250 });22512252 if (removed > 0) await persistEntries();2253 return removed;2254}225522562257225822592260226122622263const HISTORY_KEY = "peach_vault_history";2264const HISTORY_MAX = 10;22652266226722682269227022712272export async function takeVaultSnapshot(2273 reason: VaultSnapshot["reason"],2274): Promise<void> {2275 try {2276 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2277 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2278 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];22792280 2281 2282 2283 if (!slotA) return;22842285 const vault_a = JSON.stringify(slotA);2286 const vault_b = JSON.stringify(slotB ?? slotA);22872288 2289 2290 2291 let entryCount: number | null = null;2292 if (derivedKey && vaultMode === "real") {2293 try {2294 entryCount = entries.filter((e) => !e.deletedAt).length;2295 } catch {2296 entryCount = null;2297 }2298 }22992300 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2301 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2302 ? existingRaw2303 : { snapshots: [] };23042305 2306 2307 2308 const latest = history.snapshots[0];2309 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2310 return;2311 }23122313 const snapshot: VaultSnapshot = {2314 id: crypto.randomUUID(),2315 savedAt: Date.now(),2316 reason,2317 vault_a,2318 vault_b,2319 entryCount,2320 };23212322 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2323 await chrome.storage.local.set({ [HISTORY_KEY]: history });2324 } catch (err) {2325 2326 2327 2328 2329 console.warn('[Peach] takeVaultSnapshot failed:', err);2330 if (String(err).includes('QUOTA') || String(err).includes('quota')) {2331 try {2332 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2333 } catch {2334 2335 }2336 }2337 }2338}233923402341export async function clearVaultHistory(): Promise<void> {2342 try {2343 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2344 } catch {2345 2346 }2347}234823492350export async function getVaultHistory(): Promise<VaultSnapshot[]> {2351 const raw = await chrome.storage.local.get(HISTORY_KEY);2352 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2353 if (!history || !Array.isArray(history.snapshots)) return [];2354 return history.snapshots;2355}23562357235823592360236123622363export async function restoreVaultSnapshot(2364 snapshotId: string,2365): Promise<{ ok: true } | { ok: false; error: string }> {2366 const history = await getVaultHistory();2367 const snapshot = history.find((s) => s.id === snapshotId);2368 if (!snapshot) return { ok: false, error: "snapshot_not_found" };23692370 2371 2372 2373 await takeVaultSnapshot("restore");23742375 let restoredA: StoredVault;2376 let restoredB: StoredVault;2377 try {2378 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2379 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2380 } catch {2381 return { ok: false, error: "snapshot_corrupt" };2382 }23832384 await writeSlot(SLOT_A, restoredA);2385 await writeSlot(SLOT_B, restoredB);2386 await stampLocalVaultMutation();23872388 2389 2390 2391 lock();23922393 return { ok: true };2394}23952396239723982399240024012402export async function changePassword(2403 currentPassword: string,2404 newPassword: string2405): Promise<boolean> {2406 if (!derivedKey || vaultMode !== "real") return false;24072408 2409 const valid = await verifyMainPassword(currentPassword);2410 if (!valid) return false;24112412 const slotA = await loadSlot(SLOT_A);2413 if (!slotA) return false;2414 if (!rootKey) return false;24152416 2417 const newSalt = generateSalt();2418 const newKey = await deriveKey(newPassword, newSalt);24192420 2421 const plaintext = encodePayload(entries, duressConfigured);2422 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");24232424 2425 2426 2427 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);24282429 const finalSlotA: StoredVault = {2430 ...slotA,2431 salt: hex(newSalt),2432 nonce,2433 ciphertext,2434 tag,2435 ...pwRoot,2436 };24372438 2439 2440 2441 await takeVaultSnapshot("local_save");24422443 2444 2445 2446 2447 await writeSlot(SLOT_A, finalSlotA);24482449 2450 2451 2452 zeroKey(derivedKey);2453 derivedKey = newKey;24542455 2456 2457 2458 if (!duressConfigured) {2459 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2460 }2461 await stampLocalVaultMutation();24622463 return true;2464}