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 { nonce, ciphertext, tag } = await encrypt(325 newDerivedKey,326 hex(rootKey)327 );328 const slotA = await loadSlot(SLOT_A);329 if (!slotA) throw new Error("missing slot A");330 await writeSlot(SLOT_A, {331 ...slotA,332 recovery_wrapped_root_key: ciphertext,333 recovery_nonce: nonce,334 recovery_tag: tag,335 336 337 });338}339340341342343344345async function reencryptVaultForNewKey(346 newKey: Uint8Array,347 slotA: StoredVault348): Promise<void> {349 const plaintext = encodePayload(entries, duressConfigured);350 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);351 await writeSlot(SLOT_A, {352 ...slotA,353 nonce,354 ciphertext,355 tag,356 });357}358359360361362363364export async function changePasswordWithNewKey(365 newSalt: Uint8Array,366 newKey: Uint8Array367): Promise<void> {368 const slotA = await loadSlot(SLOT_A);369 if (!slotA) throw new Error("missing slot A");370 371 372 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });373 if (derivedKey) zeroKey(derivedKey);374 derivedKey = newKey;375 if (!duressConfigured) {376 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));377 }378}379380381382383384385export { deriveKey, generateSalt };386387export async function loadSlotA(): Promise<StoredVault | null> {388 return loadSlot(SLOT_A);389}390391392393394395396397398399400401402403404405406407408async function deriveRootKeyFromDerivedKey(409 _derivedKey: Uint8Array,410 _recoverySaltHex: string411): Promise<Uint8Array | null> {412 413 414 415 416 417 418 419 return null;420}421422423424425426427428429export async function migrateToRecovery(): Promise<void> {430 if (!derivedKey || vaultMode !== "real") {431 throw new Error("vault must be unlocked in real mode");432 }433 const slotA = await loadSlot(SLOT_A);434 if (!slotA) throw new Error("missing slot A");435 if (slotA.recovery_salt && slotA.recovery_wrapped_root_key) return; 436437 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =438 await generateAndWrapRootKey();439440 rootKey = unhex(rawRootKey);441442 const nextSlotA: StoredVault = {443 ...slotA,444 recovery_salt: recoverySalt,445 recovery_wrapped_root_key: wrappedRootKey,446 recovery_nonce: wrappedNonce,447 recovery_tag: wrappedTag,448 };449 await writeSlot(SLOT_A, nextSlotA);450}451452453454455456457export async function getRecoveryWords(): Promise<string[]> {458 if (!rootKey) {459 throw new Error("recovery not set up — vault may need migration");460 }461 return rootKeyToWords(rootKey);462}463464async function loadSlot(key: string): Promise<StoredVault | null> {465 const result = await chrome.storage.local.get(key);466 return (result as Record<string, StoredVault | undefined>)[key] ?? null;467}468469async function writeSlot(key: string, vault: StoredVault): Promise<void> {470 try {471 await chrome.storage.local.set({ [key]: vault });472 } catch (err) {473 throw mapStorageError(err);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 if (legacyMain && !slotAExists) {500 await writeSlot(SLOT_A, legacyMain);501 }502503 504 if (!slotBExists) {505 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;506 if (legacyDecoy) {507 await writeSlot(SLOT_B, legacyDecoy);508 } else if (target) {509 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));510 }511 }512513 const toRemove: string[] = [];514 if (legacyMain) toRemove.push(LEGACY_MAIN);515 if (legacyDecoy) toRemove.push(LEGACY_DECOY);516 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);517 if (toRemove.length > 0) {518 await chrome.storage.local.remove(toRemove);519 }520}521522523524525526527528529530531532function padPlaintext(json: string): string {533 const encoded = new TextEncoder().encode(json);534 const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);535 const padLen = target - encoded.length;536 return json + " ".repeat(padLen);537}538539540541542543544545546547548549const CSPRNG_CHUNK = 65_536;550551function fillRandom(buf: Uint8Array): Uint8Array {552 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {553 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);554 const chunk = new Uint8Array(end - offset);555 crypto.getRandomValues(chunk);556 buf.set(chunk, offset);557 }558 return buf;559}560561function generateNoiseBlob(ciphertextBytes: number): StoredVault {562 const salt = crypto.getRandomValues(new Uint8Array(16));563 const nonce = crypto.getRandomValues(new Uint8Array(12));564 const cipher = fillRandom(new Uint8Array(ciphertextBytes));565 const tag = crypto.getRandomValues(new Uint8Array(16));566 return {567 version: 1,568 salt: hex(salt),569 nonce: hex(nonce),570 ciphertext: hex(cipher),571 tag: hex(tag),572 };573}574575576577578579type VaultPayload = {580 entries: VaultEntry[];581 duressConfigured?: boolean;582};583584type DecryptedPayload = {585 entries: VaultEntry[];586 duressConfigured: boolean;587 588589590591592593594 migratedFromLegacy: boolean;595};596597function encodePayload(entries: VaultEntry[], duress: boolean): string {598 const envelope: VaultPayload = { entries, duressConfigured: duress };599 return padPlaintext(JSON.stringify(envelope));600}601602function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {603 if ("created" in raw || "updated" in raw) return true;604 const flat = raw as Partial<VaultEntry>;605 if (flat.type === "passkey") return true;606 607 if (flat.rpId !== undefined && flat.passkey === undefined) return true;608 return false;609}610611function decodePayload(plain: string): DecryptedPayload {612 const parsed = JSON.parse(plain) as613 | Array<VaultEntry | LegacyVaultEntry>614 | VaultPayload;615616 const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];617 const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);618619 const normalized = rawEntries.map(normalizeEntry);620 const { merged } = mergePasskeyOnlyIntoLogins(normalized);621622 if (Array.isArray(parsed)) {623 return {624 entries: normalized,625 duressConfigured: false,626 migratedFromLegacy: true, 627 };628 }629630 return {631 entries: normalized,632 duressConfigured: parsed.duressConfigured === true,633 migratedFromLegacy: flatMigrationNeeded || merged,634 };635}636637638639640641642643644645646647648649650651652653654655656657658659660function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {661 let merged = false;662 const now = Date.now();663664 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);665 const byRpId = new Map<string, VaultEntry[]>();666 for (const e of candidates) {667 const rpId = e.passkey!.rpId;668 const list = byRpId.get(rpId);669 if (list) list.push(e);670 else byRpId.set(rpId, [e]);671 }672673 for (const [rpId, group] of byRpId) {674 if (group.length === 0) continue;675 group.sort((a, b) => {676 const aT =677 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;678 const bT =679 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;680 return bT - aT;681 });682 const winner = group[0]!;683684 685 686 687 const loginTarget = entries.find((e) => {688 if (e.deletedAt || e.passkey || e.type !== "login") return false;689 if (e.url && extractDomain(e.url) === rpId) return true;690 for (const extra of e.additionalUrls ?? []) {691 if (extractDomain(extra) === rpId) return true;692 }693 return false;694 });695696 if (loginTarget) {697 loginTarget.passkey = winner.passkey;698 loginTarget.modifiedAt = now;699 for (const dup of group) {700 if (!dup.deletedAt) {701 dup.deletedAt = now;702 dup.modifiedAt = now;703 }704 }705 merged = true;706 } else if (group.length > 1) {707 for (let i = 1; i < group.length; i += 1) {708 const dup = group[i]!;709 if (!dup.deletedAt) {710 dup.deletedAt = now;711 dup.modifiedAt = now;712 }713 }714 merged = true;715 }716 }717718 return { merged };719}720721722723724725726export async function tryDecryptSlot(727 vault: StoredVault,728 key: Uint8Array729): Promise<DecryptedPayload | null> {730 try {731 const plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);732 return decodePayload(plain);733 } catch {734 return null;735 }736}737738739740741742function buildDomainIndexSync(all: VaultEntry[]): void {743 domainIndex = new Map();744745 for (const entry of all) {746 if (entry.deletedAt) continue;747 if (entry.type === "note" || entry.type === "card") continue;748749 750 751 752 753 const domains = new Set<string>();754 const primary = extractDomain(entry.url ?? "");755 if (primary) domains.add(primary);756 for (const extra of entry.additionalUrls ?? []) {757 const d = extractDomain(extra);758 if (d) domains.add(d);759 }760 if (domains.size === 0) continue;761762 for (const domain of domains) {763 const bucket = domainIndex.get(domain) ?? [];764 bucket.push(entry);765 domainIndex.set(domain, bucket);766 }767 }768}769770export async function createVault(password: string): Promise<void> {771 await ensureMigrated();772773 const salt = generateSalt();774 derivedKey = await deriveKey(password, salt);775 entries = [];776 vaultMode = "real";777 duressConfigured = false;778 buildDomainIndexSync(entries);779780 781 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =782 await generateAndWrapRootKey();783 rootKey = unhex(rawRootKey);784785 const plaintext = encodePayload(entries, duressConfigured);786 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);787788 const slotA: StoredVault = {789 version: 1,790 salt: hex(salt),791 nonce,792 ciphertext,793 tag,794 recovery_salt: recoverySalt,795 recovery_wrapped_root_key: wrappedRootKey,796 recovery_nonce: wrappedNonce,797 recovery_tag: wrappedTag,798 };799 await writeSlot(SLOT_A, slotA);800801 802 803 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));804805 touchAutoLock();806}807808export async function hasVault(): Promise<boolean> {809 await ensureMigrated();810 return (await loadSlot(SLOT_A)) !== null;811}812813814815816817818export async function isDuressEnabled(): Promise<boolean> {819 return vaultMode === "real" && duressConfigured;820}821822export async function setupDuress(duressPassword: string): Promise<void> {823 if (!derivedKey || vaultMode !== "real") {824 throw new Error("vault must be unlocked in real mode");825 }826 await ensureMigrated();827828 829 830 831 const slotA = await loadSlot(SLOT_A);832 if (!slotA) throw new Error("missing slot A");833834 const decoySalt = generateSalt();835 const decoyKey = await deriveKey(duressPassword, decoySalt);836 try {837 838 839 const targetBytes = slotA.ciphertext.length / 2;840 let plaintext = encodePayload([], false);841 const encoded = new TextEncoder().encode(plaintext);842 if (encoded.length < targetBytes) {843 plaintext = plaintext + " ".repeat(targetBytes - encoded.length);844 }845 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);846 await writeSlot(SLOT_B, {847 version: 1,848 salt: hex(decoySalt),849 nonce,850 ciphertext,851 tag,852 });853 } finally {854 zeroKey(decoyKey);855 }856857 858 859 duressConfigured = true;860 await persistSlotA();861}862863export async function disableDuress(): Promise<void> {864 if (!derivedKey || vaultMode !== "real") {865 throw new Error("vault must be unlocked in real mode");866 }867 await ensureMigrated();868869 870 871 872 const slotA = await loadSlot(SLOT_A);873 if (slotA) {874 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));875 }876877 duressConfigured = false;878 await persistSlotA();879}880881export async function verifyMainPassword(password: string): Promise<boolean> {882 await ensureMigrated();883 const slotA = await loadSlot(SLOT_A);884 if (!slotA) return false;885 const key = await deriveKey(password, unhex(slotA.salt));886 try {887 return (await tryDecryptSlot(slotA, key)) !== null;888 } finally {889 zeroKey(key);890 }891}892893export async function unlock(password: string): Promise<boolean> {894 await ensureMigrated();895896 897 898 899 900 901 const [slotA, slotB] = await Promise.all([902 loadSlot(SLOT_A),903 loadSlot(SLOT_B),904 ]);905906 907 const [keyA, keyB] = await Promise.all([908 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),909 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),910 ]);911912 913 const [payloadA, payloadB] = await Promise.all([914 keyA && slotA ? tryDecryptSlot(slotA, keyA) : Promise.resolve(null),915 keyB && slotB ? tryDecryptSlot(slotB, keyB) : Promise.resolve(null),916 ]);917918 if (payloadA && keyA) {919 derivedKey = keyA;920 vaultMode = "real";921 entries = payloadA.entries;922 duressConfigured = payloadA.duressConfigured;923 buildDomainIndexSync(entries);924 await loadSettings();925 touchAutoLock();926 if (keyB) zeroKey(keyB);927928 929 if (slotA) {930 const needsRecoverySetup =931 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;932 if (!needsRecoverySetup) {933 rootKey = await deriveRootKeyFromDerivedKey(934 keyA,935 slotA.recovery_salt!936 );937 }938 }939940 941 942 943 if (payloadA.migratedFromLegacy) {944 await persistSlotA();945 }946 return true;947 }948949 if (payloadB && keyB) {950 derivedKey = keyB;951 vaultMode = "decoy";952 entries = payloadB.entries;953 duressConfigured = false;954 buildDomainIndexSync(entries);955 await loadSettings();956 touchAutoLock();957 if (keyA) zeroKey(keyA);958 return true;959 }960961 if (keyA) zeroKey(keyA);962 if (keyB) zeroKey(keyB);963 return false;964}965966export function lock(): void {967 if (derivedKey) {968 zeroKey(derivedKey);969 derivedKey = null;970 }971 if (rootKey) {972 rootKey.fill(0);973 rootKey = null;974 }975976 entries = [];977 domainIndex = new Map();978 vaultMode = "real";979 duressConfigured = false;980 clearAutoLock();981}982983export function isUnlocked(): boolean {984 return derivedKey !== null;985}986987988989export async function unlockWithKey(990 masterKeyHex: string,991): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {992 try {993 const masterKeyBytes = unhex(masterKeyHex);994 const stored = await loadSlot(SLOT_A);995 if (!stored) return { ok: false, error: "invalid_key" };996 const payload = await tryDecryptSlot(stored, masterKeyBytes);997 if (!payload) return { ok: false, error: "invalid_key" };998999 derivedKey = masterKeyBytes;1000 vaultMode = "real";1001 entries = payload.entries;1002 duressConfigured = payload.duressConfigured;1003 buildDomainIndexSync(entries);1004 await loadSettings();1005 touchAutoLock();1006 return { ok: true, entries };1007 } catch {1008 return { ok: false, error: "invalid_key" };1009 }1010}10111012101310141015101610171018101910201021102210231024102510261027102810291030export async function unlockFromCodex(params: {1031 slotA: string; 1032 slotB: string | null; 1033 timestamp: number;1034 masterPassphrase: string;1035}): Promise<1036 | { ok: true; entryCount: number }1037 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1038> {1039 let parsedSlotA: StoredVault;1040 let parsedSlotB: StoredVault | null = null;1041 try {1042 parsedSlotA = JSON.parse(params.slotA) as StoredVault;1043 if (params.slotB !== null) {1044 parsedSlotB = JSON.parse(params.slotB) as StoredVault;1045 }1046 1047 1048 1049 if (1050 typeof parsedSlotA?.salt !== "string" ||1051 typeof parsedSlotA?.nonce !== "string" ||1052 typeof parsedSlotA?.ciphertext !== "string" ||1053 typeof parsedSlotA?.tag !== "string"1054 ) {1055 return { ok: false, reason: "invalid_slot_format" };1056 }1057 } catch {1058 return { ok: false, reason: "invalid_slot_format" };1059 }10601061 1062 1063 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1064 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey);1065 if (!trialPayload) {1066 zeroKey(trialKey);1067 return { ok: false, reason: "wrong_passphrase" };1068 }1069 1070 1071 zeroKey(trialKey);10721073 1074 1075 await takeVaultSnapshot("restore");10761077 await writeSlot(SLOT_A, parsedSlotA);1078 if (parsedSlotB) {1079 await writeSlot(SLOT_B, parsedSlotB);1080 } else {1081 1082 1083 1084 await writeSlot(1085 SLOT_B,1086 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1087 );1088 }1089 await chrome.storage.local.set({1090 peach_last_modified_at: String(params.timestamp),1091 });10921093 1094 1095 const ok = await unlock(params.masterPassphrase);1096 if (!ok) {1097 1098 1099 1100 return { ok: false, reason: "wrong_passphrase" };1101 }1102 return { ok: true, entryCount: getEntries().length };1103}1104110511061107export function getDerivedKeyHex(): string {1108 if (!derivedKey) throw new Error("vault_not_unlocked");1109 return hex(derivedKey);1110}11111112export function getEntries(): VaultEntry[] {1113 if (!derivedKey) throw new Error("vault locked");1114 return entries.filter((entry) => !entry.deletedAt);1115}11161117const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;11181119export function getIdentities(): SavedIdentity[] {1120 if (!derivedKey) throw new Error("vault locked");1121 const active = entries.filter((e) => !e.deletedAt && e.type === "login");11221123 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1124 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();11251126 for (const entry of active) {1127 const value = entry.username?.trim();1128 if (!value) continue;1129 const map = EMAIL_RE.test(value) ? emailMap : usernameMap;1130 const existing = map.get(value);1131 const domain = extractDomain(entry.url ?? "") ?? "";1132 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;1133 if (!existing || modifiedAt > existing.lastUsed) {1134 map.set(value, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1135 } else {1136 map.set(value, { ...existing, count: existing.count + 1 });1137 }1138 }11391140 const toIdentity = (type: "email" | "username") =>1141 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1142 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1143 });11441145 return [1146 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1147 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1148 ];1149}11501151export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1152 if (!derivedKey) throw new Error("vault locked");11531154 const now = Date.now();1155 const added: VaultEntry[] = [];1156 for (const draft of drafts) {1157 try {1158 const sanitized = sanitizeEntryDraft(draft);1159 const nextEntry: VaultEntry = {1160 ...sanitized,1161 id: crypto.randomUUID(),1162 createdAt: now,1163 modifiedAt: now,1164 };1165 entries.push(nextEntry);1166 added.push(nextEntry);1167 } catch {1168 1169 }1170 }11711172 if (added.length > 0) {1173 await persistEntries();1174 }11751176 return added;1177}117811791180118111821183118411851186export async function restoreEntries(1187 incoming: VaultEntry[],1188 mode: "replace" | "merge"1189): Promise<{ added: number; updated: number; skipped: number }> {1190 if (!derivedKey) throw new Error("vault locked");11911192 1193 1194 1195 if (mode === "replace" && incoming.length === 0) {1196 throw new Error("refuse to replace with empty entry set");1197 }11981199 let added = 0;1200 let updated = 0;1201 let skipped = 0;12021203 if (mode === "replace") {1204 entries = [];1205 for (const entry of incoming) {1206 const normalized = sanitizeRestoredEntry(entry);1207 if (!normalized) {1208 skipped += 1;1209 continue;1210 }1211 entries.push(normalized);1212 added += 1;1213 }1214 } else {1215 const byId = new Map<string, VaultEntry>();1216 for (const e of entries) byId.set(e.id, e);12171218 for (const entry of incoming) {1219 const normalized = sanitizeRestoredEntry(entry);1220 if (!normalized) {1221 skipped += 1;1222 continue;1223 }1224 const existing = byId.get(normalized.id);1225 if (!existing) {1226 entries.push(normalized);1227 added += 1;1228 } else if (normalized.modifiedAt > existing.modifiedAt) {1229 const idx = entries.findIndex((e) => e.id === existing.id);1230 if (idx !== -1) {1231 entries[idx] = normalized;1232 updated += 1;1233 }1234 } else {1235 skipped += 1;1236 }1237 }1238 }12391240 await persistEntries("import");1241 return { added, updated, skipped };1242}1243124412451246124712481249function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1250 if (!entry || typeof entry !== "object") return null;1251 const title = typeof entry.title === "string" ? entry.title.trim() : "";1252 if (!title) return null;1253 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1254 const now = Date.now();1255 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1256 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;12571258 1259 1260 1261 const migrated: VaultEntry = migrateLegacyPasskey(1262 {1263 ...entry,1264 id,1265 title,1266 type: entry.type ?? "login",1267 createdAt,1268 modifiedAt,1269 },1270 entry1271 );12721273 1274 1275 if (1276 migrated.type === "login" &&1277 !migrated.passkey &&1278 typeof migrated.password !== "string"1279 ) {1280 return null;1281 }1282 if (migrated.passkey && !migrated.passkey.credentialId) {1283 return null;1284 }12851286 return migrated;1287}128812891290129112921293export async function clearVaultEntries(1294 confirmation: "REPLACE_ALL"1295): Promise<void> {1296 if (confirmation !== "REPLACE_ALL") {1297 throw new Error("clearVaultEntries requires explicit confirmation");1298 }1299 if (!derivedKey) throw new Error("vault locked");1300 for (const entry of entries) {1301 if (!entry.deletedAt) {1302 entry.deletedAt = Date.now();1303 entry.modifiedAt = Date.now();1304 }1305 }1306 await persistEntries();1307}13081309export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1310 if (!derivedKey) throw new Error("vault locked");13111312 const sanitized = sanitizeEntryDraft(entry);1313 const now = Date.now();1314 const nextEntry: VaultEntry = {1315 ...sanitized,1316 id: crypto.randomUUID(),1317 createdAt: now,1318 modifiedAt: now,1319 1320 1321 passwordChangedAt: sanitized.password ? now : undefined,1322 };13231324 entries.push(nextEntry);1325 await persistEntries();13261327 1328 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1329 checkBreach(nextEntry.password).then(async (count) => {1330 if (count > 0) {1331 const idx = entries.findIndex((e) => e.id === nextEntry.id);1332 if (idx !== -1) {1333 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };1334 await persistEntries();1335 }1336 }1337 });1338 }13391340 return nextEntry;1341}13421343export async function updateEntry(1344 id: string,1345 patch: Partial<VaultEntry>1346): Promise<void> {1347 if (!derivedKey) throw new Error("vault locked");13481349 const idx = entries.findIndex((entry) => entry.id === id);1350 if (idx === -1 || entries[idx].deletedAt) {1351 throw new Error("entry not found");1352 }13531354 const current = entries[idx];1355 const sanitizedPatch = sanitizeEntryPatch(patch);1356 1357 1358 1359 const passwordChanged =1360 "password" in patch &&1361 typeof sanitizedPatch.password === "string" &&1362 sanitizedPatch.password !== current.password;1363 entries[idx] = {1364 ...current,1365 ...sanitizedPatch,1366 modifiedAt: Date.now(),1367 ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),1368 };13691370 1371 1372 1373 1374 if (1375 entries[idx].type === "login" &&1376 !entries[idx].password &&1377 !entries[idx].passkey1378 ) {1379 entries[idx] = current;1380 throw new Error("password or passkey required");1381 }13821383 1384 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1385 entries[idx] = current;1386 throw new Error("card number is required for card entries");1387 }13881389 await persistEntries();13901391 1392 1393 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1394 entries[idx] = { ...entries[idx], breach: undefined };1395 await persistEntries();1396 const password = entries[idx].password;1397 if (password) {1398 checkBreach(password).then(async (count) => {1399 const i = entries.findIndex((e) => e.id === id);1400 if (i !== -1 && count > 0) {1401 entries[i] = { ...entries[i], breach: count, modifiedAt: Date.now() };1402 await persistEntries();1403 }1404 });1405 }1406 }1407}14081409export async function deleteEntry(id: string): Promise<void> {1410 if (!derivedKey) throw new Error("vault locked");14111412 const entry = entries.find((item) => item.id === id);1413 if (!entry || entry.deletedAt) {1414 throw new Error("entry not found");1415 }14161417 entry.deletedAt = Date.now();1418 entry.modifiedAt = Date.now();1419 await persistEntries();1420}14211422export function getDeletedEntries(): VaultEntry[] {1423 if (!derivedKey) return [];1424 return entries.filter((e) => !!e.deletedAt);1425}14261427export async function restoreEntry(id: string): Promise<void> {1428 if (!derivedKey) throw new Error("vault locked");1429 const entry = entries.find((e) => e.id === id && e.deletedAt);1430 if (!entry) throw new Error("deleted entry not found");1431 delete entry.deletedAt;1432 entry.modifiedAt = Date.now();1433 await persistEntries();1434 buildDomainIndexSync(entries);1435}14361437export async function purgeEntry(id: string): Promise<void> {1438 if (!derivedKey) throw new Error("vault locked");1439 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1440 if (idx === -1) throw new Error("deleted entry not found");1441 entries.splice(idx, 1);1442 await persistEntries();1443}144414451446export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1447 if (!derivedKey || retentionMs === 0) return 0;1448 const cutoff = Date.now() - retentionMs;1449 const before = entries.length;1450 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1451 const purged = before - entries.length;1452 if (purged > 0) await persistEntries();1453 return purged;1454}14551456export function queryDomain(domain: string): VaultEntry[] {1457 return domainIndex.get(domain) ?? [];1458}14591460export function getEntryById(id: string): VaultEntry | undefined {1461 return entries.find((entry) => entry.id === id && !entry.deletedAt);1462}14631464async function persistSlotA(1465 reason: VaultSnapshot["reason"] = "local_save",1466): Promise<StoredVault> {1467 if (!derivedKey) throw new Error("vault locked");14681469 const slotA = await loadSlot(SLOT_A);1470 if (!slotA) throw new Error("missing slot A");14711472 1473 1474 await takeVaultSnapshot(reason);14751476 const plaintext = encodePayload(entries, duressConfigured);1477 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);14781479 1480 const nextSlotA: StoredVault = {1481 ...slotA,1482 nonce,1483 ciphertext,1484 tag,1485 1486 };1487 await writeSlot(SLOT_A, nextSlotA);14881489 1490 1491 1492 if (!duressConfigured) {1493 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1494 }14951496 return nextSlotA;1497}14981499async function persistSlotB(): Promise<void> {1500 if (!derivedKey) throw new Error("vault locked");15011502 const slotB = await loadSlot(SLOT_B);1503 if (!slotB) throw new Error("missing slot B");15041505 1506 1507 const plaintext = encodePayload(entries, false);1508 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);15091510 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1511}15121513async function persistEntries(1514 reason: VaultSnapshot["reason"] = "local_save",1515): Promise<void> {1516 if (!derivedKey) throw new Error("vault locked");15171518 if (vaultMode === "decoy") {1519 await persistSlotB();1520 } else {1521 await persistSlotA(reason);1522 }1523 buildDomainIndexSync(entries);1524}15251526function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1527 if (!Array.isArray(values)) return undefined;1528 const seen = new Set<string>();1529 const cleaned: string[] = [];1530 for (const value of values) {1531 const trimmed = typeof value === "string" ? value.trim() : "";1532 if (!trimmed) continue;1533 const key = trimmed.toLowerCase();1534 if (seen.has(key)) continue;1535 seen.add(key);1536 cleaned.push(trimmed);1537 }1538 return cleaned.length > 0 ? cleaned : undefined;1539}15401541function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1542 const title = entry.title.trim();1543 1544 1545 1546 let type: VaultEntry["type"] = entry.type ?? "login";1547 if (type === "passkey") type = "login";15481549 if (!title) throw new Error("title is required");1550 1551 1552 if (type === "login" && !entry.password?.trim() && !entry.passkey) {1553 throw new Error("password or passkey required");1554 }1555 if (entry.passkey) {1556 const p = entry.passkey;1557 if (1558 !p.rpId ||1559 !p.credentialId ||1560 !p.publicKeyCose ||1561 !p.userHandle ||1562 !p.userName ||1563 (p.algorithm !== -7 && p.algorithm !== -8)1564 ) {1565 throw new Error("invalid passkey credential");1566 }1567 }1568 if (type === "card") {1569 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1570 throw new Error("cardholder name and card number are required");1571 }1572 }15731574 return {1575 type,1576 title,1577 username: type === "login" ? entry.username?.trim() ?? "" : undefined,1578 password: type === "login" ? entry.password : undefined,1579 url: type === "login" ? entry.url?.trim() ?? "" : undefined,1580 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1581 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1582 notes: entry.notes?.trim() || undefined,1583 breach: type === "login" ? entry.breach : undefined,1584 canary: type === "login" ? entry.canary : undefined,1585 tags: entry.tags,1586 passkey: entry.passkey,1587 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1588 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1589 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1590 expYear: type === "card" ? entry.expYear?.trim() : undefined,1591 cvc: type === "card" ? entry.cvc?.trim() : undefined,1592 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1593 network: type === "card" ? entry.network : undefined,1594 last4: type === "card" ? entry.last4?.trim() : undefined,1595 };1596}15971598function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1599 const nextPatch: Partial<VaultEntry> = {};16001601 if ("type" in patch) {1602 nextPatch.type = patch.type;1603 }16041605 if ("title" in patch) {1606 const title = patch.title?.trim() ?? "";1607 if (!title) throw new Error("title is required");1608 nextPatch.title = title;1609 }16101611 if ("url" in patch) {1612 nextPatch.url = patch.url?.trim() ?? "";1613 }16141615 if ("additionalUrls" in patch) {1616 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1617 const seen = new Set<string>();1618 const cleaned: string[] = [];1619 for (const value of incoming) {1620 const trimmed = typeof value === "string" ? value.trim() : "";1621 if (!trimmed) continue;1622 const key = trimmed.toLowerCase();1623 if (seen.has(key)) continue;1624 seen.add(key);1625 cleaned.push(trimmed);1626 }1627 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1628 }16291630 if ("username" in patch) {1631 nextPatch.username = patch.username?.trim() ?? "";1632 }16331634 if ("email" in patch) {1635 nextPatch.email = patch.email?.trim() || undefined;1636 }16371638 if ("password" in patch) {1639 const password = patch.password ?? "";1640 1641 nextPatch.password = password || undefined;1642 }16431644 if ("totp" in patch) {1645 nextPatch.totp = patch.totp?.trim() || undefined;1646 }16471648 if ("notes" in patch) {1649 nextPatch.notes = patch.notes?.trim() || undefined;1650 }16511652 if ("breach" in patch) {1653 nextPatch.breach = patch.breach;1654 }16551656 if ("canary" in patch) {1657 nextPatch.canary = patch.canary;1658 }16591660 if ("passkey" in patch) {1661 1662 1663 1664 nextPatch.passkey = patch.passkey;1665 }16661667 if ("tags" in patch) {1668 nextPatch.tags = patch.tags;1669 }16701671 if ("cardholderName" in patch) {1672 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1673 }1674 if ("cardNumber" in patch) {1675 const n = patch.cardNumber ?? "";1676 nextPatch.cardNumber = n || undefined;1677 }1678 if ("expMonth" in patch) {1679 nextPatch.expMonth = patch.expMonth?.trim() || undefined;1680 }1681 if ("expYear" in patch) {1682 nextPatch.expYear = patch.expYear?.trim() || undefined;1683 }1684 if ("cvc" in patch) {1685 nextPatch.cvc = patch.cvc?.trim() || undefined;1686 }1687 if ("cardNickname" in patch) {1688 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1689 }1690 if ("network" in patch) {1691 nextPatch.network = patch.network;1692 }1693 if ("last4" in patch) {1694 nextPatch.last4 = patch.last4?.trim() || undefined;1695 }16961697 return nextPatch;1698}16991700function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1701 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1702 const modifiedAt =1703 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;17041705 const base: VaultEntry = {1706 id: entry.id,1707 type: "type" in entry ? entry.type : "login",1708 title: entry.title,1709 username: entry.username,1710 password: entry.password,1711 url: entry.url,1712 totp: entry.totp,1713 notes: entry.notes,1714 breach: entry.breach,1715 canary: "canary" in entry ? entry.canary : undefined,1716 passkey: "passkey" in entry ? entry.passkey : undefined,1717 createdAt,1718 modifiedAt,1719 deletedAt: entry.deletedAt,1720 tags: "tags" in entry ? entry.tags : undefined,1721 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1722 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1723 expMonth: "expMonth" in entry ? entry.expMonth : undefined,1724 expYear: "expYear" in entry ? entry.expYear : undefined,1725 cvc: "cvc" in entry ? entry.cvc : undefined,1726 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1727 network: "network" in entry ? entry.network : undefined,1728 last4: "last4" in entry ? entry.last4 : undefined,1729 };17301731 return migrateLegacyPasskey(base, entry);1732}173317341735173617371738173917401741function migrateLegacyPasskey(1742 base: VaultEntry,1743 raw: VaultEntry | LegacyVaultEntry1744): VaultEntry {1745 if (base.passkey) return base;1746 const flat = raw as Partial<VaultEntry>;1747 const isPasskeyType = base.type === "passkey";1748 const hasFlatPasskeyFields =1749 typeof flat.rpId === "string" &&1750 typeof flat.credentialId === "string" &&1751 typeof flat.publicKeyCose === "string" &&1752 typeof flat.userHandle === "string" &&1753 typeof flat.userName === "string";1754 if (!isPasskeyType && !hasFlatPasskeyFields) return base;1755 if (!hasFlatPasskeyFields) return base;17561757 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;1758 base.passkey = {1759 rpId: flat.rpId!,1760 rpName: flat.rpName ?? flat.rpId!,1761 credentialId: flat.credentialId!,1762 publicKeyCose: flat.publicKeyCose!,1763 privateKeyJwk: flat.privateKeyJwk,1764 userHandle: flat.userHandle!,1765 userName: flat.userName!,1766 userDisplayName: flat.userDisplayName ?? flat.userName!,1767 algorithm,1768 signCount: flat.signCount ?? 0,1769 aaguid: flat.aaguid ?? "",1770 backupEligible: flat.backupEligible === true,1771 backupState: flat.backupState === true,1772 lastUsedAt: flat.lastUsedAt,1773 browser: flat.browser,1774 transports: flat.transports,1775 createdAt: base.createdAt,1776 };1777 1778 1779 1780 if (isPasskeyType) base.type = "login";1781 if (!base.username) base.username = flat.userName;1782 if (!base.url) base.url = `https://${flat.rpId}`;1783 return base;1784}1785178617871788178917901791function entryDupeKey(e: VaultEntry): string {1792 if (e.type === "note") return `note|${e.title.toLowerCase()}`;1793 1794 1795 1796 1797 if (e.passkey) {1798 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;1799 }1800 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1801}180218031804export function countDuplicates(): number {1805 const best = new Map<string, VaultEntry>();1806 let dupeCount = 0;1807 for (const e of entries.filter((e) => !e.deletedAt)) {1808 const key = entryDupeKey(e);1809 const existing = best.get(key);1810 if (!existing) {1811 best.set(key, e);1812 } else {1813 dupeCount++;1814 1815 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1816 best.set(key, e);1817 }1818 }1819 }1820 return dupeCount;1821}182218231824182518261827export async function deduplicateEntries(): Promise<number> {1828 if (!derivedKey) throw new Error("vault locked");1829 1830 1831 if (vaultMode === "decoy") {1832 throw new Error("deduplicate disabled in decoy mode");1833 }18341835 const best = new Map<string, VaultEntry>();1836 for (const e of entries.filter((en) => !en.deletedAt)) {1837 const key = entryDupeKey(e);1838 const existing = best.get(key);1839 if (!existing) {1840 best.set(key, e);1841 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1842 best.set(key, e);1843 }1844 }18451846 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1847 const now = Date.now();1848 let removed = 0;1849 entries = entries.map((e) => {1850 if (!e.deletedAt && !keepIds.has(e.id)) {1851 removed++;1852 return { ...e, deletedAt: now };1853 }1854 return e;1855 });18561857 if (removed > 0) await persistEntries();1858 return removed;1859}186018611862186318641865186618671868const HISTORY_KEY = "peach_vault_history";1869const HISTORY_MAX = 10;18701871187218731874187518761877export async function takeVaultSnapshot(1878 reason: VaultSnapshot["reason"],1879): Promise<void> {1880 try {1881 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);1882 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];1883 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];18841885 1886 1887 1888 if (!slotA) return;18891890 const vault_a = JSON.stringify(slotA);1891 const vault_b = JSON.stringify(slotB ?? slotA);18921893 1894 1895 1896 let entryCount: number | null = null;1897 if (derivedKey && vaultMode === "real") {1898 try {1899 entryCount = entries.filter((e) => !e.deletedAt).length;1900 } catch {1901 entryCount = null;1902 }1903 }19041905 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];1906 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)1907 ? existingRaw1908 : { snapshots: [] };19091910 1911 1912 1913 const latest = history.snapshots[0];1914 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {1915 return;1916 }19171918 const snapshot: VaultSnapshot = {1919 id: crypto.randomUUID(),1920 savedAt: Date.now(),1921 reason,1922 vault_a,1923 vault_b,1924 entryCount,1925 };19261927 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);1928 await chrome.storage.local.set({ [HISTORY_KEY]: history });1929 } catch {1930 1931 1932 }1933}193419351936export async function clearVaultHistory(): Promise<void> {1937 try {1938 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });1939 } catch {1940 1941 }1942}194319441945export async function getVaultHistory(): Promise<VaultSnapshot[]> {1946 const raw = await chrome.storage.local.get(HISTORY_KEY);1947 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];1948 if (!history || !Array.isArray(history.snapshots)) return [];1949 return history.snapshots;1950}19511952195319541955195619571958export async function restoreVaultSnapshot(1959 snapshotId: string,1960): Promise<{ ok: true } | { ok: false; error: string }> {1961 const history = await getVaultHistory();1962 const snapshot = history.find((s) => s.id === snapshotId);1963 if (!snapshot) return { ok: false, error: "snapshot_not_found" };19641965 1966 1967 1968 await takeVaultSnapshot("restore");19691970 let restoredA: StoredVault;1971 let restoredB: StoredVault;1972 try {1973 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;1974 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;1975 } catch {1976 return { ok: false, error: "snapshot_corrupt" };1977 }19781979 await writeSlot(SLOT_A, restoredA);1980 await writeSlot(SLOT_B, restoredB);19811982 1983 1984 1985 lock();19861987 return { ok: true };1988}19891990199119921993199419951996export async function changePassword(1997 currentPassword: string,1998 newPassword: string1999): Promise<boolean> {2000 if (!derivedKey || vaultMode !== "real") return false;20012002 2003 const valid = await verifyMainPassword(currentPassword);2004 if (!valid) return false;20052006 const slotA = await loadSlot(SLOT_A);2007 if (!slotA) return false;20082009 2010 const newSalt = generateSalt();2011 const newKey = await deriveKey(newPassword, newSalt);20122013 2014 const plaintext = encodePayload(entries, duressConfigured);2015 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);20162017 const freshSlotA: StoredVault = {2018 ...slotA,2019 salt: hex(newSalt),2020 nonce,2021 ciphertext,2022 tag,2023 };2024 2025 2026 2027 await takeVaultSnapshot("local_save");2028 await writeSlot(SLOT_A, freshSlotA);20292030 2031 2032 2033 zeroKey(derivedKey);2034 derivedKey = newKey;20352036 2037 2038 const {2039 rawRootKey: freshRawKey,2040 recoverySalt: freshSalt,2041 wrappedRootKey: freshWrapped,2042 wrappedNonce: freshNonce,2043 wrappedTag: freshTag,2044 } = await generateAndWrapRootKey();2045 rootKey = unhex(freshRawKey);2046 const slotAfterRekey = await loadSlot(SLOT_A);2047 if (slotAfterRekey) {2048 await writeSlot(SLOT_A, {2049 ...slotAfterRekey,2050 recovery_salt: freshSalt,2051 recovery_wrapped_root_key: freshWrapped,2052 recovery_nonce: freshNonce,2053 recovery_tag: freshTag,2054 });2055 }20562057 2058 2059 2060 if (!duressConfigured) {2061 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2062 }20632064 return true;2065}206620672068206920702071export async function getVaultBlobForSync(): Promise<{2072 blob: string;2073 timestamp: number;2074 license?: string;2075} | null> {2076 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, 'peach_last_modified_at', 'peach_license']);2077 const slotA = (raw as Record<string, unknown>)[SLOT_A];2078 if (!slotA) return null;2079 const slotB = (raw as Record<string, unknown>)[SLOT_B];2080 const blob = JSON.stringify({ a: slotA, b: slotB });2081 const rawTs = (raw as Record<string, unknown>)['peach_last_modified_at'];2082 const timestamp = typeof rawTs === 'string' ? parseInt(rawTs) : 0;2083 const license = (raw as Record<string, unknown>)['peach_license'];2084 return {2085 blob,2086 timestamp,2087 license: typeof license === 'string' ? license : undefined,2088 };2089}20902091export async function applyIncomingVault(2092 blobJson: string,2093 remoteTimestamp: number,2094 remoteLicense?: string,2095): Promise<boolean> {2096 const raw = await chrome.storage.local.get('peach_last_modified_at');2097 const rawTs = (raw as Record<string, unknown>)['peach_last_modified_at'];2098 const localTimestamp = typeof rawTs === 'string' ? parseInt(rawTs) : 0;20992100 if (remoteLicense) {2101 const licenseRaw = await chrome.storage.local.get('peach_license');2102 const existing = (licenseRaw as Record<string, unknown>)['peach_license'];2103 if (!existing) {2104 await chrome.storage.local.set({ peach_license: remoteLicense });2105 }2106 }21072108 if (remoteTimestamp <= localTimestamp) return false;21092110 2111 await takeVaultSnapshot('sync_received').catch(() => {});21122113 const { a, b } = JSON.parse(blobJson) as { a: unknown; b?: unknown };2114 await writeSlot(SLOT_A, a as StoredVault);2115 if (b) await writeSlot(SLOT_B, b as StoredVault);2116 await chrome.storage.local.set({2117 peach_last_sync_at: String(Date.now()),2118 peach_last_modified_at: String(remoteTimestamp),2119 });21202121 return true;2122}21232124export async function getSyncIdentity(): Promise<{2125 privateKeyBytes: Uint8Array;2126 publicKeyBytes: Uint8Array;2127 publicKeyHex: string;2128}> {2129 const raw = await chrome.storage.local.get('peach_sync_identity');2130 const stored = (raw as Record<string, unknown>)['peach_sync_identity'];2131 if (typeof stored === 'string') {2132 const { priv, pub } = JSON.parse(stored) as { priv: string; pub: string };2133 const privateKeyBytes = base64DecodeBytes(priv);2134 const publicKeyBytes = base64DecodeBytes(pub);2135 return { privateKeyBytes, publicKeyBytes, publicKeyHex: bytesToHex(publicKeyBytes) };2136 }21372138 2139 const { generateKeyPair } = await import('@stablelib/x25519');2140 const keypair = generateKeyPair();2141 await chrome.storage.local.set({2142 peach_sync_identity: JSON.stringify({2143 priv: base64EncodeBytes(keypair.secretKey),2144 pub: base64EncodeBytes(keypair.publicKey),2145 }),2146 });2147 return {2148 privateKeyBytes: keypair.secretKey,2149 publicKeyBytes: keypair.publicKey,2150 publicKeyHex: bytesToHex(keypair.publicKey),2151 };2152}21532154215521562157export async function reloadEntriesAfterSync(): Promise<boolean> {2158 if (!derivedKey) return false;2159 const slotA = await loadSlot(SLOT_A);2160 if (!slotA) return false;2161 try {2162 const payload = await tryDecryptSlot(slotA, derivedKey);2163 if (!payload) return false;2164 entries = payload.entries;2165 buildDomainIndexSync(entries);2166 return true;2167 } catch {2168 return false;2169 }2170}21712172function bytesToHex(bytes: Uint8Array): string {2173 return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');2174}21752176function base64EncodeBytes(bytes: Uint8Array): string {2177 let binary = '';2178 for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);2179 return btoa(binary);2180}21812182function base64DecodeBytes(b64: string): Uint8Array {2183 const binary = atob(b64);2184 const bytes = new Uint8Array(binary.length);2185 for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);2186 return bytes;2187}