123456789101112import { deriveKey, generateSalt } from "../crypto/argon2";13import { extractDomain } from "../shared/domain";14import type {15 AccentColorName,16 LegacyVaultEntry,17 ManualSummonRecord,18 StoredVault,19 VaultEntry,20} from "../shared/types";21import { decrypt, encrypt, zeroKey } from "./aes";22import { clearAutoLock, touchAutoLock } from "./alarms";2324type EntryDraft = Omit<25 VaultEntry,26 "id" | "createdAt" | "modifiedAt" | "deletedAt"27>;282930313233const SLOT_A = "peach_vault_a";34const SLOT_B = "peach_vault_b";353637const LEGACY_MAIN = "peach_vault";38const LEGACY_DECOY = "peach_decoy_vault";39const LEGACY_DURESS_FLAG = "peach_duress";40414243const PAD_BLOCK = 1024;4445let derivedKey: Uint8Array | null = null;46let entries: VaultEntry[] = [];47let domainIndex: Map<string, VaultEntry[]> = new Map();48let vaultMode: "real" | "decoy" = "real";49505152let duressConfigured = false;5354let migrationRan = false;555657585960export interface VaultSettings {61 accentColor?: AccentColorName;62 theme?: "dark" | "light";63 autoLockMinutes?: number;64 clipboardClearSeconds?: number;65 lockOnClose?: boolean;66 breachMonitoring?: boolean;67 passkeyProviderEnabled?: boolean;68 trashRetentionDays?: number;69 manualSummonDomains?: Record<string, ManualSummonRecord>;70 declinedCardFingerprints?: string[];71}7273let settings: VaultSettings = {};7475export function getSettings(): VaultSettings {76 return settings;77}7879export async function ensureSettingsLoaded(): Promise<VaultSettings> {80 await loadSettings();81 return settings;82}8384export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {85 await loadSettings();86 settings = { ...settings, ...patch };87 await persistSettings();88}8990async function persistSettings(): Promise<void> {91 try {92 await chrome.storage.local.set({ peach_settings: settings });93 } catch (err) {94 throw mapStorageError(err);95 }96}9798function mapStorageError(err: unknown): Error {99 const message = err instanceof Error ? err.message : String(err);100 if (/QUOTA_BYTES|quota/i.test(message)) {101 return new Error("STORAGE_QUOTA_EXCEEDED");102 }103 return err instanceof Error ? err : new Error(message);104}105106async function loadSettings(): Promise<void> {107 const result = await chrome.storage.local.get("peach_settings");108 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};109}110111112113114115export async function checkBreach(password: string): Promise<number> {116 const encoder = new TextEncoder();117 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));118 const hashHex = Array.from(new Uint8Array(hashBuffer))119 .map((b) => b.toString(16).padStart(2, "0"))120 .join("")121 .toUpperCase();122123 const prefix = hashHex.slice(0, 5);124 const suffix = hashHex.slice(5);125126 try {127 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);128 if (!res.ok) return 0;129 const text = await res.text();130 const match = text.split("\r\n").find((l) => l.startsWith(suffix));131 return match ? parseInt(match.split(":")[1], 10) : 0;132 } catch {133 return 0; 134 }135}136137export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {138 const fingerprint = await cardFingerprint(last4, expMonth, expYear);139 await loadSettings();140 settings = {141 ...settings,142 declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],143 };144 await persistSettings();145}146147export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {148 await loadSettings();149 const fingerprint = await cardFingerprint(last4, expMonth, expYear);150 return (settings.declinedCardFingerprints ?? []).includes(fingerprint);151}152153async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {154 const encoder = new TextEncoder();155 const buf = await crypto.subtle.digest("SHA-1", encoder.encode(`${last4}${expMonth}${expYear}`));156 return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, "0")).join("");157}158159function hex(buf: Uint8Array): string {160 return Array.from(buf)161 .map((b) => b.toString(16).padStart(2, "0"))162 .join("");163}164165function unhex(s: string): Uint8Array {166 const out = new Uint8Array(s.length / 2);167 for (let i = 0; i < s.length; i += 2) {168 out[i / 2] = parseInt(s.slice(i, i + 2), 16);169 }170 return out;171}172173174175176177async function loadSlot(key: string): Promise<StoredVault | null> {178 const result = await chrome.storage.local.get(key);179 return (result as Record<string, StoredVault | undefined>)[key] ?? null;180}181182async function writeSlot(key: string, vault: StoredVault): Promise<void> {183 try {184 await chrome.storage.local.set({ [key]: vault });185 } catch (err) {186 throw mapStorageError(err);187 }188}189190191192193194195async function ensureMigrated(): Promise<void> {196 if (migrationRan) return;197 migrationRan = true;198199 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];200 const raw = await chrome.storage.local.get(keys);201 const slotAExists = raw[SLOT_A] !== undefined;202 const slotBExists = raw[SLOT_B] !== undefined;203 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;204 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;205206 207 if (!slotAExists && !legacyMain) return;208209 210 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;211212 if (legacyMain && !slotAExists) {213 await writeSlot(SLOT_A, legacyMain);214 }215216 217 if (!slotBExists) {218 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;219 if (legacyDecoy) {220 await writeSlot(SLOT_B, legacyDecoy);221 } else if (target) {222 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));223 }224 }225226 const toRemove: string[] = [];227 if (legacyMain) toRemove.push(LEGACY_MAIN);228 if (legacyDecoy) toRemove.push(LEGACY_DECOY);229 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);230 if (toRemove.length > 0) {231 await chrome.storage.local.remove(toRemove);232 }233}234235236237238239240241242243244245function padPlaintext(json: string): string {246 const encoded = new TextEncoder().encode(json);247 const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);248 const padLen = target - encoded.length;249 return json + " ".repeat(padLen);250}251252253254255256257258function generateNoiseBlob(ciphertextBytes: number): StoredVault {259 const salt = crypto.getRandomValues(new Uint8Array(16));260 const nonce = crypto.getRandomValues(new Uint8Array(12));261 const cipher = crypto.getRandomValues(new Uint8Array(ciphertextBytes));262 const tag = crypto.getRandomValues(new Uint8Array(16));263 return {264 version: 1,265 salt: hex(salt),266 nonce: hex(nonce),267 ciphertext: hex(cipher),268 tag: hex(tag),269 };270}271272273274275276type VaultPayload = {277 entries: VaultEntry[];278 duressConfigured?: boolean;279};280281type DecryptedPayload = {282 entries: VaultEntry[];283 duressConfigured: boolean;284};285286function encodePayload(entries: VaultEntry[], duress: boolean): string {287 const envelope: VaultPayload = { entries, duressConfigured: duress };288 return padPlaintext(JSON.stringify(envelope));289}290291function decodePayload(plain: string): DecryptedPayload {292 const parsed = JSON.parse(plain) as293 | Array<VaultEntry | LegacyVaultEntry>294 | VaultPayload;295296 if (Array.isArray(parsed)) {297 298 return {299 entries: parsed.map(normalizeEntry),300 duressConfigured: false,301 };302 }303304 return {305 entries: (parsed.entries ?? []).map(normalizeEntry),306 duressConfigured: parsed.duressConfigured === true,307 };308}309310async function tryDecryptSlot(311 vault: StoredVault,312 key: Uint8Array313): Promise<DecryptedPayload | null> {314 try {315 const plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);316 return decodePayload(plain);317 } catch {318 return null;319 }320}321322323324325326function buildDomainIndexSync(all: VaultEntry[]): void {327 domainIndex = new Map();328329 for (const entry of all) {330 if (entry.deletedAt) continue;331 if (entry.type === "note" || entry.type === "passkey" || entry.type === "card") continue;332333 334 335 336 337 const domains = new Set<string>();338 const primary = extractDomain(entry.url ?? "");339 if (primary) domains.add(primary);340 for (const extra of entry.additionalUrls ?? []) {341 const d = extractDomain(extra);342 if (d) domains.add(d);343 }344 if (domains.size === 0) continue;345346 for (const domain of domains) {347 const bucket = domainIndex.get(domain) ?? [];348 bucket.push(entry);349 domainIndex.set(domain, bucket);350 }351 }352}353354export async function createVault(password: string): Promise<void> {355 await ensureMigrated();356357 const salt = generateSalt();358 derivedKey = await deriveKey(password, salt);359 entries = [];360 vaultMode = "real";361 duressConfigured = false;362 buildDomainIndexSync(entries);363364 const plaintext = encodePayload(entries, duressConfigured);365 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);366367 const slotA: StoredVault = {368 version: 1,369 salt: hex(salt),370 nonce,371 ciphertext,372 tag,373 };374 await writeSlot(SLOT_A, slotA);375376 377 378 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));379380 touchAutoLock();381}382383export async function hasVault(): Promise<boolean> {384 await ensureMigrated();385 return (await loadSlot(SLOT_A)) !== null;386}387388389390391392393export async function isDuressEnabled(): Promise<boolean> {394 return vaultMode === "real" && duressConfigured;395}396397export async function hasDecoyVault(): Promise<boolean> {398 return isDuressEnabled();399}400401export async function setupDuress(duressPassword: string): Promise<void> {402 if (!derivedKey || vaultMode !== "real") {403 throw new Error("vault must be unlocked in real mode");404 }405 await ensureMigrated();406407 408 409 410 const slotA = await loadSlot(SLOT_A);411 if (!slotA) throw new Error("missing slot A");412413 const decoySalt = generateSalt();414 const decoyKey = await deriveKey(duressPassword, decoySalt);415 try {416 417 418 const targetBytes = slotA.ciphertext.length / 2;419 let plaintext = encodePayload([], false);420 const encoded = new TextEncoder().encode(plaintext);421 if (encoded.length < targetBytes) {422 plaintext = plaintext + " ".repeat(targetBytes - encoded.length);423 }424 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);425 await writeSlot(SLOT_B, {426 version: 1,427 salt: hex(decoySalt),428 nonce,429 ciphertext,430 tag,431 });432 } finally {433 zeroKey(decoyKey);434 }435436 437 438 duressConfigured = true;439 await persistSlotA();440}441442export async function disableDuress(): Promise<void> {443 if (!derivedKey || vaultMode !== "real") {444 throw new Error("vault must be unlocked in real mode");445 }446 await ensureMigrated();447448 449 450 451 const slotA = await loadSlot(SLOT_A);452 if (slotA) {453 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));454 }455456 duressConfigured = false;457 await persistSlotA();458}459460export async function verifyMainPassword(password: string): Promise<boolean> {461 await ensureMigrated();462 const slotA = await loadSlot(SLOT_A);463 if (!slotA) return false;464 const key = await deriveKey(password, unhex(slotA.salt));465 try {466 return (await tryDecryptSlot(slotA, key)) !== null;467 } finally {468 zeroKey(key);469 }470}471472export async function unlock(password: string): Promise<boolean> {473 await ensureMigrated();474475 476 477 478 479 480 const [slotA, slotB] = await Promise.all([481 loadSlot(SLOT_A),482 loadSlot(SLOT_B),483 ]);484485 486 const [keyA, keyB] = await Promise.all([487 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),488 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),489 ]);490491 492 const [payloadA, payloadB] = await Promise.all([493 keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),494 keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),495 ]);496497 if (payloadA && keyA) {498 derivedKey = keyA;499 vaultMode = "real";500 entries = payloadA.entries;501 duressConfigured = payloadA.duressConfigured;502 buildDomainIndexSync(entries);503 await loadSettings();504 touchAutoLock();505 if (keyB) zeroKey(keyB);506 507 508 if (hasLegacyShape(payloadA.entries)) {509 await persistSlotA();510 }511 return true;512 }513514 if (payloadB && keyB) {515 derivedKey = keyB;516 vaultMode = "decoy";517 entries = payloadB.entries;518 duressConfigured = false;519 buildDomainIndexSync(entries);520 await loadSettings();521 touchAutoLock();522 if (keyA) zeroKey(keyA);523 return true;524 }525526 if (keyA) zeroKey(keyA);527 if (keyB) zeroKey(keyB);528 return false;529}530531export function lock(): void {532 if (derivedKey) {533 zeroKey(derivedKey);534 derivedKey = null;535 }536537 entries = [];538 domainIndex = new Map();539 vaultMode = "real";540 duressConfigured = false;541 clearAutoLock();542}543544export function isUnlocked(): boolean {545 return derivedKey !== null;546}547548export function getEntries(): VaultEntry[] {549 if (!derivedKey) throw new Error("vault locked");550 return entries.filter((entry) => !entry.deletedAt);551}552553export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {554 if (!derivedKey) throw new Error("vault locked");555556 const now = Date.now();557 const added: VaultEntry[] = [];558 for (const draft of drafts) {559 try {560 const sanitized = sanitizeEntryDraft(draft);561 const nextEntry: VaultEntry = {562 ...sanitized,563 id: crypto.randomUUID(),564 createdAt: now,565 modifiedAt: now,566 };567 entries.push(nextEntry);568 added.push(nextEntry);569 } catch {570 571 }572 }573574 if (added.length > 0) {575 await persistEntries();576 }577578 return added;579}580581582583584585586587588export async function restoreEntries(589 incoming: VaultEntry[],590 mode: "replace" | "merge"591): Promise<{ added: number; updated: number; skipped: number }> {592 if (!derivedKey) throw new Error("vault locked");593594 595 596 597 if (mode === "replace" && incoming.length === 0) {598 throw new Error("refuse to replace with empty entry set");599 }600601 let added = 0;602 let updated = 0;603 let skipped = 0;604605 if (mode === "replace") {606 entries = [];607 for (const entry of incoming) {608 const normalized = sanitizeRestoredEntry(entry);609 if (!normalized) {610 skipped += 1;611 continue;612 }613 entries.push(normalized);614 added += 1;615 }616 } else {617 const byId = new Map<string, VaultEntry>();618 for (const e of entries) byId.set(e.id, e);619620 for (const entry of incoming) {621 const normalized = sanitizeRestoredEntry(entry);622 if (!normalized) {623 skipped += 1;624 continue;625 }626 const existing = byId.get(normalized.id);627 if (!existing) {628 entries.push(normalized);629 added += 1;630 } else if (normalized.modifiedAt > existing.modifiedAt) {631 const idx = entries.findIndex((e) => e.id === existing.id);632 if (idx !== -1) {633 entries[idx] = normalized;634 updated += 1;635 }636 } else {637 skipped += 1;638 }639 }640 }641642 await persistEntries();643 return { added, updated, skipped };644}645646647648649650651function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {652 if (!entry || typeof entry !== "object") return null;653 const title = typeof entry.title === "string" ? entry.title.trim() : "";654 if (!title) return null;655 const type = entry.type ?? "login";656 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();657 const now = Date.now();658 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;659 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;660661 if (type === "login" && typeof entry.password !== "string") {662 return null;663 }664 if (665 type === "passkey" &&666 (typeof entry.rpId !== "string" ||667 typeof entry.credentialId !== "string" ||668 !entry.privateKeyJwk)669 ) {670 return null;671 }672673 return {674 ...entry,675 id,676 title,677 type,678 createdAt,679 modifiedAt,680 };681}682683684685686687export async function clearVaultEntries(688 confirmation: "REPLACE_ALL"689): Promise<void> {690 if (confirmation !== "REPLACE_ALL") {691 throw new Error("clearVaultEntries requires explicit confirmation");692 }693 if (!derivedKey) throw new Error("vault locked");694 for (const entry of entries) {695 if (!entry.deletedAt) {696 entry.deletedAt = Date.now();697 entry.modifiedAt = Date.now();698 }699 }700 await persistEntries();701}702703export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {704 if (!derivedKey) throw new Error("vault locked");705706 const sanitized = sanitizeEntryDraft(entry);707 const now = Date.now();708 const nextEntry: VaultEntry = {709 ...sanitized,710 id: crypto.randomUUID(),711 createdAt: now,712 modifiedAt: now,713 };714715 entries.push(nextEntry);716 await persistEntries();717718 719 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {720 checkBreach(nextEntry.password).then(async (count) => {721 if (count > 0) {722 const idx = entries.findIndex((e) => e.id === nextEntry.id);723 if (idx !== -1) {724 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };725 await persistEntries();726 }727 }728 });729 }730731 return nextEntry;732}733734export async function updateEntry(735 id: string,736 patch: Partial<VaultEntry>737): Promise<void> {738 if (!derivedKey) throw new Error("vault locked");739740 const idx = entries.findIndex((entry) => entry.id === id);741 if (idx === -1 || entries[idx].deletedAt) {742 throw new Error("entry not found");743 }744745 const current = entries[idx];746 entries[idx] = {747 ...current,748 ...sanitizeEntryPatch(patch),749 modifiedAt: Date.now(),750 };751752 753 754 755 if (entries[idx].type === "login" && !entries[idx].password) {756 entries[idx] = current;757 throw new Error("password is required for login entries");758 }759760 761 if (entries[idx].type === "card" && !entries[idx].cardNumber) {762 entries[idx] = current;763 throw new Error("card number is required for card entries");764 }765766 await persistEntries();767768 769 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login" && entries[idx].password) {770 const password = entries[idx].password!;771 checkBreach(password).then(async (count) => {772 const i = entries.findIndex((e) => e.id === id);773 if (i !== -1) {774 entries[i] = { ...entries[i], breach: count > 0 ? count : undefined, modifiedAt: Date.now() };775 await persistEntries();776 }777 });778 }779}780781export async function deleteEntry(id: string): Promise<void> {782 if (!derivedKey) throw new Error("vault locked");783784 const entry = entries.find((item) => item.id === id);785 if (!entry || entry.deletedAt) {786 throw new Error("entry not found");787 }788789 entry.deletedAt = Date.now();790 entry.modifiedAt = Date.now();791 await persistEntries();792}793794export function getDeletedEntries(): VaultEntry[] {795 if (!derivedKey) return [];796 return entries.filter((e) => !!e.deletedAt);797}798799export async function restoreEntry(id: string): Promise<void> {800 if (!derivedKey) throw new Error("vault locked");801 const entry = entries.find((e) => e.id === id && e.deletedAt);802 if (!entry) throw new Error("deleted entry not found");803 delete entry.deletedAt;804 entry.modifiedAt = Date.now();805 await persistEntries();806 buildDomainIndexSync(entries);807}808809export async function purgeEntry(id: string): Promise<void> {810 if (!derivedKey) throw new Error("vault locked");811 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);812 if (idx === -1) throw new Error("deleted entry not found");813 entries.splice(idx, 1);814 await persistEntries();815}816817818export async function purgeExpiredTrash(retentionMs: number): Promise<number> {819 if (!derivedKey || retentionMs === 0) return 0;820 const cutoff = Date.now() - retentionMs;821 const before = entries.length;822 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));823 const purged = before - entries.length;824 if (purged > 0) await persistEntries();825 return purged;826}827828export function queryDomain(domain: string): VaultEntry[] {829 return domainIndex.get(domain) ?? [];830}831832export function getEntryById(id: string): VaultEntry | undefined {833 return entries.find((entry) => entry.id === id && !entry.deletedAt);834}835836async function persistSlotA(): Promise<StoredVault> {837 if (!derivedKey) throw new Error("vault locked");838839 const slotA = await loadSlot(SLOT_A);840 if (!slotA) throw new Error("missing slot A");841842 const plaintext = encodePayload(entries, duressConfigured);843 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);844845 const nextSlotA: StoredVault = {846 ...slotA,847 nonce,848 ciphertext,849 tag,850 };851 await writeSlot(SLOT_A, nextSlotA);852853 854 855 856 if (!duressConfigured) {857 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));858 }859860 return nextSlotA;861}862863async function persistSlotB(): Promise<void> {864 if (!derivedKey) throw new Error("vault locked");865866 const slotB = await loadSlot(SLOT_B);867 if (!slotB) throw new Error("missing slot B");868869 870 871 const plaintext = encodePayload(entries, false);872 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);873874 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });875}876877async function persistEntries(): Promise<void> {878 if (!derivedKey) throw new Error("vault locked");879880 if (vaultMode === "decoy") {881 await persistSlotB();882 } else {883 const freshSlotA = await persistSlotA();884 885 886 const activeCount = entries.filter((e) => !e.deletedAt).length;887 void takeSnapshot(freshSlotA, activeCount);888 }889 buildDomainIndexSync(entries);890}891892function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {893 if (!Array.isArray(values)) return undefined;894 const seen = new Set<string>();895 const cleaned: string[] = [];896 for (const value of values) {897 const trimmed = typeof value === "string" ? value.trim() : "";898 if (!trimmed) continue;899 const key = trimmed.toLowerCase();900 if (seen.has(key)) continue;901 seen.add(key);902 cleaned.push(trimmed);903 }904 return cleaned.length > 0 ? cleaned : undefined;905}906907function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {908 const title = entry.title.trim();909 const type = entry.type ?? "login";910911 if (!title) throw new Error("title is required");912 if (type === "login" && !entry.password?.trim()) {913 throw new Error("password is required");914 }915 if (type === "passkey") {916 if (917 !entry.rpId?.trim() ||918 !entry.rpName?.trim() ||919 !entry.credentialId?.trim() ||920 !entry.publicKeyCose?.trim() ||921 !entry.privateKeyJwk ||922 !entry.userHandle?.trim() ||923 !entry.userName?.trim() ||924 !entry.userDisplayName?.trim() ||925 (entry.algorithm !== -7 && entry.algorithm !== -8)926 ) {927 throw new Error("invalid passkey entry");928 }929 }930 if (type === "card") {931 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {932 throw new Error("cardholder name and card number are required");933 }934 }935936 return {937 type,938 title,939 username: type === "login" ? entry.username?.trim() ?? "" : undefined,940 password: type === "login" ? entry.password : undefined,941 url: type === "login" ? entry.url?.trim() ?? "" : undefined,942 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,943 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,944 notes: type === "passkey" ? undefined : entry.notes?.trim() || undefined,945 breach: type === "login" ? entry.breach : undefined,946 canary: type === "login" ? entry.canary : undefined,947 tags: entry.tags,948 rpId: type === "passkey" ? entry.rpId?.trim() : undefined,949 rpName: type === "passkey" ? entry.rpName?.trim() : undefined,950 credentialId: type === "passkey" ? entry.credentialId?.trim() : undefined,951 publicKeyCose: type === "passkey" ? entry.publicKeyCose?.trim() : undefined,952 privateKeyJwk: type === "passkey" ? entry.privateKeyJwk : undefined,953 userHandle: type === "passkey" ? entry.userHandle?.trim() : undefined,954 userName: type === "passkey" ? entry.userName?.trim() : undefined,955 userDisplayName: type === "passkey" ? entry.userDisplayName?.trim() : undefined,956 algorithm: type === "passkey" ? entry.algorithm : undefined,957 signCount: type === "passkey" ? entry.signCount ?? 0 : undefined,958 aaguid: type === "passkey" ? entry.aaguid?.trim() : undefined,959 backupEligible: type === "passkey" ? entry.backupEligible === true : undefined,960 backupState: type === "passkey" ? entry.backupState === true : undefined,961 lastUsedAt: type === "passkey" ? entry.lastUsedAt : undefined,962 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,963 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,964 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,965 expYear: type === "card" ? entry.expYear?.trim() : undefined,966 cvc: type === "card" ? entry.cvc?.trim() : undefined,967 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,968 network: type === "card" ? entry.network : undefined,969 last4: type === "card" ? entry.last4?.trim() : undefined,970 };971}972973function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {974 const nextPatch: Partial<VaultEntry> = {};975976 if ("type" in patch) {977 nextPatch.type = patch.type;978 }979980 if ("title" in patch) {981 const title = patch.title?.trim() ?? "";982 if (!title) throw new Error("title is required");983 nextPatch.title = title;984 }985986 if ("url" in patch) {987 nextPatch.url = patch.url?.trim() ?? "";988 }989990 if ("additionalUrls" in patch) {991 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];992 const seen = new Set<string>();993 const cleaned: string[] = [];994 for (const value of incoming) {995 const trimmed = typeof value === "string" ? value.trim() : "";996 if (!trimmed) continue;997 const key = trimmed.toLowerCase();998 if (seen.has(key)) continue;999 seen.add(key);1000 cleaned.push(trimmed);1001 }1002 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1003 }10041005 if ("username" in patch) {1006 nextPatch.username = patch.username?.trim() ?? "";1007 }10081009 if ("password" in patch) {1010 const password = patch.password ?? "";1011 1012 nextPatch.password = password || undefined;1013 }10141015 if ("totp" in patch) {1016 nextPatch.totp = patch.totp?.trim() || undefined;1017 }10181019 if ("notes" in patch) {1020 nextPatch.notes = patch.notes?.trim() || undefined;1021 }10221023 if ("breach" in patch) {1024 nextPatch.breach = patch.breach;1025 }10261027 if ("canary" in patch) {1028 nextPatch.canary = patch.canary;1029 }10301031 if ("rpId" in patch) {1032 nextPatch.rpId = patch.rpId?.trim();1033 }10341035 if ("rpName" in patch) {1036 nextPatch.rpName = patch.rpName?.trim();1037 }10381039 if ("credentialId" in patch) {1040 nextPatch.credentialId = patch.credentialId?.trim();1041 }10421043 if ("publicKeyCose" in patch) {1044 nextPatch.publicKeyCose = patch.publicKeyCose?.trim();1045 }10461047 if ("privateKeyJwk" in patch) {1048 nextPatch.privateKeyJwk = patch.privateKeyJwk;1049 }10501051 if ("userHandle" in patch) {1052 nextPatch.userHandle = patch.userHandle?.trim();1053 }10541055 if ("userName" in patch) {1056 nextPatch.userName = patch.userName?.trim();1057 }10581059 if ("userDisplayName" in patch) {1060 nextPatch.userDisplayName = patch.userDisplayName?.trim();1061 }10621063 if ("algorithm" in patch) {1064 nextPatch.algorithm = patch.algorithm;1065 }10661067 if ("signCount" in patch) {1068 nextPatch.signCount = patch.signCount;1069 }10701071 if ("aaguid" in patch) {1072 nextPatch.aaguid = patch.aaguid?.trim();1073 }10741075 if ("backupEligible" in patch) {1076 nextPatch.backupEligible = patch.backupEligible;1077 }10781079 if ("backupState" in patch) {1080 nextPatch.backupState = patch.backupState;1081 }10821083 if ("lastUsedAt" in patch) {1084 nextPatch.lastUsedAt = patch.lastUsedAt;1085 }10861087 if ("tags" in patch) {1088 nextPatch.tags = patch.tags;1089 }10901091 if ("cardholderName" in patch) {1092 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1093 }1094 if ("cardNumber" in patch) {1095 const n = patch.cardNumber ?? "";1096 nextPatch.cardNumber = n || undefined;1097 }1098 if ("expMonth" in patch) {1099 nextPatch.expMonth = patch.expMonth?.trim() || undefined;1100 }1101 if ("expYear" in patch) {1102 nextPatch.expYear = patch.expYear?.trim() || undefined;1103 }1104 if ("cvc" in patch) {1105 nextPatch.cvc = patch.cvc?.trim() || undefined;1106 }1107 if ("cardNickname" in patch) {1108 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1109 }1110 if ("network" in patch) {1111 nextPatch.network = patch.network;1112 }1113 if ("last4" in patch) {1114 nextPatch.last4 = patch.last4?.trim() || undefined;1115 }11161117 return nextPatch;1118}11191120function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1121 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1122 const modifiedAt =1123 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;11241125 return {1126 id: entry.id,1127 type: "type" in entry ? entry.type : "login",1128 title: entry.title,1129 username: entry.username,1130 password: entry.password,1131 url: entry.url,1132 totp: entry.totp,1133 notes: entry.notes,1134 breach: entry.breach,1135 canary: "canary" in entry ? entry.canary : undefined,1136 rpId: "rpId" in entry ? entry.rpId : undefined,1137 rpName: "rpName" in entry ? entry.rpName : undefined,1138 credentialId: "credentialId" in entry ? entry.credentialId : undefined,1139 publicKeyCose: "publicKeyCose" in entry ? entry.publicKeyCose : undefined,1140 privateKeyJwk: "privateKeyJwk" in entry ? entry.privateKeyJwk : undefined,1141 userHandle: "userHandle" in entry ? entry.userHandle : undefined,1142 userName: "userName" in entry ? entry.userName : undefined,1143 userDisplayName: "userDisplayName" in entry ? entry.userDisplayName : undefined,1144 algorithm: "algorithm" in entry ? entry.algorithm : undefined,1145 signCount: "signCount" in entry ? entry.signCount : undefined,1146 aaguid: "aaguid" in entry ? entry.aaguid : undefined,1147 backupEligible: "backupEligible" in entry ? entry.backupEligible : undefined,1148 backupState: "backupState" in entry ? entry.backupState : undefined,1149 lastUsedAt: "lastUsedAt" in entry ? entry.lastUsedAt : undefined,1150 createdAt,1151 modifiedAt,1152 deletedAt: entry.deletedAt,1153 tags: "tags" in entry ? entry.tags : undefined,1154 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1155 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1156 expMonth: "expMonth" in entry ? entry.expMonth : undefined,1157 expYear: "expYear" in entry ? entry.expYear : undefined,1158 cvc: "cvc" in entry ? entry.cvc : undefined,1159 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1160 network: "network" in entry ? entry.network : undefined,1161 last4: "last4" in entry ? entry.last4 : undefined,1162 };1163}11641165function hasLegacyShape(parsed: VaultEntry[]): boolean {1166 return parsed.some(1167 (entry) => "created" in entry || "updated" in entry1168 );1169}117011711172117311741175function entryDupeKey(e: VaultEntry): string {1176 if (e.type === "note") return `note|${e.title.toLowerCase()}`;1177 if (e.type === "passkey") return `passkey|${(e.rpId ?? "").toLowerCase()}|${(e.userName ?? "").toLowerCase()}`;1178 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1179}118011811182export function countDuplicates(): number {1183 const best = new Map<string, VaultEntry>();1184 let dupeCount = 0;1185 for (const e of entries.filter((e) => !e.deletedAt)) {1186 const key = entryDupeKey(e);1187 const existing = best.get(key);1188 if (!existing) {1189 best.set(key, e);1190 } else {1191 dupeCount++;1192 1193 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1194 best.set(key, e);1195 }1196 }1197 }1198 return dupeCount;1199}120012011202120312041205export async function deduplicateEntries(): Promise<number> {1206 if (!derivedKey) throw new Error("vault locked");1207 1208 1209 if (vaultMode === "decoy") {1210 throw new Error("deduplicate disabled in decoy mode");1211 }12121213 const best = new Map<string, VaultEntry>();1214 for (const e of entries.filter((en) => !en.deletedAt)) {1215 const key = entryDupeKey(e);1216 const existing = best.get(key);1217 if (!existing) {1218 best.set(key, e);1219 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1220 best.set(key, e);1221 }1222 }12231224 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1225 const now = Date.now();1226 let removed = 0;1227 entries = entries.map((e) => {1228 if (!e.deletedAt && !keepIds.has(e.id)) {1229 removed++;1230 return { ...e, deletedAt: now };1231 }1232 return e;1233 });12341235 if (removed > 0) await persistEntries();1236 return removed;1237}123812391240124112421243const SNAPSHOT_KEY = "peach_vault_snapshots";1244const MAX_SNAPSHOTS = 6;12451246interface VaultSnapshot {1247 timestamp: number;1248 entryCount: number;1249 slot: StoredVault;1250}125112521253export interface SnapshotSummary {1254 index: number;1255 timestamp: number;1256 entryCount: number;1257}12581259async function takeSnapshot(1260 slot: StoredVault,1261 entryCount: number1262): Promise<void> {1263 try {1264 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1265 const existing: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1266 const next: VaultSnapshot[] = [1267 { timestamp: Date.now(), entryCount, slot },1268 ...existing,1269 ].slice(0, MAX_SNAPSHOTS);1270 await chrome.storage.local.set({ [SNAPSHOT_KEY]: next });1271 } catch {1272 1273 }1274}12751276export async function listSnapshots(): Promise<SnapshotSummary[]> {1277 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1278 const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1279 return snapshots.map((s, i) => ({1280 index: i,1281 timestamp: s.timestamp,1282 entryCount: s.entryCount,1283 }));1284}12851286128712881289129012911292export async function restoreFromSnapshot(1293 index: number,1294 password: string1295): Promise<boolean> {1296 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1297 const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1298 const snapshot = snapshots[index];1299 if (!snapshot) return false;13001301 1302 const key = await deriveKey(password, unhex(snapshot.slot.salt));1303 const payload = await tryDecryptSlot(snapshot.slot, key);1304 if (!payload) {1305 zeroKey(key);1306 return false;1307 }13081309 1310 await writeSlot(SLOT_A, snapshot.slot);1311 await writeSlot(SLOT_B, generateNoiseBlob(snapshot.slot.ciphertext.length / 2));13121313 1314 if (derivedKey) zeroKey(derivedKey);1315 derivedKey = key;1316 entries = payload.entries;1317 duressConfigured = payload.duressConfigured;1318 vaultMode = "real";1319 buildDomainIndexSync(entries);1320 await loadSettings();1321 touchAutoLock();13221323 return true;1324}13251326132713281329133013311332export async function changePassword(1333 currentPassword: string,1334 newPassword: string1335): Promise<boolean> {1336 if (!derivedKey || vaultMode !== "real") return false;13371338 1339 const valid = await verifyMainPassword(currentPassword);1340 if (!valid) return false;13411342 const slotA = await loadSlot(SLOT_A);1343 if (!slotA) return false;13441345 1346 const newSalt = generateSalt();1347 const newKey = await deriveKey(newPassword, newSalt);13481349 1350 const plaintext = encodePayload(entries, duressConfigured);1351 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);13521353 const freshSlotA: StoredVault = {1354 ...slotA,1355 salt: hex(newSalt),1356 nonce,1357 ciphertext,1358 tag,1359 };1360 await writeSlot(SLOT_A, freshSlotA);13611362 1363 1364 1365 zeroKey(derivedKey);1366 derivedKey = newKey;13671368 1369 1370 1371 const activeCount = entries.filter((e) => !e.deletedAt).length;1372 void takeSnapshot(freshSlotA, activeCount);13731374 1375 1376 1377 if (!duressConfigured) {1378 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1379 }13801381 return true;1382}