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}281282283284285286287288289290291292const CSPRNG_CHUNK = 65_536;293294function fillRandom(buf: Uint8Array): Uint8Array {295 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {296 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);297 const chunk = new Uint8Array(end - offset);298 crypto.getRandomValues(chunk);299 buf.set(chunk, offset);300 }301 return buf;302}303304function generateNoiseBlob(ciphertextBytes: number): StoredVault {305 const salt = crypto.getRandomValues(new Uint8Array(16));306 const nonce = crypto.getRandomValues(new Uint8Array(12));307 const cipher = fillRandom(new Uint8Array(ciphertextBytes));308 const tag = crypto.getRandomValues(new Uint8Array(16));309 return {310 version: 1,311 salt: hex(salt),312 nonce: hex(nonce),313 ciphertext: hex(cipher),314 tag: hex(tag),315 };316}317318319320321322type VaultPayload = {323 entries: VaultEntry[];324 duressConfigured?: boolean;325};326327type DecryptedPayload = {328 entries: VaultEntry[];329 duressConfigured: boolean;330 331332333334335336337 migratedFromLegacy: boolean;338};339340function encodePayload(entries: VaultEntry[], duress: boolean): string {341 const envelope: VaultPayload = { entries, duressConfigured: duress };342 return padPlaintext(JSON.stringify(envelope));343}344345function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {346 if ("created" in raw || "updated" in raw) return true;347 const flat = raw as Partial<VaultEntry>;348 if (flat.type === "passkey") return true;349 350 if (flat.rpId !== undefined && flat.passkey === undefined) return true;351 return false;352}353354function decodePayload(plain: string): DecryptedPayload {355 const parsed = JSON.parse(plain) as356 | Array<VaultEntry | LegacyVaultEntry>357 | VaultPayload;358359 const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];360 const migratedFromLegacy = rawEntries.some(rawNeedsMigration);361362 if (Array.isArray(parsed)) {363 return {364 entries: parsed.map(normalizeEntry),365 duressConfigured: false,366 migratedFromLegacy: true, 367 };368 }369370 return {371 entries: rawEntries.map(normalizeEntry),372 duressConfigured: parsed.duressConfigured === true,373 migratedFromLegacy,374 };375}376377async function tryDecryptSlot(378 vault: StoredVault,379 key: Uint8Array380): Promise<DecryptedPayload | null> {381 try {382 const plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);383 return decodePayload(plain);384 } catch {385 return null;386 }387}388389390391392393function buildDomainIndexSync(all: VaultEntry[]): void {394 domainIndex = new Map();395396 for (const entry of all) {397 if (entry.deletedAt) continue;398 if (entry.type === "note" || entry.type === "passkey" || entry.type === "card") continue;399400 401 402 403 404 const domains = new Set<string>();405 const primary = extractDomain(entry.url ?? "");406 if (primary) domains.add(primary);407 for (const extra of entry.additionalUrls ?? []) {408 const d = extractDomain(extra);409 if (d) domains.add(d);410 }411 if (domains.size === 0) continue;412413 for (const domain of domains) {414 const bucket = domainIndex.get(domain) ?? [];415 bucket.push(entry);416 domainIndex.set(domain, bucket);417 }418 }419}420421export async function createVault(password: string): Promise<void> {422 await ensureMigrated();423424 const salt = generateSalt();425 derivedKey = await deriveKey(password, salt);426 entries = [];427 vaultMode = "real";428 duressConfigured = false;429 buildDomainIndexSync(entries);430431 const plaintext = encodePayload(entries, duressConfigured);432 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);433434 const slotA: StoredVault = {435 version: 1,436 salt: hex(salt),437 nonce,438 ciphertext,439 tag,440 };441 await writeSlot(SLOT_A, slotA);442443 444 445 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));446447 touchAutoLock();448}449450export async function hasVault(): Promise<boolean> {451 await ensureMigrated();452 return (await loadSlot(SLOT_A)) !== null;453}454455456457458459460export async function isDuressEnabled(): Promise<boolean> {461 return vaultMode === "real" && duressConfigured;462}463464export async function hasDecoyVault(): Promise<boolean> {465 return isDuressEnabled();466}467468export async function setupDuress(duressPassword: string): Promise<void> {469 if (!derivedKey || vaultMode !== "real") {470 throw new Error("vault must be unlocked in real mode");471 }472 await ensureMigrated();473474 475 476 477 const slotA = await loadSlot(SLOT_A);478 if (!slotA) throw new Error("missing slot A");479480 const decoySalt = generateSalt();481 const decoyKey = await deriveKey(duressPassword, decoySalt);482 try {483 484 485 const targetBytes = slotA.ciphertext.length / 2;486 let plaintext = encodePayload([], false);487 const encoded = new TextEncoder().encode(plaintext);488 if (encoded.length < targetBytes) {489 plaintext = plaintext + " ".repeat(targetBytes - encoded.length);490 }491 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);492 await writeSlot(SLOT_B, {493 version: 1,494 salt: hex(decoySalt),495 nonce,496 ciphertext,497 tag,498 });499 } finally {500 zeroKey(decoyKey);501 }502503 504 505 duressConfigured = true;506 await persistSlotA();507}508509export async function disableDuress(): Promise<void> {510 if (!derivedKey || vaultMode !== "real") {511 throw new Error("vault must be unlocked in real mode");512 }513 await ensureMigrated();514515 516 517 518 const slotA = await loadSlot(SLOT_A);519 if (slotA) {520 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));521 }522523 duressConfigured = false;524 await persistSlotA();525}526527export async function verifyMainPassword(password: string): Promise<boolean> {528 await ensureMigrated();529 const slotA = await loadSlot(SLOT_A);530 if (!slotA) return false;531 const key = await deriveKey(password, unhex(slotA.salt));532 try {533 return (await tryDecryptSlot(slotA, key)) !== null;534 } finally {535 zeroKey(key);536 }537}538539export async function unlock(password: string): Promise<boolean> {540 await ensureMigrated();541542 543 544 545 546 547 const [slotA, slotB] = await Promise.all([548 loadSlot(SLOT_A),549 loadSlot(SLOT_B),550 ]);551552 553 const [keyA, keyB] = await Promise.all([554 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),555 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),556 ]);557558 559 const [payloadA, payloadB] = await Promise.all([560 keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),561 keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),562 ]);563564 if (payloadA && keyA) {565 derivedKey = keyA;566 vaultMode = "real";567 entries = payloadA.entries;568 duressConfigured = payloadA.duressConfigured;569 buildDomainIndexSync(entries);570 await loadSettings();571 touchAutoLock();572 if (keyB) zeroKey(keyB);573 574 575 576 if (payloadA.migratedFromLegacy) {577 await persistSlotA();578 }579 return true;580 }581582 if (payloadB && keyB) {583 derivedKey = keyB;584 vaultMode = "decoy";585 entries = payloadB.entries;586 duressConfigured = false;587 buildDomainIndexSync(entries);588 await loadSettings();589 touchAutoLock();590 if (keyA) zeroKey(keyA);591 return true;592 }593594 if (keyA) zeroKey(keyA);595 if (keyB) zeroKey(keyB);596 return false;597}598599export function lock(): void {600 if (derivedKey) {601 zeroKey(derivedKey);602 derivedKey = null;603 }604605 entries = [];606 domainIndex = new Map();607 vaultMode = "real";608 duressConfigured = false;609 clearAutoLock();610}611612export function isUnlocked(): boolean {613 return derivedKey !== null;614}615616export function getEntries(): VaultEntry[] {617 if (!derivedKey) throw new Error("vault locked");618 return entries.filter((entry) => !entry.deletedAt);619}620621const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;622623export function getIdentities(): SavedIdentity[] {624 if (!derivedKey) throw new Error("vault locked");625 const active = entries.filter((e) => !e.deletedAt && e.type === "login");626627 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();628 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();629630 for (const entry of active) {631 const value = entry.username?.trim();632 if (!value) continue;633 const map = EMAIL_RE.test(value) ? emailMap : usernameMap;634 const existing = map.get(value);635 const domain = extractDomain(entry.url ?? "") ?? "";636 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;637 if (!existing || modifiedAt > existing.lastUsed) {638 map.set(value, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });639 } else {640 map.set(value, { ...existing, count: existing.count + 1 });641 }642 }643644 const toIdentity = (type: "email" | "username") =>645 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({646 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,647 });648649 return [650 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),651 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),652 ];653}654655export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {656 if (!derivedKey) throw new Error("vault locked");657658 const now = Date.now();659 const added: VaultEntry[] = [];660 for (const draft of drafts) {661 try {662 const sanitized = sanitizeEntryDraft(draft);663 const nextEntry: VaultEntry = {664 ...sanitized,665 id: crypto.randomUUID(),666 createdAt: now,667 modifiedAt: now,668 };669 entries.push(nextEntry);670 added.push(nextEntry);671 } catch {672 673 }674 }675676 if (added.length > 0) {677 await persistEntries();678 }679680 return added;681}682683684685686687688689690export async function restoreEntries(691 incoming: VaultEntry[],692 mode: "replace" | "merge"693): Promise<{ added: number; updated: number; skipped: number }> {694 if (!derivedKey) throw new Error("vault locked");695696 697 698 699 if (mode === "replace" && incoming.length === 0) {700 throw new Error("refuse to replace with empty entry set");701 }702703 let added = 0;704 let updated = 0;705 let skipped = 0;706707 if (mode === "replace") {708 entries = [];709 for (const entry of incoming) {710 const normalized = sanitizeRestoredEntry(entry);711 if (!normalized) {712 skipped += 1;713 continue;714 }715 entries.push(normalized);716 added += 1;717 }718 } else {719 const byId = new Map<string, VaultEntry>();720 for (const e of entries) byId.set(e.id, e);721722 for (const entry of incoming) {723 const normalized = sanitizeRestoredEntry(entry);724 if (!normalized) {725 skipped += 1;726 continue;727 }728 const existing = byId.get(normalized.id);729 if (!existing) {730 entries.push(normalized);731 added += 1;732 } else if (normalized.modifiedAt > existing.modifiedAt) {733 const idx = entries.findIndex((e) => e.id === existing.id);734 if (idx !== -1) {735 entries[idx] = normalized;736 updated += 1;737 }738 } else {739 skipped += 1;740 }741 }742 }743744 await persistEntries();745 return { added, updated, skipped };746}747748749750751752753function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {754 if (!entry || typeof entry !== "object") return null;755 const title = typeof entry.title === "string" ? entry.title.trim() : "";756 if (!title) return null;757 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();758 const now = Date.now();759 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;760 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;761762 763 764 765 const migrated: VaultEntry = migrateLegacyPasskey(766 {767 ...entry,768 id,769 title,770 type: entry.type ?? "login",771 createdAt,772 modifiedAt,773 },774 entry775 );776777 778 779 if (780 migrated.type === "login" &&781 !migrated.passkey &&782 typeof migrated.password !== "string"783 ) {784 return null;785 }786 if (migrated.passkey && !migrated.passkey.credentialId) {787 return null;788 }789790 return migrated;791}792793794795796797export async function clearVaultEntries(798 confirmation: "REPLACE_ALL"799): Promise<void> {800 if (confirmation !== "REPLACE_ALL") {801 throw new Error("clearVaultEntries requires explicit confirmation");802 }803 if (!derivedKey) throw new Error("vault locked");804 for (const entry of entries) {805 if (!entry.deletedAt) {806 entry.deletedAt = Date.now();807 entry.modifiedAt = Date.now();808 }809 }810 await persistEntries();811}812813export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {814 if (!derivedKey) throw new Error("vault locked");815816 const sanitized = sanitizeEntryDraft(entry);817 const now = Date.now();818 const nextEntry: VaultEntry = {819 ...sanitized,820 id: crypto.randomUUID(),821 createdAt: now,822 modifiedAt: now,823 824 825 passwordChangedAt: sanitized.password ? now : undefined,826 };827828 entries.push(nextEntry);829 await persistEntries();830831 832 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {833 checkBreach(nextEntry.password).then(async (count) => {834 if (count > 0) {835 const idx = entries.findIndex((e) => e.id === nextEntry.id);836 if (idx !== -1) {837 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };838 await persistEntries();839 }840 }841 });842 }843844 return nextEntry;845}846847export async function updateEntry(848 id: string,849 patch: Partial<VaultEntry>850): Promise<void> {851 if (!derivedKey) throw new Error("vault locked");852853 const idx = entries.findIndex((entry) => entry.id === id);854 if (idx === -1 || entries[idx].deletedAt) {855 throw new Error("entry not found");856 }857858 const current = entries[idx];859 const sanitizedPatch = sanitizeEntryPatch(patch);860 861 862 863 const passwordChanged =864 "password" in patch &&865 typeof sanitizedPatch.password === "string" &&866 sanitizedPatch.password !== current.password;867 entries[idx] = {868 ...current,869 ...sanitizedPatch,870 modifiedAt: Date.now(),871 ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),872 };873874 875 876 877 if (entries[idx].type === "login" && !entries[idx].password) {878 entries[idx] = current;879 throw new Error("password is required for login entries");880 }881882 883 if (entries[idx].type === "card" && !entries[idx].cardNumber) {884 entries[idx] = current;885 throw new Error("card number is required for card entries");886 }887888 await persistEntries();889890 891 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login" && entries[idx].password) {892 const password = entries[idx].password!;893 checkBreach(password).then(async (count) => {894 const i = entries.findIndex((e) => e.id === id);895 if (i !== -1) {896 entries[i] = { ...entries[i], breach: count > 0 ? count : undefined, modifiedAt: Date.now() };897 await persistEntries();898 }899 });900 }901}902903export async function deleteEntry(id: string): Promise<void> {904 if (!derivedKey) throw new Error("vault locked");905906 const entry = entries.find((item) => item.id === id);907 if (!entry || entry.deletedAt) {908 throw new Error("entry not found");909 }910911 entry.deletedAt = Date.now();912 entry.modifiedAt = Date.now();913 await persistEntries();914}915916export function getDeletedEntries(): VaultEntry[] {917 if (!derivedKey) return [];918 return entries.filter((e) => !!e.deletedAt);919}920921export async function restoreEntry(id: string): Promise<void> {922 if (!derivedKey) throw new Error("vault locked");923 const entry = entries.find((e) => e.id === id && e.deletedAt);924 if (!entry) throw new Error("deleted entry not found");925 delete entry.deletedAt;926 entry.modifiedAt = Date.now();927 await persistEntries();928 buildDomainIndexSync(entries);929}930931export async function purgeEntry(id: string): Promise<void> {932 if (!derivedKey) throw new Error("vault locked");933 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);934 if (idx === -1) throw new Error("deleted entry not found");935 entries.splice(idx, 1);936 await persistEntries();937}938939940export async function purgeExpiredTrash(retentionMs: number): Promise<number> {941 if (!derivedKey || retentionMs === 0) return 0;942 const cutoff = Date.now() - retentionMs;943 const before = entries.length;944 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));945 const purged = before - entries.length;946 if (purged > 0) await persistEntries();947 return purged;948}949950export function queryDomain(domain: string): VaultEntry[] {951 return domainIndex.get(domain) ?? [];952}953954export function getEntryById(id: string): VaultEntry | undefined {955 return entries.find((entry) => entry.id === id && !entry.deletedAt);956}957958async function persistSlotA(): Promise<StoredVault> {959 if (!derivedKey) throw new Error("vault locked");960961 const slotA = await loadSlot(SLOT_A);962 if (!slotA) throw new Error("missing slot A");963964 const plaintext = encodePayload(entries, duressConfigured);965 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);966967 const nextSlotA: StoredVault = {968 ...slotA,969 nonce,970 ciphertext,971 tag,972 };973 await writeSlot(SLOT_A, nextSlotA);974975 976 977 978 if (!duressConfigured) {979 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));980 }981982 return nextSlotA;983}984985async function persistSlotB(): Promise<void> {986 if (!derivedKey) throw new Error("vault locked");987988 const slotB = await loadSlot(SLOT_B);989 if (!slotB) throw new Error("missing slot B");990991 992 993 const plaintext = encodePayload(entries, false);994 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);995996 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });997}998999async function persistEntries(): Promise<void> {1000 if (!derivedKey) throw new Error("vault locked");10011002 if (vaultMode === "decoy") {1003 await persistSlotB();1004 } else {1005 const freshSlotA = await persistSlotA();1006 1007 1008 const activeCount = entries.filter((e) => !e.deletedAt).length;1009 void takeSnapshot(freshSlotA, activeCount);1010 }1011 buildDomainIndexSync(entries);1012}10131014function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1015 if (!Array.isArray(values)) return undefined;1016 const seen = new Set<string>();1017 const cleaned: string[] = [];1018 for (const value of values) {1019 const trimmed = typeof value === "string" ? value.trim() : "";1020 if (!trimmed) continue;1021 const key = trimmed.toLowerCase();1022 if (seen.has(key)) continue;1023 seen.add(key);1024 cleaned.push(trimmed);1025 }1026 return cleaned.length > 0 ? cleaned : undefined;1027}10281029function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1030 const title = entry.title.trim();1031 1032 1033 1034 let type: VaultEntry["type"] = entry.type ?? "login";1035 if (type === "passkey") type = "login";10361037 if (!title) throw new Error("title is required");1038 1039 1040 if (type === "login" && !entry.password?.trim() && !entry.passkey) {1041 throw new Error("password or passkey required");1042 }1043 if (entry.passkey) {1044 const p = entry.passkey;1045 if (1046 !p.rpId ||1047 !p.credentialId ||1048 !p.publicKeyCose ||1049 !p.userHandle ||1050 !p.userName ||1051 (p.algorithm !== -7 && p.algorithm !== -8)1052 ) {1053 throw new Error("invalid passkey credential");1054 }1055 }1056 if (type === "card") {1057 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1058 throw new Error("cardholder name and card number are required");1059 }1060 }10611062 return {1063 type,1064 title,1065 username: type === "login" ? entry.username?.trim() ?? "" : undefined,1066 password: type === "login" ? entry.password : undefined,1067 url: type === "login" ? entry.url?.trim() ?? "" : undefined,1068 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1069 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1070 notes: entry.notes?.trim() || undefined,1071 breach: type === "login" ? entry.breach : undefined,1072 canary: type === "login" ? entry.canary : undefined,1073 tags: entry.tags,1074 passkey: entry.passkey,1075 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1076 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1077 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1078 expYear: type === "card" ? entry.expYear?.trim() : undefined,1079 cvc: type === "card" ? entry.cvc?.trim() : undefined,1080 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1081 network: type === "card" ? entry.network : undefined,1082 last4: type === "card" ? entry.last4?.trim() : undefined,1083 };1084}10851086function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1087 const nextPatch: Partial<VaultEntry> = {};10881089 if ("type" in patch) {1090 nextPatch.type = patch.type;1091 }10921093 if ("title" in patch) {1094 const title = patch.title?.trim() ?? "";1095 if (!title) throw new Error("title is required");1096 nextPatch.title = title;1097 }10981099 if ("url" in patch) {1100 nextPatch.url = patch.url?.trim() ?? "";1101 }11021103 if ("additionalUrls" in patch) {1104 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1105 const seen = new Set<string>();1106 const cleaned: string[] = [];1107 for (const value of incoming) {1108 const trimmed = typeof value === "string" ? value.trim() : "";1109 if (!trimmed) continue;1110 const key = trimmed.toLowerCase();1111 if (seen.has(key)) continue;1112 seen.add(key);1113 cleaned.push(trimmed);1114 }1115 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1116 }11171118 if ("username" in patch) {1119 nextPatch.username = patch.username?.trim() ?? "";1120 }11211122 if ("password" in patch) {1123 const password = patch.password ?? "";1124 1125 nextPatch.password = password || undefined;1126 }11271128 if ("totp" in patch) {1129 nextPatch.totp = patch.totp?.trim() || undefined;1130 }11311132 if ("notes" in patch) {1133 nextPatch.notes = patch.notes?.trim() || undefined;1134 }11351136 if ("breach" in patch) {1137 nextPatch.breach = patch.breach;1138 }11391140 if ("canary" in patch) {1141 nextPatch.canary = patch.canary;1142 }11431144 if ("rpId" in patch) {1145 nextPatch.rpId = patch.rpId?.trim();1146 }11471148 if ("rpName" in patch) {1149 nextPatch.rpName = patch.rpName?.trim();1150 }11511152 if ("credentialId" in patch) {1153 nextPatch.credentialId = patch.credentialId?.trim();1154 }11551156 if ("publicKeyCose" in patch) {1157 nextPatch.publicKeyCose = patch.publicKeyCose?.trim();1158 }11591160 if ("privateKeyJwk" in patch) {1161 nextPatch.privateKeyJwk = patch.privateKeyJwk;1162 }11631164 if ("userHandle" in patch) {1165 nextPatch.userHandle = patch.userHandle?.trim();1166 }11671168 if ("userName" in patch) {1169 nextPatch.userName = patch.userName?.trim();1170 }11711172 if ("userDisplayName" in patch) {1173 nextPatch.userDisplayName = patch.userDisplayName?.trim();1174 }11751176 if ("algorithm" in patch) {1177 nextPatch.algorithm = patch.algorithm;1178 }11791180 if ("signCount" in patch) {1181 nextPatch.signCount = patch.signCount;1182 }11831184 if ("aaguid" in patch) {1185 nextPatch.aaguid = patch.aaguid?.trim();1186 }11871188 if ("backupEligible" in patch) {1189 nextPatch.backupEligible = patch.backupEligible;1190 }11911192 if ("backupState" in patch) {1193 nextPatch.backupState = patch.backupState;1194 }11951196 if ("lastUsedAt" in patch) {1197 nextPatch.lastUsedAt = patch.lastUsedAt;1198 }11991200 if ("tags" in patch) {1201 nextPatch.tags = patch.tags;1202 }12031204 if ("cardholderName" in patch) {1205 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1206 }1207 if ("cardNumber" in patch) {1208 const n = patch.cardNumber ?? "";1209 nextPatch.cardNumber = n || undefined;1210 }1211 if ("expMonth" in patch) {1212 nextPatch.expMonth = patch.expMonth?.trim() || undefined;1213 }1214 if ("expYear" in patch) {1215 nextPatch.expYear = patch.expYear?.trim() || undefined;1216 }1217 if ("cvc" in patch) {1218 nextPatch.cvc = patch.cvc?.trim() || undefined;1219 }1220 if ("cardNickname" in patch) {1221 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1222 }1223 if ("network" in patch) {1224 nextPatch.network = patch.network;1225 }1226 if ("last4" in patch) {1227 nextPatch.last4 = patch.last4?.trim() || undefined;1228 }12291230 return nextPatch;1231}12321233function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1234 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1235 const modifiedAt =1236 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;12371238 const base: VaultEntry = {1239 id: entry.id,1240 type: "type" in entry ? entry.type : "login",1241 title: entry.title,1242 username: entry.username,1243 password: entry.password,1244 url: entry.url,1245 totp: entry.totp,1246 notes: entry.notes,1247 breach: entry.breach,1248 canary: "canary" in entry ? entry.canary : undefined,1249 passkey: "passkey" in entry ? entry.passkey : undefined,1250 createdAt,1251 modifiedAt,1252 deletedAt: entry.deletedAt,1253 tags: "tags" in entry ? entry.tags : undefined,1254 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1255 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1256 expMonth: "expMonth" in entry ? entry.expMonth : undefined,1257 expYear: "expYear" in entry ? entry.expYear : undefined,1258 cvc: "cvc" in entry ? entry.cvc : undefined,1259 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1260 network: "network" in entry ? entry.network : undefined,1261 last4: "last4" in entry ? entry.last4 : undefined,1262 };12631264 return migrateLegacyPasskey(base, entry);1265}126612671268126912701271127212731274function migrateLegacyPasskey(1275 base: VaultEntry,1276 raw: VaultEntry | LegacyVaultEntry1277): VaultEntry {1278 if (base.passkey) return base;1279 const flat = raw as Partial<VaultEntry>;1280 const isPasskeyType = base.type === "passkey";1281 const hasFlatPasskeyFields =1282 typeof flat.rpId === "string" &&1283 typeof flat.credentialId === "string" &&1284 typeof flat.publicKeyCose === "string" &&1285 typeof flat.userHandle === "string" &&1286 typeof flat.userName === "string";1287 if (!isPasskeyType && !hasFlatPasskeyFields) return base;1288 if (!hasFlatPasskeyFields) return base;12891290 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;1291 base.passkey = {1292 rpId: flat.rpId!,1293 rpName: flat.rpName ?? flat.rpId!,1294 credentialId: flat.credentialId!,1295 publicKeyCose: flat.publicKeyCose!,1296 privateKeyJwk: flat.privateKeyJwk,1297 userHandle: flat.userHandle!,1298 userName: flat.userName!,1299 userDisplayName: flat.userDisplayName ?? flat.userName!,1300 algorithm,1301 signCount: flat.signCount ?? 0,1302 aaguid: flat.aaguid ?? "",1303 backupEligible: flat.backupEligible === true,1304 backupState: flat.backupState === true,1305 lastUsedAt: flat.lastUsedAt,1306 browser: flat.browser,1307 transports: flat.transports,1308 createdAt: base.createdAt,1309 };1310 1311 1312 1313 if (isPasskeyType) base.type = "login";1314 if (!base.username) base.username = flat.userName;1315 if (!base.url) base.url = `https://${flat.rpId}`;1316 return base;1317}1318131913201321132213231324function entryDupeKey(e: VaultEntry): string {1325 if (e.type === "note") return `note|${e.title.toLowerCase()}`;1326 1327 1328 1329 1330 if (e.passkey) {1331 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;1332 }1333 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1334}133513361337export function countDuplicates(): number {1338 const best = new Map<string, VaultEntry>();1339 let dupeCount = 0;1340 for (const e of entries.filter((e) => !e.deletedAt)) {1341 const key = entryDupeKey(e);1342 const existing = best.get(key);1343 if (!existing) {1344 best.set(key, e);1345 } else {1346 dupeCount++;1347 1348 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1349 best.set(key, e);1350 }1351 }1352 }1353 return dupeCount;1354}135513561357135813591360export async function deduplicateEntries(): Promise<number> {1361 if (!derivedKey) throw new Error("vault locked");1362 1363 1364 if (vaultMode === "decoy") {1365 throw new Error("deduplicate disabled in decoy mode");1366 }13671368 const best = new Map<string, VaultEntry>();1369 for (const e of entries.filter((en) => !en.deletedAt)) {1370 const key = entryDupeKey(e);1371 const existing = best.get(key);1372 if (!existing) {1373 best.set(key, e);1374 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1375 best.set(key, e);1376 }1377 }13781379 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1380 const now = Date.now();1381 let removed = 0;1382 entries = entries.map((e) => {1383 if (!e.deletedAt && !keepIds.has(e.id)) {1384 removed++;1385 return { ...e, deletedAt: now };1386 }1387 return e;1388 });13891390 if (removed > 0) await persistEntries();1391 return removed;1392}139313941395139613971398const SNAPSHOT_KEY = "peach_vault_snapshots";1399const MAX_SNAPSHOTS = 6;14001401interface VaultSnapshot {1402 timestamp: number;1403 entryCount: number;1404 slot: StoredVault;1405}140614071408export interface SnapshotSummary {1409 index: number;1410 timestamp: number;1411 entryCount: number;1412}14131414async function takeSnapshot(1415 slot: StoredVault,1416 entryCount: number1417): Promise<void> {1418 try {1419 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1420 const existing: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1421 const next: VaultSnapshot[] = [1422 { timestamp: Date.now(), entryCount, slot },1423 ...existing,1424 ].slice(0, MAX_SNAPSHOTS);1425 await chrome.storage.local.set({ [SNAPSHOT_KEY]: next });1426 } catch {1427 1428 }1429}14301431export async function listSnapshots(): Promise<SnapshotSummary[]> {1432 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1433 const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1434 return snapshots.map((s, i) => ({1435 index: i,1436 timestamp: s.timestamp,1437 entryCount: s.entryCount,1438 }));1439}14401441144214431444144514461447export async function restoreFromSnapshot(1448 index: number,1449 password: string1450): Promise<boolean> {1451 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1452 const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1453 const snapshot = snapshots[index];1454 if (!snapshot) return false;14551456 1457 const key = await deriveKey(password, unhex(snapshot.slot.salt));1458 const payload = await tryDecryptSlot(snapshot.slot, key);1459 if (!payload) {1460 zeroKey(key);1461 return false;1462 }14631464 1465 await writeSlot(SLOT_A, snapshot.slot);1466 await writeSlot(SLOT_B, generateNoiseBlob(snapshot.slot.ciphertext.length / 2));14671468 1469 if (derivedKey) zeroKey(derivedKey);1470 derivedKey = key;1471 entries = payload.entries;1472 duressConfigured = payload.duressConfigured;1473 vaultMode = "real";1474 buildDomainIndexSync(entries);1475 await loadSettings();1476 touchAutoLock();14771478 return true;1479}14801481148214831484148514861487export async function changePassword(1488 currentPassword: string,1489 newPassword: string1490): Promise<boolean> {1491 if (!derivedKey || vaultMode !== "real") return false;14921493 1494 const valid = await verifyMainPassword(currentPassword);1495 if (!valid) return false;14961497 const slotA = await loadSlot(SLOT_A);1498 if (!slotA) return false;14991500 1501 const newSalt = generateSalt();1502 const newKey = await deriveKey(newPassword, newSalt);15031504 1505 const plaintext = encodePayload(entries, duressConfigured);1506 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);15071508 const freshSlotA: StoredVault = {1509 ...slotA,1510 salt: hex(newSalt),1511 nonce,1512 ciphertext,1513 tag,1514 };1515 await writeSlot(SLOT_A, freshSlotA);15161517 1518 1519 1520 zeroKey(derivedKey);1521 derivedKey = newKey;15221523 1524 1525 1526 const activeCount = entries.filter((e) => !e.deletedAt).length;1527 void takeSnapshot(freshSlotA, activeCount);15281529 1530 1531 1532 if (!duressConfigured) {1533 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1534 }15351536 return true;1537}