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;5253545556575859606162const DURESS_SLOT_SIZE = 262_144;6364let derivedKey: Uint8Array | null = null;6566676869let rootKey: Uint8Array | null = null;70let entries: VaultEntry[] = [];71let domainIndex: Map<string, VaultEntry[]> = new Map();72let vaultMode: "real" | "decoy" = "real";73747576let duressConfigured = false;7778let migrationRan = false;7980818283848586878889let lastUnlockFailureReason: string | null = null;9091export function consumeLastUnlockFailureReason(): string | null {92 const r = lastUnlockFailureReason;93 lastUnlockFailureReason = null;94 return r;95}96979899100101export type TextSize = "small" | "medium" | "large";102103export interface VaultSettings {104 accentColor?: AccentColorName;105 theme?: "dark" | "light";106 autoLockMinutes?: number;107 clipboardClearSeconds?: number;108 lockOnClose?: boolean;109 breachMonitoring?: boolean;110 passkeyProviderEnabled?: boolean;111 trashRetentionDays?: number;112 manualSummonDomains?: Record<string, ManualSummonRecord>;113 declinedCardFingerprints?: string[];114 115116117118119 textSize?: TextSize;120 121122123124125 reduceMotion?: boolean;126 127128129130131 biometricsEnabled?: boolean;132 133134135136137138139 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;140}141142let settings: VaultSettings = {};143144export function getSettings(): VaultSettings {145 return settings;146}147148export async function ensureSettingsLoaded(): Promise<VaultSettings> {149 await loadSettings();150 return settings;151}152153export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {154 await loadSettings();155 settings = { ...settings, ...patch };156 await persistSettings();157}158159async function persistSettings(): Promise<void> {160 try {161 await chrome.storage.local.set({ peach_settings: settings });162 } catch (err) {163 throw mapStorageError(err);164 }165}166167function mapStorageError(err: unknown): Error {168 const message = err instanceof Error ? err.message : String(err);169 if (/QUOTA_BYTES|quota/i.test(message)) {170 return new Error("STORAGE_QUOTA_EXCEEDED");171 }172 return err instanceof Error ? err : new Error(message);173}174175async function loadSettings(): Promise<void> {176 const result = await chrome.storage.local.get("peach_settings");177 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};178}179180181182183184185function fireSyncTrigger(reason: "vault_change"): void {186 void import("./sync-trigger")187 .then((mod) => mod.triggerSyncIfReachable({ reason }))188 .catch(() => {189 190 });191}192193194195196197export async function checkBreach(password: string): Promise<number> {198 199 200 const encoder = new TextEncoder();201 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));202 const hashHex = Array.from(new Uint8Array(hashBuffer))203 .map((b) => b.toString(16).padStart(2, "0"))204 .join("")205 .toUpperCase();206207 const prefix = hashHex.slice(0, 5);208 const suffix = hashHex.slice(5);209210 try {211 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);212 if (!res.ok) return 0;213 const text = await res.text();214 const match = text.split("\r\n").find((l) => l.startsWith(suffix));215 return match ? parseInt(match.split(":")[1], 10) : 0;216 } catch {217 return 0; 218 }219}220221export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {222 const fingerprint = await cardFingerprint(last4, expMonth, expYear);223 await loadSettings();224 settings = {225 ...settings,226 declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],227 };228 await persistSettings();229}230231export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {232 await loadSettings();233 const fingerprint = await cardFingerprint(last4, expMonth, expYear);234 return (settings.declinedCardFingerprints ?? []).includes(fingerprint);235}236237async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {238 const encoder = new TextEncoder();239 const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));240 return Array.from(new Uint8Array(buf).slice(0, 8))241 .map((b) => b.toString(16).padStart(2, "0"))242 .join("");243}244245function hex(buf: Uint8Array): string {246 return Array.from(buf)247 .map((b) => b.toString(16).padStart(2, "0"))248 .join("");249}250251function unhex(s: string): Uint8Array {252 const out = new Uint8Array(s.length / 2);253 for (let i = 0; i < s.length; i += 2) {254 out[i / 2] = parseInt(s.slice(i, i + 2), 16);255 }256 return out;257}258259260261262263264265266267268269270async function generateAndWrapRootKey(): Promise<{271 rawRootKey: string; 272 recoverySalt: string;273 wrappedRootKey: string;274 wrappedNonce: string;275 wrappedTag: string;276}> {277 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));278279 280 const words = await rootKeyToWords(rootKeyBytes);281 const wordMaterial = await wordsToRootKeyMaterial(words);282 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));283 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);284285 286 287 const nonce = crypto.getRandomValues(new Uint8Array(12));288 const encoded = new TextEncoder().encode(hex(rootKeyBytes));289 const raw = await crypto.subtle.encrypt(290 {291 name: "AES-GCM",292 iv: nonce,293 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,294 tagLength: 128,295 },296 recoveryKey,297 encoded298 );299 300 const rawU8 = new Uint8Array(raw);301 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));302 const tag = hex(rawU8.slice(rawU8.length - 16));303304 return {305 rawRootKey: hex(rootKeyBytes),306 recoverySalt: hex(recoverySalt),307 wrappedRootKey: ciphertext,308 wrappedNonce: hex(nonce),309 wrappedTag: tag,310 };311}312313314315316317318export async function unwrapRootKeyForRecovery(319 wrappedRootKey: string,320 wrappedNonce: string,321 wrappedTag: string,322 recoverySaltHex: string,323 words: string[]324): Promise<void> {325 const wordMaterial = await wordsToRootKeyMaterial(words);326 const recoverySalt = unhex(recoverySaltHex);327 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);328329 330 331 332 333 334 const nonceRaw = unhex(wrappedNonce);335 const ciphertextRaw = unhex(wrappedRootKey);336 const tagRaw = unhex(wrappedTag);337 338 339 const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;340 const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);341 combined.set(ciphertextRaw, 0);342 combined.set(tagRaw, ciphertextRaw.length);343 const raw = await crypto.subtle.decrypt(344 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },345 recoveryKey,346 combined347 );348 const decryptedHex = new TextDecoder().decode(raw);349 rootKey = unhex(decryptedHex);350}351352353354355356357358359360361export async function rewrapRootKeyForPassword(362 newDerivedKey: Uint8Array,363 _newSalt: Uint8Array364): Promise<void> {365 if (!rootKey) throw new Error("root key not set");366 const slotA = await loadSlot(SLOT_A);367 if (!slotA) throw new Error("missing slot A");368 const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);369 await writeSlot(SLOT_A, { ...slotA, ...pwRoot });370}371372373374375376377async function reencryptVaultForNewKey(378 newKey: Uint8Array,379 slotA: StoredVault380): Promise<void> {381 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);382 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");383 await writeSlot(SLOT_A, {384 ...slotA,385 nonce,386 ciphertext,387 tag,388 });389}390391392393394395396export async function changePasswordWithNewKey(397 newSalt: Uint8Array,398 newKey: Uint8Array399): Promise<void> {400 const slotA = await loadSlot(SLOT_A);401 if (!slotA) throw new Error("missing slot A");402 403 404 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });405 if (derivedKey) zeroKey(derivedKey);406 derivedKey = newKey;407 408 409 410 411 if (rootKey) {412 const slotAfter = await loadSlot(SLOT_A);413 if (slotAfter) {414 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);415 await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });416 }417 }418 if (!duressConfigured) {419 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));420 }421 await stampLocalVaultMutation();422}423424425426427428429export { deriveKey, generateSalt };430431export async function loadSlotA(): Promise<StoredVault | null> {432 return loadSlot(SLOT_A);433}434435436437438439440441442443444445446447async function deriveRootKeyFromDerivedKey(448 derivedKeyBytes: Uint8Array,449 slotA: StoredVault,450): Promise<Uint8Array | null> {451 if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {452 return null;453 }454 try {455 const rootHex = await decrypt(456 derivedKeyBytes,457 slotA.pw_root_nonce,458 slotA.pw_root_ciphertext,459 slotA.pw_root_tag,460 "pw_root_v1",461 );462 return unhex(rootHex);463 } catch {464 return null;465 }466}467468469470async function wrapRootKeyForDerivedKey(471 derivedKeyBytes: Uint8Array,472 rootKeyBytes: Uint8Array,473): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {474 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");475 return {476 pw_root_ciphertext: ciphertext,477 pw_root_nonce: nonce,478 pw_root_tag: tag,479 };480}481482483484485486487488489export async function migrateToRecovery(): Promise<void> {490 if (!derivedKey || vaultMode !== "real") {491 throw new Error("vault must be unlocked in real mode");492 }493 const slotA = await loadSlot(SLOT_A);494 if (!slotA) throw new Error("missing slot A");495 const hasRecoveryEscrow =496 !!slotA.recovery_salt && !!slotA.recovery_wrapped_root_key;497 const hasPwRoot = !!slotA.pw_root_ciphertext && !!slotA.pw_root_nonce;498 if (hasRecoveryEscrow && hasPwRoot) return; 499500 501 502 503 504 505 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =506 await generateAndWrapRootKey();507 rootKey = unhex(rawRootKey);508509 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);510 const nextSlotA: StoredVault = {511 ...slotA,512 recovery_salt: recoverySalt,513 recovery_wrapped_root_key: wrappedRootKey,514 recovery_nonce: wrappedNonce,515 recovery_tag: wrappedTag,516 ...pwRoot,517 };518 await writeSlot(SLOT_A, nextSlotA);519 await stampLocalVaultMutation();520}521522523524525526527export async function getRecoveryWords(): Promise<string[]> {528 if (!rootKey) {529 throw new Error("recovery not set up — vault may need migration");530 }531 return rootKeyToWords(rootKey);532}533534async function loadSlot(key: string): Promise<StoredVault | null> {535 const result = await chrome.storage.local.get(key);536 return (result as Record<string, StoredVault | undefined>)[key] ?? null;537}538539async function writeSlot(key: string, vault: StoredVault): Promise<void> {540 try {541 await chrome.storage.local.set({ [key]: vault });542 } catch (err) {543 throw mapStorageError(err);544 }545}546547548549550551552553554let lastEntryTimestamp = 0;555function monotonicEntryTimestamp(currentModifiedAt?: number): number {556 const base = Math.max(557 Date.now(),558 (currentModifiedAt ?? 0) + 1,559 lastEntryTimestamp + 1,560 );561 lastEntryTimestamp = base;562 return base;563}564565async function stampLocalVaultMutation(): Promise<void> {566 const raw = await chrome.storage.local.get("peach_last_modified_at");567 const previous = Number(568 (raw as Record<string, unknown>).peach_last_modified_at,569 );570 const next = Math.max(571 Date.now(),572 Number.isFinite(previous) ? previous + 1 : 0,573 );574 await chrome.storage.local.set({575 peach_last_modified_at: String(next),576 });577}578579580581582583584async function ensureMigrated(): Promise<void> {585 if (migrationRan) return;586 migrationRan = true;587588 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];589 const raw = await chrome.storage.local.get(keys);590 const slotAExists = raw[SLOT_A] !== undefined;591 const slotBExists = raw[SLOT_B] !== undefined;592 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;593 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;594595 596 if (!slotAExists && !legacyMain) return;597598 599 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;600601 let didMigrate = false;602 if (legacyMain && !slotAExists) {603 await writeSlot(SLOT_A, legacyMain);604 didMigrate = true;605 }606607 608 if (!slotBExists) {609 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;610 if (legacyDecoy) {611 await writeSlot(SLOT_B, legacyDecoy);612 didMigrate = true;613 } else if (target) {614 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));615 didMigrate = true;616 }617 }618619 const toRemove: string[] = [];620 if (legacyMain) toRemove.push(LEGACY_MAIN);621 if (legacyDecoy) toRemove.push(LEGACY_DECOY);622 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);623 if (toRemove.length > 0) {624 await chrome.storage.local.remove(toRemove);625 didMigrate = true;626 }627 if (didMigrate) {628 await stampLocalVaultMutation();629 }630}631632633634635636637638639640641642function padPlaintext(json: string, minBytes = 0): string {643 const encoded = new TextEncoder().encode(json);644 const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);645 646 const target = Math.max(bucket, minBytes);647 const padLen = target - encoded.length;648 return json + " ".repeat(padLen);649}650651652653654655656657658659660661const CSPRNG_CHUNK = 65_536;662663function fillRandom(buf: Uint8Array): Uint8Array {664 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {665 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);666 const chunk = new Uint8Array(end - offset);667 crypto.getRandomValues(chunk);668 buf.set(chunk, offset);669 }670 return buf;671}672673function generateNoiseBlob(ciphertextBytes: number): StoredVault {674 const salt = crypto.getRandomValues(new Uint8Array(16));675 const nonce = crypto.getRandomValues(new Uint8Array(12));676 const cipher = fillRandom(new Uint8Array(ciphertextBytes));677 const tag = crypto.getRandomValues(new Uint8Array(16));678 return {679 version: 1,680 salt: hex(salt),681 nonce: hex(nonce),682 ciphertext: hex(cipher),683 tag: hex(tag),684 };685}686687688689690691type VaultPayload = {692 entries: VaultEntry[];693 duressConfigured?: boolean;694};695696type DecryptedPayload = {697 entries: VaultEntry[];698 duressConfigured: boolean;699 700701702703704705706 migratedFromLegacy: boolean;707};708709function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {710 const envelope: VaultPayload = { entries, duressConfigured: duress };711 return padPlaintext(JSON.stringify(envelope), minBytes);712}713714function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {715 if ("created" in raw || "updated" in raw) return true;716 const flat = raw as Partial<VaultEntry>;717 if (flat.type === "passkey") return true;718 719 if (flat.rpId !== undefined && flat.passkey === undefined) return true;720 return false;721}722723function decodePayload(plain: string): DecryptedPayload {724 const parsed = JSON.parse(plain) as725 | Array<VaultEntry | LegacyVaultEntry>726 | VaultPayload;727728 const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];729 const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);730731 const normalized = rawEntries.map(normalizeEntry);732 const { merged } = mergePasskeyOnlyIntoLogins(normalized);733734 if (Array.isArray(parsed)) {735 return {736 entries: normalized,737 duressConfigured: false,738 migratedFromLegacy: true, 739 };740 }741742 return {743 entries: normalized,744 duressConfigured: parsed.duressConfigured === true,745 migratedFromLegacy: flatMigrationNeeded || merged,746 };747}748749750751752753754755756757758759760761762763764765766767768769770771772function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {773 let merged = false;774 const now = Date.now();775776 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);777 const byRpId = new Map<string, VaultEntry[]>();778 for (const e of candidates) {779 const rpId = e.passkey!.rpId;780 const list = byRpId.get(rpId);781 if (list) list.push(e);782 else byRpId.set(rpId, [e]);783 }784785 for (const [rpId, group] of byRpId) {786 if (group.length === 0) continue;787 group.sort((a, b) => {788 const aT =789 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;790 const bT =791 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;792 return bT - aT;793 });794 const winner = group[0]!;795796 797 798 799 const loginTarget = entries.find((e) => {800 if (e.deletedAt || e.passkey || e.type !== "login") return false;801 if (e.url && extractDomain(e.url) === rpId) return true;802 for (const extra of e.additionalUrls ?? []) {803 if (extractDomain(extra) === rpId) return true;804 }805 return false;806 });807808 if (loginTarget) {809 loginTarget.passkey = winner.passkey;810 loginTarget.modifiedAt = now;811 for (const dup of group) {812 if (!dup.deletedAt) {813 dup.deletedAt = now;814 dup.modifiedAt = now;815 }816 }817 merged = true;818 } else if (group.length > 1) {819 for (let i = 1; i < group.length; i += 1) {820 const dup = group[i]!;821 if (!dup.deletedAt) {822 dup.deletedAt = now;823 dup.modifiedAt = now;824 }825 }826 merged = true;827 }828 }829830 return { merged };831}832833834835836837838839function hasValidVaultSchema(v: unknown): v is StoredVault {840 if (typeof v !== "object" || v === null) return false;841 const r = v as Record<string, unknown>;842 return (843 r.version === 1 &&844 typeof r.salt === "string" &&845 typeof r.nonce === "string" &&846 typeof r.ciphertext === "string" &&847 typeof r.tag === "string"848 );849}850851export type TryDecryptResult =852 | { status: "ok"; payload: DecryptedPayload }853 | { status: "wrong_key" }854 | { status: "corrupted"; reason: string };855856857858859860861export async function tryDecryptSlot(862 vault: StoredVault,863 key: Uint8Array,864 aadTag?: string,865): Promise<DecryptedPayload | null> {866 const res = await tryDecryptSlotResult(vault, key, aadTag);867 return res.status === "ok" ? res.payload : null;868}869870export async function tryDecryptSlotResult(871 vault: StoredVault,872 key: Uint8Array,873 aadTag?: string,874): Promise<TryDecryptResult> {875 if (!hasValidVaultSchema(vault)) {876 return { status: "corrupted", reason: "invalid_slot_schema" };877 }878 let plain: string;879 try {880 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);881 } catch {882 return { status: "wrong_key" };883 }884 try {885 const parsed = JSON.parse(plain) as unknown;886 if (typeof parsed !== "object" || parsed === null) {887 return { status: "corrupted", reason: "invalid_payload_shape" };888 }889 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {890 return { status: "corrupted", reason: "invalid_payload_shape" };891 }892 return { status: "ok", payload: decodePayload(plain) };893 } catch {894 return { status: "corrupted", reason: "invalid_payload_json" };895 }896}897898899900901902function buildDomainIndexSync(all: VaultEntry[]): void {903 domainIndex = new Map();904905 for (const entry of all) {906 if (entry.deletedAt) continue;907 if (entry.type === "note" || entry.type === "card") continue;908909 910 911 912 913 const domains = new Set<string>();914 const primary = extractDomain(entry.url ?? "");915 if (primary) domains.add(primary);916 for (const extra of entry.additionalUrls ?? []) {917 const d = extractDomain(extra);918 if (d) domains.add(d);919 }920 if (domains.size === 0) continue;921922 for (const domain of domains) {923 const bucket = domainIndex.get(domain) ?? [];924 bucket.push(entry);925 domainIndex.set(domain, bucket);926 }927 }928}929930export async function createVault(password: string): Promise<void> {931 await ensureMigrated();932933 const salt = generateSalt();934 derivedKey = await deriveKey(password, salt);935 entries = [];936 vaultMode = "real";937 duressConfigured = false;938 buildDomainIndexSync(entries);939940 941 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =942 await generateAndWrapRootKey();943 rootKey = unhex(rawRootKey);944945 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);946 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");947948 949 950 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);951952 const slotA: StoredVault = {953 version: 1,954 salt: hex(salt),955 nonce,956 ciphertext,957 tag,958 recovery_salt: recoverySalt,959 recovery_wrapped_root_key: wrappedRootKey,960 recovery_nonce: wrappedNonce,961 recovery_tag: wrappedTag,962 ...pwRoot,963 };964 await writeSlot(SLOT_A, slotA);965966 967 968 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));969 await stampLocalVaultMutation();970971 touchAutoLock();972}973974export async function hasVault(): Promise<boolean> {975 await ensureMigrated();976 return (await loadSlot(SLOT_A)) !== null;977}978979980981982983984export async function isDuressEnabled(): Promise<boolean> {985 return vaultMode === "real" && duressConfigured;986}987988export async function setupDuress(duressPassword: string): Promise<void> {989 if (!derivedKey || vaultMode !== "real") {990 throw new Error("vault must be unlocked in real mode");991 }992 await ensureMigrated();993994 995 996 997 const slotA = await loadSlot(SLOT_A);998 if (!slotA) throw new Error("missing slot A");9991000 const decoySalt = generateSalt();1001 const decoyKey = await deriveKey(duressPassword, decoySalt);1002 try {1003 1004 1005 1006 1007 const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1008 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1009 await writeSlot(SLOT_B, {1010 version: 1,1011 salt: hex(decoySalt),1012 nonce,1013 ciphertext,1014 tag,1015 });1016 } finally {1017 zeroKey(decoyKey);1018 }10191020 1021 1022 duressConfigured = true;1023 await persistSlotA();1024}10251026export async function disableDuress(): Promise<void> {1027 if (!derivedKey || vaultMode !== "real") {1028 throw new Error("vault must be unlocked in real mode");1029 }1030 await ensureMigrated();10311032 1033 1034 1035 const slotA = await loadSlot(SLOT_A);1036 if (slotA) {1037 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1038 }10391040 duressConfigured = false;1041 await persistSlotA();1042}10431044104510461047104810491050export async function verifyMainPassword(password: string): Promise<boolean> {1051 await ensureMigrated();1052 const slotA = await loadSlot(SLOT_A);1053 if (!slotA) return false;1054 const key = await deriveKey(password, unhex(slotA.salt));1055 try {1056 return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1057 } finally {1058 zeroKey(key);1059 }1060}10611062export async function unlock(password: string): Promise<boolean> {1063 await ensureMigrated();10641065 1066 1067 1068 1069 1070 const [slotA, slotB] = await Promise.all([1071 loadSlot(SLOT_A),1072 loadSlot(SLOT_B),1073 ]);10741075 1076 const [keyA, keyB] = await Promise.all([1077 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),1078 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),1079 ]);10801081 1082 1083 1084 const [resultA, resultB] = await Promise.all([1085 keyA && slotA1086 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1087 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1088 keyB && slotB1089 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1090 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1091 ]);1092 const payloadA = resultA.status === "ok" ? resultA.payload : null;1093 const payloadB = resultB.status === "ok" ? resultB.payload : null;10941095 if (payloadA && keyA) {1096 derivedKey = keyA;1097 void saveSessionKey(keyA, "real");1098 vaultMode = "real";1099 entries = payloadA.entries;1100 duressConfigured = payloadA.duressConfigured;1101 buildDomainIndexSync(entries);1102 await loadSettings();1103 touchAutoLock();1104 if (keyB) zeroKey(keyB);11051106 1107 if (slotA) {1108 const needsRecoverySetup =1109 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;1110 if (!needsRecoverySetup) {1111 rootKey = await deriveRootKeyFromDerivedKey(keyA, slotA);1112 }1113 1114 1115 1116 1117 if (needsRecoverySetup || !rootKey) {1118 try {1119 await migrateToRecovery();1120 if (needsRecoverySetup) {1121 1122 1123 1124 await chrome.storage.local.set({ peach_recovery_pending: '1' });1125 }1126 } catch (migrationErr) {1127 console.warn('[Peach] recovery migration failed:', migrationErr);1128 }1129 }1130 }11311132 1133 1134 1135 if (payloadA.migratedFromLegacy) {1136 await persistSlotA();1137 }1138 return true;1139 }11401141 if (payloadB && keyB) {1142 derivedKey = keyB;1143 void saveSessionKey(keyB, "decoy");1144 vaultMode = "decoy";1145 entries = payloadB.entries;1146 duressConfigured = false;1147 buildDomainIndexSync(entries);1148 await loadSettings();1149 touchAutoLock();1150 if (keyA) zeroKey(keyA);1151 return true;1152 }11531154 if (keyA) zeroKey(keyA);1155 if (keyB) zeroKey(keyB);1156 1157 1158 if (resultA.status === "corrupted") {1159 lastUnlockFailureReason = resultA.reason;1160 } else if (resultB.status === "corrupted") {1161 lastUnlockFailureReason = resultB.reason;1162 }1163 return false;1164}11651166116711681169117011711172const SESSION_KEY_STORAGE = "peach_session_dk";11731174async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1175 if (!chrome.storage.session) return;1176 await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1177}11781179async function clearSessionKey(): Promise<void> {1180 if (!chrome.storage.session) return;1181 await chrome.storage.session.remove(SESSION_KEY_STORAGE);1182}11831184118511861187118811891190export async function restoreFromSession(): Promise<boolean> {1191 if (derivedKey !== null) return true;1192 if (!chrome.storage.session) return false;1193 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1194 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1195 if (!entry?.keyHex) return false;11961197 const key = unhex(entry.keyHex);1198 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1199 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1200 const slot = await loadSlot(slotKey);1201 if (!slot) { await clearSessionKey(); return false; }12021203 const result = await tryDecryptSlotResult(slot, key, aadTag);1204 if (result.status !== "ok") { await clearSessionKey(); return false; }12051206 derivedKey = key;1207 vaultMode = entry.mode;1208 entries = result.payload.entries;1209 duressConfigured = entry.mode === "real" ? result.payload.duressConfigured : false;1210 buildDomainIndexSync(entries);1211 await loadSettings();1212 touchAutoLock();12131214 if (entry.mode === "real" && slot.recovery_salt && slot.recovery_wrapped_root_key) {1215 try { rootKey = await deriveRootKeyFromDerivedKey(key, slot); } catch { }1216 }12171218 return true;1219}12201221export function lock(): void {1222 void clearSessionKey();1223 if (derivedKey) {1224 zeroKey(derivedKey);1225 derivedKey = null;1226 }1227 if (rootKey) {1228 rootKey.fill(0);1229 rootKey = null;1230 }12311232 entries = [];1233 domainIndex = new Map();1234 vaultMode = "real";1235 duressConfigured = false;1236 clearAutoLock();1237}12381239export function isUnlocked(): boolean {1240 return derivedKey !== null;1241}1242124312441245export async function unlockWithKey(1246 masterKeyHex: string,1247): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1248 try {1249 const masterKeyBytes = unhex(masterKeyHex);1250 const stored = await loadSlot(SLOT_A);1251 if (!stored) return { ok: false, error: "invalid_key" };1252 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1253 if (!payload) return { ok: false, error: "invalid_key" };12541255 derivedKey = masterKeyBytes;1256 vaultMode = "real";1257 entries = payload.entries;1258 duressConfigured = payload.duressConfigured;1259 buildDomainIndexSync(entries);1260 await loadSettings();1261 touchAutoLock();1262 return { ok: true, entries };1263 } catch {1264 return { ok: false, error: "invalid_key" };1265 }1266}12671268126912701271127212731274127512761277127812791280128112821283128412851286export async function unlockFromCodex(params: {1287 slotA: string; 1288 slotB: string | null; 1289 timestamp: number;1290 masterPassphrase: string;1291}): Promise<1292 | { ok: true; entryCount: number }1293 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1294> {1295 let parsedSlotA: StoredVault;1296 let parsedSlotB: StoredVault | null = null;1297 try {1298 const rawA = JSON.parse(params.slotA) as unknown;1299 if (!hasValidVaultSchema(rawA)) {1300 return { ok: false, reason: "invalid_slot_format" };1301 }1302 parsedSlotA = rawA;1303 if (params.slotB !== null) {1304 const rawB = JSON.parse(params.slotB) as unknown;1305 if (!hasValidVaultSchema(rawB)) {1306 return { ok: false, reason: "invalid_slot_format" };1307 }1308 parsedSlotB = rawB;1309 }1310 } catch {1311 return { ok: false, reason: "invalid_slot_format" };1312 }13131314 1315 1316 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1317 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1318 if (!trialPayload) {1319 zeroKey(trialKey);1320 return { ok: false, reason: "wrong_passphrase" };1321 }1322 1323 1324 zeroKey(trialKey);13251326 1327 1328 await takeVaultSnapshot("restore");13291330 await writeSlot(SLOT_A, parsedSlotA);1331 if (parsedSlotB) {1332 await writeSlot(SLOT_B, parsedSlotB);1333 } else {1334 1335 1336 1337 await writeSlot(1338 SLOT_B,1339 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1340 );1341 }1342 await chrome.storage.local.set({1343 peach_last_modified_at: String(params.timestamp),1344 });13451346 1347 1348 const ok = await unlock(params.masterPassphrase);1349 if (!ok) {1350 1351 1352 1353 return { ok: false, reason: "wrong_passphrase" };1354 }1355 return { ok: true, entryCount: getEntries().length };1356}1357135813591360export function getDerivedKeyHex(): string {1361 if (!derivedKey) throw new Error("vault_not_unlocked");1362 return hex(derivedKey);1363}13641365136613671368136913701371137213731374137513761377137813791380export function getRootKeyHex(): string | null {1381 return rootKey ? hex(rootKey) : null;1382}13831384export function getEntries(): VaultEntry[] {1385 if (!derivedKey) throw new Error("vault_locked");1386 return entries.filter((entry) => !entry.deletedAt);1387}13881389const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;13901391export function getIdentities(): SavedIdentity[] {1392 if (!derivedKey) throw new Error("vault_locked");1393 const active = entries.filter((e) => !e.deletedAt && e.type === "login");13941395 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1396 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();13971398 for (const entry of active) {1399 const domain = extractDomain(entry.url ?? "") ?? "";1400 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;14011402 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1403 const existing = map.get(v);1404 if (!existing || modifiedAt > existing.lastUsed) {1405 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1406 } else {1407 map.set(v, { ...existing, count: existing.count + 1 });1408 }1409 }14101411 const uVal = entry.username?.trim();1412 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);14131414 1415 1416 const eVal = entry.email?.trim();1417 if (eVal) tally(emailMap, eVal);1418 }14191420 const toIdentity = (type: "email" | "username") =>1421 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1422 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1423 });14241425 return [1426 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1427 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1428 ];1429}14301431export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1432 if (!derivedKey) throw new Error("vault_locked");14331434 const now = Date.now();1435 const added: VaultEntry[] = [];1436 for (const draft of drafts) {1437 try {1438 const sanitized = sanitizeEntryDraft(draft);1439 const nextEntry: VaultEntry = {1440 ...sanitized,1441 id: crypto.randomUUID(),1442 createdAt: now,1443 modifiedAt: monotonicEntryTimestamp(),1444 };1445 entries.push(nextEntry);1446 added.push(nextEntry);1447 } catch {1448 1449 }1450 }14511452 if (added.length > 0) {1453 await persistEntries();1454 }14551456 return added;1457}145814591460146114621463146414651466export async function restoreEntries(1467 incoming: VaultEntry[],1468 mode: "replace" | "merge"1469): Promise<{ added: number; updated: number; skipped: number }> {1470 if (!derivedKey) throw new Error("vault_locked");14711472 1473 1474 1475 if (mode === "replace" && incoming.length === 0) {1476 throw new Error("refuse to replace with empty entry set");1477 }14781479 let added = 0;1480 let updated = 0;1481 let skipped = 0;14821483 if (mode === "replace") {1484 entries = [];1485 for (const entry of incoming) {1486 const normalized = sanitizeRestoredEntry(entry);1487 if (!normalized) {1488 skipped += 1;1489 continue;1490 }1491 entries.push(normalized);1492 added += 1;1493 }1494 } else {1495 const byId = new Map<string, VaultEntry>();1496 for (const e of entries) byId.set(e.id, e);14971498 for (const entry of incoming) {1499 const normalized = sanitizeRestoredEntry(entry);1500 if (!normalized) {1501 skipped += 1;1502 continue;1503 }1504 const existing = byId.get(normalized.id);1505 if (!existing) {1506 entries.push(normalized);1507 added += 1;1508 } else if (normalized.modifiedAt > existing.modifiedAt) {1509 const idx = entries.findIndex((e) => e.id === existing.id);1510 if (idx !== -1) {1511 entries[idx] = normalized;1512 updated += 1;1513 }1514 } else {1515 skipped += 1;1516 }1517 }1518 }15191520 await persistEntries("import");1521 return { added, updated, skipped };1522}1523152415251526152715281529function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1530 if (!entry || typeof entry !== "object") return null;1531 const title = typeof entry.title === "string" ? entry.title.trim() : "";1532 if (!title) return null;1533 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1534 const now = Date.now();1535 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1536 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;15371538 1539 1540 1541 const migrated: VaultEntry = migrateLegacyPasskey(1542 {1543 ...entry,1544 id,1545 title,1546 type: entry.type ?? "login",1547 createdAt,1548 modifiedAt,1549 },1550 entry1551 );15521553 1554 1555 if (1556 migrated.type === "login" &&1557 !migrated.passkey &&1558 typeof migrated.password !== "string"1559 ) {1560 return null;1561 }1562 if (migrated.passkey && !migrated.passkey.credentialId) {1563 return null;1564 }15651566 return migrated;1567}156815691570157115721573export async function clearVaultEntries(1574 confirmation: "REPLACE_ALL"1575): Promise<void> {1576 if (confirmation !== "REPLACE_ALL") {1577 throw new Error("clearVaultEntries requires explicit confirmation");1578 }1579 if (!derivedKey) throw new Error("vault_locked");1580 for (const entry of entries) {1581 if (!entry.deletedAt) {1582 const stamp = monotonicEntryTimestamp(entry.modifiedAt);1583 entry.deletedAt = stamp;1584 entry.modifiedAt = stamp;1585 }1586 }1587 await persistEntries();1588}15891590export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1591 if (!derivedKey) throw new Error("vault_locked");15921593 const sanitized = sanitizeEntryDraft(entry);1594 const now = Date.now();1595 const stamp = monotonicEntryTimestamp();1596 const nextEntry: VaultEntry = {1597 ...sanitized,1598 id: crypto.randomUUID(),1599 createdAt: now,1600 modifiedAt: stamp,1601 1602 1603 passwordChangedAt: sanitized.password ? stamp : undefined,1604 };16051606 1607 1608 1609 const rollback = entries;1610 entries = [...entries, nextEntry];1611 try {1612 await persistEntries();1613 } catch (err) {1614 entries = rollback;1615 throw err;1616 }16171618 1619 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1620 checkBreach(nextEntry.password).then(async (count) => {1621 if (count > 0) {1622 const idx = entries.findIndex((e) => e.id === nextEntry.id);1623 if (idx !== -1) {1624 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };1625 await persistEntries();1626 }1627 }1628 });1629 }16301631 fireSyncTrigger("vault_change");16321633 return nextEntry;1634}16351636export async function updateEntry(1637 id: string,1638 patch: Partial<VaultEntry>1639): Promise<void> {1640 if (!derivedKey) throw new Error("vault_locked");16411642 const idx = entries.findIndex((entry) => entry.id === id);1643 if (idx === -1 || entries[idx].deletedAt) {1644 throw new Error("entry_not_found");1645 }16461647 const current = entries[idx];1648 const sanitizedPatch = sanitizeEntryPatch(patch);1649 1650 1651 1652 const passwordChanged =1653 "password" in patch &&1654 typeof sanitizedPatch.password === "string" &&1655 sanitizedPatch.password !== current.password;1656 const stamp = monotonicEntryTimestamp(current.modifiedAt);1657 entries[idx] = {1658 ...current,1659 ...sanitizedPatch,1660 modifiedAt: stamp,1661 ...(passwordChanged ? { passwordChangedAt: stamp } : {}),1662 };1663166416651666 1667 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1668 entries[idx] = current;1669 throw new Error("card number is required for card entries");1670 }16711672 1673 1674 1675 1676 try {1677 await persistEntries();1678 } catch (err) {1679 entries[idx] = current;1680 throw err;1681 }16821683 1684 1685 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1686 entries[idx] = { ...entries[idx], breach: undefined };1687 await persistEntries();1688 const password = entries[idx].password;1689 if (password) {1690 checkBreach(password).then(async (count) => {1691 const i = entries.findIndex((e) => e.id === id);1692 if (i !== -1 && count > 0) {1693 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };1694 await persistEntries();1695 }1696 });1697 }1698 }16991700 fireSyncTrigger("vault_change");1701}17021703export async function deleteEntry(id: string): Promise<void> {1704 if (!derivedKey) throw new Error("vault_locked");17051706 const entry = entries.find((item) => item.id === id);1707 if (!entry || entry.deletedAt) {1708 throw new Error("entry_not_found");1709 }17101711 1712 1713 1714 const previousModifiedAt = entry.modifiedAt;1715 const stamp = monotonicEntryTimestamp(entry.modifiedAt);1716 entry.deletedAt = stamp;1717 entry.modifiedAt = stamp;1718 1719 1720 try {1721 await persistEntries();1722 } catch (err) {1723 delete entry.deletedAt;1724 entry.modifiedAt = previousModifiedAt;1725 throw err;1726 }1727 fireSyncTrigger("vault_change");1728}17291730export function getDeletedEntries(): VaultEntry[] {1731 if (!derivedKey) return [];1732 return entries.filter((e) => !!e.deletedAt);1733}1734173517361737173817391740export async function getVaultVersion(): Promise<number> {1741 try {1742 const raw = await chrome.storage.local.get("peach_last_modified_at");1743 const v = (raw as Record<string, unknown>).peach_last_modified_at;1744 if (typeof v === "number") return v;1745 if (typeof v === "string" && v !== "") {1746 const n = Number.parseInt(v, 10);1747 return Number.isFinite(n) ? n : 0;1748 }1749 return 0;1750 } catch {1751 return 0;1752 }1753}175417551756175717581759176017611762176317641765export async function addEntryDirect(entry: VaultEntry): Promise<void> {1766 if (!derivedKey) throw new Error("vault_locked");1767 entries = [...entries, entry];1768 await persistEntries("sync_received");1769}17701771export async function updateEntryDirect(patch: VaultEntry): Promise<void> {1772 if (!derivedKey) throw new Error("vault_locked");1773 const idx = entries.findIndex((e) => e.id === patch.id);1774 if (idx === -1) throw new Error("entry_not_found");1775 entries[idx] = { ...entries[idx], ...patch };1776 await persistEntries("sync_received");1777}17781779export async function deleteEntryDirect(id: string, deletedAt: number): Promise<void> {1780 if (!derivedKey) throw new Error("vault_locked");1781 const idx = entries.findIndex((e) => e.id === id);1782 if (idx === -1) throw new Error("entry_not_found");1783 entries[idx] = { ...entries[idx], deletedAt, modifiedAt: deletedAt };1784 await persistEntries("sync_received");1785}1786178717881789179017911792179317941795179617971798export async function applyMergedRemoteEntries(1799 remoteEntries: VaultEntry[],1800 remoteTombstones: Record<string, number>,1801): Promise<{ received: number; applied: number; conflicts: number }> {1802 1803 1804 1805 1806 1807 1808 if (!derivedKey) throw new Error("vault_locked");18091810 const received = remoteEntries.length + Object.keys(remoteTombstones).length;1811 let applied = 0;1812 let conflicts = 0;18131814 const byId = new Map<string, VaultEntry>();1815 for (const e of entries) byId.set(e.id, e);18161817 for (const remote of remoteEntries) {1818 const sanitized = sanitizeRestoredEntry(remote);1819 if (!sanitized) continue;1820 const tombstoneTs = remoteTombstones[sanitized.id];1821 if (tombstoneTs !== undefined && tombstoneTs >= sanitized.modifiedAt) {1822 continue;1823 }1824 const local = byId.get(sanitized.id);1825 if (!local) {1826 entries.push(sanitized);1827 byId.set(sanitized.id, sanitized);1828 applied += 1;1829 } else if (sanitized.modifiedAt > local.modifiedAt) {1830 const idx = entries.findIndex((e) => e.id === local.id);1831 if (idx !== -1) {1832 entries[idx] = sanitized;1833 byId.set(sanitized.id, sanitized);1834 applied += 1;1835 if (!local.deletedAt) conflicts += 1;1836 }1837 }1838 }18391840 for (const [id, deletedAt] of Object.entries(remoteTombstones)) {1841 if (typeof deletedAt !== "number" || !Number.isFinite(deletedAt)) continue;1842 const local = byId.get(id);1843 if (!local) continue;1844 if (local.deletedAt && local.deletedAt >= deletedAt) continue;1845 if (deletedAt < local.modifiedAt) continue;1846 const idx = entries.findIndex((e) => e.id === id);1847 if (idx !== -1) {1848 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };1849 applied += 1;1850 }1851 }18521853 if (applied > 0) {1854 await persistEntries("sync_received");1855 buildDomainIndexSync(entries);1856 }1857 return { received, applied, conflicts };1858}18591860export async function restoreEntry(id: string): Promise<void> {1861 if (!derivedKey) throw new Error("vault_locked");1862 const entry = entries.find((e) => e.id === id && e.deletedAt);1863 if (!entry) throw new Error("deleted entry not found");1864 delete entry.deletedAt;1865 entry.modifiedAt = monotonicEntryTimestamp(entry.modifiedAt);1866 await persistEntries();1867 buildDomainIndexSync(entries);1868}18691870export async function purgeEntry(id: string): Promise<void> {1871 if (!derivedKey) throw new Error("vault_locked");1872 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1873 if (idx === -1) throw new Error("deleted entry not found");1874 entries.splice(idx, 1);1875 await persistEntries();1876}187718781879export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1880 if (!derivedKey || retentionMs === 0) return 0;1881 const cutoff = Date.now() - retentionMs;1882 const before = entries.length;1883 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1884 const purged = before - entries.length;1885 if (purged > 0) await persistEntries();1886 return purged;1887}18881889export function queryDomain(domain: string): VaultEntry[] {1890 return domainIndex.get(domain) ?? [];1891}18921893export function getEntryById(id: string): VaultEntry | undefined {1894 return entries.find((entry) => entry.id === id && !entry.deletedAt);1895}18961897async function persistSlotA(1898 reason: VaultSnapshot["reason"] = "local_save",1899): Promise<StoredVault> {1900 if (!derivedKey) throw new Error("vault_locked");19011902 const slotA = await loadSlot(SLOT_A);1903 if (!slotA) throw new Error("missing slot A");19041905 1906 1907 await takeVaultSnapshot(reason);19081909 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);1910 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");19111912 1913 const nextSlotA: StoredVault = {1914 ...slotA,1915 nonce,1916 ciphertext,1917 tag,1918 1919 };1920 await writeSlot(SLOT_A, nextSlotA);19211922 1923 1924 1925 if (!duressConfigured) {1926 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1927 }1928 1929 1930 1931 1932 1933 if (reason !== "sync_received") {1934 await stampLocalVaultMutation();1935 }19361937 return nextSlotA;1938}19391940async function persistSlotB(): Promise<void> {1941 if (!derivedKey) throw new Error("vault_locked");19421943 const slotB = await loadSlot(SLOT_B);1944 if (!slotB) throw new Error("missing slot B");19451946 1947 1948 1949 1950 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);1951 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");19521953 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1954 await stampLocalVaultMutation();1955}19561957async function persistEntries(1958 reason: VaultSnapshot["reason"] = "local_save",1959): Promise<void> {1960 if (!derivedKey) throw new Error("vault_locked");19611962 if (vaultMode === "decoy") {1963 await persistSlotB();1964 } else {1965 await persistSlotA(reason);1966 }1967 buildDomainIndexSync(entries);1968}19691970function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1971 if (!Array.isArray(values)) return undefined;1972 const seen = new Set<string>();1973 const cleaned: string[] = [];1974 for (const value of values) {1975 const trimmed = typeof value === "string" ? value.trim() : "";1976 if (!trimmed) continue;1977 const key = trimmed.toLowerCase();1978 if (seen.has(key)) continue;1979 seen.add(key);1980 cleaned.push(trimmed);1981 }1982 return cleaned.length > 0 ? cleaned : undefined;1983}19841985function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1986 const title = entry.title.trim();1987 1988 1989 1990 let type: VaultEntry["type"] = entry.type ?? "login";1991 if (type === "passkey") type = "login";19921993 if (!title) throw new Error("title is required");19941995 if (entry.passkey) {1996 const p = entry.passkey;1997 if (1998 !p.rpId ||1999 !p.credentialId ||2000 !p.publicKeyCose ||2001 !p.userHandle ||2002 !p.userName ||2003 (p.algorithm !== -7 && p.algorithm !== -8)2004 ) {2005 throw new Error("invalid passkey credential");2006 }2007 }2008 if (type === "card") {2009 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2010 throw new Error("cardholder name and card number are required");2011 }2012 }20132014 return {2015 type,2016 title,2017 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2018 email: type === "login" ? entry.email?.trim() || undefined : undefined,2019 password: type === "login" ? entry.password : undefined,2020 url: type === "login" ? entry.url?.trim() ?? "" : undefined,2021 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2022 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2023 notes: entry.notes?.trim() || undefined,2024 breach: type === "login" ? entry.breach : undefined,2025 canary: type === "login" ? entry.canary : undefined,2026 tags: entry.tags,2027 passkey: entry.passkey,2028 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2029 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2030 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2031 expYear: type === "card" ? entry.expYear?.trim() : undefined,2032 cvc: type === "card" ? entry.cvc?.trim() : undefined,2033 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2034 network: type === "card" ? entry.network : undefined,2035 last4: type === "card" ? entry.last4?.trim() : undefined,2036 };2037}20382039function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2040 const nextPatch: Partial<VaultEntry> = {};20412042 if ("type" in patch) {2043 nextPatch.type = patch.type;2044 }20452046 if ("title" in patch) {2047 const title = patch.title?.trim() ?? "";2048 if (!title) throw new Error("title is required");2049 nextPatch.title = title;2050 }20512052 if ("url" in patch) {2053 nextPatch.url = patch.url?.trim() ?? "";2054 }20552056 if ("additionalUrls" in patch) {2057 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2058 const seen = new Set<string>();2059 const cleaned: string[] = [];2060 for (const value of incoming) {2061 const trimmed = typeof value === "string" ? value.trim() : "";2062 if (!trimmed) continue;2063 const key = trimmed.toLowerCase();2064 if (seen.has(key)) continue;2065 seen.add(key);2066 cleaned.push(trimmed);2067 }2068 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2069 }20702071 if ("username" in patch) {2072 nextPatch.username = patch.username?.trim() ?? "";2073 }20742075 if ("email" in patch) {2076 nextPatch.email = patch.email?.trim() || undefined;2077 }20782079 if ("password" in patch) {2080 const password = patch.password ?? "";2081 2082 nextPatch.password = password || undefined;2083 }20842085 if ("totp" in patch) {2086 nextPatch.totp = patch.totp?.trim() || undefined;2087 }20882089 if ("notes" in patch) {2090 nextPatch.notes = patch.notes?.trim() || undefined;2091 }20922093 if ("breach" in patch) {2094 nextPatch.breach = patch.breach;2095 }20962097 if ("canary" in patch) {2098 nextPatch.canary = patch.canary;2099 }21002101 if ("passkey" in patch) {2102 2103 2104 2105 nextPatch.passkey = patch.passkey;2106 }21072108 if ("tags" in patch) {2109 nextPatch.tags = patch.tags;2110 }21112112 if ("cardholderName" in patch) {2113 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2114 }2115 if ("cardNumber" in patch) {2116 const n = patch.cardNumber ?? "";2117 nextPatch.cardNumber = n || undefined;2118 }2119 if ("expMonth" in patch) {2120 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2121 }2122 if ("expYear" in patch) {2123 nextPatch.expYear = patch.expYear?.trim() || undefined;2124 }2125 if ("cvc" in patch) {2126 nextPatch.cvc = patch.cvc?.trim() || undefined;2127 }2128 if ("cardNickname" in patch) {2129 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2130 }2131 if ("network" in patch) {2132 nextPatch.network = patch.network;2133 }2134 if ("last4" in patch) {2135 nextPatch.last4 = patch.last4?.trim() || undefined;2136 }21372138 return nextPatch;2139}21402141function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {2142 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;2143 const modifiedAt =2144 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;21452146 const base: VaultEntry = {2147 id: entry.id,2148 type: "type" in entry ? entry.type : "login",2149 title: entry.title,2150 username: entry.username,2151 email: "email" in entry ? entry.email : undefined,2152 password: entry.password,2153 url: entry.url,2154 additionalUrls:2155 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2156 ? entry.additionalUrls2157 : undefined,2158 totp: entry.totp,2159 notes: entry.notes,2160 breach: entry.breach,2161 2162 2163 2164 passwordChangedAt:2165 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2166 canary: "canary" in entry ? entry.canary : undefined,2167 passkey: "passkey" in entry ? entry.passkey : undefined,2168 createdAt,2169 modifiedAt,2170 deletedAt: entry.deletedAt,2171 tags: "tags" in entry ? entry.tags : undefined,2172 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2173 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2174 expMonth: "expMonth" in entry ? entry.expMonth : undefined,2175 expYear: "expYear" in entry ? entry.expYear : undefined,2176 cvc: "cvc" in entry ? entry.cvc : undefined,2177 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2178 network: "network" in entry ? entry.network : undefined,2179 last4: "last4" in entry ? entry.last4 : undefined,2180 };21812182 return migrateLegacyPasskey(base, entry);2183}218421852186218721882189219021912192function migrateLegacyPasskey(2193 base: VaultEntry,2194 raw: VaultEntry | LegacyVaultEntry2195): VaultEntry {2196 if (base.passkey) return base;2197 const flat = raw as Partial<VaultEntry>;2198 const isPasskeyType = base.type === "passkey";2199 const hasFlatPasskeyFields =2200 typeof flat.rpId === "string" &&2201 typeof flat.credentialId === "string" &&2202 typeof flat.publicKeyCose === "string" &&2203 typeof flat.userHandle === "string" &&2204 typeof flat.userName === "string";2205 if (!isPasskeyType && !hasFlatPasskeyFields) return base;2206 if (!hasFlatPasskeyFields) return base;22072208 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;2209 base.passkey = {2210 rpId: flat.rpId!,2211 rpName: flat.rpName ?? flat.rpId!,2212 credentialId: flat.credentialId!,2213 publicKeyCose: flat.publicKeyCose!,2214 privateKeyJwk: flat.privateKeyJwk,2215 userHandle: flat.userHandle!,2216 userName: flat.userName!,2217 userDisplayName: flat.userDisplayName ?? flat.userName!,2218 algorithm,2219 signCount: flat.signCount ?? 0,2220 aaguid: flat.aaguid ?? "",2221 backupEligible: flat.backupEligible === true,2222 backupState: flat.backupState === true,2223 lastUsedAt: flat.lastUsedAt,2224 browser: flat.browser,2225 transports: flat.transports,2226 createdAt: base.createdAt,2227 };2228 2229 2230 2231 if (isPasskeyType) base.type = "login";2232 if (!base.username) base.username = flat.userName;2233 if (!base.url) base.url = `https://${flat.rpId}`;2234 return base;2235}2236223722382239224022412242function entryDupeKey(e: VaultEntry): string {2243 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2244 2245 2246 2247 2248 if (e.passkey) {2249 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2250 }2251 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2252}225322542255export function countDuplicates(): number {2256 const best = new Map<string, VaultEntry>();2257 let dupeCount = 0;2258 for (const e of entries.filter((e) => !e.deletedAt)) {2259 const key = entryDupeKey(e);2260 const existing = best.get(key);2261 if (!existing) {2262 best.set(key, e);2263 } else {2264 dupeCount++;2265 2266 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2267 best.set(key, e);2268 }2269 }2270 }2271 return dupeCount;2272}227322742275227622772278export async function deduplicateEntries(): Promise<number> {2279 if (!derivedKey) throw new Error("vault_locked");2280 2281 2282 if (vaultMode === "decoy") {2283 throw new Error("deduplicate disabled in decoy mode");2284 }22852286 const best = new Map<string, VaultEntry>();2287 for (const e of entries.filter((en) => !en.deletedAt)) {2288 const key = entryDupeKey(e);2289 const existing = best.get(key);2290 if (!existing) {2291 best.set(key, e);2292 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2293 best.set(key, e);2294 }2295 }22962297 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2298 let removed = 0;2299 entries = entries.map((e) => {2300 if (!e.deletedAt && !keepIds.has(e.id)) {2301 removed++;2302 return { ...e, deletedAt: monotonicEntryTimestamp(e.modifiedAt) };2303 }2304 return e;2305 });23062307 if (removed > 0) await persistEntries();2308 return removed;2309}231023112312231323142315231623172318const HISTORY_KEY = "peach_vault_history";2319const HISTORY_MAX = 10;23202321232223232324232523262327export async function takeVaultSnapshot(2328 reason: VaultSnapshot["reason"],2329): Promise<void> {2330 try {2331 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2332 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2333 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];23342335 2336 2337 2338 if (!slotA) return;23392340 const vault_a = JSON.stringify(slotA);2341 const vault_b = JSON.stringify(slotB ?? slotA);23422343 2344 2345 2346 let entryCount: number | null = null;2347 if (derivedKey && vaultMode === "real") {2348 try {2349 entryCount = entries.filter((e) => !e.deletedAt).length;2350 } catch {2351 entryCount = null;2352 }2353 }23542355 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2356 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2357 ? existingRaw2358 : { snapshots: [] };23592360 2361 2362 2363 const latest = history.snapshots[0];2364 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2365 return;2366 }23672368 const snapshot: VaultSnapshot = {2369 id: crypto.randomUUID(),2370 savedAt: Date.now(),2371 reason,2372 vault_a,2373 vault_b,2374 entryCount,2375 };23762377 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2378 await chrome.storage.local.set({ [HISTORY_KEY]: history });2379 } catch (err) {2380 2381 2382 2383 2384 console.warn('[Peach] takeVaultSnapshot failed:', err);2385 if (String(err).includes('QUOTA') || String(err).includes('quota')) {2386 try {2387 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2388 } catch {2389 2390 }2391 }2392 }2393}239423952396export async function clearVaultHistory(): Promise<void> {2397 try {2398 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2399 } catch {2400 2401 }2402}240324042405export async function getVaultHistory(): Promise<VaultSnapshot[]> {2406 const raw = await chrome.storage.local.get(HISTORY_KEY);2407 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2408 if (!history || !Array.isArray(history.snapshots)) return [];2409 return history.snapshots;2410}24112412241324142415241624172418export async function restoreVaultSnapshot(2419 snapshotId: string,2420): Promise<{ ok: true } | { ok: false; error: string }> {2421 const history = await getVaultHistory();2422 const snapshot = history.find((s) => s.id === snapshotId);2423 if (!snapshot) return { ok: false, error: "snapshot_not_found" };24242425 2426 2427 2428 await takeVaultSnapshot("restore");24292430 let restoredA: StoredVault;2431 let restoredB: StoredVault;2432 try {2433 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2434 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2435 } catch {2436 return { ok: false, error: "snapshot_corrupt" };2437 }24382439 await writeSlot(SLOT_A, restoredA);2440 await writeSlot(SLOT_B, restoredB);2441 await stampLocalVaultMutation();24422443 2444 2445 2446 lock();24472448 return { ok: true };2449}24502451245224532454245524562457export async function changePassword(2458 currentPassword: string,2459 newPassword: string2460): Promise<boolean> {2461 if (!derivedKey || vaultMode !== "real") return false;24622463 2464 const valid = await verifyMainPassword(currentPassword);2465 if (!valid) return false;24662467 const slotA = await loadSlot(SLOT_A);2468 if (!slotA) return false;2469 if (!rootKey) return false;24702471 2472 const newSalt = generateSalt();2473 const newKey = await deriveKey(newPassword, newSalt);24742475 2476 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2477 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");24782479 2480 2481 2482 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);24832484 const finalSlotA: StoredVault = {2485 ...slotA,2486 salt: hex(newSalt),2487 nonce,2488 ciphertext,2489 tag,2490 ...pwRoot,2491 };24922493 2494 2495 2496 await takeVaultSnapshot("local_save");24972498 2499 2500 2501 2502 await writeSlot(SLOT_A, finalSlotA);25032504 2505 2506 2507 zeroKey(derivedKey);2508 derivedKey = newKey;25092510 2511 2512 2513 if (!duressConfigured) {2514 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2515 }2516 await stampLocalVaultMutation();25172518 return true;2519}