1import { deriveKey, generateSalt } from "../crypto/argon2";2import { extractDomain } from "../shared/domain";3import type {4 AccentColorName,5 LegacyVaultEntry,6 ManualSummonRecord,7 StoredVault,8 VaultEntry,9} from "../shared/types";10import { decrypt, encrypt, zeroKey } from "./aes";11import { clearAutoLock, touchAutoLock } from "./alarms";1213type EntryDraft = Omit<14 VaultEntry,15 "id" | "createdAt" | "modifiedAt" | "deletedAt"16>;171819202122const SLOT_A = "peach_vault_a";23const SLOT_B = "peach_vault_b";242526const LEGACY_MAIN = "peach_vault";27const LEGACY_DECOY = "peach_decoy_vault";28const LEGACY_DURESS_FLAG = "peach_duress";29303132const PAD_BLOCK = 1024;3334let derivedKey: Uint8Array | null = null;35let entries: VaultEntry[] = [];36let domainIndex: Map<string, VaultEntry[]> = new Map();37let vaultMode: "real" | "decoy" = "real";38394041let duressConfigured = false;4243let migrationRan = false;444546474849export interface VaultSettings {50 accentColor?: AccentColorName;51 theme?: "dark" | "light";52 autoLockMinutes?: number;53 clipboardClearSeconds?: number;54 lockOnClose?: boolean;55 breachMonitoring?: boolean;56 passkeyProviderEnabled?: boolean;57 trashRetentionDays?: number;58 manualSummonDomains?: Record<string, ManualSummonRecord>;59}6061let settings: VaultSettings = {};6263export function getSettings(): VaultSettings {64 return settings;65}6667export async function ensureSettingsLoaded(): Promise<VaultSettings> {68 await loadSettings();69 return settings;70}7172export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {73 await loadSettings();74 settings = { ...settings, ...patch };75 await persistSettings();76}7778async function persistSettings(): Promise<void> {79 try {80 await chrome.storage.local.set({ peach_settings: settings });81 } catch (err) {82 throw mapStorageError(err);83 }84}8586function mapStorageError(err: unknown): Error {87 const message = err instanceof Error ? err.message : String(err);88 if (/QUOTA_BYTES|quota/i.test(message)) {89 return new Error("STORAGE_QUOTA_EXCEEDED");90 }91 return err instanceof Error ? err : new Error(message);92}9394async function loadSettings(): Promise<void> {95 const result = await chrome.storage.local.get("peach_settings");96 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};97}9899100101102103export async function checkBreach(password: string): Promise<number> {104 const encoder = new TextEncoder();105 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));106 const hashHex = Array.from(new Uint8Array(hashBuffer))107 .map((b) => b.toString(16).padStart(2, "0"))108 .join("")109 .toUpperCase();110111 const prefix = hashHex.slice(0, 5);112 const suffix = hashHex.slice(5);113114 try {115 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);116 if (!res.ok) return 0;117 const text = await res.text();118 const match = text.split("\r\n").find((l) => l.startsWith(suffix));119 return match ? parseInt(match.split(":")[1], 10) : 0;120 } catch {121 return 0; 122 }123}124125function hex(buf: Uint8Array): string {126 return Array.from(buf)127 .map((b) => b.toString(16).padStart(2, "0"))128 .join("");129}130131function unhex(s: string): Uint8Array {132 const out = new Uint8Array(s.length / 2);133 for (let i = 0; i < s.length; i += 2) {134 out[i / 2] = parseInt(s.slice(i, i + 2), 16);135 }136 return out;137}138139140141142143async function loadSlot(key: string): Promise<StoredVault | null> {144 const result = await chrome.storage.local.get(key);145 return (result as Record<string, StoredVault | undefined>)[key] ?? null;146}147148async function writeSlot(key: string, vault: StoredVault): Promise<void> {149 try {150 await chrome.storage.local.set({ [key]: vault });151 } catch (err) {152 throw mapStorageError(err);153 }154}155156157158159160161async function ensureMigrated(): Promise<void> {162 if (migrationRan) return;163 migrationRan = true;164165 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];166 const raw = await chrome.storage.local.get(keys);167 const slotAExists = raw[SLOT_A] !== undefined;168 const slotBExists = raw[SLOT_B] !== undefined;169 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;170 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;171172 173 if (!slotAExists && !legacyMain) return;174175 176 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;177178 if (legacyMain && !slotAExists) {179 await writeSlot(SLOT_A, legacyMain);180 }181182 183 if (!slotBExists) {184 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;185 if (legacyDecoy) {186 await writeSlot(SLOT_B, legacyDecoy);187 } else if (target) {188 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));189 }190 }191192 const toRemove: string[] = [];193 if (legacyMain) toRemove.push(LEGACY_MAIN);194 if (legacyDecoy) toRemove.push(LEGACY_DECOY);195 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);196 if (toRemove.length > 0) {197 await chrome.storage.local.remove(toRemove);198 }199}200201202203204205206207208209210211function padPlaintext(json: string): string {212 const encoded = new TextEncoder().encode(json);213 const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);214 const padLen = target - encoded.length;215 return json + " ".repeat(padLen);216}217218219220221222223224function generateNoiseBlob(ciphertextBytes: number): StoredVault {225 const salt = crypto.getRandomValues(new Uint8Array(16));226 const nonce = crypto.getRandomValues(new Uint8Array(12));227 const cipher = crypto.getRandomValues(new Uint8Array(ciphertextBytes));228 const tag = crypto.getRandomValues(new Uint8Array(16));229 return {230 version: 1,231 salt: hex(salt),232 nonce: hex(nonce),233 ciphertext: hex(cipher),234 tag: hex(tag),235 };236}237238239240241242type VaultPayload = {243 entries: VaultEntry[];244 duressConfigured?: boolean;245};246247type DecryptedPayload = {248 entries: VaultEntry[];249 duressConfigured: boolean;250};251252function encodePayload(entries: VaultEntry[], duress: boolean): string {253 const envelope: VaultPayload = { entries, duressConfigured: duress };254 return padPlaintext(JSON.stringify(envelope));255}256257function decodePayload(plain: string): DecryptedPayload {258 const parsed = JSON.parse(plain) as259 | Array<VaultEntry | LegacyVaultEntry>260 | VaultPayload;261262 if (Array.isArray(parsed)) {263 264 return {265 entries: parsed.map(normalizeEntry),266 duressConfigured: false,267 };268 }269270 return {271 entries: (parsed.entries ?? []).map(normalizeEntry),272 duressConfigured: parsed.duressConfigured === true,273 };274}275276async function tryDecryptSlot(277 vault: StoredVault,278 key: Uint8Array279): Promise<DecryptedPayload | null> {280 try {281 const plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);282 return decodePayload(plain);283 } catch {284 return null;285 }286}287288289290291292function buildDomainIndexSync(all: VaultEntry[]): void {293 domainIndex = new Map();294295 for (const entry of all) {296 if (entry.deletedAt) continue;297 if (entry.type === "note" || entry.type === "passkey") continue;298299 const domain = extractDomain(entry.url ?? "");300 if (!domain) continue;301302 const bucket = domainIndex.get(domain) ?? [];303 bucket.push(entry);304 domainIndex.set(domain, bucket);305 }306}307308export async function createVault(password: string): Promise<void> {309 await ensureMigrated();310311 const salt = generateSalt();312 derivedKey = await deriveKey(password, salt);313 entries = [];314 vaultMode = "real";315 duressConfigured = false;316 buildDomainIndexSync(entries);317318 const plaintext = encodePayload(entries, duressConfigured);319 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);320321 const slotA: StoredVault = {322 version: 1,323 salt: hex(salt),324 nonce,325 ciphertext,326 tag,327 };328 await writeSlot(SLOT_A, slotA);329330 331 332 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));333334 touchAutoLock();335}336337export async function hasVault(): Promise<boolean> {338 await ensureMigrated();339 return (await loadSlot(SLOT_A)) !== null;340}341342343344345346347export async function isDuressEnabled(): Promise<boolean> {348 return vaultMode === "real" && duressConfigured;349}350351export async function hasDecoyVault(): Promise<boolean> {352 return isDuressEnabled();353}354355export async function setupDuress(duressPassword: string): Promise<void> {356 if (!derivedKey || vaultMode !== "real") {357 throw new Error("vault must be unlocked in real mode");358 }359 await ensureMigrated();360361 362 363 364 const slotA = await loadSlot(SLOT_A);365 if (!slotA) throw new Error("missing slot A");366367 const decoySalt = generateSalt();368 const decoyKey = await deriveKey(duressPassword, decoySalt);369 try {370 371 372 const targetBytes = slotA.ciphertext.length / 2;373 let plaintext = encodePayload([], false);374 const encoded = new TextEncoder().encode(plaintext);375 if (encoded.length < targetBytes) {376 plaintext = plaintext + " ".repeat(targetBytes - encoded.length);377 }378 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);379 await writeSlot(SLOT_B, {380 version: 1,381 salt: hex(decoySalt),382 nonce,383 ciphertext,384 tag,385 });386 } finally {387 zeroKey(decoyKey);388 }389390 391 392 duressConfigured = true;393 await persistSlotA();394}395396export async function disableDuress(): Promise<void> {397 if (!derivedKey || vaultMode !== "real") {398 throw new Error("vault must be unlocked in real mode");399 }400 await ensureMigrated();401402 403 404 405 const slotA = await loadSlot(SLOT_A);406 if (slotA) {407 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));408 }409410 duressConfigured = false;411 await persistSlotA();412}413414export async function verifyMainPassword(password: string): Promise<boolean> {415 await ensureMigrated();416 const slotA = await loadSlot(SLOT_A);417 if (!slotA) return false;418 const key = await deriveKey(password, unhex(slotA.salt));419 try {420 return (await tryDecryptSlot(slotA, key)) !== null;421 } finally {422 zeroKey(key);423 }424}425426export async function unlock(password: string): Promise<boolean> {427 await ensureMigrated();428429 430 431 432 433 434 const [slotA, slotB] = await Promise.all([435 loadSlot(SLOT_A),436 loadSlot(SLOT_B),437 ]);438439 440 const [keyA, keyB] = await Promise.all([441 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),442 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),443 ]);444445 446 const [payloadA, payloadB] = await Promise.all([447 keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),448 keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),449 ]);450451 if (payloadA && keyA) {452 derivedKey = keyA;453 vaultMode = "real";454 entries = payloadA.entries;455 duressConfigured = payloadA.duressConfigured;456 buildDomainIndexSync(entries);457 await loadSettings();458 touchAutoLock();459 if (keyB) zeroKey(keyB);460 461 462 if (hasLegacyShape(payloadA.entries)) {463 await persistSlotA();464 }465 return true;466 }467468 if (payloadB && keyB) {469 derivedKey = keyB;470 vaultMode = "decoy";471 entries = payloadB.entries;472 duressConfigured = false;473 buildDomainIndexSync(entries);474 await loadSettings();475 touchAutoLock();476 if (keyA) zeroKey(keyA);477 return true;478 }479480 if (keyA) zeroKey(keyA);481 if (keyB) zeroKey(keyB);482 return false;483}484485export function lock(): void {486 if (derivedKey) {487 zeroKey(derivedKey);488 derivedKey = null;489 }490491 entries = [];492 domainIndex = new Map();493 vaultMode = "real";494 duressConfigured = false;495 clearAutoLock();496}497498export function isUnlocked(): boolean {499 return derivedKey !== null;500}501502export function getEntries(): VaultEntry[] {503 if (!derivedKey) throw new Error("vault locked");504 return entries.filter((entry) => !entry.deletedAt);505}506507export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {508 if (!derivedKey) throw new Error("vault locked");509510 const now = Date.now();511 const added: VaultEntry[] = [];512 for (const draft of drafts) {513 try {514 const sanitized = sanitizeEntryDraft(draft);515 const nextEntry: VaultEntry = {516 ...sanitized,517 id: crypto.randomUUID(),518 createdAt: now,519 modifiedAt: now,520 };521 entries.push(nextEntry);522 added.push(nextEntry);523 } catch {524 525 }526 }527528 if (added.length > 0) {529 await persistEntries();530 }531532 return added;533}534535536537538539540541542export async function restoreEntries(543 incoming: VaultEntry[],544 mode: "replace" | "merge"545): Promise<{ added: number; updated: number; skipped: number }> {546 if (!derivedKey) throw new Error("vault locked");547548 549 550 551 if (mode === "replace" && incoming.length === 0) {552 throw new Error("refuse to replace with empty entry set");553 }554555 let added = 0;556 let updated = 0;557 let skipped = 0;558559 if (mode === "replace") {560 entries = [];561 for (const entry of incoming) {562 const normalized = sanitizeRestoredEntry(entry);563 if (!normalized) {564 skipped += 1;565 continue;566 }567 entries.push(normalized);568 added += 1;569 }570 } else {571 const byId = new Map<string, VaultEntry>();572 for (const e of entries) byId.set(e.id, e);573574 for (const entry of incoming) {575 const normalized = sanitizeRestoredEntry(entry);576 if (!normalized) {577 skipped += 1;578 continue;579 }580 const existing = byId.get(normalized.id);581 if (!existing) {582 entries.push(normalized);583 added += 1;584 } else if (normalized.modifiedAt > existing.modifiedAt) {585 const idx = entries.findIndex((e) => e.id === existing.id);586 if (idx !== -1) {587 entries[idx] = normalized;588 updated += 1;589 }590 } else {591 skipped += 1;592 }593 }594 }595596 await persistEntries();597 return { added, updated, skipped };598}599600601602603604605function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {606 if (!entry || typeof entry !== "object") return null;607 const title = typeof entry.title === "string" ? entry.title.trim() : "";608 if (!title) return null;609 const type = entry.type ?? "login";610 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();611 const now = Date.now();612 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;613 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;614615 if (type === "login" && typeof entry.password !== "string") {616 return null;617 }618 if (619 type === "passkey" &&620 (typeof entry.rpId !== "string" ||621 typeof entry.credentialId !== "string" ||622 !entry.privateKeyJwk)623 ) {624 return null;625 }626627 return {628 ...entry,629 id,630 title,631 type,632 createdAt,633 modifiedAt,634 };635}636637export async function clearVaultEntries(638 confirmation: "REPLACE_ALL"639): Promise<void> {640 if (confirmation !== "REPLACE_ALL") {641 throw new Error("clearVaultEntries requires explicit confirmation");642 }643 if (!derivedKey) throw new Error("vault locked");644 for (const entry of entries) {645 if (!entry.deletedAt) {646 entry.deletedAt = Date.now();647 entry.modifiedAt = Date.now();648 }649 }650 await persistEntries();651}652653export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {654 if (!derivedKey) throw new Error("vault locked");655656 const sanitized = sanitizeEntryDraft(entry);657 const now = Date.now();658 const nextEntry: VaultEntry = {659 ...sanitized,660 id: crypto.randomUUID(),661 createdAt: now,662 modifiedAt: now,663 };664665 entries.push(nextEntry);666 await persistEntries();667668 669 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {670 checkBreach(nextEntry.password).then(async (count) => {671 if (count > 0) {672 const idx = entries.findIndex((e) => e.id === nextEntry.id);673 if (idx !== -1) {674 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };675 await persistEntries();676 }677 }678 });679 }680681 return nextEntry;682}683684export async function updateEntry(685 id: string,686 patch: Partial<VaultEntry>687): Promise<void> {688 if (!derivedKey) throw new Error("vault locked");689690 const idx = entries.findIndex((entry) => entry.id === id);691 if (idx === -1 || entries[idx].deletedAt) {692 throw new Error("entry not found");693 }694695 const current = entries[idx];696 entries[idx] = {697 ...current,698 ...sanitizeEntryPatch(patch),699 modifiedAt: Date.now(),700 };701702 703 704 705 if (entries[idx].type === "login" && !entries[idx].password) {706 entries[idx] = current;707 throw new Error("password is required for login entries");708 }709710 await persistEntries();711712 713 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login" && entries[idx].password) {714 const password = entries[idx].password!;715 checkBreach(password).then(async (count) => {716 const i = entries.findIndex((e) => e.id === id);717 if (i !== -1) {718 entries[i] = { ...entries[i], breach: count > 0 ? count : undefined, modifiedAt: Date.now() };719 await persistEntries();720 }721 });722 }723}724725export async function deleteEntry(id: string): Promise<void> {726 if (!derivedKey) throw new Error("vault locked");727728 const entry = entries.find((item) => item.id === id);729 if (!entry || entry.deletedAt) {730 throw new Error("entry not found");731 }732733 entry.deletedAt = Date.now();734 entry.modifiedAt = Date.now();735 await persistEntries();736}737738export function getDeletedEntries(): VaultEntry[] {739 if (!derivedKey) return [];740 return entries.filter((e) => !!e.deletedAt);741}742743export async function restoreEntry(id: string): Promise<void> {744 if (!derivedKey) throw new Error("vault locked");745 const entry = entries.find((e) => e.id === id && e.deletedAt);746 if (!entry) throw new Error("deleted entry not found");747 delete entry.deletedAt;748 entry.modifiedAt = Date.now();749 await persistEntries();750 buildDomainIndexSync(entries);751}752753export async function purgeEntry(id: string): Promise<void> {754 if (!derivedKey) throw new Error("vault locked");755 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);756 if (idx === -1) throw new Error("deleted entry not found");757 entries.splice(idx, 1);758 await persistEntries();759}760761762export async function purgeExpiredTrash(retentionMs: number): Promise<number> {763 if (!derivedKey || retentionMs === 0) return 0;764 const cutoff = Date.now() - retentionMs;765 const before = entries.length;766 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));767 const purged = before - entries.length;768 if (purged > 0) await persistEntries();769 return purged;770}771772export function queryDomain(domain: string): VaultEntry[] {773 return domainIndex.get(domain) ?? [];774}775776export function getEntryById(id: string): VaultEntry | undefined {777 return entries.find((entry) => entry.id === id && !entry.deletedAt);778}779780async function persistSlotA(): Promise<StoredVault> {781 if (!derivedKey) throw new Error("vault locked");782783 const slotA = await loadSlot(SLOT_A);784 if (!slotA) throw new Error("missing slot A");785786 const plaintext = encodePayload(entries, duressConfigured);787 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);788789 const nextSlotA: StoredVault = {790 ...slotA,791 nonce,792 ciphertext,793 tag,794 };795 await writeSlot(SLOT_A, nextSlotA);796797 798 799 800 if (!duressConfigured) {801 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));802 }803804 return nextSlotA;805}806807async function persistSlotB(): Promise<void> {808 if (!derivedKey) throw new Error("vault locked");809810 const slotB = await loadSlot(SLOT_B);811 if (!slotB) throw new Error("missing slot B");812813 814 815 const plaintext = encodePayload(entries, false);816 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);817818 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });819}820821async function persistEntries(): Promise<void> {822 if (!derivedKey) throw new Error("vault locked");823824 if (vaultMode === "decoy") {825 await persistSlotB();826 } else {827 const freshSlotA = await persistSlotA();828 829 830 const activeCount = entries.filter((e) => !e.deletedAt).length;831 void takeSnapshot(freshSlotA, activeCount);832 }833 buildDomainIndexSync(entries);834}835836function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {837 const title = entry.title.trim();838 const type = entry.type ?? "login";839840 if (!title) throw new Error("title is required");841 if (type === "login" && !entry.password?.trim()) {842 throw new Error("password is required");843 }844 if (type === "passkey") {845 if (846 !entry.rpId?.trim() ||847 !entry.rpName?.trim() ||848 !entry.credentialId?.trim() ||849 !entry.publicKeyCose?.trim() ||850 !entry.privateKeyJwk ||851 !entry.userHandle?.trim() ||852 !entry.userName?.trim() ||853 !entry.userDisplayName?.trim() ||854 (entry.algorithm !== -7 && entry.algorithm !== -8)855 ) {856 throw new Error("invalid passkey entry");857 }858 }859860 return {861 type,862 title,863 username: type === "login" ? entry.username?.trim() ?? "" : undefined,864 password: type === "login" ? entry.password : undefined,865 url: type === "login" ? entry.url?.trim() ?? "" : undefined,866 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,867 notes: type === "passkey" ? undefined : entry.notes?.trim() || undefined,868 breach: type === "login" ? entry.breach : undefined,869 canary: type === "login" ? entry.canary : undefined,870 rpId: type === "passkey" ? entry.rpId?.trim() : undefined,871 rpName: type === "passkey" ? entry.rpName?.trim() : undefined,872 credentialId: type === "passkey" ? entry.credentialId?.trim() : undefined,873 publicKeyCose: type === "passkey" ? entry.publicKeyCose?.trim() : undefined,874 privateKeyJwk: type === "passkey" ? entry.privateKeyJwk : undefined,875 userHandle: type === "passkey" ? entry.userHandle?.trim() : undefined,876 userName: type === "passkey" ? entry.userName?.trim() : undefined,877 userDisplayName: type === "passkey" ? entry.userDisplayName?.trim() : undefined,878 algorithm: type === "passkey" ? entry.algorithm : undefined,879 signCount: type === "passkey" ? entry.signCount ?? 0 : undefined,880 aaguid: type === "passkey" ? entry.aaguid?.trim() : undefined,881 backupEligible: type === "passkey" ? entry.backupEligible === true : undefined,882 backupState: type === "passkey" ? entry.backupState === true : undefined,883 lastUsedAt: type === "passkey" ? entry.lastUsedAt : undefined,884 };885}886887function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {888 const nextPatch: Partial<VaultEntry> = {};889890 if ("type" in patch) {891 nextPatch.type = patch.type;892 }893894 if ("title" in patch) {895 const title = patch.title?.trim() ?? "";896 if (!title) throw new Error("title is required");897 nextPatch.title = title;898 }899900 if ("url" in patch) {901 nextPatch.url = patch.url?.trim() ?? "";902 }903904 if ("username" in patch) {905 nextPatch.username = patch.username?.trim() ?? "";906 }907908 if ("password" in patch) {909 const password = patch.password ?? "";910 911 nextPatch.password = password || undefined;912 }913914 if ("totp" in patch) {915 nextPatch.totp = patch.totp?.trim() || undefined;916 }917918 if ("notes" in patch) {919 nextPatch.notes = patch.notes?.trim() || undefined;920 }921922 if ("breach" in patch) {923 nextPatch.breach = patch.breach;924 }925926 if ("canary" in patch) {927 nextPatch.canary = patch.canary;928 }929930 if ("rpId" in patch) {931 nextPatch.rpId = patch.rpId?.trim();932 }933934 if ("rpName" in patch) {935 nextPatch.rpName = patch.rpName?.trim();936 }937938 if ("credentialId" in patch) {939 nextPatch.credentialId = patch.credentialId?.trim();940 }941942 if ("publicKeyCose" in patch) {943 nextPatch.publicKeyCose = patch.publicKeyCose?.trim();944 }945946 if ("privateKeyJwk" in patch) {947 nextPatch.privateKeyJwk = patch.privateKeyJwk;948 }949950 if ("userHandle" in patch) {951 nextPatch.userHandle = patch.userHandle?.trim();952 }953954 if ("userName" in patch) {955 nextPatch.userName = patch.userName?.trim();956 }957958 if ("userDisplayName" in patch) {959 nextPatch.userDisplayName = patch.userDisplayName?.trim();960 }961962 if ("algorithm" in patch) {963 nextPatch.algorithm = patch.algorithm;964 }965966 if ("signCount" in patch) {967 nextPatch.signCount = patch.signCount;968 }969970 if ("aaguid" in patch) {971 nextPatch.aaguid = patch.aaguid?.trim();972 }973974 if ("backupEligible" in patch) {975 nextPatch.backupEligible = patch.backupEligible;976 }977978 if ("backupState" in patch) {979 nextPatch.backupState = patch.backupState;980 }981982 if ("lastUsedAt" in patch) {983 nextPatch.lastUsedAt = patch.lastUsedAt;984 }985986 return nextPatch;987}988989function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {990 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;991 const modifiedAt =992 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;993994 return {995 id: entry.id,996 type: "type" in entry ? entry.type : "login",997 title: entry.title,998 username: entry.username,999 password: entry.password,1000 url: entry.url,1001 totp: entry.totp,1002 notes: entry.notes,1003 breach: entry.breach,1004 canary: "canary" in entry ? entry.canary : undefined,1005 rpId: "rpId" in entry ? entry.rpId : undefined,1006 rpName: "rpName" in entry ? entry.rpName : undefined,1007 credentialId: "credentialId" in entry ? entry.credentialId : undefined,1008 publicKeyCose: "publicKeyCose" in entry ? entry.publicKeyCose : undefined,1009 privateKeyJwk: "privateKeyJwk" in entry ? entry.privateKeyJwk : undefined,1010 userHandle: "userHandle" in entry ? entry.userHandle : undefined,1011 userName: "userName" in entry ? entry.userName : undefined,1012 userDisplayName: "userDisplayName" in entry ? entry.userDisplayName : undefined,1013 algorithm: "algorithm" in entry ? entry.algorithm : undefined,1014 signCount: "signCount" in entry ? entry.signCount : undefined,1015 aaguid: "aaguid" in entry ? entry.aaguid : undefined,1016 backupEligible: "backupEligible" in entry ? entry.backupEligible : undefined,1017 backupState: "backupState" in entry ? entry.backupState : undefined,1018 lastUsedAt: "lastUsedAt" in entry ? entry.lastUsedAt : undefined,1019 createdAt,1020 modifiedAt,1021 deletedAt: entry.deletedAt,1022 tags: "tags" in entry ? entry.tags : undefined,1023 };1024}10251026function hasLegacyShape(parsed: VaultEntry[]): boolean {1027 return parsed.some(1028 (entry) => "created" in entry || "updated" in entry1029 );1030}103110321033103410351036function entryDupeKey(e: VaultEntry): string {1037 if (e.type === "note") return `note|${e.title.toLowerCase()}`;1038 if (e.type === "passkey") return `passkey|${(e.rpId ?? "").toLowerCase()}|${(e.userName ?? "").toLowerCase()}`;1039 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1040}104110421043export function countDuplicates(): number {1044 const best = new Map<string, VaultEntry>();1045 let dupeCount = 0;1046 for (const e of entries.filter((e) => !e.deletedAt)) {1047 const key = entryDupeKey(e);1048 const existing = best.get(key);1049 if (!existing) {1050 best.set(key, e);1051 } else {1052 dupeCount++;1053 1054 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1055 best.set(key, e);1056 }1057 }1058 }1059 return dupeCount;1060}106110621063106410651066export async function deduplicateEntries(): Promise<number> {1067 if (!derivedKey) throw new Error("vault locked");1068 1069 1070 if (vaultMode === "decoy") {1071 throw new Error("deduplicate disabled in decoy mode");1072 }10731074 const best = new Map<string, VaultEntry>();1075 for (const e of entries.filter((en) => !en.deletedAt)) {1076 const key = entryDupeKey(e);1077 const existing = best.get(key);1078 if (!existing) {1079 best.set(key, e);1080 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1081 best.set(key, e);1082 }1083 }10841085 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1086 const now = Date.now();1087 let removed = 0;1088 entries = entries.map((e) => {1089 if (!e.deletedAt && !keepIds.has(e.id)) {1090 removed++;1091 return { ...e, deletedAt: now };1092 }1093 return e;1094 });10951096 if (removed > 0) await persistEntries();1097 return removed;1098}109911001101110211031104const SNAPSHOT_KEY = "peach_vault_snapshots";1105const MAX_SNAPSHOTS = 6;11061107interface VaultSnapshot {1108 timestamp: number;1109 entryCount: number;1110 slot: StoredVault;1111}111211131114export interface SnapshotSummary {1115 index: number;1116 timestamp: number;1117 entryCount: number;1118}11191120async function takeSnapshot(1121 slot: StoredVault,1122 entryCount: number1123): Promise<void> {1124 try {1125 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1126 const existing: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1127 const next: VaultSnapshot[] = [1128 { timestamp: Date.now(), entryCount, slot },1129 ...existing,1130 ].slice(0, MAX_SNAPSHOTS);1131 await chrome.storage.local.set({ [SNAPSHOT_KEY]: next });1132 } catch {1133 1134 }1135}11361137export async function listSnapshots(): Promise<SnapshotSummary[]> {1138 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1139 const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1140 return snapshots.map((s, i) => ({1141 index: i,1142 timestamp: s.timestamp,1143 entryCount: s.entryCount,1144 }));1145}11461147114811491150115111521153export async function restoreFromSnapshot(1154 index: number,1155 password: string1156): Promise<boolean> {1157 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1158 const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1159 const snapshot = snapshots[index];1160 if (!snapshot) return false;11611162 1163 const key = await deriveKey(password, unhex(snapshot.slot.salt));1164 const payload = await tryDecryptSlot(snapshot.slot, key);1165 if (!payload) {1166 zeroKey(key);1167 return false;1168 }11691170 1171 await writeSlot(SLOT_A, snapshot.slot);1172 await writeSlot(SLOT_B, generateNoiseBlob(snapshot.slot.ciphertext.length / 2));11731174 1175 if (derivedKey) zeroKey(derivedKey);1176 derivedKey = key;1177 entries = payload.entries;1178 duressConfigured = payload.duressConfigured;1179 vaultMode = "real";1180 buildDomainIndexSync(entries);1181 await loadSettings();1182 touchAutoLock();11831184 return true;1185}11861187118811891190119111921193export async function changePassword(1194 currentPassword: string,1195 newPassword: string1196): Promise<boolean> {1197 if (!derivedKey || vaultMode !== "real") return false;11981199 1200 const valid = await verifyMainPassword(currentPassword);1201 if (!valid) return false;12021203 const slotA = await loadSlot(SLOT_A);1204 if (!slotA) return false;12051206 1207 const newSalt = generateSalt();1208 const newKey = await deriveKey(newPassword, newSalt);12091210 1211 const plaintext = encodePayload(entries, duressConfigured);1212 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);12131214 const freshSlotA: StoredVault = {1215 ...slotA,1216 salt: hex(newSalt),1217 nonce,1218 ciphertext,1219 tag,1220 };1221 await writeSlot(SLOT_A, freshSlotA);12221223 1224 1225 1226 zeroKey(derivedKey);1227 derivedKey = newKey;12281229 1230 1231 1232 const activeCount = entries.filter((e) => !e.deletedAt).length;1233 void takeSnapshot(freshSlotA, activeCount);12341235 1236 1237 1238 if (!duressConfigured) {1239 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1240 }12411242 return true;1243}