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} from "../shared/types";22import { decrypt, encrypt, zeroKey } from "./aes";23import { clearAutoLock, touchAutoLock } from "./alarms";2425type EntryDraft = Omit<26 VaultEntry,27 "id" | "createdAt" | "modifiedAt" | "deletedAt"28>;293031323334const SLOT_A = "peach_vault_a";35const SLOT_B = "peach_vault_b";363738const LEGACY_MAIN = "peach_vault";39const LEGACY_DECOY = "peach_decoy_vault";40const LEGACY_DURESS_FLAG = "peach_duress";41424344const PAD_BLOCK = 1024;4546let derivedKey: Uint8Array | null = null;47let entries: VaultEntry[] = [];48let domainIndex: Map<string, VaultEntry[]> = new Map();49let vaultMode: "real" | "decoy" = "real";50515253let duressConfigured = false;5455let migrationRan = false;565758596061export type DisplayFont = "cormorant-italic" | "cormorant" | "inter" | "system";62export type TextSize = "small" | "medium" | "large";6364export interface VaultSettings {65 accentColor?: AccentColorName;66 theme?: "dark" | "light";67 autoLockMinutes?: number;68 clipboardClearSeconds?: number;69 lockOnClose?: boolean;70 breachMonitoring?: boolean;71 passkeyProviderEnabled?: boolean;72 trashRetentionDays?: number;73 manualSummonDomains?: Record<string, ManualSummonRecord>;74 declinedCardFingerprints?: string[];75 7677787980 displayFont?: DisplayFont;81 8283848586 textSize?: TextSize;87 8889909192 reduceMotion?: boolean;93 949596979899100 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;101}102103let settings: VaultSettings = {};104105export function getSettings(): VaultSettings {106 return settings;107}108109export async function ensureSettingsLoaded(): Promise<VaultSettings> {110 await loadSettings();111 return settings;112}113114export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {115 await loadSettings();116 settings = { ...settings, ...patch };117 await persistSettings();118}119120async function persistSettings(): Promise<void> {121 try {122 await chrome.storage.local.set({ peach_settings: settings });123 } catch (err) {124 throw mapStorageError(err);125 }126}127128function mapStorageError(err: unknown): Error {129 const message = err instanceof Error ? err.message : String(err);130 if (/QUOTA_BYTES|quota/i.test(message)) {131 return new Error("STORAGE_QUOTA_EXCEEDED");132 }133 return err instanceof Error ? err : new Error(message);134}135136async function loadSettings(): Promise<void> {137 const result = await chrome.storage.local.get("peach_settings");138 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};139}140141142143144145export async function checkBreach(password: string): Promise<number> {146 const encoder = new TextEncoder();147 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));148 const hashHex = Array.from(new Uint8Array(hashBuffer))149 .map((b) => b.toString(16).padStart(2, "0"))150 .join("")151 .toUpperCase();152153 const prefix = hashHex.slice(0, 5);154 const suffix = hashHex.slice(5);155156 try {157 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);158 if (!res.ok) return 0;159 const text = await res.text();160 const match = text.split("\r\n").find((l) => l.startsWith(suffix));161 return match ? parseInt(match.split(":")[1], 10) : 0;162 } catch {163 return 0; 164 }165}166167export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {168 const fingerprint = await cardFingerprint(last4, expMonth, expYear);169 await loadSettings();170 settings = {171 ...settings,172 declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],173 };174 await persistSettings();175}176177export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {178 await loadSettings();179 const fingerprint = await cardFingerprint(last4, expMonth, expYear);180 return (settings.declinedCardFingerprints ?? []).includes(fingerprint);181}182183async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {184 const encoder = new TextEncoder();185 const buf = await crypto.subtle.digest("SHA-1", encoder.encode(`${last4}${expMonth}${expYear}`));186 return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, "0")).join("");187}188189function hex(buf: Uint8Array): string {190 return Array.from(buf)191 .map((b) => b.toString(16).padStart(2, "0"))192 .join("");193}194195function unhex(s: string): Uint8Array {196 const out = new Uint8Array(s.length / 2);197 for (let i = 0; i < s.length; i += 2) {198 out[i / 2] = parseInt(s.slice(i, i + 2), 16);199 }200 return out;201}202203204205206207async function loadSlot(key: string): Promise<StoredVault | null> {208 const result = await chrome.storage.local.get(key);209 return (result as Record<string, StoredVault | undefined>)[key] ?? null;210}211212async function writeSlot(key: string, vault: StoredVault): Promise<void> {213 try {214 await chrome.storage.local.set({ [key]: vault });215 } catch (err) {216 throw mapStorageError(err);217 }218}219220221222223224225async function ensureMigrated(): Promise<void> {226 if (migrationRan) return;227 migrationRan = true;228229 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];230 const raw = await chrome.storage.local.get(keys);231 const slotAExists = raw[SLOT_A] !== undefined;232 const slotBExists = raw[SLOT_B] !== undefined;233 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;234 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;235236 237 if (!slotAExists && !legacyMain) return;238239 240 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;241242 if (legacyMain && !slotAExists) {243 await writeSlot(SLOT_A, legacyMain);244 }245246 247 if (!slotBExists) {248 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;249 if (legacyDecoy) {250 await writeSlot(SLOT_B, legacyDecoy);251 } else if (target) {252 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));253 }254 }255256 const toRemove: string[] = [];257 if (legacyMain) toRemove.push(LEGACY_MAIN);258 if (legacyDecoy) toRemove.push(LEGACY_DECOY);259 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);260 if (toRemove.length > 0) {261 await chrome.storage.local.remove(toRemove);262 }263}264265266267268269270271272273274275function padPlaintext(json: string): string {276 const encoded = new TextEncoder().encode(json);277 const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);278 const padLen = target - encoded.length;279 return json + " ".repeat(padLen);280}281282283284285286287288function generateNoiseBlob(ciphertextBytes: number): StoredVault {289 const salt = crypto.getRandomValues(new Uint8Array(16));290 const nonce = crypto.getRandomValues(new Uint8Array(12));291 const cipher = crypto.getRandomValues(new Uint8Array(ciphertextBytes));292 const tag = crypto.getRandomValues(new Uint8Array(16));293 return {294 version: 1,295 salt: hex(salt),296 nonce: hex(nonce),297 ciphertext: hex(cipher),298 tag: hex(tag),299 };300}301302303304305306type VaultPayload = {307 entries: VaultEntry[];308 duressConfigured?: boolean;309};310311type DecryptedPayload = {312 entries: VaultEntry[];313 duressConfigured: boolean;314};315316function encodePayload(entries: VaultEntry[], duress: boolean): string {317 const envelope: VaultPayload = { entries, duressConfigured: duress };318 return padPlaintext(JSON.stringify(envelope));319}320321function decodePayload(plain: string): DecryptedPayload {322 const parsed = JSON.parse(plain) as323 | Array<VaultEntry | LegacyVaultEntry>324 | VaultPayload;325326 if (Array.isArray(parsed)) {327 328 return {329 entries: parsed.map(normalizeEntry),330 duressConfigured: false,331 };332 }333334 return {335 entries: (parsed.entries ?? []).map(normalizeEntry),336 duressConfigured: parsed.duressConfigured === true,337 };338}339340async function tryDecryptSlot(341 vault: StoredVault,342 key: Uint8Array343): Promise<DecryptedPayload | null> {344 try {345 const plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);346 return decodePayload(plain);347 } catch {348 return null;349 }350}351352353354355356function buildDomainIndexSync(all: VaultEntry[]): void {357 domainIndex = new Map();358359 for (const entry of all) {360 if (entry.deletedAt) continue;361 if (entry.type === "note" || entry.type === "passkey" || entry.type === "card") continue;362363 364 365 366 367 const domains = new Set<string>();368 const primary = extractDomain(entry.url ?? "");369 if (primary) domains.add(primary);370 for (const extra of entry.additionalUrls ?? []) {371 const d = extractDomain(extra);372 if (d) domains.add(d);373 }374 if (domains.size === 0) continue;375376 for (const domain of domains) {377 const bucket = domainIndex.get(domain) ?? [];378 bucket.push(entry);379 domainIndex.set(domain, bucket);380 }381 }382}383384export async function createVault(password: string): Promise<void> {385 await ensureMigrated();386387 const salt = generateSalt();388 derivedKey = await deriveKey(password, salt);389 entries = [];390 vaultMode = "real";391 duressConfigured = false;392 buildDomainIndexSync(entries);393394 const plaintext = encodePayload(entries, duressConfigured);395 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);396397 const slotA: StoredVault = {398 version: 1,399 salt: hex(salt),400 nonce,401 ciphertext,402 tag,403 };404 await writeSlot(SLOT_A, slotA);405406 407 408 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));409410 touchAutoLock();411}412413export async function hasVault(): Promise<boolean> {414 await ensureMigrated();415 return (await loadSlot(SLOT_A)) !== null;416}417418419420421422423export async function isDuressEnabled(): Promise<boolean> {424 return vaultMode === "real" && duressConfigured;425}426427export async function hasDecoyVault(): Promise<boolean> {428 return isDuressEnabled();429}430431export async function setupDuress(duressPassword: string): Promise<void> {432 if (!derivedKey || vaultMode !== "real") {433 throw new Error("vault must be unlocked in real mode");434 }435 await ensureMigrated();436437 438 439 440 const slotA = await loadSlot(SLOT_A);441 if (!slotA) throw new Error("missing slot A");442443 const decoySalt = generateSalt();444 const decoyKey = await deriveKey(duressPassword, decoySalt);445 try {446 447 448 const targetBytes = slotA.ciphertext.length / 2;449 let plaintext = encodePayload([], false);450 const encoded = new TextEncoder().encode(plaintext);451 if (encoded.length < targetBytes) {452 plaintext = plaintext + " ".repeat(targetBytes - encoded.length);453 }454 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);455 await writeSlot(SLOT_B, {456 version: 1,457 salt: hex(decoySalt),458 nonce,459 ciphertext,460 tag,461 });462 } finally {463 zeroKey(decoyKey);464 }465466 467 468 duressConfigured = true;469 await persistSlotA();470}471472export async function disableDuress(): Promise<void> {473 if (!derivedKey || vaultMode !== "real") {474 throw new Error("vault must be unlocked in real mode");475 }476 await ensureMigrated();477478 479 480 481 const slotA = await loadSlot(SLOT_A);482 if (slotA) {483 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));484 }485486 duressConfigured = false;487 await persistSlotA();488}489490export async function verifyMainPassword(password: string): Promise<boolean> {491 await ensureMigrated();492 const slotA = await loadSlot(SLOT_A);493 if (!slotA) return false;494 const key = await deriveKey(password, unhex(slotA.salt));495 try {496 return (await tryDecryptSlot(slotA, key)) !== null;497 } finally {498 zeroKey(key);499 }500}501502export async function unlock(password: string): Promise<boolean> {503 await ensureMigrated();504505 506 507 508 509 510 const [slotA, slotB] = await Promise.all([511 loadSlot(SLOT_A),512 loadSlot(SLOT_B),513 ]);514515 516 const [keyA, keyB] = await Promise.all([517 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),518 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),519 ]);520521 522 const [payloadA, payloadB] = await Promise.all([523 keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),524 keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),525 ]);526527 if (payloadA && keyA) {528 derivedKey = keyA;529 vaultMode = "real";530 entries = payloadA.entries;531 duressConfigured = payloadA.duressConfigured;532 buildDomainIndexSync(entries);533 await loadSettings();534 touchAutoLock();535 if (keyB) zeroKey(keyB);536 537 538 if (hasLegacyShape(payloadA.entries)) {539 await persistSlotA();540 }541 return true;542 }543544 if (payloadB && keyB) {545 derivedKey = keyB;546 vaultMode = "decoy";547 entries = payloadB.entries;548 duressConfigured = false;549 buildDomainIndexSync(entries);550 await loadSettings();551 touchAutoLock();552 if (keyA) zeroKey(keyA);553 return true;554 }555556 if (keyA) zeroKey(keyA);557 if (keyB) zeroKey(keyB);558 return false;559}560561export function lock(): void {562 if (derivedKey) {563 zeroKey(derivedKey);564 derivedKey = null;565 }566567 entries = [];568 domainIndex = new Map();569 vaultMode = "real";570 duressConfigured = false;571 clearAutoLock();572}573574export function isUnlocked(): boolean {575 return derivedKey !== null;576}577578export function getEntries(): VaultEntry[] {579 if (!derivedKey) throw new Error("vault locked");580 return entries.filter((entry) => !entry.deletedAt);581}582583const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;584585export function getIdentities(): SavedIdentity[] {586 if (!derivedKey) throw new Error("vault locked");587 const active = entries.filter((e) => !e.deletedAt && e.type === "login");588589 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();590 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();591592 for (const entry of active) {593 const value = entry.username?.trim();594 if (!value) continue;595 const map = EMAIL_RE.test(value) ? emailMap : usernameMap;596 const existing = map.get(value);597 const domain = extractDomain(entry.url ?? "") ?? "";598 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;599 if (!existing || modifiedAt > existing.lastUsed) {600 map.set(value, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });601 } else {602 map.set(value, { ...existing, count: existing.count + 1 });603 }604 }605606 const toIdentity = (type: "email" | "username") =>607 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({608 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,609 });610611 return [612 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),613 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),614 ];615}616617export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {618 if (!derivedKey) throw new Error("vault locked");619620 const now = Date.now();621 const added: VaultEntry[] = [];622 for (const draft of drafts) {623 try {624 const sanitized = sanitizeEntryDraft(draft);625 const nextEntry: VaultEntry = {626 ...sanitized,627 id: crypto.randomUUID(),628 createdAt: now,629 modifiedAt: now,630 };631 entries.push(nextEntry);632 added.push(nextEntry);633 } catch {634 635 }636 }637638 if (added.length > 0) {639 await persistEntries();640 }641642 return added;643}644645646647648649650651652export async function restoreEntries(653 incoming: VaultEntry[],654 mode: "replace" | "merge"655): Promise<{ added: number; updated: number; skipped: number }> {656 if (!derivedKey) throw new Error("vault locked");657658 659 660 661 if (mode === "replace" && incoming.length === 0) {662 throw new Error("refuse to replace with empty entry set");663 }664665 let added = 0;666 let updated = 0;667 let skipped = 0;668669 if (mode === "replace") {670 entries = [];671 for (const entry of incoming) {672 const normalized = sanitizeRestoredEntry(entry);673 if (!normalized) {674 skipped += 1;675 continue;676 }677 entries.push(normalized);678 added += 1;679 }680 } else {681 const byId = new Map<string, VaultEntry>();682 for (const e of entries) byId.set(e.id, e);683684 for (const entry of incoming) {685 const normalized = sanitizeRestoredEntry(entry);686 if (!normalized) {687 skipped += 1;688 continue;689 }690 const existing = byId.get(normalized.id);691 if (!existing) {692 entries.push(normalized);693 added += 1;694 } else if (normalized.modifiedAt > existing.modifiedAt) {695 const idx = entries.findIndex((e) => e.id === existing.id);696 if (idx !== -1) {697 entries[idx] = normalized;698 updated += 1;699 }700 } else {701 skipped += 1;702 }703 }704 }705706 await persistEntries();707 return { added, updated, skipped };708}709710711712713714715function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {716 if (!entry || typeof entry !== "object") return null;717 const title = typeof entry.title === "string" ? entry.title.trim() : "";718 if (!title) return null;719 const type = entry.type ?? "login";720 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();721 const now = Date.now();722 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;723 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;724725 if (type === "login" && typeof entry.password !== "string") {726 return null;727 }728 if (729 type === "passkey" &&730 (typeof entry.rpId !== "string" ||731 typeof entry.credentialId !== "string" ||732 !entry.privateKeyJwk)733 ) {734 return null;735 }736737 return {738 ...entry,739 id,740 title,741 type,742 createdAt,743 modifiedAt,744 };745}746747748749750751export async function clearVaultEntries(752 confirmation: "REPLACE_ALL"753): Promise<void> {754 if (confirmation !== "REPLACE_ALL") {755 throw new Error("clearVaultEntries requires explicit confirmation");756 }757 if (!derivedKey) throw new Error("vault locked");758 for (const entry of entries) {759 if (!entry.deletedAt) {760 entry.deletedAt = Date.now();761 entry.modifiedAt = Date.now();762 }763 }764 await persistEntries();765}766767export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {768 if (!derivedKey) throw new Error("vault locked");769770 const sanitized = sanitizeEntryDraft(entry);771 const now = Date.now();772 const nextEntry: VaultEntry = {773 ...sanitized,774 id: crypto.randomUUID(),775 createdAt: now,776 modifiedAt: now,777 778 779 passwordChangedAt: sanitized.password ? now : undefined,780 };781782 entries.push(nextEntry);783 await persistEntries();784785 786 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {787 checkBreach(nextEntry.password).then(async (count) => {788 if (count > 0) {789 const idx = entries.findIndex((e) => e.id === nextEntry.id);790 if (idx !== -1) {791 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };792 await persistEntries();793 }794 }795 });796 }797798 return nextEntry;799}800801export async function updateEntry(802 id: string,803 patch: Partial<VaultEntry>804): Promise<void> {805 if (!derivedKey) throw new Error("vault locked");806807 const idx = entries.findIndex((entry) => entry.id === id);808 if (idx === -1 || entries[idx].deletedAt) {809 throw new Error("entry not found");810 }811812 const current = entries[idx];813 const sanitizedPatch = sanitizeEntryPatch(patch);814 815 816 817 const passwordChanged =818 "password" in patch &&819 typeof sanitizedPatch.password === "string" &&820 sanitizedPatch.password !== current.password;821 entries[idx] = {822 ...current,823 ...sanitizedPatch,824 modifiedAt: Date.now(),825 ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),826 };827828 829 830 831 if (entries[idx].type === "login" && !entries[idx].password) {832 entries[idx] = current;833 throw new Error("password is required for login entries");834 }835836 837 if (entries[idx].type === "card" && !entries[idx].cardNumber) {838 entries[idx] = current;839 throw new Error("card number is required for card entries");840 }841842 await persistEntries();843844 845 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login" && entries[idx].password) {846 const password = entries[idx].password!;847 checkBreach(password).then(async (count) => {848 const i = entries.findIndex((e) => e.id === id);849 if (i !== -1) {850 entries[i] = { ...entries[i], breach: count > 0 ? count : undefined, modifiedAt: Date.now() };851 await persistEntries();852 }853 });854 }855}856857export async function deleteEntry(id: string): Promise<void> {858 if (!derivedKey) throw new Error("vault locked");859860 const entry = entries.find((item) => item.id === id);861 if (!entry || entry.deletedAt) {862 throw new Error("entry not found");863 }864865 entry.deletedAt = Date.now();866 entry.modifiedAt = Date.now();867 await persistEntries();868}869870export function getDeletedEntries(): VaultEntry[] {871 if (!derivedKey) return [];872 return entries.filter((e) => !!e.deletedAt);873}874875export async function restoreEntry(id: string): Promise<void> {876 if (!derivedKey) throw new Error("vault locked");877 const entry = entries.find((e) => e.id === id && e.deletedAt);878 if (!entry) throw new Error("deleted entry not found");879 delete entry.deletedAt;880 entry.modifiedAt = Date.now();881 await persistEntries();882 buildDomainIndexSync(entries);883}884885export async function purgeEntry(id: string): Promise<void> {886 if (!derivedKey) throw new Error("vault locked");887 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);888 if (idx === -1) throw new Error("deleted entry not found");889 entries.splice(idx, 1);890 await persistEntries();891}892893894export async function purgeExpiredTrash(retentionMs: number): Promise<number> {895 if (!derivedKey || retentionMs === 0) return 0;896 const cutoff = Date.now() - retentionMs;897 const before = entries.length;898 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));899 const purged = before - entries.length;900 if (purged > 0) await persistEntries();901 return purged;902}903904export function queryDomain(domain: string): VaultEntry[] {905 return domainIndex.get(domain) ?? [];906}907908export function getEntryById(id: string): VaultEntry | undefined {909 return entries.find((entry) => entry.id === id && !entry.deletedAt);910}911912async function persistSlotA(): Promise<StoredVault> {913 if (!derivedKey) throw new Error("vault locked");914915 const slotA = await loadSlot(SLOT_A);916 if (!slotA) throw new Error("missing slot A");917918 const plaintext = encodePayload(entries, duressConfigured);919 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);920921 const nextSlotA: StoredVault = {922 ...slotA,923 nonce,924 ciphertext,925 tag,926 };927 await writeSlot(SLOT_A, nextSlotA);928929 930 931 932 if (!duressConfigured) {933 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));934 }935936 return nextSlotA;937}938939async function persistSlotB(): Promise<void> {940 if (!derivedKey) throw new Error("vault locked");941942 const slotB = await loadSlot(SLOT_B);943 if (!slotB) throw new Error("missing slot B");944945 946 947 const plaintext = encodePayload(entries, false);948 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);949950 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });951}952953async function persistEntries(): Promise<void> {954 if (!derivedKey) throw new Error("vault locked");955956 if (vaultMode === "decoy") {957 await persistSlotB();958 } else {959 const freshSlotA = await persistSlotA();960 961 962 const activeCount = entries.filter((e) => !e.deletedAt).length;963 void takeSnapshot(freshSlotA, activeCount);964 }965 buildDomainIndexSync(entries);966}967968function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {969 if (!Array.isArray(values)) return undefined;970 const seen = new Set<string>();971 const cleaned: string[] = [];972 for (const value of values) {973 const trimmed = typeof value === "string" ? value.trim() : "";974 if (!trimmed) continue;975 const key = trimmed.toLowerCase();976 if (seen.has(key)) continue;977 seen.add(key);978 cleaned.push(trimmed);979 }980 return cleaned.length > 0 ? cleaned : undefined;981}982983function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {984 const title = entry.title.trim();985 const type = entry.type ?? "login";986987 if (!title) throw new Error("title is required");988 if (type === "login" && !entry.password?.trim()) {989 throw new Error("password is required");990 }991 if (type === "passkey") {992 if (993 !entry.rpId?.trim() ||994 !entry.rpName?.trim() ||995 !entry.credentialId?.trim() ||996 !entry.publicKeyCose?.trim() ||997 !entry.privateKeyJwk ||998 !entry.userHandle?.trim() ||999 !entry.userName?.trim() ||1000 !entry.userDisplayName?.trim() ||1001 (entry.algorithm !== -7 && entry.algorithm !== -8)1002 ) {1003 throw new Error("invalid passkey entry");1004 }1005 }1006 if (type === "card") {1007 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1008 throw new Error("cardholder name and card number are required");1009 }1010 }10111012 return {1013 type,1014 title,1015 username: type === "login" ? entry.username?.trim() ?? "" : undefined,1016 password: type === "login" ? entry.password : undefined,1017 url: type === "login" ? entry.url?.trim() ?? "" : undefined,1018 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1019 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1020 notes: type === "passkey" ? undefined : entry.notes?.trim() || undefined,1021 breach: type === "login" ? entry.breach : undefined,1022 canary: type === "login" ? entry.canary : undefined,1023 tags: entry.tags,1024 rpId: type === "passkey" ? entry.rpId?.trim() : undefined,1025 rpName: type === "passkey" ? entry.rpName?.trim() : undefined,1026 credentialId: type === "passkey" ? entry.credentialId?.trim() : undefined,1027 publicKeyCose: type === "passkey" ? entry.publicKeyCose?.trim() : undefined,1028 privateKeyJwk: type === "passkey" ? entry.privateKeyJwk : undefined,1029 userHandle: type === "passkey" ? entry.userHandle?.trim() : undefined,1030 userName: type === "passkey" ? entry.userName?.trim() : undefined,1031 userDisplayName: type === "passkey" ? entry.userDisplayName?.trim() : undefined,1032 algorithm: type === "passkey" ? entry.algorithm : undefined,1033 signCount: type === "passkey" ? entry.signCount ?? 0 : undefined,1034 aaguid: type === "passkey" ? entry.aaguid?.trim() : undefined,1035 backupEligible: type === "passkey" ? entry.backupEligible === true : undefined,1036 backupState: type === "passkey" ? entry.backupState === true : undefined,1037 lastUsedAt: type === "passkey" ? entry.lastUsedAt : undefined,1038 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1039 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1040 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1041 expYear: type === "card" ? entry.expYear?.trim() : undefined,1042 cvc: type === "card" ? entry.cvc?.trim() : undefined,1043 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1044 network: type === "card" ? entry.network : undefined,1045 last4: type === "card" ? entry.last4?.trim() : undefined,1046 };1047}10481049function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1050 const nextPatch: Partial<VaultEntry> = {};10511052 if ("type" in patch) {1053 nextPatch.type = patch.type;1054 }10551056 if ("title" in patch) {1057 const title = patch.title?.trim() ?? "";1058 if (!title) throw new Error("title is required");1059 nextPatch.title = title;1060 }10611062 if ("url" in patch) {1063 nextPatch.url = patch.url?.trim() ?? "";1064 }10651066 if ("additionalUrls" in patch) {1067 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1068 const seen = new Set<string>();1069 const cleaned: string[] = [];1070 for (const value of incoming) {1071 const trimmed = typeof value === "string" ? value.trim() : "";1072 if (!trimmed) continue;1073 const key = trimmed.toLowerCase();1074 if (seen.has(key)) continue;1075 seen.add(key);1076 cleaned.push(trimmed);1077 }1078 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1079 }10801081 if ("username" in patch) {1082 nextPatch.username = patch.username?.trim() ?? "";1083 }10841085 if ("password" in patch) {1086 const password = patch.password ?? "";1087 1088 nextPatch.password = password || undefined;1089 }10901091 if ("totp" in patch) {1092 nextPatch.totp = patch.totp?.trim() || undefined;1093 }10941095 if ("notes" in patch) {1096 nextPatch.notes = patch.notes?.trim() || undefined;1097 }10981099 if ("breach" in patch) {1100 nextPatch.breach = patch.breach;1101 }11021103 if ("canary" in patch) {1104 nextPatch.canary = patch.canary;1105 }11061107 if ("rpId" in patch) {1108 nextPatch.rpId = patch.rpId?.trim();1109 }11101111 if ("rpName" in patch) {1112 nextPatch.rpName = patch.rpName?.trim();1113 }11141115 if ("credentialId" in patch) {1116 nextPatch.credentialId = patch.credentialId?.trim();1117 }11181119 if ("publicKeyCose" in patch) {1120 nextPatch.publicKeyCose = patch.publicKeyCose?.trim();1121 }11221123 if ("privateKeyJwk" in patch) {1124 nextPatch.privateKeyJwk = patch.privateKeyJwk;1125 }11261127 if ("userHandle" in patch) {1128 nextPatch.userHandle = patch.userHandle?.trim();1129 }11301131 if ("userName" in patch) {1132 nextPatch.userName = patch.userName?.trim();1133 }11341135 if ("userDisplayName" in patch) {1136 nextPatch.userDisplayName = patch.userDisplayName?.trim();1137 }11381139 if ("algorithm" in patch) {1140 nextPatch.algorithm = patch.algorithm;1141 }11421143 if ("signCount" in patch) {1144 nextPatch.signCount = patch.signCount;1145 }11461147 if ("aaguid" in patch) {1148 nextPatch.aaguid = patch.aaguid?.trim();1149 }11501151 if ("backupEligible" in patch) {1152 nextPatch.backupEligible = patch.backupEligible;1153 }11541155 if ("backupState" in patch) {1156 nextPatch.backupState = patch.backupState;1157 }11581159 if ("lastUsedAt" in patch) {1160 nextPatch.lastUsedAt = patch.lastUsedAt;1161 }11621163 if ("tags" in patch) {1164 nextPatch.tags = patch.tags;1165 }11661167 if ("cardholderName" in patch) {1168 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1169 }1170 if ("cardNumber" in patch) {1171 const n = patch.cardNumber ?? "";1172 nextPatch.cardNumber = n || undefined;1173 }1174 if ("expMonth" in patch) {1175 nextPatch.expMonth = patch.expMonth?.trim() || undefined;1176 }1177 if ("expYear" in patch) {1178 nextPatch.expYear = patch.expYear?.trim() || undefined;1179 }1180 if ("cvc" in patch) {1181 nextPatch.cvc = patch.cvc?.trim() || undefined;1182 }1183 if ("cardNickname" in patch) {1184 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1185 }1186 if ("network" in patch) {1187 nextPatch.network = patch.network;1188 }1189 if ("last4" in patch) {1190 nextPatch.last4 = patch.last4?.trim() || undefined;1191 }11921193 return nextPatch;1194}11951196function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1197 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1198 const modifiedAt =1199 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;12001201 return {1202 id: entry.id,1203 type: "type" in entry ? entry.type : "login",1204 title: entry.title,1205 username: entry.username,1206 password: entry.password,1207 url: entry.url,1208 totp: entry.totp,1209 notes: entry.notes,1210 breach: entry.breach,1211 canary: "canary" in entry ? entry.canary : undefined,1212 rpId: "rpId" in entry ? entry.rpId : undefined,1213 rpName: "rpName" in entry ? entry.rpName : undefined,1214 credentialId: "credentialId" in entry ? entry.credentialId : undefined,1215 publicKeyCose: "publicKeyCose" in entry ? entry.publicKeyCose : undefined,1216 privateKeyJwk: "privateKeyJwk" in entry ? entry.privateKeyJwk : undefined,1217 userHandle: "userHandle" in entry ? entry.userHandle : undefined,1218 userName: "userName" in entry ? entry.userName : undefined,1219 userDisplayName: "userDisplayName" in entry ? entry.userDisplayName : undefined,1220 algorithm: "algorithm" in entry ? entry.algorithm : undefined,1221 signCount: "signCount" in entry ? entry.signCount : undefined,1222 aaguid: "aaguid" in entry ? entry.aaguid : undefined,1223 backupEligible: "backupEligible" in entry ? entry.backupEligible : undefined,1224 backupState: "backupState" in entry ? entry.backupState : undefined,1225 lastUsedAt: "lastUsedAt" in entry ? entry.lastUsedAt : undefined,1226 createdAt,1227 modifiedAt,1228 deletedAt: entry.deletedAt,1229 tags: "tags" in entry ? entry.tags : undefined,1230 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1231 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1232 expMonth: "expMonth" in entry ? entry.expMonth : undefined,1233 expYear: "expYear" in entry ? entry.expYear : undefined,1234 cvc: "cvc" in entry ? entry.cvc : undefined,1235 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1236 network: "network" in entry ? entry.network : undefined,1237 last4: "last4" in entry ? entry.last4 : undefined,1238 };1239}12401241function hasLegacyShape(parsed: VaultEntry[]): boolean {1242 return parsed.some(1243 (entry) => "created" in entry || "updated" in entry1244 );1245}124612471248124912501251function entryDupeKey(e: VaultEntry): string {1252 if (e.type === "note") return `note|${e.title.toLowerCase()}`;1253 if (e.type === "passkey") return `passkey|${(e.rpId ?? "").toLowerCase()}|${(e.userName ?? "").toLowerCase()}`;1254 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1255}125612571258export function countDuplicates(): number {1259 const best = new Map<string, VaultEntry>();1260 let dupeCount = 0;1261 for (const e of entries.filter((e) => !e.deletedAt)) {1262 const key = entryDupeKey(e);1263 const existing = best.get(key);1264 if (!existing) {1265 best.set(key, e);1266 } else {1267 dupeCount++;1268 1269 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1270 best.set(key, e);1271 }1272 }1273 }1274 return dupeCount;1275}127612771278127912801281export async function deduplicateEntries(): Promise<number> {1282 if (!derivedKey) throw new Error("vault locked");1283 1284 1285 if (vaultMode === "decoy") {1286 throw new Error("deduplicate disabled in decoy mode");1287 }12881289 const best = new Map<string, VaultEntry>();1290 for (const e of entries.filter((en) => !en.deletedAt)) {1291 const key = entryDupeKey(e);1292 const existing = best.get(key);1293 if (!existing) {1294 best.set(key, e);1295 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1296 best.set(key, e);1297 }1298 }12991300 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1301 const now = Date.now();1302 let removed = 0;1303 entries = entries.map((e) => {1304 if (!e.deletedAt && !keepIds.has(e.id)) {1305 removed++;1306 return { ...e, deletedAt: now };1307 }1308 return e;1309 });13101311 if (removed > 0) await persistEntries();1312 return removed;1313}131413151316131713181319const SNAPSHOT_KEY = "peach_vault_snapshots";1320const MAX_SNAPSHOTS = 6;13211322interface VaultSnapshot {1323 timestamp: number;1324 entryCount: number;1325 slot: StoredVault;1326}132713281329export interface SnapshotSummary {1330 index: number;1331 timestamp: number;1332 entryCount: number;1333}13341335async function takeSnapshot(1336 slot: StoredVault,1337 entryCount: number1338): Promise<void> {1339 try {1340 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1341 const existing: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1342 const next: VaultSnapshot[] = [1343 { timestamp: Date.now(), entryCount, slot },1344 ...existing,1345 ].slice(0, MAX_SNAPSHOTS);1346 await chrome.storage.local.set({ [SNAPSHOT_KEY]: next });1347 } catch {1348 1349 }1350}13511352export async function listSnapshots(): Promise<SnapshotSummary[]> {1353 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1354 const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1355 return snapshots.map((s, i) => ({1356 index: i,1357 timestamp: s.timestamp,1358 entryCount: s.entryCount,1359 }));1360}13611362136313641365136613671368export async function restoreFromSnapshot(1369 index: number,1370 password: string1371): Promise<boolean> {1372 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1373 const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1374 const snapshot = snapshots[index];1375 if (!snapshot) return false;13761377 1378 const key = await deriveKey(password, unhex(snapshot.slot.salt));1379 const payload = await tryDecryptSlot(snapshot.slot, key);1380 if (!payload) {1381 zeroKey(key);1382 return false;1383 }13841385 1386 await writeSlot(SLOT_A, snapshot.slot);1387 await writeSlot(SLOT_B, generateNoiseBlob(snapshot.slot.ciphertext.length / 2));13881389 1390 if (derivedKey) zeroKey(derivedKey);1391 derivedKey = key;1392 entries = payload.entries;1393 duressConfigured = payload.duressConfigured;1394 vaultMode = "real";1395 buildDomainIndexSync(entries);1396 await loadSettings();1397 touchAutoLock();13981399 return true;1400}14011402140314041405140614071408export async function changePassword(1409 currentPassword: string,1410 newPassword: string1411): Promise<boolean> {1412 if (!derivedKey || vaultMode !== "real") return false;14131414 1415 const valid = await verifyMainPassword(currentPassword);1416 if (!valid) return false;14171418 const slotA = await loadSlot(SLOT_A);1419 if (!slotA) return false;14201421 1422 const newSalt = generateSalt();1423 const newKey = await deriveKey(newPassword, newSalt);14241425 1426 const plaintext = encodePayload(entries, duressConfigured);1427 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);14281429 const freshSlotA: StoredVault = {1430 ...slotA,1431 salt: hex(newSalt),1432 nonce,1433 ciphertext,1434 tag,1435 };1436 await writeSlot(SLOT_A, freshSlotA);14371438 1439 1440 1441 zeroKey(derivedKey);1442 derivedKey = newKey;14431444 1445 1446 1447 const activeCount = entries.filter((e) => !e.deletedAt).length;1448 void takeSnapshot(freshSlotA, activeCount);14491450 1451 1452 1453 if (!duressConfigured) {1454 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1455 }14561457 return true;1458}