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}998999export async function verifyMainPassword(password: string): Promise<boolean> {1000 await ensureMigrated();1001 const slotA = await loadSlot(SLOT_A);1002 if (!slotA) return false;1003 const key = await deriveKey(password, unhex(slotA.salt));1004 try {1005 return (await tryDecryptSlot(slotA, key)) !== null;1006 } finally {1007 zeroKey(key);1008 }1009}10101011export async function unlock(password: string): Promise<boolean> {1012 await ensureMigrated();10131014 1015 1016 1017 1018 1019 const [slotA, slotB] = await Promise.all([1020 loadSlot(SLOT_A),1021 loadSlot(SLOT_B),1022 ]);10231024 1025 const [keyA, keyB] = await Promise.all([1026 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),1027 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),1028 ]);10291030 1031 1032 1033 const [resultA, resultB] = await Promise.all([1034 keyA && slotA1035 ? tryDecryptSlotResult(slotA, keyA)1036 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1037 keyB && slotB1038 ? tryDecryptSlotResult(slotB, keyB)1039 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1040 ]);1041 const payloadA = resultA.status === "ok" ? resultA.payload : null;1042 const payloadB = resultB.status === "ok" ? resultB.payload : null;10431044 if (payloadA && keyA) {1045 derivedKey = keyA;1046 vaultMode = "real";1047 entries = payloadA.entries;1048 duressConfigured = payloadA.duressConfigured;1049 buildDomainIndexSync(entries);1050 await loadSettings();1051 touchAutoLock();1052 if (keyB) zeroKey(keyB);10531054 1055 if (slotA) {1056 const needsRecoverySetup =1057 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;1058 if (!needsRecoverySetup) {1059 rootKey = await deriveRootKeyFromDerivedKey(keyA, slotA);1060 }1061 1062 1063 1064 1065 if (needsRecoverySetup || !rootKey) {1066 try {1067 await migrateToRecovery();1068 if (needsRecoverySetup) {1069 1070 1071 1072 await chrome.storage.local.set({ peach_recovery_pending: '1' });1073 }1074 } catch (migrationErr) {1075 console.warn('[Peach] recovery migration failed:', migrationErr);1076 }1077 }1078 }10791080 1081 1082 1083 if (payloadA.migratedFromLegacy) {1084 await persistSlotA();1085 }1086 return true;1087 }10881089 if (payloadB && keyB) {1090 derivedKey = keyB;1091 vaultMode = "decoy";1092 entries = payloadB.entries;1093 duressConfigured = false;1094 buildDomainIndexSync(entries);1095 await loadSettings();1096 touchAutoLock();1097 if (keyA) zeroKey(keyA);1098 return true;1099 }11001101 if (keyA) zeroKey(keyA);1102 if (keyB) zeroKey(keyB);1103 1104 1105 if (resultA.status === "corrupted") {1106 lastUnlockFailureReason = resultA.reason;1107 } else if (resultB.status === "corrupted") {1108 lastUnlockFailureReason = resultB.reason;1109 }1110 return false;1111}11121113export function lock(): void {1114 if (derivedKey) {1115 zeroKey(derivedKey);1116 derivedKey = null;1117 }1118 if (rootKey) {1119 rootKey.fill(0);1120 rootKey = null;1121 }11221123 entries = [];1124 domainIndex = new Map();1125 vaultMode = "real";1126 duressConfigured = false;1127 clearAutoLock();1128}11291130export function isUnlocked(): boolean {1131 return derivedKey !== null;1132}1133113411351136export async function unlockWithKey(1137 masterKeyHex: string,1138): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1139 try {1140 const masterKeyBytes = unhex(masterKeyHex);1141 const stored = await loadSlot(SLOT_A);1142 if (!stored) return { ok: false, error: "invalid_key" };1143 const payload = await tryDecryptSlot(stored, masterKeyBytes);1144 if (!payload) return { ok: false, error: "invalid_key" };11451146 derivedKey = masterKeyBytes;1147 vaultMode = "real";1148 entries = payload.entries;1149 duressConfigured = payload.duressConfigured;1150 buildDomainIndexSync(entries);1151 await loadSettings();1152 touchAutoLock();1153 return { ok: true, entries };1154 } catch {1155 return { ok: false, error: "invalid_key" };1156 }1157}11581159116011611162116311641165116611671168116911701171117211731174117511761177export async function unlockFromCodex(params: {1178 slotA: string; 1179 slotB: string | null; 1180 timestamp: number;1181 masterPassphrase: string;1182}): Promise<1183 | { ok: true; entryCount: number }1184 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1185> {1186 let parsedSlotA: StoredVault;1187 let parsedSlotB: StoredVault | null = null;1188 try {1189 const rawA = JSON.parse(params.slotA) as unknown;1190 if (!hasValidVaultSchema(rawA)) {1191 return { ok: false, reason: "invalid_slot_format" };1192 }1193 parsedSlotA = rawA;1194 if (params.slotB !== null) {1195 const rawB = JSON.parse(params.slotB) as unknown;1196 if (!hasValidVaultSchema(rawB)) {1197 return { ok: false, reason: "invalid_slot_format" };1198 }1199 parsedSlotB = rawB;1200 }1201 } catch {1202 return { ok: false, reason: "invalid_slot_format" };1203 }12041205 1206 1207 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1208 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey);1209 if (!trialPayload) {1210 zeroKey(trialKey);1211 return { ok: false, reason: "wrong_passphrase" };1212 }1213 1214 1215 zeroKey(trialKey);12161217 1218 1219 await takeVaultSnapshot("restore");12201221 await writeSlot(SLOT_A, parsedSlotA);1222 if (parsedSlotB) {1223 await writeSlot(SLOT_B, parsedSlotB);1224 } else {1225 1226 1227 1228 await writeSlot(1229 SLOT_B,1230 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1231 );1232 }1233 await chrome.storage.local.set({1234 peach_last_modified_at: String(params.timestamp),1235 });12361237 1238 1239 const ok = await unlock(params.masterPassphrase);1240 if (!ok) {1241 1242 1243 1244 return { ok: false, reason: "wrong_passphrase" };1245 }1246 return { ok: true, entryCount: getEntries().length };1247}1248124912501251export function getDerivedKeyHex(): string {1252 if (!derivedKey) throw new Error("vault_not_unlocked");1253 return hex(derivedKey);1254}12551256export function getEntries(): VaultEntry[] {1257 if (!derivedKey) throw new Error("vault_locked");1258 return entries.filter((entry) => !entry.deletedAt);1259}12601261const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;12621263export function getIdentities(): SavedIdentity[] {1264 if (!derivedKey) throw new Error("vault_locked");1265 const active = entries.filter((e) => !e.deletedAt && e.type === "login");12661267 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1268 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();12691270 for (const entry of active) {1271 const value = entry.username?.trim();1272 if (!value) continue;1273 const map = EMAIL_RE.test(value) ? emailMap : usernameMap;1274 const existing = map.get(value);1275 const domain = extractDomain(entry.url ?? "") ?? "";1276 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;1277 if (!existing || modifiedAt > existing.lastUsed) {1278 map.set(value, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1279 } else {1280 map.set(value, { ...existing, count: existing.count + 1 });1281 }1282 }12831284 const toIdentity = (type: "email" | "username") =>1285 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1286 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1287 });12881289 return [1290 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1291 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1292 ];1293}12941295export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1296 if (!derivedKey) throw new Error("vault_locked");12971298 const now = Date.now();1299 const added: VaultEntry[] = [];1300 for (const draft of drafts) {1301 try {1302 const sanitized = sanitizeEntryDraft(draft);1303 const nextEntry: VaultEntry = {1304 ...sanitized,1305 id: crypto.randomUUID(),1306 createdAt: now,1307 modifiedAt: now,1308 };1309 entries.push(nextEntry);1310 added.push(nextEntry);1311 } catch {1312 1313 }1314 }13151316 if (added.length > 0) {1317 await persistEntries();1318 }13191320 return added;1321}132213231324132513261327132813291330export async function restoreEntries(1331 incoming: VaultEntry[],1332 mode: "replace" | "merge"1333): Promise<{ added: number; updated: number; skipped: number }> {1334 if (!derivedKey) throw new Error("vault_locked");13351336 1337 1338 1339 if (mode === "replace" && incoming.length === 0) {1340 throw new Error("refuse to replace with empty entry set");1341 }13421343 let added = 0;1344 let updated = 0;1345 let skipped = 0;13461347 if (mode === "replace") {1348 entries = [];1349 for (const entry of incoming) {1350 const normalized = sanitizeRestoredEntry(entry);1351 if (!normalized) {1352 skipped += 1;1353 continue;1354 }1355 entries.push(normalized);1356 added += 1;1357 }1358 } else {1359 const byId = new Map<string, VaultEntry>();1360 for (const e of entries) byId.set(e.id, e);13611362 for (const entry of incoming) {1363 const normalized = sanitizeRestoredEntry(entry);1364 if (!normalized) {1365 skipped += 1;1366 continue;1367 }1368 const existing = byId.get(normalized.id);1369 if (!existing) {1370 entries.push(normalized);1371 added += 1;1372 } else if (normalized.modifiedAt > existing.modifiedAt) {1373 const idx = entries.findIndex((e) => e.id === existing.id);1374 if (idx !== -1) {1375 entries[idx] = normalized;1376 updated += 1;1377 }1378 } else {1379 skipped += 1;1380 }1381 }1382 }13831384 await persistEntries("import");1385 return { added, updated, skipped };1386}1387138813891390139113921393function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1394 if (!entry || typeof entry !== "object") return null;1395 const title = typeof entry.title === "string" ? entry.title.trim() : "";1396 if (!title) return null;1397 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1398 const now = Date.now();1399 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1400 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;14011402 1403 1404 1405 const migrated: VaultEntry = migrateLegacyPasskey(1406 {1407 ...entry,1408 id,1409 title,1410 type: entry.type ?? "login",1411 createdAt,1412 modifiedAt,1413 },1414 entry1415 );14161417 1418 1419 if (1420 migrated.type === "login" &&1421 !migrated.passkey &&1422 typeof migrated.password !== "string"1423 ) {1424 return null;1425 }1426 if (migrated.passkey && !migrated.passkey.credentialId) {1427 return null;1428 }14291430 return migrated;1431}143214331434143514361437export async function clearVaultEntries(1438 confirmation: "REPLACE_ALL"1439): Promise<void> {1440 if (confirmation !== "REPLACE_ALL") {1441 throw new Error("clearVaultEntries requires explicit confirmation");1442 }1443 if (!derivedKey) throw new Error("vault_locked");1444 for (const entry of entries) {1445 if (!entry.deletedAt) {1446 entry.deletedAt = Date.now();1447 entry.modifiedAt = Date.now();1448 }1449 }1450 await persistEntries();1451}14521453export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1454 if (!derivedKey) throw new Error("vault_locked");14551456 const sanitized = sanitizeEntryDraft(entry);1457 const now = Date.now();1458 const nextEntry: VaultEntry = {1459 ...sanitized,1460 id: crypto.randomUUID(),1461 createdAt: now,1462 modifiedAt: now,1463 1464 1465 passwordChangedAt: sanitized.password ? now : undefined,1466 };14671468 1469 1470 1471 const rollback = entries;1472 entries = [...entries, nextEntry];1473 try {1474 await persistEntries();1475 } catch (err) {1476 entries = rollback;1477 throw err;1478 }14791480 1481 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1482 checkBreach(nextEntry.password).then(async (count) => {1483 if (count > 0) {1484 const idx = entries.findIndex((e) => e.id === nextEntry.id);1485 if (idx !== -1) {1486 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };1487 await persistEntries();1488 }1489 }1490 });1491 }14921493 return nextEntry;1494}14951496export async function updateEntry(1497 id: string,1498 patch: Partial<VaultEntry>1499): Promise<void> {1500 if (!derivedKey) throw new Error("vault_locked");15011502 const idx = entries.findIndex((entry) => entry.id === id);1503 if (idx === -1 || entries[idx].deletedAt) {1504 throw new Error("entry_not_found");1505 }15061507 const current = entries[idx];1508 const sanitizedPatch = sanitizeEntryPatch(patch);1509 1510 1511 1512 const passwordChanged =1513 "password" in patch &&1514 typeof sanitizedPatch.password === "string" &&1515 sanitizedPatch.password !== current.password;1516 entries[idx] = {1517 ...current,1518 ...sanitizedPatch,1519 modifiedAt: Date.now(),1520 ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),1521 };15221523 1524 1525 1526 1527 if (1528 entries[idx].type === "login" &&1529 !entries[idx].password &&1530 !entries[idx].passkey1531 ) {1532 entries[idx] = current;1533 throw new Error("password or passkey required");1534 }15351536 1537 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1538 entries[idx] = current;1539 throw new Error("card number is required for card entries");1540 }15411542 await persistEntries();15431544 1545 1546 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1547 entries[idx] = { ...entries[idx], breach: undefined };1548 await persistEntries();1549 const password = entries[idx].password;1550 if (password) {1551 checkBreach(password).then(async (count) => {1552 const i = entries.findIndex((e) => e.id === id);1553 if (i !== -1 && count > 0) {1554 entries[i] = { ...entries[i], breach: count, modifiedAt: Date.now() };1555 await persistEntries();1556 }1557 });1558 }1559 }1560}15611562export async function deleteEntry(id: string): Promise<void> {1563 if (!derivedKey) throw new Error("vault_locked");15641565 const entry = entries.find((item) => item.id === id);1566 if (!entry || entry.deletedAt) {1567 throw new Error("entry_not_found");1568 }15691570 entry.deletedAt = Date.now();1571 entry.modifiedAt = Date.now();1572 await persistEntries();1573}15741575export function getDeletedEntries(): VaultEntry[] {1576 if (!derivedKey) return [];1577 return entries.filter((e) => !!e.deletedAt);1578}15791580export async function restoreEntry(id: string): Promise<void> {1581 if (!derivedKey) throw new Error("vault_locked");1582 const entry = entries.find((e) => e.id === id && e.deletedAt);1583 if (!entry) throw new Error("deleted entry not found");1584 delete entry.deletedAt;1585 entry.modifiedAt = Date.now();1586 await persistEntries();1587 buildDomainIndexSync(entries);1588}15891590export async function purgeEntry(id: string): Promise<void> {1591 if (!derivedKey) throw new Error("vault_locked");1592 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1593 if (idx === -1) throw new Error("deleted entry not found");1594 entries.splice(idx, 1);1595 await persistEntries();1596}159715981599export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1600 if (!derivedKey || retentionMs === 0) return 0;1601 const cutoff = Date.now() - retentionMs;1602 const before = entries.length;1603 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1604 const purged = before - entries.length;1605 if (purged > 0) await persistEntries();1606 return purged;1607}16081609export function queryDomain(domain: string): VaultEntry[] {1610 return domainIndex.get(domain) ?? [];1611}16121613export function getEntryById(id: string): VaultEntry | undefined {1614 return entries.find((entry) => entry.id === id && !entry.deletedAt);1615}16161617async function persistSlotA(1618 reason: VaultSnapshot["reason"] = "local_save",1619): Promise<StoredVault> {1620 if (!derivedKey) throw new Error("vault_locked");16211622 const slotA = await loadSlot(SLOT_A);1623 if (!slotA) throw new Error("missing slot A");16241625 1626 1627 await takeVaultSnapshot(reason);16281629 const plaintext = encodePayload(entries, duressConfigured);1630 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);16311632 1633 const nextSlotA: StoredVault = {1634 ...slotA,1635 nonce,1636 ciphertext,1637 tag,1638 1639 };1640 await writeSlot(SLOT_A, nextSlotA);16411642 1643 1644 1645 if (!duressConfigured) {1646 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1647 }1648 await stampLocalVaultMutation();16491650 return nextSlotA;1651}16521653async function persistSlotB(): Promise<void> {1654 if (!derivedKey) throw new Error("vault_locked");16551656 const slotB = await loadSlot(SLOT_B);1657 if (!slotB) throw new Error("missing slot B");16581659 1660 1661 const plaintext = encodePayload(entries, false);1662 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);16631664 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1665 await stampLocalVaultMutation();1666}16671668async function persistEntries(1669 reason: VaultSnapshot["reason"] = "local_save",1670): Promise<void> {1671 if (!derivedKey) throw new Error("vault_locked");16721673 if (vaultMode === "decoy") {1674 await persistSlotB();1675 } else {1676 await persistSlotA(reason);1677 }1678 buildDomainIndexSync(entries);1679}16801681function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1682 if (!Array.isArray(values)) return undefined;1683 const seen = new Set<string>();1684 const cleaned: string[] = [];1685 for (const value of values) {1686 const trimmed = typeof value === "string" ? value.trim() : "";1687 if (!trimmed) continue;1688 const key = trimmed.toLowerCase();1689 if (seen.has(key)) continue;1690 seen.add(key);1691 cleaned.push(trimmed);1692 }1693 return cleaned.length > 0 ? cleaned : undefined;1694}16951696function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1697 const title = entry.title.trim();1698 1699 1700 1701 let type: VaultEntry["type"] = entry.type ?? "login";1702 if (type === "passkey") type = "login";17031704 if (!title) throw new Error("title is required");1705 1706 1707 if (type === "login" && !entry.password?.trim() && !entry.passkey) {1708 throw new Error("password or passkey required");1709 }1710 if (entry.passkey) {1711 const p = entry.passkey;1712 if (1713 !p.rpId ||1714 !p.credentialId ||1715 !p.publicKeyCose ||1716 !p.userHandle ||1717 !p.userName ||1718 (p.algorithm !== -7 && p.algorithm !== -8)1719 ) {1720 throw new Error("invalid passkey credential");1721 }1722 }1723 if (type === "card") {1724 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1725 throw new Error("cardholder name and card number are required");1726 }1727 }17281729 return {1730 type,1731 title,1732 username: type === "login" ? entry.username?.trim() ?? "" : undefined,1733 password: type === "login" ? entry.password : undefined,1734 url: type === "login" ? entry.url?.trim() ?? "" : undefined,1735 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1736 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1737 notes: entry.notes?.trim() || undefined,1738 breach: type === "login" ? entry.breach : undefined,1739 canary: type === "login" ? entry.canary : undefined,1740 tags: entry.tags,1741 passkey: entry.passkey,1742 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1743 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1744 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1745 expYear: type === "card" ? entry.expYear?.trim() : undefined,1746 cvc: type === "card" ? entry.cvc?.trim() : undefined,1747 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1748 network: type === "card" ? entry.network : undefined,1749 last4: type === "card" ? entry.last4?.trim() : undefined,1750 };1751}17521753function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1754 const nextPatch: Partial<VaultEntry> = {};17551756 if ("type" in patch) {1757 nextPatch.type = patch.type;1758 }17591760 if ("title" in patch) {1761 const title = patch.title?.trim() ?? "";1762 if (!title) throw new Error("title is required");1763 nextPatch.title = title;1764 }17651766 if ("url" in patch) {1767 nextPatch.url = patch.url?.trim() ?? "";1768 }17691770 if ("additionalUrls" in patch) {1771 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1772 const seen = new Set<string>();1773 const cleaned: string[] = [];1774 for (const value of incoming) {1775 const trimmed = typeof value === "string" ? value.trim() : "";1776 if (!trimmed) continue;1777 const key = trimmed.toLowerCase();1778 if (seen.has(key)) continue;1779 seen.add(key);1780 cleaned.push(trimmed);1781 }1782 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1783 }17841785 if ("username" in patch) {1786 nextPatch.username = patch.username?.trim() ?? "";1787 }17881789 if ("email" in patch) {1790 nextPatch.email = patch.email?.trim() || undefined;1791 }17921793 if ("password" in patch) {1794 const password = patch.password ?? "";1795 1796 nextPatch.password = password || undefined;1797 }17981799 if ("totp" in patch) {1800 nextPatch.totp = patch.totp?.trim() || undefined;1801 }18021803 if ("notes" in patch) {1804 nextPatch.notes = patch.notes?.trim() || undefined;1805 }18061807 if ("breach" in patch) {1808 nextPatch.breach = patch.breach;1809 }18101811 if ("canary" in patch) {1812 nextPatch.canary = patch.canary;1813 }18141815 if ("passkey" in patch) {1816 1817 1818 1819 nextPatch.passkey = patch.passkey;1820 }18211822 if ("tags" in patch) {1823 nextPatch.tags = patch.tags;1824 }18251826 if ("cardholderName" in patch) {1827 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1828 }1829 if ("cardNumber" in patch) {1830 const n = patch.cardNumber ?? "";1831 nextPatch.cardNumber = n || undefined;1832 }1833 if ("expMonth" in patch) {1834 nextPatch.expMonth = patch.expMonth?.trim() || undefined;1835 }1836 if ("expYear" in patch) {1837 nextPatch.expYear = patch.expYear?.trim() || undefined;1838 }1839 if ("cvc" in patch) {1840 nextPatch.cvc = patch.cvc?.trim() || undefined;1841 }1842 if ("cardNickname" in patch) {1843 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1844 }1845 if ("network" in patch) {1846 nextPatch.network = patch.network;1847 }1848 if ("last4" in patch) {1849 nextPatch.last4 = patch.last4?.trim() || undefined;1850 }18511852 return nextPatch;1853}18541855function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1856 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1857 const modifiedAt =1858 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;18591860 const base: VaultEntry = {1861 id: entry.id,1862 type: "type" in entry ? entry.type : "login",1863 title: entry.title,1864 username: entry.username,1865 password: entry.password,1866 url: entry.url,1867 totp: entry.totp,1868 notes: entry.notes,1869 breach: entry.breach,1870 canary: "canary" in entry ? entry.canary : undefined,1871 passkey: "passkey" in entry ? entry.passkey : undefined,1872 createdAt,1873 modifiedAt,1874 deletedAt: entry.deletedAt,1875 tags: "tags" in entry ? entry.tags : undefined,1876 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1877 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1878 expMonth: "expMonth" in entry ? entry.expMonth : undefined,1879 expYear: "expYear" in entry ? entry.expYear : undefined,1880 cvc: "cvc" in entry ? entry.cvc : undefined,1881 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1882 network: "network" in entry ? entry.network : undefined,1883 last4: "last4" in entry ? entry.last4 : undefined,1884 };18851886 return migrateLegacyPasskey(base, entry);1887}188818891890189118921893189418951896function migrateLegacyPasskey(1897 base: VaultEntry,1898 raw: VaultEntry | LegacyVaultEntry1899): VaultEntry {1900 if (base.passkey) return base;1901 const flat = raw as Partial<VaultEntry>;1902 const isPasskeyType = base.type === "passkey";1903 const hasFlatPasskeyFields =1904 typeof flat.rpId === "string" &&1905 typeof flat.credentialId === "string" &&1906 typeof flat.publicKeyCose === "string" &&1907 typeof flat.userHandle === "string" &&1908 typeof flat.userName === "string";1909 if (!isPasskeyType && !hasFlatPasskeyFields) return base;1910 if (!hasFlatPasskeyFields) return base;19111912 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;1913 base.passkey = {1914 rpId: flat.rpId!,1915 rpName: flat.rpName ?? flat.rpId!,1916 credentialId: flat.credentialId!,1917 publicKeyCose: flat.publicKeyCose!,1918 privateKeyJwk: flat.privateKeyJwk,1919 userHandle: flat.userHandle!,1920 userName: flat.userName!,1921 userDisplayName: flat.userDisplayName ?? flat.userName!,1922 algorithm,1923 signCount: flat.signCount ?? 0,1924 aaguid: flat.aaguid ?? "",1925 backupEligible: flat.backupEligible === true,1926 backupState: flat.backupState === true,1927 lastUsedAt: flat.lastUsedAt,1928 browser: flat.browser,1929 transports: flat.transports,1930 createdAt: base.createdAt,1931 };1932 1933 1934 1935 if (isPasskeyType) base.type = "login";1936 if (!base.username) base.username = flat.userName;1937 if (!base.url) base.url = `https://${flat.rpId}`;1938 return base;1939}1940194119421943194419451946function entryDupeKey(e: VaultEntry): string {1947 if (e.type === "note") return `note|${e.title.toLowerCase()}`;1948 1949 1950 1951 1952 if (e.passkey) {1953 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;1954 }1955 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1956}195719581959export function countDuplicates(): number {1960 const best = new Map<string, VaultEntry>();1961 let dupeCount = 0;1962 for (const e of entries.filter((e) => !e.deletedAt)) {1963 const key = entryDupeKey(e);1964 const existing = best.get(key);1965 if (!existing) {1966 best.set(key, e);1967 } else {1968 dupeCount++;1969 1970 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1971 best.set(key, e);1972 }1973 }1974 }1975 return dupeCount;1976}197719781979198019811982export async function deduplicateEntries(): Promise<number> {1983 if (!derivedKey) throw new Error("vault_locked");1984 1985 1986 if (vaultMode === "decoy") {1987 throw new Error("deduplicate disabled in decoy mode");1988 }19891990 const best = new Map<string, VaultEntry>();1991 for (const e of entries.filter((en) => !en.deletedAt)) {1992 const key = entryDupeKey(e);1993 const existing = best.get(key);1994 if (!existing) {1995 best.set(key, e);1996 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1997 best.set(key, e);1998 }1999 }20002001 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2002 const now = Date.now();2003 let removed = 0;2004 entries = entries.map((e) => {2005 if (!e.deletedAt && !keepIds.has(e.id)) {2006 removed++;2007 return { ...e, deletedAt: now };2008 }2009 return e;2010 });20112012 if (removed > 0) await persistEntries();2013 return removed;2014}201520162017201820192020202120222023const HISTORY_KEY = "peach_vault_history";2024const HISTORY_MAX = 10;20252026202720282029203020312032export async function takeVaultSnapshot(2033 reason: VaultSnapshot["reason"],2034): Promise<void> {2035 try {2036 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2037 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2038 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];20392040 2041 2042 2043 if (!slotA) return;20442045 const vault_a = JSON.stringify(slotA);2046 const vault_b = JSON.stringify(slotB ?? slotA);20472048 2049 2050 2051 let entryCount: number | null = null;2052 if (derivedKey && vaultMode === "real") {2053 try {2054 entryCount = entries.filter((e) => !e.deletedAt).length;2055 } catch {2056 entryCount = null;2057 }2058 }20592060 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2061 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2062 ? existingRaw2063 : { snapshots: [] };20642065 2066 2067 2068 const latest = history.snapshots[0];2069 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2070 return;2071 }20722073 const snapshot: VaultSnapshot = {2074 id: crypto.randomUUID(),2075 savedAt: Date.now(),2076 reason,2077 vault_a,2078 vault_b,2079 entryCount,2080 };20812082 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2083 await chrome.storage.local.set({ [HISTORY_KEY]: history });2084 } catch (err) {2085 2086 2087 2088 2089 console.warn('[Peach] takeVaultSnapshot failed:', err);2090 if (String(err).includes('QUOTA') || String(err).includes('quota')) {2091 try {2092 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2093 } catch {2094 2095 }2096 }2097 }2098}209921002101export async function clearVaultHistory(): Promise<void> {2102 try {2103 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2104 } catch {2105 2106 }2107}210821092110export async function getVaultHistory(): Promise<VaultSnapshot[]> {2111 const raw = await chrome.storage.local.get(HISTORY_KEY);2112 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2113 if (!history || !Array.isArray(history.snapshots)) return [];2114 return history.snapshots;2115}21162117211821192120212121222123export async function restoreVaultSnapshot(2124 snapshotId: string,2125): Promise<{ ok: true } | { ok: false; error: string }> {2126 const history = await getVaultHistory();2127 const snapshot = history.find((s) => s.id === snapshotId);2128 if (!snapshot) return { ok: false, error: "snapshot_not_found" };21292130 2131 2132 2133 await takeVaultSnapshot("restore");21342135 let restoredA: StoredVault;2136 let restoredB: StoredVault;2137 try {2138 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2139 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2140 } catch {2141 return { ok: false, error: "snapshot_corrupt" };2142 }21432144 await writeSlot(SLOT_A, restoredA);2145 await writeSlot(SLOT_B, restoredB);2146 await stampLocalVaultMutation();21472148 2149 2150 2151 lock();21522153 return { ok: true };2154}21552156215721582159216021612162export async function changePassword(2163 currentPassword: string,2164 newPassword: string2165): Promise<boolean> {2166 if (!derivedKey || vaultMode !== "real") return false;21672168 2169 const valid = await verifyMainPassword(currentPassword);2170 if (!valid) return false;21712172 const slotA = await loadSlot(SLOT_A);2173 if (!slotA) return false;2174 if (!rootKey) return false;21752176 2177 const newSalt = generateSalt();2178 const newKey = await deriveKey(newPassword, newSalt);21792180 2181 const plaintext = encodePayload(entries, duressConfigured);2182 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);21832184 2185 2186 2187 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);21882189 const finalSlotA: StoredVault = {2190 ...slotA,2191 salt: hex(newSalt),2192 nonce,2193 ciphertext,2194 tag,2195 ...pwRoot,2196 };21972198 2199 2200 2201 await takeVaultSnapshot("local_save");22022203 2204 2205 2206 2207 await writeSlot(SLOT_A, finalSlotA);22082209 2210 2211 2212 zeroKey(derivedKey);2213 derivedKey = newKey;22142215 2216 2217 2218 if (!duressConfigured) {2219 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2220 }2221 await stampLocalVaultMutation();22222223 return true;2224}222522262227222822292230export async function getVaultBlobForSync(): Promise<{2231 blob: string;2232 timestamp: number;2233 license?: string;2234} | null> {2235 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, 'peach_last_modified_at', 'peach_license']);2236 const slotA = (raw as Record<string, unknown>)[SLOT_A];2237 if (!slotA) return null;2238 const slotB = (raw as Record<string, unknown>)[SLOT_B];2239 const blob = JSON.stringify({ a: slotA, b: slotB });2240 const rawTs = (raw as Record<string, unknown>)['peach_last_modified_at'];2241 const timestamp = typeof rawTs === 'string' ? parseInt(rawTs) : 0;2242 const license = (raw as Record<string, unknown>)['peach_license'];2243 return {2244 blob,2245 timestamp,2246 license: typeof license === 'string' ? license : undefined,2247 };2248}22492250export async function applyIncomingVault(2251 blobJson: string,2252 remoteTimestamp: number,2253 remoteLicense?: string,2254): Promise<boolean> {2255 const raw = await chrome.storage.local.get('peach_last_modified_at');2256 const rawTs = (raw as Record<string, unknown>)['peach_last_modified_at'];2257 const localTimestamp = typeof rawTs === 'string' ? parseInt(rawTs) : 0;22582259 if (remoteLicense) {2260 const licenseRaw = await chrome.storage.local.get('peach_license');2261 const existing = (licenseRaw as Record<string, unknown>)['peach_license'];2262 if (!existing) {2263 await chrome.storage.local.set({ peach_license: remoteLicense });2264 }2265 }22662267 if (remoteTimestamp <= localTimestamp) return false;22682269 2270 2271 2272 2273 2274 let parsed: unknown;2275 try {2276 parsed = JSON.parse(blobJson);2277 } catch {2278 throw new Error('invalid_incoming_vault_blob');2279 }2280 if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {2281 throw new Error('invalid_incoming_vault_blob');2282 }2283 const { a, b } = parsed as { a?: unknown; b?: unknown };2284 if (!hasValidVaultSchema(a)) {2285 throw new Error('invalid_incoming_vault_blob');2286 }22872288 2289 2290 2291 try {2292 await takeVaultSnapshot('sync_received');2293 } catch (snapshotErr) {2294 console.warn('[Peach] vault snapshot failed during sync:', snapshotErr);2295 if (String(snapshotErr).includes('QUOTA') || String(snapshotErr).includes('quota')) {2296 try {2297 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2298 } catch {2299 2300 }2301 }2302 }23032304 await writeSlot(SLOT_A, a);2305 if (hasValidVaultSchema(b)) await writeSlot(SLOT_B, b);2306 await chrome.storage.local.set({2307 peach_last_sync_at: String(Date.now()),2308 peach_last_modified_at: String(remoteTimestamp),2309 });23102311 return true;2312}23132314231523162317231823192320232123222323232423252326export async function getSyncIdentity(): Promise<{2327 privateKeyBytes: Uint8Array;2328 publicKeyBytes: Uint8Array;2329 publicKeyHex: string;2330}> {2331 if (!derivedKey) throw new Error('vault_locked');2332 const raw = await chrome.storage.local.get('peach_sync_identity');2333 const stored = (raw as Record<string, unknown>)['peach_sync_identity'];23342335 if (typeof stored === 'string') {2336 const parsed = JSON.parse(stored) as {2337 pub: string;2338 priv?: string;2339 enc_priv?: string;2340 enc_nonce?: string;2341 enc_tag?: string;2342 };2343 const publicKeyBytes = base64DecodeBytes(parsed.pub);23442345 if (parsed.enc_priv && parsed.enc_nonce && parsed.enc_tag) {2346 const privHex = await decrypt(2347 derivedKey,2348 parsed.enc_nonce,2349 parsed.enc_priv,2350 parsed.enc_tag,2351 );2352 const privateKeyBytes = unhex(privHex);2353 return { privateKeyBytes, publicKeyBytes, publicKeyHex: bytesToHex(publicKeyBytes) };2354 }23552356 if (parsed.priv) {2357 2358 const privateKeyBytes = base64DecodeBytes(parsed.priv);2359 await persistEncryptedSyncIdentity(privateKeyBytes, publicKeyBytes);2360 return { privateKeyBytes, publicKeyBytes, publicKeyHex: bytesToHex(publicKeyBytes) };2361 }2362 }23632364 2365 const { generateKeyPair } = await import('@stablelib/x25519');2366 const keypair = generateKeyPair();2367 await persistEncryptedSyncIdentity(keypair.secretKey, keypair.publicKey);2368 return {2369 privateKeyBytes: keypair.secretKey,2370 publicKeyBytes: keypair.publicKey,2371 publicKeyHex: bytesToHex(keypair.publicKey),2372 };2373}23742375async function persistEncryptedSyncIdentity(2376 privateKeyBytes: Uint8Array,2377 publicKeyBytes: Uint8Array,2378): Promise<void> {2379 if (!derivedKey) throw new Error('vault_locked');2380 const enc = await encrypt(derivedKey, hex(privateKeyBytes));2381 await chrome.storage.local.set({2382 peach_sync_identity: JSON.stringify({2383 pub: base64EncodeBytes(publicKeyBytes),2384 enc_priv: enc.ciphertext,2385 enc_nonce: enc.nonce,2386 enc_tag: enc.tag,2387 }),2388 });2389}23902391239223932394export async function reloadEntriesAfterSync(): Promise<boolean> {2395 if (!derivedKey) return false;2396 const slotA = await loadSlot(SLOT_A);2397 if (!slotA) return false;2398 try {2399 const payload = await tryDecryptSlot(slotA, derivedKey);2400 if (!payload) return false;2401 entries = payload.entries;2402 buildDomainIndexSync(entries);2403 return true;2404 } catch {2405 return false;2406 }2407}24082409function bytesToHex(bytes: Uint8Array): string {2410 return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');2411}24122413function base64EncodeBytes(bytes: Uint8Array): string {2414 let binary = '';2415 for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);2416 return btoa(binary);2417}24182419function base64DecodeBytes(b64: string): Uint8Array {2420 const binary = atob(b64);2421 const bytes = new Uint8Array(binary.length);2422 for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);2423 return bytes;2424}