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 1424 1425 1426 1427 1428 1429 const [slotA, slotB] = await Promise.all([1430 loadSlot(SLOT_A),1431 loadSlot(SLOT_B),1432 ]);14331434 1435 const [keyA, keyB] = await Promise.all([1436 slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1437 slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1438 ]);14391440 1441 1442 1443 const [resultA, resultB] = await Promise.all([1444 keyA && slotA1445 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1446 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1447 keyB && slotB1448 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1449 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1450 ]);1451 const payloadA = resultA.status === "ok" ? resultA.payload : null;1452 const payloadB = resultB.status === "ok" ? resultB.payload : null;14531454 if (payloadA && keyA) {1455 derivedKey = keyA;1456 void saveSessionKey(keyA, "real");1457 vaultMode = "real";1458 entries = payloadA.entries;1459 tombstones = payloadA.tombstones;1460 duressConfigured = payloadA.duressConfigured;1461 buildDomainIndexSync(entries);1462 await loadSettings();1463 touchAutoLock();1464 if (keyB) zeroKey(keyB);14651466 1467 1468 rootKey = slotA ? await deriveRootKeyFromDerivedKey(keyA, slotA) : null;1469 if (!rootKey) {1470 await lock();1471 return false;1472 }14731474 1475 1476 await reconcilePairSecrets(rootKey);14771478 await lockedSyncUnlockHook?.().catch((error) => {1479 console.warn("[Peach sync] locked inbox drain failed:", error);1480 });1481 await resetPinFailedAttempts();1482 return true;1483 }14841485 if (payloadB && keyB) {1486 derivedKey = keyB;1487 void saveSessionKey(keyB, "decoy");1488 vaultMode = "decoy";1489 entries = payloadB.entries;1490 tombstones = payloadB.tombstones;1491 duressConfigured = false;1492 buildDomainIndexSync(entries);1493 await loadSettings();1494 touchAutoLock();1495 if (keyA) zeroKey(keyA);1496 1497 await dropSessionCache();1498 return true;1499 }15001501 if (keyA) zeroKey(keyA);1502 if (keyB) zeroKey(keyB);1503 1504 1505 if (resultA.status === "corrupted") {1506 lastUnlockFailureReason = resultA.reason;1507 } else if (resultB.status === "corrupted") {1508 lastUnlockFailureReason = resultB.reason;1509 }1510 return false;1511}15121513151415151516151715181519const SESSION_KEY_STORAGE = "peach_session_dk";152015211522152315241525let sessionEscrowAvailable = true;15261527152815291530153115321533export async function applyStorageHardening(sessionEscrow: boolean): Promise<void> {1534 setSessionAllowed(sessionEscrow);1535 if (sessionEscrow) return;1536 sessionEscrowAvailable = false;1537 await clearSessionKey();1538}15391540async function saveSessionKey(key: Uint8Array, mode: "real" | "decoy"): Promise<void> {1541 if (!chrome.storage.session || !sessionEscrowAvailable) return;1542 await chrome.storage.session.set({ [SESSION_KEY_STORAGE]: { keyHex: hex(key), mode } });1543}15441545async function clearSessionKey(): Promise<void> {1546 if (!chrome.storage.session) return;1547 await chrome.storage.session.remove(SESSION_KEY_STORAGE);1548}15491550155115521553155415551556let vaultEpoch = 0;1557155815591560let restoreInFlight: Promise<boolean> | null = null;156115621563156415651566156715681569export async function restoreFromSession(): Promise<boolean> {1570 if (derivedKey !== null) return true;1571 if (restoreInFlight) return restoreInFlight;1572 const task = restoreFromSessionInner();1573 restoreInFlight = task;1574 try {1575 return await task;1576 } finally {1577 restoreInFlight = null;1578 }1579}15801581async function restoreFromSessionInner(): Promise<boolean> {1582 const epoch = vaultEpoch;1583 const stale = () => epoch !== vaultEpoch;1584 if (derivedKey !== null) return true;1585 if (!sessionEscrowAvailable) return false;1586 if (!chrome.storage.session) return false;1587 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1588 if (stale()) return false;1589 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1590 if (!entry?.keyHex) return false;15911592 const key = unhex(entry.keyHex);1593 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1594 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1595 const slot = await loadSlot(slotKey);1596 if (stale()) { zeroKey(key); return false; }1597 if (!slot) { await clearSessionKey(); return false; }15981599 const result = await tryDecryptSlotResult(slot, key, aadTag);1600 if (stale()) { zeroKey(key); return false; }1601 if (result.status !== "ok") { await clearSessionKey(); return false; }16021603 1604 1605 const payload = result.payload;1606 await loadSettings();1607 if (stale()) { zeroKey(key); return false; }16081609 1610 1611 let freshRoot: Uint8Array | null = null;1612 if (entry.mode === "real") {1613 freshRoot = await deriveRootKeyFromDerivedKey(key, slot);1614 if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1615 if (!freshRoot) {1616 await lock();1617 return false;1618 }1619 }16201621 if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1622 if (freshRoot) {1623 1624 await reconcilePairSecrets(freshRoot);1625 if (stale()) { zeroKey(key); zeroKey(freshRoot); return false; }1626 } else {1627 1628 await dropSessionCache();1629 if (stale()) { zeroKey(key); return false; }1630 }1631 derivedKey = key;1632 vaultMode = entry.mode;1633 entries = payload.entries;1634 tombstones = payload.tombstones;1635 duressConfigured = entry.mode === "real" ? payload.duressConfigured : false;1636 if (freshRoot) rootKey = freshRoot;1637 buildDomainIndexSync(entries);1638 touchAutoLock();16391640 await lockedSyncUnlockHook?.().catch((error) => {1641 console.warn("[Peach sync] locked inbox drain failed:", error);1642 });1643 if (stale()) return false;16441645 return true;1646}16471648export async function lock(): Promise<void> {1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 vaultEpoch++;1659 if (derivedKey) {1660 zeroKey(derivedKey);1661 derivedKey = null;1662 }1663 if (rootKey) {1664 rootKey.fill(0);1665 rootKey = null;1666 }16671668 entries = [];1669 tombstones = new Map();1670 domainIndex = new Map();1671 vaultMode = "real";1672 duressConfigured = false;1673 clearAutoLock();1674 await clearSessionKey();1675 await clearRecoveryConfirmPending();1676}16771678export function isUnlocked(): boolean {1679 return derivedKey !== null;1680}16811682168316841685export async function unlockWithKey(1686 masterKeyHex: string,1687): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1688 try {1689 const masterKeyBytes = unhex(masterKeyHex);1690 const stored = await loadSlot(SLOT_A);1691 if (!stored) return { ok: false, error: "invalid_key" };1692 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1693 if (!payload) return { ok: false, error: "invalid_key" };16941695 derivedKey = masterKeyBytes;1696 vaultMode = "real";1697 entries = payload.entries;1698 tombstones = payload.tombstones;1699 duressConfigured = payload.duressConfigured;1700 buildDomainIndexSync(entries);1701 await loadSettings();1702 touchAutoLock();17031704 rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1705 if (!rootKey) {1706 await lock();1707 return { ok: false, error: "invalid_key" };1708 }1709 await reconcilePairSecrets(rootKey);1710 return { ok: true, entries };1711 } catch {1712 return { ok: false, error: "invalid_key" };1713 }1714}171517161717171817191720172117221723export async function exportDuressSlotForAdoption(): Promise<string | null> {1724 if (!(await isDuressEnabled())) return null;1725 const slotB = await loadSlot(SLOT_B);1726 if (!slotB) return null;1727 return JSON.stringify(slotB);1728}17291730173117321733173417351736export async function installAdoptedDuressSlot(json: string): Promise<void> {1737 let parsed: StoredVault;1738 try {1739 const raw = JSON.parse(json) as unknown;1740 if (!hasValidVaultSchema(raw)) {1741 throw new Error("invalid_duress_slot_shape");1742 }1743 parsed = raw;1744 } catch (err) {1745 if (err instanceof SyntaxError) {1746 throw new Error("invalid_duress_slot_shape");1747 }1748 throw err;1749 }1750 await writeSlot(SLOT_B, parsed);1751}17521753175417551756175717581759176017611762176317641765176617671768176917701771export async function unlockFromCodex(params: {1772 slotA: string; 1773 slotB: string | null; 1774 timestamp: number;1775 masterPassphrase: string;1776}): Promise<1777 | { ok: true; entryCount: number }1778 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" }1779> {1780 let parsedSlotA: StoredVault;1781 let parsedSlotB: StoredVault | null = null;1782 try {1783 const rawA = JSON.parse(params.slotA) as unknown;1784 if (!hasValidVaultSchema(rawA)) {1785 return { ok: false, reason: "invalid_slot_format" };1786 }1787 parsedSlotA = rawA;1788 if (params.slotB !== null) {1789 const rawB = JSON.parse(params.slotB) as unknown;1790 if (!hasValidVaultSchema(rawB)) {1791 return { ok: false, reason: "invalid_slot_format" };1792 }1793 parsedSlotB = rawB;1794 }1795 } catch {1796 return { ok: false, reason: "invalid_slot_format" };1797 }17981799 1800 1801 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1802 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");1803 if (!trialPayload) {1804 zeroKey(trialKey);1805 return { ok: false, reason: "wrong_passphrase" };1806 }1807 1808 1809 zeroKey(trialKey);18101811 1812 1813 await takeVaultSnapshot("restore");18141815 await writeSlot(SLOT_A, parsedSlotA);1816 if (parsedSlotB) {1817 await writeSlot(SLOT_B, parsedSlotB);1818 } else {1819 1820 1821 1822 await writeSlot(1823 SLOT_B,1824 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),1825 );1826 }1827 await chrome.storage.local.set({1828 peach_last_modified_at: String(params.timestamp),1829 });18301831 1832 1833 const ok = await unlock(params.masterPassphrase);1834 if (!ok) {1835 1836 1837 1838 return { ok: false, reason: "wrong_passphrase" };1839 }1840 return { ok: true, entryCount: getEntries().length };1841}18421843184418451846export function getDerivedKeyHex(): string {1847 if (!derivedKey) throw new Error("vault_not_unlocked");1848 return hex(derivedKey);1849}18501851185218531854185518561857185818591860186118621863186418651866export function getRootKeyHex(): string | null {1867 return rootKey ? hex(rootKey) : null;1868}18691870export function getEntries(): VaultEntry[] {1871 if (!derivedKey) throw new Error("vault_locked");1872 return entries.filter((entry) => !entry.deletedAt);1873}18741875const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;18761877export function getIdentities(): SavedIdentity[] {1878 if (!derivedKey) throw new Error("vault_locked");1879 const active = entries.filter((e) => !e.deletedAt && e.type === "login");18801881 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();1882 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();18831884 for (const entry of active) {1885 const domain = extractDomain(entry.url ?? "") ?? "";1886 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;18871888 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {1889 const existing = map.get(v);1890 if (!existing || modifiedAt > existing.lastUsed) {1891 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });1892 } else {1893 map.set(v, { ...existing, count: existing.count + 1 });1894 }1895 }18961897 const uVal = entry.username?.trim();1898 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);18991900 1901 1902 const eVal = entry.email?.trim();1903 if (eVal) tally(emailMap, eVal);1904 }19051906 const toIdentity = (type: "email" | "username") =>1907 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({1908 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,1909 });19101911 return [1912 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),1913 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),1914 ];1915}19161917export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {1918 if (!derivedKey) throw new Error("vault_locked");19191920 const now = Date.now();1921 const added: VaultEntry[] = [];1922 for (const draft of drafts) {1923 try {1924 const sanitized = sanitizeEntryDraft(draft);1925 const nextEntry: VaultEntry = {1926 ...sanitized,1927 id: crypto.randomUUID(),1928 createdAt: now,1929 modifiedAt: monotonicEntryTimestamp(),1930 };1931 entries.push(nextEntry);1932 added.push(nextEntry);1933 } catch {1934 1935 }1936 }19371938 if (added.length > 0) {1939 await persistEntries();1940 }19411942 return added;1943}194419451946194719481949195019511952export async function restoreEntries(1953 incoming: VaultEntry[],1954 mode: "replace" | "merge"1955): Promise<{ added: number; updated: number; skipped: number }> {1956 if (!derivedKey) throw new Error("vault_locked");19571958 1959 1960 1961 if (mode === "replace" && incoming.length === 0) {1962 throw new Error("refuse to replace with empty entry set");1963 }19641965 let added = 0;1966 let updated = 0;1967 let skipped = 0;19681969 if (mode === "replace") {1970 entries = [];1971 for (const entry of incoming) {1972 const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));1973 if (!normalized) {1974 skipped += 1;1975 continue;1976 }1977 entries.push(normalized);1978 if (normalized.deletedAt) {1979 tombstones.set(1980 normalized.id,1981 Math.max(tombstones.get(normalized.id) ?? 0, normalized.deletedAt),1982 );1983 } else {1984 tombstones.delete(normalized.id);1985 }1986 added += 1;1987 }1988 } else {1989 const byId = new Map<string, VaultEntry>();1990 for (const e of entries) byId.set(e.id, e);19911992 for (const entry of incoming) {1993 const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));1994 if (!normalized) {1995 skipped += 1;1996 continue;1997 }1998 const existing = byId.get(normalized.id);1999 if (!existing) {2000 entries.push(normalized);2001 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);2002 else tombstones.delete(normalized.id);2003 added += 1;2004 } else if (normalized.modifiedAt > existing.modifiedAt) {2005 const idx = entries.findIndex((e) => e.id === existing.id);2006 if (idx !== -1) {2007 entries[idx] = normalized;2008 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);2009 else tombstones.delete(normalized.id);2010 updated += 1;2011 }2012 } else {2013 skipped += 1;2014 }2015 }2016 }20172018 await persistEntries("import");2019 return { added, updated, skipped };2020}202120222023202420252026202720282029203020312032203320342035203620372038const ENTRY_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;20392040function naturalizeRestoredId(entry: VaultEntry | null): VaultEntry | null {2041 if (!entry || ENTRY_ID_SHAPE.test(entry.id)) return entry;2042 return { ...entry, id: crypto.randomUUID() };2043}204420452046204720482049205020512052function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {2053 if (!entry || typeof entry !== "object") return null;2054 const title = typeof entry.title === "string" ? entry.title.trim() : "";2055 if (!title) return null;2056 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();2057 const now = Date.now();2058 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;2059 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;20602061 2062 2063 const normalized = normalizeEntry({2064 ...entry,2065 id,2066 title,2067 type: entry.type ?? "login",2068 createdAt,2069 modifiedAt,2070 });20712072 if (normalized.passkey) {2073 normalized.passkey = sanitizePasskey(normalized.passkey);2074 }2075 if (normalized.canary) {2076 normalized.canary = sanitizeCanary(normalized.canary);2077 }2078 2079 normalized.tags = sanitizeTags(normalized.tags);20802081 2082 2083 if (2084 normalized.type === "login" &&2085 !normalized.passkey &&2086 typeof normalized.password !== "string"2087 ) {2088 return null;2089 }2090 if (entry.passkey && !normalized.passkey) {2091 2092 2093 return null;2094 }20952096 return normalized;2097}2098209921002101210221032104function sanitizePasskey(pk: unknown): PasskeyData | undefined {2105 if (!pk || typeof pk !== "object") return undefined;2106 const r = pk as Record<string, unknown>;2107 if (2108 typeof r.rpId !== "string" || !r.rpId ||2109 typeof r.credentialId !== "string" || !r.credentialId ||2110 typeof r.publicKeyCose !== "string" ||2111 typeof r.userHandle !== "string" ||2112 typeof r.userName !== "string"2113 ) {2114 return undefined;2115 }2116 return {2117 rpId: r.rpId,2118 rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,2119 credentialId: r.credentialId,2120 publicKeyCose: r.publicKeyCose,2121 privateKeyJwk:2122 r.privateKeyJwk && typeof r.privateKeyJwk === "object"2123 ? (r.privateKeyJwk as JsonWebKey)2124 : undefined,2125 userHandle: r.userHandle,2126 userName: r.userName,2127 userDisplayName:2128 typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,2129 algorithm: r.algorithm === -8 ? -8 : -7,2130 signCount: typeof r.signCount === "number" ? r.signCount : 0,2131 aaguid: typeof r.aaguid === "string" ? r.aaguid : "",2132 backupEligible: r.backupEligible === true,2133 backupState: r.backupState === true,2134 lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,2135 browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,2136 transports: Array.isArray(r.transports)2137 ? r.transports.filter((t): t is string => typeof t === "string")2138 : undefined,2139 createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),2140 };2141}21422143214421452146214721482149215021512152function sanitizeCanary(c: unknown): Canary | undefined {2153 if (!c || typeof c !== "object") return undefined;2154 const r = c as Record<string, unknown>;2155 if (typeof r.webhookUrl !== "string") return undefined;2156 let url: URL;2157 try {2158 url = new URL(r.webhookUrl);2159 } catch {2160 return undefined;2161 }2162 if (url.protocol !== "https:") return undefined;2163 return {2164 webhookUrl: url.toString(),2165 label: typeof r.label === "string" ? r.label : undefined,2166 lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,2167 pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,2168 };2169}21702171217221732174217521762177function sanitizeTags(tags: unknown): string[] | undefined {2178 if (!Array.isArray(tags)) return undefined;2179 const out: string[] = [];2180 for (const t of tags) {2181 if (typeof t !== "string") continue;2182 const trimmed = t.trim();2183 if (!trimmed) continue;2184 out.push(trimmed.slice(0, 256));2185 if (out.length >= 255) break;2186 }2187 return out.length > 0 ? out : undefined;2188}218921902191219221932194export async function clearVaultEntries(2195 confirmation: "REPLACE_ALL"2196): Promise<void> {2197 if (confirmation !== "REPLACE_ALL") {2198 throw new Error("clearVaultEntries requires explicit confirmation");2199 }2200 if (!derivedKey) throw new Error("vault_locked");2201 for (const entry of entries) {2202 if (!entry.deletedAt) {2203 const stamp = monotonicEntryTimestamp(Math.max(2204 entry.modifiedAt,2205 tombstones.get(entry.id) ?? 0,2206 ));2207 entry.deletedAt = stamp;2208 entry.modifiedAt = stamp;2209 tombstones.set(entry.id, stamp);2210 }2211 }2212 await persistEntries();2213}22142215export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {2216 if (!derivedKey) throw new Error("vault_locked");22172218 const sanitized = sanitizeEntryDraft(entry);2219 const now = Date.now();2220 const stamp = monotonicEntryTimestamp();2221 const nextEntry: VaultEntry = {2222 ...sanitized,2223 id: crypto.randomUUID(),2224 createdAt: now,2225 modifiedAt: stamp,2226 2227 2228 passwordChangedAt: sanitized.password ? stamp : undefined,2229 };22302231 2232 2233 2234 const rollback = entries;2235 entries = [...entries, nextEntry];2236 try {2237 await persistEntries();2238 } catch (err) {2239 entries = rollback;2240 throw err;2241 }22422243 2244 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {2245 checkBreach(nextEntry.password).then(async (count) => {2246 if (count > 0) {2247 const idx = entries.findIndex((e) => e.id === nextEntry.id);2248 if (idx !== -1) {2249 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };2250 await persistEntries();2251 }2252 }2253 });2254 }22552256 fireSyncTrigger("vault_change");22572258 return nextEntry;2259}22602261226222632264226522662267export async function consolidatePasskeyEntries(): Promise<void> {2268 if (!derivedKey) return;2269 const { merged } = mergePasskeyOnlyIntoLogins(entries);2270 if (merged) {2271 await persistEntries();2272 }2273}22742275export async function updateEntry(2276 id: string,2277 patch: Partial<VaultEntry>2278): Promise<void> {2279 if (!derivedKey) throw new Error("vault_locked");22802281 const idx = entries.findIndex((entry) => entry.id === id);2282 if (idx === -1 || entries[idx].deletedAt) {2283 throw new Error("entry_not_found");2284 }22852286 const current = entries[idx];2287 const sanitizedPatch = sanitizeEntryPatch(patch);2288 2289 2290 2291 const passwordChanged =2292 "password" in patch &&2293 typeof sanitizedPatch.password === "string" &&2294 sanitizedPatch.password !== current.password;2295 const stamp = monotonicEntryTimestamp(current.modifiedAt);2296 2297 2298 2299 2300 2301 2302 2303 const priorPassword = current.password;2304 const shouldCapture =2305 passwordChanged &&2306 typeof priorPassword === "string" &&2307 priorPassword.length > 0 &&2308 current.passwordHistory?.[0]?.password !== priorPassword;2309 const nextHistory = shouldCapture2310 ? [2311 { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },2312 ...(current.passwordHistory ?? []),2313 ].slice(0, PASSWORD_HISTORY_MAX)2314 : current.passwordHistory;2315 entries[idx] = {2316 ...current,2317 ...sanitizedPatch,2318 modifiedAt: stamp,2319 ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),2320 };2321232223232324 2325 if (entries[idx].type === "card" && !entries[idx].cardNumber) {2326 entries[idx] = current;2327 throw new Error("card number is required for card entries");2328 }23292330 2331 2332 2333 2334 try {2335 await persistEntries();2336 } catch (err) {2337 entries[idx] = current;2338 throw err;2339 }23402341 2342 2343 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {2344 entries[idx] = { ...entries[idx], breach: undefined };2345 await persistEntries();2346 const password = entries[idx].password;2347 if (password) {2348 checkBreach(password).then(async (count) => {2349 const i = entries.findIndex((e) => e.id === id);2350 if (i !== -1 && count > 0) {2351 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };2352 await persistEntries();2353 }2354 });2355 }2356 }23572358 fireSyncTrigger("vault_change");2359}23602361export async function deleteEntry(id: string): Promise<void> {2362 if (!derivedKey) throw new Error("vault_locked");23632364 const entry = entries.find((item) => item.id === id);2365 if (!entry || entry.deletedAt) {2366 throw new Error("entry_not_found");2367 }23682369 2370 2371 2372 const previousModifiedAt = entry.modifiedAt;2373 const previousTombstone = tombstones.get(id);2374 const stamp = monotonicEntryTimestamp(Math.max(entry.modifiedAt, previousTombstone ?? 0));2375 entry.deletedAt = stamp;2376 entry.modifiedAt = stamp;2377 tombstones.set(id, stamp);2378 2379 2380 try {2381 await persistEntries();2382 } catch (err) {2383 delete entry.deletedAt;2384 entry.modifiedAt = previousModifiedAt;2385 if (previousTombstone === undefined) tombstones.delete(id);2386 else tombstones.set(id, previousTombstone);2387 throw err;2388 }2389 fireSyncTrigger("vault_change");2390}23912392export function getDeletedEntries(): VaultEntry[] {2393 if (!derivedKey) return [];2394 return entries.filter((e) => !!e.deletedAt);2395}239623972398export function getTombstoneManifest(): Record<string, number> {2399 if (!derivedKey) return {};2400 return Object.fromEntries(tombstones);2401}2402240324042405240624072408export async function getVaultVersion(): Promise<number> {2409 try {2410 const raw = await chrome.storage.local.get("peach_last_modified_at");2411 const v = (raw as Record<string, unknown>).peach_last_modified_at;2412 if (typeof v === "number") return v;2413 if (typeof v === "string" && v !== "") {2414 const n = Number.parseInt(v, 10);2415 return Number.isFinite(n) ? n : 0;2416 }2417 return 0;2418 } catch {2419 return 0;2420 }2421}2422242324242425242624272428242924302431243224332434export async function applyMergedRemoteEntries(2435 remoteEntries: VaultEntry[],2436 remoteTombstones: Record<string, number>,2437 _context: { deviceName?: string } = {},2438): Promise<{ received: number; applied: number; conflicts: number }> {2439 2440 2441 2442 2443 2444 2445 if (!derivedKey) throw new Error("vault_locked");24462447 const received = remoteEntries.length + Object.keys(remoteTombstones).length;2448 let applied = 0;2449 let conflicts = 0;24502451 const byId = new Map<string, VaultEntry>();2452 for (const e of entries) byId.set(e.id, e);24532454 const mergeNow = Date.now();2455 const remoteTombstoneTimes = new Map<string, number>();2456 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2457 if (typeof rawDeletedAt === "number" && Number.isFinite(rawDeletedAt) && rawDeletedAt > 0) {2458 remoteTombstoneTimes.set(id, clampSyncTimestamp(rawDeletedAt, mergeNow));2459 }2460 }24612462 for (const remote of remoteEntries) {2463 const sanitized = sanitizeRestoredEntry(remote);2464 if (!sanitized) continue;2465 2466 2467 2468 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2469 if (typeof sanitized.deletedAt === "number") {2470 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2471 }2472 const remoteTombstoneTs = remoteTombstoneTimes.get(sanitized.id);2473 const localTombstoneTs = tombstones.get(sanitized.id);2474 const tombstoneTs = Math.max(remoteTombstoneTs ?? 0, localTombstoneTs ?? 0);2475 if (tombstoneTs > 0 && tombstoneTs >= sanitized.modifiedAt) {2476 continue;2477 }2478 const local = byId.get(sanitized.id);2479 if (!local) {2480 entries.push(sanitized);2481 byId.set(sanitized.id, sanitized);2482 tombstones.delete(sanitized.id);2483 applied += 1;2484 } else if (sanitized.modifiedAt > local.modifiedAt) {2485 const idx = entries.findIndex((e) => e.id === local.id);2486 if (idx !== -1) {2487 entries[idx] = sanitized;2488 byId.set(sanitized.id, sanitized);2489 tombstones.delete(sanitized.id);2490 applied += 1;2491 if (!local.deletedAt) conflicts += 1;2492 }2493 }2494 }24952496 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2497 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt) || rawDeletedAt <= 0) continue;2498 2499 const deletedAt = remoteTombstoneTimes.get(id)!;2500 const knownTombstone = tombstones.get(id);2501 if (knownTombstone !== undefined && knownTombstone >= deletedAt) continue;2502 const local = byId.get(id);2503 if (!local) {2504 tombstones.set(id, deletedAt);2505 applied += 1;2506 continue;2507 }2508 if (local.deletedAt && local.deletedAt >= deletedAt) continue;2509 if (deletedAt < local.modifiedAt) continue;2510 const idx = entries.findIndex((e) => e.id === id);2511 if (idx !== -1) {2512 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2513 tombstones.set(id, deletedAt);2514 applied += 1;2515 }2516 }25172518 if (applied > 0) {2519 await persistEntries("sync_received");2520 buildDomainIndexSync(entries);2521 }2522 return { received, applied, conflicts };2523}25242525export async function restoreEntry(id: string): Promise<void> {2526 if (!derivedKey) throw new Error("vault_locked");2527 const entry = entries.find((e) => e.id === id && e.deletedAt);2528 if (!entry) throw new Error("deleted entry not found");2529 const previousDeletedAt = entry.deletedAt;2530 const previousModifiedAt = entry.modifiedAt;2531 const previousTombstone = tombstones.get(id);2532 delete entry.deletedAt;2533 entry.modifiedAt = monotonicEntryTimestamp(Math.max(2534 entry.modifiedAt,2535 previousTombstone ?? 0,2536 ));2537 tombstones.delete(id);2538 try {2539 await persistEntries();2540 } catch (err) {2541 entry.deletedAt = previousDeletedAt;2542 entry.modifiedAt = previousModifiedAt;2543 if (previousTombstone !== undefined) tombstones.set(id, previousTombstone);2544 throw err;2545 }2546 buildDomainIndexSync(entries);2547}25482549export async function purgeEntry(id: string): Promise<void> {2550 if (!derivedKey) throw new Error("vault_locked");2551 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2552 if (idx === -1) throw new Error("deleted entry not found");2553 const entry = entries[idx]!;2554 tombstones.set(id, Math.max(tombstones.get(id) ?? 0, entry.deletedAt!));2555 entries.splice(idx, 1);2556 await persistEntries();2557}255825592560export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2561 if (!derivedKey || retentionMs === 0) return 0;2562 const cutoff = Date.now() - retentionMs;2563 const before = entries.length;2564 for (const entry of entries) {2565 if (entry.deletedAt && entry.deletedAt < cutoff) {2566 tombstones.set(entry.id, Math.max(tombstones.get(entry.id) ?? 0, entry.deletedAt));2567 }2568 }2569 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2570 const purged = before - entries.length;2571 if (purged > 0) await persistEntries();2572 return purged;2573}25742575export function queryDomain(domain: string): VaultEntry[] {2576 return domainIndex.get(domain) ?? [];2577}25782579export function getEntryById(id: string): VaultEntry | undefined {2580 return entries.find((entry) => entry.id === id && !entry.deletedAt);2581}25822583async function persistSlotA(2584 reason: VaultSnapshot["reason"] = "local_save",2585): Promise<StoredVault> {2586 if (!derivedKey) throw new Error("vault_locked");25872588 const slotA = await loadSlot(SLOT_A);2589 if (!slotA) throw new Error("missing slot A");25902591 2592 2593 await takeVaultSnapshot(reason);25942595 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2596 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");25972598 2599 const nextSlotA: StoredVault = {2600 ...slotA,2601 nonce,2602 ciphertext,2603 tag,2604 2605 };2606 await writeSlot(SLOT_A, nextSlotA);26072608 2609 2610 2611 2612 2613 2614 try {2615 2616 2617 2618 if (!duressConfigured) {2619 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2620 }2621 2622 2623 2624 2625 2626 if (reason !== "sync_received") {2627 await stampLocalVaultMutation();2628 }2629 } catch (err) {2630 2631 2632 await writeSlot(SLOT_A, slotA).catch(() => {});2633 throw err;2634 }26352636 return nextSlotA;2637}26382639async function persistSlotB(): Promise<void> {2640 if (!derivedKey) throw new Error("vault_locked");26412642 const slotB = await loadSlot(SLOT_B);2643 if (!slotB) throw new Error("missing slot B");26442645 2646 2647 2648 2649 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2650 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");26512652 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2653 2654 2655 2656 try {2657 await stampLocalVaultMutation();2658 } catch (err) {2659 await writeSlot(SLOT_B, slotB).catch(() => {});2660 throw err;2661 }2662}26632664async function persistEntries(2665 reason: VaultSnapshot["reason"] = "local_save",2666): Promise<void> {2667 if (!derivedKey) throw new Error("vault_locked");26682669 2670 2671 const previousTombstones = tombstones;2672 tombstones = new Map(tombstones);2673 reconcileTombstones();2674 try {2675 if (vaultMode === "decoy") {2676 await persistSlotB();2677 } else {2678 await persistSlotA(reason);2679 }2680 } catch (err) {2681 tombstones = previousTombstones;2682 throw err;2683 }2684 buildDomainIndexSync(entries);2685 await refreshLockedSyncState();2686}2687268826892690269126922693function allowHttpUrl(url: string | undefined): string {2694 const trimmed = url?.trim() ?? "";2695 if (!trimmed) return "";2696 try {2697 const parsed = new URL(trimmed);2698 if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return "";2699 } catch {2700 2701 }2702 return trimmed;2703}27042705function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2706 if (!Array.isArray(values)) return undefined;2707 const seen = new Set<string>();2708 const cleaned: string[] = [];2709 for (const value of values) {2710 const trimmed = typeof value === "string" ? value.trim() : "";2711 if (!trimmed) continue;2712 if (!allowHttpUrl(trimmed)) continue;2713 const key = trimmed.toLowerCase();2714 if (seen.has(key)) continue;2715 seen.add(key);2716 cleaned.push(trimmed);2717 }2718 return cleaned.length > 0 ? cleaned : undefined;2719}27202721function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2722 const title = entry.title.trim();2723 const type: VaultEntry["type"] = entry.type ?? "login";27242725 if (!title) throw new Error("title is required");27262727 if (entry.passkey) {2728 const p = entry.passkey;2729 if (2730 !p.rpId ||2731 !p.credentialId ||2732 !p.publicKeyCose ||2733 !p.userHandle ||2734 !p.userName ||2735 (p.algorithm !== -7 && p.algorithm !== -8)2736 ) {2737 throw new Error("invalid passkey credential");2738 }2739 }2740 if (type === "card") {2741 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2742 throw new Error("cardholder name and card number are required");2743 }2744 }27452746 return {2747 type,2748 title,2749 username: type === "login" ? entry.username?.trim() ?? "" : undefined,2750 email: type === "login" ? entry.email?.trim() || undefined : undefined,2751 password: type === "login" ? entry.password : undefined,2752 url: type === "login" ? allowHttpUrl(entry.url) : undefined,2753 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,2754 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,2755 2756 2757 totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,2758 totpDigits: type === "login" ? entry.totpDigits : undefined,2759 totpPeriod: type === "login" ? entry.totpPeriod : undefined,2760 notes: entry.notes?.trim() || undefined,2761 breach: type === "login" ? entry.breach : undefined,2762 2763 2764 canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,2765 tags: sanitizeTags(entry.tags),2766 passkey: entry.passkey,2767 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,2768 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,2769 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,2770 expYear: type === "card" ? entry.expYear?.trim() : undefined,2771 cvc: type === "card" ? entry.cvc?.trim() : undefined,2772 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,2773 network: type === "card" ? entry.network : undefined,2774 last4: type === "card" ? entry.last4?.trim() : undefined,2775 };2776}27772778function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {2779 const nextPatch: Partial<VaultEntry> = {};27802781 if ("type" in patch) {2782 nextPatch.type = patch.type;2783 }27842785 if ("title" in patch) {2786 const title = patch.title?.trim() ?? "";2787 if (!title) throw new Error("title is required");2788 nextPatch.title = title;2789 }27902791 if ("url" in patch) {2792 nextPatch.url = allowHttpUrl(patch.url);2793 }27942795 if ("additionalUrls" in patch) {2796 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];2797 const seen = new Set<string>();2798 const cleaned: string[] = [];2799 for (const value of incoming) {2800 const trimmed = typeof value === "string" ? value.trim() : "";2801 if (!trimmed || !allowHttpUrl(trimmed)) continue;2802 const key = trimmed.toLowerCase();2803 if (seen.has(key)) continue;2804 seen.add(key);2805 cleaned.push(trimmed);2806 }2807 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;2808 }28092810 if ("username" in patch) {2811 nextPatch.username = patch.username?.trim() ?? "";2812 }28132814 if ("email" in patch) {2815 nextPatch.email = patch.email?.trim() || undefined;2816 }28172818 if ("password" in patch) {2819 const password = patch.password ?? "";2820 2821 nextPatch.password = password || undefined;2822 }28232824 if ("totp" in patch) {2825 nextPatch.totp = patch.totp?.trim() || undefined;2826 }28272828 if ("notes" in patch) {2829 nextPatch.notes = patch.notes?.trim() || undefined;2830 }28312832 if ("breach" in patch) {2833 nextPatch.breach = patch.breach;2834 }28352836 if ("canary" in patch) {2837 2838 2839 nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;2840 }28412842 if ("passkey" in patch) {2843 2844 2845 if (patch.passkey === undefined || patch.passkey === null) {2846 nextPatch.passkey = undefined;2847 } else {2848 const sanitized = sanitizePasskey(patch.passkey);2849 if (!sanitized) throw new Error("invalid passkey credential");2850 nextPatch.passkey = sanitized;2851 }2852 }28532854 if ("tags" in patch) {2855 2856 nextPatch.tags = sanitizeTags(patch.tags);2857 }28582859 if ("cardholderName" in patch) {2860 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;2861 }2862 if ("cardNumber" in patch) {2863 const n = patch.cardNumber ?? "";2864 nextPatch.cardNumber = n || undefined;2865 }2866 if ("expMonth" in patch) {2867 nextPatch.expMonth = patch.expMonth?.trim() || undefined;2868 }2869 if ("expYear" in patch) {2870 nextPatch.expYear = patch.expYear?.trim() || undefined;2871 }2872 if ("cvc" in patch) {2873 nextPatch.cvc = patch.cvc?.trim() || undefined;2874 }2875 if ("cardNickname" in patch) {2876 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;2877 }2878 if ("network" in patch) {2879 nextPatch.network = patch.network;2880 }2881 if ("last4" in patch) {2882 nextPatch.last4 = patch.last4?.trim() || undefined;2883 }28842885 return nextPatch;2886}28872888function normalizeEntry(entry: VaultEntry): VaultEntry {2889 const base: VaultEntry = {2890 id: entry.id,2891 type: entry.type,2892 title: entry.title,2893 username: entry.username,2894 email: entry.email,2895 password: entry.password,2896 url: entry.url,2897 additionalUrls:2898 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)2899 ? entry.additionalUrls2900 : undefined,2901 totp: entry.totp,2902 2903 totpAlgorithm: entry.totpAlgorithm,2904 totpDigits: entry.totpDigits,2905 totpPeriod: entry.totpPeriod,2906 notes: entry.notes,2907 breach: entry.breach,2908 2909 2910 2911 passwordChangedAt:2912 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,2913 2914 2915 2916 passwordHistory:2917 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)2918 ? entry.passwordHistory2919 : undefined,2920 canary: entry.canary,2921 passkey: entry.passkey,2922 createdAt: entry.createdAt,2923 modifiedAt: entry.modifiedAt,2924 deletedAt: entry.deletedAt,2925 tags: entry.tags,2926 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,2927 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,2928 expMonth: "expMonth" in entry ? entry.expMonth : undefined,2929 expYear: "expYear" in entry ? entry.expYear : undefined,2930 cvc: "cvc" in entry ? entry.cvc : undefined,2931 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,2932 network: "network" in entry ? entry.network : undefined,2933 last4: "last4" in entry ? entry.last4 : undefined,2934 };29352936 return base;2937}2938293929402941294229432944function entryDupeKey(e: VaultEntry): string {2945 if (e.type === "note") return `note|${e.title.toLowerCase()}`;2946 2947 2948 2949 2950 if (e.passkey) {2951 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;2952 }2953 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;2954}295529562957export function countDuplicates(): number {2958 const best = new Map<string, VaultEntry>();2959 let dupeCount = 0;2960 for (const e of entries.filter((e) => !e.deletedAt)) {2961 const key = entryDupeKey(e);2962 const existing = best.get(key);2963 if (!existing) {2964 best.set(key, e);2965 } else {2966 dupeCount++;2967 2968 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2969 best.set(key, e);2970 }2971 }2972 }2973 return dupeCount;2974}297529762977297829792980export async function deduplicateEntries(): Promise<number> {2981 if (!derivedKey) throw new Error("vault_locked");2982 2983 2984 if (vaultMode === "decoy") {2985 throw new Error("deduplicate disabled in decoy mode");2986 }29872988 const best = new Map<string, VaultEntry>();2989 for (const e of entries.filter((en) => !en.deletedAt)) {2990 const key = entryDupeKey(e);2991 const existing = best.get(key);2992 if (!existing) {2993 best.set(key, e);2994 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {2995 best.set(key, e);2996 }2997 }29982999 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));3000 let removed = 0;3001 entries = entries.map((e) => {3002 if (!e.deletedAt && !keepIds.has(e.id)) {3003 removed++;3004 const deletedAt = monotonicEntryTimestamp(Math.max(3005 e.modifiedAt,3006 tombstones.get(e.id) ?? 0,3007 ));3008 tombstones.set(e.id, deletedAt);3009 return { ...e, deletedAt };3010 }3011 return e;3012 });30133014 if (removed > 0) await persistEntries();3015 return removed;3016}301730183019302030213022302330243025const HISTORY_KEY = "peach_vault_history";3026const HISTORY_MAX = 5;302730283029303030313032function bucketEntryCount(n: number): number {3033 if (n <= 0) return 0;3034 if (n <= 10) return 10;3035 if (n <= 25) return 25;3036 if (n <= 50) return 50;3037 if (n <= 100) return 100;3038 if (n <= 250) return 250;3039 if (n <= 500) return 500;3040 return Math.ceil(n / 500) * 500;3041}30423043304430453046304730483049export async function takeVaultSnapshot(3050 reason: VaultSnapshot["reason"],3051): Promise<void> {3052 try {3053 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);3054 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];3055 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];30563057 3058 3059 3060 if (!slotA) return;30613062 const vault_a = JSON.stringify(slotA);3063 const vault_b = JSON.stringify(slotB ?? slotA);30643065 3066 3067 3068 3069 3070 3071 let entryCount: number | null = null;3072 if (derivedKey && vaultMode === "real") {3073 try {3074 entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);3075 } catch {3076 entryCount = null;3077 }3078 }30793080 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3081 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)3082 ? existingRaw3083 : { snapshots: [] };30843085 3086 3087 3088 const latest = history.snapshots[0];3089 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {3090 return;3091 }30923093 const snapshot: VaultSnapshot = {3094 id: crypto.randomUUID(),3095 savedAt: Date.now(),3096 reason,3097 vault_a,3098 vault_b,3099 entryCount,3100 };31013102 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);3103 await chrome.storage.local.set({ [HISTORY_KEY]: history });3104 } catch (err) {3105 3106 3107 3108 3109 console.warn('[Peach] takeVaultSnapshot failed:', err);3110 if (String(err).includes('QUOTA') || String(err).includes('quota')) {3111 try {3112 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });3113 } catch {3114 3115 }3116 }3117 }3118}311931203121export async function clearVaultHistory(): Promise<void> {3122 try {3123 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });3124 } catch {3125 3126 }3127}312831293130export async function getVaultHistory(): Promise<VaultSnapshot[]> {3131 const raw = await chrome.storage.local.get(HISTORY_KEY);3132 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3133 if (!history || !Array.isArray(history.snapshots)) return [];3134 return history.snapshots;3135}31363137313831393140314131423143export async function restoreVaultSnapshot(3144 snapshotId: string,3145): Promise<{ ok: true } | { ok: false; error: string }> {3146 const history = await getVaultHistory();3147 const snapshot = history.find((s) => s.id === snapshotId);3148 if (!snapshot) return { ok: false, error: "snapshot_not_found" };31493150 3151 3152 3153 await takeVaultSnapshot("restore");31543155 let restoredA: StoredVault;3156 let restoredB: StoredVault;3157 try {3158 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;3159 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;3160 } catch {3161 return { ok: false, error: "snapshot_corrupt" };3162 }31633164 await writeSlot(SLOT_A, restoredA);3165 await writeSlot(SLOT_B, restoredB);3166 await stampLocalVaultMutation();31673168 3169 3170 3171 await lock();31723173 return { ok: true };3174}317531763177317831793180318131823183318431853186318731883189export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {3190 if (!derivedKey || vaultMode !== "real") {3191 return { ok: false, error: "vault_locked" };3192 }3193 const slotA = await loadSlot(SLOT_A);3194 if (!slotA) return { ok: false, error: "missing_slot_a" };31953196 3197 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =3198 await generateAndWrapRootKey();31993200 3201 const newRootKey = unhex(rawRootKey);3202 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);32033204 3205 3206 3207 3208 await takeVaultSnapshot("local_save");3209 const nextSlotA: StoredVault = {3210 ...slotA,3211 recovery_salt: recoverySalt,3212 recovery_wrapped_root_key: wrappedRootKey,3213 recovery_nonce: wrappedNonce,3214 recovery_tag: wrappedTag,3215 ...pwRoot,3216 };3217 await writeSlot(SLOT_A, nextSlotA);32183219 3220 if (rootKey) zeroKey(rootKey);3221 rootKey = newRootKey;32223223 3224 3225 3226 await clearPairedDevices();3227 await clearPairSecrets();32283229 3230 await setRecoveryConfirmPending(); 32313232 await stampLocalVaultMutation();3233 return { ok: true };3234}32353236323732383239324032413242export async function changePassword(3243 currentPassword: string,3244 newPassword: string3245): Promise<boolean> {3246 if (!derivedKey || vaultMode !== "real") return false;32473248 3249 const valid = await verifyMainPassword(currentPassword);3250 if (!valid) return false;32513252 const slotA = await loadSlot(SLOT_A);3253 if (!slotA) return false;3254 if (!rootKey) return false;32553256 3257 const newSalt = generateSalt();3258 const newKey = await deriveKey(newPassword, newSalt);32593260 3261 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);3262 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");32633264 3265 3266 3267 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);32683269 const finalSlotA: StoredVault = {3270 ...slotA,3271 salt: hex(newSalt),3272 nonce,3273 ciphertext,3274 tag,3275 ...pwRoot,3276 };32773278 3279 3280 3281 await takeVaultSnapshot("local_save");32823283 3284 3285 3286 3287 await writeSlot(SLOT_A, finalSlotA);32883289 3290 3291 3292 zeroKey(derivedKey);3293 derivedKey = newKey;32943295 3296 3297 3298 3299 void saveSessionKey(newKey, "real");33003301 3302 3303 3304 if (!duressConfigured) {3305 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3306 }3307 await clearPinUnlockStorage();3308 await stampLocalVaultMutation();33093310 return true;3311}