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, "slot_a_v1");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 "pw_root_v1",444 );445 return unhex(rootHex);446 } catch {447 return null;448 }449}450451452453async function wrapRootKeyForDerivedKey(454 derivedKeyBytes: Uint8Array,455 rootKeyBytes: Uint8Array,456): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {457 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");458 return {459 pw_root_ciphertext: ciphertext,460 pw_root_nonce: nonce,461 pw_root_tag: tag,462 };463}464465466467468469470471472export async function migrateToRecovery(): Promise<void> {473 if (!derivedKey || vaultMode !== "real") {474 throw new Error("vault must be unlocked in real mode");475 }476 const slotA = await loadSlot(SLOT_A);477 if (!slotA) throw new Error("missing slot A");478 const hasRecoveryEscrow =479 !!slotA.recovery_salt && !!slotA.recovery_wrapped_root_key;480 const hasPwRoot = !!slotA.pw_root_ciphertext && !!slotA.pw_root_nonce;481 if (hasRecoveryEscrow && hasPwRoot) return; 482483 484 485 486 487 488 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =489 await generateAndWrapRootKey();490 rootKey = unhex(rawRootKey);491492 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);493 const nextSlotA: StoredVault = {494 ...slotA,495 recovery_salt: recoverySalt,496 recovery_wrapped_root_key: wrappedRootKey,497 recovery_nonce: wrappedNonce,498 recovery_tag: wrappedTag,499 ...pwRoot,500 };501 await writeSlot(SLOT_A, nextSlotA);502 await stampLocalVaultMutation();503}504505506507508509510export async function getRecoveryWords(): Promise<string[]> {511 if (!rootKey) {512 throw new Error("recovery not set up — vault may need migration");513 }514 return rootKeyToWords(rootKey);515}516517async function loadSlot(key: string): Promise<StoredVault | null> {518 const result = await chrome.storage.local.get(key);519 return (result as Record<string, StoredVault | undefined>)[key] ?? null;520}521522async function writeSlot(key: string, vault: StoredVault): Promise<void> {523 try {524 await chrome.storage.local.set({ [key]: vault });525 } catch (err) {526 throw mapStorageError(err);527 }528}529530async function stampLocalVaultMutation(): Promise<void> {531 await chrome.storage.local.set({532 peach_last_modified_at: String(Date.now()),533 });534}535536537538539540541async function ensureMigrated(): Promise<void> {542 if (migrationRan) return;543 migrationRan = true;544545 const keys = [LEGACY_MAIN, LEGACY_DECOY, LEGACY_DURESS_FLAG, SLOT_A, SLOT_B];546 const raw = await chrome.storage.local.get(keys);547 const slotAExists = raw[SLOT_A] !== undefined;548 const slotBExists = raw[SLOT_B] !== undefined;549 const legacyMain = raw[LEGACY_MAIN] as StoredVault | undefined;550 const legacyDecoy = raw[LEGACY_DECOY] as StoredVault | undefined;551552 553 if (!slotAExists && !legacyMain) return;554555 556 if (slotAExists && slotBExists && !legacyMain && !legacyDecoy) return;557558 let didMigrate = false;559 if (legacyMain && !slotAExists) {560 await writeSlot(SLOT_A, legacyMain);561 didMigrate = true;562 }563564 565 if (!slotBExists) {566 const target = (legacyMain ?? raw[SLOT_A]) as StoredVault | undefined;567 if (legacyDecoy) {568 await writeSlot(SLOT_B, legacyDecoy);569 didMigrate = true;570 } else if (target) {571 await writeSlot(SLOT_B, generateNoiseBlob(target.ciphertext.length / 2));572 didMigrate = true;573 }574 }575576 const toRemove: string[] = [];577 if (legacyMain) toRemove.push(LEGACY_MAIN);578 if (legacyDecoy) toRemove.push(LEGACY_DECOY);579 if (raw[LEGACY_DURESS_FLAG] !== undefined) toRemove.push(LEGACY_DURESS_FLAG);580 if (toRemove.length > 0) {581 await chrome.storage.local.remove(toRemove);582 didMigrate = true;583 }584 if (didMigrate) {585 await stampLocalVaultMutation();586 }587}588589590591592593594595596597598599function padPlaintext(json: string): string {600 const encoded = new TextEncoder().encode(json);601 const target = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);602 const padLen = target - encoded.length;603 return json + " ".repeat(padLen);604}605606607608609610611612613614615616const CSPRNG_CHUNK = 65_536;617618function fillRandom(buf: Uint8Array): Uint8Array {619 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {620 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);621 const chunk = new Uint8Array(end - offset);622 crypto.getRandomValues(chunk);623 buf.set(chunk, offset);624 }625 return buf;626}627628function generateNoiseBlob(ciphertextBytes: number): StoredVault {629 const salt = crypto.getRandomValues(new Uint8Array(16));630 const nonce = crypto.getRandomValues(new Uint8Array(12));631 const cipher = fillRandom(new Uint8Array(ciphertextBytes));632 const tag = crypto.getRandomValues(new Uint8Array(16));633 return {634 version: 1,635 salt: hex(salt),636 nonce: hex(nonce),637 ciphertext: hex(cipher),638 tag: hex(tag),639 };640}641642643644645646type VaultPayload = {647 entries: VaultEntry[];648 duressConfigured?: boolean;649};650651type DecryptedPayload = {652 entries: VaultEntry[];653 duressConfigured: boolean;654 655656657658659660661 migratedFromLegacy: boolean;662};663664function encodePayload(entries: VaultEntry[], duress: boolean): string {665 const envelope: VaultPayload = { entries, duressConfigured: duress };666 return padPlaintext(JSON.stringify(envelope));667}668669function rawNeedsMigration(raw: VaultEntry | LegacyVaultEntry): boolean {670 if ("created" in raw || "updated" in raw) return true;671 const flat = raw as Partial<VaultEntry>;672 if (flat.type === "passkey") return true;673 674 if (flat.rpId !== undefined && flat.passkey === undefined) return true;675 return false;676}677678function decodePayload(plain: string): DecryptedPayload {679 const parsed = JSON.parse(plain) as680 | Array<VaultEntry | LegacyVaultEntry>681 | VaultPayload;682683 const rawEntries = Array.isArray(parsed) ? parsed : parsed.entries ?? [];684 const flatMigrationNeeded = rawEntries.some(rawNeedsMigration);685686 const normalized = rawEntries.map(normalizeEntry);687 const { merged } = mergePasskeyOnlyIntoLogins(normalized);688689 if (Array.isArray(parsed)) {690 return {691 entries: normalized,692 duressConfigured: false,693 migratedFromLegacy: true, 694 };695 }696697 return {698 entries: normalized,699 duressConfigured: parsed.duressConfigured === true,700 migratedFromLegacy: flatMigrationNeeded || merged,701 };702}703704705706707708709710711712713714715716717718719720721722723724725726727function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {728 let merged = false;729 const now = Date.now();730731 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);732 const byRpId = new Map<string, VaultEntry[]>();733 for (const e of candidates) {734 const rpId = e.passkey!.rpId;735 const list = byRpId.get(rpId);736 if (list) list.push(e);737 else byRpId.set(rpId, [e]);738 }739740 for (const [rpId, group] of byRpId) {741 if (group.length === 0) continue;742 group.sort((a, b) => {743 const aT =744 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;745 const bT =746 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;747 return bT - aT;748 });749 const winner = group[0]!;750751 752 753 754 const loginTarget = entries.find((e) => {755 if (e.deletedAt || e.passkey || e.type !== "login") return false;756 if (e.url && extractDomain(e.url) === rpId) return true;757 for (const extra of e.additionalUrls ?? []) {758 if (extractDomain(extra) === rpId) return true;759 }760 return false;761 });762763 if (loginTarget) {764 loginTarget.passkey = winner.passkey;765 loginTarget.modifiedAt = now;766 for (const dup of group) {767 if (!dup.deletedAt) {768 dup.deletedAt = now;769 dup.modifiedAt = now;770 }771 }772 merged = true;773 } else if (group.length > 1) {774 for (let i = 1; i < group.length; i += 1) {775 const dup = group[i]!;776 if (!dup.deletedAt) {777 dup.deletedAt = now;778 dup.modifiedAt = now;779 }780 }781 merged = true;782 }783 }784785 return { merged };786}787788789790791792793794function hasValidVaultSchema(v: unknown): v is StoredVault {795 if (typeof v !== "object" || v === null) return false;796 const r = v as Record<string, unknown>;797 return (798 r.version === 1 &&799 typeof r.salt === "string" &&800 typeof r.nonce === "string" &&801 typeof r.ciphertext === "string" &&802 typeof r.tag === "string"803 );804}805806export type TryDecryptResult =807 | { status: "ok"; payload: DecryptedPayload }808 | { status: "wrong_key" }809 | { status: "corrupted"; reason: string };810811812813814815816export async function tryDecryptSlot(817 vault: StoredVault,818 key: Uint8Array,819 aadTag?: string,820): Promise<DecryptedPayload | null> {821 const res = await tryDecryptSlotResult(vault, key, aadTag);822 return res.status === "ok" ? res.payload : null;823}824825export async function tryDecryptSlotResult(826 vault: StoredVault,827 key: Uint8Array,828 aadTag?: string,829): Promise<TryDecryptResult> {830 if (!hasValidVaultSchema(vault)) {831 return { status: "corrupted", reason: "invalid_slot_schema" };832 }833 let plain: string;834 try {835 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);836 } catch {837 return { status: "wrong_key" };838 }839 try {840 const parsed = JSON.parse(plain) as unknown;841 if (typeof parsed !== "object" || parsed === null) {842 return { status: "corrupted", reason: "invalid_payload_shape" };843 }844 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {845 return { status: "corrupted", reason: "invalid_payload_shape" };846 }847 return { status: "ok", payload: decodePayload(plain) };848 } catch {849 return { status: "corrupted", reason: "invalid_payload_json" };850 }851}852853854855856857function buildDomainIndexSync(all: VaultEntry[]): void {858 domainIndex = new Map();859860 for (const entry of all) {861 if (entry.deletedAt) continue;862 if (entry.type === "note" || entry.type === "card") continue;863864 865 866 867 868 const domains = new Set<string>();869 const primary = extractDomain(entry.url ?? "");870 if (primary) domains.add(primary);871 for (const extra of entry.additionalUrls ?? []) {872 const d = extractDomain(extra);873 if (d) domains.add(d);874 }875 if (domains.size === 0) continue;876877 for (const domain of domains) {878 const bucket = domainIndex.get(domain) ?? [];879 bucket.push(entry);880 domainIndex.set(domain, bucket);881 }882 }883}884885export async function createVault(password: string): Promise<void> {886 await ensureMigrated();887888 const salt = generateSalt();889 derivedKey = await deriveKey(password, salt);890 entries = [];891 vaultMode = "real";892 duressConfigured = false;893 buildDomainIndexSync(entries);894895 896 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =897 await generateAndWrapRootKey();898 rootKey = unhex(rawRootKey);899900 const plaintext = encodePayload(entries, duressConfigured);901 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");902903 904 905 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);906907 const slotA: StoredVault = {908 version: 1,909 salt: hex(salt),910 nonce,911 ciphertext,912 tag,913 recovery_salt: recoverySalt,914 recovery_wrapped_root_key: wrappedRootKey,915 recovery_nonce: wrappedNonce,916 recovery_tag: wrappedTag,917 ...pwRoot,918 };919 await writeSlot(SLOT_A, slotA);920921 922 923 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));924 await stampLocalVaultMutation();925926 touchAutoLock();927}928929export async function hasVault(): Promise<boolean> {930 await ensureMigrated();931 return (await loadSlot(SLOT_A)) !== null;932}933934935936937938939export async function isDuressEnabled(): Promise<boolean> {940 return vaultMode === "real" && duressConfigured;941}942943export async function setupDuress(duressPassword: string): Promise<void> {944 if (!derivedKey || vaultMode !== "real") {945 throw new Error("vault must be unlocked in real mode");946 }947 await ensureMigrated();948949 950 951 952 const slotA = await loadSlot(SLOT_A);953 if (!slotA) throw new Error("missing slot A");954955 const decoySalt = generateSalt();956 const decoyKey = await deriveKey(duressPassword, decoySalt);957 try {958 959 960 const targetBytes = slotA.ciphertext.length / 2;961 let plaintext = encodePayload([], false);962 const encoded = new TextEncoder().encode(plaintext);963 if (encoded.length < targetBytes) {964 plaintext = plaintext + " ".repeat(targetBytes - encoded.length);965 }966 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");967 await writeSlot(SLOT_B, {968 version: 1,969 salt: hex(decoySalt),970 nonce,971 ciphertext,972 tag,973 });974 } finally {975 zeroKey(decoyKey);976 }977978 979 980 duressConfigured = true;981 await persistSlotA();982}983984export async function disableDuress(): Promise<void> {985 if (!derivedKey || vaultMode !== "real") {986 throw new Error("vault must be unlocked in real mode");987 }988 await ensureMigrated();989990 991 992 993 const slotA = await loadSlot(SLOT_A);994 if (slotA) {995 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));996 }997998 duressConfigured = false;999 await persistSlotA();1000}10011002100310041005100610071008export async function verifyMainPassword(password: string): Promise<boolean> {1009 await ensureMigrated();1010 const slotA = await loadSlot(SLOT_A);1011 if (!slotA) return false;1012 const key = await deriveKey(password, unhex(slotA.salt));1013 try {1014 return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1015 } finally {1016 zeroKey(key);1017 }1018}10191020export async function unlock(password: string): Promise<boolean> {1021 await ensureMigrated();10221023 1024 1025 1026 1027 1028 const [slotA, slotB] = await Promise.all([1029 loadSlot(SLOT_A),1030 loadSlot(SLOT_B),1031 ]);10321033 1034 const [keyA, keyB] = await Promise.all([1035 slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),1036 slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),1037 ]);10381039 1040 1041 1042 const [resultA, resultB] = await Promise.all([1043 keyA && slotA1044 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1045 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1046 keyB && slotB1047 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1048 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1049 ]);1050 const payloadA = resultA.status === "ok" ? resultA.payload : null;1051 const payloadB = resultB.status === "ok" ? resultB.payload : null;10521053 if (payloadA && keyA) {1054 derivedKey = keyA;1055 vaultMode = "real";1056 entries = payloadA.entries;1057 duressConfigured = payloadA.duressConfigured;1058 buildDomainIndexSync(entries);1059 await loadSettings();1060 touchAutoLock();1061 if (keyB) zeroKey(keyB);10621063 1064 if (slotA) {1065 const needsRecoverySetup =1066 !slotA.recovery_salt || !slotA.recovery_wrapped_root_key;1067 if (!needsRecoverySetup) {1068 rootKey = await deriveRootKeyFromDerivedKey(keyA, slotA);1069 }1070 1071 1072 1073 1074 if (needsRecoverySetup || !rootKey) {1075 try {1076 await migrateToRecovery();1077 if (needsRecoverySetup) {1078 1079 1080 1081 await chrome.storage.local.set({ peach_recovery_pending: '1' });1082 }1083 } catch (migrationErr) {1084 console.warn('[Peach] recovery migration failed:', migrationErr);1085 }1086 }1087 }10881089 1090 1091 1092 if (payloadA.migratedFromLegacy) {1093 await persistSlotA();1094 }1095 return true;1096 }10971098 if (payloadB && keyB) {1099 derivedKey = keyB;1100 vaultMode = "decoy";1101 entries = payloadB.entries;1102 duressConfigured = false;1103 buildDomainIndexSync(entries);1104 await loadSettings();1105 touchAutoLock();1106 if (keyA) zeroKey(keyA);1107 return true;1108 }11091110 if (keyA) zeroKey(keyA);1111 if (keyB) zeroKey(keyB);1112 1113 1114 if (resultA.status === "corrupted") {1115 lastUnlockFailureReason = resultA.reason;1116 } else if (resultB.status === "corrupted") {1117 lastUnlockFailureReason = resultB.reason;1118 }1119 return false;1120}11211122export function lock(): void {1123 if (derivedKey) {1124 zeroKey(derivedKey);1125 derivedKey = null;1126 }1127 if (rootKey) {1128 rootKey.fill(0);1129 rootKey = null;1130 }11311132 entries = [];1133 domainIndex = new Map();1134 vaultMode = "real";1135 duressConfigured = false;1136 clearAutoLock();1137}11381139export function isUnlocked(): boolean {1140 return derivedKey !== null;1141}1142114311441145export async function unlockWithKey(1146 masterKeyHex: string,1147): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1148 try {1149 const masterKeyBytes = unhex(masterKeyHex);1150 const stored = await loadSlot(SLOT_A);1151 if (!stored) return { ok: false, error: "invalid_key" };1152 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1153 if (!payload) return { ok: false, error: "invalid_key" };11541155 derivedKey = masterKeyBytes;1156 vaultMode = "real";1157 entries = payload.entries;1158 duressConfigured = payload.duressConfigured;1159 buildDomainIndexSync(entries);1160 await loadSettings();1161 touchAutoLock();1162 return { ok: true, entries };1163 } catch {1164 return { ok: false, error: "invalid_key" };1165 }1166}11671168116911701171117211731174117511761177117811791180118111821183118411851186export async function unlockFromCodex(params: {1187 slotA: string; 1188 slotB: string | null; 1189 timestamp: number;1190 masterPassphrase: string;1191}): Promise<1192 | { ok: true; entryCount: number }1193 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1194> {1195 let parsedSlotA: StoredVault;1196 let parsedSlotB: StoredVault | null = null;1197 try {1198 const rawA = JSON.parse(params.slotA) as unknown;1199 if (!hasValidVaultSchema(rawA)) {1200 return { ok: false, reason: "invalid_slot_format" };1201 }1202 parsedSlotA = rawA;1203 if (params.slotB !== null) {1204 const rawB = JSON.parse(params.slotB) as unknown;1205 if (!hasValidVaultSchema(rawB)) {1206 return { ok: false, reason: "invalid_slot_format" };1207 }1208 parsedSlotB = rawB;1209 }1210 } catch {1211 return { ok: false, reason: "invalid_slot_format" };1212 }12131214 1215 1216 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1217 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1218 if (!trialPayload) {1219 zeroKey(trialKey);1220 return { ok: false, reason: "wrong_passphrase" };1221 }1222 1223 1224 zeroKey(trialKey);12251226 1227 1228 await takeVaultSnapshot("restore");12291230 await writeSlot(SLOT_A, parsedSlotA);1231 if (parsedSlotB) {1232 await writeSlot(SLOT_B, parsedSlotB);1233 } else {1234 1235 1236 1237 await writeSlot(1238 SLOT_B,1239 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1240 );1241 }1242 await chrome.storage.local.set({1243 peach_last_modified_at: String(params.timestamp),1244 });12451246 1247 1248 const ok = await unlock(params.masterPassphrase);1249 if (!ok) {1250 1251 1252 1253 return { ok: false, reason: "wrong_passphrase" };1254 }1255 return { ok: true, entryCount: getEntries().length };1256}1257125812591260export function getDerivedKeyHex(): string {1261 if (!derivedKey) throw new Error("vault_not_unlocked");1262 return hex(derivedKey);1263}12641265export function getEntries(): VaultEntry[] {1266 if (!derivedKey) throw new Error("vault_locked");1267 return entries.filter((entry) => !entry.deletedAt);1268}12691270const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;12711272export function getIdentities(): SavedIdentity[] {1273 if (!derivedKey) throw new Error("vault_locked");1274 const active = entries.filter((e) => !e.deletedAt && e.type === "login");12751276 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1277 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();12781279 for (const entry of active) {1280 const value = entry.username?.trim();1281 if (!value) continue;1282 const map = EMAIL_RE.test(value) ? emailMap : usernameMap;1283 const existing = map.get(value);1284 const domain = extractDomain(entry.url ?? "") ?? "";1285 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;1286 if (!existing || modifiedAt > existing.lastUsed) {1287 map.set(value, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1288 } else {1289 map.set(value, { ...existing, count: existing.count + 1 });1290 }1291 }12921293 const toIdentity = (type: "email" | "username") =>1294 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1295 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1296 });12971298 return [1299 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1300 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1301 ];1302}13031304export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1305 if (!derivedKey) throw new Error("vault_locked");13061307 const now = Date.now();1308 const added: VaultEntry[] = [];1309 for (const draft of drafts) {1310 try {1311 const sanitized = sanitizeEntryDraft(draft);1312 const nextEntry: VaultEntry = {1313 ...sanitized,1314 id: crypto.randomUUID(),1315 createdAt: now,1316 modifiedAt: now,1317 };1318 entries.push(nextEntry);1319 added.push(nextEntry);1320 } catch {1321 1322 }1323 }13241325 if (added.length > 0) {1326 await persistEntries();1327 }13281329 return added;1330}133113321333133413351336133713381339export async function restoreEntries(1340 incoming: VaultEntry[],1341 mode: "replace" | "merge"1342): Promise<{ added: number; updated: number; skipped: number }> {1343 if (!derivedKey) throw new Error("vault_locked");13441345 1346 1347 1348 if (mode === "replace" && incoming.length === 0) {1349 throw new Error("refuse to replace with empty entry set");1350 }13511352 let added = 0;1353 let updated = 0;1354 let skipped = 0;13551356 if (mode === "replace") {1357 entries = [];1358 for (const entry of incoming) {1359 const normalized = sanitizeRestoredEntry(entry);1360 if (!normalized) {1361 skipped += 1;1362 continue;1363 }1364 entries.push(normalized);1365 added += 1;1366 }1367 } else {1368 const byId = new Map<string, VaultEntry>();1369 for (const e of entries) byId.set(e.id, e);13701371 for (const entry of incoming) {1372 const normalized = sanitizeRestoredEntry(entry);1373 if (!normalized) {1374 skipped += 1;1375 continue;1376 }1377 const existing = byId.get(normalized.id);1378 if (!existing) {1379 entries.push(normalized);1380 added += 1;1381 } else if (normalized.modifiedAt > existing.modifiedAt) {1382 const idx = entries.findIndex((e) => e.id === existing.id);1383 if (idx !== -1) {1384 entries[idx] = normalized;1385 updated += 1;1386 }1387 } else {1388 skipped += 1;1389 }1390 }1391 }13921393 await persistEntries("import");1394 return { added, updated, skipped };1395}1396139713981399140014011402function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {1403 if (!entry || typeof entry !== "object") return null;1404 const title = typeof entry.title === "string" ? entry.title.trim() : "";1405 if (!title) return null;1406 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();1407 const now = Date.now();1408 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;1409 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;14101411 1412 1413 1414 const migrated: VaultEntry = migrateLegacyPasskey(1415 {1416 ...entry,1417 id,1418 title,1419 type: entry.type ?? "login",1420 createdAt,1421 modifiedAt,1422 },1423 entry1424 );14251426 1427 1428 if (1429 migrated.type === "login" &&1430 !migrated.passkey &&1431 typeof migrated.password !== "string"1432 ) {1433 return null;1434 }1435 if (migrated.passkey && !migrated.passkey.credentialId) {1436 return null;1437 }14381439 return migrated;1440}144114421443144414451446export async function clearVaultEntries(1447 confirmation: "REPLACE_ALL"1448): Promise<void> {1449 if (confirmation !== "REPLACE_ALL") {1450 throw new Error("clearVaultEntries requires explicit confirmation");1451 }1452 if (!derivedKey) throw new Error("vault_locked");1453 for (const entry of entries) {1454 if (!entry.deletedAt) {1455 entry.deletedAt = Date.now();1456 entry.modifiedAt = Date.now();1457 }1458 }1459 await persistEntries();1460}14611462export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {1463 if (!derivedKey) throw new Error("vault_locked");14641465 const sanitized = sanitizeEntryDraft(entry);1466 const now = Date.now();1467 const nextEntry: VaultEntry = {1468 ...sanitized,1469 id: crypto.randomUUID(),1470 createdAt: now,1471 modifiedAt: now,1472 1473 1474 passwordChangedAt: sanitized.password ? now : undefined,1475 };14761477 1478 1479 1480 const rollback = entries;1481 entries = [...entries, nextEntry];1482 try {1483 await persistEntries();1484 } catch (err) {1485 entries = rollback;1486 throw err;1487 }14881489 1490 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {1491 checkBreach(nextEntry.password).then(async (count) => {1492 if (count > 0) {1493 const idx = entries.findIndex((e) => e.id === nextEntry.id);1494 if (idx !== -1) {1495 entries[idx] = { ...entries[idx], breach: count, modifiedAt: Date.now() };1496 await persistEntries();1497 }1498 }1499 });1500 }15011502 return nextEntry;1503}15041505export async function updateEntry(1506 id: string,1507 patch: Partial<VaultEntry>1508): Promise<void> {1509 if (!derivedKey) throw new Error("vault_locked");15101511 const idx = entries.findIndex((entry) => entry.id === id);1512 if (idx === -1 || entries[idx].deletedAt) {1513 throw new Error("entry_not_found");1514 }15151516 const current = entries[idx];1517 const sanitizedPatch = sanitizeEntryPatch(patch);1518 1519 1520 1521 const passwordChanged =1522 "password" in patch &&1523 typeof sanitizedPatch.password === "string" &&1524 sanitizedPatch.password !== current.password;1525 entries[idx] = {1526 ...current,1527 ...sanitizedPatch,1528 modifiedAt: Date.now(),1529 ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),1530 };15311532 1533 1534 1535 1536 if (1537 entries[idx].type === "login" &&1538 !entries[idx].password &&1539 !entries[idx].passkey1540 ) {1541 entries[idx] = current;1542 throw new Error("password or passkey required");1543 }15441545 1546 if (entries[idx].type === "card" && !entries[idx].cardNumber) {1547 entries[idx] = current;1548 throw new Error("card number is required for card entries");1549 }15501551 await persistEntries();15521553 1554 1555 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {1556 entries[idx] = { ...entries[idx], breach: undefined };1557 await persistEntries();1558 const password = entries[idx].password;1559 if (password) {1560 checkBreach(password).then(async (count) => {1561 const i = entries.findIndex((e) => e.id === id);1562 if (i !== -1 && count > 0) {1563 entries[i] = { ...entries[i], breach: count, modifiedAt: Date.now() };1564 await persistEntries();1565 }1566 });1567 }1568 }1569}15701571export async function deleteEntry(id: string): Promise<void> {1572 if (!derivedKey) throw new Error("vault_locked");15731574 const entry = entries.find((item) => item.id === id);1575 if (!entry || entry.deletedAt) {1576 throw new Error("entry_not_found");1577 }15781579 entry.deletedAt = Date.now();1580 entry.modifiedAt = Date.now();1581 await persistEntries();1582}15831584export function getDeletedEntries(): VaultEntry[] {1585 if (!derivedKey) return [];1586 return entries.filter((e) => !!e.deletedAt);1587}15881589export async function restoreEntry(id: string): Promise<void> {1590 if (!derivedKey) throw new Error("vault_locked");1591 const entry = entries.find((e) => e.id === id && e.deletedAt);1592 if (!entry) throw new Error("deleted entry not found");1593 delete entry.deletedAt;1594 entry.modifiedAt = Date.now();1595 await persistEntries();1596 buildDomainIndexSync(entries);1597}15981599export async function purgeEntry(id: string): Promise<void> {1600 if (!derivedKey) throw new Error("vault_locked");1601 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);1602 if (idx === -1) throw new Error("deleted entry not found");1603 entries.splice(idx, 1);1604 await persistEntries();1605}160616071608export async function purgeExpiredTrash(retentionMs: number): Promise<number> {1609 if (!derivedKey || retentionMs === 0) return 0;1610 const cutoff = Date.now() - retentionMs;1611 const before = entries.length;1612 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));1613 const purged = before - entries.length;1614 if (purged > 0) await persistEntries();1615 return purged;1616}16171618export function queryDomain(domain: string): VaultEntry[] {1619 return domainIndex.get(domain) ?? [];1620}16211622export function getEntryById(id: string): VaultEntry | undefined {1623 return entries.find((entry) => entry.id === id && !entry.deletedAt);1624}16251626async function persistSlotA(1627 reason: VaultSnapshot["reason"] = "local_save",1628): Promise<StoredVault> {1629 if (!derivedKey) throw new Error("vault_locked");16301631 const slotA = await loadSlot(SLOT_A);1632 if (!slotA) throw new Error("missing slot A");16331634 1635 1636 await takeVaultSnapshot(reason);16371638 const plaintext = encodePayload(entries, duressConfigured);1639 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");16401641 1642 const nextSlotA: StoredVault = {1643 ...slotA,1644 nonce,1645 ciphertext,1646 tag,1647 1648 };1649 await writeSlot(SLOT_A, nextSlotA);16501651 1652 1653 1654 if (!duressConfigured) {1655 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1656 }1657 await stampLocalVaultMutation();16581659 return nextSlotA;1660}16611662async function persistSlotB(): Promise<void> {1663 if (!derivedKey) throw new Error("vault_locked");16641665 const slotB = await loadSlot(SLOT_B);1666 if (!slotB) throw new Error("missing slot B");16671668 1669 1670 const plaintext = encodePayload(entries, false);1671 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");16721673 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });1674 await stampLocalVaultMutation();1675}16761677async function persistEntries(1678 reason: VaultSnapshot["reason"] = "local_save",1679): Promise<void> {1680 if (!derivedKey) throw new Error("vault_locked");16811682 if (vaultMode === "decoy") {1683 await persistSlotB();1684 } else {1685 await persistSlotA(reason);1686 }1687 buildDomainIndexSync(entries);1688}16891690function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {1691 if (!Array.isArray(values)) return undefined;1692 const seen = new Set<string>();1693 const cleaned: string[] = [];1694 for (const value of values) {1695 const trimmed = typeof value === "string" ? value.trim() : "";1696 if (!trimmed) continue;1697 const key = trimmed.toLowerCase();1698 if (seen.has(key)) continue;1699 seen.add(key);1700 cleaned.push(trimmed);1701 }1702 return cleaned.length > 0 ? cleaned : undefined;1703}17041705function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {1706 const title = entry.title.trim();1707 1708 1709 1710 let type: VaultEntry["type"] = entry.type ?? "login";1711 if (type === "passkey") type = "login";17121713 if (!title) throw new Error("title is required");1714 1715 1716 if (type === "login" && !entry.password?.trim() && !entry.passkey) {1717 throw new Error("password or passkey required");1718 }1719 if (entry.passkey) {1720 const p = entry.passkey;1721 if (1722 !p.rpId ||1723 !p.credentialId ||1724 !p.publicKeyCose ||1725 !p.userHandle ||1726 !p.userName ||1727 (p.algorithm !== -7 && p.algorithm !== -8)1728 ) {1729 throw new Error("invalid passkey credential");1730 }1731 }1732 if (type === "card") {1733 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {1734 throw new Error("cardholder name and card number are required");1735 }1736 }17371738 return {1739 type,1740 title,1741 username: type === "login" ? entry.username?.trim() ?? "" : undefined,1742 password: type === "login" ? entry.password : undefined,1743 url: type === "login" ? entry.url?.trim() ?? "" : undefined,1744 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,1745 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,1746 notes: entry.notes?.trim() || undefined,1747 breach: type === "login" ? entry.breach : undefined,1748 canary: type === "login" ? entry.canary : undefined,1749 tags: entry.tags,1750 passkey: entry.passkey,1751 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,1752 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,1753 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,1754 expYear: type === "card" ? entry.expYear?.trim() : undefined,1755 cvc: type === "card" ? entry.cvc?.trim() : undefined,1756 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,1757 network: type === "card" ? entry.network : undefined,1758 last4: type === "card" ? entry.last4?.trim() : undefined,1759 };1760}17611762function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {1763 const nextPatch: Partial<VaultEntry> = {};17641765 if ("type" in patch) {1766 nextPatch.type = patch.type;1767 }17681769 if ("title" in patch) {1770 const title = patch.title?.trim() ?? "";1771 if (!title) throw new Error("title is required");1772 nextPatch.title = title;1773 }17741775 if ("url" in patch) {1776 nextPatch.url = patch.url?.trim() ?? "";1777 }17781779 if ("additionalUrls" in patch) {1780 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];1781 const seen = new Set<string>();1782 const cleaned: string[] = [];1783 for (const value of incoming) {1784 const trimmed = typeof value === "string" ? value.trim() : "";1785 if (!trimmed) continue;1786 const key = trimmed.toLowerCase();1787 if (seen.has(key)) continue;1788 seen.add(key);1789 cleaned.push(trimmed);1790 }1791 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;1792 }17931794 if ("username" in patch) {1795 nextPatch.username = patch.username?.trim() ?? "";1796 }17971798 if ("email" in patch) {1799 nextPatch.email = patch.email?.trim() || undefined;1800 }18011802 if ("password" in patch) {1803 const password = patch.password ?? "";1804 1805 nextPatch.password = password || undefined;1806 }18071808 if ("totp" in patch) {1809 nextPatch.totp = patch.totp?.trim() || undefined;1810 }18111812 if ("notes" in patch) {1813 nextPatch.notes = patch.notes?.trim() || undefined;1814 }18151816 if ("breach" in patch) {1817 nextPatch.breach = patch.breach;1818 }18191820 if ("canary" in patch) {1821 nextPatch.canary = patch.canary;1822 }18231824 if ("passkey" in patch) {1825 1826 1827 1828 nextPatch.passkey = patch.passkey;1829 }18301831 if ("tags" in patch) {1832 nextPatch.tags = patch.tags;1833 }18341835 if ("cardholderName" in patch) {1836 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;1837 }1838 if ("cardNumber" in patch) {1839 const n = patch.cardNumber ?? "";1840 nextPatch.cardNumber = n || undefined;1841 }1842 if ("expMonth" in patch) {1843 nextPatch.expMonth = patch.expMonth?.trim() || undefined;1844 }1845 if ("expYear" in patch) {1846 nextPatch.expYear = patch.expYear?.trim() || undefined;1847 }1848 if ("cvc" in patch) {1849 nextPatch.cvc = patch.cvc?.trim() || undefined;1850 }1851 if ("cardNickname" in patch) {1852 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;1853 }1854 if ("network" in patch) {1855 nextPatch.network = patch.network;1856 }1857 if ("last4" in patch) {1858 nextPatch.last4 = patch.last4?.trim() || undefined;1859 }18601861 return nextPatch;1862}18631864function normalizeEntry(entry: VaultEntry | LegacyVaultEntry): VaultEntry {1865 const createdAt = "createdAt" in entry ? entry.createdAt : entry.created;1866 const modifiedAt =1867 "modifiedAt" in entry ? entry.modifiedAt : entry.updated;18681869 const base: VaultEntry = {1870 id: entry.id,1871 type: "type" in entry ? entry.type : "login",1872 title: entry.title,1873 username: entry.username,1874 password: entry.password,1875 url: entry.url,1876 totp: entry.totp,1877 notes: entry.notes,1878 breach: entry.breach,1879 canary: "canary" in entry ? entry.canary : undefined,1880 passkey: "passkey" in entry ? entry.passkey : undefined,1881 createdAt,1882 modifiedAt,1883 deletedAt: entry.deletedAt,1884 tags: "tags" in entry ? entry.tags : undefined,1885 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,1886 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,1887 expMonth: "expMonth" in entry ? entry.expMonth : undefined,1888 expYear: "expYear" in entry ? entry.expYear : undefined,1889 cvc: "cvc" in entry ? entry.cvc : undefined,1890 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,1891 network: "network" in entry ? entry.network : undefined,1892 last4: "last4" in entry ? entry.last4 : undefined,1893 };18941895 return migrateLegacyPasskey(base, entry);1896}189718981899190019011902190319041905function migrateLegacyPasskey(1906 base: VaultEntry,1907 raw: VaultEntry | LegacyVaultEntry1908): VaultEntry {1909 if (base.passkey) return base;1910 const flat = raw as Partial<VaultEntry>;1911 const isPasskeyType = base.type === "passkey";1912 const hasFlatPasskeyFields =1913 typeof flat.rpId === "string" &&1914 typeof flat.credentialId === "string" &&1915 typeof flat.publicKeyCose === "string" &&1916 typeof flat.userHandle === "string" &&1917 typeof flat.userName === "string";1918 if (!isPasskeyType && !hasFlatPasskeyFields) return base;1919 if (!hasFlatPasskeyFields) return base;19201921 const algorithm: -7 | -8 = flat.algorithm === -8 ? -8 : -7;1922 base.passkey = {1923 rpId: flat.rpId!,1924 rpName: flat.rpName ?? flat.rpId!,1925 credentialId: flat.credentialId!,1926 publicKeyCose: flat.publicKeyCose!,1927 privateKeyJwk: flat.privateKeyJwk,1928 userHandle: flat.userHandle!,1929 userName: flat.userName!,1930 userDisplayName: flat.userDisplayName ?? flat.userName!,1931 algorithm,1932 signCount: flat.signCount ?? 0,1933 aaguid: flat.aaguid ?? "",1934 backupEligible: flat.backupEligible === true,1935 backupState: flat.backupState === true,1936 lastUsedAt: flat.lastUsedAt,1937 browser: flat.browser,1938 transports: flat.transports,1939 createdAt: base.createdAt,1940 };1941 1942 1943 1944 if (isPasskeyType) base.type = "login";1945 if (!base.username) base.username = flat.userName;1946 if (!base.url) base.url = `https://${flat.rpId}`;1947 return base;1948}1949195019511952195319541955function entryDupeKey(e: VaultEntry): string {1956 if (e.type === "note") return `note|${e.title.toLowerCase()}`;1957 1958 1959 1960 1961 if (e.passkey) {1962 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;1963 }1964 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;1965}196619671968export function countDuplicates(): number {1969 const best = new Map<string, VaultEntry>();1970 let dupeCount = 0;1971 for (const e of entries.filter((e) => !e.deletedAt)) {1972 const key = entryDupeKey(e);1973 const existing = best.get(key);1974 if (!existing) {1975 best.set(key, e);1976 } else {1977 dupeCount++;1978 1979 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {1980 best.set(key, e);1981 }1982 }1983 }1984 return dupeCount;1985}198619871988198919901991export async function deduplicateEntries(): Promise<number> {1992 if (!derivedKey) throw new Error("vault_locked");1993 1994 1995 if (vaultMode === "decoy") {1996 throw new Error("deduplicate disabled in decoy mode");1997 }19981999 const best = new Map<string, VaultEntry>();2000 for (const e of entries.filter((en) => !en.deletedAt)) {2001 const key = entryDupeKey(e);2002 const existing = best.get(key);2003 if (!existing) {2004 best.set(key, e);2005 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2006 best.set(key, e);2007 }2008 }20092010 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));2011 const now = Date.now();2012 let removed = 0;2013 entries = entries.map((e) => {2014 if (!e.deletedAt && !keepIds.has(e.id)) {2015 removed++;2016 return { ...e, deletedAt: now };2017 }2018 return e;2019 });20202021 if (removed > 0) await persistEntries();2022 return removed;2023}202420252026202720282029203020312032const HISTORY_KEY = "peach_vault_history";2033const HISTORY_MAX = 10;20342035203620372038203920402041export async function takeVaultSnapshot(2042 reason: VaultSnapshot["reason"],2043): Promise<void> {2044 try {2045 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);2046 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];2047 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];20482049 2050 2051 2052 if (!slotA) return;20532054 const vault_a = JSON.stringify(slotA);2055 const vault_b = JSON.stringify(slotB ?? slotA);20562057 2058 2059 2060 let entryCount: number | null = null;2061 if (derivedKey && vaultMode === "real") {2062 try {2063 entryCount = entries.filter((e) => !e.deletedAt).length;2064 } catch {2065 entryCount = null;2066 }2067 }20682069 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2070 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)2071 ? existingRaw2072 : { snapshots: [] };20732074 2075 2076 2077 const latest = history.snapshots[0];2078 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {2079 return;2080 }20812082 const snapshot: VaultSnapshot = {2083 id: crypto.randomUUID(),2084 savedAt: Date.now(),2085 reason,2086 vault_a,2087 vault_b,2088 entryCount,2089 };20902091 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);2092 await chrome.storage.local.set({ [HISTORY_KEY]: history });2093 } catch (err) {2094 2095 2096 2097 2098 console.warn('[Peach] takeVaultSnapshot failed:', err);2099 if (String(err).includes('QUOTA') || String(err).includes('quota')) {2100 try {2101 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2102 } catch {2103 2104 }2105 }2106 }2107}210821092110export async function clearVaultHistory(): Promise<void> {2111 try {2112 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });2113 } catch {2114 2115 }2116}211721182119export async function getVaultHistory(): Promise<VaultSnapshot[]> {2120 const raw = await chrome.storage.local.get(HISTORY_KEY);2121 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];2122 if (!history || !Array.isArray(history.snapshots)) return [];2123 return history.snapshots;2124}21252126212721282129213021312132export async function restoreVaultSnapshot(2133 snapshotId: string,2134): Promise<{ ok: true } | { ok: false; error: string }> {2135 const history = await getVaultHistory();2136 const snapshot = history.find((s) => s.id === snapshotId);2137 if (!snapshot) return { ok: false, error: "snapshot_not_found" };21382139 2140 2141 2142 await takeVaultSnapshot("restore");21432144 let restoredA: StoredVault;2145 let restoredB: StoredVault;2146 try {2147 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;2148 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;2149 } catch {2150 return { ok: false, error: "snapshot_corrupt" };2151 }21522153 await writeSlot(SLOT_A, restoredA);2154 await writeSlot(SLOT_B, restoredB);2155 await stampLocalVaultMutation();21562157 2158 2159 2160 lock();21612162 return { ok: true };2163}21642165216621672168216921702171export async function changePassword(2172 currentPassword: string,2173 newPassword: string2174): Promise<boolean> {2175 if (!derivedKey || vaultMode !== "real") return false;21762177 2178 const valid = await verifyMainPassword(currentPassword);2179 if (!valid) return false;21802181 const slotA = await loadSlot(SLOT_A);2182 if (!slotA) return false;2183 if (!rootKey) return false;21842185 2186 const newSalt = generateSalt();2187 const newKey = await deriveKey(newPassword, newSalt);21882189 2190 const plaintext = encodePayload(entries, duressConfigured);2191 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");21922193 2194 2195 2196 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);21972198 const finalSlotA: StoredVault = {2199 ...slotA,2200 salt: hex(newSalt),2201 nonce,2202 ciphertext,2203 tag,2204 ...pwRoot,2205 };22062207 2208 2209 2210 await takeVaultSnapshot("local_save");22112212 2213 2214 2215 2216 await writeSlot(SLOT_A, finalSlotA);22172218 2219 2220 2221 zeroKey(derivedKey);2222 derivedKey = newKey;22232224 2225 2226 2227 if (!duressConfigured) {2228 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2229 }2230 await stampLocalVaultMutation();22312232 return true;2233}223422352236223722382239export async function getVaultBlobForSync(): Promise<{2240 blob: string;2241 timestamp: number;2242 license?: string;2243} | null> {2244 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, 'peach_last_modified_at', 'peach_license']);2245 const slotA = (raw as Record<string, unknown>)[SLOT_A];2246 if (!slotA) return null;2247 const slotB = (raw as Record<string, unknown>)[SLOT_B];2248 const blob = JSON.stringify({ a: slotA, b: slotB });2249 const rawTs = (raw as Record<string, unknown>)['peach_last_modified_at'];2250 const timestamp = typeof rawTs === 'string' ? parseInt(rawTs) : 0;2251 const license = (raw as Record<string, unknown>)['peach_license'];2252 return {2253 blob,2254 timestamp,2255 license: typeof license === 'string' ? license : undefined,2256 };2257}22582259export async function applyIncomingVault(2260 blobJson: string,2261 remoteTimestamp: number,2262 remoteLicense?: string,2263): Promise<boolean> {2264 const raw = await chrome.storage.local.get('peach_last_modified_at');2265 const rawTs = (raw as Record<string, unknown>)['peach_last_modified_at'];2266 const localTimestamp = typeof rawTs === 'string' ? parseInt(rawTs) : 0;22672268 if (remoteLicense) {2269 const licenseRaw = await chrome.storage.local.get('peach_license');2270 const existing = (licenseRaw as Record<string, unknown>)['peach_license'];2271 if (!existing) {2272 2273 2274 2275 const { validateLicense } = await import('./license');2276 const validation = await validateLicense(remoteLicense);2277 if (validation.valid) {2278 await chrome.storage.local.set({ peach_license: remoteLicense });2279 }2280 2281 }2282 }22832284 2285 2286 2287 2288 const SYNC_CLOCK_SKEW_BUDGET_MS = 5 * 60 * 1_000;2289 if (remoteTimestamp > Date.now() + SYNC_CLOCK_SKEW_BUDGET_MS) {2290 console.warn(2291 '[Peach] sync rejected: remote timestamp too far in future',2292 { remoteTimestamp, now: Date.now(), delta: remoteTimestamp - Date.now() },2293 );2294 return false;2295 }22962297 if (remoteTimestamp <= localTimestamp) return false;22982299 2300 2301 2302 2303 2304 let parsed: unknown;2305 try {2306 parsed = JSON.parse(blobJson);2307 } catch {2308 throw new Error('invalid_incoming_vault_blob');2309 }2310 if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {2311 throw new Error('invalid_incoming_vault_blob');2312 }2313 const { a, b } = parsed as { a?: unknown; b?: unknown };2314 if (!hasValidVaultSchema(a)) {2315 throw new Error('invalid_incoming_vault_blob');2316 }23172318 2319 2320 2321 try {2322 await takeVaultSnapshot('sync_received');2323 } catch (snapshotErr) {2324 console.warn('[Peach] vault snapshot failed during sync:', snapshotErr);2325 if (String(snapshotErr).includes('QUOTA') || String(snapshotErr).includes('quota')) {2326 try {2327 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2328 } catch {2329 2330 }2331 }2332 }23332334 await writeSlot(SLOT_A, a);2335 if (hasValidVaultSchema(b)) await writeSlot(SLOT_B, b);2336 await chrome.storage.local.set({2337 peach_last_sync_at: String(Date.now()),2338 peach_last_modified_at: String(remoteTimestamp),2339 });23402341 return true;2342}23432344234523462347234823492350235123522353235423552356export async function getSyncIdentity(): Promise<{2357 privateKeyBytes: Uint8Array;2358 publicKeyBytes: Uint8Array;2359 publicKeyHex: string;2360}> {2361 if (!derivedKey) throw new Error('vault_locked');2362 const raw = await chrome.storage.local.get('peach_sync_identity');2363 const stored = (raw as Record<string, unknown>)['peach_sync_identity'];23642365 if (typeof stored === 'string') {2366 const parsed = JSON.parse(stored) as {2367 pub: string;2368 priv?: string;2369 enc_priv?: string;2370 enc_nonce?: string;2371 enc_tag?: string;2372 };2373 const publicKeyBytes = base64DecodeBytes(parsed.pub);23742375 if (parsed.enc_priv && parsed.enc_nonce && parsed.enc_tag) {2376 const privHex = await decrypt(2377 derivedKey,2378 parsed.enc_nonce,2379 parsed.enc_priv,2380 parsed.enc_tag,2381 "sync_identity_v1",2382 );2383 const privateKeyBytes = unhex(privHex);2384 return { privateKeyBytes, publicKeyBytes, publicKeyHex: bytesToHex(publicKeyBytes) };2385 }23862387 if (parsed.priv) {2388 2389 const privateKeyBytes = base64DecodeBytes(parsed.priv);2390 await persistEncryptedSyncIdentity(privateKeyBytes, publicKeyBytes);2391 return { privateKeyBytes, publicKeyBytes, publicKeyHex: bytesToHex(publicKeyBytes) };2392 }2393 }23942395 2396 const { generateKeyPair } = await import('@stablelib/x25519');2397 const keypair = generateKeyPair();2398 await persistEncryptedSyncIdentity(keypair.secretKey, keypair.publicKey);2399 return {2400 privateKeyBytes: keypair.secretKey,2401 publicKeyBytes: keypair.publicKey,2402 publicKeyHex: bytesToHex(keypair.publicKey),2403 };2404}24052406async function persistEncryptedSyncIdentity(2407 privateKeyBytes: Uint8Array,2408 publicKeyBytes: Uint8Array,2409): Promise<void> {2410 if (!derivedKey) throw new Error('vault_locked');2411 const enc = await encrypt(derivedKey, hex(privateKeyBytes), "sync_identity_v1");2412 await chrome.storage.local.set({2413 peach_sync_identity: JSON.stringify({2414 pub: base64EncodeBytes(publicKeyBytes),2415 enc_priv: enc.ciphertext,2416 enc_nonce: enc.nonce,2417 enc_tag: enc.tag,2418 }),2419 });2420}24212422242324242425export async function reloadEntriesAfterSync(): Promise<boolean> {2426 if (!derivedKey) return false;2427 const slotA = await loadSlot(SLOT_A);2428 if (!slotA) return false;2429 try {2430 const payload = await tryDecryptSlot(slotA, derivedKey, "slot_a_v1");2431 if (!payload) return false;2432 entries = payload.entries;2433 buildDomainIndexSync(entries);2434 return true;2435 } catch {2436 return false;2437 }2438}24392440function bytesToHex(bytes: Uint8Array): string {2441 return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');2442}24432444function base64EncodeBytes(bytes: Uint8Array): string {2445 let binary = '';2446 for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);2447 return btoa(binary);2448}24492450function base64DecodeBytes(b64: string): Uint8Array {2451 const binary = atob(b64);2452 const bytes = new Uint8Array(binary.length);2453 for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);2454 return bytes;2455}