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 === "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 ("passkey" in patch) {1145 1146 1147 1148 nextPatch.passkey = patch.passkey;1149 }11501151 if ("tags" in patch) {1152 nextPatch.tags = patch.tags;1153 }11541155 if ("cardholderName" in patch) {1156 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1157 }1158 if ("cardNumber" in patch) {1159 const n = patch.cardNumber ?? "";1160 nextPatch.cardNumber = n || undefined;1161 }1162 if ("expMonth" in patch) {1163 nextPatch.expMonth = patch.expMonth?.trim() || undefined;1164 }1165 if ("expYear" in patch) {1166 nextPatch.expYear = patch.expYear?.trim() || undefined;1167 }1168 if ("cvc" in patch) {1169 nextPatch.cvc = patch.cvc?.trim() || undefined;1170 }1171 if ("cardNickname" in patch) {1172 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1173 }1174 if ("network" in patch) {1175 nextPatch.network = patch.network;1176 }1177 if ("last4" in patch) {1178 nextPatch.last4 = patch.last4?.trim() || undefined;1179 }11801181 return nextPatch;1182}11831184function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1185 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1186 const modifiedAt =1187 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;11881189 const base: VaultEntry = {1190 id: entry.id,1191 type: "type" in entry ? entry.type : "login",1192 title: entry.title,1193 username: entry.username,1194 password: entry.password,1195 url: entry.url,1196 totp: entry.totp,1197 notes: entry.notes,1198 breach: entry.breach,1199 canary: "canary" in entry ? entry.canary : undefined,1200 passkey: "passkey" in entry ? entry.passkey : undefined,1201 createdAt,1202 modifiedAt,1203 deletedAt: entry.deletedAt,1204 tags: "tags" in entry ? entry.tags : undefined,1205 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1206 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1207 expMonth: "expMonth" in entry ? entry.expMonth : undefined,1208 expYear: "expYear" in entry ? entry.expYear : undefined,1209 cvc: "cvc" in entry ? entry.cvc : undefined,1210 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1211 network: "network" in entry ? entry.network : undefined,1212 last4: "last4" in entry ? entry.last4 : undefined,1213 };12141215 return migrateLegacyPasskey(base, entry);1216}121712181219122012211222122312241225function migrateLegacyPasskey(1226 base: VaultEntry,1227 raw: VaultEntry | LegacyVaultEntry1228): VaultEntry {1229 if (base.passkey) return base;1230 const flat = raw as Partial<VaultEntry>;1231 const isPasskeyType = base.type === "passkey";1232 const hasFlatPasskeyFields =1233 typeof flat.rpId === "string" &&1234 typeof flat.credentialId === "string" &&1235 typeof flat.publicKeyCose === "string" &&1236 typeof flat.userHandle === "string" &&1237 typeof flat.userName === "string";1238 if (!isPasskeyType && !hasFlatPasskeyFields) return base;1239 if (!hasFlatPasskeyFields) return base;12401241 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;1242 base.passkey = {1243 rpId: flat.rpId!,1244 rpName: flat.rpName ?? flat.rpId!,1245 credentialId: flat.credentialId!,1246 publicKeyCose: flat.publicKeyCose!,1247 privateKeyJwk: flat.privateKeyJwk,1248 userHandle: flat.userHandle!,1249 userName: flat.userName!,1250 userDisplayName: flat.userDisplayName ?? flat.userName!,1251 algorithm,1252 signCount: flat.signCount ?? 0,1253 aaguid: flat.aaguid ?? "",1254 backupEligible: flat.backupEligible === true,1255 backupState: flat.backupState === true,1256 lastUsedAt: flat.lastUsedAt,1257 browser: flat.browser,1258 transports: flat.transports,1259 createdAt: base.createdAt,1260 };1261 1262 1263 1264 if (isPasskeyType) base.type = "login";1265 if (!base.username) base.username = flat.userName;1266 if (!base.url) base.url = `https://${flat.rpId}`;1267 return base;1268}1269127012711272127312741275function entryDupeKey(e: VaultEntry): string {1276 if (e.type === "note") return `note|${e.title.toLowerCase()}`;1277 1278 1279 1280 1281 if (e.passkey) {1282 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;1283 }1284 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1285}128612871288export function countDuplicates(): number {1289 const best = new Map<string, VaultEntry>();1290 let dupeCount = 0;1291 for (const e of entries.filter((e) => !e.deletedAt)) {1292 const key = entryDupeKey(e);1293 const existing = best.get(key);1294 if (!existing) {1295 best.set(key, e);1296 } else {1297 dupeCount++;1298 1299 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1300 best.set(key, e);1301 }1302 }1303 }1304 return dupeCount;1305}130613071308130913101311export async function deduplicateEntries(): Promise<number> {1312 if (!derivedKey) throw new Error("vault locked");1313 1314 1315 if (vaultMode === "decoy") {1316 throw new Error("deduplicate disabled in decoy mode");1317 }13181319 const best = new Map<string, VaultEntry>();1320 for (const e of entries.filter((en) => !en.deletedAt)) {1321 const key = entryDupeKey(e);1322 const existing = best.get(key);1323 if (!existing) {1324 best.set(key, e);1325 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1326 best.set(key, e);1327 }1328 }13291330 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1331 const now = Date.now();1332 let removed = 0;1333 entries = entries.map((e) => {1334 if (!e.deletedAt && !keepIds.has(e.id)) {1335 removed++;1336 return { ...e, deletedAt: now };1337 }1338 return e;1339 });13401341 if (removed > 0) await persistEntries();1342 return removed;1343}134413451346134713481349const SNAPSHOT_KEY = "peach_vault_snapshots";1350const MAX_SNAPSHOTS = 6;13511352interface VaultSnapshot {1353 timestamp: number;1354 entryCount: number;1355 slot: StoredVault;1356}135713581359export interface SnapshotSummary {1360 index: number;1361 timestamp: number;1362 entryCount: number;1363}13641365async function takeSnapshot(1366 slot: StoredVault,1367 entryCount: number1368): Promise<void> {1369 try {1370 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1371 const existing: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1372 const next: VaultSnapshot[] = [1373 { timestamp: Date.now(), entryCount, slot },1374 ...existing,1375 ].slice(0, MAX_SNAPSHOTS);1376 await chrome.storage.local.set({ [SNAPSHOT_KEY]: next });1377 } catch {1378 1379 }1380}13811382export async function listSnapshots(): Promise<SnapshotSummary[]> {1383 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1384 const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1385 return snapshots.map((s, i) => ({1386 index: i,1387 timestamp: s.timestamp,1388 entryCount: s.entryCount,1389 }));1390}13911392139313941395139613971398export async function restoreFromSnapshot(1399 index: number,1400 password: string1401): Promise<boolean> {1402 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1403 const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1404 const snapshot = snapshots[index];1405 if (!snapshot) return false;14061407 1408 const key = await deriveKey(password, unhex(snapshot.slot.salt));1409 const payload = await tryDecryptSlot(snapshot.slot, key);1410 if (!payload) {1411 zeroKey(key);1412 return false;1413 }14141415 1416 await writeSlot(SLOT_A, snapshot.slot);1417 await writeSlot(SLOT_B, generateNoiseBlob(snapshot.slot.ciphertext.length / 2));14181419 1420 if (derivedKey) zeroKey(derivedKey);1421 derivedKey = key;1422 entries = payload.entries;1423 duressConfigured = payload.duressConfigured;1424 vaultMode = "real";1425 buildDomainIndexSync(entries);1426 await loadSettings();1427 touchAutoLock();14281429 return true;1430}14311432143314341435143614371438export async function changePassword(1439 currentPassword: string,1440 newPassword: string1441): Promise<boolean> {1442 if (!derivedKey || vaultMode !== "real") return false;14431444 1445 const valid = await verifyMainPassword(currentPassword);1446 if (!valid) return false;14471448 const slotA = await loadSlot(SLOT_A);1449 if (!slotA) return false;14501451 1452 const newSalt = generateSalt();1453 const newKey = await deriveKey(newPassword, newSalt);14541455 1456 const plaintext = encodePayload(entries, duressConfigured);1457 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);14581459 const freshSlotA: StoredVault = {1460 ...slotA,1461 salt: hex(newSalt),1462 nonce,1463 ciphertext,1464 tag,1465 };1466 await writeSlot(SLOT_A, freshSlotA);14671468 1469 1470 1471 zeroKey(derivedKey);1472 derivedKey = newKey;14731474 1475 1476 1477 const activeCount = entries.filter((e) => !e.deletedAt).length;1478 void takeSnapshot(freshSlotA, activeCount);14791480 1481 1482 1483 if (!duressConfigured) {1484 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1485 }14861487 return true;1488}