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 entries.push(nextEntry);1469 await persistEntries();14701471 1472 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1473 checkBreach(nextEntry.password).then(async (count) => {1474 if (count > 0) {1475 const idx = entries.findIndex((e) => e.id === nextEntry.id);1476 if (idx !== -1) {1477 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };1478 await persistEntries();1479 }1480 }1481 });1482 }14831484 return nextEntry;1485}14861487export async function updateEntry(1488 id: string,1489 patch: Partial<VaultEntry>1490): Promise<void> {1491 if (!derivedKey) throw new Error("vault_locked");14921493 const idx = entries.findIndex((entry) => entry.id === id);1494 if (idx === -1 || entries[idx].deletedAt) {1495 throw new Error("entry_not_found");1496 }14971498 const current = entries[idx];1499 const sanitizedPatch = sanitizeEntryPatch(patch);1500 1501 1502 1503 const passwordChanged =1504 "password" in patch &&1505 typeof sanitizedPatch.password === "string" &&1506 sanitizedPatch.password !== current.password;1507 entries[idx] = {1508 ...current,1509 ...sanitizedPatch,1510 modifiedAt: Date.now(),1511 ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),1512 };15131514 1515 1516 1517 1518 if (1519 entries[idx].type === "login" &&1520 !entries[idx].password &&1521 !entries[idx].passkey1522 ) {1523 entries[idx] = current;1524 throw new Error("password or passkey required");1525 }15261527 1528 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1529 entries[idx] = current;1530 throw new Error("card number is required for card entries");1531 }15321533 await persistEntries();15341535 1536 1537 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1538 entries[idx] = { ...entries[idx], breach: undefined };1539 await persistEntries();1540 const password = entries[idx].password;1541 if (password) {1542 checkBreach(password).then(async (count) => {1543 const i = entries.findIndex((e) => e.id === id);1544 if (i !== -1 && count > 0) {1545 entries[i] = { ...entries[i], breach: count, modifiedAt: Date.now() };1546 await persistEntries();1547 }1548 });1549 }1550 }1551}15521553export async function deleteEntry(id: string): Promise<void> {1554 if (!derivedKey) throw new Error("vault_locked");15551556 const entry = entries.find((item) => item.id === id);1557 if (!entry || entry.deletedAt) {1558 throw new Error("entry_not_found");1559 }15601561 entry.deletedAt = Date.now();1562 entry.modifiedAt = Date.now();1563 await persistEntries();1564}15651566export function getDeletedEntries(): VaultEntry[] {1567 if (!derivedKey) return [];1568 return entries.filter((e) => !!e.deletedAt);1569}15701571export async function restoreEntry(id: string): Promise<void> {1572 if (!derivedKey) throw new Error("vault_locked");1573 const entry = entries.find((e) => e.id === id && e.deletedAt);1574 if (!entry) throw new Error("deleted entry not found");1575 delete entry.deletedAt;1576 entry.modifiedAt = Date.now();1577 await persistEntries();1578 buildDomainIndexSync(entries);1579}15801581export async function purgeEntry(id: string): Promise<void> {1582 if (!derivedKey) throw new Error("vault_locked");1583 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1584 if (idx === -1) throw new Error("deleted entry not found");1585 entries.splice(idx, 1);1586 await persistEntries();1587}158815891590export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1591 if (!derivedKey || retentionMs === 0) return 0;1592 const cutoff = Date.now() - retentionMs;1593 const before = entries.length;1594 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1595 const purged = before - entries.length;1596 if (purged > 0) await persistEntries();1597 return purged;1598}15991600export function queryDomain(domain: string): VaultEntry[] {1601 return domainIndex.get(domain) ?? [];1602}16031604export function getEntryById(id: string): VaultEntry | undefined {1605 return entries.find((entry) => entry.id === id && !entry.deletedAt);1606}16071608async function persistSlotA(1609 reason: VaultSnapshot["reason"] = "local_save",1610): Promise<StoredVault> {1611 if (!derivedKey) throw new Error("vault_locked");16121613 const slotA = await loadSlot(SLOT_A);1614 if (!slotA) throw new Error("missing slot A");16151616 1617 1618 await takeVaultSnapshot(reason);16191620 const plaintext = encodePayload(entries, duressConfigured);1621 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);16221623 1624 const nextSlotA: StoredVault = {1625 ...slotA,1626 nonce,1627 ciphertext,1628 tag,1629 1630 };1631 await writeSlot(SLOT_A, nextSlotA);16321633 1634 1635 1636 if (!duressConfigured) {1637 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1638 }1639 await stampLocalVaultMutation();16401641 return nextSlotA;1642}16431644async function persistSlotB(): Promise<void> {1645 if (!derivedKey) throw new Error("vault_locked");16461647 const slotB = await loadSlot(SLOT_B);1648 if (!slotB) throw new Error("missing slot B");16491650 1651 1652 const plaintext = encodePayload(entries, false);1653 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);16541655 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1656 await stampLocalVaultMutation();1657}16581659async function persistEntries(1660 reason: VaultSnapshot["reason"] = "local_save",1661): Promise<void> {1662 if (!derivedKey) throw new Error("vault_locked");16631664 if (vaultMode === "decoy") {1665 await persistSlotB();1666 } else {1667 await persistSlotA(reason);1668 }1669 buildDomainIndexSync(entries);1670}16711672function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1673 if (!Array.isArray(values)) return undefined;1674 const seen = new Set<string>();1675 const cleaned: string[] = [];1676 for (const value of values) {1677 const trimmed = typeof value === "string" ? value.trim() : "";1678 if (!trimmed) continue;1679 const key = trimmed.toLowerCase();1680 if (seen.has(key)) continue;1681 seen.add(key);1682 cleaned.push(trimmed);1683 }1684 return cleaned.length > 0 ? cleaned : undefined;1685}16861687function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1688 const title = entry.title.trim();1689 1690 1691 1692 let type: VaultEntry["type"] = entry.type ?? "login";1693 if (type === "passkey") type = "login";16941695 if (!title) throw new Error("title is required");1696 1697 1698 if (type === "login" && !entry.password?.trim() && !entry.passkey) {1699 throw new Error("password or passkey required");1700 }1701 if (entry.passkey) {1702 const p = entry.passkey;1703 if (1704 !p.rpId ||1705 !p.credentialId ||1706 !p.publicKeyCose ||1707 !p.userHandle ||1708 !p.userName ||1709 (p.algorithm !== -7 && p.algorithm !== -8)1710 ) {1711 throw new Error("invalid passkey credential");1712 }1713 }1714 if (type === "card") {1715 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1716 throw new Error("cardholder name and card number are required");1717 }1718 }17191720 return {1721 type,1722 title,1723 username: type === "login" ? entry.username?.trim() ?? "" : undefined,1724 password: type === "login" ? entry.password : undefined,1725 url: type === "login" ? entry.url?.trim() ?? "" : undefined,1726 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1727 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1728 notes: entry.notes?.trim() || undefined,1729 breach: type === "login" ? entry.breach : undefined,1730 canary: type === "login" ? entry.canary : undefined,1731 tags: entry.tags,1732 passkey: entry.passkey,1733 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1734 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1735 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1736 expYear: type === "card" ? entry.expYear?.trim() : undefined,1737 cvc: type === "card" ? entry.cvc?.trim() : undefined,1738 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1739 network: type === "card" ? entry.network : undefined,1740 last4: type === "card" ? entry.last4?.trim() : undefined,1741 };1742}17431744function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1745 const nextPatch: Partial<VaultEntry> = {};17461747 if ("type" in patch) {1748 nextPatch.type = patch.type;1749 }17501751 if ("title" in patch) {1752 const title = patch.title?.trim() ?? "";1753 if (!title) throw new Error("title is required");1754 nextPatch.title = title;1755 }17561757 if ("url" in patch) {1758 nextPatch.url = patch.url?.trim() ?? "";1759 }17601761 if ("additionalUrls" in patch) {1762 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1763 const seen = new Set<string>();1764 const cleaned: string[] = [];1765 for (const value of incoming) {1766 const trimmed = typeof value === "string" ? value.trim() : "";1767 if (!trimmed) continue;1768 const key = trimmed.toLowerCase();1769 if (seen.has(key)) continue;1770 seen.add(key);1771 cleaned.push(trimmed);1772 }1773 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1774 }17751776 if ("username" in patch) {1777 nextPatch.username = patch.username?.trim() ?? "";1778 }17791780 if ("email" in patch) {1781 nextPatch.email = patch.email?.trim() || undefined;1782 }17831784 if ("password" in patch) {1785 const password = patch.password ?? "";1786 1787 nextPatch.password = password || undefined;1788 }17891790 if ("totp" in patch) {1791 nextPatch.totp = patch.totp?.trim() || undefined;1792 }17931794 if ("notes" in patch) {1795 nextPatch.notes = patch.notes?.trim() || undefined;1796 }17971798 if ("breach" in patch) {1799 nextPatch.breach = patch.breach;1800 }18011802 if ("canary" in patch) {1803 nextPatch.canary = patch.canary;1804 }18051806 if ("passkey" in patch) {1807 1808 1809 1810 nextPatch.passkey = patch.passkey;1811 }18121813 if ("tags" in patch) {1814 nextPatch.tags = patch.tags;1815 }18161817 if ("cardholderName" in patch) {1818 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1819 }1820 if ("cardNumber" in patch) {1821 const n = patch.cardNumber ?? "";1822 nextPatch.cardNumber = n || undefined;1823 }1824 if ("expMonth" in patch) {1825 nextPatch.expMonth = patch.expMonth?.trim() || undefined;1826 }1827 if ("expYear" in patch) {1828 nextPatch.expYear = patch.expYear?.trim() || undefined;1829 }1830 if ("cvc" in patch) {1831 nextPatch.cvc = patch.cvc?.trim() || undefined;1832 }1833 if ("cardNickname" in patch) {1834 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1835 }1836 if ("network" in patch) {1837 nextPatch.network = patch.network;1838 }1839 if ("last4" in patch) {1840 nextPatch.last4 = patch.last4?.trim() || undefined;1841 }18421843 return nextPatch;1844}18451846function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1847 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1848 const modifiedAt =1849 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;18501851 const base: VaultEntry = {1852 id: entry.id,1853 type: "type" in entry ? entry.type : "login",1854 title: entry.title,1855 username: entry.username,1856 password: entry.password,1857 url: entry.url,1858 totp: entry.totp,1859 notes: entry.notes,1860 breach: entry.breach,1861 canary: "canary" in entry ? entry.canary : undefined,1862 passkey: "passkey" in entry ? entry.passkey : undefined,1863 createdAt,1864 modifiedAt,1865 deletedAt: entry.deletedAt,1866 tags: "tags" in entry ? entry.tags : undefined,1867 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1868 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1869 expMonth: "expMonth" in entry ? entry.expMonth : undefined,1870 expYear: "expYear" in entry ? entry.expYear : undefined,1871 cvc: "cvc" in entry ? entry.cvc : undefined,1872 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1873 network: "network" in entry ? entry.network : undefined,1874 last4: "last4" in entry ? entry.last4 : undefined,1875 };18761877 return migrateLegacyPasskey(base, entry);1878}187918801881188218831884188518861887function migrateLegacyPasskey(1888 base: VaultEntry,1889 raw: VaultEntry | LegacyVaultEntry1890): VaultEntry {1891 if (base.passkey) return base;1892 const flat = raw as Partial<VaultEntry>;1893 const isPasskeyType = base.type === "passkey";1894 const hasFlatPasskeyFields =1895 typeof flat.rpId === "string" &&1896 typeof flat.credentialId === "string" &&1897 typeof flat.publicKeyCose === "string" &&1898 typeof flat.userHandle === "string" &&1899 typeof flat.userName === "string";1900 if (!isPasskeyType && !hasFlatPasskeyFields) return base;1901 if (!hasFlatPasskeyFields) return base;19021903 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;1904 base.passkey = {1905 rpId: flat.rpId!,1906 rpName: flat.rpName ?? flat.rpId!,1907 credentialId: flat.credentialId!,1908 publicKeyCose: flat.publicKeyCose!,1909 privateKeyJwk: flat.privateKeyJwk,1910 userHandle: flat.userHandle!,1911 userName: flat.userName!,1912 userDisplayName: flat.userDisplayName ?? flat.userName!,1913 algorithm,1914 signCount: flat.signCount ?? 0,1915 aaguid: flat.aaguid ?? "",1916 backupEligible: flat.backupEligible === true,1917 backupState: flat.backupState === true,1918 lastUsedAt: flat.lastUsedAt,1919 browser: flat.browser,1920 transports: flat.transports,1921 createdAt: base.createdAt,1922 };1923 1924 1925 1926 if (isPasskeyType) base.type = "login";1927 if (!base.username) base.username = flat.userName;1928 if (!base.url) base.url = `https://${flat.rpId}`;1929 return base;1930}1931193219331934193519361937function entryDupeKey(e: VaultEntry): string {1938 if (e.type === "note") return `note|${e.title.toLowerCase()}`;1939 1940 1941 1942 1943 if (e.passkey) {1944 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;1945 }1946 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1947}194819491950export function countDuplicates(): number {1951 const best = new Map<string, VaultEntry>();1952 let dupeCount = 0;1953 for (const e of entries.filter((e) => !e.deletedAt)) {1954 const key = entryDupeKey(e);1955 const existing = best.get(key);1956 if (!existing) {1957 best.set(key, e);1958 } else {1959 dupeCount++;1960 1961 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1962 best.set(key, e);1963 }1964 }1965 }1966 return dupeCount;1967}196819691970197119721973export async function deduplicateEntries(): Promise<number> {1974 if (!derivedKey) throw new Error("vault_locked");1975 1976 1977 if (vaultMode === "decoy") {1978 throw new Error("deduplicate disabled in decoy mode");1979 }19801981 const best = new Map<string, VaultEntry>();1982 for (const e of entries.filter((en) => !en.deletedAt)) {1983 const key = entryDupeKey(e);1984 const existing = best.get(key);1985 if (!existing) {1986 best.set(key, e);1987 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1988 best.set(key, e);1989 }1990 }19911992 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));1993 const now = Date.now();1994 let removed = 0;1995 entries = entries.map((e) => {1996 if (!e.deletedAt && !keepIds.has(e.id)) {1997 removed++;1998 return { ...e, deletedAt: now };1999 }2000 return e;2001 });20022003 if (removed > 0) await persistEntries();2004 return removed;2005}200620072008200920102011201220132014const HISTORY_KEY = "peach_vault_history";2015const HISTORY_MAX = 10;20162017201820192020202120222023export async function takeVaultSnapshot(2024 reason: VaultSnapshot["reason"],2025): Promise<void> {2026 try {2027 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2028 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2029 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];20302031 2032 2033 2034 if (!slotA) return;20352036 const vault_a = JSON.stringify(slotA);2037 const vault_b = JSON.stringify(slotB ?? slotA);20382039 2040 2041 2042 let entryCount: number | null = null;2043 if (derivedKey && vaultMode === "real") {2044 try {2045 entryCount = entries.filter((e) => !e.deletedAt).length;2046 } catch {2047 entryCount = null;2048 }2049 }20502051 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2052 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2053 ? existingRaw2054 : { snapshots: [] };20552056 2057 2058 2059 const latest = history.snapshots[0];2060 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2061 return;2062 }20632064 const snapshot: VaultSnapshot = {2065 id: crypto.randomUUID(),2066 savedAt: Date.now(),2067 reason,2068 vault_a,2069 vault_b,2070 entryCount,2071 };20722073 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2074 await chrome.storage.local.set({ [HISTORY_KEY]: history });2075 } catch (err) {2076 2077 2078 2079 2080 console.warn('[Peach] takeVaultSnapshot failed:', err);2081 if (String(err).includes('QUOTA') || String(err).includes('quota')) {2082 try {2083 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2084 } catch {2085 2086 }2087 }2088 }2089}209020912092export async function clearVaultHistory(): Promise<void> {2093 try {2094 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2095 } catch {2096 2097 }2098}209921002101export async function getVaultHistory(): Promise<VaultSnapshot[]> {2102 const raw = await chrome.storage.local.get(HISTORY_KEY);2103 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2104 if (!history || !Array.isArray(history.snapshots)) return [];2105 return history.snapshots;2106}21072108210921102111211221132114export async function restoreVaultSnapshot(2115 snapshotId: string,2116): Promise<{ ok: true } | { ok: false; error: string }> {2117 const history = await getVaultHistory();2118 const snapshot = history.find((s) => s.id === snapshotId);2119 if (!snapshot) return { ok: false, error: "snapshot_not_found" };21202121 2122 2123 2124 await takeVaultSnapshot("restore");21252126 let restoredA: StoredVault;2127 let restoredB: StoredVault;2128 try {2129 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2130 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2131 } catch {2132 return { ok: false, error: "snapshot_corrupt" };2133 }21342135 await writeSlot(SLOT_A, restoredA);2136 await writeSlot(SLOT_B, restoredB);2137 await stampLocalVaultMutation();21382139 2140 2141 2142 lock();21432144 return { ok: true };2145}21462147214821492150215121522153export async function changePassword(2154 currentPassword: string,2155 newPassword: string2156): Promise<boolean> {2157 if (!derivedKey || vaultMode !== "real") return false;21582159 2160 const valid = await verifyMainPassword(currentPassword);2161 if (!valid) return false;21622163 const slotA = await loadSlot(SLOT_A);2164 if (!slotA) return false;2165 if (!rootKey) return false;21662167 2168 const newSalt = generateSalt();2169 const newKey = await deriveKey(newPassword, newSalt);21702171 2172 const plaintext = encodePayload(entries, duressConfigured);2173 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);21742175 2176 2177 2178 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);21792180 const finalSlotA: StoredVault = {2181 ...slotA,2182 salt: hex(newSalt),2183 nonce,2184 ciphertext,2185 tag,2186 ...pwRoot,2187 };21882189 2190 2191 2192 await takeVaultSnapshot("local_save");21932194 2195 2196 2197 2198 await writeSlot(SLOT_A, finalSlotA);21992200 2201 2202 2203 zeroKey(derivedKey);2204 derivedKey = newKey;22052206 2207 2208 2209 if (!duressConfigured) {2210 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2211 }2212 await stampLocalVaultMutation();22132214 return true;2215}221622172218221922202221export async function getVaultBlobForSync(): Promise<{2222 blob: string;2223 timestamp: number;2224 license?: string;2225} | null> {2226 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, 'peach_last_modified_at', 'peach_license']);2227 const slotA = (raw as Record<string, unknown>)[SLOT_A];2228 if (!slotA) return null;2229 const slotB = (raw as Record<string, unknown>)[SLOT_B];2230 const blob = JSON.stringify({ a: slotA, b: slotB });2231 const rawTs = (raw as Record<string, unknown>)['peach_last_modified_at'];2232 const timestamp = typeof rawTs === 'string' ? parseInt(rawTs) : 0;2233 const license = (raw as Record<string, unknown>)['peach_license'];2234 return {2235 blob,2236 timestamp,2237 license: typeof license === 'string' ? license : undefined,2238 };2239}22402241export async function applyIncomingVault(2242 blobJson: string,2243 remoteTimestamp: number,2244 remoteLicense?: string,2245): Promise<boolean> {2246 const raw = await chrome.storage.local.get('peach_last_modified_at');2247 const rawTs = (raw as Record<string, unknown>)['peach_last_modified_at'];2248 const localTimestamp = typeof rawTs === 'string' ? parseInt(rawTs) : 0;22492250 if (remoteLicense) {2251 const licenseRaw = await chrome.storage.local.get('peach_license');2252 const existing = (licenseRaw as Record<string, unknown>)['peach_license'];2253 if (!existing) {2254 await chrome.storage.local.set({ peach_license: remoteLicense });2255 }2256 }22572258 if (remoteTimestamp <= localTimestamp) return false;22592260 2261 2262 2263 2264 2265 let parsed: unknown;2266 try {2267 parsed = JSON.parse(blobJson);2268 } catch {2269 throw new Error('invalid_incoming_vault_blob');2270 }2271 if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {2272 throw new Error('invalid_incoming_vault_blob');2273 }2274 const { a, b } = parsed as { a?: unknown; b?: unknown };2275 if (!hasValidVaultSchema(a)) {2276 throw new Error('invalid_incoming_vault_blob');2277 }22782279 2280 2281 2282 try {2283 await takeVaultSnapshot('sync_received');2284 } catch (snapshotErr) {2285 console.warn('[Peach] vault snapshot failed during sync:', snapshotErr);2286 if (String(snapshotErr).includes('QUOTA') || String(snapshotErr).includes('quota')) {2287 try {2288 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2289 } catch {2290 2291 }2292 }2293 }22942295 await writeSlot(SLOT_A, a);2296 if (hasValidVaultSchema(b)) await writeSlot(SLOT_B, b);2297 await chrome.storage.local.set({2298 peach_last_sync_at: String(Date.now()),2299 peach_last_modified_at: String(remoteTimestamp),2300 });23012302 return true;2303}23042305230623072308230923102311231223132314231523162317export async function getSyncIdentity(): Promise<{2318 privateKeyBytes: Uint8Array;2319 publicKeyBytes: Uint8Array;2320 publicKeyHex: string;2321}> {2322 if (!derivedKey) throw new Error('vault_locked');2323 const raw = await chrome.storage.local.get('peach_sync_identity');2324 const stored = (raw as Record<string, unknown>)['peach_sync_identity'];23252326 if (typeof stored === 'string') {2327 const parsed = JSON.parse(stored) as {2328 pub: string;2329 priv?: string;2330 enc_priv?: string;2331 enc_nonce?: string;2332 enc_tag?: string;2333 };2334 const publicKeyBytes = base64DecodeBytes(parsed.pub);23352336 if (parsed.enc_priv && parsed.enc_nonce && parsed.enc_tag) {2337 const privHex = await decrypt(2338 derivedKey,2339 parsed.enc_nonce,2340 parsed.enc_priv,2341 parsed.enc_tag,2342 );2343 const privateKeyBytes = unhex(privHex);2344 return { privateKeyBytes, publicKeyBytes, publicKeyHex: bytesToHex(publicKeyBytes) };2345 }23462347 if (parsed.priv) {2348 2349 const privateKeyBytes = base64DecodeBytes(parsed.priv);2350 await persistEncryptedSyncIdentity(privateKeyBytes, publicKeyBytes);2351 return { privateKeyBytes, publicKeyBytes, publicKeyHex: bytesToHex(publicKeyBytes) };2352 }2353 }23542355 2356 const { generateKeyPair } = await import('@stablelib/x25519');2357 const keypair = generateKeyPair();2358 await persistEncryptedSyncIdentity(keypair.secretKey, keypair.publicKey);2359 return {2360 privateKeyBytes: keypair.secretKey,2361 publicKeyBytes: keypair.publicKey,2362 publicKeyHex: bytesToHex(keypair.publicKey),2363 };2364}23652366async function persistEncryptedSyncIdentity(2367 privateKeyBytes: Uint8Array,2368 publicKeyBytes: Uint8Array,2369): Promise<void> {2370 if (!derivedKey) throw new Error('vault_locked');2371 const enc = await encrypt(derivedKey, hex(privateKeyBytes));2372 await chrome.storage.local.set({2373 peach_sync_identity: JSON.stringify({2374 pub: base64EncodeBytes(publicKeyBytes),2375 enc_priv: enc.ciphertext,2376 enc_nonce: enc.nonce,2377 enc_tag: enc.tag,2378 }),2379 });2380}23812382238323842385export async function reloadEntriesAfterSync(): Promise<boolean> {2386 if (!derivedKey) return false;2387 const slotA = await loadSlot(SLOT_A);2388 if (!slotA) return false;2389 try {2390 const payload = await tryDecryptSlot(slotA, derivedKey);2391 if (!payload) return false;2392 entries = payload.entries;2393 buildDomainIndexSync(entries);2394 return true;2395 } catch {2396 return false;2397 }2398}23992400function bytesToHex(bytes: Uint8Array): string {2401 return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');2402}24032404function base64EncodeBytes(bytes: Uint8Array): string {2405 let binary = '';2406 for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);2407 return btoa(binary);2408}24092410function base64DecodeBytes(b64: string): Uint8Array {2411 const binary = atob(b64);2412 const bytes = new Uint8Array(binary.length);2413 for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);2414 return bytes;2415}