123456789101112import { deriveKey, generateSalt } from "../crypto/argon2";13import { extractDomain } from "../shared/domain";14import {15 rootKeyToWords,16 wordsToRootKeyMaterial,17 deriveRecoveryKey,18} from "../shared/recovery-words";19import type {20 AccentColorName,21 LegacyVaultEntry,22 ManualSummonRecord,23 SavedIdentity,24 StoredVault,25 VaultEntry,26 VaultHistory,27 VaultSnapshot,28} from "../shared/types";29import { decrypt, encrypt, zeroKey } from "./aes";30import { clearAutoLock, touchAutoLock } from "./alarms";3132type EntryDraft = Omit<33 VaultEntry,34 "id" | "createdAt" | "modifiedAt" | "deletedAt"35>;363738394041const SLOT_A = "peach_vault_a";42const SLOT_B = "peach_vault_b";434445const LEGACY_MAIN = "peach_vault";46const LEGACY_DECOY = "peach_decoy_vault";47const LEGACY_DURESS_FLAG = "peach_duress";48495051const PAD_BLOCK = 1024;5253let derivedKey: Uint8Array | null = null;5455565758let rootKey: Uint8Array | null = null;59let entries: VaultEntry[] = [];60let domainIndex: Map<string, VaultEntry[]> = new Map();61let vaultMode: "real" | "decoy" = "real";62636465let duressConfigured = false;6667let migrationRan = false;6869707172737475767778let lastUnlockFailureReason: string | null = null;7980export function consumeLastUnlockFailureReason(): string | null {81 const r = lastUnlockFailureReason;82 lastUnlockFailureReason = null;83 return r;84}858687888990export type DisplayFont = "cormorant-italic" | "cormorant" | "inter" | "system";91export type TextSize = "small" | "medium" | "large";9293export interface VaultSettings {94 accentColor?: AccentColorName;95 theme?: "dark" | "light";96 autoLockMinutes?: number;97 clipboardClearSeconds?: number;98 lockOnClose?: boolean;99 breachMonitoring?: boolean;100 passkeyProviderEnabled?: boolean;101 trashRetentionDays?: number;102 manualSummonDomains?: Record<string, ManualSummonRecord>;103 declinedCardFingerprints?: string[];104 105106107108109 displayFont?: DisplayFont;110 111112113114115 textSize?: TextSize;116 117118119120121 reduceMotion?: boolean;122 123124125126127 biometricsEnabled?: boolean;128 129130131132133134135 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;136}137138let settings: VaultSettings = {};139140export function getSettings(): VaultSettings {141 return settings;142}143144export async function ensureSettingsLoaded(): Promise<VaultSettings> {145 await loadSettings();146 return settings;147}148149export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {150 await loadSettings();151 settings = { ...settings, ...patch };152 await persistSettings();153}154155async function persistSettings(): Promise<void> {156 try {157 await chrome.storage.local.set({ peach_settings: settings });158 } catch (err) {159 throw mapStorageError(err);160 }161}162163function mapStorageError(err: unknown): Error {164 const message = err instanceof Error ? err.message : String(err);165 if (/QUOTA_BYTES|quota/i.test(message)) {166 return new Error("STORAGE_QUOTA_EXCEEDED");167 }168 return err instanceof Error ? err : new Error(message);169}170171async function loadSettings(): Promise<void> {172 const result = await chrome.storage.local.get("peach_settings");173 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};174}175176177178179180export async function checkBreach(password: string): Promise<number> {181 const encoder = new TextEncoder();182 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));183 const hashHex = Array.from(new Uint8Array(hashBuffer))184 .map((b) => b.toString(16).padStart(2, "0"))185 .join("")186 .toUpperCase();187188 const prefix = hashHex.slice(0, 5);189 const suffix = hashHex.slice(5);190191 try {192 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);193 if (!res.ok) return 0;194 const text = await res.text();195 const match = text.split("\r\n").find((l) => l.startsWith(suffix));196 return match ? parseInt(match.split(":")[1], 10) : 0;197 } catch {198 return 0; 199 }200}201202export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {203 const fingerprint = await cardFingerprint(last4, expMonth, expYear);204 await loadSettings();205 settings = {206 ...settings,207 declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],208 };209 await persistSettings();210}211212export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {213 await loadSettings();214 const fingerprint = await cardFingerprint(last4, expMonth, expYear);215 return (settings.declinedCardFingerprints ?? []).includes(fingerprint);216}217218async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {219 const encoder = new TextEncoder();220 const buf = await crypto.subtle.digest("SHA-1", encoder.encode(`${last4}${expMonth}${expYear}`));221 return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, "0")).join("");222}223224function hex(buf: Uint8Array): string {225 return Array.from(buf)226 .map((b) => b.toString(16).padStart(2, "0"))227 .join("");228}229230function unhex(s: string): Uint8Array {231 const out = new Uint8Array(s.length / 2);232 for (let i = 0; i < s.length; i += 2) {233 out[i / 2] = parseInt(s.slice(i, i + 2), 16);234 }235 return out;236}237238239240241242243244245246247248249async function generateAndWrapRootKey(): Promise<{250 rawRootKey: string; 251 recoverySalt: string;252 wrappedRootKey: string;253 wrappedNonce: string;254 wrappedTag: string;255}> {256 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));257258 259 const words = await rootKeyToWords(rootKeyBytes);260 const wordMaterial = await wordsToRootKeyMaterial(words);261 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));262 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);263264 265 266 const nonce = crypto.getRandomValues(new Uint8Array(12));267 const encoded = new TextEncoder().encode(hex(rootKeyBytes));268 const raw = await crypto.subtle.encrypt(269 {270 name: "AES-GCM",271 iv: nonce,272 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,273 tagLength: 128,274 },275 recoveryKey,276 encoded277 );278 279 const rawU8 = new Uint8Array(raw);280 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));281 const tag = hex(rawU8.slice(rawU8.length - 16));282283 return {284 rawRootKey: hex(rootKeyBytes),285 recoverySalt: hex(recoverySalt),286 wrappedRootKey: ciphertext,287 wrappedNonce: hex(nonce),288 wrappedTag: tag,289 };290}291292293294295296297export async function unwrapRootKeyForRecovery(298 wrappedRootKey: string,299 wrappedNonce: string,300 wrappedTag: string,301 recoverySaltHex: string,302 words: string[]303): Promise<void> {304 const wordMaterial = await wordsToRootKeyMaterial(words);305 const recoverySalt = unhex(recoverySaltHex);306 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);307308 309 310 311 312 313 const nonceRaw = unhex(wrappedNonce);314 const ciphertextRaw = unhex(wrappedRootKey);315 const tagRaw = unhex(wrappedTag);316 317 318 const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;319 const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);320 combined.set(ciphertextRaw, 0);321 combined.set(tagRaw, ciphertextRaw.length);322 const raw = await crypto.subtle.decrypt(323 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },324 recoveryKey,325 combined326 );327 const decryptedHex = new TextDecoder().decode(raw);328 rootKey = unhex(decryptedHex);329}330331332333334335336337338339340export async function rewrapRootKeyForPassword(341 newDerivedKey: Uint8Array,342 _newSalt: Uint8Array343): Promise<void> {344 if (!rootKey) throw new Error("root key not set");345 const slotA = await loadSlot(SLOT_A);346 if (!slotA) throw new Error("missing slot A");347 const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);348 await writeSlot(SLOT_A, { ...slotA, ...pwRoot });349}350351352353354355356async function reencryptVaultForNewKey(357 newKey: Uint8Array,358 slotA: StoredVault359): Promise<void> {360 const plaintext = encodePayload(entries, duressConfigured);361 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);362 await writeSlot(SLOT_A, {363 ...slotA,364 nonce,365 ciphertext,366 tag,367 });368}369370371372373374375export async function changePasswordWithNewKey(376 newSalt: Uint8Array,377 newKey: Uint8Array378): Promise<void> {379 const slotA = await loadSlot(SLOT_A);380 if (!slotA) throw new Error("missing slot A");381 382 383 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });384 if (derivedKey) zeroKey(derivedKey);385 derivedKey = newKey;386 387 388 389 390 if (rootKey) {391 const slotAfter = await loadSlot(SLOT_A);392 if (slotAfter) {393 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);394 await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });395 }396 }397 if (!duressConfigured) {398 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));399 }400 await stampLocalVaultMutation();401}402403404405406407408export { deriveKey, generateSalt };409410export async function loadSlotA(): Promise<StoredVault | null> {411 return loadSlot(SLOT_A);412}413414415416417418419420421422423424425426async function deriveRootKeyFromDerivedKey(427 derivedKeyBytes: Uint8Array,428 slotA: StoredVault,429): Promise<Uint8Array | null> {430 if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {431 return null;432 }433 try {434 const rootHex = await decrypt(435 derivedKeyBytes,436 slotA.pw_root_nonce,437 slotA.pw_root_ciphertext,438 slotA.pw_root_tag,439 );440 return unhex(rootHex);441 } catch {442 return null;443 }444}445446447448async function wrapRootKeyForDerivedKey(449 derivedKeyBytes: Uint8Array,450 rootKeyBytes: Uint8Array,451): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {452 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes));453 return {454 pw_root_ciphertext: ciphertext,455 pw_root_nonce: nonce,456 pw_root_tag: tag,457 };458}459460461462463464465466467export async function migrateToRecovery(): Promise<void> {468 if (!derivedKey || vaultMode !== "real") {469 throw new Error("vault must be unlocked in real mode");470 }471 const slotA = await loadSlot(SLOT_A);472 if (!slotA) throw new Error("missing slot A");473 const hasRecoveryEscrow =474 !!slotA.recovery_salt && !!slotA.recovery_wrapped_root_key;475 const hasPwRoot = !!slotA.pw_root_ciphertext && !!slotA.pw_root_nonce;476 if (hasRecoveryEscrow && hasPwRoot) return; 477478 479 480 481 482 483 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =484 await generateAndWrapRootKey();485 rootKey = unhex(rawRootKey);486487 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);488 const nextSlotA: StoredVault = {489 ...slotA,490 recovery_salt: recoverySalt,491 recovery_wrapped_root_key: wrappedRootKey,492 recovery_nonce: wrappedNonce,493 recovery_tag: wrappedTag,494 ...pwRoot,495 };496 await writeSlot(SLOT_A, nextSlotA);497 await stampLocalVaultMutation();498}499500501502503504505export async function getRecoveryWords(): Promise<string[]> {506 if (!rootKey) {507 throw new Error("recovery not set up — vault may need migration");508 }509 return rootKeyToWords(rootKey);510}511512async function loadSlot(key: string): Promise<StoredVault | null> {513 const result = await chrome.storage.local.get(key);514 return (result as Record<string, StoredVault | undefined>)[key] ?? null;515}516517async function writeSlot(key: string, vault: StoredVault): Promise<void> {518 try {519 await chrome.storage.local.set({ [key]: vault });520 } catch (err) {521 throw mapStorageError(err);522 }523}524525async function stampLocalVaultMutation(): Promise<void> {526 await chrome.storage.local.set({527 peach_last_modified_at: String(Date.now()),528 });529}530531532533534535536async function ensureMigrated(): Promise<void> {537 if (migrationRan) return;538 migrationRan = true;539540 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];541 const raw = await chrome.storage.local.get(keys);542 const slotAExists = raw[SLOT_A] !== undefined;543 const slotBExists = raw[SLOT_B] !== undefined;544 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;545 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;546547 548 if (!slotAExists && !legacyMain) return;549550 551 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;552553 let didMigrate = false;554 if (legacyMain && !slotAExists) {555 await writeSlot(SLOT_A, legacyMain);556 didMigrate = true;557 }558559 560 if (!slotBExists) {561 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;562 if (legacyDecoy) {563 await writeSlot(SLOT_B, legacyDecoy);564 didMigrate = true;565 } else if (target) {566 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));567 didMigrate = true;568 }569 }570571 const toRemove: string[] = [];572 if (legacyMain) toRemove.push(LEGACY_MAIN);573 if (legacyDecoy) toRemove.push(LEGACY_DECOY);574 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);575 if (toRemove.length > 0) {576 await chrome.storage.local.remove(toRemove);577 didMigrate = true;578 }579 if (didMigrate) {580 await stampLocalVaultMutation();581 }582}583584585586587588589590591592593594function padPlaintext(json: string): string {595 const encoded = new TextEncoder().encode(json);596 const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);597 const padLen = target - encoded.length;598 return json + " ".repeat(padLen);599}600601602603604605606607608609610611const CSPRNG_CHUNK = 65_536;612613function fillRandom(buf: Uint8Array): Uint8Array {614 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {615 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);616 const chunk = new Uint8Array(end - offset);617 crypto.getRandomValues(chunk);618 buf.set(chunk, offset);619 }620 return buf;621}622623function generateNoiseBlob(ciphertextBytes: number): StoredVault {624 const salt = crypto.getRandomValues(new Uint8Array(16));625 const nonce = crypto.getRandomValues(new Uint8Array(12));626 const cipher = fillRandom(new Uint8Array(ciphertextBytes));627 const tag = crypto.getRandomValues(new Uint8Array(16));628 return {629 version: 1,630 salt: hex(salt),631 nonce: hex(nonce),632 ciphertext: hex(cipher),633 tag: hex(tag),634 };635}636637638639640641type VaultPayload = {642 entries: VaultEntry[];643 duressConfigured?: boolean;644};645646type DecryptedPayload = {647 entries: VaultEntry[];648 duressConfigured: boolean;649 650651652653654655656 migratedFromLegacy: boolean;657};658659function encodePayload(entries: VaultEntry[], duress: boolean): string {660 const envelope: VaultPayload = { entries, duressConfigured: duress };661 return padPlaintext(JSON.stringify(envelope));662}663664function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {665 if ("created" in raw || "updated" in raw) return true;666 const flat = raw as Partial<VaultEntry>;667 if (flat.type === "passkey") return true;668 669 if (flat.rpId !== undefined && flat.passkey === undefined) return true;670 return false;671}672673function decodePayload(plain: string): DecryptedPayload {674 const parsed = JSON.parse(plain) as675 | Array<VaultEntry | LegacyVaultEntry>676 | VaultPayload;677678 const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];679 const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);680681 const normalized = rawEntries.map(normalizeEntry);682 const { merged } = mergePasskeyOnlyIntoLogins(normalized);683684 if (Array.isArray(parsed)) {685 return {686 entries: normalized,687 duressConfigured: false,688 migratedFromLegacy: true, 689 };690 }691692 return {693 entries: normalized,694 duressConfigured: parsed.duressConfigured === true,695 migratedFromLegacy: flatMigrationNeeded || merged,696 };697}698699700701702703704705706707708709710711712713714715716717718719720721722function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {723 let merged = false;724 const now = Date.now();725726 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);727 const byRpId = new Map<string, VaultEntry[]>();728 for (const e of candidates) {729 const rpId = e.passkey!.rpId;730 const list = byRpId.get(rpId);731 if (list) list.push(e);732 else byRpId.set(rpId, [e]);733 }734735 for (const [rpId, group] of byRpId) {736 if (group.length === 0) continue;737 group.sort((a, b) => {738 const aT =739 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;740 const bT =741 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;742 return bT - aT;743 });744 const winner = group[0]!;745746 747 748 749 const loginTarget = entries.find((e) => {750 if (e.deletedAt || e.passkey || e.type !== "login") return false;751 if (e.url && extractDomain(e.url) === rpId) return true;752 for (const extra of e.additionalUrls ?? []) {753 if (extractDomain(extra) === rpId) return true;754 }755 return false;756 });757758 if (loginTarget) {759 loginTarget.passkey = winner.passkey;760 loginTarget.modifiedAt = now;761 for (const dup of group) {762 if (!dup.deletedAt) {763 dup.deletedAt = now;764 dup.modifiedAt = now;765 }766 }767 merged = true;768 } else if (group.length > 1) {769 for (let i = 1; i < group.length; i += 1) {770 const dup = group[i]!;771 if (!dup.deletedAt) {772 dup.deletedAt = now;773 dup.modifiedAt = now;774 }775 }776 merged = true;777 }778 }779780 return { merged };781}782783784785786787788789function hasValidVaultSchema(v: unknown): v is StoredVault {790 if (typeof v !== "object" || v === null) return false;791 const r = v as Record<string, unknown>;792 return (793 r.version === 1 &&794 typeof r.salt === "string" &&795 typeof r.nonce === "string" &&796 typeof r.ciphertext === "string" &&797 typeof r.tag === "string"798 );799}800801export type TryDecryptResult =802 | { status: "ok"; payload: DecryptedPayload }803 | { status: "wrong_key" }804 | { status: "corrupted"; reason: string };805806807808809810811export async function tryDecryptSlot(812 vault: StoredVault,813 key: Uint8Array814): Promise<DecryptedPayload | null> {815 const res = await tryDecryptSlotResult(vault, key);816 return res.status === "ok" ? res.payload : null;817}818819export async function tryDecryptSlotResult(820 vault: StoredVault,821 key: Uint8Array822): Promise<TryDecryptResult> {823 if (!hasValidVaultSchema(vault)) {824 return { status: "corrupted", reason: "invalid_slot_schema" };825 }826 let plain: string;827 try {828 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);829 } catch {830 return { status: "wrong_key" };831 }832 try {833 const parsed = JSON.parse(plain) as unknown;834 if (typeof parsed !== "object" || parsed === null) {835 return { status: "corrupted", reason: "invalid_payload_shape" };836 }837 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {838 return { status: "corrupted", reason: "invalid_payload_shape" };839 }840 return { status: "ok", payload: decodePayload(plain) };841 } catch {842 return { status: "corrupted", reason: "invalid_payload_json" };843 }844}845846847848849850function buildDomainIndexSync(all: VaultEntry[]): void {851 domainIndex = new Map();852853 for (const entry of all) {854 if (entry.deletedAt) continue;855 if (entry.type === "note" || entry.type === "card") continue;856857 858 859 860 861 const domains = new Set<string>();862 const primary = extractDomain(entry.url ?? "");863 if (primary) domains.add(primary);864 for (const extra of entry.additionalUrls ?? []) {865 const d = extractDomain(extra);866 if (d) domains.add(d);867 }868 if (domains.size === 0) continue;869870 for (const domain of domains) {871 const bucket = domainIndex.get(domain) ?? [];872 bucket.push(entry);873 domainIndex.set(domain, bucket);874 }875 }876}877878export async function createVault(password: string): Promise<void> {879 await ensureMigrated();880881 const salt = generateSalt();882 derivedKey = await deriveKey(password, salt);883 entries = [];884 vaultMode = "real";885 duressConfigured = false;886 buildDomainIndexSync(entries);887888 889 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =890 await generateAndWrapRootKey();891 rootKey = unhex(rawRootKey);892893 const plaintext = encodePayload(entries, duressConfigured);894 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);895896 897 898 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);899900 const slotA: StoredVault = {901 version: 1,902 salt: hex(salt),903 nonce,904 ciphertext,905 tag,906 recovery_salt: recoverySalt,907 recovery_wrapped_root_key: wrappedRootKey,908 recovery_nonce: wrappedNonce,909 recovery_tag: wrappedTag,910 ...pwRoot,911 };912 await writeSlot(SLOT_A, slotA);913914 915 916 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));917 await stampLocalVaultMutation();918919 touchAutoLock();920}921922export async function hasVault(): Promise<boolean> {923 await ensureMigrated();924 return (await loadSlot(SLOT_A)) !== null;925}926927928929930931932export async function isDuressEnabled(): Promise<boolean> {933 return vaultMode === "real" && duressConfigured;934}935936export async function setupDuress(duressPassword: string): Promise<void> {937 if (!derivedKey || vaultMode !== "real") {938 throw new Error("vault must be unlocked in real mode");939 }940 await ensureMigrated();941942 943 944 945 const slotA = await loadSlot(SLOT_A);946 if (!slotA) throw new Error("missing slot A");947948 const decoySalt = generateSalt();949 const decoyKey = await deriveKey(duressPassword, decoySalt);950 try {951 952 953 const targetBytes = slotA.ciphertext.length / 2;954 let plaintext = encodePayload([], false);955 const encoded = new TextEncoder().encode(plaintext);956 if (encoded.length < targetBytes) {957 plaintext = plaintext + " ".repeat(targetBytes - encoded.length);958 }959 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);960 await writeSlot(SLOT_B, {961 version: 1,962 salt: hex(decoySalt),963 nonce,964 ciphertext,965 tag,966 });967 } finally {968 zeroKey(decoyKey);969 }970971 972 973 duressConfigured = true;974 await persistSlotA();975}976977export async function disableDuress(): Promise<void> {978 if (!derivedKey || vaultMode !== "real") {979 throw new Error("vault must be unlocked in real mode");980 }981 await ensureMigrated();982983 984 985 986 const slotA = await loadSlot(SLOT_A);987 if (slotA) {988 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));989 }990991 duressConfigured = false;992 await persistSlotA();993}994995export async function verifyMainPassword(password: string): Promise<boolean> {996 await ensureMigrated();997 const slotA = await loadSlot(SLOT_A);998 if (!slotA) return false;999 const key = await deriveKey(password, unhex(slotA.salt));1000 try {1001 return (await tryDecryptSlot(slotA, key)) !== null;1002 } finally {1003 zeroKey(key);1004 }1005}10061007export async function unlock(password: string): Promise<boolean> {1008 await ensureMigrated();10091010 1011 1012 1013 1014 1015 const [slotA, slotB] = await Promise.all([1016 loadSlot(SLOT_A),1017 loadSlot(SLOT_B),1018 ]);10191020 1021 const [keyA, keyB] = await Promise.all([1022 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),1023 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),1024 ]);10251026 1027 1028 1029 const [resultA, resultB] = await Promise.all([1030 keyA && slotA1031 ? tryDecryptSlotResult(slotA, keyA)1032 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1033 keyB && slotB1034 ? tryDecryptSlotResult(slotB, keyB)1035 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1036 ]);1037 const payloadA = resultA.status === "ok" ? resultA.payload : null;1038 const payloadB = resultB.status === "ok" ? resultB.payload : null;10391040 if (payloadA && keyA) {1041 derivedKey = keyA;1042 vaultMode = "real";1043 entries = payloadA.entries;1044 duressConfigured = payloadA.duressConfigured;1045 buildDomainIndexSync(entries);1046 await loadSettings();1047 touchAutoLock();1048 if (keyB) zeroKey(keyB);10491050 1051 if (slotA) {1052 const needsRecoverySetup =1053 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;1054 if (!needsRecoverySetup) {1055 rootKey = await deriveRootKeyFromDerivedKey(keyA, slotA);1056 }1057 1058 1059 1060 1061 if (needsRecoverySetup || !rootKey) {1062 try {1063 await migrateToRecovery();1064 if (needsRecoverySetup) {1065 1066 1067 1068 await chrome.storage.local.set({ peach_recovery_pending: '1' });1069 }1070 } catch (migrationErr) {1071 console.warn('[Peach] recovery migration failed:', migrationErr);1072 }1073 }1074 }10751076 1077 1078 1079 if (payloadA.migratedFromLegacy) {1080 await persistSlotA();1081 }1082 return true;1083 }10841085 if (payloadB && keyB) {1086 derivedKey = keyB;1087 vaultMode = "decoy";1088 entries = payloadB.entries;1089 duressConfigured = false;1090 buildDomainIndexSync(entries);1091 await loadSettings();1092 touchAutoLock();1093 if (keyA) zeroKey(keyA);1094 return true;1095 }10961097 if (keyA) zeroKey(keyA);1098 if (keyB) zeroKey(keyB);1099 1100 1101 if (resultA.status === "corrupted") {1102 lastUnlockFailureReason = resultA.reason;1103 } else if (resultB.status === "corrupted") {1104 lastUnlockFailureReason = resultB.reason;1105 }1106 return false;1107}11081109export function lock(): void {1110 if (derivedKey) {1111 zeroKey(derivedKey);1112 derivedKey = null;1113 }1114 if (rootKey) {1115 rootKey.fill(0);1116 rootKey = null;1117 }11181119 entries = [];1120 domainIndex = new Map();1121 vaultMode = "real";1122 duressConfigured = false;1123 clearAutoLock();1124}11251126export function isUnlocked(): boolean {1127 return derivedKey !== null;1128}1129113011311132export async function unlockWithKey(1133 masterKeyHex: string,1134): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1135 try {1136 const masterKeyBytes = unhex(masterKeyHex);1137 const stored = await loadSlot(SLOT_A);1138 if (!stored) return { ok: false, error: "invalid_key" };1139 const payload = await tryDecryptSlot(stored, masterKeyBytes);1140 if (!payload) return { ok: false, error: "invalid_key" };11411142 derivedKey = masterKeyBytes;1143 vaultMode = "real";1144 entries = payload.entries;1145 duressConfigured = payload.duressConfigured;1146 buildDomainIndexSync(entries);1147 await loadSettings();1148 touchAutoLock();1149 return { ok: true, entries };1150 } catch {1151 return { ok: false, error: "invalid_key" };1152 }1153}11541155115611571158115911601161116211631164116511661167116811691170117111721173export async function unlockFromCodex(params: {1174 slotA: string; 1175 slotB: string | null; 1176 timestamp: number;1177 masterPassphrase: string;1178}): Promise<1179 | { ok: true; entryCount: number }1180 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1181> {1182 let parsedSlotA: StoredVault;1183 let parsedSlotB: StoredVault | null = null;1184 try {1185 const rawA = JSON.parse(params.slotA) as unknown;1186 if (!hasValidVaultSchema(rawA)) {1187 return { ok: false, reason: "invalid_slot_format" };1188 }1189 parsedSlotA = rawA;1190 if (params.slotB !== null) {1191 const rawB = JSON.parse(params.slotB) as unknown;1192 if (!hasValidVaultSchema(rawB)) {1193 return { ok: false, reason: "invalid_slot_format" };1194 }1195 parsedSlotB = rawB;1196 }1197 } catch {1198 return { ok: false, reason: "invalid_slot_format" };1199 }12001201 1202 1203 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1204 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey);1205 if (!trialPayload) {1206 zeroKey(trialKey);1207 return { ok: false, reason: "wrong_passphrase" };1208 }1209 1210 1211 zeroKey(trialKey);12121213 1214 1215 await takeVaultSnapshot("restore");12161217 await writeSlot(SLOT_A, parsedSlotA);1218 if (parsedSlotB) {1219 await writeSlot(SLOT_B, parsedSlotB);1220 } else {1221 1222 1223 1224 await writeSlot(1225 SLOT_B,1226 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1227 );1228 }1229 await chrome.storage.local.set({1230 peach_last_modified_at: String(params.timestamp),1231 });12321233 1234 1235 const ok = await unlock(params.masterPassphrase);1236 if (!ok) {1237 1238 1239 1240 return { ok: false, reason: "wrong_passphrase" };1241 }1242 return { ok: true, entryCount: getEntries().length };1243}1244124512461247export function getDerivedKeyHex(): string {1248 if (!derivedKey) throw new Error("vault_not_unlocked");1249 return hex(derivedKey);1250}12511252export function getEntries(): VaultEntry[] {1253 if (!derivedKey) throw new Error("vault locked");1254 return entries.filter((entry) => !entry.deletedAt);1255}12561257const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;12581259export function getIdentities(): SavedIdentity[] {1260 if (!derivedKey) throw new Error("vault locked");1261 const active = entries.filter((e) => !e.deletedAt && e.type === "login");12621263 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1264 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();12651266 for (const entry of active) {1267 const value = entry.username?.trim();1268 if (!value) continue;1269 const map = EMAIL_RE.test(value) ? emailMap : usernameMap;1270 const existing = map.get(value);1271 const domain = extractDomain(entry.url ?? "") ?? "";1272 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;1273 if (!existing || modifiedAt > existing.lastUsed) {1274 map.set(value, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1275 } else {1276 map.set(value, { ...existing, count: existing.count + 1 });1277 }1278 }12791280 const toIdentity = (type: "email" | "username") =>1281 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1282 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1283 });12841285 return [1286 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1287 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1288 ];1289}12901291export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1292 if (!derivedKey) throw new Error("vault locked");12931294 const now = Date.now();1295 const added: VaultEntry[] = [];1296 for (const draft of drafts) {1297 try {1298 const sanitized = sanitizeEntryDraft(draft);1299 const nextEntry: VaultEntry = {1300 ...sanitized,1301 id: crypto.randomUUID(),1302 createdAt: now,1303 modifiedAt: now,1304 };1305 entries.push(nextEntry);1306 added.push(nextEntry);1307 } catch {1308 1309 }1310 }13111312 if (added.length > 0) {1313 await persistEntries();1314 }13151316 return added;1317}131813191320132113221323132413251326export async function restoreEntries(1327 incoming: VaultEntry[],1328 mode: "replace" | "merge"1329): Promise<{ added: number; updated: number; skipped: number }> {1330 if (!derivedKey) throw new Error("vault locked");13311332 1333 1334 1335 if (mode === "replace" && incoming.length === 0) {1336 throw new Error("refuse to replace with empty entry set");1337 }13381339 let added = 0;1340 let updated = 0;1341 let skipped = 0;13421343 if (mode === "replace") {1344 entries = [];1345 for (const entry of incoming) {1346 const normalized = sanitizeRestoredEntry(entry);1347 if (!normalized) {1348 skipped += 1;1349 continue;1350 }1351 entries.push(normalized);1352 added += 1;1353 }1354 } else {1355 const byId = new Map<string, VaultEntry>();1356 for (const e of entries) byId.set(e.id, e);13571358 for (const entry of incoming) {1359 const normalized = sanitizeRestoredEntry(entry);1360 if (!normalized) {1361 skipped += 1;1362 continue;1363 }1364 const existing = byId.get(normalized.id);1365 if (!existing) {1366 entries.push(normalized);1367 added += 1;1368 } else if (normalized.modifiedAt > existing.modifiedAt) {1369 const idx = entries.findIndex((e) => e.id === existing.id);1370 if (idx !== -1) {1371 entries[idx] = normalized;1372 updated += 1;1373 }1374 } else {1375 skipped += 1;1376 }1377 }1378 }13791380 await persistEntries("import");1381 return { added, updated, skipped };1382}1383138413851386138713881389function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1390 if (!entry || typeof entry !== "object") return null;1391 const title = typeof entry.title === "string" ? entry.title.trim() : "";1392 if (!title) return null;1393 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1394 const now = Date.now();1395 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1396 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;13971398 1399 1400 1401 const migrated: VaultEntry = migrateLegacyPasskey(1402 {1403 ...entry,1404 id,1405 title,1406 type: entry.type ?? "login",1407 createdAt,1408 modifiedAt,1409 },1410 entry1411 );14121413 1414 1415 if (1416 migrated.type === "login" &&1417 !migrated.passkey &&1418 typeof migrated.password !== "string"1419 ) {1420 return null;1421 }1422 if (migrated.passkey && !migrated.passkey.credentialId) {1423 return null;1424 }14251426 return migrated;1427}142814291430143114321433export async function clearVaultEntries(1434 confirmation: "REPLACE_ALL"1435): Promise<void> {1436 if (confirmation !== "REPLACE_ALL") {1437 throw new Error("clearVaultEntries requires explicit confirmation");1438 }1439 if (!derivedKey) throw new Error("vault locked");1440 for (const entry of entries) {1441 if (!entry.deletedAt) {1442 entry.deletedAt = Date.now();1443 entry.modifiedAt = Date.now();1444 }1445 }1446 await persistEntries();1447}14481449export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1450 if (!derivedKey) throw new Error("vault locked");14511452 const sanitized = sanitizeEntryDraft(entry);1453 const now = Date.now();1454 const nextEntry: VaultEntry = {1455 ...sanitized,1456 id: crypto.randomUUID(),1457 createdAt: now,1458 modifiedAt: now,1459 1460 1461 passwordChangedAt: sanitized.password ? now : undefined,1462 };14631464 entries.push(nextEntry);1465 await persistEntries();14661467 1468 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1469 checkBreach(nextEntry.password).then(async (count) => {1470 if (count > 0) {1471 const idx = entries.findIndex((e) => e.id === nextEntry.id);1472 if (idx !== -1) {1473 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };1474 await persistEntries();1475 }1476 }1477 });1478 }14791480 return nextEntry;1481}14821483export async function updateEntry(1484 id: string,1485 patch: Partial<VaultEntry>1486): Promise<void> {1487 if (!derivedKey) throw new Error("vault locked");14881489 const idx = entries.findIndex((entry) => entry.id === id);1490 if (idx === -1 || entries[idx].deletedAt) {1491 throw new Error("entry not found");1492 }14931494 const current = entries[idx];1495 const sanitizedPatch = sanitizeEntryPatch(patch);1496 1497 1498 1499 const passwordChanged =1500 "password" in patch &&1501 typeof sanitizedPatch.password === "string" &&1502 sanitizedPatch.password !== current.password;1503 entries[idx] = {1504 ...current,1505 ...sanitizedPatch,1506 modifiedAt: Date.now(),1507 ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),1508 };15091510 1511 1512 1513 1514 if (1515 entries[idx].type === "login" &&1516 !entries[idx].password &&1517 !entries[idx].passkey1518 ) {1519 entries[idx] = current;1520 throw new Error("password or passkey required");1521 }15221523 1524 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1525 entries[idx] = current;1526 throw new Error("card number is required for card entries");1527 }15281529 await persistEntries();15301531 1532 1533 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1534 entries[idx] = { ...entries[idx], breach: undefined };1535 await persistEntries();1536 const password = entries[idx].password;1537 if (password) {1538 checkBreach(password).then(async (count) => {1539 const i = entries.findIndex((e) => e.id === id);1540 if (i !== -1 && count > 0) {1541 entries[i] = { ...entries[i], breach: count, modifiedAt: Date.now() };1542 await persistEntries();1543 }1544 });1545 }1546 }1547}15481549export async function deleteEntry(id: string): Promise<void> {1550 if (!derivedKey) throw new Error("vault locked");15511552 const entry = entries.find((item) => item.id === id);1553 if (!entry || entry.deletedAt) {1554 throw new Error("entry not found");1555 }15561557 entry.deletedAt = Date.now();1558 entry.modifiedAt = Date.now();1559 await persistEntries();1560}15611562export function getDeletedEntries(): VaultEntry[] {1563 if (!derivedKey) return [];1564 return entries.filter((e) => !!e.deletedAt);1565}15661567export async function restoreEntry(id: string): Promise<void> {1568 if (!derivedKey) throw new Error("vault locked");1569 const entry = entries.find((e) => e.id === id && e.deletedAt);1570 if (!entry) throw new Error("deleted entry not found");1571 delete entry.deletedAt;1572 entry.modifiedAt = Date.now();1573 await persistEntries();1574 buildDomainIndexSync(entries);1575}15761577export async function purgeEntry(id: string): Promise<void> {1578 if (!derivedKey) throw new Error("vault locked");1579 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1580 if (idx === -1) throw new Error("deleted entry not found");1581 entries.splice(idx, 1);1582 await persistEntries();1583}158415851586export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1587 if (!derivedKey || retentionMs === 0) return 0;1588 const cutoff = Date.now() - retentionMs;1589 const before = entries.length;1590 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1591 const purged = before - entries.length;1592 if (purged > 0) await persistEntries();1593 return purged;1594}15951596export function queryDomain(domain: string): VaultEntry[] {1597 return domainIndex.get(domain) ?? [];1598}15991600export function getEntryById(id: string): VaultEntry | undefined {1601 return entries.find((entry) => entry.id === id && !entry.deletedAt);1602}16031604async function persistSlotA(1605 reason: VaultSnapshot["reason"] = "local_save",1606): Promise<StoredVault> {1607 if (!derivedKey) throw new Error("vault locked");16081609 const slotA = await loadSlot(SLOT_A);1610 if (!slotA) throw new Error("missing slot A");16111612 1613 1614 await takeVaultSnapshot(reason);16151616 const plaintext = encodePayload(entries, duressConfigured);1617 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);16181619 1620 const nextSlotA: StoredVault = {1621 ...slotA,1622 nonce,1623 ciphertext,1624 tag,1625 1626 };1627 await writeSlot(SLOT_A, nextSlotA);16281629 1630 1631 1632 if (!duressConfigured) {1633 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1634 }1635 await stampLocalVaultMutation();16361637 return nextSlotA;1638}16391640async function persistSlotB(): Promise<void> {1641 if (!derivedKey) throw new Error("vault locked");16421643 const slotB = await loadSlot(SLOT_B);1644 if (!slotB) throw new Error("missing slot B");16451646 1647 1648 const plaintext = encodePayload(entries, false);1649 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);16501651 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1652 await stampLocalVaultMutation();1653}16541655async function persistEntries(1656 reason: VaultSnapshot["reason"] = "local_save",1657): Promise<void> {1658 if (!derivedKey) throw new Error("vault locked");16591660 if (vaultMode === "decoy") {1661 await persistSlotB();1662 } else {1663 await persistSlotA(reason);1664 }1665 buildDomainIndexSync(entries);1666}16671668function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1669 if (!Array.isArray(values)) return undefined;1670 const seen = new Set<string>();1671 const cleaned: string[] = [];1672 for (const value of values) {1673 const trimmed = typeof value === "string" ? value.trim() : "";1674 if (!trimmed) continue;1675 const key = trimmed.toLowerCase();1676 if (seen.has(key)) continue;1677 seen.add(key);1678 cleaned.push(trimmed);1679 }1680 return cleaned.length > 0 ? cleaned : undefined;1681}16821683function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1684 const title = entry.title.trim();1685 1686 1687 1688 let type: VaultEntry["type"] = entry.type ?? "login";1689 if (type === "passkey") type = "login";16901691 if (!title) throw new Error("title is required");1692 1693 1694 if (type === "login" && !entry.password?.trim() && !entry.passkey) {1695 throw new Error("password or passkey required");1696 }1697 if (entry.passkey) {1698 const p = entry.passkey;1699 if (1700 !p.rpId ||1701 !p.credentialId ||1702 !p.publicKeyCose ||1703 !p.userHandle ||1704 !p.userName ||1705 (p.algorithm !== -7 && p.algorithm !== -8)1706 ) {1707 throw new Error("invalid passkey credential");1708 }1709 }1710 if (type === "card") {1711 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1712 throw new Error("cardholder name and card number are required");1713 }1714 }17151716 return {1717 type,1718 title,1719 username: type === "login" ? entry.username?.trim() ?? "" : undefined,1720 password: type === "login" ? entry.password : undefined,1721 url: type === "login" ? entry.url?.trim() ?? "" : undefined,1722 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1723 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1724 notes: entry.notes?.trim() || undefined,1725 breach: type === "login" ? entry.breach : undefined,1726 canary: type === "login" ? entry.canary : undefined,1727 tags: entry.tags,1728 passkey: entry.passkey,1729 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1730 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1731 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1732 expYear: type === "card" ? entry.expYear?.trim() : undefined,1733 cvc: type === "card" ? entry.cvc?.trim() : undefined,1734 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1735 network: type === "card" ? entry.network : undefined,1736 last4: type === "card" ? entry.last4?.trim() : undefined,1737 };1738}17391740function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1741 const nextPatch: Partial<VaultEntry> = {};17421743 if ("type" in patch) {1744 nextPatch.type = patch.type;1745 }17461747 if ("title" in patch) {1748 const title = patch.title?.trim() ?? "";1749 if (!title) throw new Error("title is required");1750 nextPatch.title = title;1751 }17521753 if ("url" in patch) {1754 nextPatch.url = patch.url?.trim() ?? "";1755 }17561757 if ("additionalUrls" in patch) {1758 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1759 const seen = new Set<string>();1760 const cleaned: string[] = [];1761 for (const value of incoming) {1762 const trimmed = typeof value === "string" ? value.trim() : "";1763 if (!trimmed) continue;1764 const key = trimmed.toLowerCase();1765 if (seen.has(key)) continue;1766 seen.add(key);1767 cleaned.push(trimmed);1768 }1769 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1770 }17711772 if ("username" in patch) {1773 nextPatch.username = patch.username?.trim() ?? "";1774 }17751776 if ("email" in patch) {1777 nextPatch.email = patch.email?.trim() || undefined;1778 }17791780 if ("password" in patch) {1781 const password = patch.password ?? "";1782 1783 nextPatch.password = password || undefined;1784 }17851786 if ("totp" in patch) {1787 nextPatch.totp = patch.totp?.trim() || undefined;1788 }17891790 if ("notes" in patch) {1791 nextPatch.notes = patch.notes?.trim() || undefined;1792 }17931794 if ("breach" in patch) {1795 nextPatch.breach = patch.breach;1796 }17971798 if ("canary" in patch) {1799 nextPatch.canary = patch.canary;1800 }18011802 if ("passkey" in patch) {1803 1804 1805 1806 nextPatch.passkey = patch.passkey;1807 }18081809 if ("tags" in patch) {1810 nextPatch.tags = patch.tags;1811 }18121813 if ("cardholderName" in patch) {1814 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1815 }1816 if ("cardNumber" in patch) {1817 const n = patch.cardNumber ?? "";1818 nextPatch.cardNumber = n || undefined;1819 }1820 if ("expMonth" in patch) {1821 nextPatch.expMonth = patch.expMonth?.trim() || undefined;1822 }1823 if ("expYear" in patch) {1824 nextPatch.expYear = patch.expYear?.trim() || undefined;1825 }1826 if ("cvc" in patch) {1827 nextPatch.cvc = patch.cvc?.trim() || undefined;1828 }1829 if ("cardNickname" in patch) {1830 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1831 }1832 if ("network" in patch) {1833 nextPatch.network = patch.network;1834 }1835 if ("last4" in patch) {1836 nextPatch.last4 = patch.last4?.trim() || undefined;1837 }18381839 return nextPatch;1840}18411842function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1843 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1844 const modifiedAt =1845 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;18461847 const base: VaultEntry = {1848 id: entry.id,1849 type: "type" in entry ? entry.type : "login",1850 title: entry.title,1851 username: entry.username,1852 password: entry.password,1853 url: entry.url,1854 totp: entry.totp,1855 notes: entry.notes,1856 breach: entry.breach,1857 canary: "canary" in entry ? entry.canary : undefined,1858 passkey: "passkey" in entry ? entry.passkey : undefined,1859 createdAt,1860 modifiedAt,1861 deletedAt: entry.deletedAt,1862 tags: "tags" in entry ? entry.tags : undefined,1863 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1864 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1865 expMonth: "expMonth" in entry ? entry.expMonth : undefined,1866 expYear: "expYear" in entry ? entry.expYear : undefined,1867 cvc: "cvc" in entry ? entry.cvc : undefined,1868 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1869 network: "network" in entry ? entry.network : undefined,1870 last4: "last4" in entry ? entry.last4 : undefined,1871 };18721873 return migrateLegacyPasskey(base, entry);1874}187518761877187818791880188118821883function migrateLegacyPasskey(1884 base: VaultEntry,1885 raw: VaultEntry | LegacyVaultEntry1886): VaultEntry {1887 if (base.passkey) return base;1888 const flat = raw as Partial<VaultEntry>;1889 const isPasskeyType = base.type === "passkey";1890 const hasFlatPasskeyFields =1891 typeof flat.rpId === "string" &&1892 typeof flat.credentialId === "string" &&1893 typeof flat.publicKeyCose === "string" &&1894 typeof flat.userHandle === "string" &&1895 typeof flat.userName === "string";1896 if (!isPasskeyType && !hasFlatPasskeyFields) return base;1897 if (!hasFlatPasskeyFields) return base;18981899 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;1900 base.passkey = {1901 rpId: flat.rpId!,1902 rpName: flat.rpName ?? flat.rpId!,1903 credentialId: flat.credentialId!,1904 publicKeyCose: flat.publicKeyCose!,1905 privateKeyJwk: flat.privateKeyJwk,1906 userHandle: flat.userHandle!,1907 userName: flat.userName!,1908 userDisplayName: flat.userDisplayName ?? flat.userName!,1909 algorithm,1910 signCount: flat.signCount ?? 0,1911 aaguid: flat.aaguid ?? "",1912 backupEligible: flat.backupEligible === true,1913 backupState: flat.backupState === true,1914 lastUsedAt: flat.lastUsedAt,1915 browser: flat.browser,1916 transports: flat.transports,1917 createdAt: base.createdAt,1918 };1919 1920 1921 1922 if (isPasskeyType) base.type = "login";1923 if (!base.username) base.username = flat.userName;1924 if (!base.url) base.url = `https://${flat.rpId}`;1925 return base;1926}1927192819291930193119321933function entryDupeKey(e: VaultEntry): string {1934 if (e.type === "note") return `note|${e.title.toLowerCase()}`;1935 1936 1937 1938 1939 if (e.passkey) {1940 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;1941 }1942 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1943}194419451946export function countDuplicates(): number {1947 const best = new Map<string, VaultEntry>();1948 let dupeCount = 0;1949 for (const e of entries.filter((e) => !e.deletedAt)) {1950 const key = entryDupeKey(e);1951 const existing = best.get(key);1952 if (!existing) {1953 best.set(key, e);1954 } else {1955 dupeCount++;1956 1957 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1958 best.set(key, e);1959 }1960 }1961 }1962 return dupeCount;1963}196419651966196719681969export async function deduplicateEntries(): Promise<number> {1970 if (!derivedKey) throw new Error("vault locked");1971 1972 1973 if (vaultMode === "decoy") {1974 throw new Error("deduplicate disabled in decoy mode");1975 }19761977 const best = new Map<string, VaultEntry>();1978 for (const e of entries.filter((en) => !en.deletedAt)) {1979 const key = entryDupeKey(e);1980 const existing = best.get(key);1981 if (!existing) {1982 best.set(key, e);1983 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1984 best.set(key, e);1985 }1986 }19871988 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1989 const now = Date.now();1990 let removed = 0;1991 entries = entries.map((e) => {1992 if (!e.deletedAt && !keepIds.has(e.id)) {1993 removed++;1994 return { ...e, deletedAt: now };1995 }1996 return e;1997 });19981999 if (removed > 0) await persistEntries();2000 return removed;2001}200220032004200520062007200820092010const HISTORY_KEY = "peach_vault_history";2011const HISTORY_MAX = 10;20122013201420152016201720182019export async function takeVaultSnapshot(2020 reason: VaultSnapshot["reason"],2021): Promise<void> {2022 try {2023 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2024 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2025 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];20262027 2028 2029 2030 if (!slotA) return;20312032 const vault_a = JSON.stringify(slotA);2033 const vault_b = JSON.stringify(slotB ?? slotA);20342035 2036 2037 2038 let entryCount: number | null = null;2039 if (derivedKey && vaultMode === "real") {2040 try {2041 entryCount = entries.filter((e) => !e.deletedAt).length;2042 } catch {2043 entryCount = null;2044 }2045 }20462047 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2048 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2049 ? existingRaw2050 : { snapshots: [] };20512052 2053 2054 2055 const latest = history.snapshots[0];2056 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2057 return;2058 }20592060 const snapshot: VaultSnapshot = {2061 id: crypto.randomUUID(),2062 savedAt: Date.now(),2063 reason,2064 vault_a,2065 vault_b,2066 entryCount,2067 };20682069 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2070 await chrome.storage.local.set({ [HISTORY_KEY]: history });2071 } catch (err) {2072 2073 2074 2075 2076 console.warn('[Peach] takeVaultSnapshot failed:', err);2077 if (String(err).includes('QUOTA') || String(err).includes('quota')) {2078 try {2079 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2080 } catch {2081 2082 }2083 }2084 }2085}208620872088export async function clearVaultHistory(): Promise<void> {2089 try {2090 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2091 } catch {2092 2093 }2094}209520962097export async function getVaultHistory(): Promise<VaultSnapshot[]> {2098 const raw = await chrome.storage.local.get(HISTORY_KEY);2099 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2100 if (!history || !Array.isArray(history.snapshots)) return [];2101 return history.snapshots;2102}21032104210521062107210821092110export async function restoreVaultSnapshot(2111 snapshotId: string,2112): Promise<{ ok: true } | { ok: false; error: string }> {2113 const history = await getVaultHistory();2114 const snapshot = history.find((s) => s.id === snapshotId);2115 if (!snapshot) return { ok: false, error: "snapshot_not_found" };21162117 2118 2119 2120 await takeVaultSnapshot("restore");21212122 let restoredA: StoredVault;2123 let restoredB: StoredVault;2124 try {2125 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2126 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2127 } catch {2128 return { ok: false, error: "snapshot_corrupt" };2129 }21302131 await writeSlot(SLOT_A, restoredA);2132 await writeSlot(SLOT_B, restoredB);2133 await stampLocalVaultMutation();21342135 2136 2137 2138 lock();21392140 return { ok: true };2141}21422143214421452146214721482149export async function changePassword(2150 currentPassword: string,2151 newPassword: string2152): Promise<boolean> {2153 if (!derivedKey || vaultMode !== "real") return false;21542155 2156 const valid = await verifyMainPassword(currentPassword);2157 if (!valid) return false;21582159 const slotA = await loadSlot(SLOT_A);2160 if (!slotA) return false;2161 if (!rootKey) return false;21622163 2164 const newSalt = generateSalt();2165 const newKey = await deriveKey(newPassword, newSalt);21662167 2168 const plaintext = encodePayload(entries, duressConfigured);2169 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);21702171 2172 2173 2174 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);21752176 const finalSlotA: StoredVault = {2177 ...slotA,2178 salt: hex(newSalt),2179 nonce,2180 ciphertext,2181 tag,2182 ...pwRoot,2183 };21842185 2186 2187 2188 await takeVaultSnapshot("local_save");21892190 2191 2192 2193 2194 await writeSlot(SLOT_A, finalSlotA);21952196 2197 2198 2199 zeroKey(derivedKey);2200 derivedKey = newKey;22012202 2203 2204 2205 if (!duressConfigured) {2206 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2207 }2208 await stampLocalVaultMutation();22092210 return true;2211}221222132214221522162217export async function getVaultBlobForSync(): Promise<{2218 blob: string;2219 timestamp: number;2220 license?: string;2221} | null> {2222 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, 'peach_last_modified_at', 'peach_license']);2223 const slotA = (raw as Record<string, unknown>)[SLOT_A];2224 if (!slotA) return null;2225 const slotB = (raw as Record<string, unknown>)[SLOT_B];2226 const blob = JSON.stringify({ a: slotA, b: slotB });2227 const rawTs = (raw as Record<string, unknown>)['peach_last_modified_at'];2228 const timestamp = typeof rawTs === 'string' ? parseInt(rawTs) : 0;2229 const license = (raw as Record<string, unknown>)['peach_license'];2230 return {2231 blob,2232 timestamp,2233 license: typeof license === 'string' ? license : undefined,2234 };2235}22362237export async function applyIncomingVault(2238 blobJson: string,2239 remoteTimestamp: number,2240 remoteLicense?: string,2241): Promise<boolean> {2242 const raw = await chrome.storage.local.get('peach_last_modified_at');2243 const rawTs = (raw as Record<string, unknown>)['peach_last_modified_at'];2244 const localTimestamp = typeof rawTs === 'string' ? parseInt(rawTs) : 0;22452246 if (remoteLicense) {2247 const licenseRaw = await chrome.storage.local.get('peach_license');2248 const existing = (licenseRaw as Record<string, unknown>)['peach_license'];2249 if (!existing) {2250 await chrome.storage.local.set({ peach_license: remoteLicense });2251 }2252 }22532254 if (remoteTimestamp <= localTimestamp) return false;22552256 2257 2258 2259 2260 2261 let parsed: unknown;2262 try {2263 parsed = JSON.parse(blobJson);2264 } catch {2265 throw new Error('invalid_incoming_vault_blob');2266 }2267 if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {2268 throw new Error('invalid_incoming_vault_blob');2269 }2270 const { a, b } = parsed as { a?: unknown; b?: unknown };2271 if (!hasValidVaultSchema(a)) {2272 throw new Error('invalid_incoming_vault_blob');2273 }22742275 2276 2277 2278 try {2279 await takeVaultSnapshot('sync_received');2280 } catch (snapshotErr) {2281 console.warn('[Peach] vault snapshot failed during sync:', snapshotErr);2282 if (String(snapshotErr).includes('QUOTA') || String(snapshotErr).includes('quota')) {2283 try {2284 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2285 } catch {2286 2287 }2288 }2289 }22902291 await writeSlot(SLOT_A, a);2292 if (hasValidVaultSchema(b)) await writeSlot(SLOT_B, b);2293 await chrome.storage.local.set({2294 peach_last_sync_at: String(Date.now()),2295 peach_last_modified_at: String(remoteTimestamp),2296 });22972298 return true;2299}23002301230223032304230523062307230823092310231123122313export async function getSyncIdentity(): Promise<{2314 privateKeyBytes: Uint8Array;2315 publicKeyBytes: Uint8Array;2316 publicKeyHex: string;2317}> {2318 if (!derivedKey) throw new Error('vault_locked');2319 const raw = await chrome.storage.local.get('peach_sync_identity');2320 const stored = (raw as Record<string, unknown>)['peach_sync_identity'];23212322 if (typeof stored === 'string') {2323 const parsed = JSON.parse(stored) as {2324 pub: string;2325 priv?: string;2326 enc_priv?: string;2327 enc_nonce?: string;2328 enc_tag?: string;2329 };2330 const publicKeyBytes = base64DecodeBytes(parsed.pub);23312332 if (parsed.enc_priv && parsed.enc_nonce && parsed.enc_tag) {2333 const privHex = await decrypt(2334 derivedKey,2335 parsed.enc_nonce,2336 parsed.enc_priv,2337 parsed.enc_tag,2338 );2339 const privateKeyBytes = unhex(privHex);2340 return { privateKeyBytes, publicKeyBytes, publicKeyHex: bytesToHex(publicKeyBytes) };2341 }23422343 if (parsed.priv) {2344 2345 const privateKeyBytes = base64DecodeBytes(parsed.priv);2346 await persistEncryptedSyncIdentity(privateKeyBytes, publicKeyBytes);2347 return { privateKeyBytes, publicKeyBytes, publicKeyHex: bytesToHex(publicKeyBytes) };2348 }2349 }23502351 2352 const { generateKeyPair } = await import('@stablelib/x25519');2353 const keypair = generateKeyPair();2354 await persistEncryptedSyncIdentity(keypair.secretKey, keypair.publicKey);2355 return {2356 privateKeyBytes: keypair.secretKey,2357 publicKeyBytes: keypair.publicKey,2358 publicKeyHex: bytesToHex(keypair.publicKey),2359 };2360}23612362async function persistEncryptedSyncIdentity(2363 privateKeyBytes: Uint8Array,2364 publicKeyBytes: Uint8Array,2365): Promise<void> {2366 if (!derivedKey) throw new Error('vault_locked');2367 const enc = await encrypt(derivedKey, hex(privateKeyBytes));2368 await chrome.storage.local.set({2369 peach_sync_identity: JSON.stringify({2370 pub: base64EncodeBytes(publicKeyBytes),2371 enc_priv: enc.ciphertext,2372 enc_nonce: enc.nonce,2373 enc_tag: enc.tag,2374 }),2375 });2376}23772378237923802381export async function reloadEntriesAfterSync(): Promise<boolean> {2382 if (!derivedKey) return false;2383 const slotA = await loadSlot(SLOT_A);2384 if (!slotA) return false;2385 try {2386 const payload = await tryDecryptSlot(slotA, derivedKey);2387 if (!payload) return false;2388 entries = payload.entries;2389 buildDomainIndexSync(entries);2390 return true;2391 } catch {2392 return false;2393 }2394}23952396function bytesToHex(bytes: Uint8Array): string {2397 return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');2398}23992400function base64EncodeBytes(bytes: Uint8Array): string {2401 let binary = '';2402 for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);2403 return btoa(binary);2404}24052406function base64DecodeBytes(b64: string): Uint8Array {2407 const binary = atob(b64);2408 const bytes = new Uint8Array(binary.length);2409 for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);2410 return bytes;2411}