123456789101112import { deriveKey, generateSalt } from "../crypto/argon2";13import { extractDomain } from "../shared/domain";14import type {15 AccentColorName,16 LegacyVaultEntry,17 ManualSummonRecord,18 SavedIdentity,19 StoredVault,20 VaultEntry,21 VaultHistory,22 VaultSnapshot,23} from "../shared/types";24import { decrypt, encrypt, zeroKey } from "./aes";25import { clearAutoLock, touchAutoLock } from "./alarms";2627type EntryDraft = Omit<28 VaultEntry,29 "id" | "createdAt" | "modifiedAt" | "deletedAt"30>;313233343536const SLOT_A = "peach_vault_a";37const SLOT_B = "peach_vault_b";383940const LEGACY_MAIN = "peach_vault";41const LEGACY_DECOY = "peach_decoy_vault";42const LEGACY_DURESS_FLAG = "peach_duress";43444546const PAD_BLOCK = 1024;4748let derivedKey: Uint8Array | null = null;49let entries: VaultEntry[] = [];50let domainIndex: Map<string, VaultEntry[]> = new Map();51let vaultMode: "real" | "decoy" = "real";52535455let duressConfigured = false;5657let migrationRan = false;585960616263export type DisplayFont = "cormorant-italic" | "cormorant" | "inter" | "system";64export type TextSize = "small" | "medium" | "large";6566export interface VaultSettings {67 accentColor?: AccentColorName;68 theme?: "dark" | "light";69 autoLockMinutes?: number;70 clipboardClearSeconds?: number;71 lockOnClose?: boolean;72 breachMonitoring?: boolean;73 passkeyProviderEnabled?: boolean;74 trashRetentionDays?: number;75 manualSummonDomains?: Record<string, ManualSummonRecord>;76 declinedCardFingerprints?: string[];77 7879808182 displayFont?: DisplayFont;83 8485868788 textSize?: TextSize;89 9091929394 reduceMotion?: boolean;95 96979899100101102 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;103}104105let settings: VaultSettings = {};106107export function getSettings(): VaultSettings {108 return settings;109}110111export async function ensureSettingsLoaded(): Promise<VaultSettings> {112 await loadSettings();113 return settings;114}115116export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {117 await loadSettings();118 settings = { ...settings, ...patch };119 await persistSettings();120}121122async function persistSettings(): Promise<void> {123 try {124 await chrome.storage.local.set({ peach_settings: settings });125 } catch (err) {126 throw mapStorageError(err);127 }128}129130function mapStorageError(err: unknown): Error {131 const message = err instanceof Error ? err.message : String(err);132 if (/QUOTA_BYTES|quota/i.test(message)) {133 return new Error("STORAGE_QUOTA_EXCEEDED");134 }135 return err instanceof Error ? err : new Error(message);136}137138async function loadSettings(): Promise<void> {139 const result = await chrome.storage.local.get("peach_settings");140 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};141}142143144145146147export async function checkBreach(password: string): Promise<number> {148 const encoder = new TextEncoder();149 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));150 const hashHex = Array.from(new Uint8Array(hashBuffer))151 .map((b) => b.toString(16).padStart(2, "0"))152 .join("")153 .toUpperCase();154155 const prefix = hashHex.slice(0, 5);156 const suffix = hashHex.slice(5);157158 try {159 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);160 if (!res.ok) return 0;161 const text = await res.text();162 const match = text.split("\r\n").find((l) => l.startsWith(suffix));163 return match ? parseInt(match.split(":")[1], 10) : 0;164 } catch {165 return 0; 166 }167}168169export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {170 const fingerprint = await cardFingerprint(last4, expMonth, expYear);171 await loadSettings();172 settings = {173 ...settings,174 declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],175 };176 await persistSettings();177}178179export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {180 await loadSettings();181 const fingerprint = await cardFingerprint(last4, expMonth, expYear);182 return (settings.declinedCardFingerprints ?? []).includes(fingerprint);183}184185async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {186 const encoder = new TextEncoder();187 const buf = await crypto.subtle.digest("SHA-1", encoder.encode(`${last4}${expMonth}${expYear}`));188 return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, "0")).join("");189}190191function hex(buf: Uint8Array): string {192 return Array.from(buf)193 .map((b) => b.toString(16).padStart(2, "0"))194 .join("");195}196197function unhex(s: string): Uint8Array {198 const out = new Uint8Array(s.length / 2);199 for (let i = 0; i < s.length; i += 2) {200 out[i / 2] = parseInt(s.slice(i, i + 2), 16);201 }202 return out;203}204205206207208209async function loadSlot(key: string): Promise<StoredVault | null> {210 const result = await chrome.storage.local.get(key);211 return (result as Record<string, StoredVault | undefined>)[key] ?? null;212}213214async function writeSlot(key: string, vault: StoredVault): Promise<void> {215 try {216 await chrome.storage.local.set({ [key]: vault });217 } catch (err) {218 throw mapStorageError(err);219 }220}221222223224225226227async function ensureMigrated(): Promise<void> {228 if (migrationRan) return;229 migrationRan = true;230231 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];232 const raw = await chrome.storage.local.get(keys);233 const slotAExists = raw[SLOT_A] !== undefined;234 const slotBExists = raw[SLOT_B] !== undefined;235 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;236 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;237238 239 if (!slotAExists && !legacyMain) return;240241 242 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;243244 if (legacyMain && !slotAExists) {245 await writeSlot(SLOT_A, legacyMain);246 }247248 249 if (!slotBExists) {250 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;251 if (legacyDecoy) {252 await writeSlot(SLOT_B, legacyDecoy);253 } else if (target) {254 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));255 }256 }257258 const toRemove: string[] = [];259 if (legacyMain) toRemove.push(LEGACY_MAIN);260 if (legacyDecoy) toRemove.push(LEGACY_DECOY);261 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);262 if (toRemove.length > 0) {263 await chrome.storage.local.remove(toRemove);264 }265}266267268269270271272273274275276277function padPlaintext(json: string): string {278 const encoded = new TextEncoder().encode(json);279 const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);280 const padLen = target - encoded.length;281 return json + " ".repeat(padLen);282}283284285286287288289290291292293294const CSPRNG_CHUNK = 65_536;295296function fillRandom(buf: Uint8Array): Uint8Array {297 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {298 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);299 const chunk = new Uint8Array(end - offset);300 crypto.getRandomValues(chunk);301 buf.set(chunk, offset);302 }303 return buf;304}305306function generateNoiseBlob(ciphertextBytes: number): StoredVault {307 const salt = crypto.getRandomValues(new Uint8Array(16));308 const nonce = crypto.getRandomValues(new Uint8Array(12));309 const cipher = fillRandom(new Uint8Array(ciphertextBytes));310 const tag = crypto.getRandomValues(new Uint8Array(16));311 return {312 version: 1,313 salt: hex(salt),314 nonce: hex(nonce),315 ciphertext: hex(cipher),316 tag: hex(tag),317 };318}319320321322323324type VaultPayload = {325 entries: VaultEntry[];326 duressConfigured?: boolean;327};328329type DecryptedPayload = {330 entries: VaultEntry[];331 duressConfigured: boolean;332 333334335336337338339 migratedFromLegacy: boolean;340};341342function encodePayload(entries: VaultEntry[], duress: boolean): string {343 const envelope: VaultPayload = { entries, duressConfigured: duress };344 return padPlaintext(JSON.stringify(envelope));345}346347function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {348 if ("created" in raw || "updated" in raw) return true;349 const flat = raw as Partial<VaultEntry>;350 if (flat.type === "passkey") return true;351 352 if (flat.rpId !== undefined && flat.passkey === undefined) return true;353 return false;354}355356function decodePayload(plain: string): DecryptedPayload {357 const parsed = JSON.parse(plain) as358 | Array<VaultEntry | LegacyVaultEntry>359 | VaultPayload;360361 const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];362 const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);363364 const normalized = rawEntries.map(normalizeEntry);365 const { merged } = mergePasskeyOnlyIntoLogins(normalized);366367 if (Array.isArray(parsed)) {368 return {369 entries: normalized,370 duressConfigured: false,371 migratedFromLegacy: true, 372 };373 }374375 return {376 entries: normalized,377 duressConfigured: parsed.duressConfigured === true,378 migratedFromLegacy: flatMigrationNeeded || merged,379 };380}381382383384385386387388389390391392393394395396397398399400401402403404405function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {406 let merged = false;407 const now = Date.now();408409 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);410 const byRpId = new Map<string, VaultEntry[]>();411 for (const e of candidates) {412 const rpId = e.passkey!.rpId;413 const list = byRpId.get(rpId);414 if (list) list.push(e);415 else byRpId.set(rpId, [e]);416 }417418 for (const [rpId, group] of byRpId) {419 if (group.length === 0) continue;420 group.sort((a, b) => {421 const aT =422 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;423 const bT =424 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;425 return bT - aT;426 });427 const winner = group[0]!;428429 430 431 432 const loginTarget = entries.find((e) => {433 if (e.deletedAt || e.passkey || e.type !== "login") return false;434 if (e.url && extractDomain(e.url) === rpId) return true;435 for (const extra of e.additionalUrls ?? []) {436 if (extractDomain(extra) === rpId) return true;437 }438 return false;439 });440441 if (loginTarget) {442 loginTarget.passkey = winner.passkey;443 loginTarget.modifiedAt = now;444 for (const dup of group) {445 if (!dup.deletedAt) {446 dup.deletedAt = now;447 dup.modifiedAt = now;448 }449 }450 merged = true;451 } else if (group.length > 1) {452 for (let i = 1; i < group.length; i += 1) {453 const dup = group[i]!;454 if (!dup.deletedAt) {455 dup.deletedAt = now;456 dup.modifiedAt = now;457 }458 }459 merged = true;460 }461 }462463 return { merged };464}465466async function tryDecryptSlot(467 vault: StoredVault,468 key: Uint8Array469): Promise<DecryptedPayload | null> {470 try {471 const plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);472 return decodePayload(plain);473 } catch {474 return null;475 }476}477478479480481482function buildDomainIndexSync(all: VaultEntry[]): void {483 domainIndex = new Map();484485 for (const entry of all) {486 if (entry.deletedAt) continue;487 if (entry.type === "note" || entry.type === "card") continue;488489 490 491 492 493 const domains = new Set<string>();494 const primary = extractDomain(entry.url ?? "");495 if (primary) domains.add(primary);496 for (const extra of entry.additionalUrls ?? []) {497 const d = extractDomain(extra);498 if (d) domains.add(d);499 }500 if (domains.size === 0) continue;501502 for (const domain of domains) {503 const bucket = domainIndex.get(domain) ?? [];504 bucket.push(entry);505 domainIndex.set(domain, bucket);506 }507 }508}509510export async function createVault(password: string): Promise<void> {511 await ensureMigrated();512513 const salt = generateSalt();514 derivedKey = await deriveKey(password, salt);515 entries = [];516 vaultMode = "real";517 duressConfigured = false;518 buildDomainIndexSync(entries);519520 const plaintext = encodePayload(entries, duressConfigured);521 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);522523 const slotA: StoredVault = {524 version: 1,525 salt: hex(salt),526 nonce,527 ciphertext,528 tag,529 };530 await writeSlot(SLOT_A, slotA);531532 533 534 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));535536 touchAutoLock();537}538539export async function hasVault(): Promise<boolean> {540 await ensureMigrated();541 return (await loadSlot(SLOT_A)) !== null;542}543544545546547548549export async function isDuressEnabled(): Promise<boolean> {550 return vaultMode === "real" && duressConfigured;551}552553export async function hasDecoyVault(): Promise<boolean> {554 return isDuressEnabled();555}556557export async function setupDuress(duressPassword: string): Promise<void> {558 if (!derivedKey || vaultMode !== "real") {559 throw new Error("vault must be unlocked in real mode");560 }561 await ensureMigrated();562563 564 565 566 const slotA = await loadSlot(SLOT_A);567 if (!slotA) throw new Error("missing slot A");568569 const decoySalt = generateSalt();570 const decoyKey = await deriveKey(duressPassword, decoySalt);571 try {572 573 574 const targetBytes = slotA.ciphertext.length / 2;575 let plaintext = encodePayload([], false);576 const encoded = new TextEncoder().encode(plaintext);577 if (encoded.length < targetBytes) {578 plaintext = plaintext + " ".repeat(targetBytes - encoded.length);579 }580 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);581 await writeSlot(SLOT_B, {582 version: 1,583 salt: hex(decoySalt),584 nonce,585 ciphertext,586 tag,587 });588 } finally {589 zeroKey(decoyKey);590 }591592 593 594 duressConfigured = true;595 await persistSlotA();596}597598export async function disableDuress(): Promise<void> {599 if (!derivedKey || vaultMode !== "real") {600 throw new Error("vault must be unlocked in real mode");601 }602 await ensureMigrated();603604 605 606 607 const slotA = await loadSlot(SLOT_A);608 if (slotA) {609 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));610 }611612 duressConfigured = false;613 await persistSlotA();614}615616export async function verifyMainPassword(password: string): Promise<boolean> {617 await ensureMigrated();618 const slotA = await loadSlot(SLOT_A);619 if (!slotA) return false;620 const key = await deriveKey(password, unhex(slotA.salt));621 try {622 return (await tryDecryptSlot(slotA, key)) !== null;623 } finally {624 zeroKey(key);625 }626}627628export async function unlock(password: string): Promise<boolean> {629 await ensureMigrated();630631 632 633 634 635 636 const [slotA, slotB] = await Promise.all([637 loadSlot(SLOT_A),638 loadSlot(SLOT_B),639 ]);640641 642 const [keyA, keyB] = await Promise.all([643 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),644 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),645 ]);646647 648 const [payloadA, payloadB] = await Promise.all([649 keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),650 keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),651 ]);652653 if (payloadA && keyA) {654 derivedKey = keyA;655 vaultMode = "real";656 entries = payloadA.entries;657 duressConfigured = payloadA.duressConfigured;658 buildDomainIndexSync(entries);659 await loadSettings();660 touchAutoLock();661 if (keyB) zeroKey(keyB);662 663 664 665 if (payloadA.migratedFromLegacy) {666 await persistSlotA();667 }668 return true;669 }670671 if (payloadB && keyB) {672 derivedKey = keyB;673 vaultMode = "decoy";674 entries = payloadB.entries;675 duressConfigured = false;676 buildDomainIndexSync(entries);677 await loadSettings();678 touchAutoLock();679 if (keyA) zeroKey(keyA);680 return true;681 }682683 if (keyA) zeroKey(keyA);684 if (keyB) zeroKey(keyB);685 return false;686}687688export function lock(): void {689 if (derivedKey) {690 zeroKey(derivedKey);691 derivedKey = null;692 }693694 entries = [];695 domainIndex = new Map();696 vaultMode = "real";697 duressConfigured = false;698 clearAutoLock();699}700701export function isUnlocked(): boolean {702 return derivedKey !== null;703}704705706707export async function unlockWithKey(708 masterKeyHex: string,709): Promise<{ ok: true } | { ok: false; error: string }> {710 try {711 const keyBytes = unhex(masterKeyHex);712 const masterKey = await crypto.subtle.importKey(713 "raw",714 keyBytes,715 { name: "AES-GCM" },716 false,717 ["decrypt"],718 );719 const payload = await tryDecryptSlot(await loadSlot(SLOT_A), masterKey);720 if (!payload) return { ok: false, error: "invalid_key" };721722 derivedKey = masterKey;723 vaultMode = "real";724 entries = payload.entries;725 duressConfigured = payload.duressConfigured;726 buildDomainIndexSync(entries);727 await loadSettings();728 touchAutoLock();729 return { ok: true };730 } catch {731 return { ok: false, error: "invalid_key" };732 }733}734735736737export function getDerivedKeyHex(): string {738 if (!derivedKey) throw new Error("vault_not_unlocked");739 return hex(derivedKeyBytes());740}741742function derivedKeyBytes(): Uint8Array {743 const buf = derivedKey!.exportKey("raw") as ArrayBuffer;744 return new Uint8Array(buf);745}746747export function getEntries(): VaultEntry[] {748 if (!derivedKey) throw new Error("vault locked");749 return entries.filter((entry) => !entry.deletedAt);750}751752const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;753754export function getIdentities(): SavedIdentity[] {755 if (!derivedKey) throw new Error("vault locked");756 const active = entries.filter((e) => !e.deletedAt && e.type === "login");757758 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();759 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();760761 for (const entry of active) {762 const value = entry.username?.trim();763 if (!value) continue;764 const map = EMAIL_RE.test(value) ? emailMap : usernameMap;765 const existing = map.get(value);766 const domain = extractDomain(entry.url ?? "") ?? "";767 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;768 if (!existing || modifiedAt > existing.lastUsed) {769 map.set(value, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });770 } else {771 map.set(value, { ...existing, count: existing.count + 1 });772 }773 }774775 const toIdentity = (type: "email" | "username") =>776 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({777 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,778 });779780 return [781 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),782 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),783 ];784}785786export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {787 if (!derivedKey) throw new Error("vault locked");788789 const now = Date.now();790 const added: VaultEntry[] = [];791 for (const draft of drafts) {792 try {793 const sanitized = sanitizeEntryDraft(draft);794 const nextEntry: VaultEntry = {795 ...sanitized,796 id: crypto.randomUUID(),797 createdAt: now,798 modifiedAt: now,799 };800 entries.push(nextEntry);801 added.push(nextEntry);802 } catch {803 804 }805 }806807 if (added.length > 0) {808 await persistEntries();809 }810811 return added;812}813814815816817818819820821export async function restoreEntries(822 incoming: VaultEntry[],823 mode: "replace" | "merge"824): Promise<{ added: number; updated: number; skipped: number }> {825 if (!derivedKey) throw new Error("vault locked");826827 828 829 830 if (mode === "replace" && incoming.length === 0) {831 throw new Error("refuse to replace with empty entry set");832 }833834 let added = 0;835 let updated = 0;836 let skipped = 0;837838 if (mode === "replace") {839 entries = [];840 for (const entry of incoming) {841 const normalized = sanitizeRestoredEntry(entry);842 if (!normalized) {843 skipped += 1;844 continue;845 }846 entries.push(normalized);847 added += 1;848 }849 } else {850 const byId = new Map<string, VaultEntry>();851 for (const e of entries) byId.set(e.id, e);852853 for (const entry of incoming) {854 const normalized = sanitizeRestoredEntry(entry);855 if (!normalized) {856 skipped += 1;857 continue;858 }859 const existing = byId.get(normalized.id);860 if (!existing) {861 entries.push(normalized);862 added += 1;863 } else if (normalized.modifiedAt > existing.modifiedAt) {864 const idx = entries.findIndex((e) => e.id === existing.id);865 if (idx !== -1) {866 entries[idx] = normalized;867 updated += 1;868 }869 } else {870 skipped += 1;871 }872 }873 }874875 await persistEntries("import");876 return { added, updated, skipped };877}878879880881882883884function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {885 if (!entry || typeof entry !== "object") return null;886 const title = typeof entry.title === "string" ? entry.title.trim() : "";887 if (!title) return null;888 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();889 const now = Date.now();890 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;891 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;892893 894 895 896 const migrated: VaultEntry = migrateLegacyPasskey(897 {898 ...entry,899 id,900 title,901 type: entry.type ?? "login",902 createdAt,903 modifiedAt,904 },905 entry906 );907908 909 910 if (911 migrated.type === "login" &&912 !migrated.passkey &&913 typeof migrated.password !== "string"914 ) {915 return null;916 }917 if (migrated.passkey && !migrated.passkey.credentialId) {918 return null;919 }920921 return migrated;922}923924925926927928export async function clearVaultEntries(929 confirmation: "REPLACE_ALL"930): Promise<void> {931 if (confirmation !== "REPLACE_ALL") {932 throw new Error("clearVaultEntries requires explicit confirmation");933 }934 if (!derivedKey) throw new Error("vault locked");935 for (const entry of entries) {936 if (!entry.deletedAt) {937 entry.deletedAt = Date.now();938 entry.modifiedAt = Date.now();939 }940 }941 await persistEntries();942}943944export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {945 if (!derivedKey) throw new Error("vault locked");946947 const sanitized = sanitizeEntryDraft(entry);948 const now = Date.now();949 const nextEntry: VaultEntry = {950 ...sanitized,951 id: crypto.randomUUID(),952 createdAt: now,953 modifiedAt: now,954 955 956 passwordChangedAt: sanitized.password ? now : undefined,957 };958959 entries.push(nextEntry);960 await persistEntries();961962 963 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {964 checkBreach(nextEntry.password).then(async (count) => {965 if (count > 0) {966 const idx = entries.findIndex((e) => e.id === nextEntry.id);967 if (idx !== -1) {968 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };969 await persistEntries();970 }971 }972 });973 }974975 return nextEntry;976}977978export async function updateEntry(979 id: string,980 patch: Partial<VaultEntry>981): Promise<void> {982 if (!derivedKey) throw new Error("vault locked");983984 const idx = entries.findIndex((entry) => entry.id === id);985 if (idx === -1 || entries[idx].deletedAt) {986 throw new Error("entry not found");987 }988989 const current = entries[idx];990 const sanitizedPatch = sanitizeEntryPatch(patch);991 992 993 994 const passwordChanged =995 "password" in patch &&996 typeof sanitizedPatch.password === "string" &&997 sanitizedPatch.password !== current.password;998 entries[idx] = {999 ...current,1000 ...sanitizedPatch,1001 modifiedAt: Date.now(),1002 ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),1003 };10041005 1006 1007 1008 1009 if (1010 entries[idx].type === "login" &&1011 !entries[idx].password &&1012 !entries[idx].passkey1013 ) {1014 entries[idx] = current;1015 throw new Error("password or passkey required");1016 }10171018 1019 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1020 entries[idx] = current;1021 throw new Error("card number is required for card entries");1022 }10231024 await persistEntries();10251026 1027 1028 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1029 entries[idx] = { ...entries[idx], breach: undefined };1030 await persistEntries();1031 const password = entries[idx].password;1032 if (password) {1033 checkBreach(password).then(async (count) => {1034 const i = entries.findIndex((e) => e.id === id);1035 if (i !== -1 && count > 0) {1036 entries[i] = { ...entries[i], breach: count, modifiedAt: Date.now() };1037 await persistEntries();1038 }1039 });1040 }1041 }1042}10431044export async function deleteEntry(id: string): Promise<void> {1045 if (!derivedKey) throw new Error("vault locked");10461047 const entry = entries.find((item) => item.id === id);1048 if (!entry || entry.deletedAt) {1049 throw new Error("entry not found");1050 }10511052 entry.deletedAt = Date.now();1053 entry.modifiedAt = Date.now();1054 await persistEntries();1055}10561057export function getDeletedEntries(): VaultEntry[] {1058 if (!derivedKey) return [];1059 return entries.filter((e) => !!e.deletedAt);1060}10611062export async function restoreEntry(id: string): Promise<void> {1063 if (!derivedKey) throw new Error("vault locked");1064 const entry = entries.find((e) => e.id === id && e.deletedAt);1065 if (!entry) throw new Error("deleted entry not found");1066 delete entry.deletedAt;1067 entry.modifiedAt = Date.now();1068 await persistEntries();1069 buildDomainIndexSync(entries);1070}10711072export async function purgeEntry(id: string): Promise<void> {1073 if (!derivedKey) throw new Error("vault locked");1074 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1075 if (idx === -1) throw new Error("deleted entry not found");1076 entries.splice(idx, 1);1077 await persistEntries();1078}107910801081export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1082 if (!derivedKey || retentionMs === 0) return 0;1083 const cutoff = Date.now() - retentionMs;1084 const before = entries.length;1085 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1086 const purged = before - entries.length;1087 if (purged > 0) await persistEntries();1088 return purged;1089}10901091export function queryDomain(domain: string): VaultEntry[] {1092 return domainIndex.get(domain) ?? [];1093}10941095export function getEntryById(id: string): VaultEntry | undefined {1096 return entries.find((entry) => entry.id === id && !entry.deletedAt);1097}10981099async function persistSlotA(1100 reason: VaultSnapshot["reason"] = "local_save",1101): Promise<StoredVault> {1102 if (!derivedKey) throw new Error("vault locked");11031104 const slotA = await loadSlot(SLOT_A);1105 if (!slotA) throw new Error("missing slot A");11061107 1108 1109 await takeVaultSnapshot(reason);11101111 const plaintext = encodePayload(entries, duressConfigured);1112 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);11131114 const nextSlotA: StoredVault = {1115 ...slotA,1116 nonce,1117 ciphertext,1118 tag,1119 };1120 await writeSlot(SLOT_A, nextSlotA);11211122 1123 1124 1125 if (!duressConfigured) {1126 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1127 }11281129 return nextSlotA;1130}11311132async function persistSlotB(): Promise<void> {1133 if (!derivedKey) throw new Error("vault locked");11341135 const slotB = await loadSlot(SLOT_B);1136 if (!slotB) throw new Error("missing slot B");11371138 1139 1140 const plaintext = encodePayload(entries, false);1141 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);11421143 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1144}11451146async function persistEntries(1147 reason: VaultSnapshot["reason"] = "local_save",1148): Promise<void> {1149 if (!derivedKey) throw new Error("vault locked");11501151 if (vaultMode === "decoy") {1152 await persistSlotB();1153 } else {1154 await persistSlotA(reason);1155 }1156 buildDomainIndexSync(entries);1157}11581159function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1160 if (!Array.isArray(values)) return undefined;1161 const seen = new Set<string>();1162 const cleaned: string[] = [];1163 for (const value of values) {1164 const trimmed = typeof value === "string" ? value.trim() : "";1165 if (!trimmed) continue;1166 const key = trimmed.toLowerCase();1167 if (seen.has(key)) continue;1168 seen.add(key);1169 cleaned.push(trimmed);1170 }1171 return cleaned.length > 0 ? cleaned : undefined;1172}11731174function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1175 const title = entry.title.trim();1176 1177 1178 1179 let type: VaultEntry["type"] = entry.type ?? "login";1180 if (type === "passkey") type = "login";11811182 if (!title) throw new Error("title is required");1183 1184 1185 if (type === "login" && !entry.password?.trim() && !entry.passkey) {1186 throw new Error("password or passkey required");1187 }1188 if (entry.passkey) {1189 const p = entry.passkey;1190 if (1191 !p.rpId ||1192 !p.credentialId ||1193 !p.publicKeyCose ||1194 !p.userHandle ||1195 !p.userName ||1196 (p.algorithm !== -7 && p.algorithm !== -8)1197 ) {1198 throw new Error("invalid passkey credential");1199 }1200 }1201 if (type === "card") {1202 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1203 throw new Error("cardholder name and card number are required");1204 }1205 }12061207 return {1208 type,1209 title,1210 username: type === "login" ? entry.username?.trim() ?? "" : undefined,1211 password: type === "login" ? entry.password : undefined,1212 url: type === "login" ? entry.url?.trim() ?? "" : undefined,1213 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1214 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1215 notes: entry.notes?.trim() || undefined,1216 breach: type === "login" ? entry.breach : undefined,1217 canary: type === "login" ? entry.canary : undefined,1218 tags: entry.tags,1219 passkey: entry.passkey,1220 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1221 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1222 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1223 expYear: type === "card" ? entry.expYear?.trim() : undefined,1224 cvc: type === "card" ? entry.cvc?.trim() : undefined,1225 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1226 network: type === "card" ? entry.network : undefined,1227 last4: type === "card" ? entry.last4?.trim() : undefined,1228 };1229}12301231function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1232 const nextPatch: Partial<VaultEntry> = {};12331234 if ("type" in patch) {1235 nextPatch.type = patch.type;1236 }12371238 if ("title" in patch) {1239 const title = patch.title?.trim() ?? "";1240 if (!title) throw new Error("title is required");1241 nextPatch.title = title;1242 }12431244 if ("url" in patch) {1245 nextPatch.url = patch.url?.trim() ?? "";1246 }12471248 if ("additionalUrls" in patch) {1249 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1250 const seen = new Set<string>();1251 const cleaned: string[] = [];1252 for (const value of incoming) {1253 const trimmed = typeof value === "string" ? value.trim() : "";1254 if (!trimmed) continue;1255 const key = trimmed.toLowerCase();1256 if (seen.has(key)) continue;1257 seen.add(key);1258 cleaned.push(trimmed);1259 }1260 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1261 }12621263 if ("username" in patch) {1264 nextPatch.username = patch.username?.trim() ?? "";1265 }12661267 if ("password" in patch) {1268 const password = patch.password ?? "";1269 1270 nextPatch.password = password || undefined;1271 }12721273 if ("totp" in patch) {1274 nextPatch.totp = patch.totp?.trim() || undefined;1275 }12761277 if ("notes" in patch) {1278 nextPatch.notes = patch.notes?.trim() || undefined;1279 }12801281 if ("breach" in patch) {1282 nextPatch.breach = patch.breach;1283 }12841285 if ("canary" in patch) {1286 nextPatch.canary = patch.canary;1287 }12881289 if ("passkey" in patch) {1290 1291 1292 1293 nextPatch.passkey = patch.passkey;1294 }12951296 if ("tags" in patch) {1297 nextPatch.tags = patch.tags;1298 }12991300 if ("cardholderName" in patch) {1301 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1302 }1303 if ("cardNumber" in patch) {1304 const n = patch.cardNumber ?? "";1305 nextPatch.cardNumber = n || undefined;1306 }1307 if ("expMonth" in patch) {1308 nextPatch.expMonth = patch.expMonth?.trim() || undefined;1309 }1310 if ("expYear" in patch) {1311 nextPatch.expYear = patch.expYear?.trim() || undefined;1312 }1313 if ("cvc" in patch) {1314 nextPatch.cvc = patch.cvc?.trim() || undefined;1315 }1316 if ("cardNickname" in patch) {1317 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1318 }1319 if ("network" in patch) {1320 nextPatch.network = patch.network;1321 }1322 if ("last4" in patch) {1323 nextPatch.last4 = patch.last4?.trim() || undefined;1324 }13251326 return nextPatch;1327}13281329function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1330 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1331 const modifiedAt =1332 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;13331334 const base: VaultEntry = {1335 id: entry.id,1336 type: "type" in entry ? entry.type : "login",1337 title: entry.title,1338 username: entry.username,1339 password: entry.password,1340 url: entry.url,1341 totp: entry.totp,1342 notes: entry.notes,1343 breach: entry.breach,1344 canary: "canary" in entry ? entry.canary : undefined,1345 passkey: "passkey" in entry ? entry.passkey : undefined,1346 createdAt,1347 modifiedAt,1348 deletedAt: entry.deletedAt,1349 tags: "tags" in entry ? entry.tags : undefined,1350 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1351 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1352 expMonth: "expMonth" in entry ? entry.expMonth : undefined,1353 expYear: "expYear" in entry ? entry.expYear : undefined,1354 cvc: "cvc" in entry ? entry.cvc : undefined,1355 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1356 network: "network" in entry ? entry.network : undefined,1357 last4: "last4" in entry ? entry.last4 : undefined,1358 };13591360 return migrateLegacyPasskey(base, entry);1361}136213631364136513661367136813691370function migrateLegacyPasskey(1371 base: VaultEntry,1372 raw: VaultEntry | LegacyVaultEntry1373): VaultEntry {1374 if (base.passkey) return base;1375 const flat = raw as Partial<VaultEntry>;1376 const isPasskeyType = base.type === "passkey";1377 const hasFlatPasskeyFields =1378 typeof flat.rpId === "string" &&1379 typeof flat.credentialId === "string" &&1380 typeof flat.publicKeyCose === "string" &&1381 typeof flat.userHandle === "string" &&1382 typeof flat.userName === "string";1383 if (!isPasskeyType && !hasFlatPasskeyFields) return base;1384 if (!hasFlatPasskeyFields) return base;13851386 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;1387 base.passkey = {1388 rpId: flat.rpId!,1389 rpName: flat.rpName ?? flat.rpId!,1390 credentialId: flat.credentialId!,1391 publicKeyCose: flat.publicKeyCose!,1392 privateKeyJwk: flat.privateKeyJwk,1393 userHandle: flat.userHandle!,1394 userName: flat.userName!,1395 userDisplayName: flat.userDisplayName ?? flat.userName!,1396 algorithm,1397 signCount: flat.signCount ?? 0,1398 aaguid: flat.aaguid ?? "",1399 backupEligible: flat.backupEligible === true,1400 backupState: flat.backupState === true,1401 lastUsedAt: flat.lastUsedAt,1402 browser: flat.browser,1403 transports: flat.transports,1404 createdAt: base.createdAt,1405 };1406 1407 1408 1409 if (isPasskeyType) base.type = "login";1410 if (!base.username) base.username = flat.userName;1411 if (!base.url) base.url = `https://${flat.rpId}`;1412 return base;1413}1414141514161417141814191420function entryDupeKey(e: VaultEntry): string {1421 if (e.type === "note") return `note|${e.title.toLowerCase()}`;1422 1423 1424 1425 1426 if (e.passkey) {1427 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;1428 }1429 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1430}143114321433export function countDuplicates(): number {1434 const best = new Map<string, VaultEntry>();1435 let dupeCount = 0;1436 for (const e of entries.filter((e) => !e.deletedAt)) {1437 const key = entryDupeKey(e);1438 const existing = best.get(key);1439 if (!existing) {1440 best.set(key, e);1441 } else {1442 dupeCount++;1443 1444 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1445 best.set(key, e);1446 }1447 }1448 }1449 return dupeCount;1450}145114521453145414551456export async function deduplicateEntries(): Promise<number> {1457 if (!derivedKey) throw new Error("vault locked");1458 1459 1460 if (vaultMode === "decoy") {1461 throw new Error("deduplicate disabled in decoy mode");1462 }14631464 const best = new Map<string, VaultEntry>();1465 for (const e of entries.filter((en) => !en.deletedAt)) {1466 const key = entryDupeKey(e);1467 const existing = best.get(key);1468 if (!existing) {1469 best.set(key, e);1470 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1471 best.set(key, e);1472 }1473 }14741475 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1476 const now = Date.now();1477 let removed = 0;1478 entries = entries.map((e) => {1479 if (!e.deletedAt && !keepIds.has(e.id)) {1480 removed++;1481 return { ...e, deletedAt: now };1482 }1483 return e;1484 });14851486 if (removed > 0) await persistEntries();1487 return removed;1488}148914901491149214931494149514961497const HISTORY_KEY = "peach_vault_history";1498const HISTORY_MAX = 10;14991500150115021503150415051506export async function takeVaultSnapshot(1507 reason: VaultSnapshot["reason"],1508): Promise<void> {1509 try {1510 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);1511 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];1512 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];15131514 1515 1516 1517 if (!slotA) return;15181519 const vault_a = JSON.stringify(slotA);1520 const vault_b = JSON.stringify(slotB ?? slotA);15211522 1523 1524 1525 let entryCount: number | null = null;1526 if (derivedKey && vaultMode === "real") {1527 try {1528 entryCount = entries.filter((e) => !e.deletedAt).length;1529 } catch {1530 entryCount = null;1531 }1532 }15331534 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];1535 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)1536 ? existingRaw1537 : { snapshots: [] };15381539 1540 1541 1542 const latest = history.snapshots[0];1543 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {1544 return;1545 }15461547 const snapshot: VaultSnapshot = {1548 id: crypto.randomUUID(),1549 savedAt: Date.now(),1550 reason,1551 vault_a,1552 vault_b,1553 entryCount,1554 };15551556 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);1557 await chrome.storage.local.set({ [HISTORY_KEY]: history });1558 } catch {1559 1560 1561 }1562}156315641565export async function clearVaultHistory(): Promise<void> {1566 try {1567 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });1568 } catch {1569 1570 }1571}157215731574export async function getVaultHistory(): Promise<VaultSnapshot[]> {1575 const raw = await chrome.storage.local.get(HISTORY_KEY);1576 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];1577 if (!history || !Array.isArray(history.snapshots)) return [];1578 return history.snapshots;1579}15801581158215831584158515861587export async function restoreVaultSnapshot(1588 snapshotId: string,1589): Promise<{ ok: true } | { ok: false; error: string }> {1590 const history = await getVaultHistory();1591 const snapshot = history.find((s) => s.id === snapshotId);1592 if (!snapshot) return { ok: false, error: "snapshot_not_found" };15931594 1595 1596 1597 await takeVaultSnapshot("restore");15981599 let restoredA: StoredVault;1600 let restoredB: StoredVault;1601 try {1602 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;1603 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;1604 } catch {1605 return { ok: false, error: "snapshot_corrupt" };1606 }16071608 await writeSlot(SLOT_A, restoredA);1609 await writeSlot(SLOT_B, restoredB);16101611 1612 1613 1614 lock();16151616 return { ok: true };1617}16181619162016211622162316241625export async function changePassword(1626 currentPassword: string,1627 newPassword: string1628): Promise<boolean> {1629 if (!derivedKey || vaultMode !== "real") return false;16301631 1632 const valid = await verifyMainPassword(currentPassword);1633 if (!valid) return false;16341635 const slotA = await loadSlot(SLOT_A);1636 if (!slotA) return false;16371638 1639 const newSalt = generateSalt();1640 const newKey = await deriveKey(newPassword, newSalt);16411642 1643 const plaintext = encodePayload(entries, duressConfigured);1644 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);16451646 const freshSlotA: StoredVault = {1647 ...slotA,1648 salt: hex(newSalt),1649 nonce,1650 ciphertext,1651 tag,1652 };1653 1654 1655 1656 await takeVaultSnapshot("local_save");1657 await writeSlot(SLOT_A, freshSlotA);16581659 1660 1661 1662 zeroKey(derivedKey);1663 derivedKey = newKey;16641665 1666 1667 1668 if (!duressConfigured) {1669 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1670 }16711672 return true;1673}