123456789101112import { deriveKey, generateSalt } from "../crypto/argon2";13import { extractDomain } from "../shared/domain";14import {15 rootKeyToWords,16 wordsToRootKeyMaterial,17 deriveRecoveryKey,18} from "../shared/recovery-words";19import type {20 AccentColorName,21 LegacyVaultEntry,22 ManualSummonRecord,23 SavedIdentity,24 StoredVault,25 VaultEntry,26 VaultHistory,27 VaultSnapshot,28} from "../shared/types";29import { decrypt, encrypt, zeroKey } from "./aes";30import { clearAutoLock, touchAutoLock } from "./alarms";3132type EntryDraft = Omit<33 VaultEntry,34 "id" | "createdAt" | "modifiedAt" | "deletedAt"35>;363738394041const SLOT_A = "peach_vault_a";42const SLOT_B = "peach_vault_b";434445const LEGACY_MAIN = "peach_vault";46const LEGACY_DECOY = "peach_decoy_vault";47const LEGACY_DURESS_FLAG = "peach_duress";48495051const PAD_BLOCK = 1024;5253let derivedKey: Uint8Array | null = null;5455565758let rootKey: Uint8Array | null = null;59let entries: VaultEntry[] = [];60let domainIndex: Map<string, VaultEntry[]> = new Map();61let vaultMode: "real" | "decoy" = "real";62636465let duressConfigured = false;6667let migrationRan = false;686970717273export type DisplayFont = "cormorant-italic" | "cormorant" | "inter" | "system";74export type TextSize = "small" | "medium" | "large";7576export interface VaultSettings {77 accentColor?: AccentColorName;78 theme?: "dark" | "light";79 autoLockMinutes?: number;80 clipboardClearSeconds?: number;81 lockOnClose?: boolean;82 breachMonitoring?: boolean;83 passkeyProviderEnabled?: boolean;84 trashRetentionDays?: number;85 manualSummonDomains?: Record<string, ManualSummonRecord>;86 declinedCardFingerprints?: string[];87 8889909192 displayFont?: DisplayFont;93 9495969798 textSize?: TextSize;99 100101102103104 reduceMotion?: boolean;105 106107108109110 biometricsEnabled?: boolean;111 112113114115116117118 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;119}120121let settings: VaultSettings = {};122123export function getSettings(): VaultSettings {124 return settings;125}126127export async function ensureSettingsLoaded(): Promise<VaultSettings> {128 await loadSettings();129 return settings;130}131132export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {133 await loadSettings();134 settings = { ...settings, ...patch };135 await persistSettings();136}137138async function persistSettings(): Promise<void> {139 try {140 await chrome.storage.local.set({ peach_settings: settings });141 } catch (err) {142 throw mapStorageError(err);143 }144}145146function mapStorageError(err: unknown): Error {147 const message = err instanceof Error ? err.message : String(err);148 if (/QUOTA_BYTES|quota/i.test(message)) {149 return new Error("STORAGE_QUOTA_EXCEEDED");150 }151 return err instanceof Error ? err : new Error(message);152}153154async function loadSettings(): Promise<void> {155 const result = await chrome.storage.local.get("peach_settings");156 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};157}158159160161162163export async function checkBreach(password: string): Promise<number> {164 const encoder = new TextEncoder();165 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));166 const hashHex = Array.from(new Uint8Array(hashBuffer))167 .map((b) => b.toString(16).padStart(2, "0"))168 .join("")169 .toUpperCase();170171 const prefix = hashHex.slice(0, 5);172 const suffix = hashHex.slice(5);173174 try {175 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);176 if (!res.ok) return 0;177 const text = await res.text();178 const match = text.split("\r\n").find((l) => l.startsWith(suffix));179 return match ? parseInt(match.split(":")[1], 10) : 0;180 } catch {181 return 0; 182 }183}184185export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {186 const fingerprint = await cardFingerprint(last4, expMonth, expYear);187 await loadSettings();188 settings = {189 ...settings,190 declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],191 };192 await persistSettings();193}194195export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {196 await loadSettings();197 const fingerprint = await cardFingerprint(last4, expMonth, expYear);198 return (settings.declinedCardFingerprints ?? []).includes(fingerprint);199}200201async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {202 const encoder = new TextEncoder();203 const buf = await crypto.subtle.digest("SHA-1", encoder.encode(`${last4}${expMonth}${expYear}`));204 return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, "0")).join("");205}206207function hex(buf: Uint8Array): string {208 return Array.from(buf)209 .map((b) => b.toString(16).padStart(2, "0"))210 .join("");211}212213function unhex(s: string): Uint8Array {214 const out = new Uint8Array(s.length / 2);215 for (let i = 0; i < s.length; i += 2) {216 out[i / 2] = parseInt(s.slice(i, i + 2), 16);217 }218 return out;219}220221222223224225226227228229230231232async function generateAndWrapRootKey(): Promise<{233 rawRootKey: string; 234 recoverySalt: string;235 wrappedRootKey: string;236 wrappedNonce: string;237 wrappedTag: string;238}> {239 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));240241 242 const words = await rootKeyToWords(rootKeyBytes);243 const wordMaterial = await wordsToRootKeyMaterial(words);244 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));245 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);246247 248 249 const nonce = crypto.getRandomValues(new Uint8Array(12));250 const encoded = new TextEncoder().encode(hex(rootKeyBytes));251 const raw = await crypto.subtle.encrypt(252 {253 name: "AES-GCM",254 iv: nonce,255 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,256 tagLength: 128,257 },258 recoveryKey,259 encoded260 );261 262 const rawU8 = new Uint8Array(raw);263 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));264 const tag = hex(rawU8.slice(rawU8.length - 16));265266 return {267 rawRootKey: hex(rootKeyBytes),268 recoverySalt: hex(recoverySalt),269 wrappedRootKey: ciphertext,270 wrappedNonce: hex(nonce),271 wrappedTag: tag,272 };273}274275276277278279280export async function unwrapRootKeyForRecovery(281 wrappedRootKey: string,282 wrappedNonce: string,283 wrappedTag: string,284 recoverySaltHex: string,285 words: string[]286): Promise<void> {287 const wordMaterial = await wordsToRootKeyMaterial(words);288 const recoverySalt = unhex(recoverySaltHex);289 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);290291 292 293 294 295 296 const nonceRaw = unhex(wrappedNonce);297 const ciphertextRaw = unhex(wrappedRootKey);298 const tagRaw = unhex(wrappedTag);299 300 301 const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;302 const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);303 combined.set(ciphertextRaw, 0);304 combined.set(tagRaw, ciphertextRaw.length);305 const raw = await crypto.subtle.decrypt(306 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },307 recoveryKey,308 combined309 );310 const decryptedHex = new TextDecoder().decode(raw);311 rootKey = unhex(decryptedHex);312}313314315316317318319320321322323export async function rewrapRootKeyForPassword(324 newDerivedKey: Uint8Array,325 _newSalt: Uint8Array326): Promise<void> {327 if (!rootKey) throw new Error("root key not set");328 const slotA = await loadSlot(SLOT_A);329 if (!slotA) throw new Error("missing slot A");330 const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);331 await writeSlot(SLOT_A, { ...slotA, ...pwRoot });332}333334335336337338339async function reencryptVaultForNewKey(340 newKey: Uint8Array,341 slotA: StoredVault342): Promise<void> {343 const plaintext = encodePayload(entries, duressConfigured);344 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);345 await writeSlot(SLOT_A, {346 ...slotA,347 nonce,348 ciphertext,349 tag,350 });351}352353354355356357358export async function changePasswordWithNewKey(359 newSalt: Uint8Array,360 newKey: Uint8Array361): Promise<void> {362 const slotA = await loadSlot(SLOT_A);363 if (!slotA) throw new Error("missing slot A");364 365 366 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });367 if (derivedKey) zeroKey(derivedKey);368 derivedKey = newKey;369 370 371 372 373 if (rootKey) {374 const slotAfter = await loadSlot(SLOT_A);375 if (slotAfter) {376 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);377 await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });378 }379 }380 if (!duressConfigured) {381 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));382 }383 await stampLocalVaultMutation();384}385386387388389390391export { deriveKey, generateSalt };392393export async function loadSlotA(): Promise<StoredVault | null> {394 return loadSlot(SLOT_A);395}396397398399400401402403404405406407408409async function deriveRootKeyFromDerivedKey(410 derivedKeyBytes: Uint8Array,411 slotA: StoredVault,412): Promise<Uint8Array | null> {413 if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {414 return null;415 }416 try {417 const rootHex = await decrypt(418 derivedKeyBytes,419 slotA.pw_root_nonce,420 slotA.pw_root_ciphertext,421 slotA.pw_root_tag,422 );423 return unhex(rootHex);424 } catch {425 return null;426 }427}428429430431async function wrapRootKeyForDerivedKey(432 derivedKeyBytes: Uint8Array,433 rootKeyBytes: Uint8Array,434): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {435 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes));436 return {437 pw_root_ciphertext: ciphertext,438 pw_root_nonce: nonce,439 pw_root_tag: tag,440 };441}442443444445446447448449450export async function migrateToRecovery(): Promise<void> {451 if (!derivedKey || vaultMode !== "real") {452 throw new Error("vault must be unlocked in real mode");453 }454 const slotA = await loadSlot(SLOT_A);455 if (!slotA) throw new Error("missing slot A");456 const hasRecoveryEscrow =457 !!slotA.recovery_salt && !!slotA.recovery_wrapped_root_key;458 const hasPwRoot = !!slotA.pw_root_ciphertext && !!slotA.pw_root_nonce;459 if (hasRecoveryEscrow && hasPwRoot) return; 460461 462 463 464 465 466 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =467 await generateAndWrapRootKey();468 rootKey = unhex(rawRootKey);469470 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);471 const nextSlotA: StoredVault = {472 ...slotA,473 recovery_salt: recoverySalt,474 recovery_wrapped_root_key: wrappedRootKey,475 recovery_nonce: wrappedNonce,476 recovery_tag: wrappedTag,477 ...pwRoot,478 };479 await writeSlot(SLOT_A, nextSlotA);480 await stampLocalVaultMutation();481}482483484485486487488export async function getRecoveryWords(): Promise<string[]> {489 if (!rootKey) {490 throw new Error("recovery not set up — vault may need migration");491 }492 return rootKeyToWords(rootKey);493}494495async function loadSlot(key: string): Promise<StoredVault | null> {496 const result = await chrome.storage.local.get(key);497 return (result as Record<string, StoredVault | undefined>)[key] ?? null;498}499500async function writeSlot(key: string, vault: StoredVault): Promise<void> {501 try {502 await chrome.storage.local.set({ [key]: vault });503 } catch (err) {504 throw mapStorageError(err);505 }506}507508async function stampLocalVaultMutation(): Promise<void> {509 await chrome.storage.local.set({510 peach_last_modified_at: String(Date.now()),511 });512}513514515516517518519async function ensureMigrated(): Promise<void> {520 if (migrationRan) return;521 migrationRan = true;522523 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];524 const raw = await chrome.storage.local.get(keys);525 const slotAExists = raw[SLOT_A] !== undefined;526 const slotBExists = raw[SLOT_B] !== undefined;527 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;528 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;529530 531 if (!slotAExists && !legacyMain) return;532533 534 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;535536 let didMigrate = false;537 if (legacyMain && !slotAExists) {538 await writeSlot(SLOT_A, legacyMain);539 didMigrate = true;540 }541542 543 if (!slotBExists) {544 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;545 if (legacyDecoy) {546 await writeSlot(SLOT_B, legacyDecoy);547 didMigrate = true;548 } else if (target) {549 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));550 didMigrate = true;551 }552 }553554 const toRemove: string[] = [];555 if (legacyMain) toRemove.push(LEGACY_MAIN);556 if (legacyDecoy) toRemove.push(LEGACY_DECOY);557 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);558 if (toRemove.length > 0) {559 await chrome.storage.local.remove(toRemove);560 didMigrate = true;561 }562 if (didMigrate) {563 await stampLocalVaultMutation();564 }565}566567568569570571572573574575576577function padPlaintext(json: string): string {578 const encoded = new TextEncoder().encode(json);579 const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);580 const padLen = target - encoded.length;581 return json + " ".repeat(padLen);582}583584585586587588589590591592593594const CSPRNG_CHUNK = 65_536;595596function fillRandom(buf: Uint8Array): Uint8Array {597 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {598 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);599 const chunk = new Uint8Array(end - offset);600 crypto.getRandomValues(chunk);601 buf.set(chunk, offset);602 }603 return buf;604}605606function generateNoiseBlob(ciphertextBytes: number): StoredVault {607 const salt = crypto.getRandomValues(new Uint8Array(16));608 const nonce = crypto.getRandomValues(new Uint8Array(12));609 const cipher = fillRandom(new Uint8Array(ciphertextBytes));610 const tag = crypto.getRandomValues(new Uint8Array(16));611 return {612 version: 1,613 salt: hex(salt),614 nonce: hex(nonce),615 ciphertext: hex(cipher),616 tag: hex(tag),617 };618}619620621622623624type VaultPayload = {625 entries: VaultEntry[];626 duressConfigured?: boolean;627};628629type DecryptedPayload = {630 entries: VaultEntry[];631 duressConfigured: boolean;632 633634635636637638639 migratedFromLegacy: boolean;640};641642function encodePayload(entries: VaultEntry[], duress: boolean): string {643 const envelope: VaultPayload = { entries, duressConfigured: duress };644 return padPlaintext(JSON.stringify(envelope));645}646647function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {648 if ("created" in raw || "updated" in raw) return true;649 const flat = raw as Partial<VaultEntry>;650 if (flat.type === "passkey") return true;651 652 if (flat.rpId !== undefined && flat.passkey === undefined) return true;653 return false;654}655656function decodePayload(plain: string): DecryptedPayload {657 const parsed = JSON.parse(plain) as658 | Array<VaultEntry | LegacyVaultEntry>659 | VaultPayload;660661 const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];662 const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);663664 const normalized = rawEntries.map(normalizeEntry);665 const { merged } = mergePasskeyOnlyIntoLogins(normalized);666667 if (Array.isArray(parsed)) {668 return {669 entries: normalized,670 duressConfigured: false,671 migratedFromLegacy: true, 672 };673 }674675 return {676 entries: normalized,677 duressConfigured: parsed.duressConfigured === true,678 migratedFromLegacy: flatMigrationNeeded || merged,679 };680}681682683684685686687688689690691692693694695696697698699700701702703704705function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {706 let merged = false;707 const now = Date.now();708709 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);710 const byRpId = new Map<string, VaultEntry[]>();711 for (const e of candidates) {712 const rpId = e.passkey!.rpId;713 const list = byRpId.get(rpId);714 if (list) list.push(e);715 else byRpId.set(rpId, [e]);716 }717718 for (const [rpId, group] of byRpId) {719 if (group.length === 0) continue;720 group.sort((a, b) => {721 const aT =722 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;723 const bT =724 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;725 return bT - aT;726 });727 const winner = group[0]!;728729 730 731 732 const loginTarget = entries.find((e) => {733 if (e.deletedAt || e.passkey || e.type !== "login") return false;734 if (e.url && extractDomain(e.url) === rpId) return true;735 for (const extra of e.additionalUrls ?? []) {736 if (extractDomain(extra) === rpId) return true;737 }738 return false;739 });740741 if (loginTarget) {742 loginTarget.passkey = winner.passkey;743 loginTarget.modifiedAt = now;744 for (const dup of group) {745 if (!dup.deletedAt) {746 dup.deletedAt = now;747 dup.modifiedAt = now;748 }749 }750 merged = true;751 } else if (group.length > 1) {752 for (let i = 1; i < group.length; i += 1) {753 const dup = group[i]!;754 if (!dup.deletedAt) {755 dup.deletedAt = now;756 dup.modifiedAt = now;757 }758 }759 merged = true;760 }761 }762763 return { merged };764}765766767768769770771export async function tryDecryptSlot(772 vault: StoredVault,773 key: Uint8Array774): Promise<DecryptedPayload | null> {775 try {776 const plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);777 return decodePayload(plain);778 } catch {779 return null;780 }781}782783784785786787function buildDomainIndexSync(all: VaultEntry[]): void {788 domainIndex = new Map();789790 for (const entry of all) {791 if (entry.deletedAt) continue;792 if (entry.type === "note" || entry.type === "card") continue;793794 795 796 797 798 const domains = new Set<string>();799 const primary = extractDomain(entry.url ?? "");800 if (primary) domains.add(primary);801 for (const extra of entry.additionalUrls ?? []) {802 const d = extractDomain(extra);803 if (d) domains.add(d);804 }805 if (domains.size === 0) continue;806807 for (const domain of domains) {808 const bucket = domainIndex.get(domain) ?? [];809 bucket.push(entry);810 domainIndex.set(domain, bucket);811 }812 }813}814815export async function createVault(password: string): Promise<void> {816 await ensureMigrated();817818 const salt = generateSalt();819 derivedKey = await deriveKey(password, salt);820 entries = [];821 vaultMode = "real";822 duressConfigured = false;823 buildDomainIndexSync(entries);824825 826 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =827 await generateAndWrapRootKey();828 rootKey = unhex(rawRootKey);829830 const plaintext = encodePayload(entries, duressConfigured);831 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);832833 834 835 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);836837 const slotA: StoredVault = {838 version: 1,839 salt: hex(salt),840 nonce,841 ciphertext,842 tag,843 recovery_salt: recoverySalt,844 recovery_wrapped_root_key: wrappedRootKey,845 recovery_nonce: wrappedNonce,846 recovery_tag: wrappedTag,847 ...pwRoot,848 };849 await writeSlot(SLOT_A, slotA);850851 852 853 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));854 await stampLocalVaultMutation();855856 touchAutoLock();857}858859export async function hasVault(): Promise<boolean> {860 await ensureMigrated();861 return (await loadSlot(SLOT_A)) !== null;862}863864865866867868869export async function isDuressEnabled(): Promise<boolean> {870 return vaultMode === "real" && duressConfigured;871}872873export async function setupDuress(duressPassword: string): Promise<void> {874 if (!derivedKey || vaultMode !== "real") {875 throw new Error("vault must be unlocked in real mode");876 }877 await ensureMigrated();878879 880 881 882 const slotA = await loadSlot(SLOT_A);883 if (!slotA) throw new Error("missing slot A");884885 const decoySalt = generateSalt();886 const decoyKey = await deriveKey(duressPassword, decoySalt);887 try {888 889 890 const targetBytes = slotA.ciphertext.length / 2;891 let plaintext = encodePayload([], false);892 const encoded = new TextEncoder().encode(plaintext);893 if (encoded.length < targetBytes) {894 plaintext = plaintext + " ".repeat(targetBytes - encoded.length);895 }896 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);897 await writeSlot(SLOT_B, {898 version: 1,899 salt: hex(decoySalt),900 nonce,901 ciphertext,902 tag,903 });904 } finally {905 zeroKey(decoyKey);906 }907908 909 910 duressConfigured = true;911 await persistSlotA();912}913914export async function disableDuress(): Promise<void> {915 if (!derivedKey || vaultMode !== "real") {916 throw new Error("vault must be unlocked in real mode");917 }918 await ensureMigrated();919920 921 922 923 const slotA = await loadSlot(SLOT_A);924 if (slotA) {925 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));926 }927928 duressConfigured = false;929 await persistSlotA();930}931932export async function verifyMainPassword(password: string): Promise<boolean> {933 await ensureMigrated();934 const slotA = await loadSlot(SLOT_A);935 if (!slotA) return false;936 const key = await deriveKey(password, unhex(slotA.salt));937 try {938 return (await tryDecryptSlot(slotA, key)) !== null;939 } finally {940 zeroKey(key);941 }942}943944export async function unlock(password: string): Promise<boolean> {945 await ensureMigrated();946947 948 949 950 951 952 const [slotA, slotB] = await Promise.all([953 loadSlot(SLOT_A),954 loadSlot(SLOT_B),955 ]);956957 958 const [keyA, keyB] = await Promise.all([959 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),960 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),961 ]);962963 964 const [payloadA, payloadB] = await Promise.all([965 keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),966 keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),967 ]);968969 if (payloadA && keyA) {970 derivedKey = keyA;971 vaultMode = "real";972 entries = payloadA.entries;973 duressConfigured = payloadA.duressConfigured;974 buildDomainIndexSync(entries);975 await loadSettings();976 touchAutoLock();977 if (keyB) zeroKey(keyB);978979 980 if (slotA) {981 const needsRecoverySetup =982 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;983 if (!needsRecoverySetup) {984 rootKey = await deriveRootKeyFromDerivedKey(keyA, slotA);985 }986 987 988 989 990 if (needsRecoverySetup || !rootKey) {991 try {992 await migrateToRecovery();993 if (needsRecoverySetup) {994 995 996 997 await chrome.storage.local.set({ peach_recovery_pending: '1' });998 }999 } catch (migrationErr) {1000 console.warn('[Peach] recovery migration failed:', migrationErr);1001 }1002 }1003 }10041005 1006 1007 1008 if (payloadA.migratedFromLegacy) {1009 await persistSlotA();1010 }1011 return true;1012 }10131014 if (payloadB && keyB) {1015 derivedKey = keyB;1016 vaultMode = "decoy";1017 entries = payloadB.entries;1018 duressConfigured = false;1019 buildDomainIndexSync(entries);1020 await loadSettings();1021 touchAutoLock();1022 if (keyA) zeroKey(keyA);1023 return true;1024 }10251026 if (keyA) zeroKey(keyA);1027 if (keyB) zeroKey(keyB);1028 return false;1029}10301031export function lock(): void {1032 if (derivedKey) {1033 zeroKey(derivedKey);1034 derivedKey = null;1035 }1036 if (rootKey) {1037 rootKey.fill(0);1038 rootKey = null;1039 }10401041 entries = [];1042 domainIndex = new Map();1043 vaultMode = "real";1044 duressConfigured = false;1045 clearAutoLock();1046}10471048export function isUnlocked(): boolean {1049 return derivedKey !== null;1050}1051105210531054export async function unlockWithKey(1055 masterKeyHex: string,1056): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1057 try {1058 const masterKeyBytes = unhex(masterKeyHex);1059 const stored = await loadSlot(SLOT_A);1060 if (!stored) return { ok: false, error: "invalid_key" };1061 const payload = await tryDecryptSlot(stored, masterKeyBytes);1062 if (!payload) return { ok: false, error: "invalid_key" };10631064 derivedKey = masterKeyBytes;1065 vaultMode = "real";1066 entries = payload.entries;1067 duressConfigured = payload.duressConfigured;1068 buildDomainIndexSync(entries);1069 await loadSettings();1070 touchAutoLock();1071 return { ok: true, entries };1072 } catch {1073 return { ok: false, error: "invalid_key" };1074 }1075}10761077107810791080108110821083108410851086108710881089109010911092109310941095export async function unlockFromCodex(params: {1096 slotA: string; 1097 slotB: string | null; 1098 timestamp: number;1099 masterPassphrase: string;1100}): Promise<1101 | { ok: true; entryCount: number }1102 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1103> {1104 let parsedSlotA: StoredVault;1105 let parsedSlotB: StoredVault | null = null;1106 try {1107 parsedSlotA = JSON.parse(params.slotA) as StoredVault;1108 if (params.slotB !== null) {1109 parsedSlotB = JSON.parse(params.slotB) as StoredVault;1110 }1111 1112 1113 1114 if (1115 typeof parsedSlotA?.salt !== "string" ||1116 typeof parsedSlotA?.nonce !== "string" ||1117 typeof parsedSlotA?.ciphertext !== "string" ||1118 typeof parsedSlotA?.tag !== "string"1119 ) {1120 return { ok: false, reason: "invalid_slot_format" };1121 }1122 } catch {1123 return { ok: false, reason: "invalid_slot_format" };1124 }11251126 1127 1128 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1129 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey);1130 if (!trialPayload) {1131 zeroKey(trialKey);1132 return { ok: false, reason: "wrong_passphrase" };1133 }1134 1135 1136 zeroKey(trialKey);11371138 1139 1140 await takeVaultSnapshot("restore");11411142 await writeSlot(SLOT_A, parsedSlotA);1143 if (parsedSlotB) {1144 await writeSlot(SLOT_B, parsedSlotB);1145 } else {1146 1147 1148 1149 await writeSlot(1150 SLOT_B,1151 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1152 );1153 }1154 await chrome.storage.local.set({1155 peach_last_modified_at: String(params.timestamp),1156 });11571158 1159 1160 const ok = await unlock(params.masterPassphrase);1161 if (!ok) {1162 1163 1164 1165 return { ok: false, reason: "wrong_passphrase" };1166 }1167 return { ok: true, entryCount: getEntries().length };1168}1169117011711172export function getDerivedKeyHex(): string {1173 if (!derivedKey) throw new Error("vault_not_unlocked");1174 return hex(derivedKey);1175}11761177export function getEntries(): VaultEntry[] {1178 if (!derivedKey) throw new Error("vault locked");1179 return entries.filter((entry) => !entry.deletedAt);1180}11811182const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;11831184export function getIdentities(): SavedIdentity[] {1185 if (!derivedKey) throw new Error("vault locked");1186 const active = entries.filter((e) => !e.deletedAt && e.type === "login");11871188 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1189 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();11901191 for (const entry of active) {1192 const value = entry.username?.trim();1193 if (!value) continue;1194 const map = EMAIL_RE.test(value) ? emailMap : usernameMap;1195 const existing = map.get(value);1196 const domain = extractDomain(entry.url ?? "") ?? "";1197 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;1198 if (!existing || modifiedAt > existing.lastUsed) {1199 map.set(value, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1200 } else {1201 map.set(value, { ...existing, count: existing.count + 1 });1202 }1203 }12041205 const toIdentity = (type: "email" | "username") =>1206 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1207 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1208 });12091210 return [1211 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1212 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1213 ];1214}12151216export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1217 if (!derivedKey) throw new Error("vault locked");12181219 const now = Date.now();1220 const added: VaultEntry[] = [];1221 for (const draft of drafts) {1222 try {1223 const sanitized = sanitizeEntryDraft(draft);1224 const nextEntry: VaultEntry = {1225 ...sanitized,1226 id: crypto.randomUUID(),1227 createdAt: now,1228 modifiedAt: now,1229 };1230 entries.push(nextEntry);1231 added.push(nextEntry);1232 } catch {1233 1234 }1235 }12361237 if (added.length > 0) {1238 await persistEntries();1239 }12401241 return added;1242}124312441245124612471248124912501251export async function restoreEntries(1252 incoming: VaultEntry[],1253 mode: "replace" | "merge"1254): Promise<{ added: number; updated: number; skipped: number }> {1255 if (!derivedKey) throw new Error("vault locked");12561257 1258 1259 1260 if (mode === "replace" && incoming.length === 0) {1261 throw new Error("refuse to replace with empty entry set");1262 }12631264 let added = 0;1265 let updated = 0;1266 let skipped = 0;12671268 if (mode === "replace") {1269 entries = [];1270 for (const entry of incoming) {1271 const normalized = sanitizeRestoredEntry(entry);1272 if (!normalized) {1273 skipped += 1;1274 continue;1275 }1276 entries.push(normalized);1277 added += 1;1278 }1279 } else {1280 const byId = new Map<string, VaultEntry>();1281 for (const e of entries) byId.set(e.id, e);12821283 for (const entry of incoming) {1284 const normalized = sanitizeRestoredEntry(entry);1285 if (!normalized) {1286 skipped += 1;1287 continue;1288 }1289 const existing = byId.get(normalized.id);1290 if (!existing) {1291 entries.push(normalized);1292 added += 1;1293 } else if (normalized.modifiedAt > existing.modifiedAt) {1294 const idx = entries.findIndex((e) => e.id === existing.id);1295 if (idx !== -1) {1296 entries[idx] = normalized;1297 updated += 1;1298 }1299 } else {1300 skipped += 1;1301 }1302 }1303 }13041305 await persistEntries("import");1306 return { added, updated, skipped };1307}1308130913101311131213131314function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1315 if (!entry || typeof entry !== "object") return null;1316 const title = typeof entry.title === "string" ? entry.title.trim() : "";1317 if (!title) return null;1318 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1319 const now = Date.now();1320 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1321 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;13221323 1324 1325 1326 const migrated: VaultEntry = migrateLegacyPasskey(1327 {1328 ...entry,1329 id,1330 title,1331 type: entry.type ?? "login",1332 createdAt,1333 modifiedAt,1334 },1335 entry1336 );13371338 1339 1340 if (1341 migrated.type === "login" &&1342 !migrated.passkey &&1343 typeof migrated.password !== "string"1344 ) {1345 return null;1346 }1347 if (migrated.passkey && !migrated.passkey.credentialId) {1348 return null;1349 }13501351 return migrated;1352}135313541355135613571358export async function clearVaultEntries(1359 confirmation: "REPLACE_ALL"1360): Promise<void> {1361 if (confirmation !== "REPLACE_ALL") {1362 throw new Error("clearVaultEntries requires explicit confirmation");1363 }1364 if (!derivedKey) throw new Error("vault locked");1365 for (const entry of entries) {1366 if (!entry.deletedAt) {1367 entry.deletedAt = Date.now();1368 entry.modifiedAt = Date.now();1369 }1370 }1371 await persistEntries();1372}13731374export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1375 if (!derivedKey) throw new Error("vault locked");13761377 const sanitized = sanitizeEntryDraft(entry);1378 const now = Date.now();1379 const nextEntry: VaultEntry = {1380 ...sanitized,1381 id: crypto.randomUUID(),1382 createdAt: now,1383 modifiedAt: now,1384 1385 1386 passwordChangedAt: sanitized.password ? now : undefined,1387 };13881389 entries.push(nextEntry);1390 await persistEntries();13911392 1393 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1394 checkBreach(nextEntry.password).then(async (count) => {1395 if (count > 0) {1396 const idx = entries.findIndex((e) => e.id === nextEntry.id);1397 if (idx !== -1) {1398 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };1399 await persistEntries();1400 }1401 }1402 });1403 }14041405 return nextEntry;1406}14071408export async function updateEntry(1409 id: string,1410 patch: Partial<VaultEntry>1411): Promise<void> {1412 if (!derivedKey) throw new Error("vault locked");14131414 const idx = entries.findIndex((entry) => entry.id === id);1415 if (idx === -1 || entries[idx].deletedAt) {1416 throw new Error("entry not found");1417 }14181419 const current = entries[idx];1420 const sanitizedPatch = sanitizeEntryPatch(patch);1421 1422 1423 1424 const passwordChanged =1425 "password" in patch &&1426 typeof sanitizedPatch.password === "string" &&1427 sanitizedPatch.password !== current.password;1428 entries[idx] = {1429 ...current,1430 ...sanitizedPatch,1431 modifiedAt: Date.now(),1432 ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),1433 };14341435 1436 1437 1438 1439 if (1440 entries[idx].type === "login" &&1441 !entries[idx].password &&1442 !entries[idx].passkey1443 ) {1444 entries[idx] = current;1445 throw new Error("password or passkey required");1446 }14471448 1449 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1450 entries[idx] = current;1451 throw new Error("card number is required for card entries");1452 }14531454 await persistEntries();14551456 1457 1458 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1459 entries[idx] = { ...entries[idx], breach: undefined };1460 await persistEntries();1461 const password = entries[idx].password;1462 if (password) {1463 checkBreach(password).then(async (count) => {1464 const i = entries.findIndex((e) => e.id === id);1465 if (i !== -1 && count > 0) {1466 entries[i] = { ...entries[i], breach: count, modifiedAt: Date.now() };1467 await persistEntries();1468 }1469 });1470 }1471 }1472}14731474export async function deleteEntry(id: string): Promise<void> {1475 if (!derivedKey) throw new Error("vault locked");14761477 const entry = entries.find((item) => item.id === id);1478 if (!entry || entry.deletedAt) {1479 throw new Error("entry not found");1480 }14811482 entry.deletedAt = Date.now();1483 entry.modifiedAt = Date.now();1484 await persistEntries();1485}14861487export function getDeletedEntries(): VaultEntry[] {1488 if (!derivedKey) return [];1489 return entries.filter((e) => !!e.deletedAt);1490}14911492export async function restoreEntry(id: string): Promise<void> {1493 if (!derivedKey) throw new Error("vault locked");1494 const entry = entries.find((e) => e.id === id && e.deletedAt);1495 if (!entry) throw new Error("deleted entry not found");1496 delete entry.deletedAt;1497 entry.modifiedAt = Date.now();1498 await persistEntries();1499 buildDomainIndexSync(entries);1500}15011502export async function purgeEntry(id: string): Promise<void> {1503 if (!derivedKey) throw new Error("vault locked");1504 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1505 if (idx === -1) throw new Error("deleted entry not found");1506 entries.splice(idx, 1);1507 await persistEntries();1508}150915101511export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1512 if (!derivedKey || retentionMs === 0) return 0;1513 const cutoff = Date.now() - retentionMs;1514 const before = entries.length;1515 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1516 const purged = before - entries.length;1517 if (purged > 0) await persistEntries();1518 return purged;1519}15201521export function queryDomain(domain: string): VaultEntry[] {1522 return domainIndex.get(domain) ?? [];1523}15241525export function getEntryById(id: string): VaultEntry | undefined {1526 return entries.find((entry) => entry.id === id && !entry.deletedAt);1527}15281529async function persistSlotA(1530 reason: VaultSnapshot["reason"] = "local_save",1531): Promise<StoredVault> {1532 if (!derivedKey) throw new Error("vault locked");15331534 const slotA = await loadSlot(SLOT_A);1535 if (!slotA) throw new Error("missing slot A");15361537 1538 1539 await takeVaultSnapshot(reason);15401541 const plaintext = encodePayload(entries, duressConfigured);1542 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);15431544 1545 const nextSlotA: StoredVault = {1546 ...slotA,1547 nonce,1548 ciphertext,1549 tag,1550 1551 };1552 await writeSlot(SLOT_A, nextSlotA);15531554 1555 1556 1557 if (!duressConfigured) {1558 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1559 }1560 await stampLocalVaultMutation();15611562 return nextSlotA;1563}15641565async function persistSlotB(): Promise<void> {1566 if (!derivedKey) throw new Error("vault locked");15671568 const slotB = await loadSlot(SLOT_B);1569 if (!slotB) throw new Error("missing slot B");15701571 1572 1573 const plaintext = encodePayload(entries, false);1574 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);15751576 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1577 await stampLocalVaultMutation();1578}15791580async function persistEntries(1581 reason: VaultSnapshot["reason"] = "local_save",1582): Promise<void> {1583 if (!derivedKey) throw new Error("vault locked");15841585 if (vaultMode === "decoy") {1586 await persistSlotB();1587 } else {1588 await persistSlotA(reason);1589 }1590 buildDomainIndexSync(entries);1591}15921593function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1594 if (!Array.isArray(values)) return undefined;1595 const seen = new Set<string>();1596 const cleaned: string[] = [];1597 for (const value of values) {1598 const trimmed = typeof value === "string" ? value.trim() : "";1599 if (!trimmed) continue;1600 const key = trimmed.toLowerCase();1601 if (seen.has(key)) continue;1602 seen.add(key);1603 cleaned.push(trimmed);1604 }1605 return cleaned.length > 0 ? cleaned : undefined;1606}16071608function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1609 const title = entry.title.trim();1610 1611 1612 1613 let type: VaultEntry["type"] = entry.type ?? "login";1614 if (type === "passkey") type = "login";16151616 if (!title) throw new Error("title is required");1617 1618 1619 if (type === "login" && !entry.password?.trim() && !entry.passkey) {1620 throw new Error("password or passkey required");1621 }1622 if (entry.passkey) {1623 const p = entry.passkey;1624 if (1625 !p.rpId ||1626 !p.credentialId ||1627 !p.publicKeyCose ||1628 !p.userHandle ||1629 !p.userName ||1630 (p.algorithm !== -7 && p.algorithm !== -8)1631 ) {1632 throw new Error("invalid passkey credential");1633 }1634 }1635 if (type === "card") {1636 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1637 throw new Error("cardholder name and card number are required");1638 }1639 }16401641 return {1642 type,1643 title,1644 username: type === "login" ? entry.username?.trim() ?? "" : undefined,1645 password: type === "login" ? entry.password : undefined,1646 url: type === "login" ? entry.url?.trim() ?? "" : undefined,1647 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1648 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1649 notes: entry.notes?.trim() || undefined,1650 breach: type === "login" ? entry.breach : undefined,1651 canary: type === "login" ? entry.canary : undefined,1652 tags: entry.tags,1653 passkey: entry.passkey,1654 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1655 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1656 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1657 expYear: type === "card" ? entry.expYear?.trim() : undefined,1658 cvc: type === "card" ? entry.cvc?.trim() : undefined,1659 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1660 network: type === "card" ? entry.network : undefined,1661 last4: type === "card" ? entry.last4?.trim() : undefined,1662 };1663}16641665function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1666 const nextPatch: Partial<VaultEntry> = {};16671668 if ("type" in patch) {1669 nextPatch.type = patch.type;1670 }16711672 if ("title" in patch) {1673 const title = patch.title?.trim() ?? "";1674 if (!title) throw new Error("title is required");1675 nextPatch.title = title;1676 }16771678 if ("url" in patch) {1679 nextPatch.url = patch.url?.trim() ?? "";1680 }16811682 if ("additionalUrls" in patch) {1683 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1684 const seen = new Set<string>();1685 const cleaned: string[] = [];1686 for (const value of incoming) {1687 const trimmed = typeof value === "string" ? value.trim() : "";1688 if (!trimmed) continue;1689 const key = trimmed.toLowerCase();1690 if (seen.has(key)) continue;1691 seen.add(key);1692 cleaned.push(trimmed);1693 }1694 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1695 }16961697 if ("username" in patch) {1698 nextPatch.username = patch.username?.trim() ?? "";1699 }17001701 if ("email" in patch) {1702 nextPatch.email = patch.email?.trim() || undefined;1703 }17041705 if ("password" in patch) {1706 const password = patch.password ?? "";1707 1708 nextPatch.password = password || undefined;1709 }17101711 if ("totp" in patch) {1712 nextPatch.totp = patch.totp?.trim() || undefined;1713 }17141715 if ("notes" in patch) {1716 nextPatch.notes = patch.notes?.trim() || undefined;1717 }17181719 if ("breach" in patch) {1720 nextPatch.breach = patch.breach;1721 }17221723 if ("canary" in patch) {1724 nextPatch.canary = patch.canary;1725 }17261727 if ("passkey" in patch) {1728 1729 1730 1731 nextPatch.passkey = patch.passkey;1732 }17331734 if ("tags" in patch) {1735 nextPatch.tags = patch.tags;1736 }17371738 if ("cardholderName" in patch) {1739 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1740 }1741 if ("cardNumber" in patch) {1742 const n = patch.cardNumber ?? "";1743 nextPatch.cardNumber = n || undefined;1744 }1745 if ("expMonth" in patch) {1746 nextPatch.expMonth = patch.expMonth?.trim() || undefined;1747 }1748 if ("expYear" in patch) {1749 nextPatch.expYear = patch.expYear?.trim() || undefined;1750 }1751 if ("cvc" in patch) {1752 nextPatch.cvc = patch.cvc?.trim() || undefined;1753 }1754 if ("cardNickname" in patch) {1755 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1756 }1757 if ("network" in patch) {1758 nextPatch.network = patch.network;1759 }1760 if ("last4" in patch) {1761 nextPatch.last4 = patch.last4?.trim() || undefined;1762 }17631764 return nextPatch;1765}17661767function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1768 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1769 const modifiedAt =1770 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;17711772 const base: VaultEntry = {1773 id: entry.id,1774 type: "type" in entry ? entry.type : "login",1775 title: entry.title,1776 username: entry.username,1777 password: entry.password,1778 url: entry.url,1779 totp: entry.totp,1780 notes: entry.notes,1781 breach: entry.breach,1782 canary: "canary" in entry ? entry.canary : undefined,1783 passkey: "passkey" in entry ? entry.passkey : undefined,1784 createdAt,1785 modifiedAt,1786 deletedAt: entry.deletedAt,1787 tags: "tags" in entry ? entry.tags : undefined,1788 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1789 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1790 expMonth: "expMonth" in entry ? entry.expMonth : undefined,1791 expYear: "expYear" in entry ? entry.expYear : undefined,1792 cvc: "cvc" in entry ? entry.cvc : undefined,1793 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1794 network: "network" in entry ? entry.network : undefined,1795 last4: "last4" in entry ? entry.last4 : undefined,1796 };17971798 return migrateLegacyPasskey(base, entry);1799}180018011802180318041805180618071808function migrateLegacyPasskey(1809 base: VaultEntry,1810 raw: VaultEntry | LegacyVaultEntry1811): VaultEntry {1812 if (base.passkey) return base;1813 const flat = raw as Partial<VaultEntry>;1814 const isPasskeyType = base.type === "passkey";1815 const hasFlatPasskeyFields =1816 typeof flat.rpId === "string" &&1817 typeof flat.credentialId === "string" &&1818 typeof flat.publicKeyCose === "string" &&1819 typeof flat.userHandle === "string" &&1820 typeof flat.userName === "string";1821 if (!isPasskeyType && !hasFlatPasskeyFields) return base;1822 if (!hasFlatPasskeyFields) return base;18231824 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;1825 base.passkey = {1826 rpId: flat.rpId!,1827 rpName: flat.rpName ?? flat.rpId!,1828 credentialId: flat.credentialId!,1829 publicKeyCose: flat.publicKeyCose!,1830 privateKeyJwk: flat.privateKeyJwk,1831 userHandle: flat.userHandle!,1832 userName: flat.userName!,1833 userDisplayName: flat.userDisplayName ?? flat.userName!,1834 algorithm,1835 signCount: flat.signCount ?? 0,1836 aaguid: flat.aaguid ?? "",1837 backupEligible: flat.backupEligible === true,1838 backupState: flat.backupState === true,1839 lastUsedAt: flat.lastUsedAt,1840 browser: flat.browser,1841 transports: flat.transports,1842 createdAt: base.createdAt,1843 };1844 1845 1846 1847 if (isPasskeyType) base.type = "login";1848 if (!base.username) base.username = flat.userName;1849 if (!base.url) base.url = `https://${flat.rpId}`;1850 return base;1851}1852185318541855185618571858function entryDupeKey(e: VaultEntry): string {1859 if (e.type === "note") return `note|${e.title.toLowerCase()}`;1860 1861 1862 1863 1864 if (e.passkey) {1865 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;1866 }1867 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1868}186918701871export function countDuplicates(): number {1872 const best = new Map<string, VaultEntry>();1873 let dupeCount = 0;1874 for (const e of entries.filter((e) => !e.deletedAt)) {1875 const key = entryDupeKey(e);1876 const existing = best.get(key);1877 if (!existing) {1878 best.set(key, e);1879 } else {1880 dupeCount++;1881 1882 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1883 best.set(key, e);1884 }1885 }1886 }1887 return dupeCount;1888}188918901891189218931894export async function deduplicateEntries(): Promise<number> {1895 if (!derivedKey) throw new Error("vault locked");1896 1897 1898 if (vaultMode === "decoy") {1899 throw new Error("deduplicate disabled in decoy mode");1900 }19011902 const best = new Map<string, VaultEntry>();1903 for (const e of entries.filter((en) => !en.deletedAt)) {1904 const key = entryDupeKey(e);1905 const existing = best.get(key);1906 if (!existing) {1907 best.set(key, e);1908 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1909 best.set(key, e);1910 }1911 }19121913 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1914 const now = Date.now();1915 let removed = 0;1916 entries = entries.map((e) => {1917 if (!e.deletedAt && !keepIds.has(e.id)) {1918 removed++;1919 return { ...e, deletedAt: now };1920 }1921 return e;1922 });19231924 if (removed > 0) await persistEntries();1925 return removed;1926}192719281929193019311932193319341935const HISTORY_KEY = "peach_vault_history";1936const HISTORY_MAX = 10;19371938193919401941194219431944export async function takeVaultSnapshot(1945 reason: VaultSnapshot["reason"],1946): Promise<void> {1947 try {1948 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);1949 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];1950 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];19511952 1953 1954 1955 if (!slotA) return;19561957 const vault_a = JSON.stringify(slotA);1958 const vault_b = JSON.stringify(slotB ?? slotA);19591960 1961 1962 1963 let entryCount: number | null = null;1964 if (derivedKey && vaultMode === "real") {1965 try {1966 entryCount = entries.filter((e) => !e.deletedAt).length;1967 } catch {1968 entryCount = null;1969 }1970 }19711972 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];1973 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)1974 ? existingRaw1975 : { snapshots: [] };19761977 1978 1979 1980 const latest = history.snapshots[0];1981 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {1982 return;1983 }19841985 const snapshot: VaultSnapshot = {1986 id: crypto.randomUUID(),1987 savedAt: Date.now(),1988 reason,1989 vault_a,1990 vault_b,1991 entryCount,1992 };19931994 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);1995 await chrome.storage.local.set({ [HISTORY_KEY]: history });1996 } catch {1997 1998 1999 }2000}200120022003export async function clearVaultHistory(): Promise<void> {2004 try {2005 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2006 } catch {2007 2008 }2009}201020112012export async function getVaultHistory(): Promise<VaultSnapshot[]> {2013 const raw = await chrome.storage.local.get(HISTORY_KEY);2014 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2015 if (!history || !Array.isArray(history.snapshots)) return [];2016 return history.snapshots;2017}20182019202020212022202320242025export async function restoreVaultSnapshot(2026 snapshotId: string,2027): Promise<{ ok: true } | { ok: false; error: string }> {2028 const history = await getVaultHistory();2029 const snapshot = history.find((s) => s.id === snapshotId);2030 if (!snapshot) return { ok: false, error: "snapshot_not_found" };20312032 2033 2034 2035 await takeVaultSnapshot("restore");20362037 let restoredA: StoredVault;2038 let restoredB: StoredVault;2039 try {2040 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2041 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2042 } catch {2043 return { ok: false, error: "snapshot_corrupt" };2044 }20452046 await writeSlot(SLOT_A, restoredA);2047 await writeSlot(SLOT_B, restoredB);2048 await stampLocalVaultMutation();20492050 2051 2052 2053 lock();20542055 return { ok: true };2056}20572058205920602061206220632064export async function changePassword(2065 currentPassword: string,2066 newPassword: string2067): Promise<boolean> {2068 if (!derivedKey || vaultMode !== "real") return false;20692070 2071 const valid = await verifyMainPassword(currentPassword);2072 if (!valid) return false;20732074 const slotA = await loadSlot(SLOT_A);2075 if (!slotA) return false;20762077 2078 const newSalt = generateSalt();2079 const newKey = await deriveKey(newPassword, newSalt);20802081 2082 const plaintext = encodePayload(entries, duressConfigured);2083 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);20842085 const freshSlotA: StoredVault = {2086 ...slotA,2087 salt: hex(newSalt),2088 nonce,2089 ciphertext,2090 tag,2091 };2092 2093 2094 2095 await takeVaultSnapshot("local_save");2096 await writeSlot(SLOT_A, freshSlotA);20972098 2099 2100 2101 zeroKey(derivedKey);2102 derivedKey = newKey;21032104 2105 2106 const {2107 rawRootKey: freshRawKey,2108 recoverySalt: freshSalt,2109 wrappedRootKey: freshWrapped,2110 wrappedNonce: freshNonce,2111 wrappedTag: freshTag,2112 } = await generateAndWrapRootKey();2113 rootKey = unhex(freshRawKey);2114 const slotAfterRekey = await loadSlot(SLOT_A);2115 if (slotAfterRekey) {2116 await writeSlot(SLOT_A, {2117 ...slotAfterRekey,2118 recovery_salt: freshSalt,2119 recovery_wrapped_root_key: freshWrapped,2120 recovery_nonce: freshNonce,2121 recovery_tag: freshTag,2122 });2123 }21242125 2126 2127 2128 if (!duressConfigured) {2129 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2130 }2131 await stampLocalVaultMutation();21322133 return true;2134}213521362137213821392140export async function getVaultBlobForSync(): Promise<{2141 blob: string;2142 timestamp: number;2143 license?: string;2144} | null> {2145 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, 'peach_last_modified_at', 'peach_license']);2146 const slotA = (raw as Record<string, unknown>)[SLOT_A];2147 if (!slotA) return null;2148 const slotB = (raw as Record<string, unknown>)[SLOT_B];2149 const blob = JSON.stringify({ a: slotA, b: slotB });2150 const rawTs = (raw as Record<string, unknown>)['peach_last_modified_at'];2151 const timestamp = typeof rawTs === 'string' ? parseInt(rawTs) : 0;2152 const license = (raw as Record<string, unknown>)['peach_license'];2153 return {2154 blob,2155 timestamp,2156 license: typeof license === 'string' ? license : undefined,2157 };2158}21592160export async function applyIncomingVault(2161 blobJson: string,2162 remoteTimestamp: number,2163 remoteLicense?: string,2164): Promise<boolean> {2165 const raw = await chrome.storage.local.get('peach_last_modified_at');2166 const rawTs = (raw as Record<string, unknown>)['peach_last_modified_at'];2167 const localTimestamp = typeof rawTs === 'string' ? parseInt(rawTs) : 0;21682169 if (remoteLicense) {2170 const licenseRaw = await chrome.storage.local.get('peach_license');2171 const existing = (licenseRaw as Record<string, unknown>)['peach_license'];2172 if (!existing) {2173 await chrome.storage.local.set({ peach_license: remoteLicense });2174 }2175 }21762177 if (remoteTimestamp <= localTimestamp) return false;21782179 2180 await takeVaultSnapshot('sync_received').catch(() => {});21812182 const { a, b } = JSON.parse(blobJson) as { a: unknown; b?: unknown };2183 await writeSlot(SLOT_A, a as StoredVault);2184 if (b) await writeSlot(SLOT_B, b as StoredVault);2185 await chrome.storage.local.set({2186 peach_last_sync_at: String(Date.now()),2187 peach_last_modified_at: String(remoteTimestamp),2188 });21892190 return true;2191}21922193export async function getSyncIdentity(): Promise<{2194 privateKeyBytes: Uint8Array;2195 publicKeyBytes: Uint8Array;2196 publicKeyHex: string;2197}> {2198 const raw = await chrome.storage.local.get('peach_sync_identity');2199 const stored = (raw as Record<string, unknown>)['peach_sync_identity'];2200 if (typeof stored === 'string') {2201 const { priv, pub } = JSON.parse(stored) as { priv: string; pub: string };2202 const privateKeyBytes = base64DecodeBytes(priv);2203 const publicKeyBytes = base64DecodeBytes(pub);2204 return { privateKeyBytes, publicKeyBytes, publicKeyHex: bytesToHex(publicKeyBytes) };2205 }22062207 2208 const { generateKeyPair } = await import('@stablelib/x25519');2209 const keypair = generateKeyPair();2210 await chrome.storage.local.set({2211 peach_sync_identity: JSON.stringify({2212 priv: base64EncodeBytes(keypair.secretKey),2213 pub: base64EncodeBytes(keypair.publicKey),2214 }),2215 });2216 return {2217 privateKeyBytes: keypair.secretKey,2218 publicKeyBytes: keypair.publicKey,2219 publicKeyHex: bytesToHex(keypair.publicKey),2220 };2221}22222223222422252226export async function reloadEntriesAfterSync(): Promise<boolean> {2227 if (!derivedKey) return false;2228 const slotA = await loadSlot(SLOT_A);2229 if (!slotA) return false;2230 try {2231 const payload = await tryDecryptSlot(slotA, derivedKey);2232 if (!payload) return false;2233 entries = payload.entries;2234 buildDomainIndexSync(entries);2235 return true;2236 } catch {2237 return false;2238 }2239}22402241function bytesToHex(bytes: Uint8Array): string {2242 return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');2243}22442245function base64EncodeBytes(bytes: Uint8Array): string {2246 let binary = '';2247 for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);2248 return btoa(binary);2249}22502251function base64DecodeBytes(b64: string): Uint8Array {2252 const binary = atob(b64);2253 const bytes = new Uint8Array(binary.length);2254 for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);2255 return bytes;2256}