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}371372373374375376377export { deriveKey, generateSalt };378379export async function loadSlotA(): Promise<StoredVault | null> {380 return loadSlot(SLOT_A);381}382383384385386387388389390391392393394395396397398399400async function deriveRootKeyFromDerivedKey(401 _derivedKey: Uint8Array,402 _recoverySaltHex: string403): Promise<Uint8Array | null> {404 405 406 407 408 409 410 411 return null;412}413414415416417418419420421export async function migrateToRecovery(): Promise<void> {422 if (!derivedKey || vaultMode !== "real") {423 throw new Error("vault must be unlocked in real mode");424 }425 const slotA = await loadSlot(SLOT_A);426 if (!slotA) throw new Error("missing slot A");427 if (slotA.recovery_salt && slotA.recovery_wrapped_root_key) return; 428429 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =430 await generateAndWrapRootKey();431432 rootKey = unhex(rawRootKey);433434 const nextSlotA: StoredVault = {435 ...slotA,436 recovery_salt: recoverySalt,437 recovery_wrapped_root_key: wrappedRootKey,438 recovery_nonce: wrappedNonce,439 recovery_tag: wrappedTag,440 };441 await writeSlot(SLOT_A, nextSlotA);442}443444445446447448449export async function getRecoveryWords(): Promise<string[]> {450 if (!rootKey) {451 throw new Error("recovery not set up — vault may need migration");452 }453 return rootKeyToWords(rootKey);454}455456async function loadSlot(key: string): Promise<StoredVault | null> {457 const result = await chrome.storage.local.get(key);458 return (result as Record<string, StoredVault | undefined>)[key] ?? null;459}460461async function writeSlot(key: string, vault: StoredVault): Promise<void> {462 try {463 await chrome.storage.local.set({ [key]: vault });464 } catch (err) {465 throw mapStorageError(err);466 }467}468469470471472473474async function ensureMigrated(): Promise<void> {475 if (migrationRan) return;476 migrationRan = true;477478 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];479 const raw = await chrome.storage.local.get(keys);480 const slotAExists = raw[SLOT_A] !== undefined;481 const slotBExists = raw[SLOT_B] !== undefined;482 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;483 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;484485 486 if (!slotAExists && !legacyMain) return;487488 489 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;490491 if (legacyMain && !slotAExists) {492 await writeSlot(SLOT_A, legacyMain);493 }494495 496 if (!slotBExists) {497 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;498 if (legacyDecoy) {499 await writeSlot(SLOT_B, legacyDecoy);500 } else if (target) {501 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));502 }503 }504505 const toRemove: string[] = [];506 if (legacyMain) toRemove.push(LEGACY_MAIN);507 if (legacyDecoy) toRemove.push(LEGACY_DECOY);508 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);509 if (toRemove.length > 0) {510 await chrome.storage.local.remove(toRemove);511 }512}513514515516517518519520521522523524function padPlaintext(json: string): string {525 const encoded = new TextEncoder().encode(json);526 const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);527 const padLen = target - encoded.length;528 return json + " ".repeat(padLen);529}530531532533534535536537538539540541const CSPRNG_CHUNK = 65_536;542543function fillRandom(buf: Uint8Array): Uint8Array {544 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {545 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);546 const chunk = new Uint8Array(end - offset);547 crypto.getRandomValues(chunk);548 buf.set(chunk, offset);549 }550 return buf;551}552553function generateNoiseBlob(ciphertextBytes: number): StoredVault {554 const salt = crypto.getRandomValues(new Uint8Array(16));555 const nonce = crypto.getRandomValues(new Uint8Array(12));556 const cipher = fillRandom(new Uint8Array(ciphertextBytes));557 const tag = crypto.getRandomValues(new Uint8Array(16));558 return {559 version: 1,560 salt: hex(salt),561 nonce: hex(nonce),562 ciphertext: hex(cipher),563 tag: hex(tag),564 };565}566567568569570571type VaultPayload = {572 entries: VaultEntry[];573 duressConfigured?: boolean;574};575576type DecryptedPayload = {577 entries: VaultEntry[];578 duressConfigured: boolean;579 580581582583584585586 migratedFromLegacy: boolean;587};588589function encodePayload(entries: VaultEntry[], duress: boolean): string {590 const envelope: VaultPayload = { entries, duressConfigured: duress };591 return padPlaintext(JSON.stringify(envelope));592}593594function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {595 if ("created" in raw || "updated" in raw) return true;596 const flat = raw as Partial<VaultEntry>;597 if (flat.type === "passkey") return true;598 599 if (flat.rpId !== undefined && flat.passkey === undefined) return true;600 return false;601}602603function decodePayload(plain: string): DecryptedPayload {604 const parsed = JSON.parse(plain) as605 | Array<VaultEntry | LegacyVaultEntry>606 | VaultPayload;607608 const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];609 const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);610611 const normalized = rawEntries.map(normalizeEntry);612 const { merged } = mergePasskeyOnlyIntoLogins(normalized);613614 if (Array.isArray(parsed)) {615 return {616 entries: normalized,617 duressConfigured: false,618 migratedFromLegacy: true, 619 };620 }621622 return {623 entries: normalized,624 duressConfigured: parsed.duressConfigured === true,625 migratedFromLegacy: flatMigrationNeeded || merged,626 };627}628629630631632633634635636637638639640641642643644645646647648649650651652function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {653 let merged = false;654 const now = Date.now();655656 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);657 const byRpId = new Map<string, VaultEntry[]>();658 for (const e of candidates) {659 const rpId = e.passkey!.rpId;660 const list = byRpId.get(rpId);661 if (list) list.push(e);662 else byRpId.set(rpId, [e]);663 }664665 for (const [rpId, group] of byRpId) {666 if (group.length === 0) continue;667 group.sort((a, b) => {668 const aT =669 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;670 const bT =671 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;672 return bT - aT;673 });674 const winner = group[0]!;675676 677 678 679 const loginTarget = entries.find((e) => {680 if (e.deletedAt || e.passkey || e.type !== "login") return false;681 if (e.url && extractDomain(e.url) === rpId) return true;682 for (const extra of e.additionalUrls ?? []) {683 if (extractDomain(extra) === rpId) return true;684 }685 return false;686 });687688 if (loginTarget) {689 loginTarget.passkey = winner.passkey;690 loginTarget.modifiedAt = now;691 for (const dup of group) {692 if (!dup.deletedAt) {693 dup.deletedAt = now;694 dup.modifiedAt = now;695 }696 }697 merged = true;698 } else if (group.length > 1) {699 for (let i = 1; i < group.length; i += 1) {700 const dup = group[i]!;701 if (!dup.deletedAt) {702 dup.deletedAt = now;703 dup.modifiedAt = now;704 }705 }706 merged = true;707 }708 }709710 return { merged };711}712713714715716717718export async function tryDecryptSlot(719 vault: StoredVault,720 key: Uint8Array721): Promise<DecryptedPayload | null> {722 try {723 const plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);724 return decodePayload(plain);725 } catch {726 return null;727 }728}729730731732733734function buildDomainIndexSync(all: VaultEntry[]): void {735 domainIndex = new Map();736737 for (const entry of all) {738 if (entry.deletedAt) continue;739 if (entry.type === "note" || entry.type === "card") continue;740741 742 743 744 745 const domains = new Set<string>();746 const primary = extractDomain(entry.url ?? "");747 if (primary) domains.add(primary);748 for (const extra of entry.additionalUrls ?? []) {749 const d = extractDomain(extra);750 if (d) domains.add(d);751 }752 if (domains.size === 0) continue;753754 for (const domain of domains) {755 const bucket = domainIndex.get(domain) ?? [];756 bucket.push(entry);757 domainIndex.set(domain, bucket);758 }759 }760}761762export async function createVault(password: string): Promise<void> {763 await ensureMigrated();764765 const salt = generateSalt();766 derivedKey = await deriveKey(password, salt);767 entries = [];768 vaultMode = "real";769 duressConfigured = false;770 buildDomainIndexSync(entries);771772 773 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =774 await generateAndWrapRootKey();775 rootKey = unhex(rawRootKey);776777 const plaintext = encodePayload(entries, duressConfigured);778 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);779780 const slotA: StoredVault = {781 version: 1,782 salt: hex(salt),783 nonce,784 ciphertext,785 tag,786 recovery_salt: recoverySalt,787 recovery_wrapped_root_key: wrappedRootKey,788 recovery_nonce: wrappedNonce,789 recovery_tag: wrappedTag,790 };791 await writeSlot(SLOT_A, slotA);792793 794 795 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));796797 touchAutoLock();798}799800export async function hasVault(): Promise<boolean> {801 await ensureMigrated();802 return (await loadSlot(SLOT_A)) !== null;803}804805806807808809810export async function isDuressEnabled(): Promise<boolean> {811 return vaultMode === "real" && duressConfigured;812}813814export async function setupDuress(duressPassword: string): Promise<void> {815 if (!derivedKey || vaultMode !== "real") {816 throw new Error("vault must be unlocked in real mode");817 }818 await ensureMigrated();819820 821 822 823 const slotA = await loadSlot(SLOT_A);824 if (!slotA) throw new Error("missing slot A");825826 const decoySalt = generateSalt();827 const decoyKey = await deriveKey(duressPassword, decoySalt);828 try {829 830 831 const targetBytes = slotA.ciphertext.length / 2;832 let plaintext = encodePayload([], false);833 const encoded = new TextEncoder().encode(plaintext);834 if (encoded.length < targetBytes) {835 plaintext = plaintext + " ".repeat(targetBytes - encoded.length);836 }837 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);838 await writeSlot(SLOT_B, {839 version: 1,840 salt: hex(decoySalt),841 nonce,842 ciphertext,843 tag,844 });845 } finally {846 zeroKey(decoyKey);847 }848849 850 851 duressConfigured = true;852 await persistSlotA();853}854855export async function disableDuress(): Promise<void> {856 if (!derivedKey || vaultMode !== "real") {857 throw new Error("vault must be unlocked in real mode");858 }859 await ensureMigrated();860861 862 863 864 const slotA = await loadSlot(SLOT_A);865 if (slotA) {866 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));867 }868869 duressConfigured = false;870 await persistSlotA();871}872873export async function verifyMainPassword(password: string): Promise<boolean> {874 await ensureMigrated();875 const slotA = await loadSlot(SLOT_A);876 if (!slotA) return false;877 const key = await deriveKey(password, unhex(slotA.salt));878 try {879 return (await tryDecryptSlot(slotA, key)) !== null;880 } finally {881 zeroKey(key);882 }883}884885export async function unlock(password: string): Promise<boolean> {886 await ensureMigrated();887888 889 890 891 892 893 const [slotA, slotB] = await Promise.all([894 loadSlot(SLOT_A),895 loadSlot(SLOT_B),896 ]);897898 899 const [keyA, keyB] = await Promise.all([900 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),901 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),902 ]);903904 905 const [payloadA, payloadB] = await Promise.all([906 keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),907 keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),908 ]);909910 if (payloadA && keyA) {911 derivedKey = keyA;912 vaultMode = "real";913 entries = payloadA.entries;914 duressConfigured = payloadA.duressConfigured;915 buildDomainIndexSync(entries);916 await loadSettings();917 touchAutoLock();918 if (keyB) zeroKey(keyB);919920 921 if (slotA) {922 const needsRecoverySetup =923 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;924 if (!needsRecoverySetup) {925 rootKey = await deriveRootKeyFromDerivedKey(926 keyA,927 slotA.recovery_salt!928 );929 }930 }931932 933 934 935 if (payloadA.migratedFromLegacy) {936 await persistSlotA();937 }938 return true;939 }940941 if (payloadB && keyB) {942 derivedKey = keyB;943 vaultMode = "decoy";944 entries = payloadB.entries;945 duressConfigured = false;946 buildDomainIndexSync(entries);947 await loadSettings();948 touchAutoLock();949 if (keyA) zeroKey(keyA);950 return true;951 }952953 if (keyA) zeroKey(keyA);954 if (keyB) zeroKey(keyB);955 return false;956}957958export function lock(): void {959 if (derivedKey) {960 zeroKey(derivedKey);961 derivedKey = null;962 }963 if (rootKey) {964 rootKey.fill(0);965 rootKey = null;966 }967968 entries = [];969 domainIndex = new Map();970 vaultMode = "real";971 duressConfigured = false;972 clearAutoLock();973}974975export function isUnlocked(): boolean {976 return derivedKey !== null;977}978979980981export async function unlockWithKey(982 masterKeyHex: string,983): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {984 try {985 const masterKeyBytes = unhex(masterKeyHex);986 const stored = await loadSlot(SLOT_A);987 if (!stored) return { ok: false, error: "invalid_key" };988 const payload = await tryDecryptSlot(stored, masterKeyBytes);989 if (!payload) return { ok: false, error: "invalid_key" };990991 derivedKey = masterKeyBytes;992 vaultMode = "real";993 entries = payload.entries;994 duressConfigured = payload.duressConfigured;995 buildDomainIndexSync(entries);996 await loadSettings();997 touchAutoLock();998 return { ok: true, entries };999 } catch {1000 return { ok: false, error: "invalid_key" };1001 }1002}10031004100510061007100810091010101110121013101410151016101710181019102010211022export async function unlockFromCodex(params: {1023 slotA: string; 1024 slotB: string | null; 1025 timestamp: number;1026 masterPassphrase: string;1027}): Promise<1028 | { ok: true; entryCount: number }1029 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1030> {1031 let parsedSlotA: StoredVault;1032 let parsedSlotB: StoredVault | null = null;1033 try {1034 parsedSlotA = JSON.parse(params.slotA) as StoredVault;1035 if (params.slotB !== null) {1036 parsedSlotB = JSON.parse(params.slotB) as StoredVault;1037 }1038 1039 1040 1041 if (1042 typeof parsedSlotA?.salt !== "string" ||1043 typeof parsedSlotA?.nonce !== "string" ||1044 typeof parsedSlotA?.ciphertext !== "string" ||1045 typeof parsedSlotA?.tag !== "string"1046 ) {1047 return { ok: false, reason: "invalid_slot_format" };1048 }1049 } catch {1050 return { ok: false, reason: "invalid_slot_format" };1051 }10521053 1054 1055 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1056 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey);1057 if (!trialPayload) {1058 zeroKey(trialKey);1059 return { ok: false, reason: "wrong_passphrase" };1060 }1061 1062 1063 zeroKey(trialKey);10641065 1066 1067 await takeVaultSnapshot("restore");10681069 await writeSlot(SLOT_A, parsedSlotA);1070 if (parsedSlotB) {1071 await writeSlot(SLOT_B, parsedSlotB);1072 } else {1073 1074 1075 1076 await writeSlot(1077 SLOT_B,1078 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1079 );1080 }1081 await chrome.storage.local.set({1082 peach_last_modified_at: String(params.timestamp),1083 });10841085 1086 1087 const ok = await unlock(params.masterPassphrase);1088 if (!ok) {1089 1090 1091 1092 return { ok: false, reason: "wrong_passphrase" };1093 }1094 return { ok: true, entryCount: getEntries().length };1095}1096109710981099export function getDerivedKeyHex(): string {1100 if (!derivedKey) throw new Error("vault_not_unlocked");1101 return hex(derivedKey);1102}11031104export function getEntries(): VaultEntry[] {1105 if (!derivedKey) throw new Error("vault locked");1106 return entries.filter((entry) => !entry.deletedAt);1107}11081109const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;11101111export function getIdentities(): SavedIdentity[] {1112 if (!derivedKey) throw new Error("vault locked");1113 const active = entries.filter((e) => !e.deletedAt && e.type === "login");11141115 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1116 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();11171118 for (const entry of active) {1119 const value = entry.username?.trim();1120 if (!value) continue;1121 const map = EMAIL_RE.test(value) ? emailMap : usernameMap;1122 const existing = map.get(value);1123 const domain = extractDomain(entry.url ?? "") ?? "";1124 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;1125 if (!existing || modifiedAt > existing.lastUsed) {1126 map.set(value, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1127 } else {1128 map.set(value, { ...existing, count: existing.count + 1 });1129 }1130 }11311132 const toIdentity = (type: "email" | "username") =>1133 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1134 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1135 });11361137 return [1138 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1139 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1140 ];1141}11421143export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1144 if (!derivedKey) throw new Error("vault locked");11451146 const now = Date.now();1147 const added: VaultEntry[] = [];1148 for (const draft of drafts) {1149 try {1150 const sanitized = sanitizeEntryDraft(draft);1151 const nextEntry: VaultEntry = {1152 ...sanitized,1153 id: crypto.randomUUID(),1154 createdAt: now,1155 modifiedAt: now,1156 };1157 entries.push(nextEntry);1158 added.push(nextEntry);1159 } catch {1160 1161 }1162 }11631164 if (added.length > 0) {1165 await persistEntries();1166 }11671168 return added;1169}117011711172117311741175117611771178export async function restoreEntries(1179 incoming: VaultEntry[],1180 mode: "replace" | "merge"1181): Promise<{ added: number; updated: number; skipped: number }> {1182 if (!derivedKey) throw new Error("vault locked");11831184 1185 1186 1187 if (mode === "replace" && incoming.length === 0) {1188 throw new Error("refuse to replace with empty entry set");1189 }11901191 let added = 0;1192 let updated = 0;1193 let skipped = 0;11941195 if (mode === "replace") {1196 entries = [];1197 for (const entry of incoming) {1198 const normalized = sanitizeRestoredEntry(entry);1199 if (!normalized) {1200 skipped += 1;1201 continue;1202 }1203 entries.push(normalized);1204 added += 1;1205 }1206 } else {1207 const byId = new Map<string, VaultEntry>();1208 for (const e of entries) byId.set(e.id, e);12091210 for (const entry of incoming) {1211 const normalized = sanitizeRestoredEntry(entry);1212 if (!normalized) {1213 skipped += 1;1214 continue;1215 }1216 const existing = byId.get(normalized.id);1217 if (!existing) {1218 entries.push(normalized);1219 added += 1;1220 } else if (normalized.modifiedAt > existing.modifiedAt) {1221 const idx = entries.findIndex((e) => e.id === existing.id);1222 if (idx !== -1) {1223 entries[idx] = normalized;1224 updated += 1;1225 }1226 } else {1227 skipped += 1;1228 }1229 }1230 }12311232 await persistEntries("import");1233 return { added, updated, skipped };1234}1235123612371238123912401241function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1242 if (!entry || typeof entry !== "object") return null;1243 const title = typeof entry.title === "string" ? entry.title.trim() : "";1244 if (!title) return null;1245 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1246 const now = Date.now();1247 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1248 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;12491250 1251 1252 1253 const migrated: VaultEntry = migrateLegacyPasskey(1254 {1255 ...entry,1256 id,1257 title,1258 type: entry.type ?? "login",1259 createdAt,1260 modifiedAt,1261 },1262 entry1263 );12641265 1266 1267 if (1268 migrated.type === "login" &&1269 !migrated.passkey &&1270 typeof migrated.password !== "string"1271 ) {1272 return null;1273 }1274 if (migrated.passkey && !migrated.passkey.credentialId) {1275 return null;1276 }12771278 return migrated;1279}128012811282128312841285export async function clearVaultEntries(1286 confirmation: "REPLACE_ALL"1287): Promise<void> {1288 if (confirmation !== "REPLACE_ALL") {1289 throw new Error("clearVaultEntries requires explicit confirmation");1290 }1291 if (!derivedKey) throw new Error("vault locked");1292 for (const entry of entries) {1293 if (!entry.deletedAt) {1294 entry.deletedAt = Date.now();1295 entry.modifiedAt = Date.now();1296 }1297 }1298 await persistEntries();1299}13001301export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1302 if (!derivedKey) throw new Error("vault locked");13031304 const sanitized = sanitizeEntryDraft(entry);1305 const now = Date.now();1306 const nextEntry: VaultEntry = {1307 ...sanitized,1308 id: crypto.randomUUID(),1309 createdAt: now,1310 modifiedAt: now,1311 1312 1313 passwordChangedAt: sanitized.password ? now : undefined,1314 };13151316 entries.push(nextEntry);1317 await persistEntries();13181319 1320 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1321 checkBreach(nextEntry.password).then(async (count) => {1322 if (count > 0) {1323 const idx = entries.findIndex((e) => e.id === nextEntry.id);1324 if (idx !== -1) {1325 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };1326 await persistEntries();1327 }1328 }1329 });1330 }13311332 return nextEntry;1333}13341335export async function updateEntry(1336 id: string,1337 patch: Partial<VaultEntry>1338): Promise<void> {1339 if (!derivedKey) throw new Error("vault locked");13401341 const idx = entries.findIndex((entry) => entry.id === id);1342 if (idx === -1 || entries[idx].deletedAt) {1343 throw new Error("entry not found");1344 }13451346 const current = entries[idx];1347 const sanitizedPatch = sanitizeEntryPatch(patch);1348 1349 1350 1351 const passwordChanged =1352 "password" in patch &&1353 typeof sanitizedPatch.password === "string" &&1354 sanitizedPatch.password !== current.password;1355 entries[idx] = {1356 ...current,1357 ...sanitizedPatch,1358 modifiedAt: Date.now(),1359 ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),1360 };13611362 1363 1364 1365 1366 if (1367 entries[idx].type === "login" &&1368 !entries[idx].password &&1369 !entries[idx].passkey1370 ) {1371 entries[idx] = current;1372 throw new Error("password or passkey required");1373 }13741375 1376 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1377 entries[idx] = current;1378 throw new Error("card number is required for card entries");1379 }13801381 await persistEntries();13821383 1384 1385 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1386 entries[idx] = { ...entries[idx], breach: undefined };1387 await persistEntries();1388 const password = entries[idx].password;1389 if (password) {1390 checkBreach(password).then(async (count) => {1391 const i = entries.findIndex((e) => e.id === id);1392 if (i !== -1 && count > 0) {1393 entries[i] = { ...entries[i], breach: count, modifiedAt: Date.now() };1394 await persistEntries();1395 }1396 });1397 }1398 }1399}14001401export async function deleteEntry(id: string): Promise<void> {1402 if (!derivedKey) throw new Error("vault locked");14031404 const entry = entries.find((item) => item.id === id);1405 if (!entry || entry.deletedAt) {1406 throw new Error("entry not found");1407 }14081409 entry.deletedAt = Date.now();1410 entry.modifiedAt = Date.now();1411 await persistEntries();1412}14131414export function getDeletedEntries(): VaultEntry[] {1415 if (!derivedKey) return [];1416 return entries.filter((e) => !!e.deletedAt);1417}14181419export async function restoreEntry(id: string): Promise<void> {1420 if (!derivedKey) throw new Error("vault locked");1421 const entry = entries.find((e) => e.id === id && e.deletedAt);1422 if (!entry) throw new Error("deleted entry not found");1423 delete entry.deletedAt;1424 entry.modifiedAt = Date.now();1425 await persistEntries();1426 buildDomainIndexSync(entries);1427}14281429export async function purgeEntry(id: string): Promise<void> {1430 if (!derivedKey) throw new Error("vault locked");1431 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1432 if (idx === -1) throw new Error("deleted entry not found");1433 entries.splice(idx, 1);1434 await persistEntries();1435}143614371438export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1439 if (!derivedKey || retentionMs === 0) return 0;1440 const cutoff = Date.now() - retentionMs;1441 const before = entries.length;1442 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1443 const purged = before - entries.length;1444 if (purged > 0) await persistEntries();1445 return purged;1446}14471448export function queryDomain(domain: string): VaultEntry[] {1449 return domainIndex.get(domain) ?? [];1450}14511452export function getEntryById(id: string): VaultEntry | undefined {1453 return entries.find((entry) => entry.id === id && !entry.deletedAt);1454}14551456async function persistSlotA(1457 reason: VaultSnapshot["reason"] = "local_save",1458): Promise<StoredVault> {1459 if (!derivedKey) throw new Error("vault locked");14601461 const slotA = await loadSlot(SLOT_A);1462 if (!slotA) throw new Error("missing slot A");14631464 1465 1466 await takeVaultSnapshot(reason);14671468 const plaintext = encodePayload(entries, duressConfigured);1469 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);14701471 1472 const nextSlotA: StoredVault = {1473 ...slotA,1474 nonce,1475 ciphertext,1476 tag,1477 1478 };1479 await writeSlot(SLOT_A, nextSlotA);14801481 1482 1483 1484 if (!duressConfigured) {1485 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1486 }14871488 return nextSlotA;1489}14901491async function persistSlotB(): Promise<void> {1492 if (!derivedKey) throw new Error("vault locked");14931494 const slotB = await loadSlot(SLOT_B);1495 if (!slotB) throw new Error("missing slot B");14961497 1498 1499 const plaintext = encodePayload(entries, false);1500 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);15011502 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1503}15041505async function persistEntries(1506 reason: VaultSnapshot["reason"] = "local_save",1507): Promise<void> {1508 if (!derivedKey) throw new Error("vault locked");15091510 if (vaultMode === "decoy") {1511 await persistSlotB();1512 } else {1513 await persistSlotA(reason);1514 }1515 buildDomainIndexSync(entries);1516}15171518function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1519 if (!Array.isArray(values)) return undefined;1520 const seen = new Set<string>();1521 const cleaned: string[] = [];1522 for (const value of values) {1523 const trimmed = typeof value === "string" ? value.trim() : "";1524 if (!trimmed) continue;1525 const key = trimmed.toLowerCase();1526 if (seen.has(key)) continue;1527 seen.add(key);1528 cleaned.push(trimmed);1529 }1530 return cleaned.length > 0 ? cleaned : undefined;1531}15321533function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1534 const title = entry.title.trim();1535 1536 1537 1538 let type: VaultEntry["type"] = entry.type ?? "login";1539 if (type === "passkey") type = "login";15401541 if (!title) throw new Error("title is required");1542 1543 1544 if (type === "login" && !entry.password?.trim() && !entry.passkey) {1545 throw new Error("password or passkey required");1546 }1547 if (entry.passkey) {1548 const p = entry.passkey;1549 if (1550 !p.rpId ||1551 !p.credentialId ||1552 !p.publicKeyCose ||1553 !p.userHandle ||1554 !p.userName ||1555 (p.algorithm !== -7 && p.algorithm !== -8)1556 ) {1557 throw new Error("invalid passkey credential");1558 }1559 }1560 if (type === "card") {1561 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1562 throw new Error("cardholder name and card number are required");1563 }1564 }15651566 return {1567 type,1568 title,1569 username: type === "login" ? entry.username?.trim() ?? "" : undefined,1570 password: type === "login" ? entry.password : undefined,1571 url: type === "login" ? entry.url?.trim() ?? "" : undefined,1572 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1573 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1574 notes: entry.notes?.trim() || undefined,1575 breach: type === "login" ? entry.breach : undefined,1576 canary: type === "login" ? entry.canary : undefined,1577 tags: entry.tags,1578 passkey: entry.passkey,1579 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1580 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1581 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1582 expYear: type === "card" ? entry.expYear?.trim() : undefined,1583 cvc: type === "card" ? entry.cvc?.trim() : undefined,1584 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1585 network: type === "card" ? entry.network : undefined,1586 last4: type === "card" ? entry.last4?.trim() : undefined,1587 };1588}15891590function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1591 const nextPatch: Partial<VaultEntry> = {};15921593 if ("type" in patch) {1594 nextPatch.type = patch.type;1595 }15961597 if ("title" in patch) {1598 const title = patch.title?.trim() ?? "";1599 if (!title) throw new Error("title is required");1600 nextPatch.title = title;1601 }16021603 if ("url" in patch) {1604 nextPatch.url = patch.url?.trim() ?? "";1605 }16061607 if ("additionalUrls" in patch) {1608 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1609 const seen = new Set<string>();1610 const cleaned: string[] = [];1611 for (const value of incoming) {1612 const trimmed = typeof value === "string" ? value.trim() : "";1613 if (!trimmed) continue;1614 const key = trimmed.toLowerCase();1615 if (seen.has(key)) continue;1616 seen.add(key);1617 cleaned.push(trimmed);1618 }1619 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1620 }16211622 if ("username" in patch) {1623 nextPatch.username = patch.username?.trim() ?? "";1624 }16251626 if ("email" in patch) {1627 nextPatch.email = patch.email?.trim() || undefined;1628 }16291630 if ("password" in patch) {1631 const password = patch.password ?? "";1632 1633 nextPatch.password = password || undefined;1634 }16351636 if ("totp" in patch) {1637 nextPatch.totp = patch.totp?.trim() || undefined;1638 }16391640 if ("notes" in patch) {1641 nextPatch.notes = patch.notes?.trim() || undefined;1642 }16431644 if ("breach" in patch) {1645 nextPatch.breach = patch.breach;1646 }16471648 if ("canary" in patch) {1649 nextPatch.canary = patch.canary;1650 }16511652 if ("passkey" in patch) {1653 1654 1655 1656 nextPatch.passkey = patch.passkey;1657 }16581659 if ("tags" in patch) {1660 nextPatch.tags = patch.tags;1661 }16621663 if ("cardholderName" in patch) {1664 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1665 }1666 if ("cardNumber" in patch) {1667 const n = patch.cardNumber ?? "";1668 nextPatch.cardNumber = n || undefined;1669 }1670 if ("expMonth" in patch) {1671 nextPatch.expMonth = patch.expMonth?.trim() || undefined;1672 }1673 if ("expYear" in patch) {1674 nextPatch.expYear = patch.expYear?.trim() || undefined;1675 }1676 if ("cvc" in patch) {1677 nextPatch.cvc = patch.cvc?.trim() || undefined;1678 }1679 if ("cardNickname" in patch) {1680 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1681 }1682 if ("network" in patch) {1683 nextPatch.network = patch.network;1684 }1685 if ("last4" in patch) {1686 nextPatch.last4 = patch.last4?.trim() || undefined;1687 }16881689 return nextPatch;1690}16911692function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1693 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1694 const modifiedAt =1695 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;16961697 const base: VaultEntry = {1698 id: entry.id,1699 type: "type" in entry ? entry.type : "login",1700 title: entry.title,1701 username: entry.username,1702 password: entry.password,1703 url: entry.url,1704 totp: entry.totp,1705 notes: entry.notes,1706 breach: entry.breach,1707 canary: "canary" in entry ? entry.canary : undefined,1708 passkey: "passkey" in entry ? entry.passkey : undefined,1709 createdAt,1710 modifiedAt,1711 deletedAt: entry.deletedAt,1712 tags: "tags" in entry ? entry.tags : undefined,1713 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1714 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1715 expMonth: "expMonth" in entry ? entry.expMonth : undefined,1716 expYear: "expYear" in entry ? entry.expYear : undefined,1717 cvc: "cvc" in entry ? entry.cvc : undefined,1718 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1719 network: "network" in entry ? entry.network : undefined,1720 last4: "last4" in entry ? entry.last4 : undefined,1721 };17221723 return migrateLegacyPasskey(base, entry);1724}172517261727172817291730173117321733function migrateLegacyPasskey(1734 base: VaultEntry,1735 raw: VaultEntry | LegacyVaultEntry1736): VaultEntry {1737 if (base.passkey) return base;1738 const flat = raw as Partial<VaultEntry>;1739 const isPasskeyType = base.type === "passkey";1740 const hasFlatPasskeyFields =1741 typeof flat.rpId === "string" &&1742 typeof flat.credentialId === "string" &&1743 typeof flat.publicKeyCose === "string" &&1744 typeof flat.userHandle === "string" &&1745 typeof flat.userName === "string";1746 if (!isPasskeyType && !hasFlatPasskeyFields) return base;1747 if (!hasFlatPasskeyFields) return base;17481749 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;1750 base.passkey = {1751 rpId: flat.rpId!,1752 rpName: flat.rpName ?? flat.rpId!,1753 credentialId: flat.credentialId!,1754 publicKeyCose: flat.publicKeyCose!,1755 privateKeyJwk: flat.privateKeyJwk,1756 userHandle: flat.userHandle!,1757 userName: flat.userName!,1758 userDisplayName: flat.userDisplayName ?? flat.userName!,1759 algorithm,1760 signCount: flat.signCount ?? 0,1761 aaguid: flat.aaguid ?? "",1762 backupEligible: flat.backupEligible === true,1763 backupState: flat.backupState === true,1764 lastUsedAt: flat.lastUsedAt,1765 browser: flat.browser,1766 transports: flat.transports,1767 createdAt: base.createdAt,1768 };1769 1770 1771 1772 if (isPasskeyType) base.type = "login";1773 if (!base.username) base.username = flat.userName;1774 if (!base.url) base.url = `https://${flat.rpId}`;1775 return base;1776}1777177817791780178117821783function entryDupeKey(e: VaultEntry): string {1784 if (e.type === "note") return `note|${e.title.toLowerCase()}`;1785 1786 1787 1788 1789 if (e.passkey) {1790 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;1791 }1792 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1793}179417951796export function countDuplicates(): number {1797 const best = new Map<string, VaultEntry>();1798 let dupeCount = 0;1799 for (const e of entries.filter((e) => !e.deletedAt)) {1800 const key = entryDupeKey(e);1801 const existing = best.get(key);1802 if (!existing) {1803 best.set(key, e);1804 } else {1805 dupeCount++;1806 1807 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1808 best.set(key, e);1809 }1810 }1811 }1812 return dupeCount;1813}181418151816181718181819export async function deduplicateEntries(): Promise<number> {1820 if (!derivedKey) throw new Error("vault locked");1821 1822 1823 if (vaultMode === "decoy") {1824 throw new Error("deduplicate disabled in decoy mode");1825 }18261827 const best = new Map<string, VaultEntry>();1828 for (const e of entries.filter((en) => !en.deletedAt)) {1829 const key = entryDupeKey(e);1830 const existing = best.get(key);1831 if (!existing) {1832 best.set(key, e);1833 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1834 best.set(key, e);1835 }1836 }18371838 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1839 const now = Date.now();1840 let removed = 0;1841 entries = entries.map((e) => {1842 if (!e.deletedAt && !keepIds.has(e.id)) {1843 removed++;1844 return { ...e, deletedAt: now };1845 }1846 return e;1847 });18481849 if (removed > 0) await persistEntries();1850 return removed;1851}185218531854185518561857185818591860const HISTORY_KEY = "peach_vault_history";1861const HISTORY_MAX = 10;18621863186418651866186718681869export async function takeVaultSnapshot(1870 reason: VaultSnapshot["reason"],1871): Promise<void> {1872 try {1873 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);1874 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];1875 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];18761877 1878 1879 1880 if (!slotA) return;18811882 const vault_a = JSON.stringify(slotA);1883 const vault_b = JSON.stringify(slotB ?? slotA);18841885 1886 1887 1888 let entryCount: number | null = null;1889 if (derivedKey && vaultMode === "real") {1890 try {1891 entryCount = entries.filter((e) => !e.deletedAt).length;1892 } catch {1893 entryCount = null;1894 }1895 }18961897 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];1898 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)1899 ? existingRaw1900 : { snapshots: [] };19011902 1903 1904 1905 const latest = history.snapshots[0];1906 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {1907 return;1908 }19091910 const snapshot: VaultSnapshot = {1911 id: crypto.randomUUID(),1912 savedAt: Date.now(),1913 reason,1914 vault_a,1915 vault_b,1916 entryCount,1917 };19181919 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);1920 await chrome.storage.local.set({ [HISTORY_KEY]: history });1921 } catch {1922 1923 1924 }1925}192619271928export async function clearVaultHistory(): Promise<void> {1929 try {1930 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });1931 } catch {1932 1933 }1934}193519361937export async function getVaultHistory(): Promise<VaultSnapshot[]> {1938 const raw = await chrome.storage.local.get(HISTORY_KEY);1939 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];1940 if (!history || !Array.isArray(history.snapshots)) return [];1941 return history.snapshots;1942}19431944194519461947194819491950export async function restoreVaultSnapshot(1951 snapshotId: string,1952): Promise<{ ok: true } | { ok: false; error: string }> {1953 const history = await getVaultHistory();1954 const snapshot = history.find((s) => s.id === snapshotId);1955 if (!snapshot) return { ok: false, error: "snapshot_not_found" };19561957 1958 1959 1960 await takeVaultSnapshot("restore");19611962 let restoredA: StoredVault;1963 let restoredB: StoredVault;1964 try {1965 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;1966 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;1967 } catch {1968 return { ok: false, error: "snapshot_corrupt" };1969 }19701971 await writeSlot(SLOT_A, restoredA);1972 await writeSlot(SLOT_B, restoredB);19731974 1975 1976 1977 lock();19781979 return { ok: true };1980}19811982198319841985198619871988export async function changePassword(1989 currentPassword: string,1990 newPassword: string1991): Promise<boolean> {1992 if (!derivedKey || vaultMode !== "real") return false;19931994 1995 const valid = await verifyMainPassword(currentPassword);1996 if (!valid) return false;19971998 const slotA = await loadSlot(SLOT_A);1999 if (!slotA) return false;20002001 2002 const newSalt = generateSalt();2003 const newKey = await deriveKey(newPassword, newSalt);20042005 2006 const plaintext = encodePayload(entries, duressConfigured);2007 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);20082009 const freshSlotA: StoredVault = {2010 ...slotA,2011 salt: hex(newSalt),2012 nonce,2013 ciphertext,2014 tag,2015 };2016 2017 2018 2019 await takeVaultSnapshot("local_save");2020 await writeSlot(SLOT_A, freshSlotA);20212022 2023 2024 2025 zeroKey(derivedKey);2026 derivedKey = newKey;20272028 2029 2030 const {2031 rawRootKey: freshRawKey,2032 recoverySalt: freshSalt,2033 wrappedRootKey: freshWrapped,2034 wrappedNonce: freshNonce,2035 wrappedTag: freshTag,2036 } = await generateAndWrapRootKey();2037 rootKey = unhex(freshRawKey);2038 const slotAfterRekey = await loadSlot(SLOT_A);2039 if (slotAfterRekey) {2040 await writeSlot(SLOT_A, {2041 ...slotAfterRekey,2042 recovery_salt: freshSalt,2043 recovery_wrapped_root_key: freshWrapped,2044 recovery_nonce: freshNonce,2045 recovery_tag: freshTag,2046 });2047 }20482049 2050 2051 2052 if (!duressConfigured) {2053 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2054 }20552056 return true;2057}205820592060206120622063export async function getVaultBlobForSync(): Promise<{2064 blob: string;2065 timestamp: number;2066 license?: string;2067} | null> {2068 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, 'peach_last_modified_at', 'peach_license']);2069 const slotA = (raw as Record<string, unknown>)[SLOT_A];2070 if (!slotA) return null;2071 const slotB = (raw as Record<string, unknown>)[SLOT_B];2072 const blob = JSON.stringify({ a: slotA, b: slotB });2073 const rawTs = (raw as Record<string, unknown>)['peach_last_modified_at'];2074 const timestamp = typeof rawTs === 'string' ? parseInt(rawTs) : 0;2075 const license = (raw as Record<string, unknown>)['peach_license'];2076 return {2077 blob,2078 timestamp,2079 license: typeof license === 'string' ? license : undefined,2080 };2081}20822083export async function applyIncomingVault(2084 blobJson: string,2085 remoteTimestamp: number,2086 remoteLicense?: string,2087): Promise<boolean> {2088 const raw = await chrome.storage.local.get('peach_last_modified_at');2089 const rawTs = (raw as Record<string, unknown>)['peach_last_modified_at'];2090 const localTimestamp = typeof rawTs === 'string' ? parseInt(rawTs) : 0;20912092 if (remoteLicense) {2093 const licenseRaw = await chrome.storage.local.get('peach_license');2094 const existing = (licenseRaw as Record<string, unknown>)['peach_license'];2095 if (!existing) {2096 await chrome.storage.local.set({ peach_license: remoteLicense });2097 }2098 }20992100 if (remoteTimestamp <= localTimestamp) return false;21012102 2103 await takeVaultSnapshot('sync_received').catch(() => {});21042105 const { a, b } = JSON.parse(blobJson) as { a: unknown; b?: unknown };2106 await writeSlot(SLOT_A, a as StoredVault);2107 if (b) await writeSlot(SLOT_B, b as StoredVault);2108 await chrome.storage.local.set({2109 peach_last_sync_at: String(Date.now()),2110 peach_last_modified_at: String(remoteTimestamp),2111 });21122113 return true;2114}21152116export async function getSyncIdentity(): Promise<{2117 privateKeyBytes: Uint8Array;2118 publicKeyBytes: Uint8Array;2119 publicKeyHex: string;2120}> {2121 const raw = await chrome.storage.local.get('peach_sync_identity');2122 const stored = (raw as Record<string, unknown>)['peach_sync_identity'];2123 if (typeof stored === 'string') {2124 const { priv, pub } = JSON.parse(stored) as { priv: string; pub: string };2125 const privateKeyBytes = base64DecodeBytes(priv);2126 const publicKeyBytes = base64DecodeBytes(pub);2127 return { privateKeyBytes, publicKeyBytes, publicKeyHex: bytesToHex(publicKeyBytes) };2128 }21292130 2131 const { generateKeyPair } = await import('@stablelib/x25519');2132 const keypair = generateKeyPair();2133 await chrome.storage.local.set({2134 peach_sync_identity: JSON.stringify({2135 priv: base64EncodeBytes(keypair.secretKey),2136 pub: base64EncodeBytes(keypair.publicKey),2137 }),2138 });2139 return {2140 privateKeyBytes: keypair.secretKey,2141 publicKeyBytes: keypair.publicKey,2142 publicKeyHex: bytesToHex(keypair.publicKey),2143 };2144}21452146214721482149export async function reloadEntriesAfterSync(): Promise<boolean> {2150 if (!derivedKey) return false;2151 const slotA = await loadSlot(SLOT_A);2152 if (!slotA) return false;2153 try {2154 const payload = await tryDecryptSlot(slotA, derivedKey);2155 if (!payload) return false;2156 entries = payload.entries;2157 buildDomainIndexSync(entries);2158 return true;2159 } catch {2160 return false;2161 }2162}21632164function bytesToHex(bytes: Uint8Array): string {2165 return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');2166}21672168function base64EncodeBytes(bytes: Uint8Array): string {2169 let binary = '';2170 for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);2171 return btoa(binary);2172}21732174function base64DecodeBytes(b64: string): Uint8Array {2175 const binary = atob(b64);2176 const bytes = new Uint8Array(binary.length);2177 for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);2178 return bytes;2179}