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}704705export function getEntries(): VaultEntry[] {706 if (!derivedKey) throw new Error("vault locked");707 return entries.filter((entry) => !entry.deletedAt);708}709710const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;711712export function getIdentities(): SavedIdentity[] {713 if (!derivedKey) throw new Error("vault locked");714 const active = entries.filter((e) => !e.deletedAt && e.type === "login");715716 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();717 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();718719 for (const entry of active) {720 const value = entry.username?.trim();721 if (!value) continue;722 const map = EMAIL_RE.test(value) ? emailMap : usernameMap;723 const existing = map.get(value);724 const domain = extractDomain(entry.url ?? "") ?? "";725 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;726 if (!existing || modifiedAt > existing.lastUsed) {727 map.set(value, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });728 } else {729 map.set(value, { ...existing, count: existing.count + 1 });730 }731 }732733 const toIdentity = (type: "email" | "username") =>734 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({735 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,736 });737738 return [739 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),740 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),741 ];742}743744export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {745 if (!derivedKey) throw new Error("vault locked");746747 const now = Date.now();748 const added: VaultEntry[] = [];749 for (const draft of drafts) {750 try {751 const sanitized = sanitizeEntryDraft(draft);752 const nextEntry: VaultEntry = {753 ...sanitized,754 id: crypto.randomUUID(),755 createdAt: now,756 modifiedAt: now,757 };758 entries.push(nextEntry);759 added.push(nextEntry);760 } catch {761 762 }763 }764765 if (added.length > 0) {766 await persistEntries();767 }768769 return added;770}771772773774775776777778779export async function restoreEntries(780 incoming: VaultEntry[],781 mode: "replace" | "merge"782): Promise<{ added: number; updated: number; skipped: number }> {783 if (!derivedKey) throw new Error("vault locked");784785 786 787 788 if (mode === "replace" && incoming.length === 0) {789 throw new Error("refuse to replace with empty entry set");790 }791792 let added = 0;793 let updated = 0;794 let skipped = 0;795796 if (mode === "replace") {797 entries = [];798 for (const entry of incoming) {799 const normalized = sanitizeRestoredEntry(entry);800 if (!normalized) {801 skipped += 1;802 continue;803 }804 entries.push(normalized);805 added += 1;806 }807 } else {808 const byId = new Map<string, VaultEntry>();809 for (const e of entries) byId.set(e.id, e);810811 for (const entry of incoming) {812 const normalized = sanitizeRestoredEntry(entry);813 if (!normalized) {814 skipped += 1;815 continue;816 }817 const existing = byId.get(normalized.id);818 if (!existing) {819 entries.push(normalized);820 added += 1;821 } else if (normalized.modifiedAt > existing.modifiedAt) {822 const idx = entries.findIndex((e) => e.id === existing.id);823 if (idx !== -1) {824 entries[idx] = normalized;825 updated += 1;826 }827 } else {828 skipped += 1;829 }830 }831 }832833 await persistEntries("import");834 return { added, updated, skipped };835}836837838839840841842function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {843 if (!entry || typeof entry !== "object") return null;844 const title = typeof entry.title === "string" ? entry.title.trim() : "";845 if (!title) return null;846 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();847 const now = Date.now();848 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;849 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;850851 852 853 854 const migrated: VaultEntry = migrateLegacyPasskey(855 {856 ...entry,857 id,858 title,859 type: entry.type ?? "login",860 createdAt,861 modifiedAt,862 },863 entry864 );865866 867 868 if (869 migrated.type === "login" &&870 !migrated.passkey &&871 typeof migrated.password !== "string"872 ) {873 return null;874 }875 if (migrated.passkey && !migrated.passkey.credentialId) {876 return null;877 }878879 return migrated;880}881882883884885886export async function clearVaultEntries(887 confirmation: "REPLACE_ALL"888): Promise<void> {889 if (confirmation !== "REPLACE_ALL") {890 throw new Error("clearVaultEntries requires explicit confirmation");891 }892 if (!derivedKey) throw new Error("vault locked");893 for (const entry of entries) {894 if (!entry.deletedAt) {895 entry.deletedAt = Date.now();896 entry.modifiedAt = Date.now();897 }898 }899 await persistEntries();900}901902export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {903 if (!derivedKey) throw new Error("vault locked");904905 const sanitized = sanitizeEntryDraft(entry);906 const now = Date.now();907 const nextEntry: VaultEntry = {908 ...sanitized,909 id: crypto.randomUUID(),910 createdAt: now,911 modifiedAt: now,912 913 914 passwordChangedAt: sanitized.password ? now : undefined,915 };916917 entries.push(nextEntry);918 await persistEntries();919920 921 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {922 checkBreach(nextEntry.password).then(async (count) => {923 if (count > 0) {924 const idx = entries.findIndex((e) => e.id === nextEntry.id);925 if (idx !== -1) {926 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };927 await persistEntries();928 }929 }930 });931 }932933 return nextEntry;934}935936export async function updateEntry(937 id: string,938 patch: Partial<VaultEntry>939): Promise<void> {940 if (!derivedKey) throw new Error("vault locked");941942 const idx = entries.findIndex((entry) => entry.id === id);943 if (idx === -1 || entries[idx].deletedAt) {944 throw new Error("entry not found");945 }946947 const current = entries[idx];948 const sanitizedPatch = sanitizeEntryPatch(patch);949 950 951 952 const passwordChanged =953 "password" in patch &&954 typeof sanitizedPatch.password === "string" &&955 sanitizedPatch.password !== current.password;956 entries[idx] = {957 ...current,958 ...sanitizedPatch,959 modifiedAt: Date.now(),960 ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),961 };962963 964 965 966 967 if (968 entries[idx].type === "login" &&969 !entries[idx].password &&970 !entries[idx].passkey971 ) {972 entries[idx] = current;973 throw new Error("password or passkey required");974 }975976 977 if (entries[idx].type === "card" && !entries[idx].cardNumber) {978 entries[idx] = current;979 throw new Error("card number is required for card entries");980 }981982 await persistEntries();983984 985 986 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {987 entries[idx] = { ...entries[idx], breach: undefined };988 await persistEntries();989 const password = entries[idx].password;990 if (password) {991 checkBreach(password).then(async (count) => {992 const i = entries.findIndex((e) => e.id === id);993 if (i !== -1 && count > 0) {994 entries[i] = { ...entries[i], breach: count, modifiedAt: Date.now() };995 await persistEntries();996 }997 });998 }999 }1000}10011002export async function deleteEntry(id: string): Promise<void> {1003 if (!derivedKey) throw new Error("vault locked");10041005 const entry = entries.find((item) => item.id === id);1006 if (!entry || entry.deletedAt) {1007 throw new Error("entry not found");1008 }10091010 entry.deletedAt = Date.now();1011 entry.modifiedAt = Date.now();1012 await persistEntries();1013}10141015export function getDeletedEntries(): VaultEntry[] {1016 if (!derivedKey) return [];1017 return entries.filter((e) => !!e.deletedAt);1018}10191020export async function restoreEntry(id: string): Promise<void> {1021 if (!derivedKey) throw new Error("vault locked");1022 const entry = entries.find((e) => e.id === id && e.deletedAt);1023 if (!entry) throw new Error("deleted entry not found");1024 delete entry.deletedAt;1025 entry.modifiedAt = Date.now();1026 await persistEntries();1027 buildDomainIndexSync(entries);1028}10291030export async function purgeEntry(id: string): Promise<void> {1031 if (!derivedKey) throw new Error("vault locked");1032 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1033 if (idx === -1) throw new Error("deleted entry not found");1034 entries.splice(idx, 1);1035 await persistEntries();1036}103710381039export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1040 if (!derivedKey || retentionMs === 0) return 0;1041 const cutoff = Date.now() - retentionMs;1042 const before = entries.length;1043 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1044 const purged = before - entries.length;1045 if (purged > 0) await persistEntries();1046 return purged;1047}10481049export function queryDomain(domain: string): VaultEntry[] {1050 return domainIndex.get(domain) ?? [];1051}10521053export function getEntryById(id: string): VaultEntry | undefined {1054 return entries.find((entry) => entry.id === id && !entry.deletedAt);1055}10561057async function persistSlotA(1058 reason: VaultSnapshot["reason"] = "local_save",1059): Promise<StoredVault> {1060 if (!derivedKey) throw new Error("vault locked");10611062 const slotA = await loadSlot(SLOT_A);1063 if (!slotA) throw new Error("missing slot A");10641065 1066 1067 await takeVaultSnapshot(reason);10681069 const plaintext = encodePayload(entries, duressConfigured);1070 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);10711072 const nextSlotA: StoredVault = {1073 ...slotA,1074 nonce,1075 ciphertext,1076 tag,1077 };1078 await writeSlot(SLOT_A, nextSlotA);10791080 1081 1082 1083 if (!duressConfigured) {1084 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1085 }10861087 return nextSlotA;1088}10891090async function persistSlotB(): Promise<void> {1091 if (!derivedKey) throw new Error("vault locked");10921093 const slotB = await loadSlot(SLOT_B);1094 if (!slotB) throw new Error("missing slot B");10951096 1097 1098 const plaintext = encodePayload(entries, false);1099 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);11001101 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1102}11031104async function persistEntries(1105 reason: VaultSnapshot["reason"] = "local_save",1106): Promise<void> {1107 if (!derivedKey) throw new Error("vault locked");11081109 if (vaultMode === "decoy") {1110 await persistSlotB();1111 } else {1112 await persistSlotA(reason);1113 }1114 buildDomainIndexSync(entries);1115}11161117function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1118 if (!Array.isArray(values)) return undefined;1119 const seen = new Set<string>();1120 const cleaned: string[] = [];1121 for (const value of values) {1122 const trimmed = typeof value === "string" ? value.trim() : "";1123 if (!trimmed) continue;1124 const key = trimmed.toLowerCase();1125 if (seen.has(key)) continue;1126 seen.add(key);1127 cleaned.push(trimmed);1128 }1129 return cleaned.length > 0 ? cleaned : undefined;1130}11311132function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1133 const title = entry.title.trim();1134 1135 1136 1137 let type: VaultEntry["type"] = entry.type ?? "login";1138 if (type === "passkey") type = "login";11391140 if (!title) throw new Error("title is required");1141 1142 1143 if (type === "login" && !entry.password?.trim() && !entry.passkey) {1144 throw new Error("password or passkey required");1145 }1146 if (entry.passkey) {1147 const p = entry.passkey;1148 if (1149 !p.rpId ||1150 !p.credentialId ||1151 !p.publicKeyCose ||1152 !p.userHandle ||1153 !p.userName ||1154 (p.algorithm !== -7 && p.algorithm !== -8)1155 ) {1156 throw new Error("invalid passkey credential");1157 }1158 }1159 if (type === "card") {1160 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1161 throw new Error("cardholder name and card number are required");1162 }1163 }11641165 return {1166 type,1167 title,1168 username: type === "login" ? entry.username?.trim() ?? "" : undefined,1169 password: type === "login" ? entry.password : undefined,1170 url: type === "login" ? entry.url?.trim() ?? "" : undefined,1171 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1172 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1173 notes: entry.notes?.trim() || undefined,1174 breach: type === "login" ? entry.breach : undefined,1175 canary: type === "login" ? entry.canary : undefined,1176 tags: entry.tags,1177 passkey: entry.passkey,1178 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1179 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1180 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1181 expYear: type === "card" ? entry.expYear?.trim() : undefined,1182 cvc: type === "card" ? entry.cvc?.trim() : undefined,1183 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1184 network: type === "card" ? entry.network : undefined,1185 last4: type === "card" ? entry.last4?.trim() : undefined,1186 };1187}11881189function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1190 const nextPatch: Partial<VaultEntry> = {};11911192 if ("type" in patch) {1193 nextPatch.type = patch.type;1194 }11951196 if ("title" in patch) {1197 const title = patch.title?.trim() ?? "";1198 if (!title) throw new Error("title is required");1199 nextPatch.title = title;1200 }12011202 if ("url" in patch) {1203 nextPatch.url = patch.url?.trim() ?? "";1204 }12051206 if ("additionalUrls" in patch) {1207 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1208 const seen = new Set<string>();1209 const cleaned: string[] = [];1210 for (const value of incoming) {1211 const trimmed = typeof value === "string" ? value.trim() : "";1212 if (!trimmed) continue;1213 const key = trimmed.toLowerCase();1214 if (seen.has(key)) continue;1215 seen.add(key);1216 cleaned.push(trimmed);1217 }1218 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1219 }12201221 if ("username" in patch) {1222 nextPatch.username = patch.username?.trim() ?? "";1223 }12241225 if ("password" in patch) {1226 const password = patch.password ?? "";1227 1228 nextPatch.password = password || undefined;1229 }12301231 if ("totp" in patch) {1232 nextPatch.totp = patch.totp?.trim() || undefined;1233 }12341235 if ("notes" in patch) {1236 nextPatch.notes = patch.notes?.trim() || undefined;1237 }12381239 if ("breach" in patch) {1240 nextPatch.breach = patch.breach;1241 }12421243 if ("canary" in patch) {1244 nextPatch.canary = patch.canary;1245 }12461247 if ("passkey" in patch) {1248 1249 1250 1251 nextPatch.passkey = patch.passkey;1252 }12531254 if ("tags" in patch) {1255 nextPatch.tags = patch.tags;1256 }12571258 if ("cardholderName" in patch) {1259 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1260 }1261 if ("cardNumber" in patch) {1262 const n = patch.cardNumber ?? "";1263 nextPatch.cardNumber = n || undefined;1264 }1265 if ("expMonth" in patch) {1266 nextPatch.expMonth = patch.expMonth?.trim() || undefined;1267 }1268 if ("expYear" in patch) {1269 nextPatch.expYear = patch.expYear?.trim() || undefined;1270 }1271 if ("cvc" in patch) {1272 nextPatch.cvc = patch.cvc?.trim() || undefined;1273 }1274 if ("cardNickname" in patch) {1275 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1276 }1277 if ("network" in patch) {1278 nextPatch.network = patch.network;1279 }1280 if ("last4" in patch) {1281 nextPatch.last4 = patch.last4?.trim() || undefined;1282 }12831284 return nextPatch;1285}12861287function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1288 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1289 const modifiedAt =1290 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;12911292 const base: VaultEntry = {1293 id: entry.id,1294 type: "type" in entry ? entry.type : "login",1295 title: entry.title,1296 username: entry.username,1297 password: entry.password,1298 url: entry.url,1299 totp: entry.totp,1300 notes: entry.notes,1301 breach: entry.breach,1302 canary: "canary" in entry ? entry.canary : undefined,1303 passkey: "passkey" in entry ? entry.passkey : undefined,1304 createdAt,1305 modifiedAt,1306 deletedAt: entry.deletedAt,1307 tags: "tags" in entry ? entry.tags : undefined,1308 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1309 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1310 expMonth: "expMonth" in entry ? entry.expMonth : undefined,1311 expYear: "expYear" in entry ? entry.expYear : undefined,1312 cvc: "cvc" in entry ? entry.cvc : undefined,1313 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1314 network: "network" in entry ? entry.network : undefined,1315 last4: "last4" in entry ? entry.last4 : undefined,1316 };13171318 return migrateLegacyPasskey(base, entry);1319}132013211322132313241325132613271328function migrateLegacyPasskey(1329 base: VaultEntry,1330 raw: VaultEntry | LegacyVaultEntry1331): VaultEntry {1332 if (base.passkey) return base;1333 const flat = raw as Partial<VaultEntry>;1334 const isPasskeyType = base.type === "passkey";1335 const hasFlatPasskeyFields =1336 typeof flat.rpId === "string" &&1337 typeof flat.credentialId === "string" &&1338 typeof flat.publicKeyCose === "string" &&1339 typeof flat.userHandle === "string" &&1340 typeof flat.userName === "string";1341 if (!isPasskeyType && !hasFlatPasskeyFields) return base;1342 if (!hasFlatPasskeyFields) return base;13431344 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;1345 base.passkey = {1346 rpId: flat.rpId!,1347 rpName: flat.rpName ?? flat.rpId!,1348 credentialId: flat.credentialId!,1349 publicKeyCose: flat.publicKeyCose!,1350 privateKeyJwk: flat.privateKeyJwk,1351 userHandle: flat.userHandle!,1352 userName: flat.userName!,1353 userDisplayName: flat.userDisplayName ?? flat.userName!,1354 algorithm,1355 signCount: flat.signCount ?? 0,1356 aaguid: flat.aaguid ?? "",1357 backupEligible: flat.backupEligible === true,1358 backupState: flat.backupState === true,1359 lastUsedAt: flat.lastUsedAt,1360 browser: flat.browser,1361 transports: flat.transports,1362 createdAt: base.createdAt,1363 };1364 1365 1366 1367 if (isPasskeyType) base.type = "login";1368 if (!base.username) base.username = flat.userName;1369 if (!base.url) base.url = `https://${flat.rpId}`;1370 return base;1371}1372137313741375137613771378function entryDupeKey(e: VaultEntry): string {1379 if (e.type === "note") return `note|${e.title.toLowerCase()}`;1380 1381 1382 1383 1384 if (e.passkey) {1385 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;1386 }1387 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1388}138913901391export function countDuplicates(): number {1392 const best = new Map<string, VaultEntry>();1393 let dupeCount = 0;1394 for (const e of entries.filter((e) => !e.deletedAt)) {1395 const key = entryDupeKey(e);1396 const existing = best.get(key);1397 if (!existing) {1398 best.set(key, e);1399 } else {1400 dupeCount++;1401 1402 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1403 best.set(key, e);1404 }1405 }1406 }1407 return dupeCount;1408}140914101411141214131414export async function deduplicateEntries(): Promise<number> {1415 if (!derivedKey) throw new Error("vault locked");1416 1417 1418 if (vaultMode === "decoy") {1419 throw new Error("deduplicate disabled in decoy mode");1420 }14211422 const best = new Map<string, VaultEntry>();1423 for (const e of entries.filter((en) => !en.deletedAt)) {1424 const key = entryDupeKey(e);1425 const existing = best.get(key);1426 if (!existing) {1427 best.set(key, e);1428 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1429 best.set(key, e);1430 }1431 }14321433 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1434 const now = Date.now();1435 let removed = 0;1436 entries = entries.map((e) => {1437 if (!e.deletedAt && !keepIds.has(e.id)) {1438 removed++;1439 return { ...e, deletedAt: now };1440 }1441 return e;1442 });14431444 if (removed > 0) await persistEntries();1445 return removed;1446}144714481449145014511452145314541455const HISTORY_KEY = "peach_vault_history";1456const HISTORY_MAX = 10;14571458145914601461146214631464export async function takeVaultSnapshot(1465 reason: VaultSnapshot["reason"],1466): Promise<void> {1467 try {1468 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);1469 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];1470 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];14711472 1473 1474 1475 if (!slotA) return;14761477 const vault_a = JSON.stringify(slotA);1478 const vault_b = JSON.stringify(slotB ?? slotA);14791480 1481 1482 1483 let entryCount: number | null = null;1484 if (derivedKey && vaultMode === "real") {1485 try {1486 entryCount = entries.filter((e) => !e.deletedAt).length;1487 } catch {1488 entryCount = null;1489 }1490 }14911492 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];1493 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)1494 ? existingRaw1495 : { snapshots: [] };14961497 1498 1499 1500 const latest = history.snapshots[0];1501 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {1502 return;1503 }15041505 const snapshot: VaultSnapshot = {1506 id: crypto.randomUUID(),1507 savedAt: Date.now(),1508 reason,1509 vault_a,1510 vault_b,1511 entryCount,1512 };15131514 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);1515 await chrome.storage.local.set({ [HISTORY_KEY]: history });1516 } catch {1517 1518 1519 }1520}152115221523export async function clearVaultHistory(): Promise<void> {1524 try {1525 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });1526 } catch {1527 1528 }1529}153015311532export async function getVaultHistory(): Promise<VaultSnapshot[]> {1533 const raw = await chrome.storage.local.get(HISTORY_KEY);1534 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];1535 if (!history || !Array.isArray(history.snapshots)) return [];1536 return history.snapshots;1537}15381539154015411542154315441545export async function restoreVaultSnapshot(1546 snapshotId: string,1547): Promise<{ ok: true } | { ok: false; error: string }> {1548 const history = await getVaultHistory();1549 const snapshot = history.find((s) => s.id === snapshotId);1550 if (!snapshot) return { ok: false, error: "snapshot_not_found" };15511552 1553 1554 1555 await takeVaultSnapshot("restore");15561557 let restoredA: StoredVault;1558 let restoredB: StoredVault;1559 try {1560 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;1561 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;1562 } catch {1563 return { ok: false, error: "snapshot_corrupt" };1564 }15651566 await writeSlot(SLOT_A, restoredA);1567 await writeSlot(SLOT_B, restoredB);15681569 1570 1571 1572 lock();15731574 return { ok: true };1575}15761577157815791580158115821583export async function changePassword(1584 currentPassword: string,1585 newPassword: string1586): Promise<boolean> {1587 if (!derivedKey || vaultMode !== "real") return false;15881589 1590 const valid = await verifyMainPassword(currentPassword);1591 if (!valid) return false;15921593 const slotA = await loadSlot(SLOT_A);1594 if (!slotA) return false;15951596 1597 const newSalt = generateSalt();1598 const newKey = await deriveKey(newPassword, newSalt);15991600 1601 const plaintext = encodePayload(entries, duressConfigured);1602 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);16031604 const freshSlotA: StoredVault = {1605 ...slotA,1606 salt: hex(newSalt),1607 nonce,1608 ciphertext,1609 tag,1610 };1611 1612 1613 1614 await takeVaultSnapshot("local_save");1615 await writeSlot(SLOT_A, freshSlotA);16161617 1618 1619 1620 zeroKey(derivedKey);1621 derivedKey = newKey;16221623 1624 1625 1626 if (!duressConfigured) {1627 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1628 }16291630 return true;1631}