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 331332333334335336337 migratedFromLegacy: boolean;338};339340function encodePayload(entries: VaultEntry[], duress: boolean): string {341 const envelope: VaultPayload = { entries, duressConfigured: duress };342 return padPlaintext(JSON.stringify(envelope));343}344345function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {346 if ("created" in raw || "updated" in raw) return true;347 const flat = raw as Partial<VaultEntry>;348 if (flat.type === "passkey") return true;349 350 if (flat.rpId !== undefined && flat.passkey === undefined) return true;351 return false;352}353354function decodePayload(plain: string): DecryptedPayload {355 const parsed = JSON.parse(plain) as356 | Array<VaultEntry | LegacyVaultEntry>357 | VaultPayload;358359 const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];360 const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);361362 const normalized = rawEntries.map(normalizeEntry);363 const { merged } = mergePasskeyOnlyIntoLogins(normalized);364365 if (Array.isArray(parsed)) {366 return {367 entries: normalized,368 duressConfigured: false,369 migratedFromLegacy: true, 370 };371 }372373 return {374 entries: normalized,375 duressConfigured: parsed.duressConfigured === true,376 migratedFromLegacy: flatMigrationNeeded || merged,377 };378}379380381382383384385386387388389390391392393394395396397398399400401402403function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {404 let merged = false;405 const now = Date.now();406407 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);408 const byRpId = new Map<string, VaultEntry[]>();409 for (const e of candidates) {410 const rpId = e.passkey!.rpId;411 const list = byRpId.get(rpId);412 if (list) list.push(e);413 else byRpId.set(rpId, [e]);414 }415416 for (const [rpId, group] of byRpId) {417 if (group.length === 0) continue;418 group.sort((a, b) => {419 const aT =420 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;421 const bT =422 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;423 return bT - aT;424 });425 const winner = group[0]!;426427 428 429 430 const loginTarget = entries.find((e) => {431 if (e.deletedAt || e.passkey || e.type !== "login") return false;432 if (e.url && extractDomain(e.url) === rpId) return true;433 for (const extra of e.additionalUrls ?? []) {434 if (extractDomain(extra) === rpId) return true;435 }436 return false;437 });438439 if (loginTarget) {440 loginTarget.passkey = winner.passkey;441 loginTarget.modifiedAt = now;442 for (const dup of group) {443 if (!dup.deletedAt) {444 dup.deletedAt = now;445 dup.modifiedAt = now;446 }447 }448 merged = true;449 } else if (group.length > 1) {450 for (let i = 1; i < group.length; i += 1) {451 const dup = group[i]!;452 if (!dup.deletedAt) {453 dup.deletedAt = now;454 dup.modifiedAt = now;455 }456 }457 merged = true;458 }459 }460461 return { merged };462}463464async function tryDecryptSlot(465 vault: StoredVault,466 key: Uint8Array467): Promise<DecryptedPayload | null> {468 try {469 const plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);470 return decodePayload(plain);471 } catch {472 return null;473 }474}475476477478479480function buildDomainIndexSync(all: VaultEntry[]): void {481 domainIndex = new Map();482483 for (const entry of all) {484 if (entry.deletedAt) continue;485 if (entry.type === "note" || entry.type === "card") continue;486487 488 489 490 491 const domains = new Set<string>();492 const primary = extractDomain(entry.url ?? "");493 if (primary) domains.add(primary);494 for (const extra of entry.additionalUrls ?? []) {495 const d = extractDomain(extra);496 if (d) domains.add(d);497 }498 if (domains.size === 0) continue;499500 for (const domain of domains) {501 const bucket = domainIndex.get(domain) ?? [];502 bucket.push(entry);503 domainIndex.set(domain, bucket);504 }505 }506}507508export async function createVault(password: string): Promise<void> {509 await ensureMigrated();510511 const salt = generateSalt();512 derivedKey = await deriveKey(password, salt);513 entries = [];514 vaultMode = "real";515 duressConfigured = false;516 buildDomainIndexSync(entries);517518 const plaintext = encodePayload(entries, duressConfigured);519 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);520521 const slotA: StoredVault = {522 version: 1,523 salt: hex(salt),524 nonce,525 ciphertext,526 tag,527 };528 await writeSlot(SLOT_A, slotA);529530 531 532 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));533534 touchAutoLock();535}536537export async function hasVault(): Promise<boolean> {538 await ensureMigrated();539 return (await loadSlot(SLOT_A)) !== null;540}541542543544545546547export async function isDuressEnabled(): Promise<boolean> {548 return vaultMode === "real" && duressConfigured;549}550551export async function hasDecoyVault(): Promise<boolean> {552 return isDuressEnabled();553}554555export async function setupDuress(duressPassword: string): Promise<void> {556 if (!derivedKey || vaultMode !== "real") {557 throw new Error("vault must be unlocked in real mode");558 }559 await ensureMigrated();560561 562 563 564 const slotA = await loadSlot(SLOT_A);565 if (!slotA) throw new Error("missing slot A");566567 const decoySalt = generateSalt();568 const decoyKey = await deriveKey(duressPassword, decoySalt);569 try {570 571 572 const targetBytes = slotA.ciphertext.length / 2;573 let plaintext = encodePayload([], false);574 const encoded = new TextEncoder().encode(plaintext);575 if (encoded.length < targetBytes) {576 plaintext = plaintext + " ".repeat(targetBytes - encoded.length);577 }578 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);579 await writeSlot(SLOT_B, {580 version: 1,581 salt: hex(decoySalt),582 nonce,583 ciphertext,584 tag,585 });586 } finally {587 zeroKey(decoyKey);588 }589590 591 592 duressConfigured = true;593 await persistSlotA();594}595596export async function disableDuress(): Promise<void> {597 if (!derivedKey || vaultMode !== "real") {598 throw new Error("vault must be unlocked in real mode");599 }600 await ensureMigrated();601602 603 604 605 const slotA = await loadSlot(SLOT_A);606 if (slotA) {607 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));608 }609610 duressConfigured = false;611 await persistSlotA();612}613614export async function verifyMainPassword(password: string): Promise<boolean> {615 await ensureMigrated();616 const slotA = await loadSlot(SLOT_A);617 if (!slotA) return false;618 const key = await deriveKey(password, unhex(slotA.salt));619 try {620 return (await tryDecryptSlot(slotA, key)) !== null;621 } finally {622 zeroKey(key);623 }624}625626export async function unlock(password: string): Promise<boolean> {627 await ensureMigrated();628629 630 631 632 633 634 const [slotA, slotB] = await Promise.all([635 loadSlot(SLOT_A),636 loadSlot(SLOT_B),637 ]);638639 640 const [keyA, keyB] = await Promise.all([641 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),642 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),643 ]);644645 646 const [payloadA, payloadB] = await Promise.all([647 keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),648 keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),649 ]);650651 if (payloadA && keyA) {652 derivedKey = keyA;653 vaultMode = "real";654 entries = payloadA.entries;655 duressConfigured = payloadA.duressConfigured;656 buildDomainIndexSync(entries);657 await loadSettings();658 touchAutoLock();659 if (keyB) zeroKey(keyB);660 661 662 663 if (payloadA.migratedFromLegacy) {664 await persistSlotA();665 }666 return true;667 }668669 if (payloadB && keyB) {670 derivedKey = keyB;671 vaultMode = "decoy";672 entries = payloadB.entries;673 duressConfigured = false;674 buildDomainIndexSync(entries);675 await loadSettings();676 touchAutoLock();677 if (keyA) zeroKey(keyA);678 return true;679 }680681 if (keyA) zeroKey(keyA);682 if (keyB) zeroKey(keyB);683 return false;684}685686export function lock(): void {687 if (derivedKey) {688 zeroKey(derivedKey);689 derivedKey = null;690 }691692 entries = [];693 domainIndex = new Map();694 vaultMode = "real";695 duressConfigured = false;696 clearAutoLock();697}698699export function isUnlocked(): boolean {700 return derivedKey !== null;701}702703export function getEntries(): VaultEntry[] {704 if (!derivedKey) throw new Error("vault locked");705 return entries.filter((entry) => !entry.deletedAt);706}707708const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;709710export function getIdentities(): SavedIdentity[] {711 if (!derivedKey) throw new Error("vault locked");712 const active = entries.filter((e) => !e.deletedAt && e.type === "login");713714 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();715 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();716717 for (const entry of active) {718 const value = entry.username?.trim();719 if (!value) continue;720 const map = EMAIL_RE.test(value) ? emailMap : usernameMap;721 const existing = map.get(value);722 const domain = extractDomain(entry.url ?? "") ?? "";723 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;724 if (!existing || modifiedAt > existing.lastUsed) {725 map.set(value, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });726 } else {727 map.set(value, { ...existing, count: existing.count + 1 });728 }729 }730731 const toIdentity = (type: "email" | "username") =>732 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({733 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,734 });735736 return [737 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),738 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),739 ];740}741742export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {743 if (!derivedKey) throw new Error("vault locked");744745 const now = Date.now();746 const added: VaultEntry[] = [];747 for (const draft of drafts) {748 try {749 const sanitized = sanitizeEntryDraft(draft);750 const nextEntry: VaultEntry = {751 ...sanitized,752 id: crypto.randomUUID(),753 createdAt: now,754 modifiedAt: now,755 };756 entries.push(nextEntry);757 added.push(nextEntry);758 } catch {759 760 }761 }762763 if (added.length > 0) {764 await persistEntries();765 }766767 return added;768}769770771772773774775776777export async function restoreEntries(778 incoming: VaultEntry[],779 mode: "replace" | "merge"780): Promise<{ added: number; updated: number; skipped: number }> {781 if (!derivedKey) throw new Error("vault locked");782783 784 785 786 if (mode === "replace" && incoming.length === 0) {787 throw new Error("refuse to replace with empty entry set");788 }789790 let added = 0;791 let updated = 0;792 let skipped = 0;793794 if (mode === "replace") {795 entries = [];796 for (const entry of incoming) {797 const normalized = sanitizeRestoredEntry(entry);798 if (!normalized) {799 skipped += 1;800 continue;801 }802 entries.push(normalized);803 added += 1;804 }805 } else {806 const byId = new Map<string, VaultEntry>();807 for (const e of entries) byId.set(e.id, e);808809 for (const entry of incoming) {810 const normalized = sanitizeRestoredEntry(entry);811 if (!normalized) {812 skipped += 1;813 continue;814 }815 const existing = byId.get(normalized.id);816 if (!existing) {817 entries.push(normalized);818 added += 1;819 } else if (normalized.modifiedAt > existing.modifiedAt) {820 const idx = entries.findIndex((e) => e.id === existing.id);821 if (idx !== -1) {822 entries[idx] = normalized;823 updated += 1;824 }825 } else {826 skipped += 1;827 }828 }829 }830831 await persistEntries();832 return { added, updated, skipped };833}834835836837838839840function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {841 if (!entry || typeof entry !== "object") return null;842 const title = typeof entry.title === "string" ? entry.title.trim() : "";843 if (!title) return null;844 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();845 const now = Date.now();846 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;847 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;848849 850 851 852 const migrated: VaultEntry = migrateLegacyPasskey(853 {854 ...entry,855 id,856 title,857 type: entry.type ?? "login",858 createdAt,859 modifiedAt,860 },861 entry862 );863864 865 866 if (867 migrated.type === "login" &&868 !migrated.passkey &&869 typeof migrated.password !== "string"870 ) {871 return null;872 }873 if (migrated.passkey && !migrated.passkey.credentialId) {874 return null;875 }876877 return migrated;878}879880881882883884export async function clearVaultEntries(885 confirmation: "REPLACE_ALL"886): Promise<void> {887 if (confirmation !== "REPLACE_ALL") {888 throw new Error("clearVaultEntries requires explicit confirmation");889 }890 if (!derivedKey) throw new Error("vault locked");891 for (const entry of entries) {892 if (!entry.deletedAt) {893 entry.deletedAt = Date.now();894 entry.modifiedAt = Date.now();895 }896 }897 await persistEntries();898}899900export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {901 if (!derivedKey) throw new Error("vault locked");902903 const sanitized = sanitizeEntryDraft(entry);904 const now = Date.now();905 const nextEntry: VaultEntry = {906 ...sanitized,907 id: crypto.randomUUID(),908 createdAt: now,909 modifiedAt: now,910 911 912 passwordChangedAt: sanitized.password ? now : undefined,913 };914915 entries.push(nextEntry);916 await persistEntries();917918 919 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {920 checkBreach(nextEntry.password).then(async (count) => {921 if (count > 0) {922 const idx = entries.findIndex((e) => e.id === nextEntry.id);923 if (idx !== -1) {924 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };925 await persistEntries();926 }927 }928 });929 }930931 return nextEntry;932}933934export async function updateEntry(935 id: string,936 patch: Partial<VaultEntry>937): Promise<void> {938 if (!derivedKey) throw new Error("vault locked");939940 const idx = entries.findIndex((entry) => entry.id === id);941 if (idx === -1 || entries[idx].deletedAt) {942 throw new Error("entry not found");943 }944945 const current = entries[idx];946 const sanitizedPatch = sanitizeEntryPatch(patch);947 948 949 950 const passwordChanged =951 "password" in patch &&952 typeof sanitizedPatch.password === "string" &&953 sanitizedPatch.password !== current.password;954 entries[idx] = {955 ...current,956 ...sanitizedPatch,957 modifiedAt: Date.now(),958 ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),959 };960961 962 963 964 965 if (966 entries[idx].type === "login" &&967 !entries[idx].password &&968 !entries[idx].passkey969 ) {970 entries[idx] = current;971 throw new Error("password or passkey required");972 }973974 975 if (entries[idx].type === "card" && !entries[idx].cardNumber) {976 entries[idx] = current;977 throw new Error("card number is required for card entries");978 }979980 await persistEntries();981982 983 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login" && entries[idx].password) {984 const password = entries[idx].password!;985 checkBreach(password).then(async (count) => {986 const i = entries.findIndex((e) => e.id === id);987 if (i !== -1) {988 entries[i] = { ...entries[i], breach: count > 0 ? count : undefined, modifiedAt: Date.now() };989 await persistEntries();990 }991 });992 }993}994995export async function deleteEntry(id: string): Promise<void> {996 if (!derivedKey) throw new Error("vault locked");997998 const entry = entries.find((item) => item.id === id);999 if (!entry || entry.deletedAt) {1000 throw new Error("entry not found");1001 }10021003 entry.deletedAt = Date.now();1004 entry.modifiedAt = Date.now();1005 await persistEntries();1006}10071008export function getDeletedEntries(): VaultEntry[] {1009 if (!derivedKey) return [];1010 return entries.filter((e) => !!e.deletedAt);1011}10121013export async function restoreEntry(id: string): Promise<void> {1014 if (!derivedKey) throw new Error("vault locked");1015 const entry = entries.find((e) => e.id === id && e.deletedAt);1016 if (!entry) throw new Error("deleted entry not found");1017 delete entry.deletedAt;1018 entry.modifiedAt = Date.now();1019 await persistEntries();1020 buildDomainIndexSync(entries);1021}10221023export async function purgeEntry(id: string): Promise<void> {1024 if (!derivedKey) throw new Error("vault locked");1025 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1026 if (idx === -1) throw new Error("deleted entry not found");1027 entries.splice(idx, 1);1028 await persistEntries();1029}103010311032export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1033 if (!derivedKey || retentionMs === 0) return 0;1034 const cutoff = Date.now() - retentionMs;1035 const before = entries.length;1036 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1037 const purged = before - entries.length;1038 if (purged > 0) await persistEntries();1039 return purged;1040}10411042export function queryDomain(domain: string): VaultEntry[] {1043 return domainIndex.get(domain) ?? [];1044}10451046export function getEntryById(id: string): VaultEntry | undefined {1047 return entries.find((entry) => entry.id === id && !entry.deletedAt);1048}10491050async function persistSlotA(): Promise<StoredVault> {1051 if (!derivedKey) throw new Error("vault locked");10521053 const slotA = await loadSlot(SLOT_A);1054 if (!slotA) throw new Error("missing slot A");10551056 const plaintext = encodePayload(entries, duressConfigured);1057 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);10581059 const nextSlotA: StoredVault = {1060 ...slotA,1061 nonce,1062 ciphertext,1063 tag,1064 };1065 await writeSlot(SLOT_A, nextSlotA);10661067 1068 1069 1070 if (!duressConfigured) {1071 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1072 }10731074 return nextSlotA;1075}10761077async function persistSlotB(): Promise<void> {1078 if (!derivedKey) throw new Error("vault locked");10791080 const slotB = await loadSlot(SLOT_B);1081 if (!slotB) throw new Error("missing slot B");10821083 1084 1085 const plaintext = encodePayload(entries, false);1086 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);10871088 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1089}10901091async function persistEntries(): Promise<void> {1092 if (!derivedKey) throw new Error("vault locked");10931094 if (vaultMode === "decoy") {1095 await persistSlotB();1096 } else {1097 const freshSlotA = await persistSlotA();1098 1099 1100 const activeCount = entries.filter((e) => !e.deletedAt).length;1101 void takeSnapshot(freshSlotA, activeCount);1102 }1103 buildDomainIndexSync(entries);1104}11051106function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1107 if (!Array.isArray(values)) return undefined;1108 const seen = new Set<string>();1109 const cleaned: string[] = [];1110 for (const value of values) {1111 const trimmed = typeof value === "string" ? value.trim() : "";1112 if (!trimmed) continue;1113 const key = trimmed.toLowerCase();1114 if (seen.has(key)) continue;1115 seen.add(key);1116 cleaned.push(trimmed);1117 }1118 return cleaned.length > 0 ? cleaned : undefined;1119}11201121function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1122 const title = entry.title.trim();1123 1124 1125 1126 let type: VaultEntry["type"] = entry.type ?? "login";1127 if (type === "passkey") type = "login";11281129 if (!title) throw new Error("title is required");1130 1131 1132 if (type === "login" && !entry.password?.trim() && !entry.passkey) {1133 throw new Error("password or passkey required");1134 }1135 if (entry.passkey) {1136 const p = entry.passkey;1137 if (1138 !p.rpId ||1139 !p.credentialId ||1140 !p.publicKeyCose ||1141 !p.userHandle ||1142 !p.userName ||1143 (p.algorithm !== -7 && p.algorithm !== -8)1144 ) {1145 throw new Error("invalid passkey credential");1146 }1147 }1148 if (type === "card") {1149 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1150 throw new Error("cardholder name and card number are required");1151 }1152 }11531154 return {1155 type,1156 title,1157 username: type === "login" ? entry.username?.trim() ?? "" : undefined,1158 password: type === "login" ? entry.password : undefined,1159 url: type === "login" ? entry.url?.trim() ?? "" : undefined,1160 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1161 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1162 notes: entry.notes?.trim() || undefined,1163 breach: type === "login" ? entry.breach : undefined,1164 canary: type === "login" ? entry.canary : undefined,1165 tags: entry.tags,1166 passkey: entry.passkey,1167 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1168 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1169 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1170 expYear: type === "card" ? entry.expYear?.trim() : undefined,1171 cvc: type === "card" ? entry.cvc?.trim() : undefined,1172 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1173 network: type === "card" ? entry.network : undefined,1174 last4: type === "card" ? entry.last4?.trim() : undefined,1175 };1176}11771178function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1179 const nextPatch: Partial<VaultEntry> = {};11801181 if ("type" in patch) {1182 nextPatch.type = patch.type;1183 }11841185 if ("title" in patch) {1186 const title = patch.title?.trim() ?? "";1187 if (!title) throw new Error("title is required");1188 nextPatch.title = title;1189 }11901191 if ("url" in patch) {1192 nextPatch.url = patch.url?.trim() ?? "";1193 }11941195 if ("additionalUrls" in patch) {1196 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1197 const seen = new Set<string>();1198 const cleaned: string[] = [];1199 for (const value of incoming) {1200 const trimmed = typeof value === "string" ? value.trim() : "";1201 if (!trimmed) continue;1202 const key = trimmed.toLowerCase();1203 if (seen.has(key)) continue;1204 seen.add(key);1205 cleaned.push(trimmed);1206 }1207 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1208 }12091210 if ("username" in patch) {1211 nextPatch.username = patch.username?.trim() ?? "";1212 }12131214 if ("password" in patch) {1215 const password = patch.password ?? "";1216 1217 nextPatch.password = password || undefined;1218 }12191220 if ("totp" in patch) {1221 nextPatch.totp = patch.totp?.trim() || undefined;1222 }12231224 if ("notes" in patch) {1225 nextPatch.notes = patch.notes?.trim() || undefined;1226 }12271228 if ("breach" in patch) {1229 nextPatch.breach = patch.breach;1230 }12311232 if ("canary" in patch) {1233 nextPatch.canary = patch.canary;1234 }12351236 if ("passkey" in patch) {1237 1238 1239 1240 nextPatch.passkey = patch.passkey;1241 }12421243 if ("tags" in patch) {1244 nextPatch.tags = patch.tags;1245 }12461247 if ("cardholderName" in patch) {1248 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1249 }1250 if ("cardNumber" in patch) {1251 const n = patch.cardNumber ?? "";1252 nextPatch.cardNumber = n || undefined;1253 }1254 if ("expMonth" in patch) {1255 nextPatch.expMonth = patch.expMonth?.trim() || undefined;1256 }1257 if ("expYear" in patch) {1258 nextPatch.expYear = patch.expYear?.trim() || undefined;1259 }1260 if ("cvc" in patch) {1261 nextPatch.cvc = patch.cvc?.trim() || undefined;1262 }1263 if ("cardNickname" in patch) {1264 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1265 }1266 if ("network" in patch) {1267 nextPatch.network = patch.network;1268 }1269 if ("last4" in patch) {1270 nextPatch.last4 = patch.last4?.trim() || undefined;1271 }12721273 return nextPatch;1274}12751276function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1277 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1278 const modifiedAt =1279 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;12801281 const base: VaultEntry = {1282 id: entry.id,1283 type: "type" in entry ? entry.type : "login",1284 title: entry.title,1285 username: entry.username,1286 password: entry.password,1287 url: entry.url,1288 totp: entry.totp,1289 notes: entry.notes,1290 breach: entry.breach,1291 canary: "canary" in entry ? entry.canary : undefined,1292 passkey: "passkey" in entry ? entry.passkey : undefined,1293 createdAt,1294 modifiedAt,1295 deletedAt: entry.deletedAt,1296 tags: "tags" in entry ? entry.tags : undefined,1297 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1298 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1299 expMonth: "expMonth" in entry ? entry.expMonth : undefined,1300 expYear: "expYear" in entry ? entry.expYear : undefined,1301 cvc: "cvc" in entry ? entry.cvc : undefined,1302 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1303 network: "network" in entry ? entry.network : undefined,1304 last4: "last4" in entry ? entry.last4 : undefined,1305 };13061307 return migrateLegacyPasskey(base, entry);1308}130913101311131213131314131513161317function migrateLegacyPasskey(1318 base: VaultEntry,1319 raw: VaultEntry | LegacyVaultEntry1320): VaultEntry {1321 if (base.passkey) return base;1322 const flat = raw as Partial<VaultEntry>;1323 const isPasskeyType = base.type === "passkey";1324 const hasFlatPasskeyFields =1325 typeof flat.rpId === "string" &&1326 typeof flat.credentialId === "string" &&1327 typeof flat.publicKeyCose === "string" &&1328 typeof flat.userHandle === "string" &&1329 typeof flat.userName === "string";1330 if (!isPasskeyType && !hasFlatPasskeyFields) return base;1331 if (!hasFlatPasskeyFields) return base;13321333 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;1334 base.passkey = {1335 rpId: flat.rpId!,1336 rpName: flat.rpName ?? flat.rpId!,1337 credentialId: flat.credentialId!,1338 publicKeyCose: flat.publicKeyCose!,1339 privateKeyJwk: flat.privateKeyJwk,1340 userHandle: flat.userHandle!,1341 userName: flat.userName!,1342 userDisplayName: flat.userDisplayName ?? flat.userName!,1343 algorithm,1344 signCount: flat.signCount ?? 0,1345 aaguid: flat.aaguid ?? "",1346 backupEligible: flat.backupEligible === true,1347 backupState: flat.backupState === true,1348 lastUsedAt: flat.lastUsedAt,1349 browser: flat.browser,1350 transports: flat.transports,1351 createdAt: base.createdAt,1352 };1353 1354 1355 1356 if (isPasskeyType) base.type = "login";1357 if (!base.username) base.username = flat.userName;1358 if (!base.url) base.url = `https://${flat.rpId}`;1359 return base;1360}1361136213631364136513661367function entryDupeKey(e: VaultEntry): string {1368 if (e.type === "note") return `note|${e.title.toLowerCase()}`;1369 1370 1371 1372 1373 if (e.passkey) {1374 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;1375 }1376 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1377}137813791380export function countDuplicates(): number {1381 const best = new Map<string, VaultEntry>();1382 let dupeCount = 0;1383 for (const e of entries.filter((e) => !e.deletedAt)) {1384 const key = entryDupeKey(e);1385 const existing = best.get(key);1386 if (!existing) {1387 best.set(key, e);1388 } else {1389 dupeCount++;1390 1391 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1392 best.set(key, e);1393 }1394 }1395 }1396 return dupeCount;1397}139813991400140114021403export async function deduplicateEntries(): Promise<number> {1404 if (!derivedKey) throw new Error("vault locked");1405 1406 1407 if (vaultMode === "decoy") {1408 throw new Error("deduplicate disabled in decoy mode");1409 }14101411 const best = new Map<string, VaultEntry>();1412 for (const e of entries.filter((en) => !en.deletedAt)) {1413 const key = entryDupeKey(e);1414 const existing = best.get(key);1415 if (!existing) {1416 best.set(key, e);1417 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1418 best.set(key, e);1419 }1420 }14211422 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1423 const now = Date.now();1424 let removed = 0;1425 entries = entries.map((e) => {1426 if (!e.deletedAt && !keepIds.has(e.id)) {1427 removed++;1428 return { ...e, deletedAt: now };1429 }1430 return e;1431 });14321433 if (removed > 0) await persistEntries();1434 return removed;1435}143614371438143914401441const SNAPSHOT_KEY = "peach_vault_snapshots";1442const MAX_SNAPSHOTS = 6;14431444interface VaultSnapshot {1445 timestamp: number;1446 entryCount: number;1447 slot: StoredVault;1448}144914501451export interface SnapshotSummary {1452 index: number;1453 timestamp: number;1454 entryCount: number;1455}14561457async function takeSnapshot(1458 slot: StoredVault,1459 entryCount: number1460): Promise<void> {1461 try {1462 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1463 const existing: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1464 const next: VaultSnapshot[] = [1465 { timestamp: Date.now(), entryCount, slot },1466 ...existing,1467 ].slice(0, MAX_SNAPSHOTS);1468 await chrome.storage.local.set({ [SNAPSHOT_KEY]: next });1469 } catch {1470 1471 }1472}14731474export async function listSnapshots(): Promise<SnapshotSummary[]> {1475 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1476 const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1477 return snapshots.map((s, i) => ({1478 index: i,1479 timestamp: s.timestamp,1480 entryCount: s.entryCount,1481 }));1482}14831484148514861487148814891490export async function restoreFromSnapshot(1491 index: number,1492 password: string1493): Promise<boolean> {1494 const raw = await chrome.storage.local.get(SNAPSHOT_KEY);1495 const snapshots: VaultSnapshot[] = (raw as Record<string, VaultSnapshot[]>)[SNAPSHOT_KEY] ?? [];1496 const snapshot = snapshots[index];1497 if (!snapshot) return false;14981499 1500 const key = await deriveKey(password, unhex(snapshot.slot.salt));1501 const payload = await tryDecryptSlot(snapshot.slot, key);1502 if (!payload) {1503 zeroKey(key);1504 return false;1505 }15061507 1508 await writeSlot(SLOT_A, snapshot.slot);1509 await writeSlot(SLOT_B, generateNoiseBlob(snapshot.slot.ciphertext.length / 2));15101511 1512 if (derivedKey) zeroKey(derivedKey);1513 derivedKey = key;1514 entries = payload.entries;1515 duressConfigured = payload.duressConfigured;1516 vaultMode = "real";1517 buildDomainIndexSync(entries);1518 await loadSettings();1519 touchAutoLock();15201521 return true;1522}15231524152515261527152815291530export async function changePassword(1531 currentPassword: string,1532 newPassword: string1533): Promise<boolean> {1534 if (!derivedKey || vaultMode !== "real") return false;15351536 1537 const valid = await verifyMainPassword(currentPassword);1538 if (!valid) return false;15391540 const slotA = await loadSlot(SLOT_A);1541 if (!slotA) return false;15421543 1544 const newSalt = generateSalt();1545 const newKey = await deriveKey(newPassword, newSalt);15461547 1548 const plaintext = encodePayload(entries, duressConfigured);1549 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);15501551 const freshSlotA: StoredVault = {1552 ...slotA,1553 salt: hex(newSalt),1554 nonce,1555 ciphertext,1556 tag,1557 };1558 await writeSlot(SLOT_A, freshSlotA);15591560 1561 1562 1563 zeroKey(derivedKey);1564 derivedKey = newKey;15651566 1567 1568 1569 const activeCount = entries.filter((e) => !e.deletedAt).length;1570 void takeSnapshot(freshSlotA, activeCount);15711572 1573 1574 1575 if (!duressConfigured) {1576 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1577 }15781579 return true;1580}