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;5253545556const PASSWORD_HISTORY_MAX = 50;5758596061626364656667const DURESS_SLOT_SIZE = 262_144;6869let derivedKey: Uint8Array | null = null;7071727374let rootKey: Uint8Array | null = null;75let entries: VaultEntry[] = [];76let domainIndex: Map<string, VaultEntry[]> = new Map();77let vaultMode: "real" | "decoy" = "real";78798081let duressConfigured = false;8283let migrationRan = false;8485868788899091929394let lastUnlockFailureReason: string | null = null;9596export function consumeLastUnlockFailureReason(): string | null {97 const r = lastUnlockFailureReason;98 lastUnlockFailureReason = null;99 return r;100}101102103104105106export type TextSize = "small" | "medium" | "large";107108export interface VaultSettings {109 accentColor?: AccentColorName;110 theme?: "dark" | "light";111 autoLockMinutes?: number;112 clipboardClearSeconds?: number;113 lockOnClose?: boolean;114 breachMonitoring?: boolean;115 passkeyProviderEnabled?: boolean;116 trashRetentionDays?: number;117 manualSummonDomains?: Record<string, ManualSummonRecord>;118 declinedCardFingerprints?: string[];119 120121122123124 textSize?: TextSize;125 126127128129130 reduceMotion?: boolean;131 132133134135136 biometricsEnabled?: boolean;137 138139140141142143144 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;145}146147let settings: VaultSettings = {};148149export function getSettings(): VaultSettings {150 return settings;151}152153export async function ensureSettingsLoaded(): Promise<VaultSettings> {154 await loadSettings();155 return settings;156}157158export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {159 await loadSettings();160 settings = { ...settings, ...patch };161 await persistSettings();162}163164async function persistSettings(): Promise<void> {165 try {166 await chrome.storage.local.set({ peach_settings: settings });167 } catch (err) {168 throw mapStorageError(err);169 }170}171172function mapStorageError(err: unknown): Error {173 const message = err instanceof Error ? err.message : String(err);174 if (/QUOTA_BYTES|quota/i.test(message)) {175 return new Error("STORAGE_QUOTA_EXCEEDED");176 }177 return err instanceof Error ? err : new Error(message);178}179180async function loadSettings(): Promise<void> {181 const result = await chrome.storage.local.get("peach_settings");182 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};183}184185186187188189190function fireSyncTrigger(reason: "vault_change"): void {191 void import("./sync-trigger")192 .then((mod) => mod.triggerSyncIfReachable({ reason }))193 .catch(() => {194 195 });196}197198199200201202export async function checkBreach(password: string): Promise<number> {203 204 205 const encoder = new TextEncoder();206 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));207 const hashHex = Array.from(new Uint8Array(hashBuffer))208 .map((b) => b.toString(16).padStart(2, "0"))209 .join("")210 .toUpperCase();211212 const prefix = hashHex.slice(0, 5);213 const suffix = hashHex.slice(5);214215 try {216 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);217 if (!res.ok) return 0;218 const text = await res.text();219 const match = text.split("\r\n").find((l) => l.startsWith(suffix));220 return match ? parseInt(match.split(":")[1], 10) : 0;221 } catch {222 return 0; 223 }224}225226export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {227 const fingerprint = await cardFingerprint(last4, expMonth, expYear);228 await loadSettings();229 settings = {230 ...settings,231 declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],232 };233 await persistSettings();234}235236export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {237 await loadSettings();238 const fingerprint = await cardFingerprint(last4, expMonth, expYear);239 return (settings.declinedCardFingerprints ?? []).includes(fingerprint);240}241242async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {243 const encoder = new TextEncoder();244 const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));245 return Array.from(new Uint8Array(buf).slice(0, 8))246 .map((b) => b.toString(16).padStart(2, "0"))247 .join("");248}249250function hex(buf: Uint8Array): string {251 return Array.from(buf)252 .map((b) => b.toString(16).padStart(2, "0"))253 .join("");254}255256function unhex(s: string): Uint8Array {257 const out = new Uint8Array(s.length / 2);258 for (let i = 0; i < s.length; i += 2) {259 out[i / 2] = parseInt(s.slice(i, i + 2), 16);260 }261 return out;262}263264265266267268269270271272273274275async function generateAndWrapRootKey(): Promise<{276 rawRootKey: string; 277 recoverySalt: string;278 wrappedRootKey: string;279 wrappedNonce: string;280 wrappedTag: string;281}> {282 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));283284 285 const words = await rootKeyToWords(rootKeyBytes);286 const wordMaterial = await wordsToRootKeyMaterial(words);287 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));288 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);289290 291 292 const nonce = crypto.getRandomValues(new Uint8Array(12));293 const encoded = new TextEncoder().encode(hex(rootKeyBytes));294 const raw = await crypto.subtle.encrypt(295 {296 name: "AES-GCM",297 iv: nonce,298 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,299 tagLength: 128,300 },301 recoveryKey,302 encoded303 );304 305 const rawU8 = new Uint8Array(raw);306 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));307 const tag = hex(rawU8.slice(rawU8.length - 16));308309 return {310 rawRootKey: hex(rootKeyBytes),311 recoverySalt: hex(recoverySalt),312 wrappedRootKey: ciphertext,313 wrappedNonce: hex(nonce),314 wrappedTag: tag,315 };316}317318319320321322323export async function unwrapRootKeyForRecovery(324 wrappedRootKey: string,325 wrappedNonce: string,326 wrappedTag: string,327 recoverySaltHex: string,328 words: string[]329): Promise<void> {330 const wordMaterial = await wordsToRootKeyMaterial(words);331 const recoverySalt = unhex(recoverySaltHex);332 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);333334 335 336 337 338 339 const nonceRaw = unhex(wrappedNonce);340 const ciphertextRaw = unhex(wrappedRootKey);341 const tagRaw = unhex(wrappedTag);342 343 344 const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;345 const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);346 combined.set(ciphertextRaw, 0);347 combined.set(tagRaw, ciphertextRaw.length);348 const raw = await crypto.subtle.decrypt(349 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },350 recoveryKey,351 combined352 );353 const decryptedHex = new TextDecoder().decode(raw);354 rootKey = unhex(decryptedHex);355}356357358359360361362363364365366export async function rewrapRootKeyForPassword(367 newDerivedKey: Uint8Array,368 _newSalt: Uint8Array369): Promise<void> {370 if (!rootKey) throw new Error("root key not set");371 const slotA = await loadSlot(SLOT_A);372 if (!slotA) throw new Error("missing slot A");373 const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);374 await writeSlot(SLOT_A, { ...slotA, ...pwRoot });375}376377378379380381382async function reencryptVaultForNewKey(383 newKey: Uint8Array,384 slotA: StoredVault385): Promise<void> {386 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);387 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");388 await writeSlot(SLOT_A, {389 ...slotA,390 nonce,391 ciphertext,392 tag,393 });394}395396397398399400401export async function changePasswordWithNewKey(402 newSalt: Uint8Array,403 newKey: Uint8Array404): Promise<void> {405 const slotA = await loadSlot(SLOT_A);406 if (!slotA) throw new Error("missing slot A");407 408 409 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });410 if (derivedKey) zeroKey(derivedKey);411 derivedKey = newKey;412 413 414 415 416 if (rootKey) {417 const slotAfter = await loadSlot(SLOT_A);418 if (slotAfter) {419 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);420 await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });421 }422 }423 if (!duressConfigured) {424 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));425 }426 await stampLocalVaultMutation();427}428429430431432433434export { deriveKey, generateSalt };435436export async function loadSlotA(): Promise<StoredVault | null> {437 return loadSlot(SLOT_A);438}439440441442443444445446447448449450451452async function deriveRootKeyFromDerivedKey(453 derivedKeyBytes: Uint8Array,454 slotA: StoredVault,455): Promise<Uint8Array | null> {456 if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {457 return null;458 }459 try {460 const rootHex = await decrypt(461 derivedKeyBytes,462 slotA.pw_root_nonce,463 slotA.pw_root_ciphertext,464 slotA.pw_root_tag,465 "pw_root_v1",466 );467 return unhex(rootHex);468 } catch {469 return null;470 }471}472473474475async function wrapRootKeyForDerivedKey(476 derivedKeyBytes: Uint8Array,477 rootKeyBytes: Uint8Array,478): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {479 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");480 return {481 pw_root_ciphertext: ciphertext,482 pw_root_nonce: nonce,483 pw_root_tag: tag,484 };485}486487488489490491492493494export async function migrateToRecovery(): Promise<void> {495 if (!derivedKey || vaultMode !== "real") {496 throw new Error("vault must be unlocked in real mode");497 }498 const slotA = await loadSlot(SLOT_A);499 if (!slotA) throw new Error("missing slot A");500 const hasRecoveryEscrow =501 !!slotA.recovery_salt && !!slotA.recovery_wrapped_root_key;502 const hasPwRoot = !!slotA.pw_root_ciphertext && !!slotA.pw_root_nonce;503 if (hasRecoveryEscrow && hasPwRoot) return; 504505 506 507 508 509 510 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =511 await generateAndWrapRootKey();512 rootKey = unhex(rawRootKey);513514 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);515 const nextSlotA: StoredVault = {516 ...slotA,517 recovery_salt: recoverySalt,518 recovery_wrapped_root_key: wrappedRootKey,519 recovery_nonce: wrappedNonce,520 recovery_tag: wrappedTag,521 ...pwRoot,522 };523 await writeSlot(SLOT_A, nextSlotA);524 await stampLocalVaultMutation();525}526527528529530531532export async function getRecoveryWords(): Promise<string[]> {533 if (!rootKey) {534 throw new Error("recovery not set up — vault may need migration");535 }536 return rootKeyToWords(rootKey);537}538539async function loadSlot(key: string): Promise<StoredVault | null> {540 const result = await chrome.storage.local.get(key);541 return (result as Record<string, StoredVault | undefined>)[key] ?? null;542}543544async function writeSlot(key: string, vault: StoredVault): Promise<void> {545 try {546 await chrome.storage.local.set({ [key]: vault });547 } catch (err) {548 throw mapStorageError(err);549 }550}551552553554555556557558559let lastEntryTimestamp = 0;560function monotonicEntryTimestamp(currentModifiedAt?: number): number {561 const base = Math.max(562 Date.now(),563 (currentModifiedAt ?? 0) + 1,564 lastEntryTimestamp + 1,565 );566 lastEntryTimestamp = base;567 return base;568}569570async function stampLocalVaultMutation(): Promise<void> {571 const raw = await chrome.storage.local.get("peach_last_modified_at");572 const previous = Number(573 (raw as Record<string, unknown>).peach_last_modified_at,574 );575 const next = Math.max(576 Date.now(),577 Number.isFinite(previous) ? previous + 1 : 0,578 );579 await chrome.storage.local.set({580 peach_last_modified_at: String(next),581 });582}583584585586587588589async function ensureMigrated(): Promise<void> {590 if (migrationRan) return;591 migrationRan = true;592593 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];594 const raw = await chrome.storage.local.get(keys);595 const slotAExists = raw[SLOT_A] !== undefined;596 const slotBExists = raw[SLOT_B] !== undefined;597 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;598 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;599600 601 if (!slotAExists && !legacyMain) return;602603 604 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;605606 let didMigrate = false;607 if (legacyMain && !slotAExists) {608 await writeSlot(SLOT_A, legacyMain);609 didMigrate = true;610 }611612 613 if (!slotBExists) {614 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;615 if (legacyDecoy) {616 await writeSlot(SLOT_B, legacyDecoy);617 didMigrate = true;618 } else if (target) {619 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));620 didMigrate = true;621 }622 }623624 const toRemove: string[] = [];625 if (legacyMain) toRemove.push(LEGACY_MAIN);626 if (legacyDecoy) toRemove.push(LEGACY_DECOY);627 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);628 if (toRemove.length > 0) {629 await chrome.storage.local.remove(toRemove);630 didMigrate = true;631 }632 if (didMigrate) {633 await stampLocalVaultMutation();634 }635}636637638639640641642643644645646647function padPlaintext(json: string, minBytes = 0): string {648 const encoded = new TextEncoder().encode(json);649 const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);650 651 const target = Math.max(bucket, minBytes);652 const padLen = target - encoded.length;653 return json + " ".repeat(padLen);654}655656657658659660661662663664665666const CSPRNG_CHUNK = 65_536;667668function fillRandom(buf: Uint8Array): Uint8Array {669 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {670 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);671 const chunk = new Uint8Array(end - offset);672 crypto.getRandomValues(chunk);673 buf.set(chunk, offset);674 }675 return buf;676}677678function generateNoiseBlob(ciphertextBytes: number): StoredVault {679 const salt = crypto.getRandomValues(new Uint8Array(16));680 const nonce = crypto.getRandomValues(new Uint8Array(12));681 const cipher = fillRandom(new Uint8Array(ciphertextBytes));682 const tag = crypto.getRandomValues(new Uint8Array(16));683 return {684 version: 1,685 salt: hex(salt),686 nonce: hex(nonce),687 ciphertext: hex(cipher),688 tag: hex(tag),689 };690}691692693694695696type VaultPayload = {697 entries: VaultEntry[];698 duressConfigured?: boolean;699};700701type DecryptedPayload = {702 entries: VaultEntry[];703 duressConfigured: boolean;704 705706707708709710711 migratedFromLegacy: boolean;712};713714function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {715 const envelope: VaultPayload = { entries, duressConfigured: duress };716 return padPlaintext(JSON.stringify(envelope), minBytes);717}718719function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {720 if ("created" in raw || "updated" in raw) return true;721 const flat = raw as Partial<VaultEntry>;722 if (flat.type === "passkey") return true;723 724 if (flat.rpId !== undefined && flat.passkey === undefined) return true;725 return false;726}727728function decodePayload(plain: string): DecryptedPayload {729 const parsed = JSON.parse(plain) as730 | Array<VaultEntry | LegacyVaultEntry>731 | VaultPayload;732733 const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];734 const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);735736 const normalized = rawEntries.map(normalizeEntry);737 const { merged } = mergePasskeyOnlyIntoLogins(normalized);738739 if (Array.isArray(parsed)) {740 return {741 entries: normalized,742 duressConfigured: false,743 migratedFromLegacy: true, 744 };745 }746747 return {748 entries: normalized,749 duressConfigured: parsed.duressConfigured === true,750 migratedFromLegacy: flatMigrationNeeded || merged,751 };752}753754755756757758759760761762763764765766767768769770771772773774775776777function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {778 let merged = false;779 const now = Date.now();780781 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);782 const byRpId = new Map<string, VaultEntry[]>();783 for (const e of candidates) {784 const rpId = e.passkey!.rpId;785 const list = byRpId.get(rpId);786 if (list) list.push(e);787 else byRpId.set(rpId, [e]);788 }789790 for (const [rpId, group] of byRpId) {791 if (group.length === 0) continue;792 group.sort((a, b) => {793 const aT =794 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;795 const bT =796 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;797 return bT - aT;798 });799 const winner = group[0]!;800801 802 803 804 const loginTarget = entries.find((e) => {805 if (e.deletedAt || e.passkey || e.type !== "login") return false;806 if (e.url && extractDomain(e.url) === rpId) return true;807 for (const extra of e.additionalUrls ?? []) {808 if (extractDomain(extra) === rpId) return true;809 }810 return false;811 });812813 if (loginTarget) {814 loginTarget.passkey = winner.passkey;815 loginTarget.modifiedAt = now;816 for (const dup of group) {817 if (!dup.deletedAt) {818 dup.deletedAt = now;819 dup.modifiedAt = now;820 }821 }822 merged = true;823 } else if (group.length > 1) {824 for (let i = 1; i < group.length; i += 1) {825 const dup = group[i]!;826 if (!dup.deletedAt) {827 dup.deletedAt = now;828 dup.modifiedAt = now;829 }830 }831 merged = true;832 }833 }834835 return { merged };836}837838839840841842843844function hasValidVaultSchema(v: unknown): v is StoredVault {845 if (typeof v !== "object" || v === null) return false;846 const r = v as Record<string, unknown>;847 return (848 r.version === 1 &&849 typeof r.salt === "string" &&850 typeof r.nonce === "string" &&851 typeof r.ciphertext === "string" &&852 typeof r.tag === "string"853 );854}855856export type TryDecryptResult =857 | { status: "ok"; payload: DecryptedPayload }858 | { status: "wrong_key" }859 | { status: "corrupted"; reason: string };860861862863864865866export async function tryDecryptSlot(867 vault: StoredVault,868 key: Uint8Array,869 aadTag?: string,870): Promise<DecryptedPayload | null> {871 const res = await tryDecryptSlotResult(vault, key, aadTag);872 return res.status === "ok" ? res.payload : null;873}874875export async function tryDecryptSlotResult(876 vault: StoredVault,877 key: Uint8Array,878 aadTag?: string,879): Promise<TryDecryptResult> {880 if (!hasValidVaultSchema(vault)) {881 return { status: "corrupted", reason: "invalid_slot_schema" };882 }883 let plain: string;884 try {885 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);886 } catch {887 return { status: "wrong_key" };888 }889 try {890 const parsed = JSON.parse(plain) as unknown;891 if (typeof parsed !== "object" || parsed === null) {892 return { status: "corrupted", reason: "invalid_payload_shape" };893 }894 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {895 return { status: "corrupted", reason: "invalid_payload_shape" };896 }897 return { status: "ok", payload: decodePayload(plain) };898 } catch {899 return { status: "corrupted", reason: "invalid_payload_json" };900 }901}902903904905906907function buildDomainIndexSync(all: VaultEntry[]): void {908 domainIndex = new Map();909910 for (const entry of all) {911 if (entry.deletedAt) continue;912 if (entry.type === "note" || entry.type === "card") continue;913914 915 916 917 918 const domains = new Set<string>();919 const primary = extractDomain(entry.url ?? "");920 if (primary) domains.add(primary);921 for (const extra of entry.additionalUrls ?? []) {922 const d = extractDomain(extra);923 if (d) domains.add(d);924 }925 if (domains.size === 0) continue;926927 for (const domain of domains) {928 const bucket = domainIndex.get(domain) ?? [];929 bucket.push(entry);930 domainIndex.set(domain, bucket);931 }932 }933}934935export async function createVault(password: string): Promise<void> {936 await ensureMigrated();937938 const salt = generateSalt();939 derivedKey = await deriveKey(password, salt);940 entries = [];941 vaultMode = "real";942 duressConfigured = false;943 buildDomainIndexSync(entries);944945 946 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =947 await generateAndWrapRootKey();948 rootKey = unhex(rawRootKey);949950 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);951 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");952953 954 955 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);956957 const slotA: StoredVault = {958 version: 1,959 salt: hex(salt),960 nonce,961 ciphertext,962 tag,963 recovery_salt: recoverySalt,964 recovery_wrapped_root_key: wrappedRootKey,965 recovery_nonce: wrappedNonce,966 recovery_tag: wrappedTag,967 ...pwRoot,968 };969 await writeSlot(SLOT_A, slotA);970971 972 973 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));974 await stampLocalVaultMutation();975976 touchAutoLock();977}978979export async function hasVault(): Promise<boolean> {980 await ensureMigrated();981 return (await loadSlot(SLOT_A)) !== null;982}983984985986987988989export async function isDuressEnabled(): Promise<boolean> {990 return vaultMode === "real" && duressConfigured;991}992993export async function setupDuress(duressPassword: string): Promise<void> {994 if (!derivedKey || vaultMode !== "real") {995 throw new Error("vault must be unlocked in real mode");996 }997 await ensureMigrated();998999 1000 1001 1002 const slotA = await loadSlot(SLOT_A);1003 if (!slotA) throw new Error("missing slot A");10041005 const decoySalt = generateSalt();1006 const decoyKey = await deriveKey(duressPassword, decoySalt);1007 try {1008 1009 1010 1011 1012 const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1013 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1014 await writeSlot(SLOT_B, {1015 version: 1,1016 salt: hex(decoySalt),1017 nonce,1018 ciphertext,1019 tag,1020 });1021 } finally {1022 zeroKey(decoyKey);1023 }10241025 1026 1027 duressConfigured = true;1028 await persistSlotA();1029}10301031export async function disableDuress(): Promise<void> {1032 if (!derivedKey || vaultMode !== "real") {1033 throw new Error("vault must be unlocked in real mode");1034 }1035 await ensureMigrated();10361037 1038 1039 1040 const slotA = await loadSlot(SLOT_A);1041 if (slotA) {1042 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1043 }10441045 duressConfigured = false;1046 await persistSlotA();1047}10481049105010511052105310541055export async function verifyMainPassword(password: string): Promise<boolean> {1056 await ensureMigrated();1057 const slotA = await loadSlot(SLOT_A);1058 if (!slotA) return false;1059 const key = await deriveKey(password, unhex(slotA.salt));1060 try {1061 return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1062 } finally {1063 zeroKey(key);1064 }1065}10661067export async function unlock(password: string): Promise<boolean> {1068 await ensureMigrated();10691070 1071 1072 1073 1074 1075 const [slotA, slotB] = await Promise.all([1076 loadSlot(SLOT_A),1077 loadSlot(SLOT_B),1078 ]);10791080 1081 const [keyA, keyB] = await Promise.all([1082 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),1083 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),1084 ]);10851086 1087 1088 1089 const [resultA, resultB] = await Promise.all([1090 keyA && slotA1091 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1092 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1093 keyB && slotB1094 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1095 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1096 ]);1097 const payloadA = resultA.status === "ok" ? resultA.payload : null;1098 const payloadB = resultB.status === "ok" ? resultB.payload : null;10991100 if (payloadA && keyA) {1101 derivedKey = keyA;1102 void saveSessionKey(keyA, "real");1103 vaultMode = "real";1104 entries = payloadA.entries;1105 duressConfigured = payloadA.duressConfigured;1106 buildDomainIndexSync(entries);1107 await loadSettings();1108 touchAutoLock();1109 if (keyB) zeroKey(keyB);11101111 1112 if (slotA) {1113 const needsRecoverySetup =1114 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;1115 if (!needsRecoverySetup) {1116 rootKey = await deriveRootKeyFromDerivedKey(keyA, slotA);1117 }1118 1119 1120 1121 1122 if (needsRecoverySetup || !rootKey) {1123 try {1124 await migrateToRecovery();1125 if (needsRecoverySetup) {1126 1127 1128 1129 await chrome.storage.local.set({ peach_recovery_pending: '1' });1130 }1131 } catch (migrationErr) {1132 console.warn('[Peach] recovery migration failed:', migrationErr);1133 }1134 }1135 }11361137 1138 1139 1140 if (payloadA.migratedFromLegacy) {1141 await persistSlotA();1142 }1143 return true;1144 }11451146 if (payloadB && keyB) {1147 derivedKey = keyB;1148 void saveSessionKey(keyB, "decoy");1149 vaultMode = "decoy";1150 entries = payloadB.entries;1151 duressConfigured = false;1152 buildDomainIndexSync(entries);1153 await loadSettings();1154 touchAutoLock();1155 if (keyA) zeroKey(keyA);1156 return true;1157 }11581159 if (keyA) zeroKey(keyA);1160 if (keyB) zeroKey(keyB);1161 1162 1163 if (resultA.status === "corrupted") {1164 lastUnlockFailureReason = resultA.reason;1165 } else if (resultB.status === "corrupted") {1166 lastUnlockFailureReason = resultB.reason;1167 }1168 return false;1169}11701171117211731174117511761177const SESSION_KEY_STORAGE = "peach_session_dk";11781179async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1180 if (!chrome.storage.session) return;1181 await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1182}11831184async function clearSessionKey(): Promise<void> {1185 if (!chrome.storage.session) return;1186 await chrome.storage.session.remove(SESSION_KEY_STORAGE);1187}11881189119011911192119311941195export async function restoreFromSession(): Promise<boolean> {1196 if (derivedKey !== null) return true;1197 if (!chrome.storage.session) return false;1198 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1199 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1200 if (!entry?.keyHex) return false;12011202 const key = unhex(entry.keyHex);1203 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1204 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1205 const slot = await loadSlot(slotKey);1206 if (!slot) { await clearSessionKey(); return false; }12071208 const result = await tryDecryptSlotResult(slot, key, aadTag);1209 if (result.status !== "ok") { await clearSessionKey(); return false; }12101211 derivedKey = key;1212 vaultMode = entry.mode;1213 entries = result.payload.entries;1214 duressConfigured = entry.mode === "real" ? result.payload.duressConfigured : false;1215 buildDomainIndexSync(entries);1216 await loadSettings();1217 touchAutoLock();12181219 if (entry.mode === "real" && slot.recovery_salt && slot.recovery_wrapped_root_key) {1220 try { rootKey = await deriveRootKeyFromDerivedKey(key, slot); } catch { }1221 }12221223 return true;1224}12251226export function lock(): void {1227 void clearSessionKey();1228 if (derivedKey) {1229 zeroKey(derivedKey);1230 derivedKey = null;1231 }1232 if (rootKey) {1233 rootKey.fill(0);1234 rootKey = null;1235 }12361237 entries = [];1238 domainIndex = new Map();1239 vaultMode = "real";1240 duressConfigured = false;1241 clearAutoLock();1242}12431244export function isUnlocked(): boolean {1245 return derivedKey !== null;1246}1247124812491250export async function unlockWithKey(1251 masterKeyHex: string,1252): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1253 try {1254 const masterKeyBytes = unhex(masterKeyHex);1255 const stored = await loadSlot(SLOT_A);1256 if (!stored) return { ok: false, error: "invalid_key" };1257 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1258 if (!payload) return { ok: false, error: "invalid_key" };12591260 derivedKey = masterKeyBytes;1261 vaultMode = "real";1262 entries = payload.entries;1263 duressConfigured = payload.duressConfigured;1264 buildDomainIndexSync(entries);1265 await loadSettings();1266 touchAutoLock();1267 return { ok: true, entries };1268 } catch {1269 return { ok: false, error: "invalid_key" };1270 }1271}12721273127412751276127712781279128012811282128312841285128612871288128912901291export async function unlockFromCodex(params: {1292 slotA: string; 1293 slotB: string | null; 1294 timestamp: number;1295 masterPassphrase: string;1296}): Promise<1297 | { ok: true; entryCount: number }1298 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1299> {1300 let parsedSlotA: StoredVault;1301 let parsedSlotB: StoredVault | null = null;1302 try {1303 const rawA = JSON.parse(params.slotA) as unknown;1304 if (!hasValidVaultSchema(rawA)) {1305 return { ok: false, reason: "invalid_slot_format" };1306 }1307 parsedSlotA = rawA;1308 if (params.slotB !== null) {1309 const rawB = JSON.parse(params.slotB) as unknown;1310 if (!hasValidVaultSchema(rawB)) {1311 return { ok: false, reason: "invalid_slot_format" };1312 }1313 parsedSlotB = rawB;1314 }1315 } catch {1316 return { ok: false, reason: "invalid_slot_format" };1317 }13181319 1320 1321 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1322 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1323 if (!trialPayload) {1324 zeroKey(trialKey);1325 return { ok: false, reason: "wrong_passphrase" };1326 }1327 1328 1329 zeroKey(trialKey);13301331 1332 1333 await takeVaultSnapshot("restore");13341335 await writeSlot(SLOT_A, parsedSlotA);1336 if (parsedSlotB) {1337 await writeSlot(SLOT_B, parsedSlotB);1338 } else {1339 1340 1341 1342 await writeSlot(1343 SLOT_B,1344 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1345 );1346 }1347 await chrome.storage.local.set({1348 peach_last_modified_at: String(params.timestamp),1349 });13501351 1352 1353 const ok = await unlock(params.masterPassphrase);1354 if (!ok) {1355 1356 1357 1358 return { ok: false, reason: "wrong_passphrase" };1359 }1360 return { ok: true, entryCount: getEntries().length };1361}1362136313641365export function getDerivedKeyHex(): string {1366 if (!derivedKey) throw new Error("vault_not_unlocked");1367 return hex(derivedKey);1368}13691370137113721373137413751376137713781379138013811382138313841385export function getRootKeyHex(): string | null {1386 return rootKey ? hex(rootKey) : null;1387}13881389export function getEntries(): VaultEntry[] {1390 if (!derivedKey) throw new Error("vault_locked");1391 return entries.filter((entry) => !entry.deletedAt);1392}13931394const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;13951396export function getIdentities(): SavedIdentity[] {1397 if (!derivedKey) throw new Error("vault_locked");1398 const active = entries.filter((e) => !e.deletedAt && e.type === "login");13991400 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1401 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();14021403 for (const entry of active) {1404 const domain = extractDomain(entry.url ?? "") ?? "";1405 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;14061407 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1408 const existing = map.get(v);1409 if (!existing || modifiedAt > existing.lastUsed) {1410 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1411 } else {1412 map.set(v, { ...existing, count: existing.count + 1 });1413 }1414 }14151416 const uVal = entry.username?.trim();1417 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);14181419 1420 1421 const eVal = entry.email?.trim();1422 if (eVal) tally(emailMap, eVal);1423 }14241425 const toIdentity = (type: "email" | "username") =>1426 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1427 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1428 });14291430 return [1431 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1432 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1433 ];1434}14351436export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1437 if (!derivedKey) throw new Error("vault_locked");14381439 const now = Date.now();1440 const added: VaultEntry[] = [];1441 for (const draft of drafts) {1442 try {1443 const sanitized = sanitizeEntryDraft(draft);1444 const nextEntry: VaultEntry = {1445 ...sanitized,1446 id: crypto.randomUUID(),1447 createdAt: now,1448 modifiedAt: monotonicEntryTimestamp(),1449 };1450 entries.push(nextEntry);1451 added.push(nextEntry);1452 } catch {1453 1454 }1455 }14561457 if (added.length > 0) {1458 await persistEntries();1459 }14601461 return added;1462}146314641465146614671468146914701471export async function restoreEntries(1472 incoming: VaultEntry[],1473 mode: "replace" | "merge"1474): Promise<{ added: number; updated: number; skipped: number }> {1475 if (!derivedKey) throw new Error("vault_locked");14761477 1478 1479 1480 if (mode === "replace" && incoming.length === 0) {1481 throw new Error("refuse to replace with empty entry set");1482 }14831484 let added = 0;1485 let updated = 0;1486 let skipped = 0;14871488 if (mode === "replace") {1489 entries = [];1490 for (const entry of incoming) {1491 const normalized = sanitizeRestoredEntry(entry);1492 if (!normalized) {1493 skipped += 1;1494 continue;1495 }1496 entries.push(normalized);1497 added += 1;1498 }1499 } else {1500 const byId = new Map<string, VaultEntry>();1501 for (const e of entries) byId.set(e.id, e);15021503 for (const entry of incoming) {1504 const normalized = sanitizeRestoredEntry(entry);1505 if (!normalized) {1506 skipped += 1;1507 continue;1508 }1509 const existing = byId.get(normalized.id);1510 if (!existing) {1511 entries.push(normalized);1512 added += 1;1513 } else if (normalized.modifiedAt > existing.modifiedAt) {1514 const idx = entries.findIndex((e) => e.id === existing.id);1515 if (idx !== -1) {1516 entries[idx] = normalized;1517 updated += 1;1518 }1519 } else {1520 skipped += 1;1521 }1522 }1523 }15241525 await persistEntries("import");1526 return { added, updated, skipped };1527}1528152915301531153215331534function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1535 if (!entry || typeof entry !== "object") return null;1536 const title = typeof entry.title === "string" ? entry.title.trim() : "";1537 if (!title) return null;1538 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1539 const now = Date.now();1540 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1541 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;15421543 1544 1545 1546 const migrated: VaultEntry = migrateLegacyPasskey(1547 {1548 ...entry,1549 id,1550 title,1551 type: entry.type ?? "login",1552 createdAt,1553 modifiedAt,1554 },1555 entry1556 );15571558 1559 1560 if (1561 migrated.type === "login" &&1562 !migrated.passkey &&1563 typeof migrated.password !== "string"1564 ) {1565 return null;1566 }1567 if (migrated.passkey && !migrated.passkey.credentialId) {1568 return null;1569 }15701571 return migrated;1572}157315741575157615771578export async function clearVaultEntries(1579 confirmation: "REPLACE_ALL"1580): Promise<void> {1581 if (confirmation !== "REPLACE_ALL") {1582 throw new Error("clearVaultEntries requires explicit confirmation");1583 }1584 if (!derivedKey) throw new Error("vault_locked");1585 for (const entry of entries) {1586 if (!entry.deletedAt) {1587 const stamp = monotonicEntryTimestamp(entry.modifiedAt);1588 entry.deletedAt = stamp;1589 entry.modifiedAt = stamp;1590 }1591 }1592 await persistEntries();1593}15941595export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1596 if (!derivedKey) throw new Error("vault_locked");15971598 const sanitized = sanitizeEntryDraft(entry);1599 const now = Date.now();1600 const stamp = monotonicEntryTimestamp();1601 const nextEntry: VaultEntry = {1602 ...sanitized,1603 id: crypto.randomUUID(),1604 createdAt: now,1605 modifiedAt: stamp,1606 1607 1608 passwordChangedAt: sanitized.password ? stamp : undefined,1609 };16101611 1612 1613 1614 const rollback = entries;1615 entries = [...entries, nextEntry];1616 try {1617 await persistEntries();1618 } catch (err) {1619 entries = rollback;1620 throw err;1621 }16221623 1624 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1625 checkBreach(nextEntry.password).then(async (count) => {1626 if (count > 0) {1627 const idx = entries.findIndex((e) => e.id === nextEntry.id);1628 if (idx !== -1) {1629 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };1630 await persistEntries();1631 }1632 }1633 });1634 }16351636 fireSyncTrigger("vault_change");16371638 return nextEntry;1639}16401641164216431644164516461647export async function consolidatePasskeyEntries(): Promise<void> {1648 if (!derivedKey) return;1649 const { merged } = mergePasskeyOnlyIntoLogins(entries);1650 if (merged) {1651 await persistEntries();1652 }1653}16541655export async function updateEntry(1656 id: string,1657 patch: Partial<VaultEntry>1658): Promise<void> {1659 if (!derivedKey) throw new Error("vault_locked");16601661 const idx = entries.findIndex((entry) => entry.id === id);1662 if (idx === -1 || entries[idx].deletedAt) {1663 throw new Error("entry_not_found");1664 }16651666 const current = entries[idx];1667 const sanitizedPatch = sanitizeEntryPatch(patch);1668 1669 1670 1671 const passwordChanged =1672 "password" in patch &&1673 typeof sanitizedPatch.password === "string" &&1674 sanitizedPatch.password !== current.password;1675 const stamp = monotonicEntryTimestamp(current.modifiedAt);1676 1677 1678 1679 1680 1681 1682 1683 const priorPassword = current.password;1684 const shouldCapture =1685 passwordChanged &&1686 typeof priorPassword === "string" &&1687 priorPassword.length > 0 &&1688 current.passwordHistory?.[0]?.password !== priorPassword;1689 const nextHistory = shouldCapture1690 ? [1691 { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },1692 ...(current.passwordHistory ?? []),1693 ].slice(0, PASSWORD_HISTORY_MAX)1694 : current.passwordHistory;1695 entries[idx] = {1696 ...current,1697 ...sanitizedPatch,1698 modifiedAt: stamp,1699 ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),1700 };1701170217031704 1705 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1706 entries[idx] = current;1707 throw new Error("card number is required for card entries");1708 }17091710 1711 1712 1713 1714 try {1715 await persistEntries();1716 } catch (err) {1717 entries[idx] = current;1718 throw err;1719 }17201721 1722 1723 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1724 entries[idx] = { ...entries[idx], breach: undefined };1725 await persistEntries();1726 const password = entries[idx].password;1727 if (password) {1728 checkBreach(password).then(async (count) => {1729 const i = entries.findIndex((e) => e.id === id);1730 if (i !== -1 && count > 0) {1731 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };1732 await persistEntries();1733 }1734 });1735 }1736 }17371738 fireSyncTrigger("vault_change");1739}17401741export async function deleteEntry(id: string): Promise<void> {1742 if (!derivedKey) throw new Error("vault_locked");17431744 const entry = entries.find((item) => item.id === id);1745 if (!entry || entry.deletedAt) {1746 throw new Error("entry_not_found");1747 }17481749 1750 1751 1752 const previousModifiedAt = entry.modifiedAt;1753 const stamp = monotonicEntryTimestamp(entry.modifiedAt);1754 entry.deletedAt = stamp;1755 entry.modifiedAt = stamp;1756 1757 1758 try {1759 await persistEntries();1760 } catch (err) {1761 delete entry.deletedAt;1762 entry.modifiedAt = previousModifiedAt;1763 throw err;1764 }1765 fireSyncTrigger("vault_change");1766}17671768export function getDeletedEntries(): VaultEntry[] {1769 if (!derivedKey) return [];1770 return entries.filter((e) => !!e.deletedAt);1771}1772177317741775177617771778export async function getVaultVersion(): Promise<number> {1779 try {1780 const raw = await chrome.storage.local.get("peach_last_modified_at");1781 const v = (raw as Record<string, unknown>).peach_last_modified_at;1782 if (typeof v === "number") return v;1783 if (typeof v === "string" && v !== "") {1784 const n = Number.parseInt(v, 10);1785 return Number.isFinite(n) ? n : 0;1786 }1787 return 0;1788 } catch {1789 return 0;1790 }1791}179217931794179517961797179817991800180118021803export async function addEntryDirect(entry: VaultEntry): Promise<void> {1804 if (!derivedKey) throw new Error("vault_locked");1805 entries = [...entries, entry];1806 await persistEntries("sync_received");1807}18081809export async function updateEntryDirect(patch: VaultEntry): Promise<void> {1810 if (!derivedKey) throw new Error("vault_locked");1811 const idx = entries.findIndex((e) => e.id === patch.id);1812 if (idx === -1) throw new Error("entry_not_found");1813 entries[idx] = { ...entries[idx], ...patch };1814 await persistEntries("sync_received");1815}18161817export async function deleteEntryDirect(id: string, deletedAt: number): Promise<void> {1818 if (!derivedKey) throw new Error("vault_locked");1819 const idx = entries.findIndex((e) => e.id === id);1820 if (idx === -1) throw new Error("entry_not_found");1821 entries[idx] = { ...entries[idx], deletedAt, modifiedAt: deletedAt };1822 await persistEntries("sync_received");1823}1824182518261827182818291830183118321833183418351836export async function applyMergedRemoteEntries(1837 remoteEntries: VaultEntry[],1838 remoteTombstones: Record<string, number>,1839): Promise<{ received: number; applied: number; conflicts: number }> {1840 1841 1842 1843 1844 1845 1846 if (!derivedKey) throw new Error("vault_locked");18471848 const received = remoteEntries.length + Object.keys(remoteTombstones).length;1849 let applied = 0;1850 let conflicts = 0;18511852 const byId = new Map<string, VaultEntry>();1853 for (const e of entries) byId.set(e.id, e);18541855 for (const remote of remoteEntries) {1856 const sanitized = sanitizeRestoredEntry(remote);1857 if (!sanitized) continue;1858 const tombstoneTs = remoteTombstones[sanitized.id];1859 if (tombstoneTs !== undefined && tombstoneTs >= sanitized.modifiedAt) {1860 continue;1861 }1862 const local = byId.get(sanitized.id);1863 if (!local) {1864 entries.push(sanitized);1865 byId.set(sanitized.id, sanitized);1866 applied += 1;1867 } else if (sanitized.modifiedAt > local.modifiedAt) {1868 const idx = entries.findIndex((e) => e.id === local.id);1869 if (idx !== -1) {1870 entries[idx] = sanitized;1871 byId.set(sanitized.id, sanitized);1872 applied += 1;1873 if (!local.deletedAt) conflicts += 1;1874 }1875 }1876 }18771878 for (const [id, deletedAt] of Object.entries(remoteTombstones)) {1879 if (typeof deletedAt !== "number" || !Number.isFinite(deletedAt)) continue;1880 const local = byId.get(id);1881 if (!local) continue;1882 if (local.deletedAt && local.deletedAt >= deletedAt) continue;1883 if (deletedAt < local.modifiedAt) continue;1884 const idx = entries.findIndex((e) => e.id === id);1885 if (idx !== -1) {1886 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };1887 applied += 1;1888 }1889 }18901891 if (applied > 0) {1892 await persistEntries("sync_received");1893 buildDomainIndexSync(entries);1894 }1895 return { received, applied, conflicts };1896}18971898export async function restoreEntry(id: string): Promise<void> {1899 if (!derivedKey) throw new Error("vault_locked");1900 const entry = entries.find((e) => e.id === id && e.deletedAt);1901 if (!entry) throw new Error("deleted entry not found");1902 delete entry.deletedAt;1903 entry.modifiedAt = monotonicEntryTimestamp(entry.modifiedAt);1904 await persistEntries();1905 buildDomainIndexSync(entries);1906}19071908export async function purgeEntry(id: string): Promise<void> {1909 if (!derivedKey) throw new Error("vault_locked");1910 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1911 if (idx === -1) throw new Error("deleted entry not found");1912 entries.splice(idx, 1);1913 await persistEntries();1914}191519161917export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1918 if (!derivedKey || retentionMs === 0) return 0;1919 const cutoff = Date.now() - retentionMs;1920 const before = entries.length;1921 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1922 const purged = before - entries.length;1923 if (purged > 0) await persistEntries();1924 return purged;1925}19261927export function queryDomain(domain: string): VaultEntry[] {1928 return domainIndex.get(domain) ?? [];1929}19301931export function getEntryById(id: string): VaultEntry | undefined {1932 return entries.find((entry) => entry.id === id && !entry.deletedAt);1933}19341935async function persistSlotA(1936 reason: VaultSnapshot["reason"] = "local_save",1937): Promise<StoredVault> {1938 if (!derivedKey) throw new Error("vault_locked");19391940 const slotA = await loadSlot(SLOT_A);1941 if (!slotA) throw new Error("missing slot A");19421943 1944 1945 await takeVaultSnapshot(reason);19461947 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);1948 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");19491950 1951 const nextSlotA: StoredVault = {1952 ...slotA,1953 nonce,1954 ciphertext,1955 tag,1956 1957 };1958 await writeSlot(SLOT_A, nextSlotA);19591960 1961 1962 1963 1964 1965 1966 try {1967 1968 1969 1970 if (!duressConfigured) {1971 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1972 }1973 1974 1975 1976 1977 1978 if (reason !== "sync_received") {1979 await stampLocalVaultMutation();1980 }1981 } catch (err) {1982 1983 1984 await writeSlot(SLOT_A, slotA).catch(() => {});1985 throw err;1986 }19871988 return nextSlotA;1989}19901991async function persistSlotB(): Promise<void> {1992 if (!derivedKey) throw new Error("vault_locked");19931994 const slotB = await loadSlot(SLOT_B);1995 if (!slotB) throw new Error("missing slot B");19961997 1998 1999 2000 2001 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2002 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");20032004 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2005 2006 2007 2008 try {2009 await stampLocalVaultMutation();2010 } catch (err) {2011 await writeSlot(SLOT_B, slotB).catch(() => {});2012 throw err;2013 }2014}20152016async function persistEntries(2017 reason: VaultSnapshot["reason"] = "local_save",2018): Promise<void> {2019 if (!derivedKey) throw new Error("vault_locked");20202021 if (vaultMode === "decoy") {2022 await persistSlotB();2023 } else {2024 await persistSlotA(reason);2025 }2026 buildDomainIndexSync(entries);2027}20282029function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2030 if (!Array.isArray(values)) return undefined;2031 const seen = new Set<string>();2032 const cleaned: string[] = [];2033 for (const value of values) {2034 const trimmed = typeof value === "string" ? value.trim() : "";2035 if (!trimmed) continue;2036 const key = trimmed.toLowerCase();2037 if (seen.has(key)) continue;2038 seen.add(key);2039 cleaned.push(trimmed);2040 }2041 return cleaned.length > 0 ? cleaned : undefined;2042}20432044function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2045 const title = entry.title.trim();2046 2047 2048 2049 let type: VaultEntry["type"] = entry.type ?? "login";2050 if (type === "passkey") type = "login";20512052 if (!title) throw new Error("title is required");20532054 if (entry.passkey) {2055 const p = entry.passkey;2056 if (2057 !p.rpId ||2058 !p.credentialId ||2059 !p.publicKeyCose ||2060 !p.userHandle ||2061 !p.userName ||2062 (p.algorithm !== -7 && p.algorithm !== -8)2063 ) {2064 throw new Error("invalid passkey credential");2065 }2066 }2067 if (type === "card") {2068 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2069 throw new Error("cardholder name and card number are required");2070 }2071 }20722073 return {2074 type,2075 title,2076 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2077 email: type === "login" ? entry.email?.trim() || undefined : undefined,2078 password: type === "login" ? entry.password : undefined,2079 url: type === "login" ? entry.url?.trim() ?? "" : undefined,2080 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2081 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2082 notes: entry.notes?.trim() || undefined,2083 breach: type === "login" ? entry.breach : undefined,2084 canary: type === "login" ? entry.canary : undefined,2085 tags: entry.tags,2086 passkey: entry.passkey,2087 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2088 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2089 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2090 expYear: type === "card" ? entry.expYear?.trim() : undefined,2091 cvc: type === "card" ? entry.cvc?.trim() : undefined,2092 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2093 network: type === "card" ? entry.network : undefined,2094 last4: type === "card" ? entry.last4?.trim() : undefined,2095 };2096}20972098function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2099 const nextPatch: Partial<VaultEntry> = {};21002101 if ("type" in patch) {2102 nextPatch.type = patch.type;2103 }21042105 if ("title" in patch) {2106 const title = patch.title?.trim() ?? "";2107 if (!title) throw new Error("title is required");2108 nextPatch.title = title;2109 }21102111 if ("url" in patch) {2112 nextPatch.url = patch.url?.trim() ?? "";2113 }21142115 if ("additionalUrls" in patch) {2116 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2117 const seen = new Set<string>();2118 const cleaned: string[] = [];2119 for (const value of incoming) {2120 const trimmed = typeof value === "string" ? value.trim() : "";2121 if (!trimmed) continue;2122 const key = trimmed.toLowerCase();2123 if (seen.has(key)) continue;2124 seen.add(key);2125 cleaned.push(trimmed);2126 }2127 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2128 }21292130 if ("username" in patch) {2131 nextPatch.username = patch.username?.trim() ?? "";2132 }21332134 if ("email" in patch) {2135 nextPatch.email = patch.email?.trim() || undefined;2136 }21372138 if ("password" in patch) {2139 const password = patch.password ?? "";2140 2141 nextPatch.password = password || undefined;2142 }21432144 if ("totp" in patch) {2145 nextPatch.totp = patch.totp?.trim() || undefined;2146 }21472148 if ("notes" in patch) {2149 nextPatch.notes = patch.notes?.trim() || undefined;2150 }21512152 if ("breach" in patch) {2153 nextPatch.breach = patch.breach;2154 }21552156 if ("canary" in patch) {2157 nextPatch.canary = patch.canary;2158 }21592160 if ("passkey" in patch) {2161 2162 2163 2164 nextPatch.passkey = patch.passkey;2165 }21662167 if ("tags" in patch) {2168 nextPatch.tags = patch.tags;2169 }21702171 if ("cardholderName" in patch) {2172 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2173 }2174 if ("cardNumber" in patch) {2175 const n = patch.cardNumber ?? "";2176 nextPatch.cardNumber = n || undefined;2177 }2178 if ("expMonth" in patch) {2179 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2180 }2181 if ("expYear" in patch) {2182 nextPatch.expYear = patch.expYear?.trim() || undefined;2183 }2184 if ("cvc" in patch) {2185 nextPatch.cvc = patch.cvc?.trim() || undefined;2186 }2187 if ("cardNickname" in patch) {2188 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2189 }2190 if ("network" in patch) {2191 nextPatch.network = patch.network;2192 }2193 if ("last4" in patch) {2194 nextPatch.last4 = patch.last4?.trim() || undefined;2195 }21962197 return nextPatch;2198}21992200function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {2201 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;2202 const modifiedAt =2203 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;22042205 const base: VaultEntry = {2206 id: entry.id,2207 type: "type" in entry ? entry.type : "login",2208 title: entry.title,2209 username: entry.username,2210 email: "email" in entry ? entry.email : undefined,2211 password: entry.password,2212 url: entry.url,2213 additionalUrls:2214 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2215 ? entry.additionalUrls2216 : undefined,2217 totp: entry.totp,2218 notes: entry.notes,2219 breach: entry.breach,2220 2221 2222 2223 passwordChangedAt:2224 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2225 2226 2227 2228 passwordHistory:2229 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)2230 ? entry.passwordHistory2231 : undefined,2232 canary: "canary" in entry ? entry.canary : undefined,2233 passkey: "passkey" in entry ? entry.passkey : undefined,2234 createdAt,2235 modifiedAt,2236 deletedAt: entry.deletedAt,2237 tags: "tags" in entry ? entry.tags : undefined,2238 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2239 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2240 expMonth: "expMonth" in entry ? entry.expMonth : undefined,2241 expYear: "expYear" in entry ? entry.expYear : undefined,2242 cvc: "cvc" in entry ? entry.cvc : undefined,2243 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2244 network: "network" in entry ? entry.network : undefined,2245 last4: "last4" in entry ? entry.last4 : undefined,2246 };22472248 return migrateLegacyPasskey(base, entry);2249}225022512252225322542255225622572258function migrateLegacyPasskey(2259 base: VaultEntry,2260 raw: VaultEntry | LegacyVaultEntry2261): VaultEntry {2262 if (base.passkey) return base;2263 const flat = raw as Partial<VaultEntry>;2264 const isPasskeyType = base.type === "passkey";2265 const hasFlatPasskeyFields =2266 typeof flat.rpId === "string" &&2267 typeof flat.credentialId === "string" &&2268 typeof flat.publicKeyCose === "string" &&2269 typeof flat.userHandle === "string" &&2270 typeof flat.userName === "string";2271 if (!isPasskeyType && !hasFlatPasskeyFields) return base;2272 if (!hasFlatPasskeyFields) return base;22732274 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;2275 base.passkey = {2276 rpId: flat.rpId!,2277 rpName: flat.rpName ?? flat.rpId!,2278 credentialId: flat.credentialId!,2279 publicKeyCose: flat.publicKeyCose!,2280 privateKeyJwk: flat.privateKeyJwk,2281 userHandle: flat.userHandle!,2282 userName: flat.userName!,2283 userDisplayName: flat.userDisplayName ?? flat.userName!,2284 algorithm,2285 signCount: flat.signCount ?? 0,2286 aaguid: flat.aaguid ?? "",2287 backupEligible: flat.backupEligible === true,2288 backupState: flat.backupState === true,2289 lastUsedAt: flat.lastUsedAt,2290 browser: flat.browser,2291 transports: flat.transports,2292 createdAt: base.createdAt,2293 };2294 2295 2296 2297 if (isPasskeyType) base.type = "login";2298 if (!base.username) base.username = flat.userName;2299 if (!base.url) base.url = `https://${flat.rpId}`;2300 return base;2301}2302230323042305230623072308function entryDupeKey(e: VaultEntry): string {2309 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2310 2311 2312 2313 2314 if (e.passkey) {2315 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2316 }2317 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2318}231923202321export function countDuplicates(): number {2322 const best = new Map<string, VaultEntry>();2323 let dupeCount = 0;2324 for (const e of entries.filter((e) => !e.deletedAt)) {2325 const key = entryDupeKey(e);2326 const existing = best.get(key);2327 if (!existing) {2328 best.set(key, e);2329 } else {2330 dupeCount++;2331 2332 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2333 best.set(key, e);2334 }2335 }2336 }2337 return dupeCount;2338}233923402341234223432344export async function deduplicateEntries(): Promise<number> {2345 if (!derivedKey) throw new Error("vault_locked");2346 2347 2348 if (vaultMode === "decoy") {2349 throw new Error("deduplicate disabled in decoy mode");2350 }23512352 const best = new Map<string, VaultEntry>();2353 for (const e of entries.filter((en) => !en.deletedAt)) {2354 const key = entryDupeKey(e);2355 const existing = best.get(key);2356 if (!existing) {2357 best.set(key, e);2358 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2359 best.set(key, e);2360 }2361 }23622363 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2364 let removed = 0;2365 entries = entries.map((e) => {2366 if (!e.deletedAt && !keepIds.has(e.id)) {2367 removed++;2368 return { ...e, deletedAt: monotonicEntryTimestamp(e.modifiedAt) };2369 }2370 return e;2371 });23722373 if (removed > 0) await persistEntries();2374 return removed;2375}237623772378237923802381238223832384const HISTORY_KEY = "peach_vault_history";2385const HISTORY_MAX = 5;23862387238823892390239123922393export async function takeVaultSnapshot(2394 reason: VaultSnapshot["reason"],2395): Promise<void> {2396 try {2397 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2398 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2399 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];24002401 2402 2403 2404 if (!slotA) return;24052406 const vault_a = JSON.stringify(slotA);2407 const vault_b = JSON.stringify(slotB ?? slotA);24082409 2410 2411 2412 let entryCount: number | null = null;2413 if (derivedKey && vaultMode === "real") {2414 try {2415 entryCount = entries.filter((e) => !e.deletedAt).length;2416 } catch {2417 entryCount = null;2418 }2419 }24202421 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2422 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2423 ? existingRaw2424 : { snapshots: [] };24252426 2427 2428 2429 const latest = history.snapshots[0];2430 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2431 return;2432 }24332434 const snapshot: VaultSnapshot = {2435 id: crypto.randomUUID(),2436 savedAt: Date.now(),2437 reason,2438 vault_a,2439 vault_b,2440 entryCount,2441 };24422443 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2444 await chrome.storage.local.set({ [HISTORY_KEY]: history });2445 } catch (err) {2446 2447 2448 2449 2450 console.warn('[Peach] takeVaultSnapshot failed:', err);2451 if (String(err).includes('QUOTA') || String(err).includes('quota')) {2452 try {2453 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2454 } catch {2455 2456 }2457 }2458 }2459}246024612462export async function clearVaultHistory(): Promise<void> {2463 try {2464 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2465 } catch {2466 2467 }2468}246924702471export async function getVaultHistory(): Promise<VaultSnapshot[]> {2472 const raw = await chrome.storage.local.get(HISTORY_KEY);2473 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2474 if (!history || !Array.isArray(history.snapshots)) return [];2475 return history.snapshots;2476}24772478247924802481248224832484export async function restoreVaultSnapshot(2485 snapshotId: string,2486): Promise<{ ok: true } | { ok: false; error: string }> {2487 const history = await getVaultHistory();2488 const snapshot = history.find((s) => s.id === snapshotId);2489 if (!snapshot) return { ok: false, error: "snapshot_not_found" };24902491 2492 2493 2494 await takeVaultSnapshot("restore");24952496 let restoredA: StoredVault;2497 let restoredB: StoredVault;2498 try {2499 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2500 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2501 } catch {2502 return { ok: false, error: "snapshot_corrupt" };2503 }25042505 await writeSlot(SLOT_A, restoredA);2506 await writeSlot(SLOT_B, restoredB);2507 await stampLocalVaultMutation();25082509 2510 2511 2512 lock();25132514 return { ok: true };2515}25162517251825192520252125222523export async function changePassword(2524 currentPassword: string,2525 newPassword: string2526): Promise<boolean> {2527 if (!derivedKey || vaultMode !== "real") return false;25282529 2530 const valid = await verifyMainPassword(currentPassword);2531 if (!valid) return false;25322533 const slotA = await loadSlot(SLOT_A);2534 if (!slotA) return false;2535 if (!rootKey) return false;25362537 2538 const newSalt = generateSalt();2539 const newKey = await deriveKey(newPassword, newSalt);25402541 2542 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2543 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");25442545 2546 2547 2548 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);25492550 const finalSlotA: StoredVault = {2551 ...slotA,2552 salt: hex(newSalt),2553 nonce,2554 ciphertext,2555 tag,2556 ...pwRoot,2557 };25582559 2560 2561 2562 await takeVaultSnapshot("local_save");25632564 2565 2566 2567 2568 await writeSlot(SLOT_A, finalSlotA);25692570 2571 2572 2573 zeroKey(derivedKey);2574 derivedKey = newKey;25752576 2577 2578 2579 if (!duressConfigured) {2580 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2581 }2582 await stampLocalVaultMutation();25832584 return true;2585}