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 182 183 const encoder = new TextEncoder();184 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));185 const hashHex = Array.from(new Uint8Array(hashBuffer))186 .map((b) => b.toString(16).padStart(2, "0"))187 .join("")188 .toUpperCase();189190 const prefix = hashHex.slice(0, 5);191 const suffix = hashHex.slice(5);192193 try {194 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);195 if (!res.ok) return 0;196 const text = await res.text();197 const match = text.split("\r\n").find((l) => l.startsWith(suffix));198 return match ? parseInt(match.split(":")[1], 10) : 0;199 } catch {200 return 0; 201 }202}203204export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {205 const fingerprint = await cardFingerprint(last4, expMonth, expYear);206 await loadSettings();207 settings = {208 ...settings,209 declinedCardFingerprints: [...(settings.declinedCardFingerprints ?? []), fingerprint],210 };211 await persistSettings();212}213214export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {215 await loadSettings();216 const fingerprint = await cardFingerprint(last4, expMonth, expYear);217 return (settings.declinedCardFingerprints ?? []).includes(fingerprint);218}219220async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {221 const encoder = new TextEncoder();222 const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));223 return Array.from(new Uint8Array(buf).slice(0, 8))224 .map((b) => b.toString(16).padStart(2, "0"))225 .join("");226}227228function hex(buf: Uint8Array): string {229 return Array.from(buf)230 .map((b) => b.toString(16).padStart(2, "0"))231 .join("");232}233234function unhex(s: string): Uint8Array {235 const out = new Uint8Array(s.length / 2);236 for (let i = 0; i < s.length; i += 2) {237 out[i / 2] = parseInt(s.slice(i, i + 2), 16);238 }239 return out;240}241242243244245246247248249250251252253async function generateAndWrapRootKey(): Promise<{254 rawRootKey: string; 255 recoverySalt: string;256 wrappedRootKey: string;257 wrappedNonce: string;258 wrappedTag: string;259}> {260 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));261262 263 const words = await rootKeyToWords(rootKeyBytes);264 const wordMaterial = await wordsToRootKeyMaterial(words);265 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));266 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);267268 269 270 const nonce = crypto.getRandomValues(new Uint8Array(12));271 const encoded = new TextEncoder().encode(hex(rootKeyBytes));272 const raw = await crypto.subtle.encrypt(273 {274 name: "AES-GCM",275 iv: nonce,276 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,277 tagLength: 128,278 },279 recoveryKey,280 encoded281 );282 283 const rawU8 = new Uint8Array(raw);284 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));285 const tag = hex(rawU8.slice(rawU8.length - 16));286287 return {288 rawRootKey: hex(rootKeyBytes),289 recoverySalt: hex(recoverySalt),290 wrappedRootKey: ciphertext,291 wrappedNonce: hex(nonce),292 wrappedTag: tag,293 };294}295296297298299300301export async function unwrapRootKeyForRecovery(302 wrappedRootKey: string,303 wrappedNonce: string,304 wrappedTag: string,305 recoverySaltHex: string,306 words: string[]307): Promise<void> {308 const wordMaterial = await wordsToRootKeyMaterial(words);309 const recoverySalt = unhex(recoverySaltHex);310 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);311312 313 314 315 316 317 const nonceRaw = unhex(wrappedNonce);318 const ciphertextRaw = unhex(wrappedRootKey);319 const tagRaw = unhex(wrappedTag);320 321 322 const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;323 const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);324 combined.set(ciphertextRaw, 0);325 combined.set(tagRaw, ciphertextRaw.length);326 const raw = await crypto.subtle.decrypt(327 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },328 recoveryKey,329 combined330 );331 const decryptedHex = new TextDecoder().decode(raw);332 rootKey = unhex(decryptedHex);333}334335336337338339340341342343344export async function rewrapRootKeyForPassword(345 newDerivedKey: Uint8Array,346 _newSalt: Uint8Array347): Promise<void> {348 if (!rootKey) throw new Error("root key not set");349 const slotA = await loadSlot(SLOT_A);350 if (!slotA) throw new Error("missing slot A");351 const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);352 await writeSlot(SLOT_A, { ...slotA, ...pwRoot });353}354355356357358359360async function reencryptVaultForNewKey(361 newKey: Uint8Array,362 slotA: StoredVault363): Promise<void> {364 const plaintext = encodePayload(entries, duressConfigured);365 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);366 await writeSlot(SLOT_A, {367 ...slotA,368 nonce,369 ciphertext,370 tag,371 });372}373374375376377378379export async function changePasswordWithNewKey(380 newSalt: Uint8Array,381 newKey: Uint8Array382): Promise<void> {383 const slotA = await loadSlot(SLOT_A);384 if (!slotA) throw new Error("missing slot A");385 386 387 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });388 if (derivedKey) zeroKey(derivedKey);389 derivedKey = newKey;390 391 392 393 394 if (rootKey) {395 const slotAfter = await loadSlot(SLOT_A);396 if (slotAfter) {397 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);398 await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });399 }400 }401 if (!duressConfigured) {402 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));403 }404 await stampLocalVaultMutation();405}406407408409410411412export { deriveKey, generateSalt };413414export async function loadSlotA(): Promise<StoredVault | null> {415 return loadSlot(SLOT_A);416}417418419420421422423424425426427428429430async function deriveRootKeyFromDerivedKey(431 derivedKeyBytes: Uint8Array,432 slotA: StoredVault,433): Promise<Uint8Array | null> {434 if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {435 return null;436 }437 try {438 const rootHex = await decrypt(439 derivedKeyBytes,440 slotA.pw_root_nonce,441 slotA.pw_root_ciphertext,442 slotA.pw_root_tag,443 );444 return unhex(rootHex);445 } catch {446 return null;447 }448}449450451452async function wrapRootKeyForDerivedKey(453 derivedKeyBytes: Uint8Array,454 rootKeyBytes: Uint8Array,455): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {456 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes));457 return {458 pw_root_ciphertext: ciphertext,459 pw_root_nonce: nonce,460 pw_root_tag: tag,461 };462}463464465466467468469470471export async function migrateToRecovery(): Promise<void> {472 if (!derivedKey || vaultMode !== "real") {473 throw new Error("vault must be unlocked in real mode");474 }475 const slotA = await loadSlot(SLOT_A);476 if (!slotA) throw new Error("missing slot A");477 const hasRecoveryEscrow =478 !!slotA.recovery_salt && !!slotA.recovery_wrapped_root_key;479 const hasPwRoot = !!slotA.pw_root_ciphertext && !!slotA.pw_root_nonce;480 if (hasRecoveryEscrow && hasPwRoot) return; 481482 483 484 485 486 487 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =488 await generateAndWrapRootKey();489 rootKey = unhex(rawRootKey);490491 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);492 const nextSlotA: StoredVault = {493 ...slotA,494 recovery_salt: recoverySalt,495 recovery_wrapped_root_key: wrappedRootKey,496 recovery_nonce: wrappedNonce,497 recovery_tag: wrappedTag,498 ...pwRoot,499 };500 await writeSlot(SLOT_A, nextSlotA);501 await stampLocalVaultMutation();502}503504505506507508509export async function getRecoveryWords(): Promise<string[]> {510 if (!rootKey) {511 throw new Error("recovery not set up — vault may need migration");512 }513 return rootKeyToWords(rootKey);514}515516async function loadSlot(key: string): Promise<StoredVault | null> {517 const result = await chrome.storage.local.get(key);518 return (result as Record<string, StoredVault | undefined>)[key] ?? null;519}520521async function writeSlot(key: string, vault: StoredVault): Promise<void> {522 try {523 await chrome.storage.local.set({ [key]: vault });524 } catch (err) {525 throw mapStorageError(err);526 }527}528529async function stampLocalVaultMutation(): Promise<void> {530 await chrome.storage.local.set({531 peach_last_modified_at: String(Date.now()),532 });533}534535536537538539540async function ensureMigrated(): Promise<void> {541 if (migrationRan) return;542 migrationRan = true;543544 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];545 const raw = await chrome.storage.local.get(keys);546 const slotAExists = raw[SLOT_A] !== undefined;547 const slotBExists = raw[SLOT_B] !== undefined;548 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;549 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;550551 552 if (!slotAExists && !legacyMain) return;553554 555 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;556557 let didMigrate = false;558 if (legacyMain && !slotAExists) {559 await writeSlot(SLOT_A, legacyMain);560 didMigrate = true;561 }562563 564 if (!slotBExists) {565 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;566 if (legacyDecoy) {567 await writeSlot(SLOT_B, legacyDecoy);568 didMigrate = true;569 } else if (target) {570 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));571 didMigrate = true;572 }573 }574575 const toRemove: string[] = [];576 if (legacyMain) toRemove.push(LEGACY_MAIN);577 if (legacyDecoy) toRemove.push(LEGACY_DECOY);578 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);579 if (toRemove.length > 0) {580 await chrome.storage.local.remove(toRemove);581 didMigrate = true;582 }583 if (didMigrate) {584 await stampLocalVaultMutation();585 }586}587588589590591592593594595596597598function padPlaintext(json: string): string {599 const encoded = new TextEncoder().encode(json);600 const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);601 const padLen = target - encoded.length;602 return json + " ".repeat(padLen);603}604605606607608609610611612613614615const CSPRNG_CHUNK = 65_536;616617function fillRandom(buf: Uint8Array): Uint8Array {618 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {619 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);620 const chunk = new Uint8Array(end - offset);621 crypto.getRandomValues(chunk);622 buf.set(chunk, offset);623 }624 return buf;625}626627function generateNoiseBlob(ciphertextBytes: number): StoredVault {628 const salt = crypto.getRandomValues(new Uint8Array(16));629 const nonce = crypto.getRandomValues(new Uint8Array(12));630 const cipher = fillRandom(new Uint8Array(ciphertextBytes));631 const tag = crypto.getRandomValues(new Uint8Array(16));632 return {633 version: 1,634 salt: hex(salt),635 nonce: hex(nonce),636 ciphertext: hex(cipher),637 tag: hex(tag),638 };639}640641642643644645type VaultPayload = {646 entries: VaultEntry[];647 duressConfigured?: boolean;648};649650type DecryptedPayload = {651 entries: VaultEntry[];652 duressConfigured: boolean;653 654655656657658659660 migratedFromLegacy: boolean;661};662663function encodePayload(entries: VaultEntry[], duress: boolean): string {664 const envelope: VaultPayload = { entries, duressConfigured: duress };665 return padPlaintext(JSON.stringify(envelope));666}667668function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {669 if ("created" in raw || "updated" in raw) return true;670 const flat = raw as Partial<VaultEntry>;671 if (flat.type === "passkey") return true;672 673 if (flat.rpId !== undefined && flat.passkey === undefined) return true;674 return false;675}676677function decodePayload(plain: string): DecryptedPayload {678 const parsed = JSON.parse(plain) as679 | Array<VaultEntry | LegacyVaultEntry>680 | VaultPayload;681682 const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];683 const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);684685 const normalized = rawEntries.map(normalizeEntry);686 const { merged } = mergePasskeyOnlyIntoLogins(normalized);687688 if (Array.isArray(parsed)) {689 return {690 entries: normalized,691 duressConfigured: false,692 migratedFromLegacy: true, 693 };694 }695696 return {697 entries: normalized,698 duressConfigured: parsed.duressConfigured === true,699 migratedFromLegacy: flatMigrationNeeded || merged,700 };701}702703704705706707708709710711712713714715716717718719720721722723724725726function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {727 let merged = false;728 const now = Date.now();729730 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);731 const byRpId = new Map<string, VaultEntry[]>();732 for (const e of candidates) {733 const rpId = e.passkey!.rpId;734 const list = byRpId.get(rpId);735 if (list) list.push(e);736 else byRpId.set(rpId, [e]);737 }738739 for (const [rpId, group] of byRpId) {740 if (group.length === 0) continue;741 group.sort((a, b) => {742 const aT =743 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;744 const bT =745 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;746 return bT - aT;747 });748 const winner = group[0]!;749750 751 752 753 const loginTarget = entries.find((e) => {754 if (e.deletedAt || e.passkey || e.type !== "login") return false;755 if (e.url && extractDomain(e.url) === rpId) return true;756 for (const extra of e.additionalUrls ?? []) {757 if (extractDomain(extra) === rpId) return true;758 }759 return false;760 });761762 if (loginTarget) {763 loginTarget.passkey = winner.passkey;764 loginTarget.modifiedAt = now;765 for (const dup of group) {766 if (!dup.deletedAt) {767 dup.deletedAt = now;768 dup.modifiedAt = now;769 }770 }771 merged = true;772 } else if (group.length > 1) {773 for (let i = 1; i < group.length; i += 1) {774 const dup = group[i]!;775 if (!dup.deletedAt) {776 dup.deletedAt = now;777 dup.modifiedAt = now;778 }779 }780 merged = true;781 }782 }783784 return { merged };785}786787788789790791792793function hasValidVaultSchema(v: unknown): v is StoredVault {794 if (typeof v !== "object" || v === null) return false;795 const r = v as Record<string, unknown>;796 return (797 r.version === 1 &&798 typeof r.salt === "string" &&799 typeof r.nonce === "string" &&800 typeof r.ciphertext === "string" &&801 typeof r.tag === "string"802 );803}804805export type TryDecryptResult =806 | { status: "ok"; payload: DecryptedPayload }807 | { status: "wrong_key" }808 | { status: "corrupted"; reason: string };809810811812813814815export async function tryDecryptSlot(816 vault: StoredVault,817 key: Uint8Array818): Promise<DecryptedPayload | null> {819 const res = await tryDecryptSlotResult(vault, key);820 return res.status === "ok" ? res.payload : null;821}822823export async function tryDecryptSlotResult(824 vault: StoredVault,825 key: Uint8Array826): Promise<TryDecryptResult> {827 if (!hasValidVaultSchema(vault)) {828 return { status: "corrupted", reason: "invalid_slot_schema" };829 }830 let plain: string;831 try {832 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);833 } catch {834 return { status: "wrong_key" };835 }836 try {837 const parsed = JSON.parse(plain) as unknown;838 if (typeof parsed !== "object" || parsed === null) {839 return { status: "corrupted", reason: "invalid_payload_shape" };840 }841 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {842 return { status: "corrupted", reason: "invalid_payload_shape" };843 }844 return { status: "ok", payload: decodePayload(plain) };845 } catch {846 return { status: "corrupted", reason: "invalid_payload_json" };847 }848}849850851852853854function buildDomainIndexSync(all: VaultEntry[]): void {855 domainIndex = new Map();856857 for (const entry of all) {858 if (entry.deletedAt) continue;859 if (entry.type === "note" || entry.type === "card") continue;860861 862 863 864 865 const domains = new Set<string>();866 const primary = extractDomain(entry.url ?? "");867 if (primary) domains.add(primary);868 for (const extra of entry.additionalUrls ?? []) {869 const d = extractDomain(extra);870 if (d) domains.add(d);871 }872 if (domains.size === 0) continue;873874 for (const domain of domains) {875 const bucket = domainIndex.get(domain) ?? [];876 bucket.push(entry);877 domainIndex.set(domain, bucket);878 }879 }880}881882export async function createVault(password: string): Promise<void> {883 await ensureMigrated();884885 const salt = generateSalt();886 derivedKey = await deriveKey(password, salt);887 entries = [];888 vaultMode = "real";889 duressConfigured = false;890 buildDomainIndexSync(entries);891892 893 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =894 await generateAndWrapRootKey();895 rootKey = unhex(rawRootKey);896897 const plaintext = encodePayload(entries, duressConfigured);898 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);899900 901 902 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);903904 const slotA: StoredVault = {905 version: 1,906 salt: hex(salt),907 nonce,908 ciphertext,909 tag,910 recovery_salt: recoverySalt,911 recovery_wrapped_root_key: wrappedRootKey,912 recovery_nonce: wrappedNonce,913 recovery_tag: wrappedTag,914 ...pwRoot,915 };916 await writeSlot(SLOT_A, slotA);917918 919 920 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));921 await stampLocalVaultMutation();922923 touchAutoLock();924}925926export async function hasVault(): Promise<boolean> {927 await ensureMigrated();928 return (await loadSlot(SLOT_A)) !== null;929}930931932933934935936export async function isDuressEnabled(): Promise<boolean> {937 return vaultMode === "real" && duressConfigured;938}939940export async function setupDuress(duressPassword: string): Promise<void> {941 if (!derivedKey || vaultMode !== "real") {942 throw new Error("vault must be unlocked in real mode");943 }944 await ensureMigrated();945946 947 948 949 const slotA = await loadSlot(SLOT_A);950 if (!slotA) throw new Error("missing slot A");951952 const decoySalt = generateSalt();953 const decoyKey = await deriveKey(duressPassword, decoySalt);954 try {955 956 957 const targetBytes = slotA.ciphertext.length / 2;958 let plaintext = encodePayload([], false);959 const encoded = new TextEncoder().encode(plaintext);960 if (encoded.length < targetBytes) {961 plaintext = plaintext + " ".repeat(targetBytes - encoded.length);962 }963 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);964 await writeSlot(SLOT_B, {965 version: 1,966 salt: hex(decoySalt),967 nonce,968 ciphertext,969 tag,970 });971 } finally {972 zeroKey(decoyKey);973 }974975 976 977 duressConfigured = true;978 await persistSlotA();979}980981export async function disableDuress(): Promise<void> {982 if (!derivedKey || vaultMode !== "real") {983 throw new Error("vault must be unlocked in real mode");984 }985 await ensureMigrated();986987 988 989 990 const slotA = await loadSlot(SLOT_A);991 if (slotA) {992 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));993 }994995 duressConfigured = false;996 await persistSlotA();997}998999100010011002100310041005export async function verifyMainPassword(password: string): Promise<boolean> {1006 await ensureMigrated();1007 const slotA = await loadSlot(SLOT_A);1008 if (!slotA) return false;1009 const key = await deriveKey(password, unhex(slotA.salt));1010 try {1011 return (await tryDecryptSlot(slotA, key)) !== null;1012 } finally {1013 zeroKey(key);1014 }1015}10161017export async function unlock(password: string): Promise<boolean> {1018 await ensureMigrated();10191020 1021 1022 1023 1024 1025 const [slotA, slotB] = await Promise.all([1026 loadSlot(SLOT_A),1027 loadSlot(SLOT_B),1028 ]);10291030 1031 const [keyA, keyB] = await Promise.all([1032 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),1033 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),1034 ]);10351036 1037 1038 1039 const [resultA, resultB] = await Promise.all([1040 keyA && slotA1041 ? tryDecryptSlotResult(slotA, keyA)1042 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1043 keyB && slotB1044 ? tryDecryptSlotResult(slotB, keyB)1045 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1046 ]);1047 const payloadA = resultA.status === "ok" ? resultA.payload : null;1048 const payloadB = resultB.status === "ok" ? resultB.payload : null;10491050 if (payloadA && keyA) {1051 derivedKey = keyA;1052 vaultMode = "real";1053 entries = payloadA.entries;1054 duressConfigured = payloadA.duressConfigured;1055 buildDomainIndexSync(entries);1056 await loadSettings();1057 touchAutoLock();1058 if (keyB) zeroKey(keyB);10591060 1061 if (slotA) {1062 const needsRecoverySetup =1063 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;1064 if (!needsRecoverySetup) {1065 rootKey = await deriveRootKeyFromDerivedKey(keyA, slotA);1066 }1067 1068 1069 1070 1071 if (needsRecoverySetup || !rootKey) {1072 try {1073 await migrateToRecovery();1074 if (needsRecoverySetup) {1075 1076 1077 1078 await chrome.storage.local.set({ peach_recovery_pending: '1' });1079 }1080 } catch (migrationErr) {1081 console.warn('[Peach] recovery migration failed:', migrationErr);1082 }1083 }1084 }10851086 1087 1088 1089 if (payloadA.migratedFromLegacy) {1090 await persistSlotA();1091 }1092 return true;1093 }10941095 if (payloadB && keyB) {1096 derivedKey = keyB;1097 vaultMode = "decoy";1098 entries = payloadB.entries;1099 duressConfigured = false;1100 buildDomainIndexSync(entries);1101 await loadSettings();1102 touchAutoLock();1103 if (keyA) zeroKey(keyA);1104 return true;1105 }11061107 if (keyA) zeroKey(keyA);1108 if (keyB) zeroKey(keyB);1109 1110 1111 if (resultA.status === "corrupted") {1112 lastUnlockFailureReason = resultA.reason;1113 } else if (resultB.status === "corrupted") {1114 lastUnlockFailureReason = resultB.reason;1115 }1116 return false;1117}11181119export function lock(): void {1120 if (derivedKey) {1121 zeroKey(derivedKey);1122 derivedKey = null;1123 }1124 if (rootKey) {1125 rootKey.fill(0);1126 rootKey = null;1127 }11281129 entries = [];1130 domainIndex = new Map();1131 vaultMode = "real";1132 duressConfigured = false;1133 clearAutoLock();1134}11351136export function isUnlocked(): boolean {1137 return derivedKey !== null;1138}1139114011411142export async function unlockWithKey(1143 masterKeyHex: string,1144): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1145 try {1146 const masterKeyBytes = unhex(masterKeyHex);1147 const stored = await loadSlot(SLOT_A);1148 if (!stored) return { ok: false, error: "invalid_key" };1149 const payload = await tryDecryptSlot(stored, masterKeyBytes);1150 if (!payload) return { ok: false, error: "invalid_key" };11511152 derivedKey = masterKeyBytes;1153 vaultMode = "real";1154 entries = payload.entries;1155 duressConfigured = payload.duressConfigured;1156 buildDomainIndexSync(entries);1157 await loadSettings();1158 touchAutoLock();1159 return { ok: true, entries };1160 } catch {1161 return { ok: false, error: "invalid_key" };1162 }1163}11641165116611671168116911701171117211731174117511761177117811791180118111821183export async function unlockFromCodex(params: {1184 slotA: string; 1185 slotB: string | null; 1186 timestamp: number;1187 masterPassphrase: string;1188}): Promise<1189 | { ok: true; entryCount: number }1190 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1191> {1192 let parsedSlotA: StoredVault;1193 let parsedSlotB: StoredVault | null = null;1194 try {1195 const rawA = JSON.parse(params.slotA) as unknown;1196 if (!hasValidVaultSchema(rawA)) {1197 return { ok: false, reason: "invalid_slot_format" };1198 }1199 parsedSlotA = rawA;1200 if (params.slotB !== null) {1201 const rawB = JSON.parse(params.slotB) as unknown;1202 if (!hasValidVaultSchema(rawB)) {1203 return { ok: false, reason: "invalid_slot_format" };1204 }1205 parsedSlotB = rawB;1206 }1207 } catch {1208 return { ok: false, reason: "invalid_slot_format" };1209 }12101211 1212 1213 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1214 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey);1215 if (!trialPayload) {1216 zeroKey(trialKey);1217 return { ok: false, reason: "wrong_passphrase" };1218 }1219 1220 1221 zeroKey(trialKey);12221223 1224 1225 await takeVaultSnapshot("restore");12261227 await writeSlot(SLOT_A, parsedSlotA);1228 if (parsedSlotB) {1229 await writeSlot(SLOT_B, parsedSlotB);1230 } else {1231 1232 1233 1234 await writeSlot(1235 SLOT_B,1236 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1237 );1238 }1239 await chrome.storage.local.set({1240 peach_last_modified_at: String(params.timestamp),1241 });12421243 1244 1245 const ok = await unlock(params.masterPassphrase);1246 if (!ok) {1247 1248 1249 1250 return { ok: false, reason: "wrong_passphrase" };1251 }1252 return { ok: true, entryCount: getEntries().length };1253}1254125512561257export function getDerivedKeyHex(): string {1258 if (!derivedKey) throw new Error("vault_not_unlocked");1259 return hex(derivedKey);1260}12611262export function getEntries(): VaultEntry[] {1263 if (!derivedKey) throw new Error("vault_locked");1264 return entries.filter((entry) => !entry.deletedAt);1265}12661267const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;12681269export function getIdentities(): SavedIdentity[] {1270 if (!derivedKey) throw new Error("vault_locked");1271 const active = entries.filter((e) => !e.deletedAt && e.type === "login");12721273 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1274 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();12751276 for (const entry of active) {1277 const value = entry.username?.trim();1278 if (!value) continue;1279 const map = EMAIL_RE.test(value) ? emailMap : usernameMap;1280 const existing = map.get(value);1281 const domain = extractDomain(entry.url ?? "") ?? "";1282 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;1283 if (!existing || modifiedAt > existing.lastUsed) {1284 map.set(value, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1285 } else {1286 map.set(value, { ...existing, count: existing.count + 1 });1287 }1288 }12891290 const toIdentity = (type: "email" | "username") =>1291 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1292 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1293 });12941295 return [1296 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1297 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1298 ];1299}13001301export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1302 if (!derivedKey) throw new Error("vault_locked");13031304 const now = Date.now();1305 const added: VaultEntry[] = [];1306 for (const draft of drafts) {1307 try {1308 const sanitized = sanitizeEntryDraft(draft);1309 const nextEntry: VaultEntry = {1310 ...sanitized,1311 id: crypto.randomUUID(),1312 createdAt: now,1313 modifiedAt: now,1314 };1315 entries.push(nextEntry);1316 added.push(nextEntry);1317 } catch {1318 1319 }1320 }13211322 if (added.length > 0) {1323 await persistEntries();1324 }13251326 return added;1327}132813291330133113321333133413351336export async function restoreEntries(1337 incoming: VaultEntry[],1338 mode: "replace" | "merge"1339): Promise<{ added: number; updated: number; skipped: number }> {1340 if (!derivedKey) throw new Error("vault_locked");13411342 1343 1344 1345 if (mode === "replace" && incoming.length === 0) {1346 throw new Error("refuse to replace with empty entry set");1347 }13481349 let added = 0;1350 let updated = 0;1351 let skipped = 0;13521353 if (mode === "replace") {1354 entries = [];1355 for (const entry of incoming) {1356 const normalized = sanitizeRestoredEntry(entry);1357 if (!normalized) {1358 skipped += 1;1359 continue;1360 }1361 entries.push(normalized);1362 added += 1;1363 }1364 } else {1365 const byId = new Map<string, VaultEntry>();1366 for (const e of entries) byId.set(e.id, e);13671368 for (const entry of incoming) {1369 const normalized = sanitizeRestoredEntry(entry);1370 if (!normalized) {1371 skipped += 1;1372 continue;1373 }1374 const existing = byId.get(normalized.id);1375 if (!existing) {1376 entries.push(normalized);1377 added += 1;1378 } else if (normalized.modifiedAt > existing.modifiedAt) {1379 const idx = entries.findIndex((e) => e.id === existing.id);1380 if (idx !== -1) {1381 entries[idx] = normalized;1382 updated += 1;1383 }1384 } else {1385 skipped += 1;1386 }1387 }1388 }13891390 await persistEntries("import");1391 return { added, updated, skipped };1392}1393139413951396139713981399function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1400 if (!entry || typeof entry !== "object") return null;1401 const title = typeof entry.title === "string" ? entry.title.trim() : "";1402 if (!title) return null;1403 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1404 const now = Date.now();1405 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1406 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;14071408 1409 1410 1411 const migrated: VaultEntry = migrateLegacyPasskey(1412 {1413 ...entry,1414 id,1415 title,1416 type: entry.type ?? "login",1417 createdAt,1418 modifiedAt,1419 },1420 entry1421 );14221423 1424 1425 if (1426 migrated.type === "login" &&1427 !migrated.passkey &&1428 typeof migrated.password !== "string"1429 ) {1430 return null;1431 }1432 if (migrated.passkey && !migrated.passkey.credentialId) {1433 return null;1434 }14351436 return migrated;1437}143814391440144114421443export async function clearVaultEntries(1444 confirmation: "REPLACE_ALL"1445): Promise<void> {1446 if (confirmation !== "REPLACE_ALL") {1447 throw new Error("clearVaultEntries requires explicit confirmation");1448 }1449 if (!derivedKey) throw new Error("vault_locked");1450 for (const entry of entries) {1451 if (!entry.deletedAt) {1452 entry.deletedAt = Date.now();1453 entry.modifiedAt = Date.now();1454 }1455 }1456 await persistEntries();1457}14581459export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1460 if (!derivedKey) throw new Error("vault_locked");14611462 const sanitized = sanitizeEntryDraft(entry);1463 const now = Date.now();1464 const nextEntry: VaultEntry = {1465 ...sanitized,1466 id: crypto.randomUUID(),1467 createdAt: now,1468 modifiedAt: now,1469 1470 1471 passwordChangedAt: sanitized.password ? now : undefined,1472 };14731474 1475 1476 1477 const rollback = entries;1478 entries = [...entries, nextEntry];1479 try {1480 await persistEntries();1481 } catch (err) {1482 entries = rollback;1483 throw err;1484 }14851486 1487 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1488 checkBreach(nextEntry.password).then(async (count) => {1489 if (count > 0) {1490 const idx = entries.findIndex((e) => e.id === nextEntry.id);1491 if (idx !== -1) {1492 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };1493 await persistEntries();1494 }1495 }1496 });1497 }14981499 return nextEntry;1500}15011502export async function updateEntry(1503 id: string,1504 patch: Partial<VaultEntry>1505): Promise<void> {1506 if (!derivedKey) throw new Error("vault_locked");15071508 const idx = entries.findIndex((entry) => entry.id === id);1509 if (idx === -1 || entries[idx].deletedAt) {1510 throw new Error("entry_not_found");1511 }15121513 const current = entries[idx];1514 const sanitizedPatch = sanitizeEntryPatch(patch);1515 1516 1517 1518 const passwordChanged =1519 "password" in patch &&1520 typeof sanitizedPatch.password === "string" &&1521 sanitizedPatch.password !== current.password;1522 entries[idx] = {1523 ...current,1524 ...sanitizedPatch,1525 modifiedAt: Date.now(),1526 ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),1527 };15281529 1530 1531 1532 1533 if (1534 entries[idx].type === "login" &&1535 !entries[idx].password &&1536 !entries[idx].passkey1537 ) {1538 entries[idx] = current;1539 throw new Error("password or passkey required");1540 }15411542 1543 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1544 entries[idx] = current;1545 throw new Error("card number is required for card entries");1546 }15471548 await persistEntries();15491550 1551 1552 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1553 entries[idx] = { ...entries[idx], breach: undefined };1554 await persistEntries();1555 const password = entries[idx].password;1556 if (password) {1557 checkBreach(password).then(async (count) => {1558 const i = entries.findIndex((e) => e.id === id);1559 if (i !== -1 && count > 0) {1560 entries[i] = { ...entries[i], breach: count, modifiedAt: Date.now() };1561 await persistEntries();1562 }1563 });1564 }1565 }1566}15671568export async function deleteEntry(id: string): Promise<void> {1569 if (!derivedKey) throw new Error("vault_locked");15701571 const entry = entries.find((item) => item.id === id);1572 if (!entry || entry.deletedAt) {1573 throw new Error("entry_not_found");1574 }15751576 entry.deletedAt = Date.now();1577 entry.modifiedAt = Date.now();1578 await persistEntries();1579}15801581export function getDeletedEntries(): VaultEntry[] {1582 if (!derivedKey) return [];1583 return entries.filter((e) => !!e.deletedAt);1584}15851586export async function restoreEntry(id: string): Promise<void> {1587 if (!derivedKey) throw new Error("vault_locked");1588 const entry = entries.find((e) => e.id === id && e.deletedAt);1589 if (!entry) throw new Error("deleted entry not found");1590 delete entry.deletedAt;1591 entry.modifiedAt = Date.now();1592 await persistEntries();1593 buildDomainIndexSync(entries);1594}15951596export async function purgeEntry(id: string): Promise<void> {1597 if (!derivedKey) throw new Error("vault_locked");1598 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1599 if (idx === -1) throw new Error("deleted entry not found");1600 entries.splice(idx, 1);1601 await persistEntries();1602}160316041605export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1606 if (!derivedKey || retentionMs === 0) return 0;1607 const cutoff = Date.now() - retentionMs;1608 const before = entries.length;1609 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1610 const purged = before - entries.length;1611 if (purged > 0) await persistEntries();1612 return purged;1613}16141615export function queryDomain(domain: string): VaultEntry[] {1616 return domainIndex.get(domain) ?? [];1617}16181619export function getEntryById(id: string): VaultEntry | undefined {1620 return entries.find((entry) => entry.id === id && !entry.deletedAt);1621}16221623async function persistSlotA(1624 reason: VaultSnapshot["reason"] = "local_save",1625): Promise<StoredVault> {1626 if (!derivedKey) throw new Error("vault_locked");16271628 const slotA = await loadSlot(SLOT_A);1629 if (!slotA) throw new Error("missing slot A");16301631 1632 1633 await takeVaultSnapshot(reason);16341635 const plaintext = encodePayload(entries, duressConfigured);1636 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);16371638 1639 const nextSlotA: StoredVault = {1640 ...slotA,1641 nonce,1642 ciphertext,1643 tag,1644 1645 };1646 await writeSlot(SLOT_A, nextSlotA);16471648 1649 1650 1651 if (!duressConfigured) {1652 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1653 }1654 await stampLocalVaultMutation();16551656 return nextSlotA;1657}16581659async function persistSlotB(): Promise<void> {1660 if (!derivedKey) throw new Error("vault_locked");16611662 const slotB = await loadSlot(SLOT_B);1663 if (!slotB) throw new Error("missing slot B");16641665 1666 1667 const plaintext = encodePayload(entries, false);1668 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);16691670 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1671 await stampLocalVaultMutation();1672}16731674async function persistEntries(1675 reason: VaultSnapshot["reason"] = "local_save",1676): Promise<void> {1677 if (!derivedKey) throw new Error("vault_locked");16781679 if (vaultMode === "decoy") {1680 await persistSlotB();1681 } else {1682 await persistSlotA(reason);1683 }1684 buildDomainIndexSync(entries);1685}16861687function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1688 if (!Array.isArray(values)) return undefined;1689 const seen = new Set<string>();1690 const cleaned: string[] = [];1691 for (const value of values) {1692 const trimmed = typeof value === "string" ? value.trim() : "";1693 if (!trimmed) continue;1694 const key = trimmed.toLowerCase();1695 if (seen.has(key)) continue;1696 seen.add(key);1697 cleaned.push(trimmed);1698 }1699 return cleaned.length > 0 ? cleaned : undefined;1700}17011702function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1703 const title = entry.title.trim();1704 1705 1706 1707 let type: VaultEntry["type"] = entry.type ?? "login";1708 if (type === "passkey") type = "login";17091710 if (!title) throw new Error("title is required");1711 1712 1713 if (type === "login" && !entry.password?.trim() && !entry.passkey) {1714 throw new Error("password or passkey required");1715 }1716 if (entry.passkey) {1717 const p = entry.passkey;1718 if (1719 !p.rpId ||1720 !p.credentialId ||1721 !p.publicKeyCose ||1722 !p.userHandle ||1723 !p.userName ||1724 (p.algorithm !== -7 && p.algorithm !== -8)1725 ) {1726 throw new Error("invalid passkey credential");1727 }1728 }1729 if (type === "card") {1730 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1731 throw new Error("cardholder name and card number are required");1732 }1733 }17341735 return {1736 type,1737 title,1738 username: type === "login" ? entry.username?.trim() ?? "" : undefined,1739 password: type === "login" ? entry.password : undefined,1740 url: type === "login" ? entry.url?.trim() ?? "" : undefined,1741 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1742 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1743 notes: entry.notes?.trim() || undefined,1744 breach: type === "login" ? entry.breach : undefined,1745 canary: type === "login" ? entry.canary : undefined,1746 tags: entry.tags,1747 passkey: entry.passkey,1748 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1749 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1750 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1751 expYear: type === "card" ? entry.expYear?.trim() : undefined,1752 cvc: type === "card" ? entry.cvc?.trim() : undefined,1753 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1754 network: type === "card" ? entry.network : undefined,1755 last4: type === "card" ? entry.last4?.trim() : undefined,1756 };1757}17581759function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1760 const nextPatch: Partial<VaultEntry> = {};17611762 if ("type" in patch) {1763 nextPatch.type = patch.type;1764 }17651766 if ("title" in patch) {1767 const title = patch.title?.trim() ?? "";1768 if (!title) throw new Error("title is required");1769 nextPatch.title = title;1770 }17711772 if ("url" in patch) {1773 nextPatch.url = patch.url?.trim() ?? "";1774 }17751776 if ("additionalUrls" in patch) {1777 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1778 const seen = new Set<string>();1779 const cleaned: string[] = [];1780 for (const value of incoming) {1781 const trimmed = typeof value === "string" ? value.trim() : "";1782 if (!trimmed) continue;1783 const key = trimmed.toLowerCase();1784 if (seen.has(key)) continue;1785 seen.add(key);1786 cleaned.push(trimmed);1787 }1788 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1789 }17901791 if ("username" in patch) {1792 nextPatch.username = patch.username?.trim() ?? "";1793 }17941795 if ("email" in patch) {1796 nextPatch.email = patch.email?.trim() || undefined;1797 }17981799 if ("password" in patch) {1800 const password = patch.password ?? "";1801 1802 nextPatch.password = password || undefined;1803 }18041805 if ("totp" in patch) {1806 nextPatch.totp = patch.totp?.trim() || undefined;1807 }18081809 if ("notes" in patch) {1810 nextPatch.notes = patch.notes?.trim() || undefined;1811 }18121813 if ("breach" in patch) {1814 nextPatch.breach = patch.breach;1815 }18161817 if ("canary" in patch) {1818 nextPatch.canary = patch.canary;1819 }18201821 if ("passkey" in patch) {1822 1823 1824 1825 nextPatch.passkey = patch.passkey;1826 }18271828 if ("tags" in patch) {1829 nextPatch.tags = patch.tags;1830 }18311832 if ("cardholderName" in patch) {1833 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1834 }1835 if ("cardNumber" in patch) {1836 const n = patch.cardNumber ?? "";1837 nextPatch.cardNumber = n || undefined;1838 }1839 if ("expMonth" in patch) {1840 nextPatch.expMonth = patch.expMonth?.trim() || undefined;1841 }1842 if ("expYear" in patch) {1843 nextPatch.expYear = patch.expYear?.trim() || undefined;1844 }1845 if ("cvc" in patch) {1846 nextPatch.cvc = patch.cvc?.trim() || undefined;1847 }1848 if ("cardNickname" in patch) {1849 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1850 }1851 if ("network" in patch) {1852 nextPatch.network = patch.network;1853 }1854 if ("last4" in patch) {1855 nextPatch.last4 = patch.last4?.trim() || undefined;1856 }18571858 return nextPatch;1859}18601861function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1862 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1863 const modifiedAt =1864 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;18651866 const base: VaultEntry = {1867 id: entry.id,1868 type: "type" in entry ? entry.type : "login",1869 title: entry.title,1870 username: entry.username,1871 password: entry.password,1872 url: entry.url,1873 totp: entry.totp,1874 notes: entry.notes,1875 breach: entry.breach,1876 canary: "canary" in entry ? entry.canary : undefined,1877 passkey: "passkey" in entry ? entry.passkey : undefined,1878 createdAt,1879 modifiedAt,1880 deletedAt: entry.deletedAt,1881 tags: "tags" in entry ? entry.tags : undefined,1882 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1883 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1884 expMonth: "expMonth" in entry ? entry.expMonth : undefined,1885 expYear: "expYear" in entry ? entry.expYear : undefined,1886 cvc: "cvc" in entry ? entry.cvc : undefined,1887 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1888 network: "network" in entry ? entry.network : undefined,1889 last4: "last4" in entry ? entry.last4 : undefined,1890 };18911892 return migrateLegacyPasskey(base, entry);1893}189418951896189718981899190019011902function migrateLegacyPasskey(1903 base: VaultEntry,1904 raw: VaultEntry | LegacyVaultEntry1905): VaultEntry {1906 if (base.passkey) return base;1907 const flat = raw as Partial<VaultEntry>;1908 const isPasskeyType = base.type === "passkey";1909 const hasFlatPasskeyFields =1910 typeof flat.rpId === "string" &&1911 typeof flat.credentialId === "string" &&1912 typeof flat.publicKeyCose === "string" &&1913 typeof flat.userHandle === "string" &&1914 typeof flat.userName === "string";1915 if (!isPasskeyType && !hasFlatPasskeyFields) return base;1916 if (!hasFlatPasskeyFields) return base;19171918 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;1919 base.passkey = {1920 rpId: flat.rpId!,1921 rpName: flat.rpName ?? flat.rpId!,1922 credentialId: flat.credentialId!,1923 publicKeyCose: flat.publicKeyCose!,1924 privateKeyJwk: flat.privateKeyJwk,1925 userHandle: flat.userHandle!,1926 userName: flat.userName!,1927 userDisplayName: flat.userDisplayName ?? flat.userName!,1928 algorithm,1929 signCount: flat.signCount ?? 0,1930 aaguid: flat.aaguid ?? "",1931 backupEligible: flat.backupEligible === true,1932 backupState: flat.backupState === true,1933 lastUsedAt: flat.lastUsedAt,1934 browser: flat.browser,1935 transports: flat.transports,1936 createdAt: base.createdAt,1937 };1938 1939 1940 1941 if (isPasskeyType) base.type = "login";1942 if (!base.username) base.username = flat.userName;1943 if (!base.url) base.url = `https://${flat.rpId}`;1944 return base;1945}1946194719481949195019511952function entryDupeKey(e: VaultEntry): string {1953 if (e.type === "note") return `note|${e.title.toLowerCase()}`;1954 1955 1956 1957 1958 if (e.passkey) {1959 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;1960 }1961 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1962}196319641965export function countDuplicates(): number {1966 const best = new Map<string, VaultEntry>();1967 let dupeCount = 0;1968 for (const e of entries.filter((e) => !e.deletedAt)) {1969 const key = entryDupeKey(e);1970 const existing = best.get(key);1971 if (!existing) {1972 best.set(key, e);1973 } else {1974 dupeCount++;1975 1976 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1977 best.set(key, e);1978 }1979 }1980 }1981 return dupeCount;1982}198319841985198619871988export async function deduplicateEntries(): Promise<number> {1989 if (!derivedKey) throw new Error("vault_locked");1990 1991 1992 if (vaultMode === "decoy") {1993 throw new Error("deduplicate disabled in decoy mode");1994 }19951996 const best = new Map<string, VaultEntry>();1997 for (const e of entries.filter((en) => !en.deletedAt)) {1998 const key = entryDupeKey(e);1999 const existing = best.get(key);2000 if (!existing) {2001 best.set(key, e);2002 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2003 best.set(key, e);2004 }2005 }20062007 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2008 const now = Date.now();2009 let removed = 0;2010 entries = entries.map((e) => {2011 if (!e.deletedAt && !keepIds.has(e.id)) {2012 removed++;2013 return { ...e, deletedAt: now };2014 }2015 return e;2016 });20172018 if (removed > 0) await persistEntries();2019 return removed;2020}202120222023202420252026202720282029const HISTORY_KEY = "peach_vault_history";2030const HISTORY_MAX = 10;20312032203320342035203620372038export async function takeVaultSnapshot(2039 reason: VaultSnapshot["reason"],2040): Promise<void> {2041 try {2042 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2043 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2044 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];20452046 2047 2048 2049 if (!slotA) return;20502051 const vault_a = JSON.stringify(slotA);2052 const vault_b = JSON.stringify(slotB ?? slotA);20532054 2055 2056 2057 let entryCount: number | null = null;2058 if (derivedKey && vaultMode === "real") {2059 try {2060 entryCount = entries.filter((e) => !e.deletedAt).length;2061 } catch {2062 entryCount = null;2063 }2064 }20652066 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2067 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2068 ? existingRaw2069 : { snapshots: [] };20702071 2072 2073 2074 const latest = history.snapshots[0];2075 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2076 return;2077 }20782079 const snapshot: VaultSnapshot = {2080 id: crypto.randomUUID(),2081 savedAt: Date.now(),2082 reason,2083 vault_a,2084 vault_b,2085 entryCount,2086 };20872088 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2089 await chrome.storage.local.set({ [HISTORY_KEY]: history });2090 } catch (err) {2091 2092 2093 2094 2095 console.warn('[Peach] takeVaultSnapshot failed:', err);2096 if (String(err).includes('QUOTA') || String(err).includes('quota')) {2097 try {2098 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2099 } catch {2100 2101 }2102 }2103 }2104}210521062107export async function clearVaultHistory(): Promise<void> {2108 try {2109 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2110 } catch {2111 2112 }2113}211421152116export async function getVaultHistory(): Promise<VaultSnapshot[]> {2117 const raw = await chrome.storage.local.get(HISTORY_KEY);2118 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2119 if (!history || !Array.isArray(history.snapshots)) return [];2120 return history.snapshots;2121}21222123212421252126212721282129export async function restoreVaultSnapshot(2130 snapshotId: string,2131): Promise<{ ok: true } | { ok: false; error: string }> {2132 const history = await getVaultHistory();2133 const snapshot = history.find((s) => s.id === snapshotId);2134 if (!snapshot) return { ok: false, error: "snapshot_not_found" };21352136 2137 2138 2139 await takeVaultSnapshot("restore");21402141 let restoredA: StoredVault;2142 let restoredB: StoredVault;2143 try {2144 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2145 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2146 } catch {2147 return { ok: false, error: "snapshot_corrupt" };2148 }21492150 await writeSlot(SLOT_A, restoredA);2151 await writeSlot(SLOT_B, restoredB);2152 await stampLocalVaultMutation();21532154 2155 2156 2157 lock();21582159 return { ok: true };2160}21612162216321642165216621672168export async function changePassword(2169 currentPassword: string,2170 newPassword: string2171): Promise<boolean> {2172 if (!derivedKey || vaultMode !== "real") return false;21732174 2175 const valid = await verifyMainPassword(currentPassword);2176 if (!valid) return false;21772178 const slotA = await loadSlot(SLOT_A);2179 if (!slotA) return false;2180 if (!rootKey) return false;21812182 2183 const newSalt = generateSalt();2184 const newKey = await deriveKey(newPassword, newSalt);21852186 2187 const plaintext = encodePayload(entries, duressConfigured);2188 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);21892190 2191 2192 2193 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);21942195 const finalSlotA: StoredVault = {2196 ...slotA,2197 salt: hex(newSalt),2198 nonce,2199 ciphertext,2200 tag,2201 ...pwRoot,2202 };22032204 2205 2206 2207 await takeVaultSnapshot("local_save");22082209 2210 2211 2212 2213 await writeSlot(SLOT_A, finalSlotA);22142215 2216 2217 2218 zeroKey(derivedKey);2219 derivedKey = newKey;22202221 2222 2223 2224 if (!duressConfigured) {2225 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2226 }2227 await stampLocalVaultMutation();22282229 return true;2230}223122322233223422352236export async function getVaultBlobForSync(): Promise<{2237 blob: string;2238 timestamp: number;2239 license?: string;2240} | null> {2241 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, 'peach_last_modified_at', 'peach_license']);2242 const slotA = (raw as Record<string, unknown>)[SLOT_A];2243 if (!slotA) return null;2244 const slotB = (raw as Record<string, unknown>)[SLOT_B];2245 const blob = JSON.stringify({ a: slotA, b: slotB });2246 const rawTs = (raw as Record<string, unknown>)['peach_last_modified_at'];2247 const timestamp = typeof rawTs === 'string' ? parseInt(rawTs) : 0;2248 const license = (raw as Record<string, unknown>)['peach_license'];2249 return {2250 blob,2251 timestamp,2252 license: typeof license === 'string' ? license : undefined,2253 };2254}22552256export async function applyIncomingVault(2257 blobJson: string,2258 remoteTimestamp: number,2259 remoteLicense?: string,2260): Promise<boolean> {2261 const raw = await chrome.storage.local.get('peach_last_modified_at');2262 const rawTs = (raw as Record<string, unknown>)['peach_last_modified_at'];2263 const localTimestamp = typeof rawTs === 'string' ? parseInt(rawTs) : 0;22642265 if (remoteLicense) {2266 const licenseRaw = await chrome.storage.local.get('peach_license');2267 const existing = (licenseRaw as Record<string, unknown>)['peach_license'];2268 if (!existing) {2269 await chrome.storage.local.set({ peach_license: remoteLicense });2270 }2271 }22722273 if (remoteTimestamp <= localTimestamp) return false;22742275 2276 2277 2278 2279 2280 let parsed: unknown;2281 try {2282 parsed = JSON.parse(blobJson);2283 } catch {2284 throw new Error('invalid_incoming_vault_blob');2285 }2286 if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {2287 throw new Error('invalid_incoming_vault_blob');2288 }2289 const { a, b } = parsed as { a?: unknown; b?: unknown };2290 if (!hasValidVaultSchema(a)) {2291 throw new Error('invalid_incoming_vault_blob');2292 }22932294 2295 2296 2297 try {2298 await takeVaultSnapshot('sync_received');2299 } catch (snapshotErr) {2300 console.warn('[Peach] vault snapshot failed during sync:', snapshotErr);2301 if (String(snapshotErr).includes('QUOTA') || String(snapshotErr).includes('quota')) {2302 try {2303 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2304 } catch {2305 2306 }2307 }2308 }23092310 await writeSlot(SLOT_A, a);2311 if (hasValidVaultSchema(b)) await writeSlot(SLOT_B, b);2312 await chrome.storage.local.set({2313 peach_last_sync_at: String(Date.now()),2314 peach_last_modified_at: String(remoteTimestamp),2315 });23162317 return true;2318}23192320232123222323232423252326232723282329233023312332export async function getSyncIdentity(): Promise<{2333 privateKeyBytes: Uint8Array;2334 publicKeyBytes: Uint8Array;2335 publicKeyHex: string;2336}> {2337 if (!derivedKey) throw new Error('vault_locked');2338 const raw = await chrome.storage.local.get('peach_sync_identity');2339 const stored = (raw as Record<string, unknown>)['peach_sync_identity'];23402341 if (typeof stored === 'string') {2342 const parsed = JSON.parse(stored) as {2343 pub: string;2344 priv?: string;2345 enc_priv?: string;2346 enc_nonce?: string;2347 enc_tag?: string;2348 };2349 const publicKeyBytes = base64DecodeBytes(parsed.pub);23502351 if (parsed.enc_priv && parsed.enc_nonce && parsed.enc_tag) {2352 const privHex = await decrypt(2353 derivedKey,2354 parsed.enc_nonce,2355 parsed.enc_priv,2356 parsed.enc_tag,2357 );2358 const privateKeyBytes = unhex(privHex);2359 return { privateKeyBytes, publicKeyBytes, publicKeyHex: bytesToHex(publicKeyBytes) };2360 }23612362 if (parsed.priv) {2363 2364 const privateKeyBytes = base64DecodeBytes(parsed.priv);2365 await persistEncryptedSyncIdentity(privateKeyBytes, publicKeyBytes);2366 return { privateKeyBytes, publicKeyBytes, publicKeyHex: bytesToHex(publicKeyBytes) };2367 }2368 }23692370 2371 const { generateKeyPair } = await import('@stablelib/x25519');2372 const keypair = generateKeyPair();2373 await persistEncryptedSyncIdentity(keypair.secretKey, keypair.publicKey);2374 return {2375 privateKeyBytes: keypair.secretKey,2376 publicKeyBytes: keypair.publicKey,2377 publicKeyHex: bytesToHex(keypair.publicKey),2378 };2379}23802381async function persistEncryptedSyncIdentity(2382 privateKeyBytes: Uint8Array,2383 publicKeyBytes: Uint8Array,2384): Promise<void> {2385 if (!derivedKey) throw new Error('vault_locked');2386 const enc = await encrypt(derivedKey, hex(privateKeyBytes));2387 await chrome.storage.local.set({2388 peach_sync_identity: JSON.stringify({2389 pub: base64EncodeBytes(publicKeyBytes),2390 enc_priv: enc.ciphertext,2391 enc_nonce: enc.nonce,2392 enc_tag: enc.tag,2393 }),2394 });2395}23962397239823992400export async function reloadEntriesAfterSync(): Promise<boolean> {2401 if (!derivedKey) return false;2402 const slotA = await loadSlot(SLOT_A);2403 if (!slotA) return false;2404 try {2405 const payload = await tryDecryptSlot(slotA, derivedKey);2406 if (!payload) return false;2407 entries = payload.entries;2408 buildDomainIndexSync(entries);2409 return true;2410 } catch {2411 return false;2412 }2413}24142415function bytesToHex(bytes: Uint8Array): string {2416 return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');2417}24182419function base64EncodeBytes(bytes: Uint8Array): string {2420 let binary = '';2421 for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);2422 return btoa(binary);2423}24242425function base64DecodeBytes(b64: string): Uint8Array {2426 const binary = atob(b64);2427 const bytes = new Uint8Array(binary.length);2428 for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);2429 return bytes;2430}