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; entries: VaultEntry[] } | { ok: false; error: string }> {710 try {711 const masterKeyBytes = unhex(masterKeyHex);712 const stored = await loadSlot(SLOT_A);713 if (!stored) return { ok: false, error: "invalid_key" };714 const payload = await tryDecryptSlot(stored, masterKeyBytes);715 if (!payload) return { ok: false, error: "invalid_key" };716717 derivedKey = masterKeyBytes;718 vaultMode = "real";719 entries = payload.entries;720 duressConfigured = payload.duressConfigured;721 buildDomainIndexSync(entries);722 await loadSettings();723 touchAutoLock();724 return { ok: true, entries };725 } catch {726 return { ok: false, error: "invalid_key" };727 }728}729730731732export function getDerivedKeyHex(): string {733 if (!derivedKey) throw new Error("vault_not_unlocked");734 return hex(derivedKey);735}736737export function getEntries(): VaultEntry[] {738 if (!derivedKey) throw new Error("vault locked");739 return entries.filter((entry) => !entry.deletedAt);740}741742const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;743744export function getIdentities(): SavedIdentity[] {745 if (!derivedKey) throw new Error("vault locked");746 const active = entries.filter((e) => !e.deletedAt && e.type === "login");747748 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();749 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();750751 for (const entry of active) {752 const value = entry.username?.trim();753 if (!value) continue;754 const map = EMAIL_RE.test(value) ? emailMap : usernameMap;755 const existing = map.get(value);756 const domain = extractDomain(entry.url ?? "") ?? "";757 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;758 if (!existing || modifiedAt > existing.lastUsed) {759 map.set(value, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });760 } else {761 map.set(value, { ...existing, count: existing.count + 1 });762 }763 }764765 const toIdentity = (type: "email" | "username") =>766 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({767 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,768 });769770 return [771 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),772 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),773 ];774}775776export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {777 if (!derivedKey) throw new Error("vault locked");778779 const now = Date.now();780 const added: VaultEntry[] = [];781 for (const draft of drafts) {782 try {783 const sanitized = sanitizeEntryDraft(draft);784 const nextEntry: VaultEntry = {785 ...sanitized,786 id: crypto.randomUUID(),787 createdAt: now,788 modifiedAt: now,789 };790 entries.push(nextEntry);791 added.push(nextEntry);792 } catch {793 794 }795 }796797 if (added.length > 0) {798 await persistEntries();799 }800801 return added;802}803804805806807808809810811export async function restoreEntries(812 incoming: VaultEntry[],813 mode: "replace" | "merge"814): Promise<{ added: number; updated: number; skipped: number }> {815 if (!derivedKey) throw new Error("vault locked");816817 818 819 820 if (mode === "replace" && incoming.length === 0) {821 throw new Error("refuse to replace with empty entry set");822 }823824 let added = 0;825 let updated = 0;826 let skipped = 0;827828 if (mode === "replace") {829 entries = [];830 for (const entry of incoming) {831 const normalized = sanitizeRestoredEntry(entry);832 if (!normalized) {833 skipped += 1;834 continue;835 }836 entries.push(normalized);837 added += 1;838 }839 } else {840 const byId = new Map<string, VaultEntry>();841 for (const e of entries) byId.set(e.id, e);842843 for (const entry of incoming) {844 const normalized = sanitizeRestoredEntry(entry);845 if (!normalized) {846 skipped += 1;847 continue;848 }849 const existing = byId.get(normalized.id);850 if (!existing) {851 entries.push(normalized);852 added += 1;853 } else if (normalized.modifiedAt > existing.modifiedAt) {854 const idx = entries.findIndex((e) => e.id === existing.id);855 if (idx !== -1) {856 entries[idx] = normalized;857 updated += 1;858 }859 } else {860 skipped += 1;861 }862 }863 }864865 await persistEntries("import");866 return { added, updated, skipped };867}868869870871872873874function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {875 if (!entry || typeof entry !== "object") return null;876 const title = typeof entry.title === "string" ? entry.title.trim() : "";877 if (!title) return null;878 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();879 const now = Date.now();880 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;881 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;882883 884 885 886 const migrated: VaultEntry = migrateLegacyPasskey(887 {888 ...entry,889 id,890 title,891 type: entry.type ?? "login",892 createdAt,893 modifiedAt,894 },895 entry896 );897898 899 900 if (901 migrated.type === "login" &&902 !migrated.passkey &&903 typeof migrated.password !== "string"904 ) {905 return null;906 }907 if (migrated.passkey && !migrated.passkey.credentialId) {908 return null;909 }910911 return migrated;912}913914915916917918export async function clearVaultEntries(919 confirmation: "REPLACE_ALL"920): Promise<void> {921 if (confirmation !== "REPLACE_ALL") {922 throw new Error("clearVaultEntries requires explicit confirmation");923 }924 if (!derivedKey) throw new Error("vault locked");925 for (const entry of entries) {926 if (!entry.deletedAt) {927 entry.deletedAt = Date.now();928 entry.modifiedAt = Date.now();929 }930 }931 await persistEntries();932}933934export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {935 if (!derivedKey) throw new Error("vault locked");936937 const sanitized = sanitizeEntryDraft(entry);938 const now = Date.now();939 const nextEntry: VaultEntry = {940 ...sanitized,941 id: crypto.randomUUID(),942 createdAt: now,943 modifiedAt: now,944 945 946 passwordChangedAt: sanitized.password ? now : undefined,947 };948949 entries.push(nextEntry);950 await persistEntries();951952 953 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {954 checkBreach(nextEntry.password).then(async (count) => {955 if (count > 0) {956 const idx = entries.findIndex((e) => e.id === nextEntry.id);957 if (idx !== -1) {958 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };959 await persistEntries();960 }961 }962 });963 }964965 return nextEntry;966}967968export async function updateEntry(969 id: string,970 patch: Partial<VaultEntry>971): Promise<void> {972 if (!derivedKey) throw new Error("vault locked");973974 const idx = entries.findIndex((entry) => entry.id === id);975 if (idx === -1 || entries[idx].deletedAt) {976 throw new Error("entry not found");977 }978979 const current = entries[idx];980 const sanitizedPatch = sanitizeEntryPatch(patch);981 982 983 984 const passwordChanged =985 "password" in patch &&986 typeof sanitizedPatch.password === "string" &&987 sanitizedPatch.password !== current.password;988 entries[idx] = {989 ...current,990 ...sanitizedPatch,991 modifiedAt: Date.now(),992 ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),993 };994995 996 997 998 999 if (1000 entries[idx].type === "login" &&1001 !entries[idx].password &&1002 !entries[idx].passkey1003 ) {1004 entries[idx] = current;1005 throw new Error("password or passkey required");1006 }10071008 1009 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1010 entries[idx] = current;1011 throw new Error("card number is required for card entries");1012 }10131014 await persistEntries();10151016 1017 1018 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1019 entries[idx] = { ...entries[idx], breach: undefined };1020 await persistEntries();1021 const password = entries[idx].password;1022 if (password) {1023 checkBreach(password).then(async (count) => {1024 const i = entries.findIndex((e) => e.id === id);1025 if (i !== -1 && count > 0) {1026 entries[i] = { ...entries[i], breach: count, modifiedAt: Date.now() };1027 await persistEntries();1028 }1029 });1030 }1031 }1032}10331034export async function deleteEntry(id: string): Promise<void> {1035 if (!derivedKey) throw new Error("vault locked");10361037 const entry = entries.find((item) => item.id === id);1038 if (!entry || entry.deletedAt) {1039 throw new Error("entry not found");1040 }10411042 entry.deletedAt = Date.now();1043 entry.modifiedAt = Date.now();1044 await persistEntries();1045}10461047export function getDeletedEntries(): VaultEntry[] {1048 if (!derivedKey) return [];1049 return entries.filter((e) => !!e.deletedAt);1050}10511052export async function restoreEntry(id: string): Promise<void> {1053 if (!derivedKey) throw new Error("vault locked");1054 const entry = entries.find((e) => e.id === id && e.deletedAt);1055 if (!entry) throw new Error("deleted entry not found");1056 delete entry.deletedAt;1057 entry.modifiedAt = Date.now();1058 await persistEntries();1059 buildDomainIndexSync(entries);1060}10611062export async function purgeEntry(id: string): Promise<void> {1063 if (!derivedKey) throw new Error("vault locked");1064 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1065 if (idx === -1) throw new Error("deleted entry not found");1066 entries.splice(idx, 1);1067 await persistEntries();1068}106910701071export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1072 if (!derivedKey || retentionMs === 0) return 0;1073 const cutoff = Date.now() - retentionMs;1074 const before = entries.length;1075 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1076 const purged = before - entries.length;1077 if (purged > 0) await persistEntries();1078 return purged;1079}10801081export function queryDomain(domain: string): VaultEntry[] {1082 return domainIndex.get(domain) ?? [];1083}10841085export function getEntryById(id: string): VaultEntry | undefined {1086 return entries.find((entry) => entry.id === id && !entry.deletedAt);1087}10881089async function persistSlotA(1090 reason: VaultSnapshot["reason"] = "local_save",1091): Promise<StoredVault> {1092 if (!derivedKey) throw new Error("vault locked");10931094 const slotA = await loadSlot(SLOT_A);1095 if (!slotA) throw new Error("missing slot A");10961097 1098 1099 await takeVaultSnapshot(reason);11001101 const plaintext = encodePayload(entries, duressConfigured);1102 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);11031104 const nextSlotA: StoredVault = {1105 ...slotA,1106 nonce,1107 ciphertext,1108 tag,1109 };1110 await writeSlot(SLOT_A, nextSlotA);11111112 1113 1114 1115 if (!duressConfigured) {1116 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1117 }11181119 return nextSlotA;1120}11211122async function persistSlotB(): Promise<void> {1123 if (!derivedKey) throw new Error("vault locked");11241125 const slotB = await loadSlot(SLOT_B);1126 if (!slotB) throw new Error("missing slot B");11271128 1129 1130 const plaintext = encodePayload(entries, false);1131 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);11321133 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1134}11351136async function persistEntries(1137 reason: VaultSnapshot["reason"] = "local_save",1138): Promise<void> {1139 if (!derivedKey) throw new Error("vault locked");11401141 if (vaultMode === "decoy") {1142 await persistSlotB();1143 } else {1144 await persistSlotA(reason);1145 }1146 buildDomainIndexSync(entries);1147}11481149function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1150 if (!Array.isArray(values)) return undefined;1151 const seen = new Set<string>();1152 const cleaned: string[] = [];1153 for (const value of values) {1154 const trimmed = typeof value === "string" ? value.trim() : "";1155 if (!trimmed) continue;1156 const key = trimmed.toLowerCase();1157 if (seen.has(key)) continue;1158 seen.add(key);1159 cleaned.push(trimmed);1160 }1161 return cleaned.length > 0 ? cleaned : undefined;1162}11631164function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1165 const title = entry.title.trim();1166 1167 1168 1169 let type: VaultEntry["type"] = entry.type ?? "login";1170 if (type === "passkey") type = "login";11711172 if (!title) throw new Error("title is required");1173 1174 1175 if (type === "login" && !entry.password?.trim() && !entry.passkey) {1176 throw new Error("password or passkey required");1177 }1178 if (entry.passkey) {1179 const p = entry.passkey;1180 if (1181 !p.rpId ||1182 !p.credentialId ||1183 !p.publicKeyCose ||1184 !p.userHandle ||1185 !p.userName ||1186 (p.algorithm !== -7 && p.algorithm !== -8)1187 ) {1188 throw new Error("invalid passkey credential");1189 }1190 }1191 if (type === "card") {1192 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1193 throw new Error("cardholder name and card number are required");1194 }1195 }11961197 return {1198 type,1199 title,1200 username: type === "login" ? entry.username?.trim() ?? "" : undefined,1201 password: type === "login" ? entry.password : undefined,1202 url: type === "login" ? entry.url?.trim() ?? "" : undefined,1203 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1204 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1205 notes: entry.notes?.trim() || undefined,1206 breach: type === "login" ? entry.breach : undefined,1207 canary: type === "login" ? entry.canary : undefined,1208 tags: entry.tags,1209 passkey: entry.passkey,1210 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1211 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1212 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1213 expYear: type === "card" ? entry.expYear?.trim() : undefined,1214 cvc: type === "card" ? entry.cvc?.trim() : undefined,1215 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1216 network: type === "card" ? entry.network : undefined,1217 last4: type === "card" ? entry.last4?.trim() : undefined,1218 };1219}12201221function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1222 const nextPatch: Partial<VaultEntry> = {};12231224 if ("type" in patch) {1225 nextPatch.type = patch.type;1226 }12271228 if ("title" in patch) {1229 const title = patch.title?.trim() ?? "";1230 if (!title) throw new Error("title is required");1231 nextPatch.title = title;1232 }12331234 if ("url" in patch) {1235 nextPatch.url = patch.url?.trim() ?? "";1236 }12371238 if ("additionalUrls" in patch) {1239 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1240 const seen = new Set<string>();1241 const cleaned: string[] = [];1242 for (const value of incoming) {1243 const trimmed = typeof value === "string" ? value.trim() : "";1244 if (!trimmed) continue;1245 const key = trimmed.toLowerCase();1246 if (seen.has(key)) continue;1247 seen.add(key);1248 cleaned.push(trimmed);1249 }1250 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1251 }12521253 if ("username" in patch) {1254 nextPatch.username = patch.username?.trim() ?? "";1255 }12561257 if ("password" in patch) {1258 const password = patch.password ?? "";1259 1260 nextPatch.password = password || undefined;1261 }12621263 if ("totp" in patch) {1264 nextPatch.totp = patch.totp?.trim() || undefined;1265 }12661267 if ("notes" in patch) {1268 nextPatch.notes = patch.notes?.trim() || undefined;1269 }12701271 if ("breach" in patch) {1272 nextPatch.breach = patch.breach;1273 }12741275 if ("canary" in patch) {1276 nextPatch.canary = patch.canary;1277 }12781279 if ("passkey" in patch) {1280 1281 1282 1283 nextPatch.passkey = patch.passkey;1284 }12851286 if ("tags" in patch) {1287 nextPatch.tags = patch.tags;1288 }12891290 if ("cardholderName" in patch) {1291 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1292 }1293 if ("cardNumber" in patch) {1294 const n = patch.cardNumber ?? "";1295 nextPatch.cardNumber = n || undefined;1296 }1297 if ("expMonth" in patch) {1298 nextPatch.expMonth = patch.expMonth?.trim() || undefined;1299 }1300 if ("expYear" in patch) {1301 nextPatch.expYear = patch.expYear?.trim() || undefined;1302 }1303 if ("cvc" in patch) {1304 nextPatch.cvc = patch.cvc?.trim() || undefined;1305 }1306 if ("cardNickname" in patch) {1307 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1308 }1309 if ("network" in patch) {1310 nextPatch.network = patch.network;1311 }1312 if ("last4" in patch) {1313 nextPatch.last4 = patch.last4?.trim() || undefined;1314 }13151316 return nextPatch;1317}13181319function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1320 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1321 const modifiedAt =1322 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;13231324 const base: VaultEntry = {1325 id: entry.id,1326 type: "type" in entry ? entry.type : "login",1327 title: entry.title,1328 username: entry.username,1329 password: entry.password,1330 url: entry.url,1331 totp: entry.totp,1332 notes: entry.notes,1333 breach: entry.breach,1334 canary: "canary" in entry ? entry.canary : undefined,1335 passkey: "passkey" in entry ? entry.passkey : undefined,1336 createdAt,1337 modifiedAt,1338 deletedAt: entry.deletedAt,1339 tags: "tags" in entry ? entry.tags : undefined,1340 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1341 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1342 expMonth: "expMonth" in entry ? entry.expMonth : undefined,1343 expYear: "expYear" in entry ? entry.expYear : undefined,1344 cvc: "cvc" in entry ? entry.cvc : undefined,1345 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1346 network: "network" in entry ? entry.network : undefined,1347 last4: "last4" in entry ? entry.last4 : undefined,1348 };13491350 return migrateLegacyPasskey(base, entry);1351}135213531354135513561357135813591360function migrateLegacyPasskey(1361 base: VaultEntry,1362 raw: VaultEntry | LegacyVaultEntry1363): VaultEntry {1364 if (base.passkey) return base;1365 const flat = raw as Partial<VaultEntry>;1366 const isPasskeyType = base.type === "passkey";1367 const hasFlatPasskeyFields =1368 typeof flat.rpId === "string" &&1369 typeof flat.credentialId === "string" &&1370 typeof flat.publicKeyCose === "string" &&1371 typeof flat.userHandle === "string" &&1372 typeof flat.userName === "string";1373 if (!isPasskeyType && !hasFlatPasskeyFields) return base;1374 if (!hasFlatPasskeyFields) return base;13751376 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;1377 base.passkey = {1378 rpId: flat.rpId!,1379 rpName: flat.rpName ?? flat.rpId!,1380 credentialId: flat.credentialId!,1381 publicKeyCose: flat.publicKeyCose!,1382 privateKeyJwk: flat.privateKeyJwk,1383 userHandle: flat.userHandle!,1384 userName: flat.userName!,1385 userDisplayName: flat.userDisplayName ?? flat.userName!,1386 algorithm,1387 signCount: flat.signCount ?? 0,1388 aaguid: flat.aaguid ?? "",1389 backupEligible: flat.backupEligible === true,1390 backupState: flat.backupState === true,1391 lastUsedAt: flat.lastUsedAt,1392 browser: flat.browser,1393 transports: flat.transports,1394 createdAt: base.createdAt,1395 };1396 1397 1398 1399 if (isPasskeyType) base.type = "login";1400 if (!base.username) base.username = flat.userName;1401 if (!base.url) base.url = `https://${flat.rpId}`;1402 return base;1403}1404140514061407140814091410function entryDupeKey(e: VaultEntry): string {1411 if (e.type === "note") return `note|${e.title.toLowerCase()}`;1412 1413 1414 1415 1416 if (e.passkey) {1417 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;1418 }1419 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1420}142114221423export function countDuplicates(): number {1424 const best = new Map<string, VaultEntry>();1425 let dupeCount = 0;1426 for (const e of entries.filter((e) => !e.deletedAt)) {1427 const key = entryDupeKey(e);1428 const existing = best.get(key);1429 if (!existing) {1430 best.set(key, e);1431 } else {1432 dupeCount++;1433 1434 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1435 best.set(key, e);1436 }1437 }1438 }1439 return dupeCount;1440}144114421443144414451446export async function deduplicateEntries(): Promise<number> {1447 if (!derivedKey) throw new Error("vault locked");1448 1449 1450 if (vaultMode === "decoy") {1451 throw new Error("deduplicate disabled in decoy mode");1452 }14531454 const best = new Map<string, VaultEntry>();1455 for (const e of entries.filter((en) => !en.deletedAt)) {1456 const key = entryDupeKey(e);1457 const existing = best.get(key);1458 if (!existing) {1459 best.set(key, e);1460 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1461 best.set(key, e);1462 }1463 }14641465 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1466 const now = Date.now();1467 let removed = 0;1468 entries = entries.map((e) => {1469 if (!e.deletedAt && !keepIds.has(e.id)) {1470 removed++;1471 return { ...e, deletedAt: now };1472 }1473 return e;1474 });14751476 if (removed > 0) await persistEntries();1477 return removed;1478}147914801481148214831484148514861487const HISTORY_KEY = "peach_vault_history";1488const HISTORY_MAX = 10;14891490149114921493149414951496export async function takeVaultSnapshot(1497 reason: VaultSnapshot["reason"],1498): Promise<void> {1499 try {1500 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);1501 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];1502 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];15031504 1505 1506 1507 if (!slotA) return;15081509 const vault_a = JSON.stringify(slotA);1510 const vault_b = JSON.stringify(slotB ?? slotA);15111512 1513 1514 1515 let entryCount: number | null = null;1516 if (derivedKey && vaultMode === "real") {1517 try {1518 entryCount = entries.filter((e) => !e.deletedAt).length;1519 } catch {1520 entryCount = null;1521 }1522 }15231524 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];1525 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)1526 ? existingRaw1527 : { snapshots: [] };15281529 1530 1531 1532 const latest = history.snapshots[0];1533 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {1534 return;1535 }15361537 const snapshot: VaultSnapshot = {1538 id: crypto.randomUUID(),1539 savedAt: Date.now(),1540 reason,1541 vault_a,1542 vault_b,1543 entryCount,1544 };15451546 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);1547 await chrome.storage.local.set({ [HISTORY_KEY]: history });1548 } catch {1549 1550 1551 }1552}155315541555export async function clearVaultHistory(): Promise<void> {1556 try {1557 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });1558 } catch {1559 1560 }1561}156215631564export async function getVaultHistory(): Promise<VaultSnapshot[]> {1565 const raw = await chrome.storage.local.get(HISTORY_KEY);1566 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];1567 if (!history || !Array.isArray(history.snapshots)) return [];1568 return history.snapshots;1569}15701571157215731574157515761577export async function restoreVaultSnapshot(1578 snapshotId: string,1579): Promise<{ ok: true } | { ok: false; error: string }> {1580 const history = await getVaultHistory();1581 const snapshot = history.find((s) => s.id === snapshotId);1582 if (!snapshot) return { ok: false, error: "snapshot_not_found" };15831584 1585 1586 1587 await takeVaultSnapshot("restore");15881589 let restoredA: StoredVault;1590 let restoredB: StoredVault;1591 try {1592 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;1593 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;1594 } catch {1595 return { ok: false, error: "snapshot_corrupt" };1596 }15971598 await writeSlot(SLOT_A, restoredA);1599 await writeSlot(SLOT_B, restoredB);16001601 1602 1603 1604 lock();16051606 return { ok: true };1607}16081609161016111612161316141615export async function changePassword(1616 currentPassword: string,1617 newPassword: string1618): Promise<boolean> {1619 if (!derivedKey || vaultMode !== "real") return false;16201621 1622 const valid = await verifyMainPassword(currentPassword);1623 if (!valid) return false;16241625 const slotA = await loadSlot(SLOT_A);1626 if (!slotA) return false;16271628 1629 const newSalt = generateSalt();1630 const newKey = await deriveKey(newPassword, newSalt);16311632 1633 const plaintext = encodePayload(entries, duressConfigured);1634 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);16351636 const freshSlotA: StoredVault = {1637 ...slotA,1638 salt: hex(newSalt),1639 nonce,1640 ciphertext,1641 tag,1642 };1643 1644 1645 1646 await takeVaultSnapshot("local_save");1647 await writeSlot(SLOT_A, freshSlotA);16481649 1650 1651 1652 zeroKey(derivedKey);1653 derivedKey = newKey;16541655 1656 1657 1658 if (!duressConfigured) {1659 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1660 }16611662 return true;1663}