123456789101112import { deriveKey, generateSalt } from "../crypto/argon2";13import { extractDomain } from "../shared/domain";14import type {15 AccentColorName,16 LegacyVaultEntry,17 ManualSummonRecord,18 SavedIdentity,19 StoredVault,20 VaultEntry,21} from "../shared/types";22import { decrypt, encrypt, zeroKey } from "./aes";23import { clearAutoLock, touchAutoLock } from "./alarms";2425type EntryDraft = Omit<26 VaultEntry,27 "id" | "createdAt" | "modifiedAt" | "deletedAt"28>;293031323334const SLOT_A = "peach_vault_a";35const SLOT_B = "peach_vault_b";363738const LEGACY_MAIN = "peach_vault";39const LEGACY_DECOY = "peach_decoy_vault";40const LEGACY_DURESS_FLAG = "peach_duress";41424344const PAD_BLOCK = 1024;4546let derivedKey: Uint8Array | null = null;47let entries: VaultEntry[] = [];48let domainIndex: Map<string, VaultEntry[]> = new Map();49let vaultMode: "real" | "decoy" = "real";50515253let duressConfigured = false;5455let migrationRan = false;565758596061export type DisplayFont = "cormorant-italic" | "cormorant" | "inter" | "system";62export type TextSize = "small" | "medium" | "large";6364export interface VaultSettings {65 accentColor?: AccentColorName;66 theme?: "dark" | "light";67 autoLockMinutes?: number;68 clipboardClearSeconds?: number;69 lockOnClose?: boolean;70 breachMonitoring?: boolean;71 passkeyProviderEnabled?: boolean;72 trashRetentionDays?: number;73 manualSummonDomains?: Record<string, ManualSummonRecord>;74 declinedCardFingerprints?: string[];75 7677787980 displayFont?: DisplayFont;81 8283848586 textSize?: TextSize;87 8889909192 reduceMotion?: boolean;93 949596979899100 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;101}102103let settings: VaultSettings = {};104105export function getSettings(): VaultSettings {106 return settings;107}108109export async function ensureSettingsLoaded(): Promise<VaultSettings> {110 await loadSettings();111 return settings;112}113114export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {115 await loadSettings();116 settings = { ...settings, ...patch };117 await persistSettings();118}119120async function persistSettings(): Promise<void> {121 try {122 await chrome.storage.local.set({ peach_settings: settings });123 } catch (err) {124 throw mapStorageError(err);125 }126}127128function mapStorageError(err: unknown): Error {129 const message = err instanceof Error ? err.message : String(err);130 if (/QUOTA_BYTES|quota/i.test(message)) {131 return new Error("STORAGE_QUOTA_EXCEEDED");132 }133 return err instanceof Error ? err : new Error(message);134}135136async function loadSettings(): Promise<void> {137 const result = await chrome.storage.local.get("peach_settings");138 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};139}140141142143144145export async function checkBreach(password: string): Promise<number> {146 const encoder = new TextEncoder();147 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));148 const hashHex = Array.from(new Uint8Array(hashBuffer))149 .map((b) => b.toString(16).padStart(2, "0"))150 .join("")151 .toUpperCase();152153 const prefix = hashHex.slice(0, 5);154 const suffix = hashHex.slice(5);155156 try {157 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);158 if (!res.ok) return 0;159 const text = await res.text();160 const match = text.split("\r\n").find((l) => l.startsWith(suffix));161 return match ? parseInt(match.split(":")[1], 10) : 0;162 } catch {163 return 0; 164 }165}166167export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {168 const fingerprint = await cardFingerprint(last4, expMonth, expYear);169 await loadSettings();170 settings = {171 ...settings,172 declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],173 };174 await persistSettings();175}176177export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {178 await loadSettings();179 const fingerprint = await cardFingerprint(last4, expMonth, expYear);180 return (settings.declinedCardFingerprints ?? []).includes(fingerprint);181}182183async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {184 const encoder = new TextEncoder();185 const buf = await crypto.subtle.digest("SHA-1", encoder.encode(`${last4}${expMonth}${expYear}`));186 return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, "0")).join("");187}188189function hex(buf: Uint8Array): string {190 return Array.from(buf)191 .map((b) => b.toString(16).padStart(2, "0"))192 .join("");193}194195function unhex(s: string): Uint8Array {196 const out = new Uint8Array(s.length / 2);197 for (let i = 0; i < s.length; i += 2) {198 out[i / 2] = parseInt(s.slice(i, i + 2), 16);199 }200 return out;201}202203204205206207async function loadSlot(key: string): Promise<StoredVault | null> {208 const result = await chrome.storage.local.get(key);209 return (result as Record<string, StoredVault | undefined>)[key] ?? null;210}211212async function writeSlot(key: string, vault: StoredVault): Promise<void> {213 try {214 await chrome.storage.local.set({ [key]: vault });215 } catch (err) {216 throw mapStorageError(err);217 }218}219220221222223224225async function ensureMigrated(): Promise<void> {226 if (migrationRan) return;227 migrationRan = true;228229 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];230 const raw = await chrome.storage.local.get(keys);231 const slotAExists = raw[SLOT_A] !== undefined;232 const slotBExists = raw[SLOT_B] !== undefined;233 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;234 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;235236 237 if (!slotAExists && !legacyMain) return;238239 240 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;241242 if (legacyMain && !slotAExists) {243 await writeSlot(SLOT_A, legacyMain);244 }245246 247 if (!slotBExists) {248 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;249 if (legacyDecoy) {250 await writeSlot(SLOT_B, legacyDecoy);251 } else if (target) {252 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));253 }254 }255256 const toRemove: string[] = [];257 if (legacyMain) toRemove.push(LEGACY_MAIN);258 if (legacyDecoy) toRemove.push(LEGACY_DECOY);259 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);260 if (toRemove.length > 0) {261 await chrome.storage.local.remove(toRemove);262 }263}264265266267268269270271272273274275function padPlaintext(json: string): string {276 const encoded = new TextEncoder().encode(json);277 const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);278 const padLen = target - encoded.length;279 return json + " ".repeat(padLen);280}281282283284285286287288289290291292const CSPRNG_CHUNK = 65_536;293294function fillRandom(buf: Uint8Array): Uint8Array {295 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {296 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);297 const chunk = new Uint8Array(end - offset);298 crypto.getRandomValues(chunk);299 buf.set(chunk, offset);300 }301 return buf;302}303304function generateNoiseBlob(ciphertextBytes: number): StoredVault {305 const salt = crypto.getRandomValues(new Uint8Array(16));306 const nonce = crypto.getRandomValues(new Uint8Array(12));307 const cipher = fillRandom(new Uint8Array(ciphertextBytes));308 const tag = crypto.getRandomValues(new Uint8Array(16));309 return {310 version: 1,311 salt: hex(salt),312 nonce: hex(nonce),313 ciphertext: hex(cipher),314 tag: hex(tag),315 };316}317318319320321322type VaultPayload = {323 entries: VaultEntry[];324 duressConfigured?: boolean;325};326327type DecryptedPayload = {328 entries: VaultEntry[];329 duressConfigured: boolean;330};331332function encodePayload(entries: VaultEntry[], duress: boolean): string {333 const envelope: VaultPayload = { entries, duressConfigured: duress };334 return padPlaintext(JSON.stringify(envelope));335}336337function decodePayload(plain: string): DecryptedPayload {338 const parsed = JSON.parse(plain) as339 | Array<VaultEntry | LegacyVaultEntry>340 | VaultPayload;341342 if (Array.isArray(parsed)) {343 344 return {345 entries: parsed.map(normalizeEntry),346 duressConfigured: false,347 };348 }349350 return {351 entries: (parsed.entries ?? []).map(normalizeEntry),352 duressConfigured: parsed.duressConfigured === true,353 };354}355356async function tryDecryptSlot(357 vault: StoredVault,358 key: Uint8Array359): Promise<DecryptedPayload | null> {360 try {361 const plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);362 return decodePayload(plain);363 } catch {364 return null;365 }366}367368369370371372function buildDomainIndexSync(all: VaultEntry[]): void {373 domainIndex = new Map();374375 for (const entry of all) {376 if (entry.deletedAt) continue;377 if (entry.type === "note" || entry.type === "passkey" || entry.type === "card") continue;378379 380 381 382 383 const domains = new Set<string>();384 const primary = extractDomain(entry.url ?? "");385 if (primary) domains.add(primary);386 for (const extra of entry.additionalUrls ?? []) {387 const d = extractDomain(extra);388 if (d) domains.add(d);389 }390 if (domains.size === 0) continue;391392 for (const domain of domains) {393 const bucket = domainIndex.get(domain) ?? [];394 bucket.push(entry);395 domainIndex.set(domain, bucket);396 }397 }398}399400export async function createVault(password: string): Promise<void> {401 await ensureMigrated();402403 const salt = generateSalt();404 derivedKey = await deriveKey(password, salt);405 entries = [];406 vaultMode = "real";407 duressConfigured = false;408 buildDomainIndexSync(entries);409410 const plaintext = encodePayload(entries, duressConfigured);411 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);412413 const slotA: StoredVault = {414 version: 1,415 salt: hex(salt),416 nonce,417 ciphertext,418 tag,419 };420 await writeSlot(SLOT_A, slotA);421422 423 424 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));425426 touchAutoLock();427}428429export async function hasVault(): Promise<boolean> {430 await ensureMigrated();431 return (await loadSlot(SLOT_A)) !== null;432}433434435436437438439export async function isDuressEnabled(): Promise<boolean> {440 return vaultMode === "real" && duressConfigured;441}442443export async function hasDecoyVault(): Promise<boolean> {444 return isDuressEnabled();445}446447export async function setupDuress(duressPassword: string): Promise<void> {448 if (!derivedKey || vaultMode !== "real") {449 throw new Error("vault must be unlocked in real mode");450 }451 await ensureMigrated();452453 454 455 456 const slotA = await loadSlot(SLOT_A);457 if (!slotA) throw new Error("missing slot A");458459 const decoySalt = generateSalt();460 const decoyKey = await deriveKey(duressPassword, decoySalt);461 try {462 463 464 const targetBytes = slotA.ciphertext.length / 2;465 let plaintext = encodePayload([], false);466 const encoded = new TextEncoder().encode(plaintext);467 if (encoded.length < targetBytes) {468 plaintext = plaintext + " ".repeat(targetBytes - encoded.length);469 }470 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);471 await writeSlot(SLOT_B, {472 version: 1,473 salt: hex(decoySalt),474 nonce,475 ciphertext,476 tag,477 });478 } finally {479 zeroKey(decoyKey);480 }481482 483 484 duressConfigured = true;485 await persistSlotA();486}487488export async function disableDuress(): Promise<void> {489 if (!derivedKey || vaultMode !== "real") {490 throw new Error("vault must be unlocked in real mode");491 }492 await ensureMigrated();493494 495 496 497 const slotA = await loadSlot(SLOT_A);498 if (slotA) {499 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));500 }501502 duressConfigured = false;503 await persistSlotA();504}505506export async function verifyMainPassword(password: string): Promise<boolean> {507 await ensureMigrated();508 const slotA = await loadSlot(SLOT_A);509 if (!slotA) return false;510 const key = await deriveKey(password, unhex(slotA.salt));511 try {512 return (await tryDecryptSlot(slotA, key)) !== null;513 } finally {514 zeroKey(key);515 }516}517518export async function unlock(password: string): Promise<boolean> {519 await ensureMigrated();520521 522 523 524 525 526 const [slotA, slotB] = await Promise.all([527 loadSlot(SLOT_A),528 loadSlot(SLOT_B),529 ]);530531 532 const [keyA, keyB] = await Promise.all([533 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),534 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),535 ]);536537 538 const [payloadA, payloadB] = await Promise.all([539 keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),540 keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),541 ]);542543 if (payloadA && keyA) {544 derivedKey = keyA;545 vaultMode = "real";546 entries = payloadA.entries;547 duressConfigured = payloadA.duressConfigured;548 buildDomainIndexSync(entries);549 await loadSettings();550 touchAutoLock();551 if (keyB) zeroKey(keyB);552 553 554 if (hasLegacyShape(payloadA.entries)) {555 await persistSlotA();556 }557 return true;558 }559560 if (payloadB && keyB) {561 derivedKey = keyB;562 vaultMode = "decoy";563 entries = payloadB.entries;564 duressConfigured = false;565 buildDomainIndexSync(entries);566 await loadSettings();567 touchAutoLock();568 if (keyA) zeroKey(keyA);569 return true;570 }571572 if (keyA) zeroKey(keyA);573 if (keyB) zeroKey(keyB);574 return false;575}576577export function lock(): void {578 if (derivedKey) {579 zeroKey(derivedKey);580 derivedKey = null;581 }582583 entries = [];584 domainIndex = new Map();585 vaultMode = "real";586 duressConfigured = false;587 clearAutoLock();588}589590export function isUnlocked(): boolean {591 return derivedKey !== null;592}593594export function getEntries(): VaultEntry[] {595 if (!derivedKey) throw new Error("vault locked");596 return entries.filter((entry) => !entry.deletedAt);597}598599const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;600601export function getIdentities(): SavedIdentity[] {602 if (!derivedKey) throw new Error("vault locked");603 const active = entries.filter((e) => !e.deletedAt && e.type === "login");604605 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();606 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();607608 for (const entry of active) {609 const value = entry.username?.trim();610 if (!value) continue;611 const map = EMAIL_RE.test(value) ? emailMap : usernameMap;612 const existing = map.get(value);613 const domain = extractDomain(entry.url ?? "") ?? "";614 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;615 if (!existing || modifiedAt > existing.lastUsed) {616 map.set(value, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });617 } else {618 map.set(value, { ...existing, count: existing.count + 1 });619 }620 }621622 const toIdentity = (type: "email" | "username") =>623 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({624 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,625 });626627 return [628 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),629 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),630 ];631}632633export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {634 if (!derivedKey) throw new Error("vault locked");635636 const now = Date.now();637 const added: VaultEntry[] = [];638 for (const draft of drafts) {639 try {640 const sanitized = sanitizeEntryDraft(draft);641 const nextEntry: VaultEntry = {642 ...sanitized,643 id: crypto.randomUUID(),644 createdAt: now,645 modifiedAt: now,646 };647 entries.push(nextEntry);648 added.push(nextEntry);649 } catch {650 651 }652 }653654 if (added.length > 0) {655 await persistEntries();656 }657658 return added;659}660661662663664665666667668export async function restoreEntries(669 incoming: VaultEntry[],670 mode: "replace" | "merge"671): Promise<{ added: number; updated: number; skipped: number }> {672 if (!derivedKey) throw new Error("vault locked");673674 675 676 677 if (mode === "replace" && incoming.length === 0) {678 throw new Error("refuse to replace with empty entry set");679 }680681 let added = 0;682 let updated = 0;683 let skipped = 0;684685 if (mode === "replace") {686 entries = [];687 for (const entry of incoming) {688 const normalized = sanitizeRestoredEntry(entry);689 if (!normalized) {690 skipped += 1;691 continue;692 }693 entries.push(normalized);694 added += 1;695 }696 } else {697 const byId = new Map<string, VaultEntry>();698 for (const e of entries) byId.set(e.id, e);699700 for (const entry of incoming) {701 const normalized = sanitizeRestoredEntry(entry);702 if (!normalized) {703 skipped += 1;704 continue;705 }706 const existing = byId.get(normalized.id);707 if (!existing) {708 entries.push(normalized);709 added += 1;710 } else if (normalized.modifiedAt > existing.modifiedAt) {711 const idx = entries.findIndex((e) => e.id === existing.id);712 if (idx !== -1) {713 entries[idx] = normalized;714 updated += 1;715 }716 } else {717 skipped += 1;718 }719 }720 }721722 await persistEntries();723 return { added, updated, skipped };724}725726727728729730731function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {732 if (!entry || typeof entry !== "object") return null;733 const title = typeof entry.title === "string" ? entry.title.trim() : "";734 if (!title) return null;735 const type = entry.type ?? "login";736 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();737 const now = Date.now();738 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;739 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;740741 if (type === "login" && typeof entry.password !== "string") {742 return null;743 }744 if (745 type === "passkey" &&746 (typeof entry.rpId !== "string" ||747 typeof entry.credentialId !== "string" ||748 !entry.privateKeyJwk)749 ) {750 return null;751 }752753 return {754 ...entry,755 id,756 title,757 type,758 createdAt,759 modifiedAt,760 };761}762763764765766767export async function clearVaultEntries(768 confirmation: "REPLACE_ALL"769): Promise<void> {770 if (confirmation !== "REPLACE_ALL") {771 throw new Error("clearVaultEntries requires explicit confirmation");772 }773 if (!derivedKey) throw new Error("vault locked");774 for (const entry of entries) {775 if (!entry.deletedAt) {776 entry.deletedAt = Date.now();777 entry.modifiedAt = Date.now();778 }779 }780 await persistEntries();781}782783export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {784 if (!derivedKey) throw new Error("vault locked");785786 const sanitized = sanitizeEntryDraft(entry);787 const now = Date.now();788 const nextEntry: VaultEntry = {789 ...sanitized,790 id: crypto.randomUUID(),791 createdAt: now,792 modifiedAt: now,793 794 795 passwordChangedAt: sanitized.password ? now : undefined,796 };797798 entries.push(nextEntry);799 await persistEntries();800801 802 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {803 checkBreach(nextEntry.password).then(async (count) => {804 if (count > 0) {805 const idx = entries.findIndex((e) => e.id === nextEntry.id);806 if (idx !== -1) {807 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };808 await persistEntries();809 }810 }811 });812 }813814 return nextEntry;815}816817export async function updateEntry(818 id: string,819 patch: Partial<VaultEntry>820): Promise<void> {821 if (!derivedKey) throw new Error("vault locked");822823 const idx = entries.findIndex((entry) => entry.id === id);824 if (idx === -1 || entries[idx].deletedAt) {825 throw new Error("entry not found");826 }827828 const current = entries[idx];829 const sanitizedPatch = sanitizeEntryPatch(patch);830 831 832 833 const passwordChanged =834 "password" in patch &&835 typeof sanitizedPatch.password === "string" &&836 sanitizedPatch.password !== current.password;837 entries[idx] = {838 ...current,839 ...sanitizedPatch,840 modifiedAt: Date.now(),841 ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),842 };843844 845 846 847 if (entries[idx].type === "login" && !entries[idx].password) {848 entries[idx] = current;849 throw new Error("password is required for login entries");850 }851852 853 if (entries[idx].type === "card" && !entries[idx].cardNumber) {854 entries[idx] = current;855 throw new Error("card number is required for card entries");856 }857858 await persistEntries();859860 861 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login" && entries[idx].password) {862 const password = entries[idx].password!;863 checkBreach(password).then(async (count) => {864 const i = entries.findIndex((e) => e.id === id);865 if (i !== -1) {866 entries[i] = { ...entries[i], breach: count > 0 ? count : undefined, modifiedAt: Date.now() };867 await persistEntries();868 }869 });870 }871}872873export async function deleteEntry(id: string): Promise<void> {874 if (!derivedKey) throw new Error("vault locked");875876 const entry = entries.find((item) => item.id === id);877 if (!entry || entry.deletedAt) {878 throw new Error("entry not found");879 }880881 entry.deletedAt = Date.now();882 entry.modifiedAt = Date.now();883 await persistEntries();884}885886export function getDeletedEntries(): VaultEntry[] {887 if (!derivedKey) return [];888 return entries.filter((e) => !!e.deletedAt);889}890891export async function restoreEntry(id: string): Promise<void> {892 if (!derivedKey) throw new Error("vault locked");893 const entry = entries.find((e) => e.id === id && e.deletedAt);894 if (!entry) throw new Error("deleted entry not found");895 delete entry.deletedAt;896 entry.modifiedAt = Date.now();897 await persistEntries();898 buildDomainIndexSync(entries);899}900901export async function purgeEntry(id: string): Promise<void> {902 if (!derivedKey) throw new Error("vault locked");903 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);904 if (idx === -1) throw new Error("deleted entry not found");905 entries.splice(idx, 1);906 await persistEntries();907}908909910export async function purgeExpiredTrash(retentionMs: number): Promise<number> {911 if (!derivedKey || retentionMs === 0) return 0;912 const cutoff = Date.now() - retentionMs;913 const before = entries.length;914 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));915 const purged = before - entries.length;916 if (purged > 0) await persistEntries();917 return purged;918}919920export function queryDomain(domain: string): VaultEntry[] {921 return domainIndex.get(domain) ?? [];922}923924export function getEntryById(id: string): VaultEntry | undefined {925 return entries.find((entry) => entry.id === id && !entry.deletedAt);926}927928async function persistSlotA(): Promise<StoredVault> {929 if (!derivedKey) throw new Error("vault locked");930931 const slotA = await loadSlot(SLOT_A);932 if (!slotA) throw new Error("missing slot A");933934 const plaintext = encodePayload(entries, duressConfigured);935 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);936937 const nextSlotA: StoredVault = {938 ...slotA,939 nonce,940 ciphertext,941 tag,942 };943 await writeSlot(SLOT_A, nextSlotA);944945 946 947 948 if (!duressConfigured) {949 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));950 }951952 return nextSlotA;953}954955async function persistSlotB(): Promise<void> {956 if (!derivedKey) throw new Error("vault locked");957958 const slotB = await loadSlot(SLOT_B);959 if (!slotB) throw new Error("missing slot B");960961 962 963 const plaintext = encodePayload(entries, false);964 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);965966 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });967}968969async function persistEntries(): Promise<void> {970 if (!derivedKey) throw new Error("vault locked");971972 if (vaultMode === "decoy") {973 await persistSlotB();974 } else {975 const freshSlotA = await persistSlotA();976 977 978 const activeCount = entries.filter((e) => !e.deletedAt).length;979 void takeSnapshot(freshSlotA, activeCount);980 }981 buildDomainIndexSync(entries);982}983984function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {985 if (!Array.isArray(values)) return undefined;986 const seen = new Set<string>();987 const cleaned: string[] = [];988 for (const value of values) {989 const trimmed = typeof value === "string" ? value.trim() : "";990 if (!trimmed) continue;991 const key = trimmed.toLowerCase();992 if (seen.has(key)) continue;993 seen.add(key);994 cleaned.push(trimmed);995 }996 return cleaned.length > 0 ? cleaned : undefined;997}998999function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1000 const title = entry.title.trim();1001 const type = entry.type ?? "login";10021003 if (!title) throw new Error("title is required");1004 if (type === "login" && !entry.password?.trim()) {1005 throw new Error("password is required");1006 }1007 if (type === "passkey") {1008 if (1009 !entry.rpId?.trim() ||1010 !entry.rpName?.trim() ||1011 !entry.credentialId?.trim() ||1012 !entry.publicKeyCose?.trim() ||1013 !entry.privateKeyJwk ||1014 !entry.userHandle?.trim() ||1015 !entry.userName?.trim() ||1016 !entry.userDisplayName?.trim() ||1017 (entry.algorithm !== -7 && entry.algorithm !== -8)1018 ) {1019 throw new Error("invalid passkey entry");1020 }1021 }1022 if (type === "card") {1023 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1024 throw new Error("cardholder name and card number are required");1025 }1026 }10271028 return {1029 type,1030 title,1031 username: type === "login" ? entry.username?.trim() ?? "" : undefined,1032 password: type === "login" ? entry.password : undefined,1033 url: type === "login" ? entry.url?.trim() ?? "" : undefined,1034 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1035 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1036 notes: type === "passkey" ? undefined : entry.notes?.trim() || undefined,1037 breach: type === "login" ? entry.breach : undefined,1038 canary: type === "login" ? entry.canary : undefined,1039 tags: entry.tags,1040 rpId: type === "passkey" ? entry.rpId?.trim() : undefined,1041 rpName: type === "passkey" ? entry.rpName?.trim() : undefined,1042 credentialId: type === "passkey" ? entry.credentialId?.trim() : undefined,1043 publicKeyCose: type === "passkey" ? entry.publicKeyCose?.trim() : undefined,1044 privateKeyJwk: type === "passkey" ? entry.privateKeyJwk : undefined,1045 userHandle: type === "passkey" ? entry.userHandle?.trim() : undefined,1046 userName: type === "passkey" ? entry.userName?.trim() : undefined,1047 userDisplayName: type === "passkey" ? entry.userDisplayName?.trim() : undefined,1048 algorithm: type === "passkey" ? entry.algorithm : undefined,1049 signCount: type === "passkey" ? entry.signCount ?? 0 : undefined,1050 aaguid: type === "passkey" ? entry.aaguid?.trim() : undefined,1051 backupEligible: type === "passkey" ? entry.backupEligible === true : undefined,1052 backupState: type === "passkey" ? entry.backupState === true : undefined,1053 lastUsedAt: type === "passkey" ? entry.lastUsedAt : undefined,1054 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1055 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1056 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1057 expYear: type === "card" ? entry.expYear?.trim() : undefined,1058 cvc: type === "card" ? entry.cvc?.trim() : undefined,1059 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1060 network: type === "card" ? entry.network : undefined,1061 last4: type === "card" ? entry.last4?.trim() : undefined,1062 };1063}10641065function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1066 const nextPatch: Partial<VaultEntry> = {};10671068 if ("type" in patch) {1069 nextPatch.type = patch.type;1070 }10711072 if ("title" in patch) {1073 const title = patch.title?.trim() ?? "";1074 if (!title) throw new Error("title is required");1075 nextPatch.title = title;1076 }10771078 if ("url" in patch) {1079 nextPatch.url = patch.url?.trim() ?? "";1080 }10811082 if ("additionalUrls" in patch) {1083 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1084 const seen = new Set<string>();1085 const cleaned: string[] = [];1086 for (const value of incoming) {1087 const trimmed = typeof value === "string" ? value.trim() : "";1088 if (!trimmed) continue;1089 const key = trimmed.toLowerCase();1090 if (seen.has(key)) continue;1091 seen.add(key);1092 cleaned.push(trimmed);1093 }1094 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1095 }10961097 if ("username" in patch) {1098 nextPatch.username = patch.username?.trim() ?? "";1099 }11001101 if ("password" in patch) {1102 const password = patch.password ?? "";1103 1104 nextPatch.password = password || undefined;1105 }11061107 if ("totp" in patch) {1108 nextPatch.totp = patch.totp?.trim() || undefined;1109 }11101111 if ("notes" in patch) {1112 nextPatch.notes = patch.notes?.trim() || undefined;1113 }11141115 if ("breach" in patch) {1116 nextPatch.breach = patch.breach;1117 }11181119 if ("canary" in patch) {1120 nextPatch.canary = patch.canary;1121 }11221123 if ("rpId" in patch) {1124 nextPatch.rpId = patch.rpId?.trim();1125 }11261127 if ("rpName" in patch) {1128 nextPatch.rpName = patch.rpName?.trim();1129 }11301131 if ("credentialId" in patch) {1132 nextPatch.credentialId = patch.credentialId?.trim();1133 }11341135 if ("publicKeyCose" in patch) {1136 nextPatch.publicKeyCose = patch.publicKeyCose?.trim();1137 }11381139 if ("privateKeyJwk" in patch) {1140 nextPatch.privateKeyJwk = patch.privateKeyJwk;1141 }11421143 if ("userHandle" in patch) {1144 nextPatch.userHandle = patch.userHandle?.trim();1145 }11461147 if ("userName" in patch) {1148 nextPatch.userName = patch.userName?.trim();1149 }11501151 if ("userDisplayName" in patch) {1152 nextPatch.userDisplayName = patch.userDisplayName?.trim();1153 }11541155 if ("algorithm" in patch) {1156 nextPatch.algorithm = patch.algorithm;1157 }11581159 if ("signCount" in patch) {1160 nextPatch.signCount = patch.signCount;1161 }11621163 if ("aaguid" in patch) {1164 nextPatch.aaguid = patch.aaguid?.trim();1165 }11661167 if ("backupEligible" in patch) {1168 nextPatch.backupEligible = patch.backupEligible;1169 }11701171 if ("backupState" in patch) {1172 nextPatch.backupState = patch.backupState;1173 }11741175 if ("lastUsedAt" in patch) {1176 nextPatch.lastUsedAt = patch.lastUsedAt;1177 }11781179 if ("tags" in patch) {1180 nextPatch.tags = patch.tags;1181 }11821183 if ("cardholderName" in patch) {1184 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1185 }1186 if ("cardNumber" in patch) {1187 const n = patch.cardNumber ?? "";1188 nextPatch.cardNumber = n || undefined;1189 }1190 if ("expMonth" in patch) {1191 nextPatch.expMonth = patch.expMonth?.trim() || undefined;1192 }1193 if ("expYear" in patch) {1194 nextPatch.expYear = patch.expYear?.trim() || undefined;1195 }1196 if ("cvc" in patch) {1197 nextPatch.cvc = patch.cvc?.trim() || undefined;1198 }1199 if ("cardNickname" in patch) {1200 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1201 }1202 if ("network" in patch) {1203 nextPatch.network = patch.network;1204 }1205 if ("last4" in patch) {1206 nextPatch.last4 = patch.last4?.trim() || undefined;1207 }12081209 return nextPatch;1210}12111212function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1213 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1214 const modifiedAt =1215 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;12161217 return {1218 id: entry.id,1219 type: "type" in entry ? entry.type : "login",1220 title: entry.title,1221 username: entry.username,1222 password: entry.password,1223 url: entry.url,1224 totp: entry.totp,1225 notes: entry.notes,1226 breach: entry.breach,1227 canary: "canary" in entry ? entry.canary : undefined,1228 rpId: "rpId" in entry ? entry.rpId : undefined,1229 rpName: "rpName" in entry ? entry.rpName : undefined,1230 credentialId: "credentialId" in entry ? entry.credentialId : undefined,1231 publicKeyCose: "publicKeyCose" in entry ? entry.publicKeyCose : undefined,1232 privateKeyJwk: "privateKeyJwk" in entry ? entry.privateKeyJwk : undefined,1233 userHandle: "userHandle" in entry ? entry.userHandle : undefined,1234 userName: "userName" in entry ? entry.userName : undefined,1235 userDisplayName: "userDisplayName" in entry ? entry.userDisplayName : undefined,1236 algorithm: "algorithm" in entry ? entry.algorithm : undefined,1237 signCount: "signCount" in entry ? entry.signCount : undefined,1238 aaguid: "aaguid" in entry ? entry.aaguid : undefined,1239 backupEligible: "backupEligible" in entry ? entry.backupEligible : undefined,1240 backupState: "backupState" in entry ? entry.backupState : undefined,1241 lastUsedAt: "lastUsedAt" in entry ? entry.lastUsedAt : undefined,1242 createdAt,1243 modifiedAt,1244 deletedAt: entry.deletedAt,1245 tags: "tags" in entry ? entry.tags : undefined,1246 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1247 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1248 expMonth: "expMonth" in entry ? entry.expMonth : undefined,1249 expYear: "expYear" in entry ? entry.expYear : undefined,1250 cvc: "cvc" in entry ? entry.cvc : undefined,1251 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1252 network: "network" in entry ? entry.network : undefined,1253 last4: "last4" in entry ? entry.last4 : undefined,1254 };1255}12561257function hasLegacyShape(parsed: VaultEntry[]): boolean {1258 return parsed.some(1259 (entry) => "created" in entry || "updated" in entry1260 );1261}126212631264126512661267function entryDupeKey(e: VaultEntry): string {1268 if (e.type === "note") return `note|${e.title.toLowerCase()}`;1269 if (e.type === "passkey") return `passkey|${(e.rpId ?? "").toLowerCase()}|${(e.userName ?? "").toLowerCase()}`;1270 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1271}127212731274export function countDuplicates(): number {1275 const best = new Map<string, VaultEntry>();1276 let dupeCount = 0;1277 for (const e of entries.filter((e) => !e.deletedAt)) {1278 const key = entryDupeKey(e);1279 const existing = best.get(key);1280 if (!existing) {1281 best.set(key, e);1282 } else {1283 dupeCount++;1284 1285 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1286 best.set(key, e);1287 }1288 }1289 }1290 return dupeCount;1291}129212931294129512961297export async function deduplicateEntries(): Promise<number> {1298 if (!derivedKey) throw new Error("vault locked");1299 1300 1301 if (vaultMode === "decoy") {1302 throw new Error("deduplicate disabled in decoy mode");1303 }13041305 const best = new Map<string, VaultEntry>();1306 for (const e of entries.filter((en) => !en.deletedAt)) {1307 const key = entryDupeKey(e);1308 const existing = best.get(key);1309 if (!existing) {1310 best.set(key, e);1311 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1312 best.set(key, e);1313 }1314 }13151316 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1317 const now = Date.now();1318 let removed = 0;1319 entries = entries.map((e) => {1320 if (!e.deletedAt && !keepIds.has(e.id)) {1321 removed++;1322 return { ...e, deletedAt: now };1323 }1324 return e;1325 });13261327 if (removed > 0) await persistEntries();1328 return removed;1329}133013311332133313341335const SNAPSHOT_KEY = "peach_vault_snapshots";1336const MAX_SNAPSHOTS = 6;13371338interface VaultSnapshot {1339 timestamp: number;1340 entryCount: number;1341 slot: StoredVault;1342}134313441345export interface SnapshotSummary {1346 index: number;1347 timestamp: number;1348 entryCount: number;1349}13501351async function takeSnapshot(1352 slot: StoredVault,1353 entryCount: number1354): Promise<void> {1355 try {1356 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1357 const existing: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1358 const next: VaultSnapshot[] = [1359 { timestamp: Date.now(), entryCount, slot },1360 ...existing,1361 ].slice(0, MAX_SNAPSHOTS);1362 await chrome.storage.local.set({ [SNAPSHOT_KEY]: next });1363 } catch {1364 1365 }1366}13671368export async function listSnapshots(): Promise<SnapshotSummary[]> {1369 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1370 const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1371 return snapshots.map((s, i) => ({1372 index: i,1373 timestamp: s.timestamp,1374 entryCount: s.entryCount,1375 }));1376}13771378137913801381138213831384export async function restoreFromSnapshot(1385 index: number,1386 password: string1387): Promise<boolean> {1388 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1389 const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1390 const snapshot = snapshots[index];1391 if (!snapshot) return false;13921393 1394 const key = await deriveKey(password, unhex(snapshot.slot.salt));1395 const payload = await tryDecryptSlot(snapshot.slot, key);1396 if (!payload) {1397 zeroKey(key);1398 return false;1399 }14001401 1402 await writeSlot(SLOT_A, snapshot.slot);1403 await writeSlot(SLOT_B, generateNoiseBlob(snapshot.slot.ciphertext.length / 2));14041405 1406 if (derivedKey) zeroKey(derivedKey);1407 derivedKey = key;1408 entries = payload.entries;1409 duressConfigured = payload.duressConfigured;1410 vaultMode = "real";1411 buildDomainIndexSync(entries);1412 await loadSettings();1413 touchAutoLock();14141415 return true;1416}14171418141914201421142214231424export async function changePassword(1425 currentPassword: string,1426 newPassword: string1427): Promise<boolean> {1428 if (!derivedKey || vaultMode !== "real") return false;14291430 1431 const valid = await verifyMainPassword(currentPassword);1432 if (!valid) return false;14331434 const slotA = await loadSlot(SLOT_A);1435 if (!slotA) return false;14361437 1438 const newSalt = generateSalt();1439 const newKey = await deriveKey(newPassword, newSalt);14401441 1442 const plaintext = encodePayload(entries, duressConfigured);1443 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);14441445 const freshSlotA: StoredVault = {1446 ...slotA,1447 salt: hex(newSalt),1448 nonce,1449 ciphertext,1450 tag,1451 };1452 await writeSlot(SLOT_A, freshSlotA);14531454 1455 1456 1457 zeroKey(derivedKey);1458 derivedKey = newKey;14591460 1461 1462 1463 const activeCount = entries.filter((e) => !e.deletedAt).length;1464 void takeSnapshot(freshSlotA, activeCount);14651466 1467 1468 1469 if (!duressConfigured) {1470 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1471 }14721473 return true;1474}