123456789101112import { deriveKey, generateSalt } from "../crypto/argon2";13import { extractDomain } from "../shared/domain";14import {15 rootKeyToWords,16 wordsToRootKeyMaterial,17 deriveRecoveryKey,18} from "../shared/recovery-words";19import type {20 AccentColorName,21 LegacyVaultEntry,22 ManualSummonRecord,23 SavedIdentity,24 StoredVault,25 VaultEntry,26 VaultHistory,27 VaultSnapshot,28} from "../shared/types";29import { decrypt, encrypt, zeroKey } from "./aes";30import { clearAutoLock, touchAutoLock } from "./alarms";3132type EntryDraft = Omit<33 VaultEntry,34 "id" | "createdAt" | "modifiedAt" | "deletedAt"35>;363738394041const SLOT_A = "peach_vault_a";42const SLOT_B = "peach_vault_b";434445const LEGACY_MAIN = "peach_vault";46const LEGACY_DECOY = "peach_decoy_vault";47const LEGACY_DURESS_FLAG = "peach_duress";48495051const PAD_BLOCK = 1024;5253let derivedKey: Uint8Array | null = null;5455565758let rootKey: Uint8Array | null = null;59let entries: VaultEntry[] = [];60let domainIndex: Map<string, VaultEntry[]> = new Map();61let vaultMode: "real" | "decoy" = "real";62636465let duressConfigured = false;6667let migrationRan = false;6869707172737475767778let lastUnlockFailureReason: string | null = null;7980export function consumeLastUnlockFailureReason(): string | null {81 const r = lastUnlockFailureReason;82 lastUnlockFailureReason = null;83 return r;84}858687888990export type TextSize = "small" | "medium" | "large";9192export interface VaultSettings {93 accentColor?: AccentColorName;94 theme?: "dark" | "light";95 autoLockMinutes?: number;96 clipboardClearSeconds?: number;97 lockOnClose?: boolean;98 breachMonitoring?: boolean;99 passkeyProviderEnabled?: boolean;100 trashRetentionDays?: number;101 manualSummonDomains?: Record<string, ManualSummonRecord>;102 declinedCardFingerprints?: string[];103 104105106107108 textSize?: TextSize;109 110111112113114 reduceMotion?: boolean;115 116117118119120 biometricsEnabled?: boolean;121 122123124125126127128 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;129}130131let settings: VaultSettings = {};132133export function getSettings(): VaultSettings {134 return settings;135}136137export async function ensureSettingsLoaded(): Promise<VaultSettings> {138 await loadSettings();139 return settings;140}141142export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {143 await loadSettings();144 settings = { ...settings, ...patch };145 await persistSettings();146}147148async function persistSettings(): Promise<void> {149 try {150 await chrome.storage.local.set({ peach_settings: settings });151 } catch (err) {152 throw mapStorageError(err);153 }154}155156function mapStorageError(err: unknown): Error {157 const message = err instanceof Error ? err.message : String(err);158 if (/QUOTA_BYTES|quota/i.test(message)) {159 return new Error("STORAGE_QUOTA_EXCEEDED");160 }161 return err instanceof Error ? err : new Error(message);162}163164async function loadSettings(): Promise<void> {165 const result = await chrome.storage.local.get("peach_settings");166 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};167}168169170171172173174function fireSyncTrigger(reason: "vault_change"): void {175 void import("./sync-trigger")176 .then((mod) => mod.triggerSyncIfReachable({ reason }))177 .catch(() => {178 179 });180}181182183184185186export async function checkBreach(password: string): Promise<number> {187 188 189 const encoder = new TextEncoder();190 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));191 const hashHex = Array.from(new Uint8Array(hashBuffer))192 .map((b) => b.toString(16).padStart(2, "0"))193 .join("")194 .toUpperCase();195196 const prefix = hashHex.slice(0, 5);197 const suffix = hashHex.slice(5);198199 try {200 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);201 if (!res.ok) return 0;202 const text = await res.text();203 const match = text.split("\r\n").find((l) => l.startsWith(suffix));204 return match ? parseInt(match.split(":")[1], 10) : 0;205 } catch {206 return 0; 207 }208}209210export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {211 const fingerprint = await cardFingerprint(last4, expMonth, expYear);212 await loadSettings();213 settings = {214 ...settings,215 declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],216 };217 await persistSettings();218}219220export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {221 await loadSettings();222 const fingerprint = await cardFingerprint(last4, expMonth, expYear);223 return (settings.declinedCardFingerprints ?? []).includes(fingerprint);224}225226async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {227 const encoder = new TextEncoder();228 const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));229 return Array.from(new Uint8Array(buf).slice(0, 8))230 .map((b) => b.toString(16).padStart(2, "0"))231 .join("");232}233234function hex(buf: Uint8Array): string {235 return Array.from(buf)236 .map((b) => b.toString(16).padStart(2, "0"))237 .join("");238}239240function unhex(s: string): Uint8Array {241 const out = new Uint8Array(s.length / 2);242 for (let i = 0; i < s.length; i += 2) {243 out[i / 2] = parseInt(s.slice(i, i + 2), 16);244 }245 return out;246}247248249250251252253254255256257258259async function generateAndWrapRootKey(): Promise<{260 rawRootKey: string; 261 recoverySalt: string;262 wrappedRootKey: string;263 wrappedNonce: string;264 wrappedTag: string;265}> {266 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));267268 269 const words = await rootKeyToWords(rootKeyBytes);270 const wordMaterial = await wordsToRootKeyMaterial(words);271 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));272 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);273274 275 276 const nonce = crypto.getRandomValues(new Uint8Array(12));277 const encoded = new TextEncoder().encode(hex(rootKeyBytes));278 const raw = await crypto.subtle.encrypt(279 {280 name: "AES-GCM",281 iv: nonce,282 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,283 tagLength: 128,284 },285 recoveryKey,286 encoded287 );288 289 const rawU8 = new Uint8Array(raw);290 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));291 const tag = hex(rawU8.slice(rawU8.length - 16));292293 return {294 rawRootKey: hex(rootKeyBytes),295 recoverySalt: hex(recoverySalt),296 wrappedRootKey: ciphertext,297 wrappedNonce: hex(nonce),298 wrappedTag: tag,299 };300}301302303304305306307export async function unwrapRootKeyForRecovery(308 wrappedRootKey: string,309 wrappedNonce: string,310 wrappedTag: string,311 recoverySaltHex: string,312 words: string[]313): Promise<void> {314 const wordMaterial = await wordsToRootKeyMaterial(words);315 const recoverySalt = unhex(recoverySaltHex);316 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);317318 319 320 321 322 323 const nonceRaw = unhex(wrappedNonce);324 const ciphertextRaw = unhex(wrappedRootKey);325 const tagRaw = unhex(wrappedTag);326 327 328 const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;329 const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);330 combined.set(ciphertextRaw, 0);331 combined.set(tagRaw, ciphertextRaw.length);332 const raw = await crypto.subtle.decrypt(333 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },334 recoveryKey,335 combined336 );337 const decryptedHex = new TextDecoder().decode(raw);338 rootKey = unhex(decryptedHex);339}340341342343344345346347348349350export async function rewrapRootKeyForPassword(351 newDerivedKey: Uint8Array,352 _newSalt: Uint8Array353): Promise<void> {354 if (!rootKey) throw new Error("root key not set");355 const slotA = await loadSlot(SLOT_A);356 if (!slotA) throw new Error("missing slot A");357 const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);358 await writeSlot(SLOT_A, { ...slotA, ...pwRoot });359}360361362363364365366async function reencryptVaultForNewKey(367 newKey: Uint8Array,368 slotA: StoredVault369): Promise<void> {370 const plaintext = encodePayload(entries, duressConfigured);371 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");372 await writeSlot(SLOT_A, {373 ...slotA,374 nonce,375 ciphertext,376 tag,377 });378}379380381382383384385export async function changePasswordWithNewKey(386 newSalt: Uint8Array,387 newKey: Uint8Array388): Promise<void> {389 const slotA = await loadSlot(SLOT_A);390 if (!slotA) throw new Error("missing slot A");391 392 393 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });394 if (derivedKey) zeroKey(derivedKey);395 derivedKey = newKey;396 397 398 399 400 if (rootKey) {401 const slotAfter = await loadSlot(SLOT_A);402 if (slotAfter) {403 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);404 await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });405 }406 }407 if (!duressConfigured) {408 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));409 }410 await stampLocalVaultMutation();411}412413414415416417418export { deriveKey, generateSalt };419420export async function loadSlotA(): Promise<StoredVault | null> {421 return loadSlot(SLOT_A);422}423424425426427428429430431432433434435436async function deriveRootKeyFromDerivedKey(437 derivedKeyBytes: Uint8Array,438 slotA: StoredVault,439): Promise<Uint8Array | null> {440 if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {441 return null;442 }443 try {444 const rootHex = await decrypt(445 derivedKeyBytes,446 slotA.pw_root_nonce,447 slotA.pw_root_ciphertext,448 slotA.pw_root_tag,449 "pw_root_v1",450 );451 return unhex(rootHex);452 } catch {453 return null;454 }455}456457458459async function wrapRootKeyForDerivedKey(460 derivedKeyBytes: Uint8Array,461 rootKeyBytes: Uint8Array,462): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {463 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");464 return {465 pw_root_ciphertext: ciphertext,466 pw_root_nonce: nonce,467 pw_root_tag: tag,468 };469}470471472473474475476477478export async function migrateToRecovery(): Promise<void> {479 if (!derivedKey || vaultMode !== "real") {480 throw new Error("vault must be unlocked in real mode");481 }482 const slotA = await loadSlot(SLOT_A);483 if (!slotA) throw new Error("missing slot A");484 const hasRecoveryEscrow =485 !!slotA.recovery_salt && !!slotA.recovery_wrapped_root_key;486 const hasPwRoot = !!slotA.pw_root_ciphertext && !!slotA.pw_root_nonce;487 if (hasRecoveryEscrow && hasPwRoot) return; 488489 490 491 492 493 494 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =495 await generateAndWrapRootKey();496 rootKey = unhex(rawRootKey);497498 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);499 const nextSlotA: StoredVault = {500 ...slotA,501 recovery_salt: recoverySalt,502 recovery_wrapped_root_key: wrappedRootKey,503 recovery_nonce: wrappedNonce,504 recovery_tag: wrappedTag,505 ...pwRoot,506 };507 await writeSlot(SLOT_A, nextSlotA);508 await stampLocalVaultMutation();509}510511512513514515516export async function getRecoveryWords(): Promise<string[]> {517 if (!rootKey) {518 throw new Error("recovery not set up — vault may need migration");519 }520 return rootKeyToWords(rootKey);521}522523async function loadSlot(key: string): Promise<StoredVault | null> {524 const result = await chrome.storage.local.get(key);525 return (result as Record<string, StoredVault | undefined>)[key] ?? null;526}527528async function writeSlot(key: string, vault: StoredVault): Promise<void> {529 try {530 await chrome.storage.local.set({ [key]: vault });531 } catch (err) {532 throw mapStorageError(err);533 }534}535536537538539540541542543let lastEntryTimestamp = 0;544function monotonicEntryTimestamp(currentModifiedAt?: number): number {545 const base = Math.max(546 Date.now(),547 (currentModifiedAt ?? 0) + 1,548 lastEntryTimestamp + 1,549 );550 lastEntryTimestamp = base;551 return base;552}553554async function stampLocalVaultMutation(): Promise<void> {555 const raw = await chrome.storage.local.get("peach_last_modified_at");556 const previous = Number(557 (raw as Record<string, unknown>).peach_last_modified_at,558 );559 const next = Math.max(560 Date.now(),561 Number.isFinite(previous) ? previous + 1 : 0,562 );563 await chrome.storage.local.set({564 peach_last_modified_at: String(next),565 });566}567568569570571572573async function ensureMigrated(): Promise<void> {574 if (migrationRan) return;575 migrationRan = true;576577 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];578 const raw = await chrome.storage.local.get(keys);579 const slotAExists = raw[SLOT_A] !== undefined;580 const slotBExists = raw[SLOT_B] !== undefined;581 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;582 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;583584 585 if (!slotAExists && !legacyMain) return;586587 588 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;589590 let didMigrate = false;591 if (legacyMain && !slotAExists) {592 await writeSlot(SLOT_A, legacyMain);593 didMigrate = true;594 }595596 597 if (!slotBExists) {598 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;599 if (legacyDecoy) {600 await writeSlot(SLOT_B, legacyDecoy);601 didMigrate = true;602 } else if (target) {603 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));604 didMigrate = true;605 }606 }607608 const toRemove: string[] = [];609 if (legacyMain) toRemove.push(LEGACY_MAIN);610 if (legacyDecoy) toRemove.push(LEGACY_DECOY);611 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);612 if (toRemove.length > 0) {613 await chrome.storage.local.remove(toRemove);614 didMigrate = true;615 }616 if (didMigrate) {617 await stampLocalVaultMutation();618 }619}620621622623624625626627628629630631function padPlaintext(json: string): string {632 const encoded = new TextEncoder().encode(json);633 const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);634 const padLen = target - encoded.length;635 return json + " ".repeat(padLen);636}637638639640641642643644645646647648const CSPRNG_CHUNK = 65_536;649650function fillRandom(buf: Uint8Array): Uint8Array {651 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {652 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);653 const chunk = new Uint8Array(end - offset);654 crypto.getRandomValues(chunk);655 buf.set(chunk, offset);656 }657 return buf;658}659660function generateNoiseBlob(ciphertextBytes: number): StoredVault {661 const salt = crypto.getRandomValues(new Uint8Array(16));662 const nonce = crypto.getRandomValues(new Uint8Array(12));663 const cipher = fillRandom(new Uint8Array(ciphertextBytes));664 const tag = crypto.getRandomValues(new Uint8Array(16));665 return {666 version: 1,667 salt: hex(salt),668 nonce: hex(nonce),669 ciphertext: hex(cipher),670 tag: hex(tag),671 };672}673674675676677678type VaultPayload = {679 entries: VaultEntry[];680 duressConfigured?: boolean;681};682683type DecryptedPayload = {684 entries: VaultEntry[];685 duressConfigured: boolean;686 687688689690691692693 migratedFromLegacy: boolean;694};695696function encodePayload(entries: VaultEntry[], duress: boolean): string {697 const envelope: VaultPayload = { entries, duressConfigured: duress };698 return padPlaintext(JSON.stringify(envelope));699}700701function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {702 if ("created" in raw || "updated" in raw) return true;703 const flat = raw as Partial<VaultEntry>;704 if (flat.type === "passkey") return true;705 706 if (flat.rpId !== undefined && flat.passkey === undefined) return true;707 return false;708}709710function decodePayload(plain: string): DecryptedPayload {711 const parsed = JSON.parse(plain) as712 | Array<VaultEntry | LegacyVaultEntry>713 | VaultPayload;714715 const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];716 const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);717718 const normalized = rawEntries.map(normalizeEntry);719 const { merged } = mergePasskeyOnlyIntoLogins(normalized);720721 if (Array.isArray(parsed)) {722 return {723 entries: normalized,724 duressConfigured: false,725 migratedFromLegacy: true, 726 };727 }728729 return {730 entries: normalized,731 duressConfigured: parsed.duressConfigured === true,732 migratedFromLegacy: flatMigrationNeeded || merged,733 };734}735736737738739740741742743744745746747748749750751752753754755756757758759function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {760 let merged = false;761 const now = Date.now();762763 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);764 const byRpId = new Map<string, VaultEntry[]>();765 for (const e of candidates) {766 const rpId = e.passkey!.rpId;767 const list = byRpId.get(rpId);768 if (list) list.push(e);769 else byRpId.set(rpId, [e]);770 }771772 for (const [rpId, group] of byRpId) {773 if (group.length === 0) continue;774 group.sort((a, b) => {775 const aT =776 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;777 const bT =778 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;779 return bT - aT;780 });781 const winner = group[0]!;782783 784 785 786 const loginTarget = entries.find((e) => {787 if (e.deletedAt || e.passkey || e.type !== "login") return false;788 if (e.url && extractDomain(e.url) === rpId) return true;789 for (const extra of e.additionalUrls ?? []) {790 if (extractDomain(extra) === rpId) return true;791 }792 return false;793 });794795 if (loginTarget) {796 loginTarget.passkey = winner.passkey;797 loginTarget.modifiedAt = now;798 for (const dup of group) {799 if (!dup.deletedAt) {800 dup.deletedAt = now;801 dup.modifiedAt = now;802 }803 }804 merged = true;805 } else if (group.length > 1) {806 for (let i = 1; i < group.length; i += 1) {807 const dup = group[i]!;808 if (!dup.deletedAt) {809 dup.deletedAt = now;810 dup.modifiedAt = now;811 }812 }813 merged = true;814 }815 }816817 return { merged };818}819820821822823824825826function hasValidVaultSchema(v: unknown): v is StoredVault {827 if (typeof v !== "object" || v === null) return false;828 const r = v as Record<string, unknown>;829 return (830 r.version === 1 &&831 typeof r.salt === "string" &&832 typeof r.nonce === "string" &&833 typeof r.ciphertext === "string" &&834 typeof r.tag === "string"835 );836}837838export type TryDecryptResult =839 | { status: "ok"; payload: DecryptedPayload }840 | { status: "wrong_key" }841 | { status: "corrupted"; reason: string };842843844845846847848export async function tryDecryptSlot(849 vault: StoredVault,850 key: Uint8Array,851 aadTag?: string,852): Promise<DecryptedPayload | null> {853 const res = await tryDecryptSlotResult(vault, key, aadTag);854 return res.status === "ok" ? res.payload : null;855}856857export async function tryDecryptSlotResult(858 vault: StoredVault,859 key: Uint8Array,860 aadTag?: string,861): Promise<TryDecryptResult> {862 if (!hasValidVaultSchema(vault)) {863 return { status: "corrupted", reason: "invalid_slot_schema" };864 }865 let plain: string;866 try {867 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);868 } catch {869 return { status: "wrong_key" };870 }871 try {872 const parsed = JSON.parse(plain) as unknown;873 if (typeof parsed !== "object" || parsed === null) {874 return { status: "corrupted", reason: "invalid_payload_shape" };875 }876 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {877 return { status: "corrupted", reason: "invalid_payload_shape" };878 }879 return { status: "ok", payload: decodePayload(plain) };880 } catch {881 return { status: "corrupted", reason: "invalid_payload_json" };882 }883}884885886887888889function buildDomainIndexSync(all: VaultEntry[]): void {890 domainIndex = new Map();891892 for (const entry of all) {893 if (entry.deletedAt) continue;894 if (entry.type === "note" || entry.type === "card") continue;895896 897 898 899 900 const domains = new Set<string>();901 const primary = extractDomain(entry.url ?? "");902 if (primary) domains.add(primary);903 for (const extra of entry.additionalUrls ?? []) {904 const d = extractDomain(extra);905 if (d) domains.add(d);906 }907 if (domains.size === 0) continue;908909 for (const domain of domains) {910 const bucket = domainIndex.get(domain) ?? [];911 bucket.push(entry);912 domainIndex.set(domain, bucket);913 }914 }915}916917export async function createVault(password: string): Promise<void> {918 await ensureMigrated();919920 const salt = generateSalt();921 derivedKey = await deriveKey(password, salt);922 entries = [];923 vaultMode = "real";924 duressConfigured = false;925 buildDomainIndexSync(entries);926927 928 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =929 await generateAndWrapRootKey();930 rootKey = unhex(rawRootKey);931932 const plaintext = encodePayload(entries, duressConfigured);933 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");934935 936 937 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);938939 const slotA: StoredVault = {940 version: 1,941 salt: hex(salt),942 nonce,943 ciphertext,944 tag,945 recovery_salt: recoverySalt,946 recovery_wrapped_root_key: wrappedRootKey,947 recovery_nonce: wrappedNonce,948 recovery_tag: wrappedTag,949 ...pwRoot,950 };951 await writeSlot(SLOT_A, slotA);952953 954 955 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));956 await stampLocalVaultMutation();957958 touchAutoLock();959}960961export async function hasVault(): Promise<boolean> {962 await ensureMigrated();963 return (await loadSlot(SLOT_A)) !== null;964}965966967968969970971export async function isDuressEnabled(): Promise<boolean> {972 return vaultMode === "real" && duressConfigured;973}974975export async function setupDuress(duressPassword: string): Promise<void> {976 if (!derivedKey || vaultMode !== "real") {977 throw new Error("vault must be unlocked in real mode");978 }979 await ensureMigrated();980981 982 983 984 const slotA = await loadSlot(SLOT_A);985 if (!slotA) throw new Error("missing slot A");986987 const decoySalt = generateSalt();988 const decoyKey = await deriveKey(duressPassword, decoySalt);989 try {990 991 992 const targetBytes = slotA.ciphertext.length / 2;993 let plaintext = encodePayload([], false);994 const encoded = new TextEncoder().encode(plaintext);995 if (encoded.length < targetBytes) {996 plaintext = plaintext + " ".repeat(targetBytes - encoded.length);997 }998 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");999 await writeSlot(SLOT_B, {1000 version: 1,1001 salt: hex(decoySalt),1002 nonce,1003 ciphertext,1004 tag,1005 });1006 } finally {1007 zeroKey(decoyKey);1008 }10091010 1011 1012 duressConfigured = true;1013 await persistSlotA();1014}10151016export async function disableDuress(): Promise<void> {1017 if (!derivedKey || vaultMode !== "real") {1018 throw new Error("vault must be unlocked in real mode");1019 }1020 await ensureMigrated();10211022 1023 1024 1025 const slotA = await loadSlot(SLOT_A);1026 if (slotA) {1027 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1028 }10291030 duressConfigured = false;1031 await persistSlotA();1032}10331034103510361037103810391040export async function verifyMainPassword(password: string): Promise<boolean> {1041 await ensureMigrated();1042 const slotA = await loadSlot(SLOT_A);1043 if (!slotA) return false;1044 const key = await deriveKey(password, unhex(slotA.salt));1045 try {1046 return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1047 } finally {1048 zeroKey(key);1049 }1050}10511052export async function unlock(password: string): Promise<boolean> {1053 await ensureMigrated();10541055 1056 1057 1058 1059 1060 const [slotA, slotB] = await Promise.all([1061 loadSlot(SLOT_A),1062 loadSlot(SLOT_B),1063 ]);10641065 1066 const [keyA, keyB] = await Promise.all([1067 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),1068 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),1069 ]);10701071 1072 1073 1074 const [resultA, resultB] = await Promise.all([1075 keyA && slotA1076 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1077 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1078 keyB && slotB1079 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1080 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1081 ]);1082 const payloadA = resultA.status === "ok" ? resultA.payload : null;1083 const payloadB = resultB.status === "ok" ? resultB.payload : null;10841085 if (payloadA && keyA) {1086 derivedKey = keyA;1087 void saveSessionKey(keyA, "real");1088 vaultMode = "real";1089 entries = payloadA.entries;1090 duressConfigured = payloadA.duressConfigured;1091 buildDomainIndexSync(entries);1092 await loadSettings();1093 touchAutoLock();1094 if (keyB) zeroKey(keyB);10951096 1097 if (slotA) {1098 const needsRecoverySetup =1099 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;1100 if (!needsRecoverySetup) {1101 rootKey = await deriveRootKeyFromDerivedKey(keyA, slotA);1102 }1103 1104 1105 1106 1107 if (needsRecoverySetup || !rootKey) {1108 try {1109 await migrateToRecovery();1110 if (needsRecoverySetup) {1111 1112 1113 1114 await chrome.storage.local.set({ peach_recovery_pending: '1' });1115 }1116 } catch (migrationErr) {1117 console.warn('[Peach] recovery migration failed:', migrationErr);1118 }1119 }1120 }11211122 1123 1124 1125 if (payloadA.migratedFromLegacy) {1126 await persistSlotA();1127 }1128 return true;1129 }11301131 if (payloadB && keyB) {1132 derivedKey = keyB;1133 void saveSessionKey(keyB, "decoy");1134 vaultMode = "decoy";1135 entries = payloadB.entries;1136 duressConfigured = false;1137 buildDomainIndexSync(entries);1138 await loadSettings();1139 touchAutoLock();1140 if (keyA) zeroKey(keyA);1141 return true;1142 }11431144 if (keyA) zeroKey(keyA);1145 if (keyB) zeroKey(keyB);1146 1147 1148 if (resultA.status === "corrupted") {1149 lastUnlockFailureReason = resultA.reason;1150 } else if (resultB.status === "corrupted") {1151 lastUnlockFailureReason = resultB.reason;1152 }1153 return false;1154}11551156115711581159116011611162const SESSION_KEY_STORAGE = "peach_session_dk";11631164async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1165 if (!chrome.storage.session) return;1166 await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1167}11681169async function clearSessionKey(): Promise<void> {1170 if (!chrome.storage.session) return;1171 await chrome.storage.session.remove(SESSION_KEY_STORAGE);1172}11731174117511761177117811791180export async function restoreFromSession(): Promise<boolean> {1181 if (derivedKey !== null) return true;1182 if (!chrome.storage.session) return false;1183 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1184 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1185 if (!entry?.keyHex) return false;11861187 const key = unhex(entry.keyHex);1188 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1189 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1190 const slot = await loadSlot(slotKey);1191 if (!slot) { await clearSessionKey(); return false; }11921193 const result = await tryDecryptSlotResult(slot, key, aadTag);1194 if (result.status !== "ok") { await clearSessionKey(); return false; }11951196 derivedKey = key;1197 vaultMode = entry.mode;1198 entries = result.payload.entries;1199 duressConfigured = entry.mode === "real" ? result.payload.duressConfigured : false;1200 buildDomainIndexSync(entries);1201 await loadSettings();1202 touchAutoLock();12031204 if (entry.mode === "real" && slot.recovery_salt && slot.recovery_wrapped_root_key) {1205 try { rootKey = await deriveRootKeyFromDerivedKey(key, slot); } catch { }1206 }12071208 return true;1209}12101211export function lock(): void {1212 void clearSessionKey();1213 if (derivedKey) {1214 zeroKey(derivedKey);1215 derivedKey = null;1216 }1217 if (rootKey) {1218 rootKey.fill(0);1219 rootKey = null;1220 }12211222 entries = [];1223 domainIndex = new Map();1224 vaultMode = "real";1225 duressConfigured = false;1226 clearAutoLock();1227}12281229export function isUnlocked(): boolean {1230 return derivedKey !== null;1231}1232123312341235export async function unlockWithKey(1236 masterKeyHex: string,1237): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1238 try {1239 const masterKeyBytes = unhex(masterKeyHex);1240 const stored = await loadSlot(SLOT_A);1241 if (!stored) return { ok: false, error: "invalid_key" };1242 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1243 if (!payload) return { ok: false, error: "invalid_key" };12441245 derivedKey = masterKeyBytes;1246 vaultMode = "real";1247 entries = payload.entries;1248 duressConfigured = payload.duressConfigured;1249 buildDomainIndexSync(entries);1250 await loadSettings();1251 touchAutoLock();1252 return { ok: true, entries };1253 } catch {1254 return { ok: false, error: "invalid_key" };1255 }1256}12571258125912601261126212631264126512661267126812691270127112721273127412751276export async function unlockFromCodex(params: {1277 slotA: string; 1278 slotB: string | null; 1279 timestamp: number;1280 masterPassphrase: string;1281}): Promise<1282 | { ok: true; entryCount: number }1283 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1284> {1285 let parsedSlotA: StoredVault;1286 let parsedSlotB: StoredVault | null = null;1287 try {1288 const rawA = JSON.parse(params.slotA) as unknown;1289 if (!hasValidVaultSchema(rawA)) {1290 return { ok: false, reason: "invalid_slot_format" };1291 }1292 parsedSlotA = rawA;1293 if (params.slotB !== null) {1294 const rawB = JSON.parse(params.slotB) as unknown;1295 if (!hasValidVaultSchema(rawB)) {1296 return { ok: false, reason: "invalid_slot_format" };1297 }1298 parsedSlotB = rawB;1299 }1300 } catch {1301 return { ok: false, reason: "invalid_slot_format" };1302 }13031304 1305 1306 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1307 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1308 if (!trialPayload) {1309 zeroKey(trialKey);1310 return { ok: false, reason: "wrong_passphrase" };1311 }1312 1313 1314 zeroKey(trialKey);13151316 1317 1318 await takeVaultSnapshot("restore");13191320 await writeSlot(SLOT_A, parsedSlotA);1321 if (parsedSlotB) {1322 await writeSlot(SLOT_B, parsedSlotB);1323 } else {1324 1325 1326 1327 await writeSlot(1328 SLOT_B,1329 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1330 );1331 }1332 await chrome.storage.local.set({1333 peach_last_modified_at: String(params.timestamp),1334 });13351336 1337 1338 const ok = await unlock(params.masterPassphrase);1339 if (!ok) {1340 1341 1342 1343 return { ok: false, reason: "wrong_passphrase" };1344 }1345 return { ok: true, entryCount: getEntries().length };1346}1347134813491350export function getDerivedKeyHex(): string {1351 if (!derivedKey) throw new Error("vault_not_unlocked");1352 return hex(derivedKey);1353}13541355135613571358135913601361136213631364136513661367136813691370export function getRootKeyHex(): string | null {1371 return rootKey ? hex(rootKey) : null;1372}13731374export function getEntries(): VaultEntry[] {1375 if (!derivedKey) throw new Error("vault_locked");1376 return entries.filter((entry) => !entry.deletedAt);1377}13781379const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;13801381export function getIdentities(): SavedIdentity[] {1382 if (!derivedKey) throw new Error("vault_locked");1383 const active = entries.filter((e) => !e.deletedAt && e.type === "login");13841385 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1386 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();13871388 for (const entry of active) {1389 const domain = extractDomain(entry.url ?? "") ?? "";1390 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;13911392 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1393 const existing = map.get(v);1394 if (!existing || modifiedAt > existing.lastUsed) {1395 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1396 } else {1397 map.set(v, { ...existing, count: existing.count + 1 });1398 }1399 }14001401 const uVal = entry.username?.trim();1402 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);14031404 1405 1406 const eVal = entry.email?.trim();1407 if (eVal) tally(emailMap, eVal);1408 }14091410 const toIdentity = (type: "email" | "username") =>1411 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1412 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1413 });14141415 return [1416 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1417 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1418 ];1419}14201421export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1422 if (!derivedKey) throw new Error("vault_locked");14231424 const now = Date.now();1425 const added: VaultEntry[] = [];1426 for (const draft of drafts) {1427 try {1428 const sanitized = sanitizeEntryDraft(draft);1429 const nextEntry: VaultEntry = {1430 ...sanitized,1431 id: crypto.randomUUID(),1432 createdAt: now,1433 modifiedAt: monotonicEntryTimestamp(),1434 };1435 entries.push(nextEntry);1436 added.push(nextEntry);1437 } catch {1438 1439 }1440 }14411442 if (added.length > 0) {1443 await persistEntries();1444 }14451446 return added;1447}144814491450145114521453145414551456export async function restoreEntries(1457 incoming: VaultEntry[],1458 mode: "replace" | "merge"1459): Promise<{ added: number; updated: number; skipped: number }> {1460 if (!derivedKey) throw new Error("vault_locked");14611462 1463 1464 1465 if (mode === "replace" && incoming.length === 0) {1466 throw new Error("refuse to replace with empty entry set");1467 }14681469 let added = 0;1470 let updated = 0;1471 let skipped = 0;14721473 if (mode === "replace") {1474 entries = [];1475 for (const entry of incoming) {1476 const normalized = sanitizeRestoredEntry(entry);1477 if (!normalized) {1478 skipped += 1;1479 continue;1480 }1481 entries.push(normalized);1482 added += 1;1483 }1484 } else {1485 const byId = new Map<string, VaultEntry>();1486 for (const e of entries) byId.set(e.id, e);14871488 for (const entry of incoming) {1489 const normalized = sanitizeRestoredEntry(entry);1490 if (!normalized) {1491 skipped += 1;1492 continue;1493 }1494 const existing = byId.get(normalized.id);1495 if (!existing) {1496 entries.push(normalized);1497 added += 1;1498 } else if (normalized.modifiedAt > existing.modifiedAt) {1499 const idx = entries.findIndex((e) => e.id === existing.id);1500 if (idx !== -1) {1501 entries[idx] = normalized;1502 updated += 1;1503 }1504 } else {1505 skipped += 1;1506 }1507 }1508 }15091510 await persistEntries("import");1511 return { added, updated, skipped };1512}1513151415151516151715181519function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1520 if (!entry || typeof entry !== "object") return null;1521 const title = typeof entry.title === "string" ? entry.title.trim() : "";1522 if (!title) return null;1523 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1524 const now = Date.now();1525 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1526 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;15271528 1529 1530 1531 const migrated: VaultEntry = migrateLegacyPasskey(1532 {1533 ...entry,1534 id,1535 title,1536 type: entry.type ?? "login",1537 createdAt,1538 modifiedAt,1539 },1540 entry1541 );15421543 1544 1545 if (1546 migrated.type === "login" &&1547 !migrated.passkey &&1548 typeof migrated.password !== "string"1549 ) {1550 return null;1551 }1552 if (migrated.passkey && !migrated.passkey.credentialId) {1553 return null;1554 }15551556 return migrated;1557}155815591560156115621563export async function clearVaultEntries(1564 confirmation: "REPLACE_ALL"1565): Promise<void> {1566 if (confirmation !== "REPLACE_ALL") {1567 throw new Error("clearVaultEntries requires explicit confirmation");1568 }1569 if (!derivedKey) throw new Error("vault_locked");1570 for (const entry of entries) {1571 if (!entry.deletedAt) {1572 const stamp = monotonicEntryTimestamp(entry.modifiedAt);1573 entry.deletedAt = stamp;1574 entry.modifiedAt = stamp;1575 }1576 }1577 await persistEntries();1578}15791580export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1581 if (!derivedKey) throw new Error("vault_locked");15821583 const sanitized = sanitizeEntryDraft(entry);1584 const now = Date.now();1585 const stamp = monotonicEntryTimestamp();1586 const nextEntry: VaultEntry = {1587 ...sanitized,1588 id: crypto.randomUUID(),1589 createdAt: now,1590 modifiedAt: stamp,1591 1592 1593 passwordChangedAt: sanitized.password ? stamp : undefined,1594 };15951596 1597 1598 1599 const rollback = entries;1600 entries = [...entries, nextEntry];1601 try {1602 await persistEntries();1603 } catch (err) {1604 entries = rollback;1605 throw err;1606 }16071608 1609 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1610 checkBreach(nextEntry.password).then(async (count) => {1611 if (count > 0) {1612 const idx = entries.findIndex((e) => e.id === nextEntry.id);1613 if (idx !== -1) {1614 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };1615 await persistEntries();1616 }1617 }1618 });1619 }16201621 fireSyncTrigger("vault_change");16221623 return nextEntry;1624}16251626export async function updateEntry(1627 id: string,1628 patch: Partial<VaultEntry>1629): Promise<void> {1630 if (!derivedKey) throw new Error("vault_locked");16311632 const idx = entries.findIndex((entry) => entry.id === id);1633 if (idx === -1 || entries[idx].deletedAt) {1634 throw new Error("entry_not_found");1635 }16361637 const current = entries[idx];1638 const sanitizedPatch = sanitizeEntryPatch(patch);1639 1640 1641 1642 const passwordChanged =1643 "password" in patch &&1644 typeof sanitizedPatch.password === "string" &&1645 sanitizedPatch.password !== current.password;1646 const stamp = monotonicEntryTimestamp(current.modifiedAt);1647 entries[idx] = {1648 ...current,1649 ...sanitizedPatch,1650 modifiedAt: stamp,1651 ...(passwordChanged ? { passwordChangedAt: stamp } : {}),1652 };1653165416551656 1657 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1658 entries[idx] = current;1659 throw new Error("card number is required for card entries");1660 }16611662 1663 1664 1665 1666 try {1667 await persistEntries();1668 } catch (err) {1669 entries[idx] = current;1670 throw err;1671 }16721673 1674 1675 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1676 entries[idx] = { ...entries[idx], breach: undefined };1677 await persistEntries();1678 const password = entries[idx].password;1679 if (password) {1680 checkBreach(password).then(async (count) => {1681 const i = entries.findIndex((e) => e.id === id);1682 if (i !== -1 && count > 0) {1683 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };1684 await persistEntries();1685 }1686 });1687 }1688 }16891690 fireSyncTrigger("vault_change");1691}16921693export async function deleteEntry(id: string): Promise<void> {1694 if (!derivedKey) throw new Error("vault_locked");16951696 const entry = entries.find((item) => item.id === id);1697 if (!entry || entry.deletedAt) {1698 throw new Error("entry_not_found");1699 }17001701 1702 1703 1704 const previousModifiedAt = entry.modifiedAt;1705 const stamp = monotonicEntryTimestamp(entry.modifiedAt);1706 entry.deletedAt = stamp;1707 entry.modifiedAt = stamp;1708 1709 1710 try {1711 await persistEntries();1712 } catch (err) {1713 delete entry.deletedAt;1714 entry.modifiedAt = previousModifiedAt;1715 throw err;1716 }1717 fireSyncTrigger("vault_change");1718}17191720export function getDeletedEntries(): VaultEntry[] {1721 if (!derivedKey) return [];1722 return entries.filter((e) => !!e.deletedAt);1723}1724172517261727172817291730export async function getVaultVersion(): Promise<number> {1731 try {1732 const raw = await chrome.storage.local.get("peach_last_modified_at");1733 const v = (raw as Record<string, unknown>).peach_last_modified_at;1734 if (typeof v === "number") return v;1735 if (typeof v === "string" && v !== "") {1736 const n = Number.parseInt(v, 10);1737 return Number.isFinite(n) ? n : 0;1738 }1739 return 0;1740 } catch {1741 return 0;1742 }1743}174417451746174717481749175017511752175317541755export async function addEntryDirect(entry: VaultEntry): Promise<void> {1756 if (!derivedKey) throw new Error("vault_locked");1757 entries = [...entries, entry];1758 await persistEntries("sync_received");1759}17601761export async function updateEntryDirect(patch: VaultEntry): Promise<void> {1762 if (!derivedKey) throw new Error("vault_locked");1763 const idx = entries.findIndex((e) => e.id === patch.id);1764 if (idx === -1) throw new Error("entry_not_found");1765 entries[idx] = { ...entries[idx], ...patch };1766 await persistEntries("sync_received");1767}17681769export async function deleteEntryDirect(id: string, deletedAt: number): Promise<void> {1770 if (!derivedKey) throw new Error("vault_locked");1771 const idx = entries.findIndex((e) => e.id === id);1772 if (idx === -1) throw new Error("entry_not_found");1773 entries[idx] = { ...entries[idx], deletedAt, modifiedAt: deletedAt };1774 await persistEntries("sync_received");1775}1776177717781779178017811782178317841785178617871788export async function applyMergedRemoteEntries(1789 remoteEntries: VaultEntry[],1790 remoteTombstones: Record<string, number>,1791): Promise<{ received: number; applied: number; conflicts: number }> {1792 1793 1794 1795 1796 1797 1798 if (!derivedKey) throw new Error("vault_locked");17991800 const received = remoteEntries.length + Object.keys(remoteTombstones).length;1801 let applied = 0;1802 let conflicts = 0;18031804 const byId = new Map<string, VaultEntry>();1805 for (const e of entries) byId.set(e.id, e);18061807 for (const remote of remoteEntries) {1808 const sanitized = sanitizeRestoredEntry(remote);1809 if (!sanitized) continue;1810 const tombstoneTs = remoteTombstones[sanitized.id];1811 if (tombstoneTs !== undefined && tombstoneTs >= sanitized.modifiedAt) {1812 continue;1813 }1814 const local = byId.get(sanitized.id);1815 if (!local) {1816 entries.push(sanitized);1817 byId.set(sanitized.id, sanitized);1818 applied += 1;1819 } else if (sanitized.modifiedAt > local.modifiedAt) {1820 const idx = entries.findIndex((e) => e.id === local.id);1821 if (idx !== -1) {1822 entries[idx] = sanitized;1823 byId.set(sanitized.id, sanitized);1824 applied += 1;1825 if (!local.deletedAt) conflicts += 1;1826 }1827 }1828 }18291830 for (const [id, deletedAt] of Object.entries(remoteTombstones)) {1831 if (typeof deletedAt !== "number" || !Number.isFinite(deletedAt)) continue;1832 const local = byId.get(id);1833 if (!local) continue;1834 if (local.deletedAt && local.deletedAt >= deletedAt) continue;1835 if (deletedAt < local.modifiedAt) continue;1836 const idx = entries.findIndex((e) => e.id === id);1837 if (idx !== -1) {1838 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };1839 applied += 1;1840 }1841 }18421843 if (applied > 0) {1844 await persistEntries("sync_received");1845 buildDomainIndexSync(entries);1846 }1847 return { received, applied, conflicts };1848}18491850export async function restoreEntry(id: string): Promise<void> {1851 if (!derivedKey) throw new Error("vault_locked");1852 const entry = entries.find((e) => e.id === id && e.deletedAt);1853 if (!entry) throw new Error("deleted entry not found");1854 delete entry.deletedAt;1855 entry.modifiedAt = monotonicEntryTimestamp(entry.modifiedAt);1856 await persistEntries();1857 buildDomainIndexSync(entries);1858}18591860export async function purgeEntry(id: string): Promise<void> {1861 if (!derivedKey) throw new Error("vault_locked");1862 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1863 if (idx === -1) throw new Error("deleted entry not found");1864 entries.splice(idx, 1);1865 await persistEntries();1866}186718681869export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1870 if (!derivedKey || retentionMs === 0) return 0;1871 const cutoff = Date.now() - retentionMs;1872 const before = entries.length;1873 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1874 const purged = before - entries.length;1875 if (purged > 0) await persistEntries();1876 return purged;1877}18781879export function queryDomain(domain: string): VaultEntry[] {1880 return domainIndex.get(domain) ?? [];1881}18821883export function getEntryById(id: string): VaultEntry | undefined {1884 return entries.find((entry) => entry.id === id && !entry.deletedAt);1885}18861887async function persistSlotA(1888 reason: VaultSnapshot["reason"] = "local_save",1889): Promise<StoredVault> {1890 if (!derivedKey) throw new Error("vault_locked");18911892 const slotA = await loadSlot(SLOT_A);1893 if (!slotA) throw new Error("missing slot A");18941895 1896 1897 await takeVaultSnapshot(reason);18981899 const plaintext = encodePayload(entries, duressConfigured);1900 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");19011902 1903 const nextSlotA: StoredVault = {1904 ...slotA,1905 nonce,1906 ciphertext,1907 tag,1908 1909 };1910 await writeSlot(SLOT_A, nextSlotA);19111912 1913 1914 1915 if (!duressConfigured) {1916 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1917 }1918 1919 1920 1921 1922 1923 if (reason !== "sync_received") {1924 await stampLocalVaultMutation();1925 }19261927 return nextSlotA;1928}19291930async function persistSlotB(): Promise<void> {1931 if (!derivedKey) throw new Error("vault_locked");19321933 const slotB = await loadSlot(SLOT_B);1934 if (!slotB) throw new Error("missing slot B");19351936 1937 1938 const plaintext = encodePayload(entries, false);1939 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");19401941 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1942 await stampLocalVaultMutation();1943}19441945async function persistEntries(1946 reason: VaultSnapshot["reason"] = "local_save",1947): Promise<void> {1948 if (!derivedKey) throw new Error("vault_locked");19491950 if (vaultMode === "decoy") {1951 await persistSlotB();1952 } else {1953 await persistSlotA(reason);1954 }1955 buildDomainIndexSync(entries);1956}19571958function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1959 if (!Array.isArray(values)) return undefined;1960 const seen = new Set<string>();1961 const cleaned: string[] = [];1962 for (const value of values) {1963 const trimmed = typeof value === "string" ? value.trim() : "";1964 if (!trimmed) continue;1965 const key = trimmed.toLowerCase();1966 if (seen.has(key)) continue;1967 seen.add(key);1968 cleaned.push(trimmed);1969 }1970 return cleaned.length > 0 ? cleaned : undefined;1971}19721973function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1974 const title = entry.title.trim();1975 1976 1977 1978 let type: VaultEntry["type"] = entry.type ?? "login";1979 if (type === "passkey") type = "login";19801981 if (!title) throw new Error("title is required");19821983 if (entry.passkey) {1984 const p = entry.passkey;1985 if (1986 !p.rpId ||1987 !p.credentialId ||1988 !p.publicKeyCose ||1989 !p.userHandle ||1990 !p.userName ||1991 (p.algorithm !== -7 && p.algorithm !== -8)1992 ) {1993 throw new Error("invalid passkey credential");1994 }1995 }1996 if (type === "card") {1997 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1998 throw new Error("cardholder name and card number are required");1999 }2000 }20012002 return {2003 type,2004 title,2005 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2006 email: type === "login" ? entry.email?.trim() || undefined : undefined,2007 password: type === "login" ? entry.password : undefined,2008 url: type === "login" ? entry.url?.trim() ?? "" : undefined,2009 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2010 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2011 notes: entry.notes?.trim() || undefined,2012 breach: type === "login" ? entry.breach : undefined,2013 canary: type === "login" ? entry.canary : undefined,2014 tags: entry.tags,2015 passkey: entry.passkey,2016 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2017 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2018 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2019 expYear: type === "card" ? entry.expYear?.trim() : undefined,2020 cvc: type === "card" ? entry.cvc?.trim() : undefined,2021 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2022 network: type === "card" ? entry.network : undefined,2023 last4: type === "card" ? entry.last4?.trim() : undefined,2024 };2025}20262027function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2028 const nextPatch: Partial<VaultEntry> = {};20292030 if ("type" in patch) {2031 nextPatch.type = patch.type;2032 }20332034 if ("title" in patch) {2035 const title = patch.title?.trim() ?? "";2036 if (!title) throw new Error("title is required");2037 nextPatch.title = title;2038 }20392040 if ("url" in patch) {2041 nextPatch.url = patch.url?.trim() ?? "";2042 }20432044 if ("additionalUrls" in patch) {2045 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2046 const seen = new Set<string>();2047 const cleaned: string[] = [];2048 for (const value of incoming) {2049 const trimmed = typeof value === "string" ? value.trim() : "";2050 if (!trimmed) continue;2051 const key = trimmed.toLowerCase();2052 if (seen.has(key)) continue;2053 seen.add(key);2054 cleaned.push(trimmed);2055 }2056 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2057 }20582059 if ("username" in patch) {2060 nextPatch.username = patch.username?.trim() ?? "";2061 }20622063 if ("email" in patch) {2064 nextPatch.email = patch.email?.trim() || undefined;2065 }20662067 if ("password" in patch) {2068 const password = patch.password ?? "";2069 2070 nextPatch.password = password || undefined;2071 }20722073 if ("totp" in patch) {2074 nextPatch.totp = patch.totp?.trim() || undefined;2075 }20762077 if ("notes" in patch) {2078 nextPatch.notes = patch.notes?.trim() || undefined;2079 }20802081 if ("breach" in patch) {2082 nextPatch.breach = patch.breach;2083 }20842085 if ("canary" in patch) {2086 nextPatch.canary = patch.canary;2087 }20882089 if ("passkey" in patch) {2090 2091 2092 2093 nextPatch.passkey = patch.passkey;2094 }20952096 if ("tags" in patch) {2097 nextPatch.tags = patch.tags;2098 }20992100 if ("cardholderName" in patch) {2101 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2102 }2103 if ("cardNumber" in patch) {2104 const n = patch.cardNumber ?? "";2105 nextPatch.cardNumber = n || undefined;2106 }2107 if ("expMonth" in patch) {2108 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2109 }2110 if ("expYear" in patch) {2111 nextPatch.expYear = patch.expYear?.trim() || undefined;2112 }2113 if ("cvc" in patch) {2114 nextPatch.cvc = patch.cvc?.trim() || undefined;2115 }2116 if ("cardNickname" in patch) {2117 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2118 }2119 if ("network" in patch) {2120 nextPatch.network = patch.network;2121 }2122 if ("last4" in patch) {2123 nextPatch.last4 = patch.last4?.trim() || undefined;2124 }21252126 return nextPatch;2127}21282129function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {2130 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;2131 const modifiedAt =2132 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;21332134 const base: VaultEntry = {2135 id: entry.id,2136 type: "type" in entry ? entry.type : "login",2137 title: entry.title,2138 username: entry.username,2139 email: "email" in entry ? entry.email : undefined,2140 password: entry.password,2141 url: entry.url,2142 additionalUrls:2143 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2144 ? entry.additionalUrls2145 : undefined,2146 totp: entry.totp,2147 notes: entry.notes,2148 breach: entry.breach,2149 2150 2151 2152 passwordChangedAt:2153 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2154 canary: "canary" in entry ? entry.canary : undefined,2155 passkey: "passkey" in entry ? entry.passkey : undefined,2156 createdAt,2157 modifiedAt,2158 deletedAt: entry.deletedAt,2159 tags: "tags" in entry ? entry.tags : undefined,2160 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2161 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2162 expMonth: "expMonth" in entry ? entry.expMonth : undefined,2163 expYear: "expYear" in entry ? entry.expYear : undefined,2164 cvc: "cvc" in entry ? entry.cvc : undefined,2165 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2166 network: "network" in entry ? entry.network : undefined,2167 last4: "last4" in entry ? entry.last4 : undefined,2168 };21692170 return migrateLegacyPasskey(base, entry);2171}217221732174217521762177217821792180function migrateLegacyPasskey(2181 base: VaultEntry,2182 raw: VaultEntry | LegacyVaultEntry2183): VaultEntry {2184 if (base.passkey) return base;2185 const flat = raw as Partial<VaultEntry>;2186 const isPasskeyType = base.type === "passkey";2187 const hasFlatPasskeyFields =2188 typeof flat.rpId === "string" &&2189 typeof flat.credentialId === "string" &&2190 typeof flat.publicKeyCose === "string" &&2191 typeof flat.userHandle === "string" &&2192 typeof flat.userName === "string";2193 if (!isPasskeyType && !hasFlatPasskeyFields) return base;2194 if (!hasFlatPasskeyFields) return base;21952196 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;2197 base.passkey = {2198 rpId: flat.rpId!,2199 rpName: flat.rpName ?? flat.rpId!,2200 credentialId: flat.credentialId!,2201 publicKeyCose: flat.publicKeyCose!,2202 privateKeyJwk: flat.privateKeyJwk,2203 userHandle: flat.userHandle!,2204 userName: flat.userName!,2205 userDisplayName: flat.userDisplayName ?? flat.userName!,2206 algorithm,2207 signCount: flat.signCount ?? 0,2208 aaguid: flat.aaguid ?? "",2209 backupEligible: flat.backupEligible === true,2210 backupState: flat.backupState === true,2211 lastUsedAt: flat.lastUsedAt,2212 browser: flat.browser,2213 transports: flat.transports,2214 createdAt: base.createdAt,2215 };2216 2217 2218 2219 if (isPasskeyType) base.type = "login";2220 if (!base.username) base.username = flat.userName;2221 if (!base.url) base.url = `https://${flat.rpId}`;2222 return base;2223}2224222522262227222822292230function entryDupeKey(e: VaultEntry): string {2231 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2232 2233 2234 2235 2236 if (e.passkey) {2237 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2238 }2239 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2240}224122422243export function countDuplicates(): number {2244 const best = new Map<string, VaultEntry>();2245 let dupeCount = 0;2246 for (const e of entries.filter((e) => !e.deletedAt)) {2247 const key = entryDupeKey(e);2248 const existing = best.get(key);2249 if (!existing) {2250 best.set(key, e);2251 } else {2252 dupeCount++;2253 2254 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2255 best.set(key, e);2256 }2257 }2258 }2259 return dupeCount;2260}226122622263226422652266export async function deduplicateEntries(): Promise<number> {2267 if (!derivedKey) throw new Error("vault_locked");2268 2269 2270 if (vaultMode === "decoy") {2271 throw new Error("deduplicate disabled in decoy mode");2272 }22732274 const best = new Map<string, VaultEntry>();2275 for (const e of entries.filter((en) => !en.deletedAt)) {2276 const key = entryDupeKey(e);2277 const existing = best.get(key);2278 if (!existing) {2279 best.set(key, e);2280 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2281 best.set(key, e);2282 }2283 }22842285 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2286 let removed = 0;2287 entries = entries.map((e) => {2288 if (!e.deletedAt && !keepIds.has(e.id)) {2289 removed++;2290 return { ...e, deletedAt: monotonicEntryTimestamp(e.modifiedAt) };2291 }2292 return e;2293 });22942295 if (removed > 0) await persistEntries();2296 return removed;2297}229822992300230123022303230423052306const HISTORY_KEY = "peach_vault_history";2307const HISTORY_MAX = 10;23082309231023112312231323142315export async function takeVaultSnapshot(2316 reason: VaultSnapshot["reason"],2317): Promise<void> {2318 try {2319 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2320 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2321 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];23222323 2324 2325 2326 if (!slotA) return;23272328 const vault_a = JSON.stringify(slotA);2329 const vault_b = JSON.stringify(slotB ?? slotA);23302331 2332 2333 2334 let entryCount: number | null = null;2335 if (derivedKey && vaultMode === "real") {2336 try {2337 entryCount = entries.filter((e) => !e.deletedAt).length;2338 } catch {2339 entryCount = null;2340 }2341 }23422343 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2344 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2345 ? existingRaw2346 : { snapshots: [] };23472348 2349 2350 2351 const latest = history.snapshots[0];2352 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2353 return;2354 }23552356 const snapshot: VaultSnapshot = {2357 id: crypto.randomUUID(),2358 savedAt: Date.now(),2359 reason,2360 vault_a,2361 vault_b,2362 entryCount,2363 };23642365 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2366 await chrome.storage.local.set({ [HISTORY_KEY]: history });2367 } catch (err) {2368 2369 2370 2371 2372 console.warn('[Peach] takeVaultSnapshot failed:', err);2373 if (String(err).includes('QUOTA') || String(err).includes('quota')) {2374 try {2375 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2376 } catch {2377 2378 }2379 }2380 }2381}238223832384export async function clearVaultHistory(): Promise<void> {2385 try {2386 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2387 } catch {2388 2389 }2390}239123922393export async function getVaultHistory(): Promise<VaultSnapshot[]> {2394 const raw = await chrome.storage.local.get(HISTORY_KEY);2395 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2396 if (!history || !Array.isArray(history.snapshots)) return [];2397 return history.snapshots;2398}23992400240124022403240424052406export async function restoreVaultSnapshot(2407 snapshotId: string,2408): Promise<{ ok: true } | { ok: false; error: string }> {2409 const history = await getVaultHistory();2410 const snapshot = history.find((s) => s.id === snapshotId);2411 if (!snapshot) return { ok: false, error: "snapshot_not_found" };24122413 2414 2415 2416 await takeVaultSnapshot("restore");24172418 let restoredA: StoredVault;2419 let restoredB: StoredVault;2420 try {2421 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2422 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2423 } catch {2424 return { ok: false, error: "snapshot_corrupt" };2425 }24262427 await writeSlot(SLOT_A, restoredA);2428 await writeSlot(SLOT_B, restoredB);2429 await stampLocalVaultMutation();24302431 2432 2433 2434 lock();24352436 return { ok: true };2437}24382439244024412442244324442445export async function changePassword(2446 currentPassword: string,2447 newPassword: string2448): Promise<boolean> {2449 if (!derivedKey || vaultMode !== "real") return false;24502451 2452 const valid = await verifyMainPassword(currentPassword);2453 if (!valid) return false;24542455 const slotA = await loadSlot(SLOT_A);2456 if (!slotA) return false;2457 if (!rootKey) return false;24582459 2460 const newSalt = generateSalt();2461 const newKey = await deriveKey(newPassword, newSalt);24622463 2464 const plaintext = encodePayload(entries, duressConfigured);2465 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");24662467 2468 2469 2470 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);24712472 const finalSlotA: StoredVault = {2473 ...slotA,2474 salt: hex(newSalt),2475 nonce,2476 ciphertext,2477 tag,2478 ...pwRoot,2479 };24802481 2482 2483 2484 await takeVaultSnapshot("local_save");24852486 2487 2488 2489 2490 await writeSlot(SLOT_A, finalSlotA);24912492 2493 2494 2495 zeroKey(derivedKey);2496 derivedKey = newKey;24972498 2499 2500 2501 if (!duressConfigured) {2502 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2503 }2504 await stampLocalVaultMutation();25052506 return true;2507}