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}313314315316317318319export async function rewrapRootKeyForPassword(320 _newDerivedKey: Uint8Array,321 _newSalt: Uint8Array322): Promise<void> {323 if (!rootKey) throw new Error("root key not set");324 const slotA = await loadSlot(SLOT_A);325 if (!slotA) throw new Error("missing slot A");326 327 328 329 330}331332333334335336337async function reencryptVaultForNewKey(338 newKey: Uint8Array,339 slotA: StoredVault340): Promise<void> {341 const plaintext = encodePayload(entries, duressConfigured);342 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);343 await writeSlot(SLOT_A, {344 ...slotA,345 nonce,346 ciphertext,347 tag,348 });349}350351352353354355356export async function changePasswordWithNewKey(357 newSalt: Uint8Array,358 newKey: Uint8Array359): Promise<void> {360 const slotA = await loadSlot(SLOT_A);361 if (!slotA) throw new Error("missing slot A");362 363 364 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });365 if (derivedKey) zeroKey(derivedKey);366 derivedKey = newKey;367 if (!duressConfigured) {368 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));369 }370 await stampLocalVaultMutation();371}372373374375376377378export { deriveKey, generateSalt };379380export async function loadSlotA(): Promise<StoredVault | null> {381 return loadSlot(SLOT_A);382}383384385386387388389390391392393394395396397398399400401async function deriveRootKeyFromDerivedKey(402 _derivedKey: Uint8Array,403 _recoverySaltHex: string404): Promise<Uint8Array | null> {405 406 407 408 409 410 411 412 return null;413}414415416417418419420421422export async function migrateToRecovery(): Promise<void> {423 if (!derivedKey || vaultMode !== "real") {424 throw new Error("vault must be unlocked in real mode");425 }426 const slotA = await loadSlot(SLOT_A);427 if (!slotA) throw new Error("missing slot A");428 if (slotA.recovery_salt && slotA.recovery_wrapped_root_key) return; 429430 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =431 await generateAndWrapRootKey();432433 rootKey = unhex(rawRootKey);434435 const nextSlotA: StoredVault = {436 ...slotA,437 recovery_salt: recoverySalt,438 recovery_wrapped_root_key: wrappedRootKey,439 recovery_nonce: wrappedNonce,440 recovery_tag: wrappedTag,441 };442 await writeSlot(SLOT_A, nextSlotA);443 await stampLocalVaultMutation();444}445446447448449450451export async function getRecoveryWords(): Promise<string[]> {452 if (!rootKey) {453 throw new Error("recovery not set up — vault may need migration");454 }455 return rootKeyToWords(rootKey);456}457458async function loadSlot(key: string): Promise<StoredVault | null> {459 const result = await chrome.storage.local.get(key);460 return (result as Record<string, StoredVault | undefined>)[key] ?? null;461}462463async function writeSlot(key: string, vault: StoredVault): Promise<void> {464 try {465 await chrome.storage.local.set({ [key]: vault });466 } catch (err) {467 throw mapStorageError(err);468 }469}470471async function stampLocalVaultMutation(): Promise<void> {472 await chrome.storage.local.set({473 peach_last_modified_at: String(Date.now()),474 });475}476477478479480481482async function ensureMigrated(): Promise<void> {483 if (migrationRan) return;484 migrationRan = true;485486 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];487 const raw = await chrome.storage.local.get(keys);488 const slotAExists = raw[SLOT_A] !== undefined;489 const slotBExists = raw[SLOT_B] !== undefined;490 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;491 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;492493 494 if (!slotAExists && !legacyMain) return;495496 497 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;498499 let didMigrate = false;500 if (legacyMain && !slotAExists) {501 await writeSlot(SLOT_A, legacyMain);502 didMigrate = true;503 }504505 506 if (!slotBExists) {507 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;508 if (legacyDecoy) {509 await writeSlot(SLOT_B, legacyDecoy);510 didMigrate = true;511 } else if (target) {512 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));513 didMigrate = true;514 }515 }516517 const toRemove: string[] = [];518 if (legacyMain) toRemove.push(LEGACY_MAIN);519 if (legacyDecoy) toRemove.push(LEGACY_DECOY);520 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);521 if (toRemove.length > 0) {522 await chrome.storage.local.remove(toRemove);523 didMigrate = true;524 }525 if (didMigrate) {526 await stampLocalVaultMutation();527 }528}529530531532533534535536537538539540function padPlaintext(json: string): string {541 const encoded = new TextEncoder().encode(json);542 const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);543 const padLen = target - encoded.length;544 return json + " ".repeat(padLen);545}546547548549550551552553554555556557const CSPRNG_CHUNK = 65_536;558559function fillRandom(buf: Uint8Array): Uint8Array {560 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {561 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);562 const chunk = new Uint8Array(end - offset);563 crypto.getRandomValues(chunk);564 buf.set(chunk, offset);565 }566 return buf;567}568569function generateNoiseBlob(ciphertextBytes: number): StoredVault {570 const salt = crypto.getRandomValues(new Uint8Array(16));571 const nonce = crypto.getRandomValues(new Uint8Array(12));572 const cipher = fillRandom(new Uint8Array(ciphertextBytes));573 const tag = crypto.getRandomValues(new Uint8Array(16));574 return {575 version: 1,576 salt: hex(salt),577 nonce: hex(nonce),578 ciphertext: hex(cipher),579 tag: hex(tag),580 };581}582583584585586587type VaultPayload = {588 entries: VaultEntry[];589 duressConfigured?: boolean;590};591592type DecryptedPayload = {593 entries: VaultEntry[];594 duressConfigured: boolean;595 596597598599600601602 migratedFromLegacy: boolean;603};604605function encodePayload(entries: VaultEntry[], duress: boolean): string {606 const envelope: VaultPayload = { entries, duressConfigured: duress };607 return padPlaintext(JSON.stringify(envelope));608}609610function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {611 if ("created" in raw || "updated" in raw) return true;612 const flat = raw as Partial<VaultEntry>;613 if (flat.type === "passkey") return true;614 615 if (flat.rpId !== undefined && flat.passkey === undefined) return true;616 return false;617}618619function decodePayload(plain: string): DecryptedPayload {620 const parsed = JSON.parse(plain) as621 | Array<VaultEntry | LegacyVaultEntry>622 | VaultPayload;623624 const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];625 const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);626627 const normalized = rawEntries.map(normalizeEntry);628 const { merged } = mergePasskeyOnlyIntoLogins(normalized);629630 if (Array.isArray(parsed)) {631 return {632 entries: normalized,633 duressConfigured: false,634 migratedFromLegacy: true, 635 };636 }637638 return {639 entries: normalized,640 duressConfigured: parsed.duressConfigured === true,641 migratedFromLegacy: flatMigrationNeeded || merged,642 };643}644645646647648649650651652653654655656657658659660661662663664665666667668function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {669 let merged = false;670 const now = Date.now();671672 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);673 const byRpId = new Map<string, VaultEntry[]>();674 for (const e of candidates) {675 const rpId = e.passkey!.rpId;676 const list = byRpId.get(rpId);677 if (list) list.push(e);678 else byRpId.set(rpId, [e]);679 }680681 for (const [rpId, group] of byRpId) {682 if (group.length === 0) continue;683 group.sort((a, b) => {684 const aT =685 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;686 const bT =687 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;688 return bT - aT;689 });690 const winner = group[0]!;691692 693 694 695 const loginTarget = entries.find((e) => {696 if (e.deletedAt || e.passkey || e.type !== "login") return false;697 if (e.url && extractDomain(e.url) === rpId) return true;698 for (const extra of e.additionalUrls ?? []) {699 if (extractDomain(extra) === rpId) return true;700 }701 return false;702 });703704 if (loginTarget) {705 loginTarget.passkey = winner.passkey;706 loginTarget.modifiedAt = now;707 for (const dup of group) {708 if (!dup.deletedAt) {709 dup.deletedAt = now;710 dup.modifiedAt = now;711 }712 }713 merged = true;714 } else if (group.length > 1) {715 for (let i = 1; i < group.length; i += 1) {716 const dup = group[i]!;717 if (!dup.deletedAt) {718 dup.deletedAt = now;719 dup.modifiedAt = now;720 }721 }722 merged = true;723 }724 }725726 return { merged };727}728729730731732733734export async function tryDecryptSlot(735 vault: StoredVault,736 key: Uint8Array737): Promise<DecryptedPayload | null> {738 try {739 const plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);740 return decodePayload(plain);741 } catch {742 return null;743 }744}745746747748749750function buildDomainIndexSync(all: VaultEntry[]): void {751 domainIndex = new Map();752753 for (const entry of all) {754 if (entry.deletedAt) continue;755 if (entry.type === "note" || entry.type === "card") continue;756757 758 759 760 761 const domains = new Set<string>();762 const primary = extractDomain(entry.url ?? "");763 if (primary) domains.add(primary);764 for (const extra of entry.additionalUrls ?? []) {765 const d = extractDomain(extra);766 if (d) domains.add(d);767 }768 if (domains.size === 0) continue;769770 for (const domain of domains) {771 const bucket = domainIndex.get(domain) ?? [];772 bucket.push(entry);773 domainIndex.set(domain, bucket);774 }775 }776}777778export async function createVault(password: string): Promise<void> {779 await ensureMigrated();780781 const salt = generateSalt();782 derivedKey = await deriveKey(password, salt);783 entries = [];784 vaultMode = "real";785 duressConfigured = false;786 buildDomainIndexSync(entries);787788 789 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =790 await generateAndWrapRootKey();791 rootKey = unhex(rawRootKey);792793 const plaintext = encodePayload(entries, duressConfigured);794 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);795796 const slotA: StoredVault = {797 version: 1,798 salt: hex(salt),799 nonce,800 ciphertext,801 tag,802 recovery_salt: recoverySalt,803 recovery_wrapped_root_key: wrappedRootKey,804 recovery_nonce: wrappedNonce,805 recovery_tag: wrappedTag,806 };807 await writeSlot(SLOT_A, slotA);808809 810 811 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));812 await stampLocalVaultMutation();813814 touchAutoLock();815}816817export async function hasVault(): Promise<boolean> {818 await ensureMigrated();819 return (await loadSlot(SLOT_A)) !== null;820}821822823824825826827export async function isDuressEnabled(): Promise<boolean> {828 return vaultMode === "real" && duressConfigured;829}830831export async function setupDuress(duressPassword: string): Promise<void> {832 if (!derivedKey || vaultMode !== "real") {833 throw new Error("vault must be unlocked in real mode");834 }835 await ensureMigrated();836837 838 839 840 const slotA = await loadSlot(SLOT_A);841 if (!slotA) throw new Error("missing slot A");842843 const decoySalt = generateSalt();844 const decoyKey = await deriveKey(duressPassword, decoySalt);845 try {846 847 848 const targetBytes = slotA.ciphertext.length / 2;849 let plaintext = encodePayload([], false);850 const encoded = new TextEncoder().encode(plaintext);851 if (encoded.length < targetBytes) {852 plaintext = plaintext + " ".repeat(targetBytes - encoded.length);853 }854 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);855 await writeSlot(SLOT_B, {856 version: 1,857 salt: hex(decoySalt),858 nonce,859 ciphertext,860 tag,861 });862 } finally {863 zeroKey(decoyKey);864 }865866 867 868 duressConfigured = true;869 await persistSlotA();870}871872export async function disableDuress(): Promise<void> {873 if (!derivedKey || vaultMode !== "real") {874 throw new Error("vault must be unlocked in real mode");875 }876 await ensureMigrated();877878 879 880 881 const slotA = await loadSlot(SLOT_A);882 if (slotA) {883 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));884 }885886 duressConfigured = false;887 await persistSlotA();888}889890export async function verifyMainPassword(password: string): Promise<boolean> {891 await ensureMigrated();892 const slotA = await loadSlot(SLOT_A);893 if (!slotA) return false;894 const key = await deriveKey(password, unhex(slotA.salt));895 try {896 return (await tryDecryptSlot(slotA, key)) !== null;897 } finally {898 zeroKey(key);899 }900}901902export async function unlock(password: string): Promise<boolean> {903 await ensureMigrated();904905 906 907 908 909 910 const [slotA, slotB] = await Promise.all([911 loadSlot(SLOT_A),912 loadSlot(SLOT_B),913 ]);914915 916 const [keyA, keyB] = await Promise.all([917 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),918 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),919 ]);920921 922 const [payloadA, payloadB] = await Promise.all([923 keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),924 keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),925 ]);926927 if (payloadA && keyA) {928 derivedKey = keyA;929 vaultMode = "real";930 entries = payloadA.entries;931 duressConfigured = payloadA.duressConfigured;932 buildDomainIndexSync(entries);933 await loadSettings();934 touchAutoLock();935 if (keyB) zeroKey(keyB);936937 938 if (slotA) {939 const needsRecoverySetup =940 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;941 if (!needsRecoverySetup) {942 rootKey = await deriveRootKeyFromDerivedKey(943 keyA,944 slotA.recovery_salt!945 );946 }947 }948949 950 951 952 if (payloadA.migratedFromLegacy) {953 await persistSlotA();954 }955 return true;956 }957958 if (payloadB && keyB) {959 derivedKey = keyB;960 vaultMode = "decoy";961 entries = payloadB.entries;962 duressConfigured = false;963 buildDomainIndexSync(entries);964 await loadSettings();965 touchAutoLock();966 if (keyA) zeroKey(keyA);967 return true;968 }969970 if (keyA) zeroKey(keyA);971 if (keyB) zeroKey(keyB);972 return false;973}974975export function lock(): void {976 if (derivedKey) {977 zeroKey(derivedKey);978 derivedKey = null;979 }980 if (rootKey) {981 rootKey.fill(0);982 rootKey = null;983 }984985 entries = [];986 domainIndex = new Map();987 vaultMode = "real";988 duressConfigured = false;989 clearAutoLock();990}991992export function isUnlocked(): boolean {993 return derivedKey !== null;994}995996997998export async function unlockWithKey(999 masterKeyHex: string,1000): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1001 try {1002 const masterKeyBytes = unhex(masterKeyHex);1003 const stored = await loadSlot(SLOT_A);1004 if (!stored) return { ok: false, error: "invalid_key" };1005 const payload = await tryDecryptSlot(stored, masterKeyBytes);1006 if (!payload) return { ok: false, error: "invalid_key" };10071008 derivedKey = masterKeyBytes;1009 vaultMode = "real";1010 entries = payload.entries;1011 duressConfigured = payload.duressConfigured;1012 buildDomainIndexSync(entries);1013 await loadSettings();1014 touchAutoLock();1015 return { ok: true, entries };1016 } catch {1017 return { ok: false, error: "invalid_key" };1018 }1019}10201021102210231024102510261027102810291030103110321033103410351036103710381039export async function unlockFromCodex(params: {1040 slotA: string; 1041 slotB: string | null; 1042 timestamp: number;1043 masterPassphrase: string;1044}): Promise<1045 | { ok: true; entryCount: number }1046 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1047> {1048 let parsedSlotA: StoredVault;1049 let parsedSlotB: StoredVault | null = null;1050 try {1051 parsedSlotA = JSON.parse(params.slotA) as StoredVault;1052 if (params.slotB !== null) {1053 parsedSlotB = JSON.parse(params.slotB) as StoredVault;1054 }1055 1056 1057 1058 if (1059 typeof parsedSlotA?.salt !== "string" ||1060 typeof parsedSlotA?.nonce !== "string" ||1061 typeof parsedSlotA?.ciphertext !== "string" ||1062 typeof parsedSlotA?.tag !== "string"1063 ) {1064 return { ok: false, reason: "invalid_slot_format" };1065 }1066 } catch {1067 return { ok: false, reason: "invalid_slot_format" };1068 }10691070 1071 1072 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1073 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey);1074 if (!trialPayload) {1075 zeroKey(trialKey);1076 return { ok: false, reason: "wrong_passphrase" };1077 }1078 1079 1080 zeroKey(trialKey);10811082 1083 1084 await takeVaultSnapshot("restore");10851086 await writeSlot(SLOT_A, parsedSlotA);1087 if (parsedSlotB) {1088 await writeSlot(SLOT_B, parsedSlotB);1089 } else {1090 1091 1092 1093 await writeSlot(1094 SLOT_B,1095 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1096 );1097 }1098 await chrome.storage.local.set({1099 peach_last_modified_at: String(params.timestamp),1100 });11011102 1103 1104 const ok = await unlock(params.masterPassphrase);1105 if (!ok) {1106 1107 1108 1109 return { ok: false, reason: "wrong_passphrase" };1110 }1111 return { ok: true, entryCount: getEntries().length };1112}1113111411151116export function getDerivedKeyHex(): string {1117 if (!derivedKey) throw new Error("vault_not_unlocked");1118 return hex(derivedKey);1119}11201121export function getEntries(): VaultEntry[] {1122 if (!derivedKey) throw new Error("vault locked");1123 return entries.filter((entry) => !entry.deletedAt);1124}11251126const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;11271128export function getIdentities(): SavedIdentity[] {1129 if (!derivedKey) throw new Error("vault locked");1130 const active = entries.filter((e) => !e.deletedAt && e.type === "login");11311132 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1133 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();11341135 for (const entry of active) {1136 const value = entry.username?.trim();1137 if (!value) continue;1138 const map = EMAIL_RE.test(value) ? emailMap : usernameMap;1139 const existing = map.get(value);1140 const domain = extractDomain(entry.url ?? "") ?? "";1141 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;1142 if (!existing || modifiedAt > existing.lastUsed) {1143 map.set(value, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1144 } else {1145 map.set(value, { ...existing, count: existing.count + 1 });1146 }1147 }11481149 const toIdentity = (type: "email" | "username") =>1150 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1151 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1152 });11531154 return [1155 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1156 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1157 ];1158}11591160export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1161 if (!derivedKey) throw new Error("vault locked");11621163 const now = Date.now();1164 const added: VaultEntry[] = [];1165 for (const draft of drafts) {1166 try {1167 const sanitized = sanitizeEntryDraft(draft);1168 const nextEntry: VaultEntry = {1169 ...sanitized,1170 id: crypto.randomUUID(),1171 createdAt: now,1172 modifiedAt: now,1173 };1174 entries.push(nextEntry);1175 added.push(nextEntry);1176 } catch {1177 1178 }1179 }11801181 if (added.length > 0) {1182 await persistEntries();1183 }11841185 return added;1186}118711881189119011911192119311941195export async function restoreEntries(1196 incoming: VaultEntry[],1197 mode: "replace" | "merge"1198): Promise<{ added: number; updated: number; skipped: number }> {1199 if (!derivedKey) throw new Error("vault locked");12001201 1202 1203 1204 if (mode === "replace" && incoming.length === 0) {1205 throw new Error("refuse to replace with empty entry set");1206 }12071208 let added = 0;1209 let updated = 0;1210 let skipped = 0;12111212 if (mode === "replace") {1213 entries = [];1214 for (const entry of incoming) {1215 const normalized = sanitizeRestoredEntry(entry);1216 if (!normalized) {1217 skipped += 1;1218 continue;1219 }1220 entries.push(normalized);1221 added += 1;1222 }1223 } else {1224 const byId = new Map<string, VaultEntry>();1225 for (const e of entries) byId.set(e.id, e);12261227 for (const entry of incoming) {1228 const normalized = sanitizeRestoredEntry(entry);1229 if (!normalized) {1230 skipped += 1;1231 continue;1232 }1233 const existing = byId.get(normalized.id);1234 if (!existing) {1235 entries.push(normalized);1236 added += 1;1237 } else if (normalized.modifiedAt > existing.modifiedAt) {1238 const idx = entries.findIndex((e) => e.id === existing.id);1239 if (idx !== -1) {1240 entries[idx] = normalized;1241 updated += 1;1242 }1243 } else {1244 skipped += 1;1245 }1246 }1247 }12481249 await persistEntries("import");1250 return { added, updated, skipped };1251}1252125312541255125612571258function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1259 if (!entry || typeof entry !== "object") return null;1260 const title = typeof entry.title === "string" ? entry.title.trim() : "";1261 if (!title) return null;1262 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1263 const now = Date.now();1264 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1265 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;12661267 1268 1269 1270 const migrated: VaultEntry = migrateLegacyPasskey(1271 {1272 ...entry,1273 id,1274 title,1275 type: entry.type ?? "login",1276 createdAt,1277 modifiedAt,1278 },1279 entry1280 );12811282 1283 1284 if (1285 migrated.type === "login" &&1286 !migrated.passkey &&1287 typeof migrated.password !== "string"1288 ) {1289 return null;1290 }1291 if (migrated.passkey && !migrated.passkey.credentialId) {1292 return null;1293 }12941295 return migrated;1296}129712981299130013011302export async function clearVaultEntries(1303 confirmation: "REPLACE_ALL"1304): Promise<void> {1305 if (confirmation !== "REPLACE_ALL") {1306 throw new Error("clearVaultEntries requires explicit confirmation");1307 }1308 if (!derivedKey) throw new Error("vault locked");1309 for (const entry of entries) {1310 if (!entry.deletedAt) {1311 entry.deletedAt = Date.now();1312 entry.modifiedAt = Date.now();1313 }1314 }1315 await persistEntries();1316}13171318export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1319 if (!derivedKey) throw new Error("vault locked");13201321 const sanitized = sanitizeEntryDraft(entry);1322 const now = Date.now();1323 const nextEntry: VaultEntry = {1324 ...sanitized,1325 id: crypto.randomUUID(),1326 createdAt: now,1327 modifiedAt: now,1328 1329 1330 passwordChangedAt: sanitized.password ? now : undefined,1331 };13321333 entries.push(nextEntry);1334 await persistEntries();13351336 1337 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1338 checkBreach(nextEntry.password).then(async (count) => {1339 if (count > 0) {1340 const idx = entries.findIndex((e) => e.id === nextEntry.id);1341 if (idx !== -1) {1342 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };1343 await persistEntries();1344 }1345 }1346 });1347 }13481349 return nextEntry;1350}13511352export async function updateEntry(1353 id: string,1354 patch: Partial<VaultEntry>1355): Promise<void> {1356 if (!derivedKey) throw new Error("vault locked");13571358 const idx = entries.findIndex((entry) => entry.id === id);1359 if (idx === -1 || entries[idx].deletedAt) {1360 throw new Error("entry not found");1361 }13621363 const current = entries[idx];1364 const sanitizedPatch = sanitizeEntryPatch(patch);1365 1366 1367 1368 const passwordChanged =1369 "password" in patch &&1370 typeof sanitizedPatch.password === "string" &&1371 sanitizedPatch.password !== current.password;1372 entries[idx] = {1373 ...current,1374 ...sanitizedPatch,1375 modifiedAt: Date.now(),1376 ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),1377 };13781379 1380 1381 1382 1383 if (1384 entries[idx].type === "login" &&1385 !entries[idx].password &&1386 !entries[idx].passkey1387 ) {1388 entries[idx] = current;1389 throw new Error("password or passkey required");1390 }13911392 1393 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1394 entries[idx] = current;1395 throw new Error("card number is required for card entries");1396 }13971398 await persistEntries();13991400 1401 1402 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1403 entries[idx] = { ...entries[idx], breach: undefined };1404 await persistEntries();1405 const password = entries[idx].password;1406 if (password) {1407 checkBreach(password).then(async (count) => {1408 const i = entries.findIndex((e) => e.id === id);1409 if (i !== -1 && count > 0) {1410 entries[i] = { ...entries[i], breach: count, modifiedAt: Date.now() };1411 await persistEntries();1412 }1413 });1414 }1415 }1416}14171418export async function deleteEntry(id: string): Promise<void> {1419 if (!derivedKey) throw new Error("vault locked");14201421 const entry = entries.find((item) => item.id === id);1422 if (!entry || entry.deletedAt) {1423 throw new Error("entry not found");1424 }14251426 entry.deletedAt = Date.now();1427 entry.modifiedAt = Date.now();1428 await persistEntries();1429}14301431export function getDeletedEntries(): VaultEntry[] {1432 if (!derivedKey) return [];1433 return entries.filter((e) => !!e.deletedAt);1434}14351436export async function restoreEntry(id: string): Promise<void> {1437 if (!derivedKey) throw new Error("vault locked");1438 const entry = entries.find((e) => e.id === id && e.deletedAt);1439 if (!entry) throw new Error("deleted entry not found");1440 delete entry.deletedAt;1441 entry.modifiedAt = Date.now();1442 await persistEntries();1443 buildDomainIndexSync(entries);1444}14451446export async function purgeEntry(id: string): Promise<void> {1447 if (!derivedKey) throw new Error("vault locked");1448 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1449 if (idx === -1) throw new Error("deleted entry not found");1450 entries.splice(idx, 1);1451 await persistEntries();1452}145314541455export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1456 if (!derivedKey || retentionMs === 0) return 0;1457 const cutoff = Date.now() - retentionMs;1458 const before = entries.length;1459 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1460 const purged = before - entries.length;1461 if (purged > 0) await persistEntries();1462 return purged;1463}14641465export function queryDomain(domain: string): VaultEntry[] {1466 return domainIndex.get(domain) ?? [];1467}14681469export function getEntryById(id: string): VaultEntry | undefined {1470 return entries.find((entry) => entry.id === id && !entry.deletedAt);1471}14721473async function persistSlotA(1474 reason: VaultSnapshot["reason"] = "local_save",1475): Promise<StoredVault> {1476 if (!derivedKey) throw new Error("vault locked");14771478 const slotA = await loadSlot(SLOT_A);1479 if (!slotA) throw new Error("missing slot A");14801481 1482 1483 await takeVaultSnapshot(reason);14841485 const plaintext = encodePayload(entries, duressConfigured);1486 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);14871488 1489 const nextSlotA: StoredVault = {1490 ...slotA,1491 nonce,1492 ciphertext,1493 tag,1494 1495 };1496 await writeSlot(SLOT_A, nextSlotA);14971498 1499 1500 1501 if (!duressConfigured) {1502 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1503 }1504 await stampLocalVaultMutation();15051506 return nextSlotA;1507}15081509async function persistSlotB(): Promise<void> {1510 if (!derivedKey) throw new Error("vault locked");15111512 const slotB = await loadSlot(SLOT_B);1513 if (!slotB) throw new Error("missing slot B");15141515 1516 1517 const plaintext = encodePayload(entries, false);1518 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);15191520 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1521 await stampLocalVaultMutation();1522}15231524async function persistEntries(1525 reason: VaultSnapshot["reason"] = "local_save",1526): Promise<void> {1527 if (!derivedKey) throw new Error("vault locked");15281529 if (vaultMode === "decoy") {1530 await persistSlotB();1531 } else {1532 await persistSlotA(reason);1533 }1534 buildDomainIndexSync(entries);1535}15361537function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1538 if (!Array.isArray(values)) return undefined;1539 const seen = new Set<string>();1540 const cleaned: string[] = [];1541 for (const value of values) {1542 const trimmed = typeof value === "string" ? value.trim() : "";1543 if (!trimmed) continue;1544 const key = trimmed.toLowerCase();1545 if (seen.has(key)) continue;1546 seen.add(key);1547 cleaned.push(trimmed);1548 }1549 return cleaned.length > 0 ? cleaned : undefined;1550}15511552function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1553 const title = entry.title.trim();1554 1555 1556 1557 let type: VaultEntry["type"] = entry.type ?? "login";1558 if (type === "passkey") type = "login";15591560 if (!title) throw new Error("title is required");1561 1562 1563 if (type === "login" && !entry.password?.trim() && !entry.passkey) {1564 throw new Error("password or passkey required");1565 }1566 if (entry.passkey) {1567 const p = entry.passkey;1568 if (1569 !p.rpId ||1570 !p.credentialId ||1571 !p.publicKeyCose ||1572 !p.userHandle ||1573 !p.userName ||1574 (p.algorithm !== -7 && p.algorithm !== -8)1575 ) {1576 throw new Error("invalid passkey credential");1577 }1578 }1579 if (type === "card") {1580 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1581 throw new Error("cardholder name and card number are required");1582 }1583 }15841585 return {1586 type,1587 title,1588 username: type === "login" ? entry.username?.trim() ?? "" : undefined,1589 password: type === "login" ? entry.password : undefined,1590 url: type === "login" ? entry.url?.trim() ?? "" : undefined,1591 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1592 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1593 notes: entry.notes?.trim() || undefined,1594 breach: type === "login" ? entry.breach : undefined,1595 canary: type === "login" ? entry.canary : undefined,1596 tags: entry.tags,1597 passkey: entry.passkey,1598 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1599 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1600 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1601 expYear: type === "card" ? entry.expYear?.trim() : undefined,1602 cvc: type === "card" ? entry.cvc?.trim() : undefined,1603 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1604 network: type === "card" ? entry.network : undefined,1605 last4: type === "card" ? entry.last4?.trim() : undefined,1606 };1607}16081609function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1610 const nextPatch: Partial<VaultEntry> = {};16111612 if ("type" in patch) {1613 nextPatch.type = patch.type;1614 }16151616 if ("title" in patch) {1617 const title = patch.title?.trim() ?? "";1618 if (!title) throw new Error("title is required");1619 nextPatch.title = title;1620 }16211622 if ("url" in patch) {1623 nextPatch.url = patch.url?.trim() ?? "";1624 }16251626 if ("additionalUrls" in patch) {1627 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1628 const seen = new Set<string>();1629 const cleaned: string[] = [];1630 for (const value of incoming) {1631 const trimmed = typeof value === "string" ? value.trim() : "";1632 if (!trimmed) continue;1633 const key = trimmed.toLowerCase();1634 if (seen.has(key)) continue;1635 seen.add(key);1636 cleaned.push(trimmed);1637 }1638 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1639 }16401641 if ("username" in patch) {1642 nextPatch.username = patch.username?.trim() ?? "";1643 }16441645 if ("email" in patch) {1646 nextPatch.email = patch.email?.trim() || undefined;1647 }16481649 if ("password" in patch) {1650 const password = patch.password ?? "";1651 1652 nextPatch.password = password || undefined;1653 }16541655 if ("totp" in patch) {1656 nextPatch.totp = patch.totp?.trim() || undefined;1657 }16581659 if ("notes" in patch) {1660 nextPatch.notes = patch.notes?.trim() || undefined;1661 }16621663 if ("breach" in patch) {1664 nextPatch.breach = patch.breach;1665 }16661667 if ("canary" in patch) {1668 nextPatch.canary = patch.canary;1669 }16701671 if ("passkey" in patch) {1672 1673 1674 1675 nextPatch.passkey = patch.passkey;1676 }16771678 if ("tags" in patch) {1679 nextPatch.tags = patch.tags;1680 }16811682 if ("cardholderName" in patch) {1683 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1684 }1685 if ("cardNumber" in patch) {1686 const n = patch.cardNumber ?? "";1687 nextPatch.cardNumber = n || undefined;1688 }1689 if ("expMonth" in patch) {1690 nextPatch.expMonth = patch.expMonth?.trim() || undefined;1691 }1692 if ("expYear" in patch) {1693 nextPatch.expYear = patch.expYear?.trim() || undefined;1694 }1695 if ("cvc" in patch) {1696 nextPatch.cvc = patch.cvc?.trim() || undefined;1697 }1698 if ("cardNickname" in patch) {1699 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1700 }1701 if ("network" in patch) {1702 nextPatch.network = patch.network;1703 }1704 if ("last4" in patch) {1705 nextPatch.last4 = patch.last4?.trim() || undefined;1706 }17071708 return nextPatch;1709}17101711function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1712 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1713 const modifiedAt =1714 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;17151716 const base: VaultEntry = {1717 id: entry.id,1718 type: "type" in entry ? entry.type : "login",1719 title: entry.title,1720 username: entry.username,1721 password: entry.password,1722 url: entry.url,1723 totp: entry.totp,1724 notes: entry.notes,1725 breach: entry.breach,1726 canary: "canary" in entry ? entry.canary : undefined,1727 passkey: "passkey" in entry ? entry.passkey : undefined,1728 createdAt,1729 modifiedAt,1730 deletedAt: entry.deletedAt,1731 tags: "tags" in entry ? entry.tags : undefined,1732 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1733 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1734 expMonth: "expMonth" in entry ? entry.expMonth : undefined,1735 expYear: "expYear" in entry ? entry.expYear : undefined,1736 cvc: "cvc" in entry ? entry.cvc : undefined,1737 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1738 network: "network" in entry ? entry.network : undefined,1739 last4: "last4" in entry ? entry.last4 : undefined,1740 };17411742 return migrateLegacyPasskey(base, entry);1743}174417451746174717481749175017511752function migrateLegacyPasskey(1753 base: VaultEntry,1754 raw: VaultEntry | LegacyVaultEntry1755): VaultEntry {1756 if (base.passkey) return base;1757 const flat = raw as Partial<VaultEntry>;1758 const isPasskeyType = base.type === "passkey";1759 const hasFlatPasskeyFields =1760 typeof flat.rpId === "string" &&1761 typeof flat.credentialId === "string" &&1762 typeof flat.publicKeyCose === "string" &&1763 typeof flat.userHandle === "string" &&1764 typeof flat.userName === "string";1765 if (!isPasskeyType && !hasFlatPasskeyFields) return base;1766 if (!hasFlatPasskeyFields) return base;17671768 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;1769 base.passkey = {1770 rpId: flat.rpId!,1771 rpName: flat.rpName ?? flat.rpId!,1772 credentialId: flat.credentialId!,1773 publicKeyCose: flat.publicKeyCose!,1774 privateKeyJwk: flat.privateKeyJwk,1775 userHandle: flat.userHandle!,1776 userName: flat.userName!,1777 userDisplayName: flat.userDisplayName ?? flat.userName!,1778 algorithm,1779 signCount: flat.signCount ?? 0,1780 aaguid: flat.aaguid ?? "",1781 backupEligible: flat.backupEligible === true,1782 backupState: flat.backupState === true,1783 lastUsedAt: flat.lastUsedAt,1784 browser: flat.browser,1785 transports: flat.transports,1786 createdAt: base.createdAt,1787 };1788 1789 1790 1791 if (isPasskeyType) base.type = "login";1792 if (!base.username) base.username = flat.userName;1793 if (!base.url) base.url = `https://${flat.rpId}`;1794 return base;1795}1796179717981799180018011802function entryDupeKey(e: VaultEntry): string {1803 if (e.type === "note") return `note|${e.title.toLowerCase()}`;1804 1805 1806 1807 1808 if (e.passkey) {1809 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;1810 }1811 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1812}181318141815export function countDuplicates(): number {1816 const best = new Map<string, VaultEntry>();1817 let dupeCount = 0;1818 for (const e of entries.filter((e) => !e.deletedAt)) {1819 const key = entryDupeKey(e);1820 const existing = best.get(key);1821 if (!existing) {1822 best.set(key, e);1823 } else {1824 dupeCount++;1825 1826 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1827 best.set(key, e);1828 }1829 }1830 }1831 return dupeCount;1832}183318341835183618371838export async function deduplicateEntries(): Promise<number> {1839 if (!derivedKey) throw new Error("vault locked");1840 1841 1842 if (vaultMode === "decoy") {1843 throw new Error("deduplicate disabled in decoy mode");1844 }18451846 const best = new Map<string, VaultEntry>();1847 for (const e of entries.filter((en) => !en.deletedAt)) {1848 const key = entryDupeKey(e);1849 const existing = best.get(key);1850 if (!existing) {1851 best.set(key, e);1852 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1853 best.set(key, e);1854 }1855 }18561857 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1858 const now = Date.now();1859 let removed = 0;1860 entries = entries.map((e) => {1861 if (!e.deletedAt && !keepIds.has(e.id)) {1862 removed++;1863 return { ...e, deletedAt: now };1864 }1865 return e;1866 });18671868 if (removed > 0) await persistEntries();1869 return removed;1870}187118721873187418751876187718781879const HISTORY_KEY = "peach_vault_history";1880const HISTORY_MAX = 10;18811882188318841885188618871888export async function takeVaultSnapshot(1889 reason: VaultSnapshot["reason"],1890): Promise<void> {1891 try {1892 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);1893 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];1894 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];18951896 1897 1898 1899 if (!slotA) return;19001901 const vault_a = JSON.stringify(slotA);1902 const vault_b = JSON.stringify(slotB ?? slotA);19031904 1905 1906 1907 let entryCount: number | null = null;1908 if (derivedKey && vaultMode === "real") {1909 try {1910 entryCount = entries.filter((e) => !e.deletedAt).length;1911 } catch {1912 entryCount = null;1913 }1914 }19151916 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];1917 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)1918 ? existingRaw1919 : { snapshots: [] };19201921 1922 1923 1924 const latest = history.snapshots[0];1925 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {1926 return;1927 }19281929 const snapshot: VaultSnapshot = {1930 id: crypto.randomUUID(),1931 savedAt: Date.now(),1932 reason,1933 vault_a,1934 vault_b,1935 entryCount,1936 };19371938 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);1939 await chrome.storage.local.set({ [HISTORY_KEY]: history });1940 } catch {1941 1942 1943 }1944}194519461947export async function clearVaultHistory(): Promise<void> {1948 try {1949 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });1950 } catch {1951 1952 }1953}195419551956export async function getVaultHistory(): Promise<VaultSnapshot[]> {1957 const raw = await chrome.storage.local.get(HISTORY_KEY);1958 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];1959 if (!history || !Array.isArray(history.snapshots)) return [];1960 return history.snapshots;1961}19621963196419651966196719681969export async function restoreVaultSnapshot(1970 snapshotId: string,1971): Promise<{ ok: true } | { ok: false; error: string }> {1972 const history = await getVaultHistory();1973 const snapshot = history.find((s) => s.id === snapshotId);1974 if (!snapshot) return { ok: false, error: "snapshot_not_found" };19751976 1977 1978 1979 await takeVaultSnapshot("restore");19801981 let restoredA: StoredVault;1982 let restoredB: StoredVault;1983 try {1984 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;1985 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;1986 } catch {1987 return { ok: false, error: "snapshot_corrupt" };1988 }19891990 await writeSlot(SLOT_A, restoredA);1991 await writeSlot(SLOT_B, restoredB);1992 await stampLocalVaultMutation();19931994 1995 1996 1997 lock();19981999 return { ok: true };2000}20012002200320042005200620072008export async function changePassword(2009 currentPassword: string,2010 newPassword: string2011): Promise<boolean> {2012 if (!derivedKey || vaultMode !== "real") return false;20132014 2015 const valid = await verifyMainPassword(currentPassword);2016 if (!valid) return false;20172018 const slotA = await loadSlot(SLOT_A);2019 if (!slotA) return false;20202021 2022 const newSalt = generateSalt();2023 const newKey = await deriveKey(newPassword, newSalt);20242025 2026 const plaintext = encodePayload(entries, duressConfigured);2027 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);20282029 const freshSlotA: StoredVault = {2030 ...slotA,2031 salt: hex(newSalt),2032 nonce,2033 ciphertext,2034 tag,2035 };2036 2037 2038 2039 await takeVaultSnapshot("local_save");2040 await writeSlot(SLOT_A, freshSlotA);20412042 2043 2044 2045 zeroKey(derivedKey);2046 derivedKey = newKey;20472048 2049 2050 const {2051 rawRootKey: freshRawKey,2052 recoverySalt: freshSalt,2053 wrappedRootKey: freshWrapped,2054 wrappedNonce: freshNonce,2055 wrappedTag: freshTag,2056 } = await generateAndWrapRootKey();2057 rootKey = unhex(freshRawKey);2058 const slotAfterRekey = await loadSlot(SLOT_A);2059 if (slotAfterRekey) {2060 await writeSlot(SLOT_A, {2061 ...slotAfterRekey,2062 recovery_salt: freshSalt,2063 recovery_wrapped_root_key: freshWrapped,2064 recovery_nonce: freshNonce,2065 recovery_tag: freshTag,2066 });2067 }20682069 2070 2071 2072 if (!duressConfigured) {2073 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2074 }2075 await stampLocalVaultMutation();20762077 return true;2078}207920802081208220832084export async function getVaultBlobForSync(): Promise<{2085 blob: string;2086 timestamp: number;2087 license?: string;2088} | null> {2089 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, 'peach_last_modified_at', 'peach_license']);2090 const slotA = (raw as Record<string, unknown>)[SLOT_A];2091 if (!slotA) return null;2092 const slotB = (raw as Record<string, unknown>)[SLOT_B];2093 const blob = JSON.stringify({ a: slotA, b: slotB });2094 const rawTs = (raw as Record<string, unknown>)['peach_last_modified_at'];2095 const timestamp = typeof rawTs === 'string' ? parseInt(rawTs) : 0;2096 const license = (raw as Record<string, unknown>)['peach_license'];2097 return {2098 blob,2099 timestamp,2100 license: typeof license === 'string' ? license : undefined,2101 };2102}21032104export async function applyIncomingVault(2105 blobJson: string,2106 remoteTimestamp: number,2107 remoteLicense?: string,2108): Promise<boolean> {2109 const raw = await chrome.storage.local.get('peach_last_modified_at');2110 const rawTs = (raw as Record<string, unknown>)['peach_last_modified_at'];2111 const localTimestamp = typeof rawTs === 'string' ? parseInt(rawTs) : 0;21122113 if (remoteLicense) {2114 const licenseRaw = await chrome.storage.local.get('peach_license');2115 const existing = (licenseRaw as Record<string, unknown>)['peach_license'];2116 if (!existing) {2117 await chrome.storage.local.set({ peach_license: remoteLicense });2118 }2119 }21202121 if (remoteTimestamp <= localTimestamp) return false;21222123 2124 await takeVaultSnapshot('sync_received').catch(() => {});21252126 const { a, b } = JSON.parse(blobJson) as { a: unknown; b?: unknown };2127 await writeSlot(SLOT_A, a as StoredVault);2128 if (b) await writeSlot(SLOT_B, b as StoredVault);2129 await chrome.storage.local.set({2130 peach_last_sync_at: String(Date.now()),2131 peach_last_modified_at: String(remoteTimestamp),2132 });21332134 return true;2135}21362137export async function getSyncIdentity(): Promise<{2138 privateKeyBytes: Uint8Array;2139 publicKeyBytes: Uint8Array;2140 publicKeyHex: string;2141}> {2142 const raw = await chrome.storage.local.get('peach_sync_identity');2143 const stored = (raw as Record<string, unknown>)['peach_sync_identity'];2144 if (typeof stored === 'string') {2145 const { priv, pub } = JSON.parse(stored) as { priv: string; pub: string };2146 const privateKeyBytes = base64DecodeBytes(priv);2147 const publicKeyBytes = base64DecodeBytes(pub);2148 return { privateKeyBytes, publicKeyBytes, publicKeyHex: bytesToHex(publicKeyBytes) };2149 }21502151 2152 const { generateKeyPair } = await import('@stablelib/x25519');2153 const keypair = generateKeyPair();2154 await chrome.storage.local.set({2155 peach_sync_identity: JSON.stringify({2156 priv: base64EncodeBytes(keypair.secretKey),2157 pub: base64EncodeBytes(keypair.publicKey),2158 }),2159 });2160 return {2161 privateKeyBytes: keypair.secretKey,2162 publicKeyBytes: keypair.publicKey,2163 publicKeyHex: bytesToHex(keypair.publicKey),2164 };2165}21662167216821692170export async function reloadEntriesAfterSync(): Promise<boolean> {2171 if (!derivedKey) return false;2172 const slotA = await loadSlot(SLOT_A);2173 if (!slotA) return false;2174 try {2175 const payload = await tryDecryptSlot(slotA, derivedKey);2176 if (!payload) return false;2177 entries = payload.entries;2178 buildDomainIndexSync(entries);2179 return true;2180 } catch {2181 return false;2182 }2183}21842185function bytesToHex(bytes: Uint8Array): string {2186 return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');2187}21882189function base64EncodeBytes(bytes: Uint8Array): string {2190 let binary = '';2191 for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);2192 return btoa(binary);2193}21942195function base64DecodeBytes(b64: string): Uint8Array {2196 const binary = atob(b64);2197 const bytes = new Uint8Array(binary.length);2198 for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);2199 return bytes;2200}