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}16351636163716381639164016411642export async function consolidatePasskeyEntries(): Promise<void> {1643 if (!derivedKey) return;1644 const { merged } = mergePasskeyOnlyIntoLogins(entries);1645 if (merged) {1646 await persistEntries();1647 }1648}16491650export async function updateEntry(1651 id: string,1652 patch: Partial<VaultEntry>1653): Promise<void> {1654 if (!derivedKey) throw new Error("vault_locked");16551656 const idx = entries.findIndex((entry) => entry.id === id);1657 if (idx === -1 || entries[idx].deletedAt) {1658 throw new Error("entry_not_found");1659 }16601661 const current = entries[idx];1662 const sanitizedPatch = sanitizeEntryPatch(patch);1663 1664 1665 1666 const passwordChanged =1667 "password" in patch &&1668 typeof sanitizedPatch.password === "string" &&1669 sanitizedPatch.password !== current.password;1670 const stamp = monotonicEntryTimestamp(current.modifiedAt);1671 entries[idx] = {1672 ...current,1673 ...sanitizedPatch,1674 modifiedAt: stamp,1675 ...(passwordChanged ? { passwordChangedAt: stamp } : {}),1676 };1677167816791680 1681 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1682 entries[idx] = current;1683 throw new Error("card number is required for card entries");1684 }16851686 1687 1688 1689 1690 try {1691 await persistEntries();1692 } catch (err) {1693 entries[idx] = current;1694 throw err;1695 }16961697 1698 1699 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1700 entries[idx] = { ...entries[idx], breach: undefined };1701 await persistEntries();1702 const password = entries[idx].password;1703 if (password) {1704 checkBreach(password).then(async (count) => {1705 const i = entries.findIndex((e) => e.id === id);1706 if (i !== -1 && count > 0) {1707 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };1708 await persistEntries();1709 }1710 });1711 }1712 }17131714 fireSyncTrigger("vault_change");1715}17161717export async function deleteEntry(id: string): Promise<void> {1718 if (!derivedKey) throw new Error("vault_locked");17191720 const entry = entries.find((item) => item.id === id);1721 if (!entry || entry.deletedAt) {1722 throw new Error("entry_not_found");1723 }17241725 1726 1727 1728 const previousModifiedAt = entry.modifiedAt;1729 const stamp = monotonicEntryTimestamp(entry.modifiedAt);1730 entry.deletedAt = stamp;1731 entry.modifiedAt = stamp;1732 1733 1734 try {1735 await persistEntries();1736 } catch (err) {1737 delete entry.deletedAt;1738 entry.modifiedAt = previousModifiedAt;1739 throw err;1740 }1741 fireSyncTrigger("vault_change");1742}17431744export function getDeletedEntries(): VaultEntry[] {1745 if (!derivedKey) return [];1746 return entries.filter((e) => !!e.deletedAt);1747}1748174917501751175217531754export async function getVaultVersion(): Promise<number> {1755 try {1756 const raw = await chrome.storage.local.get("peach_last_modified_at");1757 const v = (raw as Record<string, unknown>).peach_last_modified_at;1758 if (typeof v === "number") return v;1759 if (typeof v === "string" && v !== "") {1760 const n = Number.parseInt(v, 10);1761 return Number.isFinite(n) ? n : 0;1762 }1763 return 0;1764 } catch {1765 return 0;1766 }1767}176817691770177117721773177417751776177717781779export async function addEntryDirect(entry: VaultEntry): Promise<void> {1780 if (!derivedKey) throw new Error("vault_locked");1781 entries = [...entries, entry];1782 await persistEntries("sync_received");1783}17841785export async function updateEntryDirect(patch: VaultEntry): Promise<void> {1786 if (!derivedKey) throw new Error("vault_locked");1787 const idx = entries.findIndex((e) => e.id === patch.id);1788 if (idx === -1) throw new Error("entry_not_found");1789 entries[idx] = { ...entries[idx], ...patch };1790 await persistEntries("sync_received");1791}17921793export async function deleteEntryDirect(id: string, deletedAt: number): Promise<void> {1794 if (!derivedKey) throw new Error("vault_locked");1795 const idx = entries.findIndex((e) => e.id === id);1796 if (idx === -1) throw new Error("entry_not_found");1797 entries[idx] = { ...entries[idx], deletedAt, modifiedAt: deletedAt };1798 await persistEntries("sync_received");1799}1800180118021803180418051806180718081809181018111812export async function applyMergedRemoteEntries(1813 remoteEntries: VaultEntry[],1814 remoteTombstones: Record<string, number>,1815): Promise<{ received: number; applied: number; conflicts: number }> {1816 1817 1818 1819 1820 1821 1822 if (!derivedKey) throw new Error("vault_locked");18231824 const received = remoteEntries.length + Object.keys(remoteTombstones).length;1825 let applied = 0;1826 let conflicts = 0;18271828 const byId = new Map<string, VaultEntry>();1829 for (const e of entries) byId.set(e.id, e);18301831 for (const remote of remoteEntries) {1832 const sanitized = sanitizeRestoredEntry(remote);1833 if (!sanitized) continue;1834 const tombstoneTs = remoteTombstones[sanitized.id];1835 if (tombstoneTs !== undefined && tombstoneTs >= sanitized.modifiedAt) {1836 continue;1837 }1838 const local = byId.get(sanitized.id);1839 if (!local) {1840 entries.push(sanitized);1841 byId.set(sanitized.id, sanitized);1842 applied += 1;1843 } else if (sanitized.modifiedAt > local.modifiedAt) {1844 const idx = entries.findIndex((e) => e.id === local.id);1845 if (idx !== -1) {1846 entries[idx] = sanitized;1847 byId.set(sanitized.id, sanitized);1848 applied += 1;1849 if (!local.deletedAt) conflicts += 1;1850 }1851 }1852 }18531854 for (const [id, deletedAt] of Object.entries(remoteTombstones)) {1855 if (typeof deletedAt !== "number" || !Number.isFinite(deletedAt)) continue;1856 const local = byId.get(id);1857 if (!local) continue;1858 if (local.deletedAt && local.deletedAt >= deletedAt) continue;1859 if (deletedAt < local.modifiedAt) continue;1860 const idx = entries.findIndex((e) => e.id === id);1861 if (idx !== -1) {1862 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };1863 applied += 1;1864 }1865 }18661867 if (applied > 0) {1868 await persistEntries("sync_received");1869 buildDomainIndexSync(entries);1870 }1871 return { received, applied, conflicts };1872}18731874export async function restoreEntry(id: string): Promise<void> {1875 if (!derivedKey) throw new Error("vault_locked");1876 const entry = entries.find((e) => e.id === id && e.deletedAt);1877 if (!entry) throw new Error("deleted entry not found");1878 delete entry.deletedAt;1879 entry.modifiedAt = monotonicEntryTimestamp(entry.modifiedAt);1880 await persistEntries();1881 buildDomainIndexSync(entries);1882}18831884export async function purgeEntry(id: string): Promise<void> {1885 if (!derivedKey) throw new Error("vault_locked");1886 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1887 if (idx === -1) throw new Error("deleted entry not found");1888 entries.splice(idx, 1);1889 await persistEntries();1890}189118921893export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1894 if (!derivedKey || retentionMs === 0) return 0;1895 const cutoff = Date.now() - retentionMs;1896 const before = entries.length;1897 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1898 const purged = before - entries.length;1899 if (purged > 0) await persistEntries();1900 return purged;1901}19021903export function queryDomain(domain: string): VaultEntry[] {1904 return domainIndex.get(domain) ?? [];1905}19061907export function getEntryById(id: string): VaultEntry | undefined {1908 return entries.find((entry) => entry.id === id && !entry.deletedAt);1909}19101911async function persistSlotA(1912 reason: VaultSnapshot["reason"] = "local_save",1913): Promise<StoredVault> {1914 if (!derivedKey) throw new Error("vault_locked");19151916 const slotA = await loadSlot(SLOT_A);1917 if (!slotA) throw new Error("missing slot A");19181919 1920 1921 await takeVaultSnapshot(reason);19221923 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);1924 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");19251926 1927 const nextSlotA: StoredVault = {1928 ...slotA,1929 nonce,1930 ciphertext,1931 tag,1932 1933 };1934 await writeSlot(SLOT_A, nextSlotA);19351936 1937 1938 1939 1940 1941 1942 try {1943 1944 1945 1946 if (!duressConfigured) {1947 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1948 }1949 1950 1951 1952 1953 1954 if (reason !== "sync_received") {1955 await stampLocalVaultMutation();1956 }1957 } catch (err) {1958 1959 1960 await writeSlot(SLOT_A, slotA).catch(() => {});1961 throw err;1962 }19631964 return nextSlotA;1965}19661967async function persistSlotB(): Promise<void> {1968 if (!derivedKey) throw new Error("vault_locked");19691970 const slotB = await loadSlot(SLOT_B);1971 if (!slotB) throw new Error("missing slot B");19721973 1974 1975 1976 1977 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);1978 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");19791980 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1981 1982 1983 1984 try {1985 await stampLocalVaultMutation();1986 } catch (err) {1987 await writeSlot(SLOT_B, slotB).catch(() => {});1988 throw err;1989 }1990}19911992async function persistEntries(1993 reason: VaultSnapshot["reason"] = "local_save",1994): Promise<void> {1995 if (!derivedKey) throw new Error("vault_locked");19961997 if (vaultMode === "decoy") {1998 await persistSlotB();1999 } else {2000 await persistSlotA(reason);2001 }2002 buildDomainIndexSync(entries);2003}20042005function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2006 if (!Array.isArray(values)) return undefined;2007 const seen = new Set<string>();2008 const cleaned: string[] = [];2009 for (const value of values) {2010 const trimmed = typeof value === "string" ? value.trim() : "";2011 if (!trimmed) continue;2012 const key = trimmed.toLowerCase();2013 if (seen.has(key)) continue;2014 seen.add(key);2015 cleaned.push(trimmed);2016 }2017 return cleaned.length > 0 ? cleaned : undefined;2018}20192020function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2021 const title = entry.title.trim();2022 2023 2024 2025 let type: VaultEntry["type"] = entry.type ?? "login";2026 if (type === "passkey") type = "login";20272028 if (!title) throw new Error("title is required");20292030 if (entry.passkey) {2031 const p = entry.passkey;2032 if (2033 !p.rpId ||2034 !p.credentialId ||2035 !p.publicKeyCose ||2036 !p.userHandle ||2037 !p.userName ||2038 (p.algorithm !== -7 && p.algorithm !== -8)2039 ) {2040 throw new Error("invalid passkey credential");2041 }2042 }2043 if (type === "card") {2044 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2045 throw new Error("cardholder name and card number are required");2046 }2047 }20482049 return {2050 type,2051 title,2052 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2053 email: type === "login" ? entry.email?.trim() || undefined : undefined,2054 password: type === "login" ? entry.password : undefined,2055 url: type === "login" ? entry.url?.trim() ?? "" : undefined,2056 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2057 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2058 notes: entry.notes?.trim() || undefined,2059 breach: type === "login" ? entry.breach : undefined,2060 canary: type === "login" ? entry.canary : undefined,2061 tags: entry.tags,2062 passkey: entry.passkey,2063 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2064 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2065 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2066 expYear: type === "card" ? entry.expYear?.trim() : undefined,2067 cvc: type === "card" ? entry.cvc?.trim() : undefined,2068 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2069 network: type === "card" ? entry.network : undefined,2070 last4: type === "card" ? entry.last4?.trim() : undefined,2071 };2072}20732074function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2075 const nextPatch: Partial<VaultEntry> = {};20762077 if ("type" in patch) {2078 nextPatch.type = patch.type;2079 }20802081 if ("title" in patch) {2082 const title = patch.title?.trim() ?? "";2083 if (!title) throw new Error("title is required");2084 nextPatch.title = title;2085 }20862087 if ("url" in patch) {2088 nextPatch.url = patch.url?.trim() ?? "";2089 }20902091 if ("additionalUrls" in patch) {2092 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2093 const seen = new Set<string>();2094 const cleaned: string[] = [];2095 for (const value of incoming) {2096 const trimmed = typeof value === "string" ? value.trim() : "";2097 if (!trimmed) continue;2098 const key = trimmed.toLowerCase();2099 if (seen.has(key)) continue;2100 seen.add(key);2101 cleaned.push(trimmed);2102 }2103 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2104 }21052106 if ("username" in patch) {2107 nextPatch.username = patch.username?.trim() ?? "";2108 }21092110 if ("email" in patch) {2111 nextPatch.email = patch.email?.trim() || undefined;2112 }21132114 if ("password" in patch) {2115 const password = patch.password ?? "";2116 2117 nextPatch.password = password || undefined;2118 }21192120 if ("totp" in patch) {2121 nextPatch.totp = patch.totp?.trim() || undefined;2122 }21232124 if ("notes" in patch) {2125 nextPatch.notes = patch.notes?.trim() || undefined;2126 }21272128 if ("breach" in patch) {2129 nextPatch.breach = patch.breach;2130 }21312132 if ("canary" in patch) {2133 nextPatch.canary = patch.canary;2134 }21352136 if ("passkey" in patch) {2137 2138 2139 2140 nextPatch.passkey = patch.passkey;2141 }21422143 if ("tags" in patch) {2144 nextPatch.tags = patch.tags;2145 }21462147 if ("cardholderName" in patch) {2148 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2149 }2150 if ("cardNumber" in patch) {2151 const n = patch.cardNumber ?? "";2152 nextPatch.cardNumber = n || undefined;2153 }2154 if ("expMonth" in patch) {2155 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2156 }2157 if ("expYear" in patch) {2158 nextPatch.expYear = patch.expYear?.trim() || undefined;2159 }2160 if ("cvc" in patch) {2161 nextPatch.cvc = patch.cvc?.trim() || undefined;2162 }2163 if ("cardNickname" in patch) {2164 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2165 }2166 if ("network" in patch) {2167 nextPatch.network = patch.network;2168 }2169 if ("last4" in patch) {2170 nextPatch.last4 = patch.last4?.trim() || undefined;2171 }21722173 return nextPatch;2174}21752176function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {2177 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;2178 const modifiedAt =2179 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;21802181 const base: VaultEntry = {2182 id: entry.id,2183 type: "type" in entry ? entry.type : "login",2184 title: entry.title,2185 username: entry.username,2186 email: "email" in entry ? entry.email : undefined,2187 password: entry.password,2188 url: entry.url,2189 additionalUrls:2190 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2191 ? entry.additionalUrls2192 : undefined,2193 totp: entry.totp,2194 notes: entry.notes,2195 breach: entry.breach,2196 2197 2198 2199 passwordChangedAt:2200 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2201 canary: "canary" in entry ? entry.canary : undefined,2202 passkey: "passkey" in entry ? entry.passkey : undefined,2203 createdAt,2204 modifiedAt,2205 deletedAt: entry.deletedAt,2206 tags: "tags" in entry ? entry.tags : undefined,2207 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2208 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2209 expMonth: "expMonth" in entry ? entry.expMonth : undefined,2210 expYear: "expYear" in entry ? entry.expYear : undefined,2211 cvc: "cvc" in entry ? entry.cvc : undefined,2212 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2213 network: "network" in entry ? entry.network : undefined,2214 last4: "last4" in entry ? entry.last4 : undefined,2215 };22162217 return migrateLegacyPasskey(base, entry);2218}221922202221222222232224222522262227function migrateLegacyPasskey(2228 base: VaultEntry,2229 raw: VaultEntry | LegacyVaultEntry2230): VaultEntry {2231 if (base.passkey) return base;2232 const flat = raw as Partial<VaultEntry>;2233 const isPasskeyType = base.type === "passkey";2234 const hasFlatPasskeyFields =2235 typeof flat.rpId === "string" &&2236 typeof flat.credentialId === "string" &&2237 typeof flat.publicKeyCose === "string" &&2238 typeof flat.userHandle === "string" &&2239 typeof flat.userName === "string";2240 if (!isPasskeyType && !hasFlatPasskeyFields) return base;2241 if (!hasFlatPasskeyFields) return base;22422243 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;2244 base.passkey = {2245 rpId: flat.rpId!,2246 rpName: flat.rpName ?? flat.rpId!,2247 credentialId: flat.credentialId!,2248 publicKeyCose: flat.publicKeyCose!,2249 privateKeyJwk: flat.privateKeyJwk,2250 userHandle: flat.userHandle!,2251 userName: flat.userName!,2252 userDisplayName: flat.userDisplayName ?? flat.userName!,2253 algorithm,2254 signCount: flat.signCount ?? 0,2255 aaguid: flat.aaguid ?? "",2256 backupEligible: flat.backupEligible === true,2257 backupState: flat.backupState === true,2258 lastUsedAt: flat.lastUsedAt,2259 browser: flat.browser,2260 transports: flat.transports,2261 createdAt: base.createdAt,2262 };2263 2264 2265 2266 if (isPasskeyType) base.type = "login";2267 if (!base.username) base.username = flat.userName;2268 if (!base.url) base.url = `https://${flat.rpId}`;2269 return base;2270}2271227222732274227522762277function entryDupeKey(e: VaultEntry): string {2278 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2279 2280 2281 2282 2283 if (e.passkey) {2284 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2285 }2286 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2287}228822892290export function countDuplicates(): number {2291 const best = new Map<string, VaultEntry>();2292 let dupeCount = 0;2293 for (const e of entries.filter((e) => !e.deletedAt)) {2294 const key = entryDupeKey(e);2295 const existing = best.get(key);2296 if (!existing) {2297 best.set(key, e);2298 } else {2299 dupeCount++;2300 2301 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2302 best.set(key, e);2303 }2304 }2305 }2306 return dupeCount;2307}230823092310231123122313export async function deduplicateEntries(): Promise<number> {2314 if (!derivedKey) throw new Error("vault_locked");2315 2316 2317 if (vaultMode === "decoy") {2318 throw new Error("deduplicate disabled in decoy mode");2319 }23202321 const best = new Map<string, VaultEntry>();2322 for (const e of entries.filter((en) => !en.deletedAt)) {2323 const key = entryDupeKey(e);2324 const existing = best.get(key);2325 if (!existing) {2326 best.set(key, e);2327 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2328 best.set(key, e);2329 }2330 }23312332 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2333 let removed = 0;2334 entries = entries.map((e) => {2335 if (!e.deletedAt && !keepIds.has(e.id)) {2336 removed++;2337 return { ...e, deletedAt: monotonicEntryTimestamp(e.modifiedAt) };2338 }2339 return e;2340 });23412342 if (removed > 0) await persistEntries();2343 return removed;2344}234523462347234823492350235123522353const HISTORY_KEY = "peach_vault_history";2354const HISTORY_MAX = 5;23552356235723582359236023612362export async function takeVaultSnapshot(2363 reason: VaultSnapshot["reason"],2364): Promise<void> {2365 try {2366 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2367 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2368 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];23692370 2371 2372 2373 if (!slotA) return;23742375 const vault_a = JSON.stringify(slotA);2376 const vault_b = JSON.stringify(slotB ?? slotA);23772378 2379 2380 2381 let entryCount: number | null = null;2382 if (derivedKey && vaultMode === "real") {2383 try {2384 entryCount = entries.filter((e) => !e.deletedAt).length;2385 } catch {2386 entryCount = null;2387 }2388 }23892390 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2391 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2392 ? existingRaw2393 : { snapshots: [] };23942395 2396 2397 2398 const latest = history.snapshots[0];2399 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2400 return;2401 }24022403 const snapshot: VaultSnapshot = {2404 id: crypto.randomUUID(),2405 savedAt: Date.now(),2406 reason,2407 vault_a,2408 vault_b,2409 entryCount,2410 };24112412 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2413 await chrome.storage.local.set({ [HISTORY_KEY]: history });2414 } catch (err) {2415 2416 2417 2418 2419 console.warn('[Peach] takeVaultSnapshot failed:', err);2420 if (String(err).includes('QUOTA') || String(err).includes('quota')) {2421 try {2422 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2423 } catch {2424 2425 }2426 }2427 }2428}242924302431export async function clearVaultHistory(): Promise<void> {2432 try {2433 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2434 } catch {2435 2436 }2437}243824392440export async function getVaultHistory(): Promise<VaultSnapshot[]> {2441 const raw = await chrome.storage.local.get(HISTORY_KEY);2442 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2443 if (!history || !Array.isArray(history.snapshots)) return [];2444 return history.snapshots;2445}24462447244824492450245124522453export async function restoreVaultSnapshot(2454 snapshotId: string,2455): Promise<{ ok: true } | { ok: false; error: string }> {2456 const history = await getVaultHistory();2457 const snapshot = history.find((s) => s.id === snapshotId);2458 if (!snapshot) return { ok: false, error: "snapshot_not_found" };24592460 2461 2462 2463 await takeVaultSnapshot("restore");24642465 let restoredA: StoredVault;2466 let restoredB: StoredVault;2467 try {2468 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2469 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2470 } catch {2471 return { ok: false, error: "snapshot_corrupt" };2472 }24732474 await writeSlot(SLOT_A, restoredA);2475 await writeSlot(SLOT_B, restoredB);2476 await stampLocalVaultMutation();24772478 2479 2480 2481 lock();24822483 return { ok: true };2484}24852486248724882489249024912492export async function changePassword(2493 currentPassword: string,2494 newPassword: string2495): Promise<boolean> {2496 if (!derivedKey || vaultMode !== "real") return false;24972498 2499 const valid = await verifyMainPassword(currentPassword);2500 if (!valid) return false;25012502 const slotA = await loadSlot(SLOT_A);2503 if (!slotA) return false;2504 if (!rootKey) return false;25052506 2507 const newSalt = generateSalt();2508 const newKey = await deriveKey(newPassword, newSalt);25092510 2511 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2512 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");25132514 2515 2516 2517 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);25182519 const finalSlotA: StoredVault = {2520 ...slotA,2521 salt: hex(newSalt),2522 nonce,2523 ciphertext,2524 tag,2525 ...pwRoot,2526 };25272528 2529 2530 2531 await takeVaultSnapshot("local_save");25322533 2534 2535 2536 2537 await writeSlot(SLOT_A, finalSlotA);25382539 2540 2541 2542 zeroKey(derivedKey);2543 derivedKey = newKey;25442545 2546 2547 2548 if (!duressConfigured) {2549 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2550 }2551 await stampLocalVaultMutation();25522553 return true;2554}