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 const domain = extractDomain(entry.url ?? "");334 if (!domain) continue;335336 const bucket = domainIndex.get(domain) ?? [];337 bucket.push(entry);338 domainIndex.set(domain, bucket);339 }340}341342export async function createVault(password: string): Promise<void> {343 await ensureMigrated();344345 const salt = generateSalt();346 derivedKey = await deriveKey(password, salt);347 entries = [];348 vaultMode = "real";349 duressConfigured = false;350 buildDomainIndexSync(entries);351352 const plaintext = encodePayload(entries, duressConfigured);353 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);354355 const slotA: StoredVault = {356 version: 1,357 salt: hex(salt),358 nonce,359 ciphertext,360 tag,361 };362 await writeSlot(SLOT_A, slotA);363364 365 366 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));367368 touchAutoLock();369}370371export async function hasVault(): Promise<boolean> {372 await ensureMigrated();373 return (await loadSlot(SLOT_A)) !== null;374}375376377378379380381export async function isDuressEnabled(): Promise<boolean> {382 return vaultMode === "real" && duressConfigured;383}384385export async function hasDecoyVault(): Promise<boolean> {386 return isDuressEnabled();387}388389export async function setupDuress(duressPassword: string): Promise<void> {390 if (!derivedKey || vaultMode !== "real") {391 throw new Error("vault must be unlocked in real mode");392 }393 await ensureMigrated();394395 396 397 398 const slotA = await loadSlot(SLOT_A);399 if (!slotA) throw new Error("missing slot A");400401 const decoySalt = generateSalt();402 const decoyKey = await deriveKey(duressPassword, decoySalt);403 try {404 405 406 const targetBytes = slotA.ciphertext.length / 2;407 let plaintext = encodePayload([], false);408 const encoded = new TextEncoder().encode(plaintext);409 if (encoded.length < targetBytes) {410 plaintext = plaintext + " ".repeat(targetBytes - encoded.length);411 }412 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);413 await writeSlot(SLOT_B, {414 version: 1,415 salt: hex(decoySalt),416 nonce,417 ciphertext,418 tag,419 });420 } finally {421 zeroKey(decoyKey);422 }423424 425 426 duressConfigured = true;427 await persistSlotA();428}429430export async function disableDuress(): Promise<void> {431 if (!derivedKey || vaultMode !== "real") {432 throw new Error("vault must be unlocked in real mode");433 }434 await ensureMigrated();435436 437 438 439 const slotA = await loadSlot(SLOT_A);440 if (slotA) {441 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));442 }443444 duressConfigured = false;445 await persistSlotA();446}447448export async function verifyMainPassword(password: string): Promise<boolean> {449 await ensureMigrated();450 const slotA = await loadSlot(SLOT_A);451 if (!slotA) return false;452 const key = await deriveKey(password, unhex(slotA.salt));453 try {454 return (await tryDecryptSlot(slotA, key)) !== null;455 } finally {456 zeroKey(key);457 }458}459460export async function unlock(password: string): Promise<boolean> {461 await ensureMigrated();462463 464 465 466 467 468 const [slotA, slotB] = await Promise.all([469 loadSlot(SLOT_A),470 loadSlot(SLOT_B),471 ]);472473 474 const [keyA, keyB] = await Promise.all([475 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),476 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),477 ]);478479 480 const [payloadA, payloadB] = await Promise.all([481 keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),482 keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),483 ]);484485 if (payloadA && keyA) {486 derivedKey = keyA;487 vaultMode = "real";488 entries = payloadA.entries;489 duressConfigured = payloadA.duressConfigured;490 buildDomainIndexSync(entries);491 await loadSettings();492 touchAutoLock();493 if (keyB) zeroKey(keyB);494 495 496 if (hasLegacyShape(payloadA.entries)) {497 await persistSlotA();498 }499 return true;500 }501502 if (payloadB && keyB) {503 derivedKey = keyB;504 vaultMode = "decoy";505 entries = payloadB.entries;506 duressConfigured = false;507 buildDomainIndexSync(entries);508 await loadSettings();509 touchAutoLock();510 if (keyA) zeroKey(keyA);511 return true;512 }513514 if (keyA) zeroKey(keyA);515 if (keyB) zeroKey(keyB);516 return false;517}518519export function lock(): void {520 if (derivedKey) {521 zeroKey(derivedKey);522 derivedKey = null;523 }524525 entries = [];526 domainIndex = new Map();527 vaultMode = "real";528 duressConfigured = false;529 clearAutoLock();530}531532export function isUnlocked(): boolean {533 return derivedKey !== null;534}535536export function getEntries(): VaultEntry[] {537 if (!derivedKey) throw new Error("vault locked");538 return entries.filter((entry) => !entry.deletedAt);539}540541export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {542 if (!derivedKey) throw new Error("vault locked");543544 const now = Date.now();545 const added: VaultEntry[] = [];546 for (const draft of drafts) {547 try {548 const sanitized = sanitizeEntryDraft(draft);549 const nextEntry: VaultEntry = {550 ...sanitized,551 id: crypto.randomUUID(),552 createdAt: now,553 modifiedAt: now,554 };555 entries.push(nextEntry);556 added.push(nextEntry);557 } catch {558 559 }560 }561562 if (added.length > 0) {563 await persistEntries();564 }565566 return added;567}568569570571572573574575576export async function restoreEntries(577 incoming: VaultEntry[],578 mode: "replace" | "merge"579): Promise<{ added: number; updated: number; skipped: number }> {580 if (!derivedKey) throw new Error("vault locked");581582 583 584 585 if (mode === "replace" && incoming.length === 0) {586 throw new Error("refuse to replace with empty entry set");587 }588589 let added = 0;590 let updated = 0;591 let skipped = 0;592593 if (mode === "replace") {594 entries = [];595 for (const entry of incoming) {596 const normalized = sanitizeRestoredEntry(entry);597 if (!normalized) {598 skipped += 1;599 continue;600 }601 entries.push(normalized);602 added += 1;603 }604 } else {605 const byId = new Map<string, VaultEntry>();606 for (const e of entries) byId.set(e.id, e);607608 for (const entry of incoming) {609 const normalized = sanitizeRestoredEntry(entry);610 if (!normalized) {611 skipped += 1;612 continue;613 }614 const existing = byId.get(normalized.id);615 if (!existing) {616 entries.push(normalized);617 added += 1;618 } else if (normalized.modifiedAt > existing.modifiedAt) {619 const idx = entries.findIndex((e) => e.id === existing.id);620 if (idx !== -1) {621 entries[idx] = normalized;622 updated += 1;623 }624 } else {625 skipped += 1;626 }627 }628 }629630 await persistEntries();631 return { added, updated, skipped };632}633634635636637638639function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {640 if (!entry || typeof entry !== "object") return null;641 const title = typeof entry.title === "string" ? entry.title.trim() : "";642 if (!title) return null;643 const type = entry.type ?? "login";644 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();645 const now = Date.now();646 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;647 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;648649 if (type === "login" && typeof entry.password !== "string") {650 return null;651 }652 if (653 type === "passkey" &&654 (typeof entry.rpId !== "string" ||655 typeof entry.credentialId !== "string" ||656 !entry.privateKeyJwk)657 ) {658 return null;659 }660661 return {662 ...entry,663 id,664 title,665 type,666 createdAt,667 modifiedAt,668 };669}670671672673674675export async function clearVaultEntries(676 confirmation: "REPLACE_ALL"677): Promise<void> {678 if (confirmation !== "REPLACE_ALL") {679 throw new Error("clearVaultEntries requires explicit confirmation");680 }681 if (!derivedKey) throw new Error("vault locked");682 for (const entry of entries) {683 if (!entry.deletedAt) {684 entry.deletedAt = Date.now();685 entry.modifiedAt = Date.now();686 }687 }688 await persistEntries();689}690691export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {692 if (!derivedKey) throw new Error("vault locked");693694 const sanitized = sanitizeEntryDraft(entry);695 const now = Date.now();696 const nextEntry: VaultEntry = {697 ...sanitized,698 id: crypto.randomUUID(),699 createdAt: now,700 modifiedAt: now,701 };702703 entries.push(nextEntry);704 await persistEntries();705706 707 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {708 checkBreach(nextEntry.password).then(async (count) => {709 if (count > 0) {710 const idx = entries.findIndex((e) => e.id === nextEntry.id);711 if (idx !== -1) {712 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };713 await persistEntries();714 }715 }716 });717 }718719 return nextEntry;720}721722export async function updateEntry(723 id: string,724 patch: Partial<VaultEntry>725): Promise<void> {726 if (!derivedKey) throw new Error("vault locked");727728 const idx = entries.findIndex((entry) => entry.id === id);729 if (idx === -1 || entries[idx].deletedAt) {730 throw new Error("entry not found");731 }732733 const current = entries[idx];734 entries[idx] = {735 ...current,736 ...sanitizeEntryPatch(patch),737 modifiedAt: Date.now(),738 };739740 741 742 743 if (entries[idx].type === "login" && !entries[idx].password) {744 entries[idx] = current;745 throw new Error("password is required for login entries");746 }747748 749 if (entries[idx].type === "card" && !entries[idx].cardNumber) {750 entries[idx] = current;751 throw new Error("card number is required for card entries");752 }753754 await persistEntries();755756 757 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login" && entries[idx].password) {758 const password = entries[idx].password!;759 checkBreach(password).then(async (count) => {760 const i = entries.findIndex((e) => e.id === id);761 if (i !== -1) {762 entries[i] = { ...entries[i], breach: count > 0 ? count : undefined, modifiedAt: Date.now() };763 await persistEntries();764 }765 });766 }767}768769export async function deleteEntry(id: string): Promise<void> {770 if (!derivedKey) throw new Error("vault locked");771772 const entry = entries.find((item) => item.id === id);773 if (!entry || entry.deletedAt) {774 throw new Error("entry not found");775 }776777 entry.deletedAt = Date.now();778 entry.modifiedAt = Date.now();779 await persistEntries();780}781782export function getDeletedEntries(): VaultEntry[] {783 if (!derivedKey) return [];784 return entries.filter((e) => !!e.deletedAt);785}786787export async function restoreEntry(id: string): Promise<void> {788 if (!derivedKey) throw new Error("vault locked");789 const entry = entries.find((e) => e.id === id && e.deletedAt);790 if (!entry) throw new Error("deleted entry not found");791 delete entry.deletedAt;792 entry.modifiedAt = Date.now();793 await persistEntries();794 buildDomainIndexSync(entries);795}796797export async function purgeEntry(id: string): Promise<void> {798 if (!derivedKey) throw new Error("vault locked");799 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);800 if (idx === -1) throw new Error("deleted entry not found");801 entries.splice(idx, 1);802 await persistEntries();803}804805806export async function purgeExpiredTrash(retentionMs: number): Promise<number> {807 if (!derivedKey || retentionMs === 0) return 0;808 const cutoff = Date.now() - retentionMs;809 const before = entries.length;810 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));811 const purged = before - entries.length;812 if (purged > 0) await persistEntries();813 return purged;814}815816export function queryDomain(domain: string): VaultEntry[] {817 return domainIndex.get(domain) ?? [];818}819820export function getEntryById(id: string): VaultEntry | undefined {821 return entries.find((entry) => entry.id === id && !entry.deletedAt);822}823824async function persistSlotA(): Promise<StoredVault> {825 if (!derivedKey) throw new Error("vault locked");826827 const slotA = await loadSlot(SLOT_A);828 if (!slotA) throw new Error("missing slot A");829830 const plaintext = encodePayload(entries, duressConfigured);831 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);832833 const nextSlotA: StoredVault = {834 ...slotA,835 nonce,836 ciphertext,837 tag,838 };839 await writeSlot(SLOT_A, nextSlotA);840841 842 843 844 if (!duressConfigured) {845 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));846 }847848 return nextSlotA;849}850851async function persistSlotB(): Promise<void> {852 if (!derivedKey) throw new Error("vault locked");853854 const slotB = await loadSlot(SLOT_B);855 if (!slotB) throw new Error("missing slot B");856857 858 859 const plaintext = encodePayload(entries, false);860 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);861862 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });863}864865async function persistEntries(): Promise<void> {866 if (!derivedKey) throw new Error("vault locked");867868 if (vaultMode === "decoy") {869 await persistSlotB();870 } else {871 const freshSlotA = await persistSlotA();872 873 874 const activeCount = entries.filter((e) => !e.deletedAt).length;875 void takeSnapshot(freshSlotA, activeCount);876 }877 buildDomainIndexSync(entries);878}879880function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {881 const title = entry.title.trim();882 const type = entry.type ?? "login";883884 if (!title) throw new Error("title is required");885 if (type === "login" && !entry.password?.trim()) {886 throw new Error("password is required");887 }888 if (type === "passkey") {889 if (890 !entry.rpId?.trim() ||891 !entry.rpName?.trim() ||892 !entry.credentialId?.trim() ||893 !entry.publicKeyCose?.trim() ||894 !entry.privateKeyJwk ||895 !entry.userHandle?.trim() ||896 !entry.userName?.trim() ||897 !entry.userDisplayName?.trim() ||898 (entry.algorithm !== -7 && entry.algorithm !== -8)899 ) {900 throw new Error("invalid passkey entry");901 }902 }903 if (type === "card") {904 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {905 throw new Error("cardholder name and card number are required");906 }907 }908909 return {910 type,911 title,912 username: type === "login" ? entry.username?.trim() ?? "" : undefined,913 password: type === "login" ? entry.password : undefined,914 url: type === "login" ? entry.url?.trim() ?? "" : undefined,915 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,916 notes: type === "passkey" ? undefined : entry.notes?.trim() || undefined,917 breach: type === "login" ? entry.breach : undefined,918 canary: type === "login" ? entry.canary : undefined,919 tags: entry.tags,920 rpId: type === "passkey" ? entry.rpId?.trim() : undefined,921 rpName: type === "passkey" ? entry.rpName?.trim() : undefined,922 credentialId: type === "passkey" ? entry.credentialId?.trim() : undefined,923 publicKeyCose: type === "passkey" ? entry.publicKeyCose?.trim() : undefined,924 privateKeyJwk: type === "passkey" ? entry.privateKeyJwk : undefined,925 userHandle: type === "passkey" ? entry.userHandle?.trim() : undefined,926 userName: type === "passkey" ? entry.userName?.trim() : undefined,927 userDisplayName: type === "passkey" ? entry.userDisplayName?.trim() : undefined,928 algorithm: type === "passkey" ? entry.algorithm : undefined,929 signCount: type === "passkey" ? entry.signCount ?? 0 : undefined,930 aaguid: type === "passkey" ? entry.aaguid?.trim() : undefined,931 backupEligible: type === "passkey" ? entry.backupEligible === true : undefined,932 backupState: type === "passkey" ? entry.backupState === true : undefined,933 lastUsedAt: type === "passkey" ? entry.lastUsedAt : undefined,934 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,935 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,936 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,937 expYear: type === "card" ? entry.expYear?.trim() : undefined,938 cvc: type === "card" ? entry.cvc?.trim() : undefined,939 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,940 network: type === "card" ? entry.network : undefined,941 last4: type === "card" ? entry.last4?.trim() : undefined,942 };943}944945function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {946 const nextPatch: Partial<VaultEntry> = {};947948 if ("type" in patch) {949 nextPatch.type = patch.type;950 }951952 if ("title" in patch) {953 const title = patch.title?.trim() ?? "";954 if (!title) throw new Error("title is required");955 nextPatch.title = title;956 }957958 if ("url" in patch) {959 nextPatch.url = patch.url?.trim() ?? "";960 }961962 if ("username" in patch) {963 nextPatch.username = patch.username?.trim() ?? "";964 }965966 if ("password" in patch) {967 const password = patch.password ?? "";968 969 nextPatch.password = password || undefined;970 }971972 if ("totp" in patch) {973 nextPatch.totp = patch.totp?.trim() || undefined;974 }975976 if ("notes" in patch) {977 nextPatch.notes = patch.notes?.trim() || undefined;978 }979980 if ("breach" in patch) {981 nextPatch.breach = patch.breach;982 }983984 if ("canary" in patch) {985 nextPatch.canary = patch.canary;986 }987988 if ("rpId" in patch) {989 nextPatch.rpId = patch.rpId?.trim();990 }991992 if ("rpName" in patch) {993 nextPatch.rpName = patch.rpName?.trim();994 }995996 if ("credentialId" in patch) {997 nextPatch.credentialId = patch.credentialId?.trim();998 }9991000 if ("publicKeyCose" in patch) {1001 nextPatch.publicKeyCose = patch.publicKeyCose?.trim();1002 }10031004 if ("privateKeyJwk" in patch) {1005 nextPatch.privateKeyJwk = patch.privateKeyJwk;1006 }10071008 if ("userHandle" in patch) {1009 nextPatch.userHandle = patch.userHandle?.trim();1010 }10111012 if ("userName" in patch) {1013 nextPatch.userName = patch.userName?.trim();1014 }10151016 if ("userDisplayName" in patch) {1017 nextPatch.userDisplayName = patch.userDisplayName?.trim();1018 }10191020 if ("algorithm" in patch) {1021 nextPatch.algorithm = patch.algorithm;1022 }10231024 if ("signCount" in patch) {1025 nextPatch.signCount = patch.signCount;1026 }10271028 if ("aaguid" in patch) {1029 nextPatch.aaguid = patch.aaguid?.trim();1030 }10311032 if ("backupEligible" in patch) {1033 nextPatch.backupEligible = patch.backupEligible;1034 }10351036 if ("backupState" in patch) {1037 nextPatch.backupState = patch.backupState;1038 }10391040 if ("lastUsedAt" in patch) {1041 nextPatch.lastUsedAt = patch.lastUsedAt;1042 }10431044 if ("tags" in patch) {1045 nextPatch.tags = patch.tags;1046 }10471048 if ("cardholderName" in patch) {1049 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1050 }1051 if ("cardNumber" in patch) {1052 const n = patch.cardNumber ?? "";1053 nextPatch.cardNumber = n || undefined;1054 }1055 if ("expMonth" in patch) {1056 nextPatch.expMonth = patch.expMonth?.trim() || undefined;1057 }1058 if ("expYear" in patch) {1059 nextPatch.expYear = patch.expYear?.trim() || undefined;1060 }1061 if ("cvc" in patch) {1062 nextPatch.cvc = patch.cvc?.trim() || undefined;1063 }1064 if ("cardNickname" in patch) {1065 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1066 }1067 if ("network" in patch) {1068 nextPatch.network = patch.network;1069 }1070 if ("last4" in patch) {1071 nextPatch.last4 = patch.last4?.trim() || undefined;1072 }10731074 return nextPatch;1075}10761077function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1078 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1079 const modifiedAt =1080 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;10811082 return {1083 id: entry.id,1084 type: "type" in entry ? entry.type : "login",1085 title: entry.title,1086 username: entry.username,1087 password: entry.password,1088 url: entry.url,1089 totp: entry.totp,1090 notes: entry.notes,1091 breach: entry.breach,1092 canary: "canary" in entry ? entry.canary : undefined,1093 rpId: "rpId" in entry ? entry.rpId : undefined,1094 rpName: "rpName" in entry ? entry.rpName : undefined,1095 credentialId: "credentialId" in entry ? entry.credentialId : undefined,1096 publicKeyCose: "publicKeyCose" in entry ? entry.publicKeyCose : undefined,1097 privateKeyJwk: "privateKeyJwk" in entry ? entry.privateKeyJwk : undefined,1098 userHandle: "userHandle" in entry ? entry.userHandle : undefined,1099 userName: "userName" in entry ? entry.userName : undefined,1100 userDisplayName: "userDisplayName" in entry ? entry.userDisplayName : undefined,1101 algorithm: "algorithm" in entry ? entry.algorithm : undefined,1102 signCount: "signCount" in entry ? entry.signCount : undefined,1103 aaguid: "aaguid" in entry ? entry.aaguid : undefined,1104 backupEligible: "backupEligible" in entry ? entry.backupEligible : undefined,1105 backupState: "backupState" in entry ? entry.backupState : undefined,1106 lastUsedAt: "lastUsedAt" in entry ? entry.lastUsedAt : undefined,1107 createdAt,1108 modifiedAt,1109 deletedAt: entry.deletedAt,1110 tags: "tags" in entry ? entry.tags : undefined,1111 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1112 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1113 expMonth: "expMonth" in entry ? entry.expMonth : undefined,1114 expYear: "expYear" in entry ? entry.expYear : undefined,1115 cvc: "cvc" in entry ? entry.cvc : undefined,1116 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1117 network: "network" in entry ? entry.network : undefined,1118 last4: "last4" in entry ? entry.last4 : undefined,1119 };1120}11211122function hasLegacyShape(parsed: VaultEntry[]): boolean {1123 return parsed.some(1124 (entry) => "created" in entry || "updated" in entry1125 );1126}112711281129113011311132function entryDupeKey(e: VaultEntry): string {1133 if (e.type === "note") return `note|${e.title.toLowerCase()}`;1134 if (e.type === "passkey") return `passkey|${(e.rpId ?? "").toLowerCase()}|${(e.userName ?? "").toLowerCase()}`;1135 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1136}113711381139export function countDuplicates(): number {1140 const best = new Map<string, VaultEntry>();1141 let dupeCount = 0;1142 for (const e of entries.filter((e) => !e.deletedAt)) {1143 const key = entryDupeKey(e);1144 const existing = best.get(key);1145 if (!existing) {1146 best.set(key, e);1147 } else {1148 dupeCount++;1149 1150 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1151 best.set(key, e);1152 }1153 }1154 }1155 return dupeCount;1156}115711581159116011611162export async function deduplicateEntries(): Promise<number> {1163 if (!derivedKey) throw new Error("vault locked");1164 1165 1166 if (vaultMode === "decoy") {1167 throw new Error("deduplicate disabled in decoy mode");1168 }11691170 const best = new Map<string, VaultEntry>();1171 for (const e of entries.filter((en) => !en.deletedAt)) {1172 const key = entryDupeKey(e);1173 const existing = best.get(key);1174 if (!existing) {1175 best.set(key, e);1176 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1177 best.set(key, e);1178 }1179 }11801181 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1182 const now = Date.now();1183 let removed = 0;1184 entries = entries.map((e) => {1185 if (!e.deletedAt && !keepIds.has(e.id)) {1186 removed++;1187 return { ...e, deletedAt: now };1188 }1189 return e;1190 });11911192 if (removed > 0) await persistEntries();1193 return removed;1194}119511961197119811991200const SNAPSHOT_KEY = "peach_vault_snapshots";1201const MAX_SNAPSHOTS = 6;12021203interface VaultSnapshot {1204 timestamp: number;1205 entryCount: number;1206 slot: StoredVault;1207}120812091210export interface SnapshotSummary {1211 index: number;1212 timestamp: number;1213 entryCount: number;1214}12151216async function takeSnapshot(1217 slot: StoredVault,1218 entryCount: number1219): Promise<void> {1220 try {1221 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1222 const existing: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1223 const next: VaultSnapshot[] = [1224 { timestamp: Date.now(), entryCount, slot },1225 ...existing,1226 ].slice(0, MAX_SNAPSHOTS);1227 await chrome.storage.local.set({ [SNAPSHOT_KEY]: next });1228 } catch {1229 1230 }1231}12321233export async function listSnapshots(): Promise<SnapshotSummary[]> {1234 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1235 const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1236 return snapshots.map((s, i) => ({1237 index: i,1238 timestamp: s.timestamp,1239 entryCount: s.entryCount,1240 }));1241}12421243124412451246124712481249export async function restoreFromSnapshot(1250 index: number,1251 password: string1252): Promise<boolean> {1253 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1254 const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1255 const snapshot = snapshots[index];1256 if (!snapshot) return false;12571258 1259 const key = await deriveKey(password, unhex(snapshot.slot.salt));1260 const payload = await tryDecryptSlot(snapshot.slot, key);1261 if (!payload) {1262 zeroKey(key);1263 return false;1264 }12651266 1267 await writeSlot(SLOT_A, snapshot.slot);1268 await writeSlot(SLOT_B, generateNoiseBlob(snapshot.slot.ciphertext.length / 2));12691270 1271 if (derivedKey) zeroKey(derivedKey);1272 derivedKey = key;1273 entries = payload.entries;1274 duressConfigured = payload.duressConfigured;1275 vaultMode = "real";1276 buildDomainIndexSync(entries);1277 await loadSettings();1278 touchAutoLock();12791280 return true;1281}12821283128412851286128712881289export async function changePassword(1290 currentPassword: string,1291 newPassword: string1292): Promise<boolean> {1293 if (!derivedKey || vaultMode !== "real") return false;12941295 1296 const valid = await verifyMainPassword(currentPassword);1297 if (!valid) return false;12981299 const slotA = await loadSlot(SLOT_A);1300 if (!slotA) return false;13011302 1303 const newSalt = generateSalt();1304 const newKey = await deriveKey(newPassword, newSalt);13051306 1307 const plaintext = encodePayload(entries, duressConfigured);1308 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);13091310 const freshSlotA: StoredVault = {1311 ...slotA,1312 salt: hex(newSalt),1313 nonce,1314 ciphertext,1315 tag,1316 };1317 await writeSlot(SLOT_A, freshSlotA);13181319 1320 1321 1322 zeroKey(derivedKey);1323 derivedKey = newKey;13241325 1326 1327 1328 const activeCount = entries.filter((e) => !e.deletedAt).length;1329 void takeSnapshot(freshSlotA, activeCount);13301331 1332 1333 1334 if (!duressConfigured) {1335 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1336 }13371338 return true;1339}