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 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login" && entries[idx].password) {986 const password = entries[idx].password!;987 checkBreach(password).then(async (count) => {988 const i = entries.findIndex((e) => e.id === id);989 if (i !== -1) {990 entries[i] = { ...entries[i], breach: count > 0 ? count : undefined, modifiedAt: Date.now() };991 await persistEntries();992 }993 });994 }995}996997export async function deleteEntry(id: string): Promise<void> {998 if (!derivedKey) throw new Error("vault locked");9991000 const entry = entries.find((item) => item.id === id);1001 if (!entry || entry.deletedAt) {1002 throw new Error("entry not found");1003 }10041005 entry.deletedAt = Date.now();1006 entry.modifiedAt = Date.now();1007 await persistEntries();1008}10091010export function getDeletedEntries(): VaultEntry[] {1011 if (!derivedKey) return [];1012 return entries.filter((e) => !!e.deletedAt);1013}10141015export async function restoreEntry(id: string): Promise<void> {1016 if (!derivedKey) throw new Error("vault locked");1017 const entry = entries.find((e) => e.id === id && e.deletedAt);1018 if (!entry) throw new Error("deleted entry not found");1019 delete entry.deletedAt;1020 entry.modifiedAt = Date.now();1021 await persistEntries();1022 buildDomainIndexSync(entries);1023}10241025export async function purgeEntry(id: string): Promise<void> {1026 if (!derivedKey) throw new Error("vault locked");1027 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1028 if (idx === -1) throw new Error("deleted entry not found");1029 entries.splice(idx, 1);1030 await persistEntries();1031}103210331034export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1035 if (!derivedKey || retentionMs === 0) return 0;1036 const cutoff = Date.now() - retentionMs;1037 const before = entries.length;1038 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1039 const purged = before - entries.length;1040 if (purged > 0) await persistEntries();1041 return purged;1042}10431044export function queryDomain(domain: string): VaultEntry[] {1045 return domainIndex.get(domain) ?? [];1046}10471048export function getEntryById(id: string): VaultEntry | undefined {1049 return entries.find((entry) => entry.id === id && !entry.deletedAt);1050}10511052async function persistSlotA(1053 reason: VaultSnapshot["reason"] = "local_save",1054): Promise<StoredVault> {1055 if (!derivedKey) throw new Error("vault locked");10561057 const slotA = await loadSlot(SLOT_A);1058 if (!slotA) throw new Error("missing slot A");10591060 1061 1062 await takeVaultSnapshot(reason);10631064 const plaintext = encodePayload(entries, duressConfigured);1065 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);10661067 const nextSlotA: StoredVault = {1068 ...slotA,1069 nonce,1070 ciphertext,1071 tag,1072 };1073 await writeSlot(SLOT_A, nextSlotA);10741075 1076 1077 1078 if (!duressConfigured) {1079 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1080 }10811082 return nextSlotA;1083}10841085async function persistSlotB(): Promise<void> {1086 if (!derivedKey) throw new Error("vault locked");10871088 const slotB = await loadSlot(SLOT_B);1089 if (!slotB) throw new Error("missing slot B");10901091 1092 1093 const plaintext = encodePayload(entries, false);1094 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);10951096 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1097}10981099async function persistEntries(1100 reason: VaultSnapshot["reason"] = "local_save",1101): Promise<void> {1102 if (!derivedKey) throw new Error("vault locked");11031104 if (vaultMode === "decoy") {1105 await persistSlotB();1106 } else {1107 await persistSlotA(reason);1108 }1109 buildDomainIndexSync(entries);1110}11111112function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1113 if (!Array.isArray(values)) return undefined;1114 const seen = new Set<string>();1115 const cleaned: string[] = [];1116 for (const value of values) {1117 const trimmed = typeof value === "string" ? value.trim() : "";1118 if (!trimmed) continue;1119 const key = trimmed.toLowerCase();1120 if (seen.has(key)) continue;1121 seen.add(key);1122 cleaned.push(trimmed);1123 }1124 return cleaned.length > 0 ? cleaned : undefined;1125}11261127function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1128 const title = entry.title.trim();1129 1130 1131 1132 let type: VaultEntry["type"] = entry.type ?? "login";1133 if (type === "passkey") type = "login";11341135 if (!title) throw new Error("title is required");1136 1137 1138 if (type === "login" && !entry.password?.trim() && !entry.passkey) {1139 throw new Error("password or passkey required");1140 }1141 if (entry.passkey) {1142 const p = entry.passkey;1143 if (1144 !p.rpId ||1145 !p.credentialId ||1146 !p.publicKeyCose ||1147 !p.userHandle ||1148 !p.userName ||1149 (p.algorithm !== -7 && p.algorithm !== -8)1150 ) {1151 throw new Error("invalid passkey credential");1152 }1153 }1154 if (type === "card") {1155 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1156 throw new Error("cardholder name and card number are required");1157 }1158 }11591160 return {1161 type,1162 title,1163 username: type === "login" ? entry.username?.trim() ?? "" : undefined,1164 password: type === "login" ? entry.password : undefined,1165 url: type === "login" ? entry.url?.trim() ?? "" : undefined,1166 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1167 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1168 notes: entry.notes?.trim() || undefined,1169 breach: type === "login" ? entry.breach : undefined,1170 canary: type === "login" ? entry.canary : undefined,1171 tags: entry.tags,1172 passkey: entry.passkey,1173 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1174 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1175 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1176 expYear: type === "card" ? entry.expYear?.trim() : undefined,1177 cvc: type === "card" ? entry.cvc?.trim() : undefined,1178 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1179 network: type === "card" ? entry.network : undefined,1180 last4: type === "card" ? entry.last4?.trim() : undefined,1181 };1182}11831184function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1185 const nextPatch: Partial<VaultEntry> = {};11861187 if ("type" in patch) {1188 nextPatch.type = patch.type;1189 }11901191 if ("title" in patch) {1192 const title = patch.title?.trim() ?? "";1193 if (!title) throw new Error("title is required");1194 nextPatch.title = title;1195 }11961197 if ("url" in patch) {1198 nextPatch.url = patch.url?.trim() ?? "";1199 }12001201 if ("additionalUrls" in patch) {1202 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1203 const seen = new Set<string>();1204 const cleaned: string[] = [];1205 for (const value of incoming) {1206 const trimmed = typeof value === "string" ? value.trim() : "";1207 if (!trimmed) continue;1208 const key = trimmed.toLowerCase();1209 if (seen.has(key)) continue;1210 seen.add(key);1211 cleaned.push(trimmed);1212 }1213 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1214 }12151216 if ("username" in patch) {1217 nextPatch.username = patch.username?.trim() ?? "";1218 }12191220 if ("password" in patch) {1221 const password = patch.password ?? "";1222 1223 nextPatch.password = password || undefined;1224 }12251226 if ("totp" in patch) {1227 nextPatch.totp = patch.totp?.trim() || undefined;1228 }12291230 if ("notes" in patch) {1231 nextPatch.notes = patch.notes?.trim() || undefined;1232 }12331234 if ("breach" in patch) {1235 nextPatch.breach = patch.breach;1236 }12371238 if ("canary" in patch) {1239 nextPatch.canary = patch.canary;1240 }12411242 if ("passkey" in patch) {1243 1244 1245 1246 nextPatch.passkey = patch.passkey;1247 }12481249 if ("tags" in patch) {1250 nextPatch.tags = patch.tags;1251 }12521253 if ("cardholderName" in patch) {1254 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1255 }1256 if ("cardNumber" in patch) {1257 const n = patch.cardNumber ?? "";1258 nextPatch.cardNumber = n || undefined;1259 }1260 if ("expMonth" in patch) {1261 nextPatch.expMonth = patch.expMonth?.trim() || undefined;1262 }1263 if ("expYear" in patch) {1264 nextPatch.expYear = patch.expYear?.trim() || undefined;1265 }1266 if ("cvc" in patch) {1267 nextPatch.cvc = patch.cvc?.trim() || undefined;1268 }1269 if ("cardNickname" in patch) {1270 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1271 }1272 if ("network" in patch) {1273 nextPatch.network = patch.network;1274 }1275 if ("last4" in patch) {1276 nextPatch.last4 = patch.last4?.trim() || undefined;1277 }12781279 return nextPatch;1280}12811282function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1283 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1284 const modifiedAt =1285 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;12861287 const base: VaultEntry = {1288 id: entry.id,1289 type: "type" in entry ? entry.type : "login",1290 title: entry.title,1291 username: entry.username,1292 password: entry.password,1293 url: entry.url,1294 totp: entry.totp,1295 notes: entry.notes,1296 breach: entry.breach,1297 canary: "canary" in entry ? entry.canary : undefined,1298 passkey: "passkey" in entry ? entry.passkey : undefined,1299 createdAt,1300 modifiedAt,1301 deletedAt: entry.deletedAt,1302 tags: "tags" in entry ? entry.tags : undefined,1303 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1304 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1305 expMonth: "expMonth" in entry ? entry.expMonth : undefined,1306 expYear: "expYear" in entry ? entry.expYear : undefined,1307 cvc: "cvc" in entry ? entry.cvc : undefined,1308 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1309 network: "network" in entry ? entry.network : undefined,1310 last4: "last4" in entry ? entry.last4 : undefined,1311 };13121313 return migrateLegacyPasskey(base, entry);1314}131513161317131813191320132113221323function migrateLegacyPasskey(1324 base: VaultEntry,1325 raw: VaultEntry | LegacyVaultEntry1326): VaultEntry {1327 if (base.passkey) return base;1328 const flat = raw as Partial<VaultEntry>;1329 const isPasskeyType = base.type === "passkey";1330 const hasFlatPasskeyFields =1331 typeof flat.rpId === "string" &&1332 typeof flat.credentialId === "string" &&1333 typeof flat.publicKeyCose === "string" &&1334 typeof flat.userHandle === "string" &&1335 typeof flat.userName === "string";1336 if (!isPasskeyType && !hasFlatPasskeyFields) return base;1337 if (!hasFlatPasskeyFields) return base;13381339 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;1340 base.passkey = {1341 rpId: flat.rpId!,1342 rpName: flat.rpName ?? flat.rpId!,1343 credentialId: flat.credentialId!,1344 publicKeyCose: flat.publicKeyCose!,1345 privateKeyJwk: flat.privateKeyJwk,1346 userHandle: flat.userHandle!,1347 userName: flat.userName!,1348 userDisplayName: flat.userDisplayName ?? flat.userName!,1349 algorithm,1350 signCount: flat.signCount ?? 0,1351 aaguid: flat.aaguid ?? "",1352 backupEligible: flat.backupEligible === true,1353 backupState: flat.backupState === true,1354 lastUsedAt: flat.lastUsedAt,1355 browser: flat.browser,1356 transports: flat.transports,1357 createdAt: base.createdAt,1358 };1359 1360 1361 1362 if (isPasskeyType) base.type = "login";1363 if (!base.username) base.username = flat.userName;1364 if (!base.url) base.url = `https://${flat.rpId}`;1365 return base;1366}1367136813691370137113721373function entryDupeKey(e: VaultEntry): string {1374 if (e.type === "note") return `note|${e.title.toLowerCase()}`;1375 1376 1377 1378 1379 if (e.passkey) {1380 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;1381 }1382 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1383}138413851386export function countDuplicates(): number {1387 const best = new Map<string, VaultEntry>();1388 let dupeCount = 0;1389 for (const e of entries.filter((e) => !e.deletedAt)) {1390 const key = entryDupeKey(e);1391 const existing = best.get(key);1392 if (!existing) {1393 best.set(key, e);1394 } else {1395 dupeCount++;1396 1397 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1398 best.set(key, e);1399 }1400 }1401 }1402 return dupeCount;1403}140414051406140714081409export async function deduplicateEntries(): Promise<number> {1410 if (!derivedKey) throw new Error("vault locked");1411 1412 1413 if (vaultMode === "decoy") {1414 throw new Error("deduplicate disabled in decoy mode");1415 }14161417 const best = new Map<string, VaultEntry>();1418 for (const e of entries.filter((en) => !en.deletedAt)) {1419 const key = entryDupeKey(e);1420 const existing = best.get(key);1421 if (!existing) {1422 best.set(key, e);1423 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1424 best.set(key, e);1425 }1426 }14271428 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1429 const now = Date.now();1430 let removed = 0;1431 entries = entries.map((e) => {1432 if (!e.deletedAt && !keepIds.has(e.id)) {1433 removed++;1434 return { ...e, deletedAt: now };1435 }1436 return e;1437 });14381439 if (removed > 0) await persistEntries();1440 return removed;1441}144214431444144514461447144814491450const HISTORY_KEY = "peach_vault_history";1451const HISTORY_MAX = 10;14521453145414551456145714581459export async function takeVaultSnapshot(1460 reason: VaultSnapshot["reason"],1461): Promise<void> {1462 try {1463 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);1464 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];1465 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];14661467 1468 1469 1470 if (!slotA) return;14711472 const vault_a = JSON.stringify(slotA);1473 const vault_b = JSON.stringify(slotB ?? slotA);14741475 1476 1477 1478 let entryCount: number | null = null;1479 if (derivedKey && vaultMode === "real") {1480 try {1481 entryCount = entries.filter((e) => !e.deletedAt).length;1482 } catch {1483 entryCount = null;1484 }1485 }14861487 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];1488 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)1489 ? existingRaw1490 : { snapshots: [] };14911492 1493 1494 1495 const latest = history.snapshots[0];1496 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {1497 return;1498 }14991500 const snapshot: VaultSnapshot = {1501 id: crypto.randomUUID(),1502 savedAt: Date.now(),1503 reason,1504 vault_a,1505 vault_b,1506 entryCount,1507 };15081509 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);1510 await chrome.storage.local.set({ [HISTORY_KEY]: history });1511 } catch {1512 1513 1514 }1515}151615171518export async function clearVaultHistory(): Promise<void> {1519 try {1520 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });1521 } catch {1522 1523 }1524}152515261527export async function getVaultHistory(): Promise<VaultSnapshot[]> {1528 const raw = await chrome.storage.local.get(HISTORY_KEY);1529 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];1530 if (!history || !Array.isArray(history.snapshots)) return [];1531 return history.snapshots;1532}15331534153515361537153815391540export async function restoreVaultSnapshot(1541 snapshotId: string,1542): Promise<{ ok: true } | { ok: false; error: string }> {1543 const history = await getVaultHistory();1544 const snapshot = history.find((s) => s.id === snapshotId);1545 if (!snapshot) return { ok: false, error: "snapshot_not_found" };15461547 1548 1549 1550 await takeVaultSnapshot("restore");15511552 let restoredA: StoredVault;1553 let restoredB: StoredVault;1554 try {1555 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;1556 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;1557 } catch {1558 return { ok: false, error: "snapshot_corrupt" };1559 }15601561 await writeSlot(SLOT_A, restoredA);1562 await writeSlot(SLOT_B, restoredB);15631564 1565 1566 1567 lock();15681569 return { ok: true };1570}15711572157315741575157615771578export async function changePassword(1579 currentPassword: string,1580 newPassword: string1581): Promise<boolean> {1582 if (!derivedKey || vaultMode !== "real") return false;15831584 1585 const valid = await verifyMainPassword(currentPassword);1586 if (!valid) return false;15871588 const slotA = await loadSlot(SLOT_A);1589 if (!slotA) return false;15901591 1592 const newSalt = generateSalt();1593 const newKey = await deriveKey(newPassword, newSalt);15941595 1596 const plaintext = encodePayload(entries, duressConfigured);1597 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);15981599 const freshSlotA: StoredVault = {1600 ...slotA,1601 salt: hex(newSalt),1602 nonce,1603 ciphertext,1604 tag,1605 };1606 1607 1608 1609 await takeVaultSnapshot("local_save");1610 await writeSlot(SLOT_A, freshSlotA);16111612 1613 1614 1615 zeroKey(derivedKey);1616 derivedKey = newKey;16171618 1619 1620 1621 if (!duressConfigured) {1622 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1623 }16241625 return true;1626}