123456789101112import { deriveKey, generateSalt } from "../crypto/argon2";13import { extractDomain } from "../shared/domain";14import { clampSyncTimestamp } from "../shared/sync-limits";15import { computeVaultId } from "../shared/sync-identity";161718import { triggerSyncIfReachable } from "./sync-trigger";19import { clearPairedDevices } from "./paired-devices";20import {21 rootKeyToWords,22 wordsToRootKeyMaterial,23 deriveRecoveryKey,24} from "../shared/recovery-words";25import type {26 AccentColorName,27 Canary,28 ManualSummonRecord,29 PasskeyData,30 SavedIdentity,31 StoredVault,32 VaultEntry,33 VaultHistory,34 VaultSnapshot,35} from "../shared/types";36import { decrypt, encrypt, zeroKey } from "./aes";37import { clearAutoLock, touchAutoLock } from "./alarms";38import {39 clearRecoveryConfirmPending,40 setRecoveryConfirmPending,41} from "./recovery-pending";42import {43 clearPairSecrets,44 dropSessionCache,45 reconcilePairSecrets,46 setSessionAllowed,47} from "./pair-secrets";4849type EntryDraft = Omit<50 VaultEntry,51 "id" | "createdAt" | "modifiedAt" | "deletedAt"52>;535455565758const SLOT_A = "peach_vault_a";59const SLOT_B = "peach_vault_b";60616263const PAD_BLOCK = 1024;6465666768const PASSWORD_HISTORY_MAX = 50;6970717273747576777879const DURESS_SLOT_SIZE = 262_144;8081let derivedKey: Uint8Array | null = null;8283848586let rootKey: Uint8Array | null = null;87let entries: VaultEntry[] = [];88899091let tombstones = new Map<string, number>();92let domainIndex: Map<string, VaultEntry[]> = new Map();93let vaultMode: "real" | "decoy" = "real";94959697let duressConfigured = false;9899100101102103104105106107108let lastUnlockFailureReason: string | null = null;109110export function consumeLastUnlockFailureReason(): string | null {111 const r = lastUnlockFailureReason;112 lastUnlockFailureReason = null;113 return r;114}115116117118119120export type TextSize = "small" | "medium" | "large";121122export interface VaultSettings {123 accentColor?: AccentColorName;124 theme?: "dark" | "light";125 autoLockMinutes?: number;126 clipboardClearSeconds?: number;127 lockOnClose?: boolean;128 breachMonitoring?: boolean;129 passkeyProviderEnabled?: boolean;130 trashRetentionDays?: number;131 manualSummonDomains?: Record<string, ManualSummonRecord>;132 manualSummonSalt?: string;133 declinedCardFingerprints?: string[];134135 136 metadataVersion?: 1;137 138139140141142 textSize?: TextSize;143 144145146147148 reduceMotion?: boolean;149 150 pinEnabled?: boolean;151 152 pinPreferred?: boolean;153 154155156157158159160 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;161}162163interface StoredPinWrap {164 version: 1;165 salt: string;166 nonce: string;167 ciphertext: string;168 tag: string;169}170171const PIN_WRAP_STORAGE = "peach_pin_wrap";172const PIN_ATTEMPTS_STORAGE = "peach_pin_attempts";173const PIN_MIN_LENGTH = 4;174const PIN_MAX_LENGTH = 12;175const PIN_MAX_ATTEMPTS = 3;176177let settings: VaultSettings = {};178const CARD_DECLINE_MAX = 256;179180const CARD_DECLINE_DIGEST_HEX = 32;181182async function metadataDigest(value: string, key: string): Promise<string> {183 const bytes = new TextEncoder().encode(`${key}:${value}`);184 const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));185 return Array.from(digest.slice(0, 16)).map((byte) => byte.toString(16).padStart(2, "0")).join("");186}187188let inMemoryPinWrap: StoredPinWrap | null = null;189let inMemoryPinFailedAttempts = 0;190let sessionPinStorageFailed = false;191192export function getSettings(): VaultSettings {193 return settings;194}195196export async function ensureSettingsLoaded(): Promise<VaultSettings> {197 await loadSettings();198 return settings;199}200201export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {202 await loadSettings();203 204 205 206 207 const safe = Object.fromEntries(208 Object.entries(patch).filter(209 ([k]) => !["__proto__", "constructor", "prototype"].includes(k),210 ),211 ) as Partial<VaultSettings>;212 settings = { ...settings, ...safe };213 await persistSettings();214}215216function isValidPin(pin: string): boolean {217 return new RegExp(`^\\d{${PIN_MIN_LENGTH},${PIN_MAX_LENGTH}}$`).test(pin);218}219220221export async function setupPinUnlock(currentPassword: string, pin: string): Promise<boolean> {222 if (!derivedKey || vaultMode !== "real" || !isValidPin(pin)) return false;223 if (!(await verifyMainPassword(currentPassword))) return false;224225 const salt = generateSalt();226 const pinKey = await deriveKey(pin, salt);227 try {228 const wrapped = await encrypt(pinKey, hex(derivedKey), "pin_wrap_v1");229 await savePinWrap({ version: 1, salt: hex(salt), ...wrapped });230 await updateSettings({ pinEnabled: true });231 return true;232 } finally {233 zeroKey(pinKey);234 }235}236237async function clearPinUnlockStorage(): Promise<void> {238 inMemoryPinWrap = null;239 inMemoryPinFailedAttempts = 0;240 sessionPinStorageFailed = false;241 if (chrome.storage.session) {242 await chrome.storage.session.remove([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);243 }244 await updateSettings({ pinEnabled: false });245}246247248export async function disablePinUnlock(currentPassword: string): Promise<boolean> {249 if (!derivedKey || vaultMode !== "real") return false;250 if (!(await verifyMainPassword(currentPassword))) return false;251 await clearPinUnlockStorage();252 return true;253}254255256export async function unlockWithPin(pin: string): Promise<boolean> {257 if (!isValidPin(pin)) return false;258 await loadSettings();259 if (!settings.pinEnabled) return false;260261 const { stored, failedAttempts } = await loadPinState();262 if (failedAttempts >= PIN_MAX_ATTEMPTS) return false;263 if (264 !stored ||265 stored.version !== 1 ||266 !stored.salt ||267 !stored.nonce ||268 !stored.ciphertext ||269 !stored.tag270 ) return false;271272 const pinKey = await deriveKey(pin, unhex(stored.salt));273 let unwrappedKey: Uint8Array | null = null;274 try {275 const masterKeyHex = await decrypt(276 pinKey,277 stored.nonce,278 stored.ciphertext,279 stored.tag,280 "pin_wrap_v1",281 );282 if (!/^[0-9a-f]{64}$/.test(masterKeyHex)) return false;283 unwrappedKey = unhex(masterKeyHex);284 const result = await unlockWithKey(masterKeyHex);285 if (!result.ok) return false;286 await saveSessionKey(unwrappedKey, "real");287 await resetPinFailedAttempts();288 return true;289 } catch {290 await recordPinFailure(failedAttempts + 1);291 return false;292 } finally {293 zeroKey(pinKey);294 if (unwrappedKey) zeroKey(unwrappedKey);295 }296}297298async function savePinWrap(stored: StoredPinWrap): Promise<void> {299 inMemoryPinWrap = stored;300 inMemoryPinFailedAttempts = 0;301 sessionPinStorageFailed = !chrome.storage.session;302 if (!chrome.storage.session) return;303 try {304 await chrome.storage.session.set({ [PIN_WRAP_STORAGE]: stored, [PIN_ATTEMPTS_STORAGE]: 0 });305 sessionPinStorageFailed = false;306 } catch {307 308 sessionPinStorageFailed = true;309 }310}311312async function loadPinState(): Promise<{313 stored: StoredPinWrap | undefined;314 failedAttempts: number;315}> {316 if (!chrome.storage.session) {317 return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };318 }319 try {320 const result = await chrome.storage.session.get([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);321 return {322 stored: (result[PIN_WRAP_STORAGE] as StoredPinWrap | undefined) ??323 (sessionPinStorageFailed ? inMemoryPinWrap ?? undefined : undefined),324 failedAttempts: typeof result[PIN_ATTEMPTS_STORAGE] === "number"325 ? result[PIN_ATTEMPTS_STORAGE]326 : 0,327 };328 } catch {329 return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };330 }331}332333async function recordPinFailure(failedAttempts: number): Promise<void> {334 inMemoryPinFailedAttempts = Math.min(failedAttempts, PIN_MAX_ATTEMPTS);335 if (!chrome.storage.session) return;336 try {337 await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: inMemoryPinFailedAttempts });338 } catch { }339}340341async function resetPinFailedAttempts(): Promise<void> {342 inMemoryPinFailedAttempts = 0;343 if (!chrome.storage.session) return;344 try {345 await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: 0 });346 } catch { }347}348349export async function getPinUnlockStatus(): Promise<{350 configured: boolean;351 available: boolean;352 attemptsRemaining: number;353}> {354 await loadSettings();355 if (!settings.pinEnabled) {356 return { configured: false, available: false, attemptsRemaining: 0 };357 }358 const { stored, failedAttempts } = await loadPinState();359 const available = Boolean(stored) && failedAttempts < PIN_MAX_ATTEMPTS;360 return {361 configured: true,362 available,363 attemptsRemaining: stored ? Math.max(0, PIN_MAX_ATTEMPTS - failedAttempts) : 0,364 };365}366367async function persistSettings(): Promise<void> {368 try {369 await chrome.storage.local.set({370 peach_settings: {371 ...settings,372 manualSummonDomains: undefined,373 manualSummonSalt: undefined,374 cardFingerprintSalt: undefined,375 declinedCardFingerprints: settings.declinedCardFingerprints ?? [],376 metadataVersion: 1,377 },378 });379 } catch (err) {380 throw mapStorageError(err);381 }382}383384function mapStorageError(err: unknown): Error {385 const message = err instanceof Error ? err.message : String(err);386 if (/QUOTA_BYTES|quota/i.test(message)) {387 return new Error("STORAGE_QUOTA_EXCEEDED");388 }389 return err instanceof Error ? err : new Error(message);390}391392async function loadSettings(): Promise<void> {393 const result = await chrome.storage.local.get("peach_settings");394 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};395}396397398399function fireSyncTrigger(reason: "vault_change"): void {400 void Promise.resolve()401 .then(() => triggerSyncIfReachable({ reason }))402 .catch(() => {403 404 });405}406407let lockedSyncRefreshHook: (() => Promise<void>) | null = null;408let lockedSyncUnlockHook: (() => Promise<void>) | null = null;409410export function setLockedSyncHooks(hooks: {411 refresh: () => Promise<void>;412 afterUnlock: () => Promise<void>;413}): void {414 lockedSyncRefreshHook = hooks.refresh;415 lockedSyncUnlockHook = hooks.afterUnlock;416}417418async function refreshLockedSyncState(): Promise<void> {419 if (vaultMode !== "real" || !rootKey || !lockedSyncRefreshHook) return;420 await lockedSyncRefreshHook().catch((error) => {421 console.warn("[Peach sync] locked spool refresh failed:", error);422 });423}424425426427428429export async function checkBreach(password: string): Promise<number> {430 431 432 const encoder = new TextEncoder();433 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));434 const hashHex = Array.from(new Uint8Array(hashBuffer))435 .map((b) => b.toString(16).padStart(2, "0"))436 .join("")437 .toUpperCase();438439 const prefix = hashHex.slice(0, 5);440 const suffix = hashHex.slice(5);441442 try {443 444 445 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {446 headers: { "Add-Padding": "true" },447 });448 if (!res.ok) return 0;449 const text = await res.text();450 const match = text.split("\r\n").find((l) => l.startsWith(suffix));451 return match ? parseInt(match.split(":")[1], 10) : 0;452 } catch {453 return 0; 454 }455}456457export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {458 459 const key = await cardDeclineKey();460 if (!key) return;461 const fingerprint = await cardFingerprint(last4, expMonth, expYear);462 await loadSettings();463 settings = {464 ...settings,465 declinedCardFingerprints: [466 467 468 ...(settings.declinedCardFingerprints ?? []).filter(469 (stored) => stored.length === CARD_DECLINE_DIGEST_HEX,470 ),471 await metadataDigest(fingerprint, key),472 ].slice(-CARD_DECLINE_MAX),473 };474 await persistSettings();475}476477export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {478 await loadSettings();479 const key = await cardDeclineKey();480 if (!key) return false;481 const fingerprint = await cardFingerprint(last4, expMonth, expYear);482 const candidate = await metadataDigest(fingerprint, key);483 return (settings.declinedCardFingerprints ?? []).some(484 (stored) => stored.length === CARD_DECLINE_DIGEST_HEX && stored === candidate,485 );486}487488489490491492493494495496497498499async function cardDeclineKey(): Promise<string | null> {500 const root = getRootKeyHex();501 if (!root) return null;502 const digest = new Uint8Array(503 await crypto.subtle.digest(504 "SHA-256",505 new TextEncoder().encode(`peach-card-decline-v1:${root}`),506 ),507 );508 return hex(digest);509}510511async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {512 const encoder = new TextEncoder();513 const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));514 return Array.from(new Uint8Array(buf).slice(0, 8))515 .map((b) => b.toString(16).padStart(2, "0"))516 .join("");517}518519function hex(buf: Uint8Array): string {520 return Array.from(buf)521 .map((b) => b.toString(16).padStart(2, "0"))522 .join("");523}524525function unhex(s: string): Uint8Array {526 const out = new Uint8Array(s.length / 2);527 for (let i = 0; i < s.length; i += 2) {528 out[i / 2] = parseInt(s.slice(i, i + 2), 16);529 }530 return out;531}532533534535536537538539540541542543544545546547548549550551552async function generateAndWrapRootKey(): Promise<{553 rawRootKey: string; 554 recoverySalt: string;555 wrappedRootKey: string;556 wrappedNonce: string;557 wrappedTag: string;558}> {559 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));560561 562 const words = await rootKeyToWords(rootKeyBytes);563 const wordMaterial = await wordsToRootKeyMaterial(words);564 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));565 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);566567 568 569 const nonce = crypto.getRandomValues(new Uint8Array(12));570 const encoded = new TextEncoder().encode(hex(rootKeyBytes));571 const raw = await crypto.subtle.encrypt(572 {573 name: "AES-GCM",574 iv: nonce,575 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,576 tagLength: 128,577 },578 recoveryKey,579 encoded580 );581 582 const rawU8 = new Uint8Array(raw);583 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));584 const tag = hex(rawU8.slice(rawU8.length - 16));585586 return {587 rawRootKey: hex(rootKeyBytes),588 recoverySalt: hex(recoverySalt),589 wrappedRootKey: ciphertext,590 wrappedNonce: hex(nonce),591 wrappedTag: tag,592 };593}594595async function wrapExistingRootKeyForRecovery(rootKeyBytes: Uint8Array): Promise<{596 recoverySalt: string;597 wrappedRootKey: string;598 wrappedNonce: string;599 wrappedTag: string;600}> {601 if (rootKeyBytes.length !== 32) {602 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");603 }604605 const words = await rootKeyToWords(rootKeyBytes);606 const wordMaterial = await wordsToRootKeyMaterial(words);607 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));608 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);609 const nonce = crypto.getRandomValues(new Uint8Array(12));610 const encoded = new TextEncoder().encode(hex(rootKeyBytes));611 const raw = await crypto.subtle.encrypt(612 {613 name: "AES-GCM",614 iv: nonce,615 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,616 tagLength: 128,617 },618 recoveryKey,619 encoded,620 );621 const rawU8 = new Uint8Array(raw);622 return {623 recoverySalt: hex(recoverySalt),624 wrappedRootKey: hex(rawU8.slice(0, rawU8.length - 16)),625 wrappedNonce: hex(nonce),626 wrappedTag: hex(rawU8.slice(rawU8.length - 16)),627 };628}629630631632633634635export async function unwrapRootKeyForRecovery(636 wrappedRootKey: string,637 wrappedNonce: string,638 wrappedTag: string,639 recoverySaltHex: string,640 words: string[]641): Promise<void> {642 const wordMaterial = await wordsToRootKeyMaterial(words);643 const recoverySalt = unhex(recoverySaltHex);644 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);645646 647 648 649 650 651 const nonceRaw = unhex(wrappedNonce);652 const ciphertextRaw = unhex(wrappedRootKey);653 const tagRaw = unhex(wrappedTag);654 655 656 const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;657 const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);658 combined.set(ciphertextRaw, 0);659 combined.set(tagRaw, ciphertextRaw.length);660 const raw = await crypto.subtle.decrypt(661 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },662 recoveryKey,663 combined664 );665 const decryptedHex = new TextDecoder().decode(raw);666 rootKey = unhex(decryptedHex);667}668669670671672673674675676677678export async function rewrapRootKeyForPassword(679 newDerivedKey: Uint8Array,680 _newSalt: Uint8Array681): Promise<void> {682 if (!rootKey) throw new Error("root key not set");683 const slotA = await loadSlot(SLOT_A);684 if (!slotA) throw new Error("missing slot A");685 const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);686 await writeSlot(SLOT_A, { ...slotA, ...pwRoot });687}688689690691692693694async function reencryptVaultForNewKey(695 newKey: Uint8Array,696 slotA: StoredVault697): Promise<void> {698 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);699 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");700 await writeSlot(SLOT_A, {701 ...slotA,702 nonce,703 ciphertext,704 tag,705 });706}707708709710711712713export async function changePasswordWithNewKey(714 newSalt: Uint8Array,715 newKey: Uint8Array716): Promise<void> {717 const slotA = await loadSlot(SLOT_A);718 if (!slotA) throw new Error("missing slot A");719 720 721 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });722 if (derivedKey) zeroKey(derivedKey);723 derivedKey = newKey;724 725 726 727 void saveSessionKey(newKey, "real");728 729 730 731 732 if (rootKey) {733 const slotAfter = await loadSlot(SLOT_A);734 if (slotAfter) {735 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);736 await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });737 }738 }739 if (!duressConfigured) {740 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));741 }742 await clearPinUnlockStorage();743 await stampLocalVaultMutation();744}745746747748749750751export { deriveKey, generateSalt };752753export async function loadSlotA(): Promise<StoredVault | null> {754 return loadSlot(SLOT_A);755}756757758759760761762763764765766async function deriveRootKeyFromDerivedKey(767 derivedKeyBytes: Uint8Array,768 slotA: StoredVault,769): Promise<Uint8Array | null> {770 if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {771 return null;772 }773 try {774 const rootHex = await decrypt(775 derivedKeyBytes,776 slotA.pw_root_nonce,777 slotA.pw_root_ciphertext,778 slotA.pw_root_tag,779 "pw_root_v1",780 );781 return unhex(rootHex);782 } catch {783 return null;784 }785}786787788789async function wrapRootKeyForDerivedKey(790 derivedKeyBytes: Uint8Array,791 rootKeyBytes: Uint8Array,792): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {793 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");794 return {795 pw_root_ciphertext: ciphertext,796 pw_root_nonce: nonce,797 pw_root_tag: tag,798 };799}800801802export async function getRecoveryWords(): Promise<string[]> {803 if (!rootKey) {804 throw new Error("recovery not set up");805 }806 return rootKeyToWords(rootKey);807}808809async function loadSlot(key: string): Promise<StoredVault | null> {810 const result = await chrome.storage.local.get(key);811 return (result as Record<string, StoredVault | undefined>)[key] ?? null;812}813814async function writeSlot(key: string, vault: StoredVault): Promise<void> {815 try {816 await chrome.storage.local.set({ [key]: vault });817 } catch (err) {818 throw mapStorageError(err);819 }820}821822823824825826827828829830831export async function rollbackAdoptedVault(): Promise<void> {832 await lock();833 try {834 await chrome.storage.local.remove([835 SLOT_A,836 SLOT_B,837 "peach_last_modified_at",838 ]);839 } catch (err) {840 throw mapStorageError(err);841 }842}843844async function commitCreatedVaultSlots(slotA: StoredVault, slotB: StoredVault): Promise<void> {845 const raw = await chrome.storage.local.get("peach_last_modified_at");846 const previous = Number(847 (raw as Record<string, unknown>).peach_last_modified_at,848 );849 const next = Math.max(850 Date.now(),851 Number.isFinite(previous) ? previous + 1 : 0,852 );853 try {854 await chrome.storage.local.set({855 [SLOT_A]: slotA,856 [SLOT_B]: slotB,857 peach_last_modified_at: String(next),858 });859 } catch (err) {860 throw mapStorageError(err);861 }862}863864865866867868869870871let lastEntryTimestamp = 0;872function monotonicEntryTimestamp(currentModifiedAt?: number): number {873 const base = Math.max(874 Date.now(),875 (currentModifiedAt ?? 0) + 1,876 lastEntryTimestamp + 1,877 );878 lastEntryTimestamp = base;879 return base;880}881882async function stampLocalVaultMutation(): Promise<void> {883 const raw = await chrome.storage.local.get("peach_last_modified_at");884 const previous = Number(885 (raw as Record<string, unknown>).peach_last_modified_at,886 );887 const next = Math.max(888 Date.now(),889 Number.isFinite(previous) ? previous + 1 : 0,890 );891 await chrome.storage.local.set({892 peach_last_modified_at: String(next),893 });894}895896897898899900901902903904905906function padPlaintext(json: string, minBytes = 0): string {907 const encoded = new TextEncoder().encode(json);908 const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);909 910 const target = Math.max(bucket, minBytes);911 const padLen = target - encoded.length;912 return json + " ".repeat(padLen);913}914915916917918919920921922923924925const CSPRNG_CHUNK = 65_536;926927function fillRandom(buf: Uint8Array): Uint8Array {928 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {929 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);930 const chunk = new Uint8Array(end - offset);931 crypto.getRandomValues(chunk);932 buf.set(chunk, offset);933 }934 return buf;935}936937function generateNoiseBlob(ciphertextBytes: number): StoredVault {938 const salt = crypto.getRandomValues(new Uint8Array(16));939 const nonce = crypto.getRandomValues(new Uint8Array(12));940 const cipher = fillRandom(new Uint8Array(ciphertextBytes));941 const tag = crypto.getRandomValues(new Uint8Array(16));942 return {943 version: 1,944 salt: hex(salt),945 nonce: hex(nonce),946 ciphertext: hex(cipher),947 tag: hex(tag),948 };949}950951952953954955type VaultPayload = {956 entries: VaultEntry[];957 tombstones?: Record<string, number>;958 duressConfigured?: boolean;959};960961type DecryptedPayload = {962 entries: VaultEntry[];963 tombstones: Map<string, number>;964 duressConfigured: boolean;965};966967function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {968 const envelope: VaultPayload = {969 entries,970 tombstones: Object.fromEntries(tombstones),971 duressConfigured: duress,972 };973 return padPlaintext(JSON.stringify(envelope), minBytes);974}975976function decodeTombstones(raw: unknown, allEntries: VaultEntry[]): Map<string, number> {977 const result = new Map<string, number>();978 if (raw && typeof raw === "object" && !Array.isArray(raw)) {979 for (const [id, timestamp] of Object.entries(raw as Record<string, unknown>)) {980 if (typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0) {981 result.set(id, timestamp);982 }983 }984 }985 986 987 for (const entry of allEntries) {988 if (typeof entry.deletedAt !== "number" || !Number.isFinite(entry.deletedAt) || entry.deletedAt <= 0) {989 continue;990 }991 if ((result.get(entry.id) ?? 0) < entry.deletedAt) {992 result.set(entry.id, entry.deletedAt);993 }994 }995 return result;996}997998function reconcileTombstones(): void {999 const byId = new Map(entries.map((entry) => [entry.id, entry]));1000 for (const [id] of tombstones) {1001 if (byId.has(id) && !byId.get(id)?.deletedAt) tombstones.delete(id);1002 }1003 for (const entry of entries) {1004 if (typeof entry.deletedAt === "number" && Number.isFinite(entry.deletedAt) && entry.deletedAt > 0) {1005 if ((tombstones.get(entry.id) ?? 0) < entry.deletedAt) {1006 tombstones.set(entry.id, entry.deletedAt);1007 }1008 }1009 }1010}10111012function decodePayload(plain: string): DecryptedPayload {1013 const parsed = JSON.parse(plain) as VaultPayload;1014 if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.entries)) {1015 throw new Error("invalid vault payload");1016 }1017 const normalized = parsed.entries.map(normalizeEntry);1018 return {1019 entries: normalized,1020 tombstones: decodeTombstones(parsed.tombstones, normalized),1021 duressConfigured: parsed.duressConfigured === true,1022 };1023}1024102510261027102810291030103110321033103410351036103710381039104010411042104310441045function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {1046 let merged = false;1047 const now = Date.now();10481049 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);1050 const byRpId = new Map<string, VaultEntry[]>();1051 for (const e of candidates) {1052 const rpId = e.passkey!.rpId;1053 const list = byRpId.get(rpId);1054 if (list) list.push(e);1055 else byRpId.set(rpId, [e]);1056 }10571058 for (const [rpId, group] of byRpId) {1059 if (group.length === 0) continue;1060 group.sort((a, b) => {1061 const aT =1062 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;1063 const bT =1064 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;1065 return bT - aT;1066 });1067 const winner = group[0]!;10681069 1070 1071 1072 const loginTarget = entries.find((e) => {1073 if (e.deletedAt || e.passkey || e.type !== "login") return false;1074 if (e.url && extractDomain(e.url) === rpId) return true;1075 for (const extra of e.additionalUrls ?? []) {1076 if (extractDomain(extra) === rpId) return true;1077 }1078 return false;1079 });10801081 if (loginTarget) {1082 loginTarget.passkey = winner.passkey;1083 1084 1085 1086 if (!loginTarget.totp && winner.totp) {1087 loginTarget.totp = winner.totp;1088 loginTarget.totpAlgorithm = winner.totpAlgorithm;1089 loginTarget.totpDigits = winner.totpDigits;1090 loginTarget.totpPeriod = winner.totpPeriod;1091 }1092 loginTarget.modifiedAt = now;1093 for (const dup of group) {1094 if (!dup.deletedAt) {1095 dup.deletedAt = now;1096 dup.modifiedAt = now;1097 }1098 }1099 merged = true;1100 } else if (group.length > 1) {1101 for (let i = 1; i < group.length; i += 1) {1102 const dup = group[i]!;1103 if (!dup.deletedAt) {1104 dup.deletedAt = now;1105 dup.modifiedAt = now;1106 }1107 }1108 merged = true;1109 }1110 }11111112 return { merged };1113}11141115111611171118111911201121function hasValidVaultSchema(v: unknown): v is StoredVault {1122 if (typeof v !== "object" || v === null) return false;1123 const r = v as Record<string, unknown>;1124 return (1125 r.version === 1 &&1126 typeof r.salt === "string" &&1127 typeof r.nonce === "string" &&1128 typeof r.ciphertext === "string" &&1129 typeof r.tag === "string"1130 );1131}11321133export type TryDecryptResult =1134 | { status: "ok"; payload: DecryptedPayload }1135 | { status: "wrong_key" }1136 | { status: "corrupted"; reason: string };1137113811391140114111421143export async function tryDecryptSlot(1144 vault: StoredVault,1145 key: Uint8Array,1146 aadTag?: string,1147): Promise<DecryptedPayload | null> {1148 const res = await tryDecryptSlotResult(vault, key, aadTag);1149 return res.status === "ok" ? res.payload : null;1150}11511152export async function tryDecryptSlotResult(1153 vault: StoredVault,1154 key: Uint8Array,1155 aadTag?: string,1156): Promise<TryDecryptResult> {1157 if (!hasValidVaultSchema(vault)) {1158 return { status: "corrupted", reason: "invalid_slot_schema" };1159 }1160 let plain: string;1161 try {1162 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);1163 } catch {1164 return { status: "wrong_key" };1165 }1166 try {1167 const parsed = JSON.parse(plain) as unknown;1168 if (typeof parsed !== "object" || parsed === null) {1169 return { status: "corrupted", reason: "invalid_payload_shape" };1170 }1171 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {1172 return { status: "corrupted", reason: "invalid_payload_shape" };1173 }1174 return { status: "ok", payload: decodePayload(plain) };1175 } catch {1176 return { status: "corrupted", reason: "invalid_payload_json" };1177 }1178}117911801181118211831184function buildDomainIndexSync(all: VaultEntry[]): void {1185 domainIndex = new Map();11861187 for (const entry of all) {1188 if (entry.deletedAt) continue;1189 if (entry.type === "note" || entry.type === "card") continue;11901191 1192 1193 1194 1195 const domains = new Set<string>();1196 const primary = extractDomain(entry.url ?? "");1197 if (primary) domains.add(primary);1198 for (const extra of entry.additionalUrls ?? []) {1199 const d = extractDomain(extra);1200 if (d) domains.add(d);1201 }1202 if (domains.size === 0) continue;12031204 for (const domain of domains) {1205 const bucket = domainIndex.get(domain) ?? [];1206 bucket.push(entry);1207 domainIndex.set(domain, bucket);1208 }1209 }1210}12111212export async function createVault(password: string): Promise<void> {1213 const salt = generateSalt();1214 derivedKey = await deriveKey(password, salt);1215 entries = [];1216 tombstones = new Map();1217 vaultMode = "real";1218 duressConfigured = false;1219 buildDomainIndexSync(entries);12201221 1222 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =1223 await generateAndWrapRootKey();1224 rootKey = unhex(rawRootKey);12251226 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);1227 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");12281229 1230 1231 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);12321233 const slotA: StoredVault = {1234 version: 1,1235 salt: hex(salt),1236 nonce,1237 ciphertext,1238 tag,1239 recovery_salt: recoverySalt,1240 recovery_wrapped_root_key: wrappedRootKey,1241 recovery_nonce: wrappedNonce,1242 recovery_tag: wrappedTag,1243 ...pwRoot,1244 };1245 await writeSlot(SLOT_A, slotA);12461247 1248 1249 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1250 await stampLocalVaultMutation();12511252 touchAutoLock();1253}1254125512561257125812591260export async function createVaultFromRootKey(1261 password: string,1262 rootKeyHex: string,1263): Promise<{ vaultId: string }> {1264 if (typeof password !== "string" || password.length === 0) {1265 throw new Error("ADOPT_INVALID_PASSWORD");1266 }1267 if (typeof rootKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(rootKeyHex)) {1268 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars");1269 }12701271 const rootKeyBytes = unhex(rootKeyHex);1272 if (rootKeyBytes.length !== 32) {1273 rootKeyBytes.fill(0);1274 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");1275 }12761277 let nextDerivedKey: Uint8Array | null = null;1278 let sessionRootKey: Uint8Array | null = null;1279 try {1280 if ((await loadSlot(SLOT_A)) !== null) {1281 throw new Error("ADOPT_VAULT_ALREADY_EXISTS");1282 }12831284 const nextEntries: VaultEntry[] = [];1285 const nextDuressConfigured = false;1286 const vaultId = await computeVaultId(rootKeyBytes);1287 const salt = generateSalt();1288 nextDerivedKey = await deriveKey(password, salt);1289 const recovery = await wrapExistingRootKeyForRecovery(rootKeyBytes);12901291 sessionRootKey = new Uint8Array(rootKeyBytes);1292 const plaintext = encodePayload(nextEntries, nextDuressConfigured, 0);1293 const { nonce, ciphertext, tag } = await encrypt(nextDerivedKey, plaintext, "slot_a_v1");1294 const pwRoot = await wrapRootKeyForDerivedKey(nextDerivedKey, sessionRootKey);1295 const slotA: StoredVault = {1296 version: 1,1297 salt: hex(salt),1298 nonce,1299 ciphertext,1300 tag,1301 recovery_salt: recovery.recoverySalt,1302 recovery_wrapped_root_key: recovery.wrappedRootKey,1303 recovery_nonce: recovery.wrappedNonce,1304 recovery_tag: recovery.wrappedTag,1305 ...pwRoot,1306 };1307 const slotB = generateNoiseBlob(ciphertext.length / 2);1308 await commitCreatedVaultSlots(slotA, slotB);13091310 const previousDerivedKey = derivedKey;1311 const previousRootKey = rootKey;1312 derivedKey = nextDerivedKey;1313 rootKey = sessionRootKey;1314 nextDerivedKey = null;1315 sessionRootKey = null;1316 entries = nextEntries;1317 tombstones = new Map();1318 vaultMode = "real";1319 duressConfigured = nextDuressConfigured;1320 buildDomainIndexSync(entries);1321 if (previousDerivedKey && previousDerivedKey !== derivedKey) zeroKey(previousDerivedKey);1322 if (previousRootKey && previousRootKey !== rootKey) previousRootKey.fill(0);1323 touchAutoLock();13241325 return { vaultId };1326 } finally {1327 rootKeyBytes.fill(0);1328 if (nextDerivedKey) zeroKey(nextDerivedKey);1329 if (sessionRootKey) sessionRootKey.fill(0);1330 }1331}13321333export async function hasVault(): Promise<boolean> {1334 return (await loadSlot(SLOT_A)) !== null;1335}1336133713381339134013411342export async function isDuressEnabled(): Promise<boolean> {1343 return vaultMode === "real" && duressConfigured;1344}13451346export async function setupDuress(duressPassword: string): Promise<void> {1347 if (!derivedKey || vaultMode !== "real") {1348 throw new Error("vault must be unlocked in real mode");1349 }1350 1351 1352 1353 const slotA = await loadSlot(SLOT_A);1354 if (!slotA) throw new Error("missing slot A");13551356 const decoySalt = generateSalt();1357 const decoyKey = await deriveKey(duressPassword, decoySalt);1358 try {1359 1360 1361 1362 1363 const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1364 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1365 await writeSlot(SLOT_B, {1366 version: 1,1367 salt: hex(decoySalt),1368 nonce,1369 ciphertext,1370 tag,1371 });1372 } finally {1373 zeroKey(decoyKey);1374 }13751376 1377 1378 duressConfigured = true;1379 await persistSlotA();1380}13811382export async function disableDuress(): Promise<void> {1383 if (!derivedKey || vaultMode !== "real") {1384 throw new Error("vault must be unlocked in real mode");1385 }1386 1387 1388 1389 const slotA = await loadSlot(SLOT_A);1390 if (slotA) {1391 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1392 }13931394 duressConfigured = false;1395 await persistSlotA();1396}13971398139914001401140214031404export async function verifyMainPassword(password: string): Promise<boolean> {1405 const slotA = await loadSlot(SLOT_A);1406 if (!slotA) return false;1407 const key = await deriveKey(password, unhex(slotA.salt));1408 try {1409 return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1410 } finally {1411 zeroKey(key);1412 }1413}14141415export async function unlock(password: string): Promise<boolean> {1416 return attemptUnlockWithDerive(password, deriveKey);1417}14181419async function attemptUnlockWithDerive(1420 password: string,1421 derive: typeof deriveKey,1422): Promise<boolean> {1423 const epoch = vaultEpoch;1424 const stale = () => epoch !== vaultEpoch;1425 1426 1427 1428 1429 1430 1431 const [slotA, slotB] = await Promise.all([1432 loadSlot(SLOT_A),1433 loadSlot(SLOT_B),1434 ]);14351436 1437 const [keyA, keyB] = await Promise.all([1438 slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1439 slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1440 ]);14411442 1443 1444 1445 const [resultA, resultB] = await Promise.all([1446 keyA && slotA1447 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1448 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1449 keyB && slotB1450 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1451 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1452 ]);1453 const payloadA = resultA.status === "ok" ? resultA.payload : null;1454 const payloadB = resultB.status === "ok" ? resultB.payload : null;14551456 if (stale()) {1457 if (keyA) zeroKey(keyA);1458 if (keyB) zeroKey(keyB);1459 return false;1460 }14611462 if (payloadA && keyA) {1463 if (stale()) {1464 zeroKey(keyA);1465 if (keyB) zeroKey(keyB);1466 return false;1467 }1468 derivedKey = keyA;1469 vaultMode = "real";1470 entries = payloadA.entries;1471 tombstones = payloadA.tombstones;1472 duressConfigured = payloadA.duressConfigured;1473 buildDomainIndexSync(entries);1474 await loadSettings();1475 if (stale()) {1476 await lock();1477 return false;1478 }1479 touchAutoLock();1480 if (keyB) zeroKey(keyB);14811482 1483 1484 rootKey = slotA ? await deriveRootKeyFromDerivedKey(keyA, slotA) : null;1485 if (!rootKey) {1486 await lock();1487 return false;1488 }14891490 1491 1492 await reconcilePairSecrets(rootKey);1493 if (stale()) {1494 await lock();1495 return false;1496 }1497 await lockedSyncUnlockHook?.().catch((error) => {1498 console.warn("[Peach sync] locked inbox drain failed:", error);1499 });1500 if (stale()) {1501 await lock();1502 return false;1503 }1504 await resetPinFailedAttempts();1505 if (stale()) {1506 await lock();1507 return false;1508 }1509 await saveSessionKey(keyA, "real");1510 return true;1511 }15121513 if (payloadB && keyB) {1514 if (stale()) {1515 zeroKey(keyB);1516 if (keyA) zeroKey(keyA);1517 return false;1518 }1519 derivedKey = keyB;1520 vaultMode = "decoy";1521 vaultMode = "decoy";1522 entries = payloadB.entries;1523 tombstones = payloadB.tombstones;1524 duressConfigured = false;1525 buildDomainIndexSync(entries);1526 await loadSettings();1527 touchAutoLock();1528 if (keyA) zeroKey(keyA);1529 1530 await dropSessionCache();1531 if (stale()) {1532 await lock();1533 return false;1534 }1535 await saveSessionKey(keyB, "decoy");1536 return true;1537 }15381539 if (keyA) zeroKey(keyA);1540 if (keyB) zeroKey(keyB);1541 1542 1543 if (resultA.status === "corrupted") {1544 lastUnlockFailureReason = resultA.reason;1545 } else if (resultB.status === "corrupted") {1546 lastUnlockFailureReason = resultB.reason;1547 }1548 return false;1549}15501551155215531554155515561557const SESSION_KEY_STORAGE = "peach_session_dk";155815591560156115621563let sessionEscrowAvailable = true;15641565156615671568156915701571export async function applyStorageHardening(sessionEscrow: boolean): Promise<void> {1572 setSessionAllowed(sessionEscrow);1573 if (sessionEscrow) return;1574 sessionEscrowAvailable = false;1575 await clearSessionKey();1576}15771578async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1579 if (!chrome.storage.session || !sessionEscrowAvailable) return;1580 await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1581}15821583async function clearSessionKey(): Promise<void> {1584 if (!chrome.storage.session) return;1585 await chrome.storage.session.remove(SESSION_KEY_STORAGE);1586}15871588158915901591159215931594let vaultEpoch = 0;1595159615971598let restoreInFlight: Promise<boolean> | null = null;159916001601160216031604160516061607export async function restoreFromSession(): Promise<boolean> {1608 if (derivedKey !== null) return true;1609 if (restoreInFlight) return restoreInFlight;1610 const task = restoreFromSessionInner();1611 restoreInFlight = task;1612 try {1613 return await task;1614 } finally {1615 restoreInFlight = null;1616 }1617}16181619async function restoreFromSessionInner(): Promise<boolean> {1620 const epoch = vaultEpoch;1621 const stale = () => epoch !== vaultEpoch;1622 if (derivedKey !== null) return true;1623 if (!sessionEscrowAvailable) return false;1624 if (!chrome.storage.session) return false;1625 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1626 if (stale()) return false;1627 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1628 if (!entry?.keyHex) return false;16291630 const key = unhex(entry.keyHex);1631 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1632 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1633 const slot = await loadSlot(slotKey);1634 if (stale()) { zeroKey(key); return false; }1635 if (!slot) { await clearSessionKey(); return false; }16361637 const result = await tryDecryptSlotResult(slot, key, aadTag);1638 if (stale()) { zeroKey(key); return false; }1639 if (result.status !== "ok") { await clearSessionKey(); return false; }16401641 1642 1643 const payload = result.payload;1644 await loadSettings();1645 if (stale()) { zeroKey(key); return false; }16461647 1648 1649 let freshRoot: Uint8Array | null = null;1650 if (entry.mode === "real") {1651 freshRoot = await deriveRootKeyFromDerivedKey(key, slot);1652 if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1653 if (!freshRoot) {1654 await lock();1655 return false;1656 }1657 }16581659 if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1660 if (freshRoot) {1661 1662 await reconcilePairSecrets(freshRoot);1663 if (stale()) { zeroKey(key); zeroKey(freshRoot); return false; }1664 } else {1665 1666 await dropSessionCache();1667 if (stale()) { zeroKey(key); return false; }1668 }1669 derivedKey = key;1670 vaultMode = entry.mode;1671 entries = payload.entries;1672 tombstones = payload.tombstones;1673 duressConfigured = entry.mode === "real" ? payload.duressConfigured : false;1674 if (freshRoot) rootKey = freshRoot;1675 buildDomainIndexSync(entries);1676 touchAutoLock();16771678 await lockedSyncUnlockHook?.().catch((error) => {1679 console.warn("[Peach sync] locked inbox drain failed:", error);1680 });1681 if (stale()) return false;16821683 return true;1684}16851686export async function lock(): Promise<void> {1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 vaultEpoch++;1697 if (derivedKey) {1698 zeroKey(derivedKey);1699 derivedKey = null;1700 }1701 if (rootKey) {1702 rootKey.fill(0);1703 rootKey = null;1704 }17051706 entries = [];1707 tombstones = new Map();1708 domainIndex = new Map();1709 vaultMode = "real";1710 duressConfigured = false;1711 clearAutoLock();1712 await clearSessionKey();1713 await clearRecoveryConfirmPending();1714}17151716export function isUnlocked(): boolean {1717 return derivedKey !== null;1718}17191720172117221723export async function unlockWithKey(1724 masterKeyHex: string,1725): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1726 const epoch = vaultEpoch;1727 const stale = () => epoch !== vaultEpoch;1728 let masterKeyBytes: Uint8Array | null = null;1729 try {1730 masterKeyBytes = unhex(masterKeyHex);1731 const stored = await loadSlot(SLOT_A);1732 if (!stored) return { ok: false, error: "invalid_key" };1733 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1734 if (!payload || stale()) {1735 zeroKey(masterKeyBytes);1736 return { ok: false, error: "invalid_key" };1737 }17381739 derivedKey = masterKeyBytes;1740 vaultMode = "real";1741 entries = payload.entries;1742 tombstones = payload.tombstones;1743 duressConfigured = payload.duressConfigured;1744 buildDomainIndexSync(entries);1745 await loadSettings();1746 touchAutoLock();17471748 rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1749 if (!rootKey) {1750 await lock();1751 return { ok: false, error: "invalid_key" };1752 }1753 await reconcilePairSecrets(rootKey);1754 if (stale()) {1755 await lock();1756 return { ok: false, error: "invalid_key" };1757 }1758 await saveSessionKey(masterKeyBytes, "real");1759 return { ok: true, entries };1760 } catch {1761 if (masterKeyBytes && derivedKey !== masterKeyBytes) zeroKey(masterKeyBytes);1762 return { ok: false, error: "invalid_key" };1763 }1764}176517661767176817691770177117721773export async function exportDuressSlotForAdoption(): Promise<string | null> {1774 if (!(await isDuressEnabled())) return null;1775 const slotB = await loadSlot(SLOT_B);1776 if (!slotB) return null;1777 return JSON.stringify(slotB);1778}17791780178117821783178417851786export async function installAdoptedDuressSlot(json: string): Promise<void> {1787 let parsed: StoredVault;1788 try {1789 const raw = JSON.parse(json) as unknown;1790 if (!hasValidVaultSchema(raw)) {1791 throw new Error("invalid_duress_slot_shape");1792 }1793 parsed = raw;1794 } catch (err) {1795 if (err instanceof SyntaxError) {1796 throw new Error("invalid_duress_slot_shape");1797 }1798 throw err;1799 }1800 await writeSlot(SLOT_B, parsed);1801}18021803180418051806180718081809181018111812181318141815181618171818181918201821export async function unlockFromCodex(params: {1822 slotA: string; 1823 slotB: string | null; 1824 timestamp: number;1825 masterPassphrase: string;1826}): Promise<1827 | { ok: true; entryCount: number }1828 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1829> {1830 let parsedSlotA: StoredVault;1831 let parsedSlotB: StoredVault | null = null;1832 try {1833 const rawA = JSON.parse(params.slotA) as unknown;1834 if (!hasValidVaultSchema(rawA)) {1835 return { ok: false, reason: "invalid_slot_format" };1836 }1837 parsedSlotA = rawA;1838 if (params.slotB !== null) {1839 const rawB = JSON.parse(params.slotB) as unknown;1840 if (!hasValidVaultSchema(rawB)) {1841 return { ok: false, reason: "invalid_slot_format" };1842 }1843 parsedSlotB = rawB;1844 }1845 } catch {1846 return { ok: false, reason: "invalid_slot_format" };1847 }18481849 1850 1851 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1852 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1853 if (!trialPayload) {1854 zeroKey(trialKey);1855 return { ok: false, reason: "wrong_passphrase" };1856 }1857 1858 1859 zeroKey(trialKey);18601861 1862 1863 await takeVaultSnapshot("restore");18641865 await writeSlot(SLOT_A, parsedSlotA);1866 if (parsedSlotB) {1867 await writeSlot(SLOT_B, parsedSlotB);1868 } else {1869 1870 1871 1872 await writeSlot(1873 SLOT_B,1874 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1875 );1876 }1877 await chrome.storage.local.set({1878 peach_last_modified_at: String(params.timestamp),1879 });18801881 1882 1883 const ok = await unlock(params.masterPassphrase);1884 if (!ok) {1885 1886 1887 1888 return { ok: false, reason: "wrong_passphrase" };1889 }1890 return { ok: true, entryCount: getEntries().length };1891}18921893189418951896export function getDerivedKeyHex(): string {1897 if (!derivedKey) throw new Error("vault_not_unlocked");1898 return hex(derivedKey);1899}19001901190219031904190519061907190819091910191119121913191419151916export function getRootKeyHex(): string | null {1917 return rootKey ? hex(rootKey) : null;1918}19191920export function getEntries(): VaultEntry[] {1921 if (!derivedKey) throw new Error("vault_locked");1922 return entries.filter((entry) => !entry.deletedAt);1923}19241925const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;19261927export function getIdentities(): SavedIdentity[] {1928 if (!derivedKey) throw new Error("vault_locked");1929 const active = entries.filter((e) => !e.deletedAt && e.type === "login");19301931 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1932 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();19331934 for (const entry of active) {1935 const domain = extractDomain(entry.url ?? "") ?? "";1936 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;19371938 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1939 const existing = map.get(v);1940 if (!existing || modifiedAt > existing.lastUsed) {1941 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1942 } else {1943 map.set(v, { ...existing, count: existing.count + 1 });1944 }1945 }19461947 const uVal = entry.username?.trim();1948 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);19491950 1951 1952 const eVal = entry.email?.trim();1953 if (eVal) tally(emailMap, eVal);1954 }19551956 const toIdentity = (type: "email" | "username") =>1957 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1958 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1959 });19601961 return [1962 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1963 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1964 ];1965}19661967export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1968 if (!derivedKey) throw new Error("vault_locked");19691970 const now = Date.now();1971 const added: VaultEntry[] = [];1972 for (const draft of drafts) {1973 try {1974 const sanitized = sanitizeEntryDraft(draft);1975 const nextEntry: VaultEntry = {1976 ...sanitized,1977 id: crypto.randomUUID(),1978 createdAt: now,1979 modifiedAt: monotonicEntryTimestamp(),1980 };1981 entries.push(nextEntry);1982 added.push(nextEntry);1983 } catch {1984 1985 }1986 }19871988 if (added.length > 0) {1989 await persistEntries();1990 }19911992 return added;1993}199419951996199719981999200020012002export async function restoreEntries(2003 incoming: VaultEntry[],2004 mode: "replace" | "merge"2005): Promise<{ added: number; updated: number; skipped: number }> {2006 if (!derivedKey) throw new Error("vault_locked");20072008 2009 2010 2011 if (mode === "replace" && incoming.length === 0) {2012 throw new Error("refuse to replace with empty entry set");2013 }20142015 let added = 0;2016 let updated = 0;2017 let skipped = 0;20182019 if (mode === "replace") {2020 entries = [];2021 for (const entry of incoming) {2022 const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));2023 if (!normalized) {2024 skipped += 1;2025 continue;2026 }2027 entries.push(normalized);2028 if (normalized.deletedAt) {2029 tombstones.set(2030 normalized.id,2031 Math.max(tombstones.get(normalized.id) ?? 0, normalized.deletedAt),2032 );2033 } else {2034 tombstones.delete(normalized.id);2035 }2036 added += 1;2037 }2038 } else {2039 const byId = new Map<string, VaultEntry>();2040 for (const e of entries) byId.set(e.id, e);20412042 for (const entry of incoming) {2043 const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));2044 if (!normalized) {2045 skipped += 1;2046 continue;2047 }2048 const existing = byId.get(normalized.id);2049 if (!existing) {2050 entries.push(normalized);2051 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);2052 else tombstones.delete(normalized.id);2053 added += 1;2054 } else if (normalized.modifiedAt > existing.modifiedAt) {2055 const idx = entries.findIndex((e) => e.id === existing.id);2056 if (idx !== -1) {2057 entries[idx] = normalized;2058 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);2059 else tombstones.delete(normalized.id);2060 updated += 1;2061 }2062 } else {2063 skipped += 1;2064 }2065 }2066 }20672068 await persistEntries("import");2069 return { added, updated, skipped };2070}207120722073207420752076207720782079208020812082208320842085208620872088const ENTRY_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;20892090function naturalizeRestoredId(entry: VaultEntry | null): VaultEntry | null {2091 if (!entry || ENTRY_ID_SHAPE.test(entry.id)) return entry;2092 return { ...entry, id: crypto.randomUUID() };2093}209420952096209720982099210021012102function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {2103 if (!entry || typeof entry !== "object") return null;2104 const title = typeof entry.title === "string" ? entry.title.trim() : "";2105 if (!title) return null;2106 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();2107 const now = Date.now();2108 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;2109 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;21102111 2112 2113 const normalized = normalizeEntry({2114 ...entry,2115 id,2116 title,2117 type: entry.type ?? "login",2118 createdAt,2119 modifiedAt,2120 });21212122 if (normalized.passkey) {2123 normalized.passkey = sanitizePasskey(normalized.passkey);2124 }2125 if (normalized.canary) {2126 normalized.canary = sanitizeCanary(normalized.canary);2127 }2128 2129 normalized.tags = sanitizeTags(normalized.tags);21302131 2132 2133 if (2134 normalized.type === "login" &&2135 !normalized.passkey &&2136 typeof normalized.password !== "string"2137 ) {2138 return null;2139 }2140 if (entry.passkey && !normalized.passkey) {2141 2142 2143 return null;2144 }21452146 return normalized;2147}2148214921502151215221532154function sanitizePasskey(pk: unknown): PasskeyData | undefined {2155 if (!pk || typeof pk !== "object") return undefined;2156 const r = pk as Record<string, unknown>;2157 if (2158 typeof r.rpId !== "string" || !r.rpId ||2159 typeof r.credentialId !== "string" || !r.credentialId ||2160 typeof r.publicKeyCose !== "string" ||2161 typeof r.userHandle !== "string" ||2162 typeof r.userName !== "string"2163 ) {2164 return undefined;2165 }2166 return {2167 rpId: r.rpId,2168 rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,2169 credentialId: r.credentialId,2170 publicKeyCose: r.publicKeyCose,2171 privateKeyJwk:2172 r.privateKeyJwk && typeof r.privateKeyJwk === "object"2173 ? (r.privateKeyJwk as JsonWebKey)2174 : undefined,2175 userHandle: r.userHandle,2176 userName: r.userName,2177 userDisplayName:2178 typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,2179 algorithm: r.algorithm === -8 ? -8 : -7,2180 signCount: typeof r.signCount === "number" ? r.signCount : 0,2181 aaguid: typeof r.aaguid === "string" ? r.aaguid : "",2182 backupEligible: r.backupEligible === true,2183 backupState: r.backupState === true,2184 lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,2185 browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,2186 transports: Array.isArray(r.transports)2187 ? r.transports.filter((t): t is string => typeof t === "string")2188 : undefined,2189 createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),2190 };2191}21922193219421952196219721982199220022012202function sanitizeCanary(c: unknown): Canary | undefined {2203 if (!c || typeof c !== "object") return undefined;2204 const r = c as Record<string, unknown>;2205 if (typeof r.webhookUrl !== "string") return undefined;2206 let url: URL;2207 try {2208 url = new URL(r.webhookUrl);2209 } catch {2210 return undefined;2211 }2212 if (url.protocol !== "https:") return undefined;2213 return {2214 webhookUrl: url.toString(),2215 label: typeof r.label === "string" ? r.label : undefined,2216 lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,2217 pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,2218 };2219}22202221222222232224222522262227function sanitizeTags(tags: unknown): string[] | undefined {2228 if (!Array.isArray(tags)) return undefined;2229 const out: string[] = [];2230 for (const t of tags) {2231 if (typeof t !== "string") continue;2232 const trimmed = t.trim();2233 if (!trimmed) continue;2234 out.push(trimmed.slice(0, 256));2235 if (out.length >= 255) break;2236 }2237 return out.length > 0 ? out : undefined;2238}223922402241224222432244export async function clearVaultEntries(2245 confirmation: "REPLACE_ALL"2246): Promise<void> {2247 if (confirmation !== "REPLACE_ALL") {2248 throw new Error("clearVaultEntries requires explicit confirmation");2249 }2250 if (!derivedKey) throw new Error("vault_locked");2251 for (const entry of entries) {2252 if (!entry.deletedAt) {2253 const stamp = monotonicEntryTimestamp(Math.max(2254 entry.modifiedAt,2255 tombstones.get(entry.id) ?? 0,2256 ));2257 entry.deletedAt = stamp;2258 entry.modifiedAt = stamp;2259 tombstones.set(entry.id, stamp);2260 }2261 }2262 await persistEntries();2263}22642265export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {2266 if (!derivedKey) throw new Error("vault_locked");22672268 const sanitized = sanitizeEntryDraft(entry);2269 const now = Date.now();2270 const stamp = monotonicEntryTimestamp();2271 const nextEntry: VaultEntry = {2272 ...sanitized,2273 id: crypto.randomUUID(),2274 createdAt: now,2275 modifiedAt: stamp,2276 2277 2278 passwordChangedAt: sanitized.password ? stamp : undefined,2279 };22802281 2282 2283 2284 const rollback = entries;2285 entries = [...entries, nextEntry];2286 try {2287 await persistEntries();2288 } catch (err) {2289 entries = rollback;2290 throw err;2291 }22922293 2294 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {2295 checkBreach(nextEntry.password).then(async (count) => {2296 if (count > 0) {2297 const idx = entries.findIndex((e) => e.id === nextEntry.id);2298 if (idx !== -1) {2299 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };2300 await persistEntries();2301 }2302 }2303 });2304 }23052306 fireSyncTrigger("vault_change");23072308 return nextEntry;2309}23102311231223132314231523162317export async function consolidatePasskeyEntries(): Promise<void> {2318 if (!derivedKey) return;2319 const { merged } = mergePasskeyOnlyIntoLogins(entries);2320 if (merged) {2321 await persistEntries();2322 }2323}23242325export async function updateEntry(2326 id: string,2327 patch: Partial<VaultEntry>2328): Promise<void> {2329 if (!derivedKey) throw new Error("vault_locked");23302331 const idx = entries.findIndex((entry) => entry.id === id);2332 if (idx === -1 || entries[idx].deletedAt) {2333 throw new Error("entry_not_found");2334 }23352336 const current = entries[idx];2337 const sanitizedPatch = sanitizeEntryPatch(patch);2338 2339 2340 2341 const passwordChanged =2342 "password" in patch &&2343 typeof sanitizedPatch.password === "string" &&2344 sanitizedPatch.password !== current.password;2345 const stamp = monotonicEntryTimestamp(current.modifiedAt);2346 2347 2348 2349 2350 2351 2352 2353 const priorPassword = current.password;2354 const shouldCapture =2355 passwordChanged &&2356 typeof priorPassword === "string" &&2357 priorPassword.length > 0 &&2358 current.passwordHistory?.[0]?.password !== priorPassword;2359 const nextHistory = shouldCapture2360 ? [2361 { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },2362 ...(current.passwordHistory ?? []),2363 ].slice(0, PASSWORD_HISTORY_MAX)2364 : current.passwordHistory;2365 entries[idx] = {2366 ...current,2367 ...sanitizedPatch,2368 modifiedAt: stamp,2369 ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),2370 };2371237223732374 2375 if (entries[idx].type === "card" && !entries[idx].cardNumber) {2376 entries[idx] = current;2377 throw new Error("card number is required for card entries");2378 }23792380 2381 2382 2383 2384 try {2385 await persistEntries();2386 } catch (err) {2387 entries[idx] = current;2388 throw err;2389 }23902391 2392 2393 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {2394 entries[idx] = { ...entries[idx], breach: undefined };2395 await persistEntries();2396 const password = entries[idx].password;2397 if (password) {2398 checkBreach(password).then(async (count) => {2399 const i = entries.findIndex((e) => e.id === id);2400 if (i !== -1 && count > 0) {2401 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };2402 await persistEntries();2403 }2404 });2405 }2406 }24072408 fireSyncTrigger("vault_change");2409}24102411export async function deleteEntry(id: string): Promise<void> {2412 if (!derivedKey) throw new Error("vault_locked");24132414 const entry = entries.find((item) => item.id === id);2415 if (!entry || entry.deletedAt) {2416 throw new Error("entry_not_found");2417 }24182419 2420 2421 2422 const previousModifiedAt = entry.modifiedAt;2423 const previousTombstone = tombstones.get(id);2424 const stamp = monotonicEntryTimestamp(Math.max(entry.modifiedAt, previousTombstone ?? 0));2425 entry.deletedAt = stamp;2426 entry.modifiedAt = stamp;2427 tombstones.set(id, stamp);2428 2429 2430 try {2431 await persistEntries();2432 } catch (err) {2433 delete entry.deletedAt;2434 entry.modifiedAt = previousModifiedAt;2435 if (previousTombstone === undefined) tombstones.delete(id);2436 else tombstones.set(id, previousTombstone);2437 throw err;2438 }2439 fireSyncTrigger("vault_change");2440}24412442export function getDeletedEntries(): VaultEntry[] {2443 if (!derivedKey) return [];2444 return entries.filter((e) => !!e.deletedAt);2445}244624472448export function getTombstoneManifest(): Record<string, number> {2449 if (!derivedKey) return {};2450 return Object.fromEntries(tombstones);2451}2452245324542455245624572458export async function getVaultVersion(): Promise<number> {2459 try {2460 const raw = await chrome.storage.local.get("peach_last_modified_at");2461 const v = (raw as Record<string, unknown>).peach_last_modified_at;2462 if (typeof v === "number") return v;2463 if (typeof v === "string" && v !== "") {2464 const n = Number.parseInt(v, 10);2465 return Number.isFinite(n) ? n : 0;2466 }2467 return 0;2468 } catch {2469 return 0;2470 }2471}2472247324742475247624772478247924802481248224832484export async function applyMergedRemoteEntries(2485 remoteEntries: VaultEntry[],2486 remoteTombstones: Record<string, number>,2487 _context: { deviceName?: string } = {},2488): Promise<{ received: number; applied: number; conflicts: number }> {2489 2490 2491 2492 2493 2494 2495 if (!derivedKey) throw new Error("vault_locked");24962497 const received = remoteEntries.length + Object.keys(remoteTombstones).length;2498 let applied = 0;2499 let conflicts = 0;25002501 const byId = new Map<string, VaultEntry>();2502 for (const e of entries) byId.set(e.id, e);25032504 const mergeNow = Date.now();2505 const remoteTombstoneTimes = new Map<string, number>();2506 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2507 if (typeof rawDeletedAt === "number" && Number.isFinite(rawDeletedAt) && rawDeletedAt > 0) {2508 remoteTombstoneTimes.set(id, clampSyncTimestamp(rawDeletedAt, mergeNow));2509 }2510 }25112512 for (const remote of remoteEntries) {2513 const sanitized = sanitizeRestoredEntry(remote);2514 if (!sanitized) continue;2515 2516 2517 2518 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2519 if (typeof sanitized.deletedAt === "number") {2520 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2521 }2522 const remoteTombstoneTs = remoteTombstoneTimes.get(sanitized.id);2523 const localTombstoneTs = tombstones.get(sanitized.id);2524 const tombstoneTs = Math.max(remoteTombstoneTs ?? 0, localTombstoneTs ?? 0);2525 if (tombstoneTs > 0 && tombstoneTs >= sanitized.modifiedAt) {2526 continue;2527 }2528 const local = byId.get(sanitized.id);2529 if (!local) {2530 entries.push(sanitized);2531 byId.set(sanitized.id, sanitized);2532 tombstones.delete(sanitized.id);2533 applied += 1;2534 } else if (sanitized.modifiedAt > local.modifiedAt) {2535 const idx = entries.findIndex((e) => e.id === local.id);2536 if (idx !== -1) {2537 entries[idx] = sanitized;2538 byId.set(sanitized.id, sanitized);2539 tombstones.delete(sanitized.id);2540 applied += 1;2541 if (!local.deletedAt) conflicts += 1;2542 }2543 }2544 }25452546 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2547 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt) || rawDeletedAt <= 0) continue;2548 2549 const deletedAt = remoteTombstoneTimes.get(id)!;2550 const knownTombstone = tombstones.get(id);2551 if (knownTombstone !== undefined && knownTombstone >= deletedAt) continue;2552 const local = byId.get(id);2553 if (!local) {2554 tombstones.set(id, deletedAt);2555 applied += 1;2556 continue;2557 }2558 if (local.deletedAt && local.deletedAt >= deletedAt) continue;2559 if (deletedAt < local.modifiedAt) continue;2560 const idx = entries.findIndex((e) => e.id === id);2561 if (idx !== -1) {2562 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2563 tombstones.set(id, deletedAt);2564 applied += 1;2565 }2566 }25672568 if (applied > 0) {2569 await persistEntries("sync_received");2570 buildDomainIndexSync(entries);2571 }2572 return { received, applied, conflicts };2573}25742575export async function restoreEntry(id: string): Promise<void> {2576 if (!derivedKey) throw new Error("vault_locked");2577 const entry = entries.find((e) => e.id === id && e.deletedAt);2578 if (!entry) throw new Error("deleted entry not found");2579 const previousDeletedAt = entry.deletedAt;2580 const previousModifiedAt = entry.modifiedAt;2581 const previousTombstone = tombstones.get(id);2582 delete entry.deletedAt;2583 entry.modifiedAt = monotonicEntryTimestamp(Math.max(2584 entry.modifiedAt,2585 previousTombstone ?? 0,2586 ));2587 tombstones.delete(id);2588 try {2589 await persistEntries();2590 } catch (err) {2591 entry.deletedAt = previousDeletedAt;2592 entry.modifiedAt = previousModifiedAt;2593 if (previousTombstone !== undefined) tombstones.set(id, previousTombstone);2594 throw err;2595 }2596 buildDomainIndexSync(entries);2597}25982599export async function purgeEntry(id: string): Promise<void> {2600 if (!derivedKey) throw new Error("vault_locked");2601 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2602 if (idx === -1) throw new Error("deleted entry not found");2603 const entry = entries[idx]!;2604 tombstones.set(id, Math.max(tombstones.get(id) ?? 0, entry.deletedAt!));2605 entries.splice(idx, 1);2606 await persistEntries();2607}260826092610export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2611 if (!derivedKey || retentionMs === 0) return 0;2612 const cutoff = Date.now() - retentionMs;2613 const before = entries.length;2614 for (const entry of entries) {2615 if (entry.deletedAt && entry.deletedAt < cutoff) {2616 tombstones.set(entry.id, Math.max(tombstones.get(entry.id) ?? 0, entry.deletedAt));2617 }2618 }2619 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2620 const purged = before - entries.length;2621 if (purged > 0) await persistEntries();2622 return purged;2623}26242625export function queryDomain(domain: string): VaultEntry[] {2626 return domainIndex.get(domain) ?? [];2627}26282629export function getEntryById(id: string): VaultEntry | undefined {2630 return entries.find((entry) => entry.id === id && !entry.deletedAt);2631}26322633async function persistSlotA(2634 reason: VaultSnapshot["reason"] = "local_save",2635): Promise<StoredVault> {2636 if (!derivedKey) throw new Error("vault_locked");26372638 const slotA = await loadSlot(SLOT_A);2639 if (!slotA) throw new Error("missing slot A");26402641 2642 2643 await takeVaultSnapshot(reason);26442645 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2646 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");26472648 2649 const nextSlotA: StoredVault = {2650 ...slotA,2651 nonce,2652 ciphertext,2653 tag,2654 2655 };2656 await writeSlot(SLOT_A, nextSlotA);26572658 2659 2660 2661 2662 2663 2664 try {2665 2666 2667 2668 if (!duressConfigured) {2669 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2670 }2671 2672 2673 2674 2675 2676 if (reason !== "sync_received") {2677 await stampLocalVaultMutation();2678 }2679 } catch (err) {2680 2681 2682 await writeSlot(SLOT_A, slotA).catch(() => {});2683 throw err;2684 }26852686 return nextSlotA;2687}26882689async function persistSlotB(): Promise<void> {2690 if (!derivedKey) throw new Error("vault_locked");26912692 const slotB = await loadSlot(SLOT_B);2693 if (!slotB) throw new Error("missing slot B");26942695 2696 2697 2698 2699 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2700 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");27012702 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2703 2704 2705 2706 try {2707 await stampLocalVaultMutation();2708 } catch (err) {2709 await writeSlot(SLOT_B, slotB).catch(() => {});2710 throw err;2711 }2712}27132714async function persistEntries(2715 reason: VaultSnapshot["reason"] = "local_save",2716): Promise<void> {2717 if (!derivedKey) throw new Error("vault_locked");27182719 2720 2721 const previousTombstones = tombstones;2722 tombstones = new Map(tombstones);2723 reconcileTombstones();2724 try {2725 if (vaultMode === "decoy") {2726 await persistSlotB();2727 } else {2728 await persistSlotA(reason);2729 }2730 } catch (err) {2731 tombstones = previousTombstones;2732 throw err;2733 }2734 buildDomainIndexSync(entries);2735 await refreshLockedSyncState();2736}2737273827392740274127422743function allowHttpUrl(url: string | undefined): string {2744 const trimmed = url?.trim() ?? "";2745 if (!trimmed) return "";2746 try {2747 const parsed = new URL(trimmed);2748 if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return "";2749 } catch {2750 2751 }2752 return trimmed;2753}27542755function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2756 if (!Array.isArray(values)) return undefined;2757 const seen = new Set<string>();2758 const cleaned: string[] = [];2759 for (const value of values) {2760 const trimmed = typeof value === "string" ? value.trim() : "";2761 if (!trimmed) continue;2762 if (!allowHttpUrl(trimmed)) continue;2763 const key = trimmed.toLowerCase();2764 if (seen.has(key)) continue;2765 seen.add(key);2766 cleaned.push(trimmed);2767 }2768 return cleaned.length > 0 ? cleaned : undefined;2769}27702771function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2772 const title = entry.title.trim();2773 const type: VaultEntry["type"] = entry.type ?? "login";27742775 if (!title) throw new Error("title is required");27762777 if (entry.passkey) {2778 const p = entry.passkey;2779 if (2780 !p.rpId ||2781 !p.credentialId ||2782 !p.publicKeyCose ||2783 !p.userHandle ||2784 !p.userName ||2785 (p.algorithm !== -7 && p.algorithm !== -8)2786 ) {2787 throw new Error("invalid passkey credential");2788 }2789 }2790 if (type === "card") {2791 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2792 throw new Error("cardholder name and card number are required");2793 }2794 }27952796 return {2797 type,2798 title,2799 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2800 email: type === "login" ? entry.email?.trim() || undefined : undefined,2801 password: type === "login" ? entry.password : undefined,2802 url: type === "login" ? allowHttpUrl(entry.url) : undefined,2803 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2804 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2805 2806 2807 totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,2808 totpDigits: type === "login" ? entry.totpDigits : undefined,2809 totpPeriod: type === "login" ? entry.totpPeriod : undefined,2810 notes: entry.notes?.trim() || undefined,2811 breach: type === "login" ? entry.breach : undefined,2812 2813 2814 canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,2815 tags: sanitizeTags(entry.tags),2816 passkey: entry.passkey,2817 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2818 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2819 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2820 expYear: type === "card" ? entry.expYear?.trim() : undefined,2821 cvc: type === "card" ? entry.cvc?.trim() : undefined,2822 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2823 network: type === "card" ? entry.network : undefined,2824 last4: type === "card" ? entry.last4?.trim() : undefined,2825 };2826}28272828function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2829 const nextPatch: Partial<VaultEntry> = {};28302831 if ("type" in patch) {2832 nextPatch.type = patch.type;2833 }28342835 if ("title" in patch) {2836 const title = patch.title?.trim() ?? "";2837 if (!title) throw new Error("title is required");2838 nextPatch.title = title;2839 }28402841 if ("url" in patch) {2842 nextPatch.url = allowHttpUrl(patch.url);2843 }28442845 if ("additionalUrls" in patch) {2846 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2847 const seen = new Set<string>();2848 const cleaned: string[] = [];2849 for (const value of incoming) {2850 const trimmed = typeof value === "string" ? value.trim() : "";2851 if (!trimmed || !allowHttpUrl(trimmed)) continue;2852 const key = trimmed.toLowerCase();2853 if (seen.has(key)) continue;2854 seen.add(key);2855 cleaned.push(trimmed);2856 }2857 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2858 }28592860 if ("username" in patch) {2861 nextPatch.username = patch.username?.trim() ?? "";2862 }28632864 if ("email" in patch) {2865 nextPatch.email = patch.email?.trim() || undefined;2866 }28672868 if ("password" in patch) {2869 const password = patch.password ?? "";2870 2871 nextPatch.password = password || undefined;2872 }28732874 if ("totp" in patch) {2875 nextPatch.totp = patch.totp?.trim() || undefined;2876 }28772878 if ("notes" in patch) {2879 nextPatch.notes = patch.notes?.trim() || undefined;2880 }28812882 if ("breach" in patch) {2883 nextPatch.breach = patch.breach;2884 }28852886 if ("canary" in patch) {2887 2888 2889 nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;2890 }28912892 if ("passkey" in patch) {2893 2894 2895 if (patch.passkey === undefined || patch.passkey === null) {2896 nextPatch.passkey = undefined;2897 } else {2898 const sanitized = sanitizePasskey(patch.passkey);2899 if (!sanitized) throw new Error("invalid passkey credential");2900 nextPatch.passkey = sanitized;2901 }2902 }29032904 if ("tags" in patch) {2905 2906 nextPatch.tags = sanitizeTags(patch.tags);2907 }29082909 if ("cardholderName" in patch) {2910 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2911 }2912 if ("cardNumber" in patch) {2913 const n = patch.cardNumber ?? "";2914 nextPatch.cardNumber = n || undefined;2915 }2916 if ("expMonth" in patch) {2917 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2918 }2919 if ("expYear" in patch) {2920 nextPatch.expYear = patch.expYear?.trim() || undefined;2921 }2922 if ("cvc" in patch) {2923 nextPatch.cvc = patch.cvc?.trim() || undefined;2924 }2925 if ("cardNickname" in patch) {2926 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2927 }2928 if ("network" in patch) {2929 nextPatch.network = patch.network;2930 }2931 if ("last4" in patch) {2932 nextPatch.last4 = patch.last4?.trim() || undefined;2933 }29342935 return nextPatch;2936}29372938function normalizeEntry(entry: VaultEntry): VaultEntry {2939 const base: VaultEntry = {2940 id: entry.id,2941 type: entry.type,2942 title: entry.title,2943 username: entry.username,2944 email: entry.email,2945 password: entry.password,2946 url: entry.url,2947 additionalUrls:2948 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2949 ? entry.additionalUrls2950 : undefined,2951 totp: entry.totp,2952 2953 totpAlgorithm: entry.totpAlgorithm,2954 totpDigits: entry.totpDigits,2955 totpPeriod: entry.totpPeriod,2956 notes: entry.notes,2957 breach: entry.breach,2958 2959 2960 2961 passwordChangedAt:2962 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2963 2964 2965 2966 passwordHistory:2967 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)2968 ? entry.passwordHistory2969 : undefined,2970 canary: entry.canary,2971 passkey: entry.passkey,2972 createdAt: entry.createdAt,2973 modifiedAt: entry.modifiedAt,2974 deletedAt: entry.deletedAt,2975 tags: entry.tags,2976 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2977 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2978 expMonth: "expMonth" in entry ? entry.expMonth : undefined,2979 expYear: "expYear" in entry ? entry.expYear : undefined,2980 cvc: "cvc" in entry ? entry.cvc : undefined,2981 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2982 network: "network" in entry ? entry.network : undefined,2983 last4: "last4" in entry ? entry.last4 : undefined,2984 };29852986 return base;2987}2988298929902991299229932994function entryDupeKey(e: VaultEntry): string {2995 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2996 2997 2998 2999 3000 if (e.passkey) {3001 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;3002 }3003 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;3004}300530063007export function countDuplicates(): number {3008 const best = new Map<string, VaultEntry>();3009 let dupeCount = 0;3010 for (const e of entries.filter((e) => !e.deletedAt)) {3011 const key = entryDupeKey(e);3012 const existing = best.get(key);3013 if (!existing) {3014 best.set(key, e);3015 } else {3016 dupeCount++;3017 3018 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {3019 best.set(key, e);3020 }3021 }3022 }3023 return dupeCount;3024}302530263027302830293030export async function deduplicateEntries(): Promise<number> {3031 if (!derivedKey) throw new Error("vault_locked");3032 3033 3034 if (vaultMode === "decoy") {3035 throw new Error("deduplicate disabled in decoy mode");3036 }30373038 const best = new Map<string, VaultEntry>();3039 for (const e of entries.filter((en) => !en.deletedAt)) {3040 const key = entryDupeKey(e);3041 const existing = best.get(key);3042 if (!existing) {3043 best.set(key, e);3044 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {3045 best.set(key, e);3046 }3047 }30483049 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));3050 let removed = 0;3051 entries = entries.map((e) => {3052 if (!e.deletedAt && !keepIds.has(e.id)) {3053 removed++;3054 const deletedAt = monotonicEntryTimestamp(Math.max(3055 e.modifiedAt,3056 tombstones.get(e.id) ?? 0,3057 ));3058 tombstones.set(e.id, deletedAt);3059 return { ...e, deletedAt };3060 }3061 return e;3062 });30633064 if (removed > 0) await persistEntries();3065 return removed;3066}306730683069307030713072307330743075const HISTORY_KEY = "peach_vault_history";3076const HISTORY_MAX = 5;307730783079308030813082function bucketEntryCount(n: number): number {3083 if (n <= 0) return 0;3084 if (n <= 10) return 10;3085 if (n <= 25) return 25;3086 if (n <= 50) return 50;3087 if (n <= 100) return 100;3088 if (n <= 250) return 250;3089 if (n <= 500) return 500;3090 return Math.ceil(n / 500) * 500;3091}30923093309430953096309730983099export async function takeVaultSnapshot(3100 reason: VaultSnapshot["reason"],3101): Promise<void> {3102 try {3103 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);3104 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];3105 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];31063107 3108 3109 3110 if (!slotA) return;31113112 const vault_a = JSON.stringify(slotA);3113 const vault_b = JSON.stringify(slotB ?? slotA);31143115 3116 3117 3118 3119 3120 3121 let entryCount: number | null = null;3122 if (derivedKey && vaultMode === "real") {3123 try {3124 entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);3125 } catch {3126 entryCount = null;3127 }3128 }31293130 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3131 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)3132 ? existingRaw3133 : { snapshots: [] };31343135 3136 3137 3138 const latest = history.snapshots[0];3139 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {3140 return;3141 }31423143 const snapshot: VaultSnapshot = {3144 id: crypto.randomUUID(),3145 savedAt: Date.now(),3146 reason,3147 vault_a,3148 vault_b,3149 entryCount,3150 };31513152 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);3153 await chrome.storage.local.set({ [HISTORY_KEY]: history });3154 } catch (err) {3155 3156 3157 3158 3159 console.warn('[Peach] takeVaultSnapshot failed:', err);3160 if (String(err).includes('QUOTA') || String(err).includes('quota')) {3161 try {3162 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });3163 } catch {3164 3165 }3166 }3167 }3168}316931703171export async function clearVaultHistory(): Promise<void> {3172 try {3173 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });3174 } catch {3175 3176 }3177}317831793180export async function getVaultHistory(): Promise<VaultSnapshot[]> {3181 const raw = await chrome.storage.local.get(HISTORY_KEY);3182 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3183 if (!history || !Array.isArray(history.snapshots)) return [];3184 return history.snapshots;3185}31863187318831893190319131923193export async function restoreVaultSnapshot(3194 snapshotId: string,3195): Promise<{ ok: true } | { ok: false; error: string }> {3196 const history = await getVaultHistory();3197 const snapshot = history.find((s) => s.id === snapshotId);3198 if (!snapshot) return { ok: false, error: "snapshot_not_found" };31993200 3201 3202 3203 await takeVaultSnapshot("restore");32043205 let restoredA: StoredVault;3206 let restoredB: StoredVault;3207 try {3208 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;3209 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;3210 } catch {3211 return { ok: false, error: "snapshot_corrupt" };3212 }32133214 await writeSlot(SLOT_A, restoredA);3215 await writeSlot(SLOT_B, restoredB);3216 await stampLocalVaultMutation();32173218 3219 3220 3221 await lock();32223223 return { ok: true };3224}322532263227322832293230323132323233323432353236323732383239export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {3240 if (!derivedKey || vaultMode !== "real") {3241 return { ok: false, error: "vault_locked" };3242 }3243 const slotA = await loadSlot(SLOT_A);3244 if (!slotA) return { ok: false, error: "missing_slot_a" };32453246 3247 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =3248 await generateAndWrapRootKey();32493250 3251 const newRootKey = unhex(rawRootKey);3252 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);32533254 3255 3256 3257 3258 await takeVaultSnapshot("local_save");3259 const nextSlotA: StoredVault = {3260 ...slotA,3261 recovery_salt: recoverySalt,3262 recovery_wrapped_root_key: wrappedRootKey,3263 recovery_nonce: wrappedNonce,3264 recovery_tag: wrappedTag,3265 ...pwRoot,3266 };3267 await writeSlot(SLOT_A, nextSlotA);32683269 3270 if (rootKey) zeroKey(rootKey);3271 rootKey = newRootKey;32723273 3274 3275 3276 await clearPairedDevices();3277 await clearPairSecrets();32783279 3280 await setRecoveryConfirmPending(); 32813282 await stampLocalVaultMutation();3283 return { ok: true };3284}32853286328732883289329032913292export async function changePassword(3293 currentPassword: string,3294 newPassword: string3295): Promise<boolean> {3296 const epoch = vaultEpoch;3297 const stale = () => epoch !== vaultEpoch;3298 if (!derivedKey || vaultMode !== "real") return false;32993300 3301 const valid = await verifyMainPassword(currentPassword);3302 if (!valid) return false;33033304 const slotA = await loadSlot(SLOT_A);3305 if (!slotA) return false;3306 if (!rootKey) return false;33073308 3309 const newSalt = generateSalt();3310 const newKey = await deriveKey(newPassword, newSalt);3311 if (stale()) {3312 zeroKey(newKey);3313 return false;3314 }33153316 3317 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);3318 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");33193320 3321 3322 3323 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);33243325 const finalSlotA: StoredVault = {3326 ...slotA,3327 salt: hex(newSalt),3328 nonce,3329 ciphertext,3330 tag,3331 ...pwRoot,3332 };33333334 3335 3336 3337 await takeVaultSnapshot("local_save");33383339 3340 3341 3342 3343 await writeSlot(SLOT_A, finalSlotA);3344 if (stale()) {3345 zeroKey(newKey);3346 return false;3347 }33483349 3350 3351 3352 zeroKey(derivedKey);3353 derivedKey = newKey;33543355 3356 3357 3358 3359 if (stale()) {3360 await lock();3361 return false;3362 }3363 await saveSessionKey(newKey, "real");33643365 3366 3367 3368 if (!duressConfigured) {3369 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3370 }3371 await clearPinUnlockStorage();3372 await stampLocalVaultMutation();33733374 return true;3375}