1import { deriveKey, generateSalt } from "../crypto/argon2";2import { extractDomain } from "../shared/domain";3import type {4 AccentColorName,5 LegacyVaultEntry,6 StoredVault,7 VaultEntry,8} from "../shared/types";9import { decrypt, encrypt, zeroKey } from "./aes";10import { clearAutoLock, touchAutoLock } from "./alarms";1112type EntryDraft = Omit<13 VaultEntry,14 "id" | "createdAt" | "modifiedAt" | "deletedAt"15>;161718192021const SLOT_A = "peach_vault_a";22const SLOT_B = "peach_vault_b";232425const LEGACY_MAIN = "peach_vault";26const LEGACY_DECOY = "peach_decoy_vault";27const LEGACY_DURESS_FLAG = "peach_duress";28293031const PAD_BLOCK = 1024;3233let derivedKey: Uint8Array | null = null;34let entries: VaultEntry[] = [];35let domainIndex: Map<string, VaultEntry[]> = new Map();36let vaultMode: "real" | "decoy" = "real";37383940let duressConfigured = false;4142let migrationRan = false;434445464748export interface VaultSettings {49 accentColor?: AccentColorName;50 theme?: "dark" | "light";51 autoLockMinutes?: number;52 clipboardClearSeconds?: number;53 lockOnClose?: boolean;54 breachMonitoring?: boolean;55 passkeyProviderEnabled?: boolean;56 trashRetentionDays?: number;57}5859let settings: VaultSettings = {};6061export function getSettings(): VaultSettings {62 return settings;63}6465export async function ensureSettingsLoaded(): Promise<VaultSettings> {66 await loadSettings();67 return settings;68}6970export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {71 await loadSettings();72 settings = { ...settings, ...patch };73 await persistSettings();74}7576async function persistSettings(): Promise<void> {77 try {78 await chrome.storage.local.set({ peach_settings: settings });79 } catch (err) {80 throw mapStorageError(err);81 }82}8384function mapStorageError(err: unknown): Error {85 const message = err instanceof Error ? err.message : String(err);86 if (/QUOTA_BYTES|quota/i.test(message)) {87 return new Error("STORAGE_QUOTA_EXCEEDED");88 }89 return err instanceof Error ? err : new Error(message);90}9192async function loadSettings(): Promise<void> {93 const result = await chrome.storage.local.get("peach_settings");94 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};95}96979899100101export async function checkBreach(password: string): Promise<number> {102 const encoder = new TextEncoder();103 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));104 const hashHex = Array.from(new Uint8Array(hashBuffer))105 .map((b) => b.toString(16).padStart(2, "0"))106 .join("")107 .toUpperCase();108109 const prefix = hashHex.slice(0, 5);110 const suffix = hashHex.slice(5);111112 try {113 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);114 if (!res.ok) return 0;115 const text = await res.text();116 const match = text.split("\r\n").find((l) => l.startsWith(suffix));117 return match ? parseInt(match.split(":")[1], 10) : 0;118 } catch {119 return 0; 120 }121}122123function hex(buf: Uint8Array): string {124 return Array.from(buf)125 .map((b) => b.toString(16).padStart(2, "0"))126 .join("");127}128129function unhex(s: string): Uint8Array {130 const out = new Uint8Array(s.length / 2);131 for (let i = 0; i < s.length; i += 2) {132 out[i / 2] = parseInt(s.slice(i, i + 2), 16);133 }134 return out;135}136137138139140141async function loadSlot(key: string): Promise<StoredVault | null> {142 const result = await chrome.storage.local.get(key);143 return (result as Record<string, StoredVault | undefined>)[key] ?? null;144}145146async function writeSlot(key: string, vault: StoredVault): Promise<void> {147 try {148 await chrome.storage.local.set({ [key]: vault });149 } catch (err) {150 throw mapStorageError(err);151 }152}153154155156157158159async function ensureMigrated(): Promise<void> {160 if (migrationRan) return;161 migrationRan = true;162163 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];164 const raw = await chrome.storage.local.get(keys);165 const slotAExists = raw[SLOT_A] !== undefined;166 const slotBExists = raw[SLOT_B] !== undefined;167 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;168 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;169170 171 if (!slotAExists && !legacyMain) return;172173 174 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;175176 if (legacyMain && !slotAExists) {177 await writeSlot(SLOT_A, legacyMain);178 }179180 181 if (!slotBExists) {182 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;183 if (legacyDecoy) {184 await writeSlot(SLOT_B, legacyDecoy);185 } else if (target) {186 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));187 }188 }189190 const toRemove: string[] = [];191 if (legacyMain) toRemove.push(LEGACY_MAIN);192 if (legacyDecoy) toRemove.push(LEGACY_DECOY);193 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);194 if (toRemove.length > 0) {195 await chrome.storage.local.remove(toRemove);196 }197}198199200201202203204205206207208209function padPlaintext(json: string): string {210 const encoded = new TextEncoder().encode(json);211 const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);212 const padLen = target - encoded.length;213 return json + " ".repeat(padLen);214}215216217218219220221222function generateNoiseBlob(ciphertextBytes: number): StoredVault {223 const salt = crypto.getRandomValues(new Uint8Array(16));224 const nonce = crypto.getRandomValues(new Uint8Array(12));225 const cipher = crypto.getRandomValues(new Uint8Array(ciphertextBytes));226 const tag = crypto.getRandomValues(new Uint8Array(16));227 return {228 version: 1,229 salt: hex(salt),230 nonce: hex(nonce),231 ciphertext: hex(cipher),232 tag: hex(tag),233 };234}235236237238239240type VaultPayload = {241 entries: VaultEntry[];242 duressConfigured?: boolean;243};244245type DecryptedPayload = {246 entries: VaultEntry[];247 duressConfigured: boolean;248};249250function encodePayload(entries: VaultEntry[], duress: boolean): string {251 const envelope: VaultPayload = { entries, duressConfigured: duress };252 return padPlaintext(JSON.stringify(envelope));253}254255function decodePayload(plain: string): DecryptedPayload {256 const parsed = JSON.parse(plain) as257 | Array<VaultEntry | LegacyVaultEntry>258 | VaultPayload;259260 if (Array.isArray(parsed)) {261 262 return {263 entries: parsed.map(normalizeEntry),264 duressConfigured: false,265 };266 }267268 return {269 entries: (parsed.entries ?? []).map(normalizeEntry),270 duressConfigured: parsed.duressConfigured === true,271 };272}273274async function tryDecryptSlot(275 vault: StoredVault,276 key: Uint8Array277): Promise<DecryptedPayload | null> {278 try {279 const plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);280 return decodePayload(plain);281 } catch {282 return null;283 }284}285286287288289290function buildDomainIndexSync(all: VaultEntry[]): void {291 domainIndex = new Map();292293 for (const entry of all) {294 if (entry.deletedAt) continue;295 if (entry.type === "note" || entry.type === "passkey") continue;296297 const domain = extractDomain(entry.url ?? "");298 if (!domain) continue;299300 const bucket = domainIndex.get(domain) ?? [];301 bucket.push(entry);302 domainIndex.set(domain, bucket);303 }304}305306export async function createVault(password: string): Promise<void> {307 await ensureMigrated();308309 const salt = generateSalt();310 derivedKey = await deriveKey(password, salt);311 entries = [];312 vaultMode = "real";313 duressConfigured = false;314 buildDomainIndexSync(entries);315316 const plaintext = encodePayload(entries, duressConfigured);317 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);318319 const slotA: StoredVault = {320 version: 1,321 salt: hex(salt),322 nonce,323 ciphertext,324 tag,325 };326 await writeSlot(SLOT_A, slotA);327328 329 330 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));331332 touchAutoLock();333}334335export async function hasVault(): Promise<boolean> {336 await ensureMigrated();337 return (await loadSlot(SLOT_A)) !== null;338}339340341342343344345export async function isDuressEnabled(): Promise<boolean> {346 return vaultMode === "real" && duressConfigured;347}348349export async function hasDecoyVault(): Promise<boolean> {350 return isDuressEnabled();351}352353export async function setupDuress(duressPassword: string): Promise<void> {354 if (!derivedKey || vaultMode !== "real") {355 throw new Error("vault must be unlocked in real mode");356 }357 await ensureMigrated();358359 360 361 362 const slotA = await loadSlot(SLOT_A);363 if (!slotA) throw new Error("missing slot A");364365 const decoySalt = generateSalt();366 const decoyKey = await deriveKey(duressPassword, decoySalt);367 try {368 369 370 const targetBytes = slotA.ciphertext.length / 2;371 let plaintext = encodePayload([], false);372 const encoded = new TextEncoder().encode(plaintext);373 if (encoded.length < targetBytes) {374 plaintext = plaintext + " ".repeat(targetBytes - encoded.length);375 }376 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);377 await writeSlot(SLOT_B, {378 version: 1,379 salt: hex(decoySalt),380 nonce,381 ciphertext,382 tag,383 });384 } finally {385 zeroKey(decoyKey);386 }387388 389 390 duressConfigured = true;391 await persistSlotA();392}393394export async function disableDuress(): Promise<void> {395 if (!derivedKey || vaultMode !== "real") {396 throw new Error("vault must be unlocked in real mode");397 }398 await ensureMigrated();399400 401 402 403 const slotA = await loadSlot(SLOT_A);404 if (slotA) {405 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));406 }407408 duressConfigured = false;409 await persistSlotA();410}411412export async function verifyMainPassword(password: string): Promise<boolean> {413 await ensureMigrated();414 const slotA = await loadSlot(SLOT_A);415 if (!slotA) return false;416 const key = await deriveKey(password, unhex(slotA.salt));417 try {418 return (await tryDecryptSlot(slotA, key)) !== null;419 } finally {420 zeroKey(key);421 }422}423424export async function unlock(password: string): Promise<boolean> {425 await ensureMigrated();426427 428 429 430 431 432 const [slotA, slotB] = await Promise.all([433 loadSlot(SLOT_A),434 loadSlot(SLOT_B),435 ]);436437 438 const [keyA, keyB] = await Promise.all([439 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),440 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),441 ]);442443 444 const [payloadA, payloadB] = await Promise.all([445 keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),446 keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),447 ]);448449 if (payloadA && keyA) {450 derivedKey = keyA;451 vaultMode = "real";452 entries = payloadA.entries;453 duressConfigured = payloadA.duressConfigured;454 buildDomainIndexSync(entries);455 await loadSettings();456 touchAutoLock();457 if (keyB) zeroKey(keyB);458 459 460 if (hasLegacyShape(payloadA.entries)) {461 await persistSlotA();462 }463 return true;464 }465466 if (payloadB && keyB) {467 derivedKey = keyB;468 vaultMode = "decoy";469 entries = payloadB.entries;470 duressConfigured = false;471 buildDomainIndexSync(entries);472 await loadSettings();473 touchAutoLock();474 if (keyA) zeroKey(keyA);475 return true;476 }477478 if (keyA) zeroKey(keyA);479 if (keyB) zeroKey(keyB);480 return false;481}482483export function lock(): void {484 if (derivedKey) {485 zeroKey(derivedKey);486 derivedKey = null;487 }488489 entries = [];490 domainIndex = new Map();491 vaultMode = "real";492 duressConfigured = false;493 clearAutoLock();494}495496export function isUnlocked(): boolean {497 return derivedKey !== null;498}499500export function getEntries(): VaultEntry[] {501 if (!derivedKey) throw new Error("vault locked");502 return entries.filter((entry) => !entry.deletedAt);503}504505export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {506 if (!derivedKey) throw new Error("vault locked");507508 const now = Date.now();509 const added: VaultEntry[] = [];510 for (const draft of drafts) {511 try {512 const sanitized = sanitizeEntryDraft(draft);513 const nextEntry: VaultEntry = {514 ...sanitized,515 id: crypto.randomUUID(),516 createdAt: now,517 modifiedAt: now,518 };519 entries.push(nextEntry);520 added.push(nextEntry);521 } catch {522 523 }524 }525526 if (added.length > 0) {527 await persistEntries();528 }529530 return added;531}532533534535536537538539540export async function restoreEntries(541 incoming: VaultEntry[],542 mode: "replace" | "merge"543): Promise<{ added: number; updated: number; skipped: number }> {544 if (!derivedKey) throw new Error("vault locked");545546 547 548 549 if (mode === "replace" && incoming.length === 0) {550 throw new Error("refuse to replace with empty entry set");551 }552553 let added = 0;554 let updated = 0;555 let skipped = 0;556557 if (mode === "replace") {558 entries = [];559 for (const entry of incoming) {560 const normalized = sanitizeRestoredEntry(entry);561 if (!normalized) {562 skipped += 1;563 continue;564 }565 entries.push(normalized);566 added += 1;567 }568 } else {569 const byId = new Map<string, VaultEntry>();570 for (const e of entries) byId.set(e.id, e);571572 for (const entry of incoming) {573 const normalized = sanitizeRestoredEntry(entry);574 if (!normalized) {575 skipped += 1;576 continue;577 }578 const existing = byId.get(normalized.id);579 if (!existing) {580 entries.push(normalized);581 added += 1;582 } else if (normalized.modifiedAt > existing.modifiedAt) {583 const idx = entries.findIndex((e) => e.id === existing.id);584 if (idx !== -1) {585 entries[idx] = normalized;586 updated += 1;587 }588 } else {589 skipped += 1;590 }591 }592 }593594 await persistEntries();595 return { added, updated, skipped };596}597598599600601602603function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {604 if (!entry || typeof entry !== "object") return null;605 const title = typeof entry.title === "string" ? entry.title.trim() : "";606 if (!title) return null;607 const type = entry.type ?? "login";608 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();609 const now = Date.now();610 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;611 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;612613 if (type === "login" && typeof entry.password !== "string") {614 return null;615 }616 if (617 type === "passkey" &&618 (typeof entry.rpId !== "string" ||619 typeof entry.credentialId !== "string" ||620 !entry.privateKeyJwk)621 ) {622 return null;623 }624625 return {626 ...entry,627 id,628 title,629 type,630 createdAt,631 modifiedAt,632 };633}634635export async function clearVaultEntries(636 confirmation: "REPLACE_ALL"637): Promise<void> {638 if (confirmation !== "REPLACE_ALL") {639 throw new Error("clearVaultEntries requires explicit confirmation");640 }641 if (!derivedKey) throw new Error("vault locked");642 for (const entry of entries) {643 if (!entry.deletedAt) {644 entry.deletedAt = Date.now();645 entry.modifiedAt = Date.now();646 }647 }648 await persistEntries();649}650651export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {652 if (!derivedKey) throw new Error("vault locked");653654 const sanitized = sanitizeEntryDraft(entry);655 const now = Date.now();656 const nextEntry: VaultEntry = {657 ...sanitized,658 id: crypto.randomUUID(),659 createdAt: now,660 modifiedAt: now,661 };662663 entries.push(nextEntry);664 await persistEntries();665666 667 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {668 checkBreach(nextEntry.password).then(async (count) => {669 if (count > 0) {670 const idx = entries.findIndex((e) => e.id === nextEntry.id);671 if (idx !== -1) {672 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };673 await persistEntries();674 }675 }676 });677 }678679 return nextEntry;680}681682export async function updateEntry(683 id: string,684 patch: Partial<VaultEntry>685): Promise<void> {686 if (!derivedKey) throw new Error("vault locked");687688 const idx = entries.findIndex((entry) => entry.id === id);689 if (idx === -1 || entries[idx].deletedAt) {690 throw new Error("entry not found");691 }692693 const current = entries[idx];694 entries[idx] = {695 ...current,696 ...sanitizeEntryPatch(patch),697 modifiedAt: Date.now(),698 };699700 701 702 703 if (entries[idx].type === "login" && !entries[idx].password) {704 entries[idx] = current;705 throw new Error("password is required for login entries");706 }707708 await persistEntries();709710 711 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login" && entries[idx].password) {712 const password = entries[idx].password!;713 checkBreach(password).then(async (count) => {714 const i = entries.findIndex((e) => e.id === id);715 if (i !== -1) {716 entries[i] = { ...entries[i], breach: count > 0 ? count : undefined, modifiedAt: Date.now() };717 await persistEntries();718 }719 });720 }721}722723export async function deleteEntry(id: string): Promise<void> {724 if (!derivedKey) throw new Error("vault locked");725726 const entry = entries.find((item) => item.id === id);727 if (!entry || entry.deletedAt) {728 throw new Error("entry not found");729 }730731 entry.deletedAt = Date.now();732 entry.modifiedAt = Date.now();733 await persistEntries();734}735736export function getDeletedEntries(): VaultEntry[] {737 if (!derivedKey) return [];738 return entries.filter((e) => !!e.deletedAt);739}740741export async function restoreEntry(id: string): Promise<void> {742 if (!derivedKey) throw new Error("vault locked");743 const entry = entries.find((e) => e.id === id && e.deletedAt);744 if (!entry) throw new Error("deleted entry not found");745 delete entry.deletedAt;746 entry.modifiedAt = Date.now();747 await persistEntries();748 buildDomainIndexSync(entries);749}750751export async function purgeEntry(id: string): Promise<void> {752 if (!derivedKey) throw new Error("vault locked");753 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);754 if (idx === -1) throw new Error("deleted entry not found");755 entries.splice(idx, 1);756 await persistEntries();757}758759760export async function purgeExpiredTrash(retentionMs: number): Promise<number> {761 if (!derivedKey || retentionMs === 0) return 0;762 const cutoff = Date.now() - retentionMs;763 const before = entries.length;764 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));765 const purged = before - entries.length;766 if (purged > 0) await persistEntries();767 return purged;768}769770export function queryDomain(domain: string): VaultEntry[] {771 return domainIndex.get(domain) ?? [];772}773774export function getEntryById(id: string): VaultEntry | undefined {775 return entries.find((entry) => entry.id === id && !entry.deletedAt);776}777778async function persistSlotA(): Promise<StoredVault> {779 if (!derivedKey) throw new Error("vault locked");780781 const slotA = await loadSlot(SLOT_A);782 if (!slotA) throw new Error("missing slot A");783784 const plaintext = encodePayload(entries, duressConfigured);785 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);786787 const nextSlotA: StoredVault = {788 ...slotA,789 nonce,790 ciphertext,791 tag,792 };793 await writeSlot(SLOT_A, nextSlotA);794795 796 797 798 if (!duressConfigured) {799 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));800 }801802 return nextSlotA;803}804805async function persistSlotB(): Promise<void> {806 if (!derivedKey) throw new Error("vault locked");807808 const slotB = await loadSlot(SLOT_B);809 if (!slotB) throw new Error("missing slot B");810811 812 813 const plaintext = encodePayload(entries, false);814 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);815816 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });817}818819async function persistEntries(): Promise<void> {820 if (!derivedKey) throw new Error("vault locked");821822 if (vaultMode === "decoy") {823 await persistSlotB();824 } else {825 const freshSlotA = await persistSlotA();826 827 828 const activeCount = entries.filter((e) => !e.deletedAt).length;829 void takeSnapshot(freshSlotA, activeCount);830 }831 buildDomainIndexSync(entries);832}833834function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {835 const title = entry.title.trim();836 const type = entry.type ?? "login";837838 if (!title) throw new Error("title is required");839 if (type === "login" && !entry.password?.trim()) {840 throw new Error("password is required");841 }842 if (type === "passkey") {843 if (844 !entry.rpId?.trim() ||845 !entry.rpName?.trim() ||846 !entry.credentialId?.trim() ||847 !entry.publicKeyCose?.trim() ||848 !entry.privateKeyJwk ||849 !entry.userHandle?.trim() ||850 !entry.userName?.trim() ||851 !entry.userDisplayName?.trim() ||852 (entry.algorithm !== -7 && entry.algorithm !== -8)853 ) {854 throw new Error("invalid passkey entry");855 }856 }857858 return {859 type,860 title,861 username: type === "login" ? entry.username?.trim() ?? "" : undefined,862 password: type === "login" ? entry.password : undefined,863 url: type === "login" ? entry.url?.trim() ?? "" : undefined,864 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,865 notes: type === "passkey" ? undefined : entry.notes?.trim() || undefined,866 breach: type === "login" ? entry.breach : undefined,867 canary: type === "login" ? entry.canary : undefined,868 rpId: type === "passkey" ? entry.rpId?.trim() : undefined,869 rpName: type === "passkey" ? entry.rpName?.trim() : undefined,870 credentialId: type === "passkey" ? entry.credentialId?.trim() : undefined,871 publicKeyCose: type === "passkey" ? entry.publicKeyCose?.trim() : undefined,872 privateKeyJwk: type === "passkey" ? entry.privateKeyJwk : undefined,873 userHandle: type === "passkey" ? entry.userHandle?.trim() : undefined,874 userName: type === "passkey" ? entry.userName?.trim() : undefined,875 userDisplayName: type === "passkey" ? entry.userDisplayName?.trim() : undefined,876 algorithm: type === "passkey" ? entry.algorithm : undefined,877 signCount: type === "passkey" ? entry.signCount ?? 0 : undefined,878 aaguid: type === "passkey" ? entry.aaguid?.trim() : undefined,879 backupEligible: type === "passkey" ? entry.backupEligible === true : undefined,880 backupState: type === "passkey" ? entry.backupState === true : undefined,881 lastUsedAt: type === "passkey" ? entry.lastUsedAt : undefined,882 };883}884885function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {886 const nextPatch: Partial<VaultEntry> = {};887888 if ("type" in patch) {889 nextPatch.type = patch.type;890 }891892 if ("title" in patch) {893 const title = patch.title?.trim() ?? "";894 if (!title) throw new Error("title is required");895 nextPatch.title = title;896 }897898 if ("url" in patch) {899 nextPatch.url = patch.url?.trim() ?? "";900 }901902 if ("username" in patch) {903 nextPatch.username = patch.username?.trim() ?? "";904 }905906 if ("password" in patch) {907 const password = patch.password ?? "";908 909 nextPatch.password = password || undefined;910 }911912 if ("totp" in patch) {913 nextPatch.totp = patch.totp?.trim() || undefined;914 }915916 if ("notes" in patch) {917 nextPatch.notes = patch.notes?.trim() || undefined;918 }919920 if ("breach" in patch) {921 nextPatch.breach = patch.breach;922 }923924 if ("canary" in patch) {925 nextPatch.canary = patch.canary;926 }927928 if ("rpId" in patch) {929 nextPatch.rpId = patch.rpId?.trim();930 }931932 if ("rpName" in patch) {933 nextPatch.rpName = patch.rpName?.trim();934 }935936 if ("credentialId" in patch) {937 nextPatch.credentialId = patch.credentialId?.trim();938 }939940 if ("publicKeyCose" in patch) {941 nextPatch.publicKeyCose = patch.publicKeyCose?.trim();942 }943944 if ("privateKeyJwk" in patch) {945 nextPatch.privateKeyJwk = patch.privateKeyJwk;946 }947948 if ("userHandle" in patch) {949 nextPatch.userHandle = patch.userHandle?.trim();950 }951952 if ("userName" in patch) {953 nextPatch.userName = patch.userName?.trim();954 }955956 if ("userDisplayName" in patch) {957 nextPatch.userDisplayName = patch.userDisplayName?.trim();958 }959960 if ("algorithm" in patch) {961 nextPatch.algorithm = patch.algorithm;962 }963964 if ("signCount" in patch) {965 nextPatch.signCount = patch.signCount;966 }967968 if ("aaguid" in patch) {969 nextPatch.aaguid = patch.aaguid?.trim();970 }971972 if ("backupEligible" in patch) {973 nextPatch.backupEligible = patch.backupEligible;974 }975976 if ("backupState" in patch) {977 nextPatch.backupState = patch.backupState;978 }979980 if ("lastUsedAt" in patch) {981 nextPatch.lastUsedAt = patch.lastUsedAt;982 }983984 return nextPatch;985}986987function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {988 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;989 const modifiedAt =990 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;991992 return {993 id: entry.id,994 type: "type" in entry ? entry.type : "login",995 title: entry.title,996 username: entry.username,997 password: entry.password,998 url: entry.url,999 totp: entry.totp,1000 notes: entry.notes,1001 breach: entry.breach,1002 canary: "canary" in entry ? entry.canary : undefined,1003 rpId: "rpId" in entry ? entry.rpId : undefined,1004 rpName: "rpName" in entry ? entry.rpName : undefined,1005 credentialId: "credentialId" in entry ? entry.credentialId : undefined,1006 publicKeyCose: "publicKeyCose" in entry ? entry.publicKeyCose : undefined,1007 privateKeyJwk: "privateKeyJwk" in entry ? entry.privateKeyJwk : undefined,1008 userHandle: "userHandle" in entry ? entry.userHandle : undefined,1009 userName: "userName" in entry ? entry.userName : undefined,1010 userDisplayName: "userDisplayName" in entry ? entry.userDisplayName : undefined,1011 algorithm: "algorithm" in entry ? entry.algorithm : undefined,1012 signCount: "signCount" in entry ? entry.signCount : undefined,1013 aaguid: "aaguid" in entry ? entry.aaguid : undefined,1014 backupEligible: "backupEligible" in entry ? entry.backupEligible : undefined,1015 backupState: "backupState" in entry ? entry.backupState : undefined,1016 lastUsedAt: "lastUsedAt" in entry ? entry.lastUsedAt : undefined,1017 createdAt,1018 modifiedAt,1019 deletedAt: entry.deletedAt,1020 tags: "tags" in entry ? entry.tags : undefined,1021 };1022}10231024function hasLegacyShape(parsed: VaultEntry[]): boolean {1025 return parsed.some(1026 (entry) => "created" in entry || "updated" in entry1027 );1028}102910301031103210331034function entryDupeKey(e: VaultEntry): string {1035 if (e.type === "note") return `note|${e.title.toLowerCase()}`;1036 if (e.type === "passkey") return `passkey|${(e.rpId ?? "").toLowerCase()}|${(e.userName ?? "").toLowerCase()}`;1037 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1038}103910401041export function countDuplicates(): number {1042 const best = new Map<string, VaultEntry>();1043 let dupeCount = 0;1044 for (const e of entries.filter((e) => !e.deletedAt)) {1045 const key = entryDupeKey(e);1046 const existing = best.get(key);1047 if (!existing) {1048 best.set(key, e);1049 } else {1050 dupeCount++;1051 1052 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1053 best.set(key, e);1054 }1055 }1056 }1057 return dupeCount;1058}105910601061106210631064export async function deduplicateEntries(): Promise<number> {1065 if (!derivedKey) throw new Error("vault locked");1066 1067 1068 if (vaultMode === "decoy") {1069 throw new Error("deduplicate disabled in decoy mode");1070 }10711072 const best = new Map<string, VaultEntry>();1073 for (const e of entries.filter((en) => !en.deletedAt)) {1074 const key = entryDupeKey(e);1075 const existing = best.get(key);1076 if (!existing) {1077 best.set(key, e);1078 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1079 best.set(key, e);1080 }1081 }10821083 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1084 const now = Date.now();1085 let removed = 0;1086 entries = entries.map((e) => {1087 if (!e.deletedAt && !keepIds.has(e.id)) {1088 removed++;1089 return { ...e, deletedAt: now };1090 }1091 return e;1092 });10931094 if (removed > 0) await persistEntries();1095 return removed;1096}109710981099110011011102const SNAPSHOT_KEY = "peach_vault_snapshots";1103const MAX_SNAPSHOTS = 6;11041105interface VaultSnapshot {1106 timestamp: number;1107 entryCount: number;1108 slot: StoredVault;1109}111011111112export interface SnapshotSummary {1113 index: number;1114 timestamp: number;1115 entryCount: number;1116}11171118async function takeSnapshot(1119 slot: StoredVault,1120 entryCount: number1121): Promise<void> {1122 try {1123 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1124 const existing: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1125 const next: VaultSnapshot[] = [1126 { timestamp: Date.now(), entryCount, slot },1127 ...existing,1128 ].slice(0, MAX_SNAPSHOTS);1129 await chrome.storage.local.set({ [SNAPSHOT_KEY]: next });1130 } catch {1131 1132 }1133}11341135export async function listSnapshots(): Promise<SnapshotSummary[]> {1136 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1137 const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1138 return snapshots.map((s, i) => ({1139 index: i,1140 timestamp: s.timestamp,1141 entryCount: s.entryCount,1142 }));1143}11441145114611471148114911501151export async function restoreFromSnapshot(1152 index: number,1153 password: string1154): Promise<boolean> {1155 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1156 const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1157 const snapshot = snapshots[index];1158 if (!snapshot) return false;11591160 1161 const key = await deriveKey(password, unhex(snapshot.slot.salt));1162 const payload = await tryDecryptSlot(snapshot.slot, key);1163 if (!payload) {1164 zeroKey(key);1165 return false;1166 }11671168 1169 await writeSlot(SLOT_A, snapshot.slot);1170 await writeSlot(SLOT_B, generateNoiseBlob(snapshot.slot.ciphertext.length / 2));11711172 1173 if (derivedKey) zeroKey(derivedKey);1174 derivedKey = key;1175 entries = payload.entries;1176 duressConfigured = payload.duressConfigured;1177 vaultMode = "real";1178 buildDomainIndexSync(entries);1179 await loadSettings();1180 touchAutoLock();11811182 return true;1183}11841185118611871188118911901191export async function changePassword(1192 currentPassword: string,1193 newPassword: string1194): Promise<boolean> {1195 if (!derivedKey || vaultMode !== "real") return false;11961197 1198 const valid = await verifyMainPassword(currentPassword);1199 if (!valid) return false;12001201 const slotA = await loadSlot(SLOT_A);1202 if (!slotA) return false;12031204 1205 const newSalt = generateSalt();1206 const newKey = await deriveKey(newPassword, newSalt);12071208 1209 const plaintext = encodePayload(entries, duressConfigured);1210 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);12111212 const freshSlotA: StoredVault = {1213 ...slotA,1214 salt: hex(newSalt),1215 nonce,1216 ciphertext,1217 tag,1218 };1219 await writeSlot(SLOT_A, freshSlotA);12201221 1222 1223 1224 zeroKey(derivedKey);1225 derivedKey = newKey;12261227 1228 1229 1230 const activeCount = entries.filter((e) => !e.deletedAt).length;1231 void takeSnapshot(freshSlotA, activeCount);12321233 1234 1235 1236 if (!duressConfigured) {1237 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1238 }12391240 return true;1241}