123456789101112import { deriveKey, generateSalt } from "../crypto/argon2";13import { extractDomain } from "../shared/domain";14import {15 rootKeyToWords,16 wordsToRootKeyMaterial,17 deriveRecoveryKey,18} from "../shared/recovery-words";19import type {20 AccentColorName,21 LegacyVaultEntry,22 ManualSummonRecord,23 SavedIdentity,24 StoredVault,25 VaultEntry,26 VaultHistory,27 VaultSnapshot,28} from "../shared/types";29import { decrypt, encrypt, zeroKey } from "./aes";30import { clearAutoLock, touchAutoLock } from "./alarms";3132type EntryDraft = Omit<33 VaultEntry,34 "id" | "createdAt" | "modifiedAt" | "deletedAt"35>;363738394041const SLOT_A = "peach_vault_a";42const SLOT_B = "peach_vault_b";434445const LEGACY_MAIN = "peach_vault";46const LEGACY_DECOY = "peach_decoy_vault";47const LEGACY_DURESS_FLAG = "peach_duress";48495051const PAD_BLOCK = 1024;5253let derivedKey: Uint8Array | null = null;5455565758let rootKey: Uint8Array | null = null;59let entries: VaultEntry[] = [];60let domainIndex: Map<string, VaultEntry[]> = new Map();61let vaultMode: "real" | "decoy" = "real";62636465let duressConfigured = false;6667let migrationRan = false;686970717273export type DisplayFont = "cormorant-italic" | "cormorant" | "inter" | "system";74export type TextSize = "small" | "medium" | "large";7576export interface VaultSettings {77 accentColor?: AccentColorName;78 theme?: "dark" | "light";79 autoLockMinutes?: number;80 clipboardClearSeconds?: number;81 lockOnClose?: boolean;82 breachMonitoring?: boolean;83 passkeyProviderEnabled?: boolean;84 trashRetentionDays?: number;85 manualSummonDomains?: Record<string, ManualSummonRecord>;86 declinedCardFingerprints?: string[];87 8889909192 displayFont?: DisplayFont;93 9495969798 textSize?: TextSize;99 100101102103104 reduceMotion?: boolean;105 106107108109110 biometricsEnabled?: boolean;111 112113114115116117118 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;119}120121let settings: VaultSettings = {};122123export function getSettings(): VaultSettings {124 return settings;125}126127export async function ensureSettingsLoaded(): Promise<VaultSettings> {128 await loadSettings();129 return settings;130}131132export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {133 await loadSettings();134 settings = { ...settings, ...patch };135 await persistSettings();136}137138async function persistSettings(): Promise<void> {139 try {140 await chrome.storage.local.set({ peach_settings: settings });141 } catch (err) {142 throw mapStorageError(err);143 }144}145146function mapStorageError(err: unknown): Error {147 const message = err instanceof Error ? err.message : String(err);148 if (/QUOTA_BYTES|quota/i.test(message)) {149 return new Error("STORAGE_QUOTA_EXCEEDED");150 }151 return err instanceof Error ? err : new Error(message);152}153154async function loadSettings(): Promise<void> {155 const result = await chrome.storage.local.get("peach_settings");156 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};157}158159160161162163export async function checkBreach(password: string): Promise<number> {164 const encoder = new TextEncoder();165 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));166 const hashHex = Array.from(new Uint8Array(hashBuffer))167 .map((b) => b.toString(16).padStart(2, "0"))168 .join("")169 .toUpperCase();170171 const prefix = hashHex.slice(0, 5);172 const suffix = hashHex.slice(5);173174 try {175 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);176 if (!res.ok) return 0;177 const text = await res.text();178 const match = text.split("\r\n").find((l) => l.startsWith(suffix));179 return match ? parseInt(match.split(":")[1], 10) : 0;180 } catch {181 return 0; 182 }183}184185export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {186 const fingerprint = await cardFingerprint(last4, expMonth, expYear);187 await loadSettings();188 settings = {189 ...settings,190 declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],191 };192 await persistSettings();193}194195export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {196 await loadSettings();197 const fingerprint = await cardFingerprint(last4, expMonth, expYear);198 return (settings.declinedCardFingerprints ?? []).includes(fingerprint);199}200201async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {202 const encoder = new TextEncoder();203 const buf = await crypto.subtle.digest("SHA-1", encoder.encode(`${last4}${expMonth}${expYear}`));204 return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, "0")).join("");205}206207function hex(buf: Uint8Array): string {208 return Array.from(buf)209 .map((b) => b.toString(16).padStart(2, "0"))210 .join("");211}212213function unhex(s: string): Uint8Array {214 const out = new Uint8Array(s.length / 2);215 for (let i = 0; i < s.length; i += 2) {216 out[i / 2] = parseInt(s.slice(i, i + 2), 16);217 }218 return out;219}220221222223224225226227228229230231232async function generateAndWrapRootKey(): Promise<{233 rawRootKey: string; 234 recoverySalt: string;235 wrappedRootKey: string;236 wrappedNonce: string;237 wrappedTag: string;238}> {239 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));240241 242 const words = await rootKeyToWords(rootKeyBytes);243 const wordMaterial = await wordsToRootKeyMaterial(words);244 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));245 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);246247 248 249 const nonce = crypto.getRandomValues(new Uint8Array(12));250 const encoded = new TextEncoder().encode(hex(rootKeyBytes));251 const raw = await crypto.subtle.encrypt(252 {253 name: "AES-GCM",254 iv: nonce,255 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,256 tagLength: 128,257 },258 recoveryKey,259 encoded260 );261 262 const rawU8 = new Uint8Array(raw);263 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));264 const tag = hex(rawU8.slice(rawU8.length - 16));265266 return {267 rawRootKey: hex(rootKeyBytes),268 recoverySalt: hex(recoverySalt),269 wrappedRootKey: ciphertext,270 wrappedNonce: hex(nonce),271 wrappedTag: tag,272 };273}274275276277278279280export async function unwrapRootKeyForRecovery(281 wrappedRootKey: string,282 wrappedNonce: string,283 wrappedTag: string,284 recoverySaltHex: string,285 words: string[]286): Promise<void> {287 const wordMaterial = await wordsToRootKeyMaterial(words);288 const recoverySalt = unhex(recoverySaltHex);289 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);290291 292 293 294 const combined = unhex(wrappedRootKey + wrappedNonce + wrappedTag);295 const nonceU8 = combined.slice(0, 12);296 const ciphertext = combined.slice(12, combined.length - 16);297 const storedTag = combined.slice(combined.length - 16);298 const raw = await crypto.subtle.decrypt(299 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },300 recoveryKey,301 new Uint8Array([...ciphertext, ...storedTag])302 );303 const decryptedHex = new TextDecoder().decode(raw);304 rootKey = unhex(decryptedHex);305}306307308309310311312export async function rewrapRootKeyForPassword(313 newDerivedKey: Uint8Array,314 _newSalt: Uint8Array315): Promise<void> {316 if (!rootKey) throw new Error("root key not set");317 const { nonce, ciphertext, tag } = await encrypt(318 newDerivedKey,319 hex(rootKey)320 );321 const slotA = await loadSlot(SLOT_A);322 if (!slotA) throw new Error("missing slot A");323 await writeSlot(SLOT_A, {324 ...slotA,325 recovery_wrapped_root_key: ciphertext,326 recovery_nonce: nonce,327 recovery_tag: tag,328 329 330 });331}332333334335336337338async function reencryptVaultForNewKey(339 newKey: Uint8Array,340 slotA: StoredVault341): Promise<void> {342 const plaintext = encodePayload(entries, duressConfigured);343 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);344 await writeSlot(SLOT_A, {345 ...slotA,346 nonce,347 ciphertext,348 tag,349 });350}351352353354355356357export async function changePasswordWithNewKey(358 newSalt: Uint8Array,359 newKey: Uint8Array360): Promise<void> {361 const slotA = await loadSlot(SLOT_A);362 if (!slotA) throw new Error("missing slot A");363 await reencryptVaultForNewKey(newKey, slotA);364 zeroKey(derivedKey!);365 derivedKey = newKey;366 if (!duressConfigured) {367 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));368 }369}370371372373374375376export { deriveKey, generateSalt };377378export async function loadSlotA(): Promise<StoredVault | null> {379 return loadSlot(SLOT_A);380}381382383384385386387388389390391392393394395396397398399async function deriveRootKeyFromDerivedKey(400 _derivedKey: Uint8Array,401 _recoverySaltHex: string402): Promise<Uint8Array | null> {403 404 405 406 407 408 409 410 return null;411}412413414415416417418419420export async function migrateToRecovery(): Promise<void> {421 if (!derivedKey || vaultMode !== "real") {422 throw new Error("vault must be unlocked in real mode");423 }424 const slotA = await loadSlot(SLOT_A);425 if (!slotA) throw new Error("missing slot A");426 if (slotA.recovery_salt && slotA.recovery_wrapped_root_key) return; 427428 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =429 await generateAndWrapRootKey();430431 rootKey = unhex(rawRootKey);432433 const nextSlotA: StoredVault = {434 ...slotA,435 recovery_salt: recoverySalt,436 recovery_wrapped_root_key: wrappedRootKey,437 recovery_nonce: wrappedNonce,438 recovery_tag: wrappedTag,439 };440 await writeSlot(SLOT_A, nextSlotA);441}442443444445446447448export async function getRecoveryWords(): Promise<string[]> {449 if (!rootKey) {450 throw new Error("recovery not set up — vault may need migration");451 }452 return rootKeyToWords(rootKey);453}454455async function loadSlot(key: string): Promise<StoredVault | null> {456 const result = await chrome.storage.local.get(key);457 return (result as Record<string, StoredVault | undefined>)[key] ?? null;458}459460async function writeSlot(key: string, vault: StoredVault): Promise<void> {461 try {462 await chrome.storage.local.set({ [key]: vault });463 } catch (err) {464 throw mapStorageError(err);465 }466}467468469470471472473async function ensureMigrated(): Promise<void> {474 if (migrationRan) return;475 migrationRan = true;476477 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];478 const raw = await chrome.storage.local.get(keys);479 const slotAExists = raw[SLOT_A] !== undefined;480 const slotBExists = raw[SLOT_B] !== undefined;481 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;482 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;483484 485 if (!slotAExists && !legacyMain) return;486487 488 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;489490 if (legacyMain && !slotAExists) {491 await writeSlot(SLOT_A, legacyMain);492 }493494 495 if (!slotBExists) {496 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;497 if (legacyDecoy) {498 await writeSlot(SLOT_B, legacyDecoy);499 } else if (target) {500 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));501 }502 }503504 const toRemove: string[] = [];505 if (legacyMain) toRemove.push(LEGACY_MAIN);506 if (legacyDecoy) toRemove.push(LEGACY_DECOY);507 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);508 if (toRemove.length > 0) {509 await chrome.storage.local.remove(toRemove);510 }511}512513514515516517518519520521522523function padPlaintext(json: string): string {524 const encoded = new TextEncoder().encode(json);525 const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);526 const padLen = target - encoded.length;527 return json + " ".repeat(padLen);528}529530531532533534535536537538539540const CSPRNG_CHUNK = 65_536;541542function fillRandom(buf: Uint8Array): Uint8Array {543 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {544 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);545 const chunk = new Uint8Array(end - offset);546 crypto.getRandomValues(chunk);547 buf.set(chunk, offset);548 }549 return buf;550}551552function generateNoiseBlob(ciphertextBytes: number): StoredVault {553 const salt = crypto.getRandomValues(new Uint8Array(16));554 const nonce = crypto.getRandomValues(new Uint8Array(12));555 const cipher = fillRandom(new Uint8Array(ciphertextBytes));556 const tag = crypto.getRandomValues(new Uint8Array(16));557 return {558 version: 1,559 salt: hex(salt),560 nonce: hex(nonce),561 ciphertext: hex(cipher),562 tag: hex(tag),563 };564}565566567568569570type VaultPayload = {571 entries: VaultEntry[];572 duressConfigured?: boolean;573};574575type DecryptedPayload = {576 entries: VaultEntry[];577 duressConfigured: boolean;578 579580581582583584585 migratedFromLegacy: boolean;586};587588function encodePayload(entries: VaultEntry[], duress: boolean): string {589 const envelope: VaultPayload = { entries, duressConfigured: duress };590 return padPlaintext(JSON.stringify(envelope));591}592593function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {594 if ("created" in raw || "updated" in raw) return true;595 const flat = raw as Partial<VaultEntry>;596 if (flat.type === "passkey") return true;597 598 if (flat.rpId !== undefined && flat.passkey === undefined) return true;599 return false;600}601602function decodePayload(plain: string): DecryptedPayload {603 const parsed = JSON.parse(plain) as604 | Array<VaultEntry | LegacyVaultEntry>605 | VaultPayload;606607 const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];608 const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);609610 const normalized = rawEntries.map(normalizeEntry);611 const { merged } = mergePasskeyOnlyIntoLogins(normalized);612613 if (Array.isArray(parsed)) {614 return {615 entries: normalized,616 duressConfigured: false,617 migratedFromLegacy: true, 618 };619 }620621 return {622 entries: normalized,623 duressConfigured: parsed.duressConfigured === true,624 migratedFromLegacy: flatMigrationNeeded || merged,625 };626}627628629630631632633634635636637638639640641642643644645646647648649650651function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {652 let merged = false;653 const now = Date.now();654655 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);656 const byRpId = new Map<string, VaultEntry[]>();657 for (const e of candidates) {658 const rpId = e.passkey!.rpId;659 const list = byRpId.get(rpId);660 if (list) list.push(e);661 else byRpId.set(rpId, [e]);662 }663664 for (const [rpId, group] of byRpId) {665 if (group.length === 0) continue;666 group.sort((a, b) => {667 const aT =668 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;669 const bT =670 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;671 return bT - aT;672 });673 const winner = group[0]!;674675 676 677 678 const loginTarget = entries.find((e) => {679 if (e.deletedAt || e.passkey || e.type !== "login") return false;680 if (e.url && extractDomain(e.url) === rpId) return true;681 for (const extra of e.additionalUrls ?? []) {682 if (extractDomain(extra) === rpId) return true;683 }684 return false;685 });686687 if (loginTarget) {688 loginTarget.passkey = winner.passkey;689 loginTarget.modifiedAt = now;690 for (const dup of group) {691 if (!dup.deletedAt) {692 dup.deletedAt = now;693 dup.modifiedAt = now;694 }695 }696 merged = true;697 } else if (group.length > 1) {698 for (let i = 1; i < group.length; i += 1) {699 const dup = group[i]!;700 if (!dup.deletedAt) {701 dup.deletedAt = now;702 dup.modifiedAt = now;703 }704 }705 merged = true;706 }707 }708709 return { merged };710}711712async function tryDecryptSlot(713 vault: StoredVault,714 key: Uint8Array715): Promise<DecryptedPayload | null> {716 try {717 const plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);718 return decodePayload(plain);719 } catch {720 return null;721 }722}723724725726727728function buildDomainIndexSync(all: VaultEntry[]): void {729 domainIndex = new Map();730731 for (const entry of all) {732 if (entry.deletedAt) continue;733 if (entry.type === "note" || entry.type === "card") continue;734735 736 737 738 739 const domains = new Set<string>();740 const primary = extractDomain(entry.url ?? "");741 if (primary) domains.add(primary);742 for (const extra of entry.additionalUrls ?? []) {743 const d = extractDomain(extra);744 if (d) domains.add(d);745 }746 if (domains.size === 0) continue;747748 for (const domain of domains) {749 const bucket = domainIndex.get(domain) ?? [];750 bucket.push(entry);751 domainIndex.set(domain, bucket);752 }753 }754}755756export async function createVault(password: string): Promise<void> {757 await ensureMigrated();758759 const salt = generateSalt();760 derivedKey = await deriveKey(password, salt);761 entries = [];762 vaultMode = "real";763 duressConfigured = false;764 buildDomainIndexSync(entries);765766 767 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =768 await generateAndWrapRootKey();769 rootKey = unhex(rawRootKey);770771 const plaintext = encodePayload(entries, duressConfigured);772 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);773774 const slotA: StoredVault = {775 version: 1,776 salt: hex(salt),777 nonce,778 ciphertext,779 tag,780 recovery_salt: recoverySalt,781 recovery_wrapped_root_key: wrappedRootKey,782 recovery_nonce: wrappedNonce,783 recovery_tag: wrappedTag,784 };785 await writeSlot(SLOT_A, slotA);786787 788 789 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));790791 touchAutoLock();792}793794export async function hasVault(): Promise<boolean> {795 await ensureMigrated();796 return (await loadSlot(SLOT_A)) !== null;797}798799800801802803804export async function isDuressEnabled(): Promise<boolean> {805 return vaultMode === "real" && duressConfigured;806}807808export async function setupDuress(duressPassword: string): Promise<void> {809 if (!derivedKey || vaultMode !== "real") {810 throw new Error("vault must be unlocked in real mode");811 }812 await ensureMigrated();813814 815 816 817 const slotA = await loadSlot(SLOT_A);818 if (!slotA) throw new Error("missing slot A");819820 const decoySalt = generateSalt();821 const decoyKey = await deriveKey(duressPassword, decoySalt);822 try {823 824 825 const targetBytes = slotA.ciphertext.length / 2;826 let plaintext = encodePayload([], false);827 const encoded = new TextEncoder().encode(plaintext);828 if (encoded.length < targetBytes) {829 plaintext = plaintext + " ".repeat(targetBytes - encoded.length);830 }831 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);832 await writeSlot(SLOT_B, {833 version: 1,834 salt: hex(decoySalt),835 nonce,836 ciphertext,837 tag,838 });839 } finally {840 zeroKey(decoyKey);841 }842843 844 845 duressConfigured = true;846 await persistSlotA();847}848849export async function disableDuress(): Promise<void> {850 if (!derivedKey || vaultMode !== "real") {851 throw new Error("vault must be unlocked in real mode");852 }853 await ensureMigrated();854855 856 857 858 const slotA = await loadSlot(SLOT_A);859 if (slotA) {860 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));861 }862863 duressConfigured = false;864 await persistSlotA();865}866867export async function verifyMainPassword(password: string): Promise<boolean> {868 await ensureMigrated();869 const slotA = await loadSlot(SLOT_A);870 if (!slotA) return false;871 const key = await deriveKey(password, unhex(slotA.salt));872 try {873 return (await tryDecryptSlot(slotA, key)) !== null;874 } finally {875 zeroKey(key);876 }877}878879export async function unlock(password: string): Promise<boolean> {880 await ensureMigrated();881882 883 884 885 886 887 const [slotA, slotB] = await Promise.all([888 loadSlot(SLOT_A),889 loadSlot(SLOT_B),890 ]);891892 893 const [keyA, keyB] = await Promise.all([894 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),895 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),896 ]);897898 899 const [payloadA, payloadB] = await Promise.all([900 keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),901 keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),902 ]);903904 if (payloadA && keyA) {905 derivedKey = keyA;906 vaultMode = "real";907 entries = payloadA.entries;908 duressConfigured = payloadA.duressConfigured;909 buildDomainIndexSync(entries);910 await loadSettings();911 touchAutoLock();912 if (keyB) zeroKey(keyB);913914 915 if (slotA) {916 const needsRecoverySetup =917 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;918 if (!needsRecoverySetup) {919 rootKey = await deriveRootKeyFromDerivedKey(920 keyA,921 slotA.recovery_salt!922 );923 }924 }925926 927 928 929 if (payloadA.migratedFromLegacy) {930 await persistSlotA();931 }932 return true;933 }934935 if (payloadB && keyB) {936 derivedKey = keyB;937 vaultMode = "decoy";938 entries = payloadB.entries;939 duressConfigured = false;940 buildDomainIndexSync(entries);941 await loadSettings();942 touchAutoLock();943 if (keyA) zeroKey(keyA);944 return true;945 }946947 if (keyA) zeroKey(keyA);948 if (keyB) zeroKey(keyB);949 return false;950}951952export function lock(): void {953 if (derivedKey) {954 zeroKey(derivedKey);955 derivedKey = null;956 }957 if (rootKey) {958 rootKey.fill(0);959 rootKey = null;960 }961962 entries = [];963 domainIndex = new Map();964 vaultMode = "real";965 duressConfigured = false;966 clearAutoLock();967}968969export function isUnlocked(): boolean {970 return derivedKey !== null;971}972973974975export async function unlockWithKey(976 masterKeyHex: string,977): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {978 try {979 const masterKeyBytes = unhex(masterKeyHex);980 const stored = await loadSlot(SLOT_A);981 if (!stored) return { ok: false, error: "invalid_key" };982 const payload = await tryDecryptSlot(stored, masterKeyBytes);983 if (!payload) return { ok: false, error: "invalid_key" };984985 derivedKey = masterKeyBytes;986 vaultMode = "real";987 entries = payload.entries;988 duressConfigured = payload.duressConfigured;989 buildDomainIndexSync(entries);990 await loadSettings();991 touchAutoLock();992 return { ok: true, entries };993 } catch {994 return { ok: false, error: "invalid_key" };995 }996}9979989991000export function getDerivedKeyHex(): string {1001 if (!derivedKey) throw new Error("vault_not_unlocked");1002 return hex(derivedKey);1003}10041005export function getEntries(): VaultEntry[] {1006 if (!derivedKey) throw new Error("vault locked");1007 return entries.filter((entry) => !entry.deletedAt);1008}10091010const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;10111012export function getIdentities(): SavedIdentity[] {1013 if (!derivedKey) throw new Error("vault locked");1014 const active = entries.filter((e) => !e.deletedAt && e.type === "login");10151016 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1017 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();10181019 for (const entry of active) {1020 const value = entry.username?.trim();1021 if (!value) continue;1022 const map = EMAIL_RE.test(value) ? emailMap : usernameMap;1023 const existing = map.get(value);1024 const domain = extractDomain(entry.url ?? "") ?? "";1025 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;1026 if (!existing || modifiedAt > existing.lastUsed) {1027 map.set(value, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1028 } else {1029 map.set(value, { ...existing, count: existing.count + 1 });1030 }1031 }10321033 const toIdentity = (type: "email" | "username") =>1034 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1035 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1036 });10371038 return [1039 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1040 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1041 ];1042}10431044export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1045 if (!derivedKey) throw new Error("vault locked");10461047 const now = Date.now();1048 const added: VaultEntry[] = [];1049 for (const draft of drafts) {1050 try {1051 const sanitized = sanitizeEntryDraft(draft);1052 const nextEntry: VaultEntry = {1053 ...sanitized,1054 id: crypto.randomUUID(),1055 createdAt: now,1056 modifiedAt: now,1057 };1058 entries.push(nextEntry);1059 added.push(nextEntry);1060 } catch {1061 1062 }1063 }10641065 if (added.length > 0) {1066 await persistEntries();1067 }10681069 return added;1070}107110721073107410751076107710781079export async function restoreEntries(1080 incoming: VaultEntry[],1081 mode: "replace" | "merge"1082): Promise<{ added: number; updated: number; skipped: number }> {1083 if (!derivedKey) throw new Error("vault locked");10841085 1086 1087 1088 if (mode === "replace" && incoming.length === 0) {1089 throw new Error("refuse to replace with empty entry set");1090 }10911092 let added = 0;1093 let updated = 0;1094 let skipped = 0;10951096 if (mode === "replace") {1097 entries = [];1098 for (const entry of incoming) {1099 const normalized = sanitizeRestoredEntry(entry);1100 if (!normalized) {1101 skipped += 1;1102 continue;1103 }1104 entries.push(normalized);1105 added += 1;1106 }1107 } else {1108 const byId = new Map<string, VaultEntry>();1109 for (const e of entries) byId.set(e.id, e);11101111 for (const entry of incoming) {1112 const normalized = sanitizeRestoredEntry(entry);1113 if (!normalized) {1114 skipped += 1;1115 continue;1116 }1117 const existing = byId.get(normalized.id);1118 if (!existing) {1119 entries.push(normalized);1120 added += 1;1121 } else if (normalized.modifiedAt > existing.modifiedAt) {1122 const idx = entries.findIndex((e) => e.id === existing.id);1123 if (idx !== -1) {1124 entries[idx] = normalized;1125 updated += 1;1126 }1127 } else {1128 skipped += 1;1129 }1130 }1131 }11321133 await persistEntries("import");1134 return { added, updated, skipped };1135}1136113711381139114011411142function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1143 if (!entry || typeof entry !== "object") return null;1144 const title = typeof entry.title === "string" ? entry.title.trim() : "";1145 if (!title) return null;1146 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1147 const now = Date.now();1148 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1149 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;11501151 1152 1153 1154 const migrated: VaultEntry = migrateLegacyPasskey(1155 {1156 ...entry,1157 id,1158 title,1159 type: entry.type ?? "login",1160 createdAt,1161 modifiedAt,1162 },1163 entry1164 );11651166 1167 1168 if (1169 migrated.type === "login" &&1170 !migrated.passkey &&1171 typeof migrated.password !== "string"1172 ) {1173 return null;1174 }1175 if (migrated.passkey && !migrated.passkey.credentialId) {1176 return null;1177 }11781179 return migrated;1180}118111821183118411851186export async function clearVaultEntries(1187 confirmation: "REPLACE_ALL"1188): Promise<void> {1189 if (confirmation !== "REPLACE_ALL") {1190 throw new Error("clearVaultEntries requires explicit confirmation");1191 }1192 if (!derivedKey) throw new Error("vault locked");1193 for (const entry of entries) {1194 if (!entry.deletedAt) {1195 entry.deletedAt = Date.now();1196 entry.modifiedAt = Date.now();1197 }1198 }1199 await persistEntries();1200}12011202export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1203 if (!derivedKey) throw new Error("vault locked");12041205 const sanitized = sanitizeEntryDraft(entry);1206 const now = Date.now();1207 const nextEntry: VaultEntry = {1208 ...sanitized,1209 id: crypto.randomUUID(),1210 createdAt: now,1211 modifiedAt: now,1212 1213 1214 passwordChangedAt: sanitized.password ? now : undefined,1215 };12161217 entries.push(nextEntry);1218 await persistEntries();12191220 1221 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1222 checkBreach(nextEntry.password).then(async (count) => {1223 if (count > 0) {1224 const idx = entries.findIndex((e) => e.id === nextEntry.id);1225 if (idx !== -1) {1226 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };1227 await persistEntries();1228 }1229 }1230 });1231 }12321233 return nextEntry;1234}12351236export async function updateEntry(1237 id: string,1238 patch: Partial<VaultEntry>1239): Promise<void> {1240 if (!derivedKey) throw new Error("vault locked");12411242 const idx = entries.findIndex((entry) => entry.id === id);1243 if (idx === -1 || entries[idx].deletedAt) {1244 throw new Error("entry not found");1245 }12461247 const current = entries[idx];1248 const sanitizedPatch = sanitizeEntryPatch(patch);1249 1250 1251 1252 const passwordChanged =1253 "password" in patch &&1254 typeof sanitizedPatch.password === "string" &&1255 sanitizedPatch.password !== current.password;1256 entries[idx] = {1257 ...current,1258 ...sanitizedPatch,1259 modifiedAt: Date.now(),1260 ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),1261 };12621263 1264 1265 1266 1267 if (1268 entries[idx].type === "login" &&1269 !entries[idx].password &&1270 !entries[idx].passkey1271 ) {1272 entries[idx] = current;1273 throw new Error("password or passkey required");1274 }12751276 1277 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1278 entries[idx] = current;1279 throw new Error("card number is required for card entries");1280 }12811282 await persistEntries();12831284 1285 1286 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1287 entries[idx] = { ...entries[idx], breach: undefined };1288 await persistEntries();1289 const password = entries[idx].password;1290 if (password) {1291 checkBreach(password).then(async (count) => {1292 const i = entries.findIndex((e) => e.id === id);1293 if (i !== -1 && count > 0) {1294 entries[i] = { ...entries[i], breach: count, modifiedAt: Date.now() };1295 await persistEntries();1296 }1297 });1298 }1299 }1300}13011302export async function deleteEntry(id: string): Promise<void> {1303 if (!derivedKey) throw new Error("vault locked");13041305 const entry = entries.find((item) => item.id === id);1306 if (!entry || entry.deletedAt) {1307 throw new Error("entry not found");1308 }13091310 entry.deletedAt = Date.now();1311 entry.modifiedAt = Date.now();1312 await persistEntries();1313}13141315export function getDeletedEntries(): VaultEntry[] {1316 if (!derivedKey) return [];1317 return entries.filter((e) => !!e.deletedAt);1318}13191320export async function restoreEntry(id: string): Promise<void> {1321 if (!derivedKey) throw new Error("vault locked");1322 const entry = entries.find((e) => e.id === id && e.deletedAt);1323 if (!entry) throw new Error("deleted entry not found");1324 delete entry.deletedAt;1325 entry.modifiedAt = Date.now();1326 await persistEntries();1327 buildDomainIndexSync(entries);1328}13291330export async function purgeEntry(id: string): Promise<void> {1331 if (!derivedKey) throw new Error("vault locked");1332 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1333 if (idx === -1) throw new Error("deleted entry not found");1334 entries.splice(idx, 1);1335 await persistEntries();1336}133713381339export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1340 if (!derivedKey || retentionMs === 0) return 0;1341 const cutoff = Date.now() - retentionMs;1342 const before = entries.length;1343 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1344 const purged = before - entries.length;1345 if (purged > 0) await persistEntries();1346 return purged;1347}13481349export function queryDomain(domain: string): VaultEntry[] {1350 return domainIndex.get(domain) ?? [];1351}13521353export function getEntryById(id: string): VaultEntry | undefined {1354 return entries.find((entry) => entry.id === id && !entry.deletedAt);1355}13561357async function persistSlotA(1358 reason: VaultSnapshot["reason"] = "local_save",1359): Promise<StoredVault> {1360 if (!derivedKey) throw new Error("vault locked");13611362 const slotA = await loadSlot(SLOT_A);1363 if (!slotA) throw new Error("missing slot A");13641365 1366 1367 await takeVaultSnapshot(reason);13681369 const plaintext = encodePayload(entries, duressConfigured);1370 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);13711372 1373 const nextSlotA: StoredVault = {1374 ...slotA,1375 nonce,1376 ciphertext,1377 tag,1378 1379 };1380 await writeSlot(SLOT_A, nextSlotA);13811382 1383 1384 1385 if (!duressConfigured) {1386 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1387 }13881389 return nextSlotA;1390}13911392async function persistSlotB(): Promise<void> {1393 if (!derivedKey) throw new Error("vault locked");13941395 const slotB = await loadSlot(SLOT_B);1396 if (!slotB) throw new Error("missing slot B");13971398 1399 1400 const plaintext = encodePayload(entries, false);1401 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);14021403 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1404}14051406async function persistEntries(1407 reason: VaultSnapshot["reason"] = "local_save",1408): Promise<void> {1409 if (!derivedKey) throw new Error("vault locked");14101411 if (vaultMode === "decoy") {1412 await persistSlotB();1413 } else {1414 await persistSlotA(reason);1415 }1416 buildDomainIndexSync(entries);1417}14181419function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1420 if (!Array.isArray(values)) return undefined;1421 const seen = new Set<string>();1422 const cleaned: string[] = [];1423 for (const value of values) {1424 const trimmed = typeof value === "string" ? value.trim() : "";1425 if (!trimmed) continue;1426 const key = trimmed.toLowerCase();1427 if (seen.has(key)) continue;1428 seen.add(key);1429 cleaned.push(trimmed);1430 }1431 return cleaned.length > 0 ? cleaned : undefined;1432}14331434function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1435 const title = entry.title.trim();1436 1437 1438 1439 let type: VaultEntry["type"] = entry.type ?? "login";1440 if (type === "passkey") type = "login";14411442 if (!title) throw new Error("title is required");1443 1444 1445 if (type === "login" && !entry.password?.trim() && !entry.passkey) {1446 throw new Error("password or passkey required");1447 }1448 if (entry.passkey) {1449 const p = entry.passkey;1450 if (1451 !p.rpId ||1452 !p.credentialId ||1453 !p.publicKeyCose ||1454 !p.userHandle ||1455 !p.userName ||1456 (p.algorithm !== -7 && p.algorithm !== -8)1457 ) {1458 throw new Error("invalid passkey credential");1459 }1460 }1461 if (type === "card") {1462 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1463 throw new Error("cardholder name and card number are required");1464 }1465 }14661467 return {1468 type,1469 title,1470 username: type === "login" ? entry.username?.trim() ?? "" : undefined,1471 password: type === "login" ? entry.password : undefined,1472 url: type === "login" ? entry.url?.trim() ?? "" : undefined,1473 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1474 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1475 notes: entry.notes?.trim() || undefined,1476 breach: type === "login" ? entry.breach : undefined,1477 canary: type === "login" ? entry.canary : undefined,1478 tags: entry.tags,1479 passkey: entry.passkey,1480 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1481 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1482 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1483 expYear: type === "card" ? entry.expYear?.trim() : undefined,1484 cvc: type === "card" ? entry.cvc?.trim() : undefined,1485 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1486 network: type === "card" ? entry.network : undefined,1487 last4: type === "card" ? entry.last4?.trim() : undefined,1488 };1489}14901491function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1492 const nextPatch: Partial<VaultEntry> = {};14931494 if ("type" in patch) {1495 nextPatch.type = patch.type;1496 }14971498 if ("title" in patch) {1499 const title = patch.title?.trim() ?? "";1500 if (!title) throw new Error("title is required");1501 nextPatch.title = title;1502 }15031504 if ("url" in patch) {1505 nextPatch.url = patch.url?.trim() ?? "";1506 }15071508 if ("additionalUrls" in patch) {1509 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1510 const seen = new Set<string>();1511 const cleaned: string[] = [];1512 for (const value of incoming) {1513 const trimmed = typeof value === "string" ? value.trim() : "";1514 if (!trimmed) continue;1515 const key = trimmed.toLowerCase();1516 if (seen.has(key)) continue;1517 seen.add(key);1518 cleaned.push(trimmed);1519 }1520 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1521 }15221523 if ("username" in patch) {1524 nextPatch.username = patch.username?.trim() ?? "";1525 }15261527 if ("email" in patch) {1528 nextPatch.email = patch.email?.trim() || undefined;1529 }15301531 if ("password" in patch) {1532 const password = patch.password ?? "";1533 1534 nextPatch.password = password || undefined;1535 }15361537 if ("totp" in patch) {1538 nextPatch.totp = patch.totp?.trim() || undefined;1539 }15401541 if ("notes" in patch) {1542 nextPatch.notes = patch.notes?.trim() || undefined;1543 }15441545 if ("breach" in patch) {1546 nextPatch.breach = patch.breach;1547 }15481549 if ("canary" in patch) {1550 nextPatch.canary = patch.canary;1551 }15521553 if ("passkey" in patch) {1554 1555 1556 1557 nextPatch.passkey = patch.passkey;1558 }15591560 if ("tags" in patch) {1561 nextPatch.tags = patch.tags;1562 }15631564 if ("cardholderName" in patch) {1565 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1566 }1567 if ("cardNumber" in patch) {1568 const n = patch.cardNumber ?? "";1569 nextPatch.cardNumber = n || undefined;1570 }1571 if ("expMonth" in patch) {1572 nextPatch.expMonth = patch.expMonth?.trim() || undefined;1573 }1574 if ("expYear" in patch) {1575 nextPatch.expYear = patch.expYear?.trim() || undefined;1576 }1577 if ("cvc" in patch) {1578 nextPatch.cvc = patch.cvc?.trim() || undefined;1579 }1580 if ("cardNickname" in patch) {1581 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1582 }1583 if ("network" in patch) {1584 nextPatch.network = patch.network;1585 }1586 if ("last4" in patch) {1587 nextPatch.last4 = patch.last4?.trim() || undefined;1588 }15891590 return nextPatch;1591}15921593function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1594 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1595 const modifiedAt =1596 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;15971598 const base: VaultEntry = {1599 id: entry.id,1600 type: "type" in entry ? entry.type : "login",1601 title: entry.title,1602 username: entry.username,1603 password: entry.password,1604 url: entry.url,1605 totp: entry.totp,1606 notes: entry.notes,1607 breach: entry.breach,1608 canary: "canary" in entry ? entry.canary : undefined,1609 passkey: "passkey" in entry ? entry.passkey : undefined,1610 createdAt,1611 modifiedAt,1612 deletedAt: entry.deletedAt,1613 tags: "tags" in entry ? entry.tags : undefined,1614 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1615 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1616 expMonth: "expMonth" in entry ? entry.expMonth : undefined,1617 expYear: "expYear" in entry ? entry.expYear : undefined,1618 cvc: "cvc" in entry ? entry.cvc : undefined,1619 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1620 network: "network" in entry ? entry.network : undefined,1621 last4: "last4" in entry ? entry.last4 : undefined,1622 };16231624 return migrateLegacyPasskey(base, entry);1625}162616271628162916301631163216331634function migrateLegacyPasskey(1635 base: VaultEntry,1636 raw: VaultEntry | LegacyVaultEntry1637): VaultEntry {1638 if (base.passkey) return base;1639 const flat = raw as Partial<VaultEntry>;1640 const isPasskeyType = base.type === "passkey";1641 const hasFlatPasskeyFields =1642 typeof flat.rpId === "string" &&1643 typeof flat.credentialId === "string" &&1644 typeof flat.publicKeyCose === "string" &&1645 typeof flat.userHandle === "string" &&1646 typeof flat.userName === "string";1647 if (!isPasskeyType && !hasFlatPasskeyFields) return base;1648 if (!hasFlatPasskeyFields) return base;16491650 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;1651 base.passkey = {1652 rpId: flat.rpId!,1653 rpName: flat.rpName ?? flat.rpId!,1654 credentialId: flat.credentialId!,1655 publicKeyCose: flat.publicKeyCose!,1656 privateKeyJwk: flat.privateKeyJwk,1657 userHandle: flat.userHandle!,1658 userName: flat.userName!,1659 userDisplayName: flat.userDisplayName ?? flat.userName!,1660 algorithm,1661 signCount: flat.signCount ?? 0,1662 aaguid: flat.aaguid ?? "",1663 backupEligible: flat.backupEligible === true,1664 backupState: flat.backupState === true,1665 lastUsedAt: flat.lastUsedAt,1666 browser: flat.browser,1667 transports: flat.transports,1668 createdAt: base.createdAt,1669 };1670 1671 1672 1673 if (isPasskeyType) base.type = "login";1674 if (!base.username) base.username = flat.userName;1675 if (!base.url) base.url = `https://${flat.rpId}`;1676 return base;1677}1678167916801681168216831684function entryDupeKey(e: VaultEntry): string {1685 if (e.type === "note") return `note|${e.title.toLowerCase()}`;1686 1687 1688 1689 1690 if (e.passkey) {1691 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;1692 }1693 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1694}169516961697export function countDuplicates(): number {1698 const best = new Map<string, VaultEntry>();1699 let dupeCount = 0;1700 for (const e of entries.filter((e) => !e.deletedAt)) {1701 const key = entryDupeKey(e);1702 const existing = best.get(key);1703 if (!existing) {1704 best.set(key, e);1705 } else {1706 dupeCount++;1707 1708 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1709 best.set(key, e);1710 }1711 }1712 }1713 return dupeCount;1714}171517161717171817191720export async function deduplicateEntries(): Promise<number> {1721 if (!derivedKey) throw new Error("vault locked");1722 1723 1724 if (vaultMode === "decoy") {1725 throw new Error("deduplicate disabled in decoy mode");1726 }17271728 const best = new Map<string, VaultEntry>();1729 for (const e of entries.filter((en) => !en.deletedAt)) {1730 const key = entryDupeKey(e);1731 const existing = best.get(key);1732 if (!existing) {1733 best.set(key, e);1734 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1735 best.set(key, e);1736 }1737 }17381739 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1740 const now = Date.now();1741 let removed = 0;1742 entries = entries.map((e) => {1743 if (!e.deletedAt && !keepIds.has(e.id)) {1744 removed++;1745 return { ...e, deletedAt: now };1746 }1747 return e;1748 });17491750 if (removed > 0) await persistEntries();1751 return removed;1752}175317541755175617571758175917601761const HISTORY_KEY = "peach_vault_history";1762const HISTORY_MAX = 10;17631764176517661767176817691770export async function takeVaultSnapshot(1771 reason: VaultSnapshot["reason"],1772): Promise<void> {1773 try {1774 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);1775 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];1776 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];17771778 1779 1780 1781 if (!slotA) return;17821783 const vault_a = JSON.stringify(slotA);1784 const vault_b = JSON.stringify(slotB ?? slotA);17851786 1787 1788 1789 let entryCount: number | null = null;1790 if (derivedKey && vaultMode === "real") {1791 try {1792 entryCount = entries.filter((e) => !e.deletedAt).length;1793 } catch {1794 entryCount = null;1795 }1796 }17971798 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];1799 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)1800 ? existingRaw1801 : { snapshots: [] };18021803 1804 1805 1806 const latest = history.snapshots[0];1807 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {1808 return;1809 }18101811 const snapshot: VaultSnapshot = {1812 id: crypto.randomUUID(),1813 savedAt: Date.now(),1814 reason,1815 vault_a,1816 vault_b,1817 entryCount,1818 };18191820 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);1821 await chrome.storage.local.set({ [HISTORY_KEY]: history });1822 } catch {1823 1824 1825 }1826}182718281829export async function clearVaultHistory(): Promise<void> {1830 try {1831 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });1832 } catch {1833 1834 }1835}183618371838export async function getVaultHistory(): Promise<VaultSnapshot[]> {1839 const raw = await chrome.storage.local.get(HISTORY_KEY);1840 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];1841 if (!history || !Array.isArray(history.snapshots)) return [];1842 return history.snapshots;1843}18441845184618471848184918501851export async function restoreVaultSnapshot(1852 snapshotId: string,1853): Promise<{ ok: true } | { ok: false; error: string }> {1854 const history = await getVaultHistory();1855 const snapshot = history.find((s) => s.id === snapshotId);1856 if (!snapshot) return { ok: false, error: "snapshot_not_found" };18571858 1859 1860 1861 await takeVaultSnapshot("restore");18621863 let restoredA: StoredVault;1864 let restoredB: StoredVault;1865 try {1866 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;1867 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;1868 } catch {1869 return { ok: false, error: "snapshot_corrupt" };1870 }18711872 await writeSlot(SLOT_A, restoredA);1873 await writeSlot(SLOT_B, restoredB);18741875 1876 1877 1878 lock();18791880 return { ok: true };1881}18821883188418851886188718881889export async function changePassword(1890 currentPassword: string,1891 newPassword: string1892): Promise<boolean> {1893 if (!derivedKey || vaultMode !== "real") return false;18941895 1896 const valid = await verifyMainPassword(currentPassword);1897 if (!valid) return false;18981899 const slotA = await loadSlot(SLOT_A);1900 if (!slotA) return false;19011902 1903 const newSalt = generateSalt();1904 const newKey = await deriveKey(newPassword, newSalt);19051906 1907 const plaintext = encodePayload(entries, duressConfigured);1908 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);19091910 const freshSlotA: StoredVault = {1911 ...slotA,1912 salt: hex(newSalt),1913 nonce,1914 ciphertext,1915 tag,1916 };1917 1918 1919 1920 await takeVaultSnapshot("local_save");1921 await writeSlot(SLOT_A, freshSlotA);19221923 1924 1925 1926 zeroKey(derivedKey);1927 derivedKey = newKey;19281929 1930 1931 const {1932 rawRootKey: freshRawKey,1933 recoverySalt: freshSalt,1934 wrappedRootKey: freshWrapped,1935 wrappedNonce: freshNonce,1936 wrappedTag: freshTag,1937 } = await generateAndWrapRootKey();1938 rootKey = unhex(freshRawKey);1939 const slotAfterRekey = await loadSlot(SLOT_A);1940 if (slotAfterRekey) {1941 await writeSlot(SLOT_A, {1942 ...slotAfterRekey,1943 recovery_salt: freshSalt,1944 recovery_wrapped_root_key: freshWrapped,1945 recovery_nonce: freshNonce,1946 recovery_tag: freshTag,1947 });1948 }19491950 1951 1952 1953 if (!duressConfigured) {1954 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1955 }19561957 return true;1958}195919601961196219631964export async function getVaultBlobForSync(): Promise<{1965 blob: string;1966 timestamp: number;1967 license?: string;1968} | null> {1969 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, 'peach_last_modified_at', 'peach_license']);1970 const slotA = (raw as Record<string, unknown>)[SLOT_A];1971 if (!slotA) return null;1972 const slotB = (raw as Record<string, unknown>)[SLOT_B];1973 const blob = JSON.stringify({ a: slotA, b: slotB });1974 const rawTs = (raw as Record<string, unknown>)['peach_last_modified_at'];1975 const timestamp = typeof rawTs === 'string' ? parseInt(rawTs) : 0;1976 const license = (raw as Record<string, unknown>)['peach_license'];1977 return {1978 blob,1979 timestamp,1980 license: typeof license === 'string' ? license : undefined,1981 };1982}19831984export async function applyIncomingVault(1985 blobJson: string,1986 remoteTimestamp: number,1987 remoteLicense?: string,1988): Promise<boolean> {1989 const raw = await chrome.storage.local.get('peach_last_modified_at');1990 const rawTs = (raw as Record<string, unknown>)['peach_last_modified_at'];1991 const localTimestamp = typeof rawTs === 'string' ? parseInt(rawTs) : 0;19921993 if (remoteLicense) {1994 const licenseRaw = await chrome.storage.local.get('peach_license');1995 const existing = (licenseRaw as Record<string, unknown>)['peach_license'];1996 if (!existing) {1997 await chrome.storage.local.set({ peach_license: remoteLicense });1998 }1999 }20002001 if (remoteTimestamp <= localTimestamp) return false;20022003 2004 await takeVaultSnapshot('sync_received').catch(() => {});20052006 const { a, b } = JSON.parse(blobJson) as { a: unknown; b?: unknown };2007 await writeSlot(SLOT_A, a as StoredVault);2008 if (b) await writeSlot(SLOT_B, b as StoredVault);2009 await chrome.storage.local.set({2010 peach_last_sync_at: String(Date.now()),2011 peach_last_modified_at: String(remoteTimestamp),2012 });20132014 return true;2015}20162017export async function getSyncIdentity(): Promise<{2018 privateKeyBytes: Uint8Array;2019 publicKeyBytes: Uint8Array;2020 publicKeyHex: string;2021}> {2022 const raw = await chrome.storage.local.get('peach_sync_identity');2023 const stored = (raw as Record<string, unknown>)['peach_sync_identity'];2024 if (typeof stored === 'string') {2025 const { priv, pub } = JSON.parse(stored) as { priv: string; pub: string };2026 const privateKeyBytes = base64DecodeBytes(priv);2027 const publicKeyBytes = base64DecodeBytes(pub);2028 return { privateKeyBytes, publicKeyBytes, publicKeyHex: bytesToHex(publicKeyBytes) };2029 }20302031 2032 const { generateKeyPair } = await import('@stablelib/x25519');2033 const keypair = generateKeyPair();2034 await chrome.storage.local.set({2035 peach_sync_identity: JSON.stringify({2036 priv: base64EncodeBytes(keypair.secretKey),2037 pub: base64EncodeBytes(keypair.publicKey),2038 }),2039 });2040 return {2041 privateKeyBytes: keypair.secretKey,2042 publicKeyBytes: keypair.publicKey,2043 publicKeyHex: bytesToHex(keypair.publicKey),2044 };2045}20462047204820492050export async function reloadEntriesAfterSync(): Promise<boolean> {2051 if (!derivedKey) return false;2052 const slotA = await loadSlot(SLOT_A);2053 if (!slotA) return false;2054 try {2055 const payload = await tryDecryptSlot(slotA, derivedKey);2056 if (!payload) return false;2057 entries = payload.entries;2058 buildDomainIndexSync(entries);2059 return true;2060 } catch {2061 return false;2062 }2063}20642065function bytesToHex(bytes: Uint8Array): string {2066 return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');2067}20682069function base64EncodeBytes(bytes: Uint8Array): string {2070 let binary = '';2071 for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);2072 return btoa(binary);2073}20742075function base64DecodeBytes(b64: string): Uint8Array {2076 const binary = atob(b64);2077 const bytes = new Uint8Array(binary.length);2078 for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);2079 return bytes;2080}