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 interface VaultSettings {62 accentColor?: AccentColorName;63 theme?: "dark" | "light";64 autoLockMinutes?: number;65 clipboardClearSeconds?: number;66 lockOnClose?: boolean;67 breachMonitoring?: boolean;68 passkeyProviderEnabled?: boolean;69 trashRetentionDays?: number;70 manualSummonDomains?: Record<string, ManualSummonRecord>;71 declinedCardFingerprints?: string[];72 73747576777879 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;80}8182let settings: VaultSettings = {};8384export function getSettings(): VaultSettings {85 return settings;86}8788export async function ensureSettingsLoaded(): Promise<VaultSettings> {89 await loadSettings();90 return settings;91}9293export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {94 await loadSettings();95 settings = { ...settings, ...patch };96 await persistSettings();97}9899async function persistSettings(): Promise<void> {100 try {101 await chrome.storage.local.set({ peach_settings: settings });102 } catch (err) {103 throw mapStorageError(err);104 }105}106107function mapStorageError(err: unknown): Error {108 const message = err instanceof Error ? err.message : String(err);109 if (/QUOTA_BYTES|quota/i.test(message)) {110 return new Error("STORAGE_QUOTA_EXCEEDED");111 }112 return err instanceof Error ? err : new Error(message);113}114115async function loadSettings(): Promise<void> {116 const result = await chrome.storage.local.get("peach_settings");117 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};118}119120121122123124export async function checkBreach(password: string): Promise<number> {125 const encoder = new TextEncoder();126 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));127 const hashHex = Array.from(new Uint8Array(hashBuffer))128 .map((b) => b.toString(16).padStart(2, "0"))129 .join("")130 .toUpperCase();131132 const prefix = hashHex.slice(0, 5);133 const suffix = hashHex.slice(5);134135 try {136 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);137 if (!res.ok) return 0;138 const text = await res.text();139 const match = text.split("\r\n").find((l) => l.startsWith(suffix));140 return match ? parseInt(match.split(":")[1], 10) : 0;141 } catch {142 return 0; 143 }144}145146export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {147 const fingerprint = await cardFingerprint(last4, expMonth, expYear);148 await loadSettings();149 settings = {150 ...settings,151 declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],152 };153 await persistSettings();154}155156export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {157 await loadSettings();158 const fingerprint = await cardFingerprint(last4, expMonth, expYear);159 return (settings.declinedCardFingerprints ?? []).includes(fingerprint);160}161162async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {163 const encoder = new TextEncoder();164 const buf = await crypto.subtle.digest("SHA-1", encoder.encode(`${last4}${expMonth}${expYear}`));165 return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, "0")).join("");166}167168function hex(buf: Uint8Array): string {169 return Array.from(buf)170 .map((b) => b.toString(16).padStart(2, "0"))171 .join("");172}173174function unhex(s: string): Uint8Array {175 const out = new Uint8Array(s.length / 2);176 for (let i = 0; i < s.length; i += 2) {177 out[i / 2] = parseInt(s.slice(i, i + 2), 16);178 }179 return out;180}181182183184185186async function loadSlot(key: string): Promise<StoredVault | null> {187 const result = await chrome.storage.local.get(key);188 return (result as Record<string, StoredVault | undefined>)[key] ?? null;189}190191async function writeSlot(key: string, vault: StoredVault): Promise<void> {192 try {193 await chrome.storage.local.set({ [key]: vault });194 } catch (err) {195 throw mapStorageError(err);196 }197}198199200201202203204async function ensureMigrated(): Promise<void> {205 if (migrationRan) return;206 migrationRan = true;207208 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];209 const raw = await chrome.storage.local.get(keys);210 const slotAExists = raw[SLOT_A] !== undefined;211 const slotBExists = raw[SLOT_B] !== undefined;212 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;213 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;214215 216 if (!slotAExists && !legacyMain) return;217218 219 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;220221 if (legacyMain && !slotAExists) {222 await writeSlot(SLOT_A, legacyMain);223 }224225 226 if (!slotBExists) {227 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;228 if (legacyDecoy) {229 await writeSlot(SLOT_B, legacyDecoy);230 } else if (target) {231 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));232 }233 }234235 const toRemove: string[] = [];236 if (legacyMain) toRemove.push(LEGACY_MAIN);237 if (legacyDecoy) toRemove.push(LEGACY_DECOY);238 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);239 if (toRemove.length > 0) {240 await chrome.storage.local.remove(toRemove);241 }242}243244245246247248249250251252253254function padPlaintext(json: string): string {255 const encoded = new TextEncoder().encode(json);256 const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);257 const padLen = target - encoded.length;258 return json + " ".repeat(padLen);259}260261262263264265266267function generateNoiseBlob(ciphertextBytes: number): StoredVault {268 const salt = crypto.getRandomValues(new Uint8Array(16));269 const nonce = crypto.getRandomValues(new Uint8Array(12));270 const cipher = crypto.getRandomValues(new Uint8Array(ciphertextBytes));271 const tag = crypto.getRandomValues(new Uint8Array(16));272 return {273 version: 1,274 salt: hex(salt),275 nonce: hex(nonce),276 ciphertext: hex(cipher),277 tag: hex(tag),278 };279}280281282283284285type VaultPayload = {286 entries: VaultEntry[];287 duressConfigured?: boolean;288};289290type DecryptedPayload = {291 entries: VaultEntry[];292 duressConfigured: boolean;293};294295function encodePayload(entries: VaultEntry[], duress: boolean): string {296 const envelope: VaultPayload = { entries, duressConfigured: duress };297 return padPlaintext(JSON.stringify(envelope));298}299300function decodePayload(plain: string): DecryptedPayload {301 const parsed = JSON.parse(plain) as302 | Array<VaultEntry | LegacyVaultEntry>303 | VaultPayload;304305 if (Array.isArray(parsed)) {306 307 return {308 entries: parsed.map(normalizeEntry),309 duressConfigured: false,310 };311 }312313 return {314 entries: (parsed.entries ?? []).map(normalizeEntry),315 duressConfigured: parsed.duressConfigured === true,316 };317}318319async function tryDecryptSlot(320 vault: StoredVault,321 key: Uint8Array322): Promise<DecryptedPayload | null> {323 try {324 const plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);325 return decodePayload(plain);326 } catch {327 return null;328 }329}330331332333334335function buildDomainIndexSync(all: VaultEntry[]): void {336 domainIndex = new Map();337338 for (const entry of all) {339 if (entry.deletedAt) continue;340 if (entry.type === "note" || entry.type === "passkey" || entry.type === "card") continue;341342 343 344 345 346 const domains = new Set<string>();347 const primary = extractDomain(entry.url ?? "");348 if (primary) domains.add(primary);349 for (const extra of entry.additionalUrls ?? []) {350 const d = extractDomain(extra);351 if (d) domains.add(d);352 }353 if (domains.size === 0) continue;354355 for (const domain of domains) {356 const bucket = domainIndex.get(domain) ?? [];357 bucket.push(entry);358 domainIndex.set(domain, bucket);359 }360 }361}362363export async function createVault(password: string): Promise<void> {364 await ensureMigrated();365366 const salt = generateSalt();367 derivedKey = await deriveKey(password, salt);368 entries = [];369 vaultMode = "real";370 duressConfigured = false;371 buildDomainIndexSync(entries);372373 const plaintext = encodePayload(entries, duressConfigured);374 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);375376 const slotA: StoredVault = {377 version: 1,378 salt: hex(salt),379 nonce,380 ciphertext,381 tag,382 };383 await writeSlot(SLOT_A, slotA);384385 386 387 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));388389 touchAutoLock();390}391392export async function hasVault(): Promise<boolean> {393 await ensureMigrated();394 return (await loadSlot(SLOT_A)) !== null;395}396397398399400401402export async function isDuressEnabled(): Promise<boolean> {403 return vaultMode === "real" && duressConfigured;404}405406export async function hasDecoyVault(): Promise<boolean> {407 return isDuressEnabled();408}409410export async function setupDuress(duressPassword: string): Promise<void> {411 if (!derivedKey || vaultMode !== "real") {412 throw new Error("vault must be unlocked in real mode");413 }414 await ensureMigrated();415416 417 418 419 const slotA = await loadSlot(SLOT_A);420 if (!slotA) throw new Error("missing slot A");421422 const decoySalt = generateSalt();423 const decoyKey = await deriveKey(duressPassword, decoySalt);424 try {425 426 427 const targetBytes = slotA.ciphertext.length / 2;428 let plaintext = encodePayload([], false);429 const encoded = new TextEncoder().encode(plaintext);430 if (encoded.length < targetBytes) {431 plaintext = plaintext + " ".repeat(targetBytes - encoded.length);432 }433 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);434 await writeSlot(SLOT_B, {435 version: 1,436 salt: hex(decoySalt),437 nonce,438 ciphertext,439 tag,440 });441 } finally {442 zeroKey(decoyKey);443 }444445 446 447 duressConfigured = true;448 await persistSlotA();449}450451export async function disableDuress(): Promise<void> {452 if (!derivedKey || vaultMode !== "real") {453 throw new Error("vault must be unlocked in real mode");454 }455 await ensureMigrated();456457 458 459 460 const slotA = await loadSlot(SLOT_A);461 if (slotA) {462 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));463 }464465 duressConfigured = false;466 await persistSlotA();467}468469export async function verifyMainPassword(password: string): Promise<boolean> {470 await ensureMigrated();471 const slotA = await loadSlot(SLOT_A);472 if (!slotA) return false;473 const key = await deriveKey(password, unhex(slotA.salt));474 try {475 return (await tryDecryptSlot(slotA, key)) !== null;476 } finally {477 zeroKey(key);478 }479}480481export async function unlock(password: string): Promise<boolean> {482 await ensureMigrated();483484 485 486 487 488 489 const [slotA, slotB] = await Promise.all([490 loadSlot(SLOT_A),491 loadSlot(SLOT_B),492 ]);493494 495 const [keyA, keyB] = await Promise.all([496 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),497 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),498 ]);499500 501 const [payloadA, payloadB] = await Promise.all([502 keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),503 keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),504 ]);505506 if (payloadA && keyA) {507 derivedKey = keyA;508 vaultMode = "real";509 entries = payloadA.entries;510 duressConfigured = payloadA.duressConfigured;511 buildDomainIndexSync(entries);512 await loadSettings();513 touchAutoLock();514 if (keyB) zeroKey(keyB);515 516 517 if (hasLegacyShape(payloadA.entries)) {518 await persistSlotA();519 }520 return true;521 }522523 if (payloadB && keyB) {524 derivedKey = keyB;525 vaultMode = "decoy";526 entries = payloadB.entries;527 duressConfigured = false;528 buildDomainIndexSync(entries);529 await loadSettings();530 touchAutoLock();531 if (keyA) zeroKey(keyA);532 return true;533 }534535 if (keyA) zeroKey(keyA);536 if (keyB) zeroKey(keyB);537 return false;538}539540export function lock(): void {541 if (derivedKey) {542 zeroKey(derivedKey);543 derivedKey = null;544 }545546 entries = [];547 domainIndex = new Map();548 vaultMode = "real";549 duressConfigured = false;550 clearAutoLock();551}552553export function isUnlocked(): boolean {554 return derivedKey !== null;555}556557export function getEntries(): VaultEntry[] {558 if (!derivedKey) throw new Error("vault locked");559 return entries.filter((entry) => !entry.deletedAt);560}561562const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;563564export function getIdentities(): SavedIdentity[] {565 if (!derivedKey) throw new Error("vault locked");566 const active = entries.filter((e) => !e.deletedAt && e.type === "login");567568 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();569 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();570571 for (const entry of active) {572 const value = entry.username?.trim();573 if (!value) continue;574 const map = EMAIL_RE.test(value) ? emailMap : usernameMap;575 const existing = map.get(value);576 const domain = extractDomain(entry.url ?? "") ?? "";577 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;578 if (!existing || modifiedAt > existing.lastUsed) {579 map.set(value, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });580 } else {581 map.set(value, { ...existing, count: existing.count + 1 });582 }583 }584585 const toIdentity = (type: "email" | "username") =>586 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({587 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,588 });589590 return [591 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),592 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),593 ];594}595596export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {597 if (!derivedKey) throw new Error("vault locked");598599 const now = Date.now();600 const added: VaultEntry[] = [];601 for (const draft of drafts) {602 try {603 const sanitized = sanitizeEntryDraft(draft);604 const nextEntry: VaultEntry = {605 ...sanitized,606 id: crypto.randomUUID(),607 createdAt: now,608 modifiedAt: now,609 };610 entries.push(nextEntry);611 added.push(nextEntry);612 } catch {613 614 }615 }616617 if (added.length > 0) {618 await persistEntries();619 }620621 return added;622}623624625626627628629630631export async function restoreEntries(632 incoming: VaultEntry[],633 mode: "replace" | "merge"634): Promise<{ added: number; updated: number; skipped: number }> {635 if (!derivedKey) throw new Error("vault locked");636637 638 639 640 if (mode === "replace" && incoming.length === 0) {641 throw new Error("refuse to replace with empty entry set");642 }643644 let added = 0;645 let updated = 0;646 let skipped = 0;647648 if (mode === "replace") {649 entries = [];650 for (const entry of incoming) {651 const normalized = sanitizeRestoredEntry(entry);652 if (!normalized) {653 skipped += 1;654 continue;655 }656 entries.push(normalized);657 added += 1;658 }659 } else {660 const byId = new Map<string, VaultEntry>();661 for (const e of entries) byId.set(e.id, e);662663 for (const entry of incoming) {664 const normalized = sanitizeRestoredEntry(entry);665 if (!normalized) {666 skipped += 1;667 continue;668 }669 const existing = byId.get(normalized.id);670 if (!existing) {671 entries.push(normalized);672 added += 1;673 } else if (normalized.modifiedAt > existing.modifiedAt) {674 const idx = entries.findIndex((e) => e.id === existing.id);675 if (idx !== -1) {676 entries[idx] = normalized;677 updated += 1;678 }679 } else {680 skipped += 1;681 }682 }683 }684685 await persistEntries();686 return { added, updated, skipped };687}688689690691692693694function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {695 if (!entry || typeof entry !== "object") return null;696 const title = typeof entry.title === "string" ? entry.title.trim() : "";697 if (!title) return null;698 const type = entry.type ?? "login";699 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();700 const now = Date.now();701 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;702 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;703704 if (type === "login" && typeof entry.password !== "string") {705 return null;706 }707 if (708 type === "passkey" &&709 (typeof entry.rpId !== "string" ||710 typeof entry.credentialId !== "string" ||711 !entry.privateKeyJwk)712 ) {713 return null;714 }715716 return {717 ...entry,718 id,719 title,720 type,721 createdAt,722 modifiedAt,723 };724}725726727728729730export async function clearVaultEntries(731 confirmation: "REPLACE_ALL"732): Promise<void> {733 if (confirmation !== "REPLACE_ALL") {734 throw new Error("clearVaultEntries requires explicit confirmation");735 }736 if (!derivedKey) throw new Error("vault locked");737 for (const entry of entries) {738 if (!entry.deletedAt) {739 entry.deletedAt = Date.now();740 entry.modifiedAt = Date.now();741 }742 }743 await persistEntries();744}745746export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {747 if (!derivedKey) throw new Error("vault locked");748749 const sanitized = sanitizeEntryDraft(entry);750 const now = Date.now();751 const nextEntry: VaultEntry = {752 ...sanitized,753 id: crypto.randomUUID(),754 createdAt: now,755 modifiedAt: now,756 757 758 passwordChangedAt: sanitized.password ? now : undefined,759 };760761 entries.push(nextEntry);762 await persistEntries();763764 765 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {766 checkBreach(nextEntry.password).then(async (count) => {767 if (count > 0) {768 const idx = entries.findIndex((e) => e.id === nextEntry.id);769 if (idx !== -1) {770 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };771 await persistEntries();772 }773 }774 });775 }776777 return nextEntry;778}779780export async function updateEntry(781 id: string,782 patch: Partial<VaultEntry>783): Promise<void> {784 if (!derivedKey) throw new Error("vault locked");785786 const idx = entries.findIndex((entry) => entry.id === id);787 if (idx === -1 || entries[idx].deletedAt) {788 throw new Error("entry not found");789 }790791 const current = entries[idx];792 const sanitizedPatch = sanitizeEntryPatch(patch);793 794 795 796 const passwordChanged =797 "password" in patch &&798 typeof sanitizedPatch.password === "string" &&799 sanitizedPatch.password !== current.password;800 entries[idx] = {801 ...current,802 ...sanitizedPatch,803 modifiedAt: Date.now(),804 ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),805 };806807 808 809 810 if (entries[idx].type === "login" && !entries[idx].password) {811 entries[idx] = current;812 throw new Error("password is required for login entries");813 }814815 816 if (entries[idx].type === "card" && !entries[idx].cardNumber) {817 entries[idx] = current;818 throw new Error("card number is required for card entries");819 }820821 await persistEntries();822823 824 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login" && entries[idx].password) {825 const password = entries[idx].password!;826 checkBreach(password).then(async (count) => {827 const i = entries.findIndex((e) => e.id === id);828 if (i !== -1) {829 entries[i] = { ...entries[i], breach: count > 0 ? count : undefined, modifiedAt: Date.now() };830 await persistEntries();831 }832 });833 }834}835836export async function deleteEntry(id: string): Promise<void> {837 if (!derivedKey) throw new Error("vault locked");838839 const entry = entries.find((item) => item.id === id);840 if (!entry || entry.deletedAt) {841 throw new Error("entry not found");842 }843844 entry.deletedAt = Date.now();845 entry.modifiedAt = Date.now();846 await persistEntries();847}848849export function getDeletedEntries(): VaultEntry[] {850 if (!derivedKey) return [];851 return entries.filter((e) => !!e.deletedAt);852}853854export async function restoreEntry(id: string): Promise<void> {855 if (!derivedKey) throw new Error("vault locked");856 const entry = entries.find((e) => e.id === id && e.deletedAt);857 if (!entry) throw new Error("deleted entry not found");858 delete entry.deletedAt;859 entry.modifiedAt = Date.now();860 await persistEntries();861 buildDomainIndexSync(entries);862}863864export async function purgeEntry(id: string): Promise<void> {865 if (!derivedKey) throw new Error("vault locked");866 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);867 if (idx === -1) throw new Error("deleted entry not found");868 entries.splice(idx, 1);869 await persistEntries();870}871872873export async function purgeExpiredTrash(retentionMs: number): Promise<number> {874 if (!derivedKey || retentionMs === 0) return 0;875 const cutoff = Date.now() - retentionMs;876 const before = entries.length;877 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));878 const purged = before - entries.length;879 if (purged > 0) await persistEntries();880 return purged;881}882883export function queryDomain(domain: string): VaultEntry[] {884 return domainIndex.get(domain) ?? [];885}886887export function getEntryById(id: string): VaultEntry | undefined {888 return entries.find((entry) => entry.id === id && !entry.deletedAt);889}890891async function persistSlotA(): Promise<StoredVault> {892 if (!derivedKey) throw new Error("vault locked");893894 const slotA = await loadSlot(SLOT_A);895 if (!slotA) throw new Error("missing slot A");896897 const plaintext = encodePayload(entries, duressConfigured);898 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);899900 const nextSlotA: StoredVault = {901 ...slotA,902 nonce,903 ciphertext,904 tag,905 };906 await writeSlot(SLOT_A, nextSlotA);907908 909 910 911 if (!duressConfigured) {912 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));913 }914915 return nextSlotA;916}917918async function persistSlotB(): Promise<void> {919 if (!derivedKey) throw new Error("vault locked");920921 const slotB = await loadSlot(SLOT_B);922 if (!slotB) throw new Error("missing slot B");923924 925 926 const plaintext = encodePayload(entries, false);927 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);928929 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });930}931932async function persistEntries(): Promise<void> {933 if (!derivedKey) throw new Error("vault locked");934935 if (vaultMode === "decoy") {936 await persistSlotB();937 } else {938 const freshSlotA = await persistSlotA();939 940 941 const activeCount = entries.filter((e) => !e.deletedAt).length;942 void takeSnapshot(freshSlotA, activeCount);943 }944 buildDomainIndexSync(entries);945}946947function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {948 if (!Array.isArray(values)) return undefined;949 const seen = new Set<string>();950 const cleaned: string[] = [];951 for (const value of values) {952 const trimmed = typeof value === "string" ? value.trim() : "";953 if (!trimmed) continue;954 const key = trimmed.toLowerCase();955 if (seen.has(key)) continue;956 seen.add(key);957 cleaned.push(trimmed);958 }959 return cleaned.length > 0 ? cleaned : undefined;960}961962function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {963 const title = entry.title.trim();964 const type = entry.type ?? "login";965966 if (!title) throw new Error("title is required");967 if (type === "login" && !entry.password?.trim()) {968 throw new Error("password is required");969 }970 if (type === "passkey") {971 if (972 !entry.rpId?.trim() ||973 !entry.rpName?.trim() ||974 !entry.credentialId?.trim() ||975 !entry.publicKeyCose?.trim() ||976 !entry.privateKeyJwk ||977 !entry.userHandle?.trim() ||978 !entry.userName?.trim() ||979 !entry.userDisplayName?.trim() ||980 (entry.algorithm !== -7 && entry.algorithm !== -8)981 ) {982 throw new Error("invalid passkey entry");983 }984 }985 if (type === "card") {986 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {987 throw new Error("cardholder name and card number are required");988 }989 }990991 return {992 type,993 title,994 username: type === "login" ? entry.username?.trim() ?? "" : undefined,995 password: type === "login" ? entry.password : undefined,996 url: type === "login" ? entry.url?.trim() ?? "" : undefined,997 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,998 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,999 notes: type === "passkey" ? undefined : entry.notes?.trim() || undefined,1000 breach: type === "login" ? entry.breach : undefined,1001 canary: type === "login" ? entry.canary : undefined,1002 tags: entry.tags,1003 rpId: type === "passkey" ? entry.rpId?.trim() : undefined,1004 rpName: type === "passkey" ? entry.rpName?.trim() : undefined,1005 credentialId: type === "passkey" ? entry.credentialId?.trim() : undefined,1006 publicKeyCose: type === "passkey" ? entry.publicKeyCose?.trim() : undefined,1007 privateKeyJwk: type === "passkey" ? entry.privateKeyJwk : undefined,1008 userHandle: type === "passkey" ? entry.userHandle?.trim() : undefined,1009 userName: type === "passkey" ? entry.userName?.trim() : undefined,1010 userDisplayName: type === "passkey" ? entry.userDisplayName?.trim() : undefined,1011 algorithm: type === "passkey" ? entry.algorithm : undefined,1012 signCount: type === "passkey" ? entry.signCount ?? 0 : undefined,1013 aaguid: type === "passkey" ? entry.aaguid?.trim() : undefined,1014 backupEligible: type === "passkey" ? entry.backupEligible === true : undefined,1015 backupState: type === "passkey" ? entry.backupState === true : undefined,1016 lastUsedAt: type === "passkey" ? entry.lastUsedAt : undefined,1017 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1018 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1019 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1020 expYear: type === "card" ? entry.expYear?.trim() : undefined,1021 cvc: type === "card" ? entry.cvc?.trim() : undefined,1022 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1023 network: type === "card" ? entry.network : undefined,1024 last4: type === "card" ? entry.last4?.trim() : undefined,1025 };1026}10271028function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1029 const nextPatch: Partial<VaultEntry> = {};10301031 if ("type" in patch) {1032 nextPatch.type = patch.type;1033 }10341035 if ("title" in patch) {1036 const title = patch.title?.trim() ?? "";1037 if (!title) throw new Error("title is required");1038 nextPatch.title = title;1039 }10401041 if ("url" in patch) {1042 nextPatch.url = patch.url?.trim() ?? "";1043 }10441045 if ("additionalUrls" in patch) {1046 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1047 const seen = new Set<string>();1048 const cleaned: string[] = [];1049 for (const value of incoming) {1050 const trimmed = typeof value === "string" ? value.trim() : "";1051 if (!trimmed) continue;1052 const key = trimmed.toLowerCase();1053 if (seen.has(key)) continue;1054 seen.add(key);1055 cleaned.push(trimmed);1056 }1057 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1058 }10591060 if ("username" in patch) {1061 nextPatch.username = patch.username?.trim() ?? "";1062 }10631064 if ("password" in patch) {1065 const password = patch.password ?? "";1066 1067 nextPatch.password = password || undefined;1068 }10691070 if ("totp" in patch) {1071 nextPatch.totp = patch.totp?.trim() || undefined;1072 }10731074 if ("notes" in patch) {1075 nextPatch.notes = patch.notes?.trim() || undefined;1076 }10771078 if ("breach" in patch) {1079 nextPatch.breach = patch.breach;1080 }10811082 if ("canary" in patch) {1083 nextPatch.canary = patch.canary;1084 }10851086 if ("rpId" in patch) {1087 nextPatch.rpId = patch.rpId?.trim();1088 }10891090 if ("rpName" in patch) {1091 nextPatch.rpName = patch.rpName?.trim();1092 }10931094 if ("credentialId" in patch) {1095 nextPatch.credentialId = patch.credentialId?.trim();1096 }10971098 if ("publicKeyCose" in patch) {1099 nextPatch.publicKeyCose = patch.publicKeyCose?.trim();1100 }11011102 if ("privateKeyJwk" in patch) {1103 nextPatch.privateKeyJwk = patch.privateKeyJwk;1104 }11051106 if ("userHandle" in patch) {1107 nextPatch.userHandle = patch.userHandle?.trim();1108 }11091110 if ("userName" in patch) {1111 nextPatch.userName = patch.userName?.trim();1112 }11131114 if ("userDisplayName" in patch) {1115 nextPatch.userDisplayName = patch.userDisplayName?.trim();1116 }11171118 if ("algorithm" in patch) {1119 nextPatch.algorithm = patch.algorithm;1120 }11211122 if ("signCount" in patch) {1123 nextPatch.signCount = patch.signCount;1124 }11251126 if ("aaguid" in patch) {1127 nextPatch.aaguid = patch.aaguid?.trim();1128 }11291130 if ("backupEligible" in patch) {1131 nextPatch.backupEligible = patch.backupEligible;1132 }11331134 if ("backupState" in patch) {1135 nextPatch.backupState = patch.backupState;1136 }11371138 if ("lastUsedAt" in patch) {1139 nextPatch.lastUsedAt = patch.lastUsedAt;1140 }11411142 if ("tags" in patch) {1143 nextPatch.tags = patch.tags;1144 }11451146 if ("cardholderName" in patch) {1147 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1148 }1149 if ("cardNumber" in patch) {1150 const n = patch.cardNumber ?? "";1151 nextPatch.cardNumber = n || undefined;1152 }1153 if ("expMonth" in patch) {1154 nextPatch.expMonth = patch.expMonth?.trim() || undefined;1155 }1156 if ("expYear" in patch) {1157 nextPatch.expYear = patch.expYear?.trim() || undefined;1158 }1159 if ("cvc" in patch) {1160 nextPatch.cvc = patch.cvc?.trim() || undefined;1161 }1162 if ("cardNickname" in patch) {1163 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1164 }1165 if ("network" in patch) {1166 nextPatch.network = patch.network;1167 }1168 if ("last4" in patch) {1169 nextPatch.last4 = patch.last4?.trim() || undefined;1170 }11711172 return nextPatch;1173}11741175function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1176 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1177 const modifiedAt =1178 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;11791180 return {1181 id: entry.id,1182 type: "type" in entry ? entry.type : "login",1183 title: entry.title,1184 username: entry.username,1185 password: entry.password,1186 url: entry.url,1187 totp: entry.totp,1188 notes: entry.notes,1189 breach: entry.breach,1190 canary: "canary" in entry ? entry.canary : undefined,1191 rpId: "rpId" in entry ? entry.rpId : undefined,1192 rpName: "rpName" in entry ? entry.rpName : undefined,1193 credentialId: "credentialId" in entry ? entry.credentialId : undefined,1194 publicKeyCose: "publicKeyCose" in entry ? entry.publicKeyCose : undefined,1195 privateKeyJwk: "privateKeyJwk" in entry ? entry.privateKeyJwk : undefined,1196 userHandle: "userHandle" in entry ? entry.userHandle : undefined,1197 userName: "userName" in entry ? entry.userName : undefined,1198 userDisplayName: "userDisplayName" in entry ? entry.userDisplayName : undefined,1199 algorithm: "algorithm" in entry ? entry.algorithm : undefined,1200 signCount: "signCount" in entry ? entry.signCount : undefined,1201 aaguid: "aaguid" in entry ? entry.aaguid : undefined,1202 backupEligible: "backupEligible" in entry ? entry.backupEligible : undefined,1203 backupState: "backupState" in entry ? entry.backupState : undefined,1204 lastUsedAt: "lastUsedAt" in entry ? entry.lastUsedAt : undefined,1205 createdAt,1206 modifiedAt,1207 deletedAt: entry.deletedAt,1208 tags: "tags" in entry ? entry.tags : undefined,1209 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1210 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1211 expMonth: "expMonth" in entry ? entry.expMonth : undefined,1212 expYear: "expYear" in entry ? entry.expYear : undefined,1213 cvc: "cvc" in entry ? entry.cvc : undefined,1214 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1215 network: "network" in entry ? entry.network : undefined,1216 last4: "last4" in entry ? entry.last4 : undefined,1217 };1218}12191220function hasLegacyShape(parsed: VaultEntry[]): boolean {1221 return parsed.some(1222 (entry) => "created" in entry || "updated" in entry1223 );1224}122512261227122812291230function entryDupeKey(e: VaultEntry): string {1231 if (e.type === "note") return `note|${e.title.toLowerCase()}`;1232 if (e.type === "passkey") return `passkey|${(e.rpId ?? "").toLowerCase()}|${(e.userName ?? "").toLowerCase()}`;1233 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1234}123512361237export function countDuplicates(): number {1238 const best = new Map<string, VaultEntry>();1239 let dupeCount = 0;1240 for (const e of entries.filter((e) => !e.deletedAt)) {1241 const key = entryDupeKey(e);1242 const existing = best.get(key);1243 if (!existing) {1244 best.set(key, e);1245 } else {1246 dupeCount++;1247 1248 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1249 best.set(key, e);1250 }1251 }1252 }1253 return dupeCount;1254}125512561257125812591260export async function deduplicateEntries(): Promise<number> {1261 if (!derivedKey) throw new Error("vault locked");1262 1263 1264 if (vaultMode === "decoy") {1265 throw new Error("deduplicate disabled in decoy mode");1266 }12671268 const best = new Map<string, VaultEntry>();1269 for (const e of entries.filter((en) => !en.deletedAt)) {1270 const key = entryDupeKey(e);1271 const existing = best.get(key);1272 if (!existing) {1273 best.set(key, e);1274 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1275 best.set(key, e);1276 }1277 }12781279 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1280 const now = Date.now();1281 let removed = 0;1282 entries = entries.map((e) => {1283 if (!e.deletedAt && !keepIds.has(e.id)) {1284 removed++;1285 return { ...e, deletedAt: now };1286 }1287 return e;1288 });12891290 if (removed > 0) await persistEntries();1291 return removed;1292}129312941295129612971298const SNAPSHOT_KEY = "peach_vault_snapshots";1299const MAX_SNAPSHOTS = 6;13001301interface VaultSnapshot {1302 timestamp: number;1303 entryCount: number;1304 slot: StoredVault;1305}130613071308export interface SnapshotSummary {1309 index: number;1310 timestamp: number;1311 entryCount: number;1312}13131314async function takeSnapshot(1315 slot: StoredVault,1316 entryCount: number1317): Promise<void> {1318 try {1319 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1320 const existing: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1321 const next: VaultSnapshot[] = [1322 { timestamp: Date.now(), entryCount, slot },1323 ...existing,1324 ].slice(0, MAX_SNAPSHOTS);1325 await chrome.storage.local.set({ [SNAPSHOT_KEY]: next });1326 } catch {1327 1328 }1329}13301331export async function listSnapshots(): Promise<SnapshotSummary[]> {1332 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1333 const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1334 return snapshots.map((s, i) => ({1335 index: i,1336 timestamp: s.timestamp,1337 entryCount: s.entryCount,1338 }));1339}13401341134213431344134513461347export async function restoreFromSnapshot(1348 index: number,1349 password: string1350): Promise<boolean> {1351 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1352 const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1353 const snapshot = snapshots[index];1354 if (!snapshot) return false;13551356 1357 const key = await deriveKey(password, unhex(snapshot.slot.salt));1358 const payload = await tryDecryptSlot(snapshot.slot, key);1359 if (!payload) {1360 zeroKey(key);1361 return false;1362 }13631364 1365 await writeSlot(SLOT_A, snapshot.slot);1366 await writeSlot(SLOT_B, generateNoiseBlob(snapshot.slot.ciphertext.length / 2));13671368 1369 if (derivedKey) zeroKey(derivedKey);1370 derivedKey = key;1371 entries = payload.entries;1372 duressConfigured = payload.duressConfigured;1373 vaultMode = "real";1374 buildDomainIndexSync(entries);1375 await loadSettings();1376 touchAutoLock();13771378 return true;1379}13801381138213831384138513861387export async function changePassword(1388 currentPassword: string,1389 newPassword: string1390): Promise<boolean> {1391 if (!derivedKey || vaultMode !== "real") return false;13921393 1394 const valid = await verifyMainPassword(currentPassword);1395 if (!valid) return false;13961397 const slotA = await loadSlot(SLOT_A);1398 if (!slotA) return false;13991400 1401 const newSalt = generateSalt();1402 const newKey = await deriveKey(newPassword, newSalt);14031404 1405 const plaintext = encodePayload(entries, duressConfigured);1406 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);14071408 const freshSlotA: StoredVault = {1409 ...slotA,1410 salt: hex(newSalt),1411 nonce,1412 ciphertext,1413 tag,1414 };1415 await writeSlot(SLOT_A, freshSlotA);14161417 1418 1419 1420 zeroKey(derivedKey);1421 derivedKey = newKey;14221423 1424 1425 1426 const activeCount = entries.filter((e) => !e.deletedAt).length;1427 void takeSnapshot(freshSlotA, activeCount);14281429 1430 1431 1432 if (!duressConfigured) {1433 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1434 }14351436 return true;1437}