1import { deriveKey, generateSalt } from "../crypto/argon2";2import { extractDomain } from "../shared/domain";3import type {4 LegacyVaultEntry,5 StoredVault,6 VaultEntry,7} from "../shared/types";8import { decrypt, encrypt, zeroKey } from "./aes";9import { clearAutoLock, touchAutoLock } from "./alarms";1011type EntryDraft = Omit<12 VaultEntry,13 "id" | "createdAt" | "modifiedAt" | "deletedAt"14>;151617181920const SLOT_A = "peach_vault_a";21const SLOT_B = "peach_vault_b";222324const LEGACY_MAIN = "peach_vault";25const LEGACY_DECOY = "peach_decoy_vault";26const LEGACY_DURESS_FLAG = "peach_duress";27282930const PAD_BLOCK = 1024;3132let derivedKey: Uint8Array | null = null;33let entries: VaultEntry[] = [];34let domainIndex: Map<string, VaultEntry[]> = new Map();35let vaultMode: "real" | "decoy" = "real";36373839let duressConfigured = false;4041let migrationRan = false;424344454647export interface VaultSettings {48 accentColor?: string;49 theme?: "dark" | "light";50 autoLockMinutes?: number;51 clipboardClearSeconds?: number;52 lockOnClose?: boolean;53 breachMonitoring?: boolean;54 passkeyProviderEnabled?: boolean;55 trashRetentionDays?: number;56}5758let settings: VaultSettings = {};5960export function getSettings(): VaultSettings {61 return settings;62}6364export async function ensureSettingsLoaded(): Promise<VaultSettings> {65 await loadSettings();66 return settings;67}6869export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {70 await loadSettings();71 settings = { ...settings, ...patch };72 await persistSettings();73}7475async function persistSettings(): Promise<void> {76 try {77 await chrome.storage.local.set({ peach_settings: settings });78 } catch (err) {79 throw mapStorageError(err);80 }81}8283function mapStorageError(err: unknown): Error {84 const message = err instanceof Error ? err.message : String(err);85 if (/QUOTA_BYTES|quota/i.test(message)) {86 return new Error("STORAGE_QUOTA_EXCEEDED");87 }88 return err instanceof Error ? err : new Error(message);89}9091async function loadSettings(): Promise<void> {92 const result = await chrome.storage.local.get("peach_settings");93 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};94}9596979899100export async function checkBreach(password: string): Promise<number> {101 const encoder = new TextEncoder();102 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));103 const hashHex = Array.from(new Uint8Array(hashBuffer))104 .map((b) => b.toString(16).padStart(2, "0"))105 .join("")106 .toUpperCase();107108 const prefix = hashHex.slice(0, 5);109 const suffix = hashHex.slice(5);110111 try {112 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);113 if (!res.ok) return 0;114 const text = await res.text();115 const match = text.split("\r\n").find((l) => l.startsWith(suffix));116 return match ? parseInt(match.split(":")[1], 10) : 0;117 } catch {118 return 0; 119 }120}121122function hex(buf: Uint8Array): string {123 return Array.from(buf)124 .map((b) => b.toString(16).padStart(2, "0"))125 .join("");126}127128function unhex(s: string): Uint8Array {129 const out = new Uint8Array(s.length / 2);130 for (let i = 0; i < s.length; i += 2) {131 out[i / 2] = parseInt(s.slice(i, i + 2), 16);132 }133 return out;134}135136137138139140async function loadSlot(key: string): Promise<StoredVault | null> {141 const result = await chrome.storage.local.get(key);142 return (result as Record<string, StoredVault | undefined>)[key] ?? null;143}144145async function writeSlot(key: string, vault: StoredVault): Promise<void> {146 try {147 await chrome.storage.local.set({ [key]: vault });148 } catch (err) {149 throw mapStorageError(err);150 }151}152153154155156157158async function ensureMigrated(): Promise<void> {159 if (migrationRan) return;160 migrationRan = true;161162 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];163 const raw = await chrome.storage.local.get(keys);164 const slotAExists = raw[SLOT_A] !== undefined;165 const slotBExists = raw[SLOT_B] !== undefined;166 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;167 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;168169 170 if (!slotAExists && !legacyMain) return;171172 173 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;174175 if (legacyMain && !slotAExists) {176 await writeSlot(SLOT_A, legacyMain);177 }178179 180 if (!slotBExists) {181 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;182 if (legacyDecoy) {183 await writeSlot(SLOT_B, legacyDecoy);184 } else if (target) {185 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));186 }187 }188189 const toRemove: string[] = [];190 if (legacyMain) toRemove.push(LEGACY_MAIN);191 if (legacyDecoy) toRemove.push(LEGACY_DECOY);192 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);193 if (toRemove.length > 0) {194 await chrome.storage.local.remove(toRemove);195 }196}197198199200201202203204205206207208function padPlaintext(json: string): string {209 const encoded = new TextEncoder().encode(json);210 const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);211 const padLen = target - encoded.length;212 return json + " ".repeat(padLen);213}214215216217218219220221function generateNoiseBlob(ciphertextBytes: number): StoredVault {222 const salt = crypto.getRandomValues(new Uint8Array(16));223 const nonce = crypto.getRandomValues(new Uint8Array(12));224 const cipher = crypto.getRandomValues(new Uint8Array(ciphertextBytes));225 const tag = crypto.getRandomValues(new Uint8Array(16));226 return {227 version: 1,228 salt: hex(salt),229 nonce: hex(nonce),230 ciphertext: hex(cipher),231 tag: hex(tag),232 };233}234235236237238239type VaultPayload = {240 entries: VaultEntry[];241 duressConfigured?: boolean;242};243244type DecryptedPayload = {245 entries: VaultEntry[];246 duressConfigured: boolean;247};248249function encodePayload(entries: VaultEntry[], duress: boolean): string {250 const envelope: VaultPayload = { entries, duressConfigured: duress };251 return padPlaintext(JSON.stringify(envelope));252}253254function decodePayload(plain: string): DecryptedPayload {255 const parsed = JSON.parse(plain) as256 | Array<VaultEntry | LegacyVaultEntry>257 | VaultPayload;258259 if (Array.isArray(parsed)) {260 261 return {262 entries: parsed.map(normalizeEntry),263 duressConfigured: false,264 };265 }266267 return {268 entries: (parsed.entries ?? []).map(normalizeEntry),269 duressConfigured: parsed.duressConfigured === true,270 };271}272273async function tryDecryptSlot(274 vault: StoredVault,275 key: Uint8Array276): Promise<DecryptedPayload | null> {277 try {278 const plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);279 return decodePayload(plain);280 } catch {281 return null;282 }283}284285286287288289function buildDomainIndexSync(all: VaultEntry[]): void {290 domainIndex = new Map();291292 for (const entry of all) {293 if (entry.deletedAt) continue;294 if (entry.type === "note" || entry.type === "passkey") continue;295296 const domain = extractDomain(entry.url ?? "");297 if (!domain) continue;298299 const bucket = domainIndex.get(domain) ?? [];300 bucket.push(entry);301 domainIndex.set(domain, bucket);302 }303}304305export async function createVault(password: string): Promise<void> {306 await ensureMigrated();307308 const salt = generateSalt();309 derivedKey = await deriveKey(password, salt);310 entries = [];311 vaultMode = "real";312 duressConfigured = false;313 buildDomainIndexSync(entries);314315 const plaintext = encodePayload(entries, duressConfigured);316 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);317318 const slotA: StoredVault = {319 version: 1,320 salt: hex(salt),321 nonce,322 ciphertext,323 tag,324 };325 await writeSlot(SLOT_A, slotA);326327 328 329 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));330331 touchAutoLock();332}333334export async function hasVault(): Promise<boolean> {335 await ensureMigrated();336 return (await loadSlot(SLOT_A)) !== null;337}338339340341342343344export async function isDuressEnabled(): Promise<boolean> {345 return vaultMode === "real" && duressConfigured;346}347348export async function hasDecoyVault(): Promise<boolean> {349 return isDuressEnabled();350}351352export async function setupDuress(duressPassword: string): Promise<void> {353 if (!derivedKey || vaultMode !== "real") {354 throw new Error("vault must be unlocked in real mode");355 }356 await ensureMigrated();357358 359 360 361 const slotA = await loadSlot(SLOT_A);362 if (!slotA) throw new Error("missing slot A");363364 const decoySalt = generateSalt();365 const decoyKey = await deriveKey(duressPassword, decoySalt);366 try {367 368 369 const targetBytes = slotA.ciphertext.length / 2;370 let plaintext = encodePayload([], false);371 const encoded = new TextEncoder().encode(plaintext);372 if (encoded.length < targetBytes) {373 plaintext = plaintext + " ".repeat(targetBytes - encoded.length);374 }375 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);376 await writeSlot(SLOT_B, {377 version: 1,378 salt: hex(decoySalt),379 nonce,380 ciphertext,381 tag,382 });383 } finally {384 zeroKey(decoyKey);385 }386387 388 389 duressConfigured = true;390 await persistSlotA();391}392393export async function disableDuress(): Promise<void> {394 if (!derivedKey || vaultMode !== "real") {395 throw new Error("vault must be unlocked in real mode");396 }397 await ensureMigrated();398399 400 401 402 const slotA = await loadSlot(SLOT_A);403 if (slotA) {404 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));405 }406407 duressConfigured = false;408 await persistSlotA();409}410411export async function verifyMainPassword(password: string): Promise<boolean> {412 await ensureMigrated();413 const slotA = await loadSlot(SLOT_A);414 if (!slotA) return false;415 const key = await deriveKey(password, unhex(slotA.salt));416 try {417 return (await tryDecryptSlot(slotA, key)) !== null;418 } finally {419 zeroKey(key);420 }421}422423export async function unlock(password: string): Promise<boolean> {424 await ensureMigrated();425426 427 428 429 430 431 const [slotA, slotB] = await Promise.all([432 loadSlot(SLOT_A),433 loadSlot(SLOT_B),434 ]);435436 437 const [keyA, keyB] = await Promise.all([438 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),439 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),440 ]);441442 443 const [payloadA, payloadB] = await Promise.all([444 keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),445 keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),446 ]);447448 if (payloadA && keyA) {449 derivedKey = keyA;450 vaultMode = "real";451 entries = payloadA.entries;452 duressConfigured = payloadA.duressConfigured;453 buildDomainIndexSync(entries);454 await loadSettings();455 touchAutoLock();456 if (keyB) zeroKey(keyB);457 458 459 if (hasLegacyShape(payloadA.entries)) {460 await persistSlotA();461 }462 return true;463 }464465 if (payloadB && keyB) {466 derivedKey = keyB;467 vaultMode = "decoy";468 entries = payloadB.entries;469 duressConfigured = false;470 buildDomainIndexSync(entries);471 await loadSettings();472 touchAutoLock();473 if (keyA) zeroKey(keyA);474 return true;475 }476477 if (keyA) zeroKey(keyA);478 if (keyB) zeroKey(keyB);479 return false;480}481482export function lock(): void {483 if (derivedKey) {484 zeroKey(derivedKey);485 derivedKey = null;486 }487488 entries = [];489 domainIndex = new Map();490 vaultMode = "real";491 duressConfigured = false;492 clearAutoLock();493}494495export function isUnlocked(): boolean {496 return derivedKey !== null;497}498499export function getEntries(): VaultEntry[] {500 if (!derivedKey) throw new Error("vault locked");501 return entries.filter((entry) => !entry.deletedAt);502}503504export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {505 if (!derivedKey) throw new Error("vault locked");506507 const now = Date.now();508 const added: VaultEntry[] = [];509 for (const draft of drafts) {510 try {511 const sanitized = sanitizeEntryDraft(draft);512 const nextEntry: VaultEntry = {513 ...sanitized,514 id: crypto.randomUUID(),515 createdAt: now,516 modifiedAt: now,517 };518 entries.push(nextEntry);519 added.push(nextEntry);520 } catch {521 522 }523 }524525 if (added.length > 0) {526 await persistEntries();527 }528529 return added;530}531532533534535536537538539export async function restoreEntries(540 incoming: VaultEntry[],541 mode: "replace" | "merge"542): Promise<{ added: number; updated: number; skipped: number }> {543 if (!derivedKey) throw new Error("vault locked");544545 546 547 548 if (mode === "replace" && incoming.length === 0) {549 throw new Error("refuse to replace with empty entry set");550 }551552 let added = 0;553 let updated = 0;554 let skipped = 0;555556 if (mode === "replace") {557 entries = [];558 for (const entry of incoming) {559 const normalized = sanitizeRestoredEntry(entry);560 if (!normalized) {561 skipped += 1;562 continue;563 }564 entries.push(normalized);565 added += 1;566 }567 } else {568 const byId = new Map<string, VaultEntry>();569 for (const e of entries) byId.set(e.id, e);570571 for (const entry of incoming) {572 const normalized = sanitizeRestoredEntry(entry);573 if (!normalized) {574 skipped += 1;575 continue;576 }577 const existing = byId.get(normalized.id);578 if (!existing) {579 entries.push(normalized);580 added += 1;581 } else if (normalized.modifiedAt > existing.modifiedAt) {582 const idx = entries.findIndex((e) => e.id === existing.id);583 if (idx !== -1) {584 entries[idx] = normalized;585 updated += 1;586 }587 } else {588 skipped += 1;589 }590 }591 }592593 await persistEntries();594 return { added, updated, skipped };595}596597598599600601602function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {603 if (!entry || typeof entry !== "object") return null;604 const title = typeof entry.title === "string" ? entry.title.trim() : "";605 if (!title) return null;606 const type = entry.type ?? "login";607 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();608 const now = Date.now();609 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;610 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;611612 if (type === "login" && typeof entry.password !== "string") {613 return null;614 }615 if (616 type === "passkey" &&617 (typeof entry.rpId !== "string" ||618 typeof entry.credentialId !== "string" ||619 !entry.privateKeyJwk)620 ) {621 return null;622 }623624 return {625 ...entry,626 id,627 title,628 type,629 createdAt,630 modifiedAt,631 };632}633634export async function clearVaultEntries(635 confirmation: "REPLACE_ALL"636): Promise<void> {637 if (confirmation !== "REPLACE_ALL") {638 throw new Error("clearVaultEntries requires explicit confirmation");639 }640 if (!derivedKey) throw new Error("vault locked");641 for (const entry of entries) {642 if (!entry.deletedAt) {643 entry.deletedAt = Date.now();644 entry.modifiedAt = Date.now();645 }646 }647 await persistEntries();648}649650export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {651 if (!derivedKey) throw new Error("vault locked");652653 const sanitized = sanitizeEntryDraft(entry);654 const now = Date.now();655 const nextEntry: VaultEntry = {656 ...sanitized,657 id: crypto.randomUUID(),658 createdAt: now,659 modifiedAt: now,660 };661662 entries.push(nextEntry);663 await persistEntries();664665 666 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {667 checkBreach(nextEntry.password).then(async (count) => {668 if (count > 0) {669 const idx = entries.findIndex((e) => e.id === nextEntry.id);670 if (idx !== -1) {671 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };672 await persistEntries();673 }674 }675 });676 }677678 return nextEntry;679}680681export async function updateEntry(682 id: string,683 patch: Partial<VaultEntry>684): Promise<void> {685 if (!derivedKey) throw new Error("vault locked");686687 const idx = entries.findIndex((entry) => entry.id === id);688 if (idx === -1 || entries[idx].deletedAt) {689 throw new Error("entry not found");690 }691692 const current = entries[idx];693 entries[idx] = {694 ...current,695 ...sanitizeEntryPatch(patch),696 modifiedAt: Date.now(),697 };698699 700 701 702 if (entries[idx].type === "login" && !entries[idx].password) {703 entries[idx] = current;704 throw new Error("password is required for login entries");705 }706707 await persistEntries();708709 710 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login" && entries[idx].password) {711 const password = entries[idx].password!;712 checkBreach(password).then(async (count) => {713 const i = entries.findIndex((e) => e.id === id);714 if (i !== -1) {715 entries[i] = { ...entries[i], breach: count > 0 ? count : undefined, modifiedAt: Date.now() };716 await persistEntries();717 }718 });719 }720}721722export async function deleteEntry(id: string): Promise<void> {723 if (!derivedKey) throw new Error("vault locked");724725 const entry = entries.find((item) => item.id === id);726 if (!entry || entry.deletedAt) {727 throw new Error("entry not found");728 }729730 entry.deletedAt = Date.now();731 entry.modifiedAt = Date.now();732 await persistEntries();733}734735export function getDeletedEntries(): VaultEntry[] {736 if (!derivedKey) return [];737 return entries.filter((e) => !!e.deletedAt);738}739740export async function restoreEntry(id: string): Promise<void> {741 if (!derivedKey) throw new Error("vault locked");742 const entry = entries.find((e) => e.id === id && e.deletedAt);743 if (!entry) throw new Error("deleted entry not found");744 delete entry.deletedAt;745 entry.modifiedAt = Date.now();746 await persistEntries();747 buildDomainIndexSync(entries);748}749750export async function purgeEntry(id: string): Promise<void> {751 if (!derivedKey) throw new Error("vault locked");752 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);753 if (idx === -1) throw new Error("deleted entry not found");754 entries.splice(idx, 1);755 await persistEntries();756}757758759export async function purgeExpiredTrash(retentionMs: number): Promise<number> {760 if (!derivedKey || retentionMs === 0) return 0;761 const cutoff = Date.now() - retentionMs;762 const before = entries.length;763 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));764 const purged = before - entries.length;765 if (purged > 0) await persistEntries();766 return purged;767}768769export function queryDomain(domain: string): VaultEntry[] {770 return domainIndex.get(domain) ?? [];771}772773export function getEntryById(id: string): VaultEntry | undefined {774 return entries.find((entry) => entry.id === id && !entry.deletedAt);775}776777async function persistSlotA(): Promise<StoredVault> {778 if (!derivedKey) throw new Error("vault locked");779780 const slotA = await loadSlot(SLOT_A);781 if (!slotA) throw new Error("missing slot A");782783 const plaintext = encodePayload(entries, duressConfigured);784 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);785786 const nextSlotA: StoredVault = {787 ...slotA,788 nonce,789 ciphertext,790 tag,791 };792 await writeSlot(SLOT_A, nextSlotA);793794 795 796 797 if (!duressConfigured) {798 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));799 }800801 return nextSlotA;802}803804async function persistSlotB(): Promise<void> {805 if (!derivedKey) throw new Error("vault locked");806807 const slotB = await loadSlot(SLOT_B);808 if (!slotB) throw new Error("missing slot B");809810 811 812 const plaintext = encodePayload(entries, false);813 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);814815 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });816}817818async function persistEntries(): Promise<void> {819 if (!derivedKey) throw new Error("vault locked");820821 if (vaultMode === "decoy") {822 await persistSlotB();823 } else {824 const freshSlotA = await persistSlotA();825 826 827 const activeCount = entries.filter((e) => !e.deletedAt).length;828 void takeSnapshot(freshSlotA, activeCount);829 }830 buildDomainIndexSync(entries);831}832833function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {834 const title = entry.title.trim();835 const type = entry.type ?? "login";836837 if (!title) throw new Error("title is required");838 if (type === "login" && !entry.password?.trim()) {839 throw new Error("password is required");840 }841 if (type === "passkey") {842 if (843 !entry.rpId?.trim() ||844 !entry.rpName?.trim() ||845 !entry.credentialId?.trim() ||846 !entry.publicKeyCose?.trim() ||847 !entry.privateKeyJwk ||848 !entry.userHandle?.trim() ||849 !entry.userName?.trim() ||850 !entry.userDisplayName?.trim() ||851 (entry.algorithm !== -7 && entry.algorithm !== -8)852 ) {853 throw new Error("invalid passkey entry");854 }855 }856857 return {858 type,859 title,860 username: type === "login" ? entry.username?.trim() ?? "" : undefined,861 password: type === "login" ? entry.password : undefined,862 url: type === "login" ? entry.url?.trim() ?? "" : undefined,863 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,864 notes: type === "passkey" ? undefined : entry.notes?.trim() || undefined,865 breach: type === "login" ? entry.breach : undefined,866 canary: type === "login" ? entry.canary : undefined,867 rpId: type === "passkey" ? entry.rpId?.trim() : undefined,868 rpName: type === "passkey" ? entry.rpName?.trim() : undefined,869 credentialId: type === "passkey" ? entry.credentialId?.trim() : undefined,870 publicKeyCose: type === "passkey" ? entry.publicKeyCose?.trim() : undefined,871 privateKeyJwk: type === "passkey" ? entry.privateKeyJwk : undefined,872 userHandle: type === "passkey" ? entry.userHandle?.trim() : undefined,873 userName: type === "passkey" ? entry.userName?.trim() : undefined,874 userDisplayName: type === "passkey" ? entry.userDisplayName?.trim() : undefined,875 algorithm: type === "passkey" ? entry.algorithm : undefined,876 signCount: type === "passkey" ? entry.signCount ?? 0 : undefined,877 aaguid: type === "passkey" ? entry.aaguid?.trim() : undefined,878 backupEligible: type === "passkey" ? entry.backupEligible === true : undefined,879 backupState: type === "passkey" ? entry.backupState === true : undefined,880 lastUsedAt: type === "passkey" ? entry.lastUsedAt : undefined,881 };882}883884function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {885 const nextPatch: Partial<VaultEntry> = {};886887 if ("type" in patch) {888 nextPatch.type = patch.type;889 }890891 if ("title" in patch) {892 const title = patch.title?.trim() ?? "";893 if (!title) throw new Error("title is required");894 nextPatch.title = title;895 }896897 if ("url" in patch) {898 nextPatch.url = patch.url?.trim() ?? "";899 }900901 if ("username" in patch) {902 nextPatch.username = patch.username?.trim() ?? "";903 }904905 if ("password" in patch) {906 const password = patch.password ?? "";907 908 nextPatch.password = password || undefined;909 }910911 if ("totp" in patch) {912 nextPatch.totp = patch.totp?.trim() || undefined;913 }914915 if ("notes" in patch) {916 nextPatch.notes = patch.notes?.trim() || undefined;917 }918919 if ("breach" in patch) {920 nextPatch.breach = patch.breach;921 }922923 if ("canary" in patch) {924 nextPatch.canary = patch.canary;925 }926927 if ("rpId" in patch) {928 nextPatch.rpId = patch.rpId?.trim();929 }930931 if ("rpName" in patch) {932 nextPatch.rpName = patch.rpName?.trim();933 }934935 if ("credentialId" in patch) {936 nextPatch.credentialId = patch.credentialId?.trim();937 }938939 if ("publicKeyCose" in patch) {940 nextPatch.publicKeyCose = patch.publicKeyCose?.trim();941 }942943 if ("privateKeyJwk" in patch) {944 nextPatch.privateKeyJwk = patch.privateKeyJwk;945 }946947 if ("userHandle" in patch) {948 nextPatch.userHandle = patch.userHandle?.trim();949 }950951 if ("userName" in patch) {952 nextPatch.userName = patch.userName?.trim();953 }954955 if ("userDisplayName" in patch) {956 nextPatch.userDisplayName = patch.userDisplayName?.trim();957 }958959 if ("algorithm" in patch) {960 nextPatch.algorithm = patch.algorithm;961 }962963 if ("signCount" in patch) {964 nextPatch.signCount = patch.signCount;965 }966967 if ("aaguid" in patch) {968 nextPatch.aaguid = patch.aaguid?.trim();969 }970971 if ("backupEligible" in patch) {972 nextPatch.backupEligible = patch.backupEligible;973 }974975 if ("backupState" in patch) {976 nextPatch.backupState = patch.backupState;977 }978979 if ("lastUsedAt" in patch) {980 nextPatch.lastUsedAt = patch.lastUsedAt;981 }982983 return nextPatch;984}985986function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {987 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;988 const modifiedAt =989 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;990991 return {992 id: entry.id,993 type: "type" in entry ? entry.type : "login",994 title: entry.title,995 username: entry.username,996 password: entry.password,997 url: entry.url,998 totp: entry.totp,999 notes: entry.notes,1000 breach: entry.breach,1001 canary: "canary" in entry ? entry.canary : undefined,1002 rpId: "rpId" in entry ? entry.rpId : undefined,1003 rpName: "rpName" in entry ? entry.rpName : undefined,1004 credentialId: "credentialId" in entry ? entry.credentialId : undefined,1005 publicKeyCose: "publicKeyCose" in entry ? entry.publicKeyCose : undefined,1006 privateKeyJwk: "privateKeyJwk" in entry ? entry.privateKeyJwk : undefined,1007 userHandle: "userHandle" in entry ? entry.userHandle : undefined,1008 userName: "userName" in entry ? entry.userName : undefined,1009 userDisplayName: "userDisplayName" in entry ? entry.userDisplayName : undefined,1010 algorithm: "algorithm" in entry ? entry.algorithm : undefined,1011 signCount: "signCount" in entry ? entry.signCount : undefined,1012 aaguid: "aaguid" in entry ? entry.aaguid : undefined,1013 backupEligible: "backupEligible" in entry ? entry.backupEligible : undefined,1014 backupState: "backupState" in entry ? entry.backupState : undefined,1015 lastUsedAt: "lastUsedAt" in entry ? entry.lastUsedAt : undefined,1016 createdAt,1017 modifiedAt,1018 deletedAt: entry.deletedAt,1019 tags: "tags" in entry ? entry.tags : undefined,1020 };1021}10221023function hasLegacyShape(parsed: VaultEntry[]): boolean {1024 return parsed.some(1025 (entry) => "created" in entry || "updated" in entry1026 );1027}102810291030103110321033function entryDupeKey(e: VaultEntry): string {1034 if (e.type === "note") return `note|${e.title.toLowerCase()}`;1035 if (e.type === "passkey") return `passkey|${(e.rpId ?? "").toLowerCase()}|${(e.userName ?? "").toLowerCase()}`;1036 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1037}103810391040export function countDuplicates(): number {1041 const best = new Map<string, VaultEntry>();1042 let dupeCount = 0;1043 for (const e of entries.filter((e) => !e.deletedAt)) {1044 const key = entryDupeKey(e);1045 const existing = best.get(key);1046 if (!existing) {1047 best.set(key, e);1048 } else {1049 dupeCount++;1050 1051 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1052 best.set(key, e);1053 }1054 }1055 }1056 return dupeCount;1057}105810591060106110621063export async function deduplicateEntries(): Promise<number> {1064 if (!derivedKey) throw new Error("vault locked");1065 1066 1067 if (vaultMode === "decoy") {1068 throw new Error("deduplicate disabled in decoy mode");1069 }10701071 const best = new Map<string, VaultEntry>();1072 for (const e of entries.filter((en) => !en.deletedAt)) {1073 const key = entryDupeKey(e);1074 const existing = best.get(key);1075 if (!existing) {1076 best.set(key, e);1077 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1078 best.set(key, e);1079 }1080 }10811082 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1083 const now = Date.now();1084 let removed = 0;1085 entries = entries.map((e) => {1086 if (!e.deletedAt && !keepIds.has(e.id)) {1087 removed++;1088 return { ...e, deletedAt: now };1089 }1090 return e;1091 });10921093 if (removed > 0) await persistEntries();1094 return removed;1095}109610971098109911001101const SNAPSHOT_KEY = "peach_vault_snapshots";1102const MAX_SNAPSHOTS = 6;11031104interface VaultSnapshot {1105 timestamp: number;1106 entryCount: number;1107 slot: StoredVault;1108}110911101111export interface SnapshotSummary {1112 index: number;1113 timestamp: number;1114 entryCount: number;1115}11161117async function takeSnapshot(1118 slot: StoredVault,1119 entryCount: number1120): Promise<void> {1121 try {1122 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1123 const existing: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1124 const next: VaultSnapshot[] = [1125 { timestamp: Date.now(), entryCount, slot },1126 ...existing,1127 ].slice(0, MAX_SNAPSHOTS);1128 await chrome.storage.local.set({ [SNAPSHOT_KEY]: next });1129 } catch {1130 1131 }1132}11331134export async function listSnapshots(): Promise<SnapshotSummary[]> {1135 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1136 const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1137 return snapshots.map((s, i) => ({1138 index: i,1139 timestamp: s.timestamp,1140 entryCount: s.entryCount,1141 }));1142}11431144114511461147114811491150export async function restoreFromSnapshot(1151 index: number,1152 password: string1153): Promise<boolean> {1154 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1155 const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1156 const snapshot = snapshots[index];1157 if (!snapshot) return false;11581159 1160 const key = await deriveKey(password, unhex(snapshot.slot.salt));1161 const payload = await tryDecryptSlot(snapshot.slot, key);1162 if (!payload) {1163 zeroKey(key);1164 return false;1165 }11661167 1168 await writeSlot(SLOT_A, snapshot.slot);1169 await writeSlot(SLOT_B, generateNoiseBlob(snapshot.slot.ciphertext.length / 2));11701171 1172 if (derivedKey) zeroKey(derivedKey);1173 derivedKey = key;1174 entries = payload.entries;1175 duressConfigured = payload.duressConfigured;1176 vaultMode = "real";1177 buildDomainIndexSync(entries);1178 await loadSettings();1179 touchAutoLock();11801181 return true;1182}11831184118511861187118811891190export async function changePassword(1191 currentPassword: string,1192 newPassword: string1193): Promise<boolean> {1194 if (!derivedKey || vaultMode !== "real") return false;11951196 1197 const valid = await verifyMainPassword(currentPassword);1198 if (!valid) return false;11991200 const slotA = await loadSlot(SLOT_A);1201 if (!slotA) return false;12021203 1204 const newSalt = generateSalt();1205 const newKey = await deriveKey(newPassword, newSalt);12061207 1208 const plaintext = encodePayload(entries, duressConfigured);1209 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);12101211 const freshSlotA: StoredVault = {1212 ...slotA,1213 salt: hex(newSalt),1214 nonce,1215 ciphertext,1216 tag,1217 };1218 await writeSlot(SLOT_A, freshSlotA);12191220 1221 1222 1223 zeroKey(derivedKey);1224 derivedKey = newKey;12251226 1227 1228 1229 const activeCount = entries.filter((e) => !e.deletedAt).length;1230 void takeSnapshot(freshSlotA, activeCount);12311232 1233 1234 1235 if (!duressConfigured) {1236 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1237 }12381239 return true;1240}