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 lockedSyncRequiresUnlock?: boolean;151 152 pinEnabled?: boolean;153 154 pinPreferred?: boolean;155 156157158159160161162 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;163}164165interface StoredPinWrap {166 version: 1;167 salt: string;168 nonce: string;169 ciphertext: string;170 tag: string;171}172173const PIN_WRAP_STORAGE = "peach_pin_wrap";174const PIN_ATTEMPTS_STORAGE = "peach_pin_attempts";175const PIN_MIN_LENGTH = 4;176const PIN_MAX_LENGTH = 12;177const PIN_MAX_ATTEMPTS = 3;178179let settings: VaultSettings = {};180const CARD_DECLINE_MAX = 256;181182const CARD_DECLINE_DIGEST_HEX = 32;183184async function metadataDigest(value: string, key: string): Promise<string> {185 const bytes = new TextEncoder().encode(`${key}:${value}`);186 const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));187 return Array.from(digest.slice(0, 16)).map((byte) => byte.toString(16).padStart(2, "0")).join("");188}189190let inMemoryPinWrap: StoredPinWrap | null = null;191let inMemoryPinFailedAttempts = 0;192let sessionPinStorageFailed = false;193194export function getSettings(): VaultSettings {195 return settings;196}197198export async function ensureSettingsLoaded(): Promise<VaultSettings> {199 await loadSettings();200 return settings;201}202203export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {204 await loadSettings();205 206 207 208 209 const safe = Object.fromEntries(210 Object.entries(patch).filter(211 ([k]) => !["__proto__", "constructor", "prototype"].includes(k),212 ),213 ) as Partial<VaultSettings>;214 settings = { ...settings, ...safe };215 await persistSettings();216}217218function isValidPin(pin: string): boolean {219 return new RegExp(`^\\d{${PIN_MIN_LENGTH},${PIN_MAX_LENGTH}}$`).test(pin);220}221222223export async function setupPinUnlock(currentPassword: string, pin: string): Promise<boolean> {224 if (!derivedKey || vaultMode !== "real" || !isValidPin(pin)) return false;225 if (!(await verifyMainPassword(currentPassword))) return false;226227 const salt = generateSalt();228 const pinKey = await deriveKey(pin, salt);229 try {230 const wrapped = await encrypt(pinKey, hex(derivedKey), "pin_wrap_v1");231 await savePinWrap({ version: 1, salt: hex(salt), ...wrapped });232 await updateSettings({ pinEnabled: true });233 return true;234 } finally {235 zeroKey(pinKey);236 }237}238239async function clearPinUnlockStorage(): Promise<void> {240 inMemoryPinWrap = null;241 inMemoryPinFailedAttempts = 0;242 sessionPinStorageFailed = false;243 if (chrome.storage.session) {244 await chrome.storage.session.remove([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);245 }246 await updateSettings({ pinEnabled: false });247}248249250export async function disablePinUnlock(currentPassword: string): Promise<boolean> {251 if (!derivedKey || vaultMode !== "real") return false;252 if (!(await verifyMainPassword(currentPassword))) return false;253 await clearPinUnlockStorage();254 return true;255}256257258export async function unlockWithPin(pin: string): Promise<boolean> {259 if (!isValidPin(pin)) return false;260 await loadSettings();261 if (!settings.pinEnabled) return false;262263 const { stored, failedAttempts } = await loadPinState();264 if (failedAttempts >= PIN_MAX_ATTEMPTS) return false;265 if (266 !stored ||267 stored.version !== 1 ||268 !stored.salt ||269 !stored.nonce ||270 !stored.ciphertext ||271 !stored.tag272 ) return false;273274 const pinKey = await deriveKey(pin, unhex(stored.salt));275 let unwrappedKey: Uint8Array | null = null;276 try {277 const masterKeyHex = await decrypt(278 pinKey,279 stored.nonce,280 stored.ciphertext,281 stored.tag,282 "pin_wrap_v1",283 );284 if (!/^[0-9a-f]{64}$/.test(masterKeyHex)) return false;285 unwrappedKey = unhex(masterKeyHex);286 const result = await unlockWithKey(masterKeyHex);287 if (!result.ok) return false;288 await resetPinFailedAttempts();289 return true;290 } catch {291 await recordPinFailure(failedAttempts + 1);292 return false;293 } finally {294 zeroKey(pinKey);295 if (unwrappedKey) zeroKey(unwrappedKey);296 }297}298299async function savePinWrap(stored: StoredPinWrap): Promise<void> {300 inMemoryPinWrap = stored;301 inMemoryPinFailedAttempts = 0;302 sessionPinStorageFailed = !chrome.storage.session;303 if (!chrome.storage.session) return;304 try {305 await chrome.storage.session.set({ [PIN_WRAP_STORAGE]: stored, [PIN_ATTEMPTS_STORAGE]: 0 });306 sessionPinStorageFailed = false;307 } catch {308 309 sessionPinStorageFailed = true;310 }311}312313async function loadPinState(): Promise<{314 stored: StoredPinWrap | undefined;315 failedAttempts: number;316}> {317 if (!chrome.storage.session) {318 return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };319 }320 try {321 const result = await chrome.storage.session.get([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);322 return {323 stored: (result[PIN_WRAP_STORAGE] as StoredPinWrap | undefined) ??324 (sessionPinStorageFailed ? inMemoryPinWrap ?? undefined : undefined),325 failedAttempts: typeof result[PIN_ATTEMPTS_STORAGE] === "number"326 ? result[PIN_ATTEMPTS_STORAGE]327 : 0,328 };329 } catch {330 return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };331 }332}333334async function recordPinFailure(failedAttempts: number): Promise<void> {335 inMemoryPinFailedAttempts = Math.min(failedAttempts, PIN_MAX_ATTEMPTS);336 if (!chrome.storage.session) return;337 try {338 await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: inMemoryPinFailedAttempts });339 } catch { }340}341342async function resetPinFailedAttempts(): Promise<void> {343 inMemoryPinFailedAttempts = 0;344 if (!chrome.storage.session) return;345 try {346 await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: 0 });347 } catch { }348}349350export async function getPinUnlockStatus(): Promise<{351 configured: boolean;352 available: boolean;353 attemptsRemaining: number;354}> {355 await loadSettings();356 if (!settings.pinEnabled) {357 return { configured: false, available: false, attemptsRemaining: 0 };358 }359 const { stored, failedAttempts } = await loadPinState();360 const available = Boolean(stored) && failedAttempts < PIN_MAX_ATTEMPTS;361 return {362 configured: true,363 available,364 attemptsRemaining: stored ? Math.max(0, PIN_MAX_ATTEMPTS - failedAttempts) : 0,365 };366}367368async function persistSettings(): Promise<void> {369 try {370 await chrome.storage.local.set({371 peach_settings: {372 ...settings,373 manualSummonDomains: undefined,374 manualSummonSalt: undefined,375 cardFingerprintSalt: undefined,376 declinedCardFingerprints: settings.declinedCardFingerprints ?? [],377 metadataVersion: 1,378 },379 });380 } catch (err) {381 throw mapStorageError(err);382 }383}384385function mapStorageError(err: unknown): Error {386 const message = err instanceof Error ? err.message : String(err);387 if (/QUOTA_BYTES|quota/i.test(message)) {388 return new Error("STORAGE_QUOTA_EXCEEDED");389 }390 return err instanceof Error ? err : new Error(message);391}392393async function loadSettings(): Promise<void> {394 const result = await chrome.storage.local.get("peach_settings");395 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};396}397398399400function fireSyncTrigger(reason: "vault_change"): void {401 void Promise.resolve()402 .then(() => triggerSyncIfReachable({ reason }))403 .catch(() => {404 405 });406}407export function lockedSyncRequiresUnlock(): boolean {408 return settings.lockedSyncRequiresUnlock === true;409}410411let lockedSyncRefreshHook: (() => Promise<void>) | null = null;412let lockedSyncUnlockHook: (() => Promise<void>) | null = null;413let decoySyncTeardownHook: (() => void) | null = null;414export type IdentityHooks = {415 416 onSessionEnded: () => Promise<void>;417 418 onKeysRotated: () => Promise<void>;419 420 onVaultCreated: (password: string) => Promise<void>;421};422423let identityHooks: IdentityHooks | null = null;424425export function setIdentityHooks(hooks: IdentityHooks): void {426 identityHooks = hooks;427}428429async function notifyIdentitySessionEnded(): Promise<void> {430 await identityHooks?.onSessionEnded().catch((error) => {431 console.warn("[Peach] local identity cache clear failed:", error);432 });433}434435async function notifyIdentityKeysRotated(): Promise<void> {436 await identityHooks?.onKeysRotated().catch((error) => {437 console.warn("[Peach] local identity re-wrap failed:", error);438 });439}440441export type SealedBox = { nonce: string; ciphertext: string; tag: string };442export type IdentityKeyEnvelope = {443 root?: SealedBox;444 local?: SealedBox;445 pending?: SealedBox;446 pendingSalt?: string;447};448449export function setLockedSyncHooks(hooks: {450 refresh: () => Promise<void>;451 afterUnlock: () => Promise<void>;452 onDecoy?: () => void;453}): void {454 lockedSyncRefreshHook = hooks.refresh;455 lockedSyncUnlockHook = hooks.afterUnlock;456 decoySyncTeardownHook = hooks.onDecoy ?? null;457}458459async function refreshLockedSyncState(): Promise<void> {460 if (vaultMode !== "real" || !rootKey || !lockedSyncRefreshHook) return;461 await lockedSyncRefreshHook().catch((error) => {462 console.warn("[Peach sync] locked spool refresh failed:", error);463 });464}465466467468469470export async function checkBreach(password: string): Promise<number> {471 472 473 const encoder = new TextEncoder();474 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));475 const hashHex = Array.from(new Uint8Array(hashBuffer))476 .map((b) => b.toString(16).padStart(2, "0"))477 .join("")478 .toUpperCase();479480 const prefix = hashHex.slice(0, 5);481 const suffix = hashHex.slice(5);482483 try {484 485 486 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {487 headers: { "Add-Padding": "true" },488 });489 if (!res.ok) return 0;490 const text = await res.text();491 const match = text.split("\r\n").find((l) => l.startsWith(suffix));492 return match ? parseInt(match.split(":")[1], 10) : 0;493 } catch {494 return 0; 495 }496}497498export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {499 500 const key = await cardDeclineKey();501 if (!key) return;502 const fingerprint = await cardFingerprint(last4, expMonth, expYear);503 await loadSettings();504 settings = {505 ...settings,506 declinedCardFingerprints: [507 508 509 ...(settings.declinedCardFingerprints ?? []).filter(510 (stored) => stored.length === CARD_DECLINE_DIGEST_HEX,511 ),512 await metadataDigest(fingerprint, key),513 ].slice(-CARD_DECLINE_MAX),514 };515 await persistSettings();516}517518export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {519 await loadSettings();520 const key = await cardDeclineKey();521 if (!key) return false;522 const fingerprint = await cardFingerprint(last4, expMonth, expYear);523 const candidate = await metadataDigest(fingerprint, key);524 return (settings.declinedCardFingerprints ?? []).some(525 (stored) => stored.length === CARD_DECLINE_DIGEST_HEX && stored === candidate,526 );527}528529530531532533534535536537538539540async function cardDeclineKey(): Promise<string | null> {541 const root = getRootKeyHex();542 if (!root) return null;543 const digest = new Uint8Array(544 await crypto.subtle.digest(545 "SHA-256",546 new TextEncoder().encode(`peach-card-decline-v1:${root}`),547 ),548 );549 return hex(digest);550}551552async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {553 const encoder = new TextEncoder();554 const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));555 return Array.from(new Uint8Array(buf).slice(0, 8))556 .map((b) => b.toString(16).padStart(2, "0"))557 .join("");558}559560function hex(buf: Uint8Array): string {561 return Array.from(buf)562 .map((b) => b.toString(16).padStart(2, "0"))563 .join("");564}565566function unhex(s: string): Uint8Array {567 const out = new Uint8Array(s.length / 2);568 for (let i = 0; i < s.length; i += 2) {569 out[i / 2] = parseInt(s.slice(i, i + 2), 16);570 }571 return out;572}573574575576577578579580581582583584585586587588589590591592593async function generateAndWrapRootKey(): Promise<{594 rawRootKey: string; 595 recoverySalt: string;596 wrappedRootKey: string;597 wrappedNonce: string;598 wrappedTag: string;599}> {600 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));601602 603 const words = await rootKeyToWords(rootKeyBytes);604 const wordMaterial = await wordsToRootKeyMaterial(words);605 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));606 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);607608 609 610 const nonce = crypto.getRandomValues(new Uint8Array(12));611 const encoded = new TextEncoder().encode(hex(rootKeyBytes));612 const raw = await crypto.subtle.encrypt(613 {614 name: "AES-GCM",615 iv: nonce,616 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,617 tagLength: 128,618 },619 recoveryKey,620 encoded621 );622 623 const rawU8 = new Uint8Array(raw);624 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));625 const tag = hex(rawU8.slice(rawU8.length - 16));626627 return {628 rawRootKey: hex(rootKeyBytes),629 recoverySalt: hex(recoverySalt),630 wrappedRootKey: ciphertext,631 wrappedNonce: hex(nonce),632 wrappedTag: tag,633 };634}635636async function wrapExistingRootKeyForRecovery(rootKeyBytes: Uint8Array): Promise<{637 recoverySalt: string;638 wrappedRootKey: string;639 wrappedNonce: string;640 wrappedTag: string;641}> {642 if (rootKeyBytes.length !== 32) {643 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");644 }645646 const words = await rootKeyToWords(rootKeyBytes);647 const wordMaterial = await wordsToRootKeyMaterial(words);648 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));649 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);650 const nonce = crypto.getRandomValues(new Uint8Array(12));651 const encoded = new TextEncoder().encode(hex(rootKeyBytes));652 const raw = await crypto.subtle.encrypt(653 {654 name: "AES-GCM",655 iv: nonce,656 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,657 tagLength: 128,658 },659 recoveryKey,660 encoded,661 );662 const rawU8 = new Uint8Array(raw);663 return {664 recoverySalt: hex(recoverySalt),665 wrappedRootKey: hex(rawU8.slice(0, rawU8.length - 16)),666 wrappedNonce: hex(nonce),667 wrappedTag: hex(rawU8.slice(rawU8.length - 16)),668 };669}670671672673674675676export async function unwrapRootKeyForRecovery(677 wrappedRootKey: string,678 wrappedNonce: string,679 wrappedTag: string,680 recoverySaltHex: string,681 words: string[]682): Promise<void> {683 const wordMaterial = await wordsToRootKeyMaterial(words);684 const recoverySalt = unhex(recoverySaltHex);685 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);686687 688 689 690 691 692 const nonceRaw = unhex(wrappedNonce);693 const ciphertextRaw = unhex(wrappedRootKey);694 const tagRaw = unhex(wrappedTag);695 696 697 const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;698 const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);699 combined.set(ciphertextRaw, 0);700 combined.set(tagRaw, ciphertextRaw.length);701 const raw = await crypto.subtle.decrypt(702 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },703 recoveryKey,704 combined705 );706 const decryptedHex = new TextDecoder().decode(raw);707 rootKey = unhex(decryptedHex);708}709710711712713714715716717718719export async function rewrapRootKeyForPassword(720 newDerivedKey: Uint8Array,721): Promise<void> {722 if (!rootKey) throw new Error("root key not set");723 const slotA = await loadSlot(SLOT_A);724 if (!slotA) throw new Error("missing slot A");725 const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);726 await writeSlot(SLOT_A, { ...slotA, ...pwRoot });727}728729730731732733734async function reencryptVaultForNewKey(735 newKey: Uint8Array,736 slotA: StoredVault737): Promise<void> {738 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);739 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");740 await writeSlot(SLOT_A, {741 ...slotA,742 nonce,743 ciphertext,744 tag,745 });746}747748749750751752753export async function changePasswordWithNewKey(754 newSalt: Uint8Array,755 newKey: Uint8Array756): Promise<void> {757 const epoch = vaultEpoch;758 const throwIfStale = () => {759 if (epoch === vaultEpoch) return;760 zeroKey(newKey);761 throw new Error("vault_locked");762 };763 const slotA = await loadSlot(SLOT_A);764 throwIfStale();765 if (!slotA) throw new Error("missing slot A");766 767 768 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });769 throwIfStale();770 if (derivedKey) zeroKey(derivedKey);771 derivedKey = newKey;772 773 774 775 await saveSessionKey(newKey, "real", epoch);776 throwIfStale();777 778 779 await notifyIdentityKeysRotated();780 throwIfStale();781 782 783 784 785 if (rootKey) {786 const slotAfter = await loadSlot(SLOT_A);787 throwIfStale();788 if (slotAfter) {789 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);790 throwIfStale();791 await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });792 throwIfStale();793 }794 }795 if (!duressConfigured) {796 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));797 throwIfStale();798 }799 await clearPinUnlockStorage();800 throwIfStale();801 await stampLocalVaultMutation();802 throwIfStale();803}804805806807808809810export { deriveKey, generateSalt };811812export async function loadSlotA(): Promise<StoredVault | null> {813 return loadSlot(SLOT_A);814}815816817818819820821822823824825async function deriveRootKeyFromDerivedKey(826 derivedKeyBytes: Uint8Array,827 slotA: StoredVault,828): Promise<Uint8Array | null> {829 if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {830 return null;831 }832 try {833 const rootHex = await decrypt(834 derivedKeyBytes,835 slotA.pw_root_nonce,836 slotA.pw_root_ciphertext,837 slotA.pw_root_tag,838 "pw_root_v1",839 );840 return unhex(rootHex);841 } catch {842 return null;843 }844}845846export async function sealIdentityKeyUnderVault(ik: Uint8Array): Promise<IdentityKeyEnvelope> {847 if (vaultMode !== "real" || !rootKey || !derivedKey) throw new Error("vault_locked");848 849 850 const [root, local] = await Promise.all([851 encrypt(rootKey, hex(ik), "peach-local-identity-ik-root-v1"),852 encrypt(derivedKey, hex(ik), "peach-local-identity-ik-local-v1"),853 ]);854 return { root, local };855}856857export async function sealIdentityKeyPreVault(858 password: string,859 ik: Uint8Array,860): Promise<IdentityKeyEnvelope> {861 const salt = generateSalt();862 const key = await deriveKey(password, salt);863 try {864 const pending = await encrypt(key, hex(ik), "peach-local-identity-ik-pending-v1");865 return { pending, pendingSalt: hex(salt) };866 } finally {867 zeroKey(key);868 }869}870871872873874875876877878879export async function openIdentityKey(880 env: IdentityKeyEnvelope,881 password?: string,882): Promise<Uint8Array | null> {883 if (env.local && derivedKey) {884 const ik = await openSealedKey(derivedKey, env.local, "peach-local-identity-ik-local-v1");885 if (ik) return ik;886 }887 if (env.root && rootKey) {888 const ik = await openSealedKey(rootKey, env.root, "peach-local-identity-ik-root-v1");889 if (ik) return ik;890 }891 if (!env.pending || !env.pendingSalt || password === undefined) return null;892 let key: Uint8Array;893 try {894 const salt = unhex(env.pendingSalt);895 key = await deriveKey(password, salt);896 salt.fill(0);897 } catch {898 return null;899 }900 try {901 return await openSealedKey(key, env.pending, "peach-local-identity-ik-pending-v1");902 } finally {903 zeroKey(key);904 }905}906907908async function openSealedKey(909 key: Uint8Array,910 box: SealedBox,911 info: string,912): Promise<Uint8Array | null> {913 try {914 const value = unhex(await decrypt(key, box.nonce, box.ciphertext, box.tag, info));915 if (value.length === 32) return value;916 value.fill(0);917 return null;918 } catch {919 return null;920 }921}922923924925async function wrapRootKeyForDerivedKey(926 derivedKeyBytes: Uint8Array,927 rootKeyBytes: Uint8Array,928): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {929 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");930 return {931 pw_root_ciphertext: ciphertext,932 pw_root_nonce: nonce,933 pw_root_tag: tag,934 };935}936937938export async function getRecoveryWords(): Promise<string[]> {939 if (!rootKey) {940 throw new Error("recovery not set up");941 }942 return rootKeyToWords(rootKey);943}944945async function loadSlot(key: string): Promise<StoredVault | null> {946 const result = await chrome.storage.local.get(key);947 return (result as Record<string, StoredVault | undefined>)[key] ?? null;948}949950async function writeSlot(key: string, vault: StoredVault): Promise<void> {951 try {952 await chrome.storage.local.set({ [key]: vault });953 } catch (err) {954 throw mapStorageError(err);955 }956}957958959960961962963964965966967export async function rollbackAdoptedVault(): Promise<void> {968 await lock();969 try {970 await chrome.storage.local.remove([971 SLOT_A,972 SLOT_B,973 "peach_last_modified_at",974 ]);975 } catch (err) {976 throw mapStorageError(err);977 }978}979980async function commitCreatedVaultSlots(slotA: StoredVault, slotB: StoredVault): Promise<void> {981 const raw = await chrome.storage.local.get("peach_last_modified_at");982 const previous = Number(983 (raw as Record<string, unknown>).peach_last_modified_at,984 );985 const next = Math.max(986 Date.now(),987 Number.isFinite(previous) ? previous + 1 : 0,988 );989 try {990 await chrome.storage.local.set({991 [SLOT_A]: slotA,992 [SLOT_B]: slotB,993 peach_last_modified_at: String(next),994 });995 } catch (err) {996 throw mapStorageError(err);997 }998}99910001001100210031004100510061007let lastEntryTimestamp = 0;1008function monotonicEntryTimestamp(currentModifiedAt?: number): number {1009 const base = Math.max(1010 Date.now(),1011 (currentModifiedAt ?? 0) + 1,1012 lastEntryTimestamp + 1,1013 );1014 lastEntryTimestamp = base;1015 return base;1016}10171018async function stampLocalVaultMutation(): Promise<void> {1019 const raw = await chrome.storage.local.get("peach_last_modified_at");1020 const previous = Number(1021 (raw as Record<string, unknown>).peach_last_modified_at,1022 );1023 const next = Math.max(1024 Date.now(),1025 Number.isFinite(previous) ? previous + 1 : 0,1026 );1027 await chrome.storage.local.set({1028 peach_last_modified_at: String(next),1029 });1030}103110321033103410351036103710381039104010411042function padPlaintext(json: string, minBytes = 0): string {1043 const encoded = new TextEncoder().encode(json);1044 const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);1045 1046 const target = Math.max(bucket, minBytes);1047 const padLen = target - encoded.length;1048 return json + " ".repeat(padLen);1049}105010511052105310541055105610571058105910601061const CSPRNG_CHUNK = 65_536;10621063function fillRandom(buf: Uint8Array): Uint8Array {1064 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {1065 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);1066 const chunk = new Uint8Array(end - offset);1067 crypto.getRandomValues(chunk);1068 buf.set(chunk, offset);1069 }1070 return buf;1071}10721073function generateNoiseBlob(ciphertextBytes: number): StoredVault {1074 const salt = crypto.getRandomValues(new Uint8Array(16));1075 const nonce = crypto.getRandomValues(new Uint8Array(12));1076 const cipher = fillRandom(new Uint8Array(ciphertextBytes));1077 const tag = crypto.getRandomValues(new Uint8Array(16));1078 return {1079 version: 1,1080 salt: hex(salt),1081 nonce: hex(nonce),1082 ciphertext: hex(cipher),1083 tag: hex(tag),1084 };1085}108610871088108910901091type VaultPayload = {1092 entries: VaultEntry[];1093 tombstones?: Record<string, number>;1094 duressConfigured?: boolean;1095};10961097type DecryptedPayload = {1098 entries: VaultEntry[];1099 tombstones: Map<string, number>;1100 duressConfigured: boolean;1101};11021103function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {1104 const envelope: VaultPayload = {1105 entries,1106 tombstones: Object.fromEntries(tombstones),1107 duressConfigured: duress,1108 };1109 return padPlaintext(JSON.stringify(envelope), minBytes);1110}11111112function decodeTombstones(raw: unknown, allEntries: VaultEntry[]): Map<string, number> {1113 const result = new Map<string, number>();1114 if (raw && typeof raw === "object" && !Array.isArray(raw)) {1115 for (const [id, timestamp] of Object.entries(raw as Record<string, unknown>)) {1116 if (typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0) {1117 result.set(id, timestamp);1118 }1119 }1120 }1121 1122 1123 for (const entry of allEntries) {1124 if (typeof entry.deletedAt !== "number" || !Number.isFinite(entry.deletedAt) || entry.deletedAt <= 0) {1125 continue;1126 }1127 if ((result.get(entry.id) ?? 0) < entry.deletedAt) {1128 result.set(entry.id, entry.deletedAt);1129 }1130 }1131 return result;1132}11331134function reconcileTombstones(): void {1135 const byId = new Map(entries.map((entry) => [entry.id, entry]));1136 for (const [id] of tombstones) {1137 if (byId.has(id) && !byId.get(id)?.deletedAt) tombstones.delete(id);1138 }1139 for (const entry of entries) {1140 if (typeof entry.deletedAt === "number" && Number.isFinite(entry.deletedAt) && entry.deletedAt > 0) {1141 if ((tombstones.get(entry.id) ?? 0) < entry.deletedAt) {1142 tombstones.set(entry.id, entry.deletedAt);1143 }1144 }1145 }1146}11471148function decodePayload(plain: string): DecryptedPayload {1149 const parsed = JSON.parse(plain) as VaultPayload;1150 if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.entries)) {1151 throw new Error("invalid vault payload");1152 }1153 const normalized = parsed.entries.map(normalizeEntry);1154 return {1155 entries: normalized,1156 tombstones: decodeTombstones(parsed.tombstones, normalized),1157 duressConfigured: parsed.duressConfigured === true,1158 };1159}1160116111621163116411651166116711681169117011711172117311741175117611771178117911801181function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {1182 let merged = false;1183 const now = Date.now();11841185 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);1186 const byRpId = new Map<string, VaultEntry[]>();1187 for (const e of candidates) {1188 const rpId = e.passkey!.rpId;1189 const list = byRpId.get(rpId);1190 if (list) list.push(e);1191 else byRpId.set(rpId, [e]);1192 }11931194 for (const [rpId, group] of byRpId) {1195 if (group.length === 0) continue;1196 group.sort((a, b) => {1197 const aT =1198 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;1199 const bT =1200 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;1201 return bT - aT;1202 });1203 const winner = group[0]!;12041205 1206 1207 1208 const loginTarget = entries.find((e) => {1209 if (e.deletedAt || e.passkey || e.type !== "login") return false;1210 if (e.url && extractDomain(e.url) === rpId) return true;1211 for (const extra of e.additionalUrls ?? []) {1212 if (extractDomain(extra) === rpId) return true;1213 }1214 return false;1215 });12161217 if (loginTarget) {1218 loginTarget.passkey = winner.passkey;1219 1220 1221 1222 if (!loginTarget.totp && winner.totp) {1223 loginTarget.totp = winner.totp;1224 loginTarget.totpAlgorithm = winner.totpAlgorithm;1225 loginTarget.totpDigits = winner.totpDigits;1226 loginTarget.totpPeriod = winner.totpPeriod;1227 }1228 loginTarget.modifiedAt = now;1229 for (const dup of group) {1230 if (!dup.deletedAt) {1231 dup.deletedAt = now;1232 dup.modifiedAt = now;1233 }1234 }1235 merged = true;1236 } else if (group.length > 1) {1237 for (let i = 1; i < group.length; i += 1) {1238 const dup = group[i]!;1239 if (!dup.deletedAt) {1240 dup.deletedAt = now;1241 dup.modifiedAt = now;1242 }1243 }1244 merged = true;1245 }1246 }12471248 return { merged };1249}12501251125212531254125512561257function hasValidVaultSchema(v: unknown): v is StoredVault {1258 if (typeof v !== "object" || v === null) return false;1259 const r = v as Record<string, unknown>;1260 return (1261 r.version === 1 &&1262 typeof r.salt === "string" &&1263 typeof r.nonce === "string" &&1264 typeof r.ciphertext === "string" &&1265 typeof r.tag === "string"1266 );1267}1268const MAX_HISTORY_BLOB_BYTES = 1_024 * 1_024;12691270function hasValidHistorySlot(value: unknown): value is StoredVault {1271 if (!hasValidVaultSchema(value)) return false;1272 return /^[0-9a-f]{32}$/i.test(value.salt) &&1273 /^[0-9a-f]{24}$/i.test(value.nonce) &&1274 /^[0-9a-f]+$/i.test(value.ciphertext) &&1275 value.ciphertext.length % 2 === 0 &&1276 value.ciphertext.length / 2 <= MAX_HISTORY_BLOB_BYTES &&1277 /^[0-9a-f]{32}$/i.test(value.tag);1278}12791280function parseHistorySlot(value: string): StoredVault | null {1281 try {1282 const parsed: unknown = JSON.parse(value);1283 return hasValidHistorySlot(parsed) ? parsed : null;1284 } catch {1285 return null;1286 }1287}12881289function isHistoryRecord(value: unknown): value is VaultSnapshot {1290 if (typeof value !== "object" || value === null) return false;1291 const record = value as Record<string, unknown>;1292 return typeof record.id === "string" && record.id.length > 0 && record.id.length <= 128 &&1293 typeof record.savedAt === "number" && Number.isFinite(record.savedAt) &&1294 typeof record.reason === "string" &&1295 typeof record.vault_a === "string" && record.vault_a.length <= MAX_HISTORY_BLOB_BYTES * 2 &&1296 typeof record.vault_b === "string" && record.vault_b.length <= MAX_HISTORY_BLOB_BYTES * 2 &&1297 (record.entryCount === null || typeof record.entryCount === "number");1298}12991300function sanitizeHistory(value: unknown): VaultSnapshot[] {1301 if (typeof value !== "object" || value === null) return [];1302 if (!("snapshots" in value) || !Array.isArray(value.snapshots)) return [];1303 return value.snapshots.filter(isHistoryRecord).slice(0, HISTORY_MAX);1304}13051306export type TryDecryptResult =1307 | { status: "ok"; payload: DecryptedPayload }1308 | { status: "wrong_key" }1309 | { status: "corrupted"; reason: string };1310131113121313131413151316export async function tryDecryptSlot(1317 vault: StoredVault,1318 key: Uint8Array,1319 aadTag?: string,1320): Promise<DecryptedPayload | null> {1321 const res = await tryDecryptSlotResult(vault, key, aadTag);1322 return res.status === "ok" ? res.payload : null;1323}13241325export async function tryDecryptSlotResult(1326 vault: StoredVault,1327 key: Uint8Array,1328 aadTag?: string,1329): Promise<TryDecryptResult> {1330 if (!hasValidVaultSchema(vault)) {1331 return { status: "corrupted", reason: "invalid_slot_schema" };1332 }1333 let plain: string;1334 try {1335 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);1336 } catch {1337 return { status: "wrong_key" };1338 }1339 try {1340 const parsed = JSON.parse(plain) as unknown;1341 if (typeof parsed !== "object" || parsed === null) {1342 return { status: "corrupted", reason: "invalid_payload_shape" };1343 }1344 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {1345 return { status: "corrupted", reason: "invalid_payload_shape" };1346 }1347 return { status: "ok", payload: decodePayload(plain) };1348 } catch {1349 return { status: "corrupted", reason: "invalid_payload_json" };1350 }1351}135213531354135513561357function buildDomainIndexSync(all: VaultEntry[]): void {1358 domainIndex = new Map();13591360 for (const entry of all) {1361 if (entry.deletedAt) continue;1362 if (entry.type === "note" || entry.type === "card") continue;13631364 1365 1366 1367 1368 const domains = new Set<string>();1369 const primary = extractDomain(entry.url ?? "");1370 if (primary) domains.add(primary);1371 for (const extra of entry.additionalUrls ?? []) {1372 const d = extractDomain(extra);1373 if (d) domains.add(d);1374 }1375 if (domains.size === 0) continue;13761377 for (const domain of domains) {1378 const bucket = domainIndex.get(domain) ?? [];1379 bucket.push(entry);1380 domainIndex.set(domain, bucket);1381 }1382 }1383}13841385export async function createVault(password: string): Promise<void> {1386 const salt = generateSalt();1387 derivedKey = await deriveKey(password, salt);1388 entries = [];1389 tombstones = new Map();1390 vaultMode = "real";1391 duressConfigured = false;1392 buildDomainIndexSync(entries);13931394 1395 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =1396 await generateAndWrapRootKey();1397 rootKey = unhex(rawRootKey);13981399 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);1400 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");14011402 1403 1404 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);14051406 const slotA: StoredVault = {1407 version: 1,1408 salt: hex(salt),1409 nonce,1410 ciphertext,1411 tag,1412 recovery_salt: recoverySalt,1413 recovery_wrapped_root_key: wrappedRootKey,1414 recovery_nonce: wrappedNonce,1415 recovery_tag: wrappedTag,1416 ...pwRoot,1417 };1418 await writeSlot(SLOT_A, slotA);14191420 1421 1422 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1423 await stampLocalVaultMutation();14241425 1426 1427 await identityHooks?.onVaultCreated(password).catch((error) => {1428 console.warn("[Peach] local identity promotion failed:", error);1429 });14301431 touchAutoLock();1432}1433143414351436143714381439export async function createVaultFromRootKey(1440 password: string,1441 rootKeyHex: string,1442): Promise<{ vaultId: string }> {1443 if (typeof password !== "string" || password.length === 0) {1444 throw new Error("ADOPT_INVALID_PASSWORD");1445 }1446 if (typeof rootKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(rootKeyHex)) {1447 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars");1448 }14491450 const rootKeyBytes = unhex(rootKeyHex);1451 if (rootKeyBytes.length !== 32) {1452 rootKeyBytes.fill(0);1453 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");1454 }14551456 let nextDerivedKey: Uint8Array | null = null;1457 let sessionRootKey: Uint8Array | null = null;1458 try {1459 if ((await loadSlot(SLOT_A)) !== null) {1460 throw new Error("ADOPT_VAULT_ALREADY_EXISTS");1461 }14621463 const nextEntries: VaultEntry[] = [];1464 const nextDuressConfigured = false;1465 const vaultId = await computeVaultId(rootKeyBytes);1466 const salt = generateSalt();1467 nextDerivedKey = await deriveKey(password, salt);1468 const recovery = await wrapExistingRootKeyForRecovery(rootKeyBytes);14691470 sessionRootKey = new Uint8Array(rootKeyBytes);1471 const plaintext = encodePayload(nextEntries, nextDuressConfigured, 0);1472 const { nonce, ciphertext, tag } = await encrypt(nextDerivedKey, plaintext, "slot_a_v1");1473 const pwRoot = await wrapRootKeyForDerivedKey(nextDerivedKey, sessionRootKey);1474 const slotA: StoredVault = {1475 version: 1,1476 salt: hex(salt),1477 nonce,1478 ciphertext,1479 tag,1480 recovery_salt: recovery.recoverySalt,1481 recovery_wrapped_root_key: recovery.wrappedRootKey,1482 recovery_nonce: recovery.wrappedNonce,1483 recovery_tag: recovery.wrappedTag,1484 ...pwRoot,1485 };1486 const slotB = generateNoiseBlob(ciphertext.length / 2);1487 await commitCreatedVaultSlots(slotA, slotB);14881489 const previousDerivedKey = derivedKey;1490 const previousRootKey = rootKey;1491 derivedKey = nextDerivedKey;1492 rootKey = sessionRootKey;1493 nextDerivedKey = null;1494 sessionRootKey = null;1495 entries = nextEntries;1496 tombstones = new Map();1497 vaultMode = "real";1498 duressConfigured = nextDuressConfigured;1499 buildDomainIndexSync(entries);1500 if (previousDerivedKey && previousDerivedKey !== derivedKey) zeroKey(previousDerivedKey);1501 if (previousRootKey && previousRootKey !== rootKey) previousRootKey.fill(0);1502 touchAutoLock();15031504 1505 1506 await identityHooks?.onVaultCreated(password).catch((error) => {1507 console.warn("[Peach] local identity promotion failed:", error);1508 });15091510 return { vaultId };1511 } finally {1512 rootKeyBytes.fill(0);1513 if (nextDerivedKey) zeroKey(nextDerivedKey);1514 if (sessionRootKey) sessionRootKey.fill(0);1515 }1516}15171518export async function hasVault(): Promise<boolean> {1519 return (await loadSlot(SLOT_A)) !== null;1520}1521152215231524152515261527export async function isDuressEnabled(): Promise<boolean> {1528 return vaultMode === "real" && duressConfigured;1529}15301531export async function setupDuress(duressPassword: string): Promise<void> {1532 if (!derivedKey || vaultMode !== "real") {1533 throw new Error("vault must be unlocked in real mode");1534 }1535 1536 1537 1538 const slotA = await loadSlot(SLOT_A);1539 if (!slotA) throw new Error("missing slot A");15401541 const decoySalt = generateSalt();1542 const decoyKey = await deriveKey(duressPassword, decoySalt);1543 try {1544 1545 1546 1547 1548 const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1549 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1550 await writeSlot(SLOT_B, {1551 version: 1,1552 salt: hex(decoySalt),1553 nonce,1554 ciphertext,1555 tag,1556 });1557 } finally {1558 zeroKey(decoyKey);1559 }15601561 1562 1563 duressConfigured = true;1564 await persistSlotA();1565}15661567export async function disableDuress(): Promise<void> {1568 if (!derivedKey || vaultMode !== "real") {1569 throw new Error("vault must be unlocked in real mode");1570 }1571 1572 1573 1574 const slotA = await loadSlot(SLOT_A);1575 if (slotA) {1576 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1577 }15781579 duressConfigured = false;1580 await persistSlotA();1581}15821583158415851586158715881589export async function verifyMainPassword(password: string): Promise<boolean> {1590 const slotA = await loadSlot(SLOT_A);1591 if (!slotA) return false;1592 const key = await deriveKey(password, unhex(slotA.salt));1593 try {1594 return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1595 } finally {1596 zeroKey(key);1597 }1598}15991600export async function unlock(password: string): Promise<boolean> {1601 return attemptUnlockWithDerive(password, deriveKey);1602}16031604async function attemptUnlockWithDerive(1605 password: string,1606 derive: typeof deriveKey,1607): Promise<boolean> {1608 const epoch = vaultEpoch;1609 const stale = () => epoch !== vaultEpoch;1610 1611 1612 1613 1614 1615 1616 const [slotA, slotB] = await Promise.all([1617 loadSlot(SLOT_A),1618 loadSlot(SLOT_B),1619 ]);16201621 1622 const [keyA, keyB] = await Promise.all([1623 slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1624 slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1625 ]);16261627 1628 1629 1630 const [resultA, resultB] = await Promise.all([1631 keyA && slotA1632 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1633 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1634 keyB && slotB1635 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1636 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1637 ]);1638 const payloadA = resultA.status === "ok" ? resultA.payload : null;1639 const payloadB = resultB.status === "ok" ? resultB.payload : null;16401641 if (stale()) {1642 if (keyA) zeroKey(keyA);1643 if (keyB) zeroKey(keyB);1644 return false;1645 }16461647 if (payloadA && keyA) {1648 if (stale()) {1649 zeroKey(keyA);1650 if (keyB) zeroKey(keyB);1651 return false;1652 }1653 derivedKey = keyA;1654 vaultMode = "real";1655 entries = payloadA.entries;1656 tombstones = payloadA.tombstones;1657 duressConfigured = payloadA.duressConfigured;1658 buildDomainIndexSync(entries);1659 await loadSettings();1660 if (stale()) {1661 await lock();1662 return false;1663 }1664 touchAutoLock();1665 if (keyB) zeroKey(keyB);16661667 1668 1669 rootKey = slotA ? await deriveRootKeyFromDerivedKey(keyA, slotA) : null;1670 if (!rootKey) {1671 await lock();1672 return false;1673 }16741675 1676 1677 await reconcilePairSecrets(rootKey);1678 if (stale()) {1679 await lock();1680 return false;1681 }1682 await lockedSyncUnlockHook?.().catch((error) => {1683 console.warn("[Peach sync] locked inbox drain failed:", error);1684 });1685 if (stale()) {1686 await lock();1687 return false;1688 }1689 await resetPinFailedAttempts();1690 if (stale()) {1691 await lock();1692 return false;1693 }1694 await saveSessionKey(keyA, "real", epoch);1695 if (stale()) return false;1696 return true;1697 }16981699 if (payloadB && keyB) {1700 if (stale()) {1701 zeroKey(keyB);1702 if (keyA) zeroKey(keyA);1703 return false;1704 }1705 derivedKey = keyB;1706 vaultMode = "decoy";1707 decoySyncTeardownHook?.();1708 1709 1710 1711 if (rootKey) {1712 rootKey.fill(0);1713 rootKey = null;1714 }1715 entries = payloadB.entries;1716 tombstones = payloadB.tombstones;1717 duressConfigured = false;1718 buildDomainIndexSync(entries);1719 await loadSettings();1720 touchAutoLock();1721 if (keyA) zeroKey(keyA);1722 1723 await dropSessionCache();1724 1725 await notifyIdentitySessionEnded();1726 if (stale()) {1727 await lock();1728 return false;1729 }1730 await saveSessionKey(keyB, "decoy", epoch);1731 if (stale()) return false;1732 return true;1733 }17341735 if (keyA) zeroKey(keyA);1736 if (keyB) zeroKey(keyB);1737 1738 1739 if (resultA.status === "corrupted") {1740 lastUnlockFailureReason = resultA.reason;1741 } else if (resultB.status === "corrupted") {1742 lastUnlockFailureReason = resultB.reason;1743 }1744 return false;1745}17461747174817491750175117521753const SESSION_KEY_STORAGE = "peach_session_dk";1754175517561757let sessionKeyMutation = Promise.resolve();17581759function mutateSessionKey(mutation: () => Promise<void>): Promise<void> {1760 const task = sessionKeyMutation.then(mutation, mutation);1761 sessionKeyMutation = task.catch(() => undefined);1762 return task;1763}176417651766176717681769let sessionEscrowAvailable = true;17701771177217731774177517761777export async function applyStorageHardening(sessionEscrow: boolean): Promise<void> {1778 setSessionAllowed(sessionEscrow);1779 if (sessionEscrow) return;1780 sessionEscrowAvailable = false;1781 await clearSessionKey();1782}17831784async function saveSessionKey(1785 key: Uint8Array,1786 mode: "real" | "decoy",1787 expectedEpoch = vaultEpoch,1788): Promise<void> {1789 const session = chrome.storage.session;1790 if (!session || !sessionEscrowAvailable || expectedEpoch !== vaultEpoch) return;1791 const stored = { keyHex: hex(key), mode };1792 await mutateSessionKey(async () => {1793 if (!sessionEscrowAvailable || expectedEpoch !== vaultEpoch) return;1794 await session.set({ [SESSION_KEY_STORAGE]: stored });1795 });1796}17971798async function clearSessionKey(): Promise<void> {1799 const session = chrome.storage.session;1800 if (!session) return;1801 await mutateSessionKey(() => session.remove(SESSION_KEY_STORAGE));1802}18031804180518061807180818091810let vaultEpoch = 0;1811181218131814let restoreInFlight: Promise<boolean> | null = null;181518161817181818191820182118221823export async function restoreFromSession(): Promise<boolean> {1824 if (derivedKey !== null) return true;1825 if (restoreInFlight) return restoreInFlight;1826 const task = restoreFromSessionInner();1827 restoreInFlight = task;1828 try {1829 return await task;1830 } finally {1831 restoreInFlight = null;1832 }1833}18341835async function restoreFromSessionInner(): Promise<boolean> {1836 const epoch = vaultEpoch;1837 const stale = () => epoch !== vaultEpoch;1838 if (derivedKey !== null) return true;1839 if (!sessionEscrowAvailable) return false;1840 if (!chrome.storage.session) return false;1841 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1842 if (stale()) return false;1843 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1844 if (!entry?.keyHex) return false;18451846 const key = unhex(entry.keyHex);1847 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1848 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1849 const slot = await loadSlot(slotKey);1850 if (stale()) { zeroKey(key); return false; }1851 if (!slot) { await clearSessionKey(); return false; }18521853 const result = await tryDecryptSlotResult(slot, key, aadTag);1854 if (stale()) { zeroKey(key); return false; }1855 if (result.status !== "ok") { await clearSessionKey(); return false; }18561857 1858 1859 const payload = result.payload;1860 await loadSettings();1861 if (stale()) { zeroKey(key); return false; }18621863 1864 1865 let freshRoot: Uint8Array | null = null;1866 if (entry.mode === "real") {1867 freshRoot = await deriveRootKeyFromDerivedKey(key, slot);1868 if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1869 if (!freshRoot) {1870 await lock();1871 return false;1872 }1873 }18741875 if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1876 if (freshRoot) {1877 1878 await reconcilePairSecrets(freshRoot);1879 if (stale()) { zeroKey(key); zeroKey(freshRoot); return false; }1880 } else {1881 1882 await dropSessionCache();1883 1884 await notifyIdentitySessionEnded();1885 if (stale()) { zeroKey(key); return false; }1886 }1887 derivedKey = key;1888 vaultMode = entry.mode;1889 if (entry.mode === "decoy") decoySyncTeardownHook?.();1890 entries = payload.entries;1891 tombstones = payload.tombstones;1892 duressConfigured = entry.mode === "real" ? payload.duressConfigured : false;1893 if (freshRoot) rootKey = freshRoot;1894 buildDomainIndexSync(entries);1895 touchAutoLock();18961897 await lockedSyncUnlockHook?.().catch((error) => {1898 console.warn("[Peach sync] locked inbox drain failed:", error);1899 });1900 if (stale()) return false;19011902 return true;1903}19041905export async function lock(): Promise<void> {1906 1907 1908 1909 1910 1911 1912 1913 1914 1915 vaultEpoch++;1916 if (derivedKey) {1917 zeroKey(derivedKey);1918 derivedKey = null;1919 }1920 if (rootKey) {1921 rootKey.fill(0);1922 rootKey = null;1923 }19241925 entries = [];1926 tombstones = new Map();1927 domainIndex = new Map();1928 vaultMode = "real";1929 duressConfigured = false;1930 clearAutoLock();1931 await clearSessionKey();1932 1933 1934 await notifyIdentitySessionEnded();1935 await clearRecoveryConfirmPending();1936}19371938export function isUnlocked(): boolean {1939 return derivedKey !== null;1940}19411942export function isDecoyMode(): boolean {1943 return derivedKey !== null && vaultMode === "decoy";1944}19451946194719481949export async function unlockWithKey(1950 masterKeyHex: string,1951): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1952 const epoch = vaultEpoch;1953 const stale = () => epoch !== vaultEpoch;1954 let masterKeyBytes: Uint8Array | null = null;1955 try {1956 masterKeyBytes = unhex(masterKeyHex);1957 const stored = await loadSlot(SLOT_A);1958 if (!stored) return { ok: false, error: "invalid_key" };1959 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1960 if (!payload || stale()) {1961 zeroKey(masterKeyBytes);1962 return { ok: false, error: "invalid_key" };1963 }19641965 derivedKey = masterKeyBytes;1966 vaultMode = "real";1967 entries = payload.entries;1968 tombstones = payload.tombstones;1969 duressConfigured = payload.duressConfigured;1970 buildDomainIndexSync(entries);1971 await loadSettings();1972 touchAutoLock();19731974 rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1975 if (!rootKey) {1976 await lock();1977 return { ok: false, error: "invalid_key" };1978 }1979 await reconcilePairSecrets(rootKey);1980 if (stale()) {1981 await lock();1982 return { ok: false, error: "invalid_key" };1983 }1984 await saveSessionKey(masterKeyBytes, "real", epoch);1985 if (stale()) return { ok: false, error: "invalid_key" };1986 return { ok: true, entries };1987 } catch {1988 if (masterKeyBytes && derivedKey !== masterKeyBytes) zeroKey(masterKeyBytes);1989 return { ok: false, error: "invalid_key" };1990 }1991}199219931994199519961997199819992000export async function exportDuressSlotForAdoption(): Promise<string | null> {2001 if (!(await isDuressEnabled())) return null;2002 const slotB = await loadSlot(SLOT_B);2003 if (!slotB) return null;2004 return JSON.stringify(slotB);2005}20062007200820092010201120122013export async function installAdoptedDuressSlot(json: string): Promise<void> {2014 let parsed: StoredVault;2015 try {2016 const raw = JSON.parse(json) as unknown;2017 if (!hasValidVaultSchema(raw)) {2018 throw new Error("invalid_duress_slot_shape");2019 }2020 parsed = raw;2021 } catch (err) {2022 if (err instanceof SyntaxError) {2023 throw new Error("invalid_duress_slot_shape");2024 }2025 throw err;2026 }2027 await writeSlot(SLOT_B, parsed);2028}20292030203120322033203420352036203720382039204020412042204320442045204620472048export async function unlockFromCodex(params: {2049 slotA: string; 2050 slotB: string | null; 2051 timestamp: number;2052 masterPassphrase: string;2053}): Promise<2054 | { ok: true; entryCount: number }2055 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" | "missing_root_escrow" }2056> {2057 let parsedSlotA: StoredVault;2058 let parsedSlotB: StoredVault | null = null;2059 try {2060 const rawA = JSON.parse(params.slotA) as unknown;2061 if (!hasValidVaultSchema(rawA)) {2062 return { ok: false, reason: "invalid_slot_format" };2063 }2064 parsedSlotA = rawA;2065 if (params.slotB !== null) {2066 const rawB = JSON.parse(params.slotB) as unknown;2067 if (!hasValidVaultSchema(rawB)) {2068 return { ok: false, reason: "invalid_slot_format" };2069 }2070 parsedSlotB = rawB;2071 }2072 } catch {2073 return { ok: false, reason: "invalid_slot_format" };2074 }20752076 2077 2078 2079 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));2080 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");2081 if (!trialPayload) {2082 zeroKey(trialKey);2083 return { ok: false, reason: "wrong_passphrase" };2084 }2085 const hasEscrow =2086 !!parsedSlotA.pw_root_ciphertext && !!parsedSlotA.pw_root_nonce && !!parsedSlotA.pw_root_tag;2087 if (!hasEscrow) {2088 zeroKey(trialKey);2089 return { ok: false, reason: "missing_root_escrow" };2090 }2091 const stagedRootKey = await deriveRootKeyFromDerivedKey(trialKey, parsedSlotA);2092 if (!stagedRootKey) {2093 zeroKey(trialKey);2094 return { ok: false, reason: "wrong_passphrase" };2095 }2096 zeroKey(stagedRootKey);2097 zeroKey(trialKey);20982099 2100 const stagedSlotB = parsedSlotB ?? generateNoiseBlob(parsedSlotA.ciphertext.length / 2);21012102 2103 21042105 2106 2107 await takeVaultSnapshot("restore");2108 const previousSlotA = await loadSlot(SLOT_A);2109 const previousSlotB = await loadSlot(SLOT_B);2110 let slotACommitted = false;2111 let slotBCommitted = false;2112 try {2113 await writeSlot(SLOT_A, parsedSlotA);2114 slotACommitted = true;2115 await writeSlot(SLOT_B, stagedSlotB);2116 slotBCommitted = true;2117 await chrome.storage.local.set({2118 peach_last_modified_at: String(params.timestamp),2119 });2120 } catch (err) {2121 if (slotBCommitted && previousSlotB) await writeSlot(SLOT_B, previousSlotB);2122 if (slotACommitted && previousSlotA) await writeSlot(SLOT_A, previousSlotA);2123 throw err;2124 }21252126 2127 2128 const ok = await unlock(params.masterPassphrase);2129 if (!ok) {2130 2131 return { ok: false, reason: "wrong_passphrase" };2132 }2133 return { ok: true, entryCount: getEntries().length };2134}21352136213721382139export function getDerivedKeyHex(): string {2140 if (!derivedKey) throw new Error("vault_not_unlocked");2141 return hex(derivedKey);2142}21432144214521462147214821492150215121522153215421552156215721582159export function getRootKeyHex(): string | null {2160 return rootKey ? hex(rootKey) : null;2161}21622163export function getEntries(): VaultEntry[] {2164 if (!derivedKey) throw new Error("vault_locked");2165 return entries.filter((entry) => !entry.deletedAt);2166}21672168const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;21692170export function getIdentities(): SavedIdentity[] {2171 if (!derivedKey) throw new Error("vault_locked");2172 const active = entries.filter((e) => !e.deletedAt && e.type === "login");21732174 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();2175 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();21762177 for (const entry of active) {2178 const domain = extractDomain(entry.url ?? "") ?? "";2179 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;21802181 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {2182 const existing = map.get(v);2183 if (!existing || modifiedAt > existing.lastUsed) {2184 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });2185 } else {2186 map.set(v, { ...existing, count: existing.count + 1 });2187 }2188 }21892190 const uVal = entry.username?.trim();2191 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);21922193 2194 2195 const eVal = entry.email?.trim();2196 if (eVal) tally(emailMap, eVal);2197 }21982199 const toIdentity = (type: "email" | "username") =>2200 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({2201 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,2202 });22032204 return [2205 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),2206 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),2207 ];2208}22092210export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {2211 if (!derivedKey) throw new Error("vault_locked");22122213 const now = Date.now();2214 const added: VaultEntry[] = [];2215 for (const draft of drafts) {2216 try {2217 const sanitized = sanitizeEntryDraft(draft);2218 const nextEntry: VaultEntry = {2219 ...sanitized,2220 id: crypto.randomUUID(),2221 createdAt: now,2222 modifiedAt: monotonicEntryTimestamp(),2223 };2224 entries.push(nextEntry);2225 added.push(nextEntry);2226 } catch {2227 2228 }2229 }22302231 if (added.length > 0) {2232 await persistEntries();2233 }22342235 return added;2236}223722382239224022412242224322442245export async function restoreEntries(2246 incoming: VaultEntry[],2247 mode: "replace" | "merge"2248): Promise<{ added: number; updated: number; skipped: number }> {2249 if (!derivedKey) throw new Error("vault_locked");22502251 2252 2253 2254 if (mode === "replace" && incoming.length === 0) {2255 throw new Error("refuse to replace with empty entry set");2256 }22572258 let added = 0;2259 let updated = 0;2260 let skipped = 0;22612262 if (mode === "replace") {2263 entries = [];2264 for (const entry of incoming) {2265 const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));2266 if (!normalized) {2267 skipped += 1;2268 continue;2269 }2270 entries.push(normalized);2271 if (normalized.deletedAt) {2272 tombstones.set(2273 normalized.id,2274 Math.max(tombstones.get(normalized.id) ?? 0, normalized.deletedAt),2275 );2276 } else {2277 tombstones.delete(normalized.id);2278 }2279 added += 1;2280 }2281 } else {2282 const byId = new Map<string, VaultEntry>();2283 for (const e of entries) byId.set(e.id, e);22842285 for (const entry of incoming) {2286 const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));2287 if (!normalized) {2288 skipped += 1;2289 continue;2290 }2291 const existing = byId.get(normalized.id);2292 if (!existing) {2293 entries.push(normalized);2294 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);2295 else tombstones.delete(normalized.id);2296 added += 1;2297 } else if (normalized.modifiedAt > existing.modifiedAt) {2298 const idx = entries.findIndex((e) => e.id === existing.id);2299 if (idx !== -1) {2300 entries[idx] = normalized;2301 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);2302 else tombstones.delete(normalized.id);2303 updated += 1;2304 }2305 } else {2306 skipped += 1;2307 }2308 }2309 }23102311 await persistEntries("import");2312 return { added, updated, skipped };2313}231423152316231723182319232023212322232323242325232623272328232923302331const ENTRY_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;23322333function naturalizeRestoredId(entry: VaultEntry | null): VaultEntry | null {2334 if (!entry || ENTRY_ID_SHAPE.test(entry.id)) return entry;2335 return { ...entry, id: crypto.randomUUID() };2336}233723382339234023412342234323442345function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {2346 if (!entry || typeof entry !== "object") return null;2347 const title = typeof entry.title === "string" ? entry.title.trim() : "";2348 if (!title) return null;2349 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();2350 const now = Date.now();2351 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;2352 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;23532354 2355 2356 const normalized = normalizeEntry({2357 ...entry,2358 id,2359 title,2360 type: entry.type ?? "login",2361 createdAt,2362 modifiedAt,2363 });23642365 if (normalized.passkey) {2366 normalized.passkey = sanitizePasskey(normalized.passkey);2367 }2368 if (normalized.canary) {2369 normalized.canary = sanitizeCanary(normalized.canary);2370 }2371 2372 normalized.tags = sanitizeTags(normalized.tags);23732374 2375 2376 if (2377 normalized.type === "login" &&2378 !normalized.passkey &&2379 typeof normalized.password !== "string"2380 ) {2381 return null;2382 }2383 if (entry.passkey && !normalized.passkey) {2384 2385 2386 return null;2387 }23882389 return normalized;2390}2391239223932394239523962397function sanitizePasskey(pk: unknown): PasskeyData | undefined {2398 if (!pk || typeof pk !== "object") return undefined;2399 const r = pk as Record<string, unknown>;2400 if (2401 typeof r.rpId !== "string" || !r.rpId ||2402 typeof r.credentialId !== "string" || !r.credentialId ||2403 typeof r.publicKeyCose !== "string" ||2404 typeof r.userHandle !== "string" ||2405 typeof r.userName !== "string"2406 ) {2407 return undefined;2408 }2409 return {2410 rpId: r.rpId,2411 rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,2412 credentialId: r.credentialId,2413 publicKeyCose: r.publicKeyCose,2414 privateKeyJwk:2415 r.privateKeyJwk && typeof r.privateKeyJwk === "object"2416 ? (r.privateKeyJwk as JsonWebKey)2417 : undefined,2418 userHandle: r.userHandle,2419 userName: r.userName,2420 userDisplayName:2421 typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,2422 algorithm: r.algorithm === -8 ? -8 : -7,2423 signCount: typeof r.signCount === "number" ? r.signCount : 0,2424 aaguid: typeof r.aaguid === "string" ? r.aaguid : "",2425 backupEligible: r.backupEligible === true,2426 backupState: r.backupState === true,2427 lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,2428 browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,2429 transports: Array.isArray(r.transports)2430 ? r.transports.filter((t): t is string => typeof t === "string")2431 : undefined,2432 createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),2433 };2434}24352436243724382439244024412442244324442445function sanitizeCanary(c: unknown): Canary | undefined {2446 if (!c || typeof c !== "object") return undefined;2447 const r = c as Record<string, unknown>;2448 if (typeof r.webhookUrl !== "string") return undefined;2449 let url: URL;2450 try {2451 url = new URL(r.webhookUrl);2452 } catch {2453 return undefined;2454 }2455 if (url.protocol !== "https:") return undefined;2456 return {2457 webhookUrl: url.toString(),2458 label: typeof r.label === "string" ? r.label : undefined,2459 lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,2460 pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,2461 };2462}24632464246524662467246824692470function sanitizeTags(tags: unknown): string[] | undefined {2471 if (!Array.isArray(tags)) return undefined;2472 const out: string[] = [];2473 for (const t of tags) {2474 if (typeof t !== "string") continue;2475 const trimmed = t.trim();2476 if (!trimmed) continue;2477 out.push(trimmed.slice(0, 256));2478 if (out.length >= 255) break;2479 }2480 return out.length > 0 ? out : undefined;2481}248224832484248524862487export async function clearVaultEntries(2488 confirmation: "REPLACE_ALL"2489): Promise<void> {2490 if (confirmation !== "REPLACE_ALL") {2491 throw new Error("clearVaultEntries requires explicit confirmation");2492 }2493 if (!derivedKey) throw new Error("vault_locked");2494 for (const entry of entries) {2495 if (!entry.deletedAt) {2496 const stamp = monotonicEntryTimestamp(Math.max(2497 entry.modifiedAt,2498 tombstones.get(entry.id) ?? 0,2499 ));2500 entry.deletedAt = stamp;2501 entry.modifiedAt = stamp;2502 tombstones.set(entry.id, stamp);2503 }2504 }2505 await persistEntries();2506}25072508export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {2509 if (!derivedKey) throw new Error("vault_locked");25102511 const sanitized = sanitizeEntryDraft(entry);2512 const now = Date.now();2513 const stamp = monotonicEntryTimestamp();2514 const nextEntry: VaultEntry = {2515 ...sanitized,2516 id: crypto.randomUUID(),2517 createdAt: now,2518 modifiedAt: stamp,2519 2520 2521 passwordChangedAt: sanitized.password ? stamp : undefined,2522 };25232524 2525 2526 2527 const rollback = entries;2528 entries = [...entries, nextEntry];2529 try {2530 await persistEntries();2531 } catch (err) {2532 entries = rollback;2533 throw err;2534 }25352536 2537 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {2538 checkBreach(nextEntry.password).then(async (count) => {2539 if (count > 0) {2540 const idx = entries.findIndex((e) => e.id === nextEntry.id);2541 if (idx !== -1) {2542 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };2543 await persistEntries();2544 }2545 }2546 });2547 }25482549 fireSyncTrigger("vault_change");25502551 return nextEntry;2552}25532554255525562557255825592560export async function consolidatePasskeyEntries(): Promise<void> {2561 if (!derivedKey) return;2562 const { merged } = mergePasskeyOnlyIntoLogins(entries);2563 if (merged) {2564 await persistEntries();2565 }2566}25672568export async function updateEntry(2569 id: string,2570 patch: Partial<VaultEntry>2571): Promise<void> {2572 if (!derivedKey) throw new Error("vault_locked");25732574 const idx = entries.findIndex((entry) => entry.id === id);2575 if (idx === -1 || entries[idx].deletedAt) {2576 throw new Error("entry_not_found");2577 }25782579 const current = entries[idx];2580 const sanitizedPatch = sanitizeEntryPatch(patch);2581 2582 2583 2584 const passwordChanged =2585 "password" in patch &&2586 typeof sanitizedPatch.password === "string" &&2587 sanitizedPatch.password !== current.password;2588 const stamp = monotonicEntryTimestamp(current.modifiedAt);2589 2590 2591 2592 2593 2594 2595 2596 const priorPassword = current.password;2597 const shouldCapture =2598 passwordChanged &&2599 typeof priorPassword === "string" &&2600 priorPassword.length > 0 &&2601 current.passwordHistory?.[0]?.password !== priorPassword;2602 const nextHistory = shouldCapture2603 ? [2604 { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },2605 ...(current.passwordHistory ?? []),2606 ].slice(0, PASSWORD_HISTORY_MAX)2607 : current.passwordHistory;2608 entries[idx] = {2609 ...current,2610 ...sanitizedPatch,2611 modifiedAt: stamp,2612 ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),2613 };2614261526162617 2618 if (entries[idx].type === "card" && !entries[idx].cardNumber) {2619 entries[idx] = current;2620 throw new Error("card number is required for card entries");2621 }26222623 2624 2625 2626 2627 try {2628 await persistEntries();2629 } catch (err) {2630 entries[idx] = current;2631 throw err;2632 }26332634 2635 2636 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {2637 entries[idx] = { ...entries[idx], breach: undefined };2638 await persistEntries();2639 const password = entries[idx].password;2640 if (password) {2641 checkBreach(password).then(async (count) => {2642 const i = entries.findIndex((e) => e.id === id);2643 if (i !== -1 && count > 0) {2644 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };2645 await persistEntries();2646 }2647 });2648 }2649 }26502651 fireSyncTrigger("vault_change");2652}26532654export async function deleteEntry(id: string): Promise<void> {2655 if (!derivedKey) throw new Error("vault_locked");26562657 const entry = entries.find((item) => item.id === id);2658 if (!entry || entry.deletedAt) {2659 throw new Error("entry_not_found");2660 }26612662 2663 2664 2665 const previousModifiedAt = entry.modifiedAt;2666 const previousTombstone = tombstones.get(id);2667 const stamp = monotonicEntryTimestamp(Math.max(entry.modifiedAt, previousTombstone ?? 0));2668 entry.deletedAt = stamp;2669 entry.modifiedAt = stamp;2670 tombstones.set(id, stamp);2671 2672 2673 try {2674 await persistEntries();2675 } catch (err) {2676 delete entry.deletedAt;2677 entry.modifiedAt = previousModifiedAt;2678 if (previousTombstone === undefined) tombstones.delete(id);2679 else tombstones.set(id, previousTombstone);2680 throw err;2681 }2682 fireSyncTrigger("vault_change");2683}26842685export function getDeletedEntries(): VaultEntry[] {2686 if (!derivedKey) return [];2687 return entries.filter((e) => !!e.deletedAt);2688}268926902691export function getTombstoneManifest(): Record<string, number> {2692 if (!derivedKey) return {};2693 return Object.fromEntries(tombstones);2694}2695269626972698269927002701export async function getVaultVersion(): Promise<number> {2702 try {2703 const raw = await chrome.storage.local.get("peach_last_modified_at");2704 const v = (raw as Record<string, unknown>).peach_last_modified_at;2705 if (typeof v === "number") return v;2706 if (typeof v === "string" && v !== "") {2707 const n = Number.parseInt(v, 10);2708 return Number.isFinite(n) ? n : 0;2709 }2710 return 0;2711 } catch {2712 return 0;2713 }2714}2715271627172718271927202721272227232724272527262727export async function applyMergedRemoteEntries(2728 remoteEntries: VaultEntry[],2729 remoteTombstones: Record<string, number>,2730): Promise<{ received: number; applied: number; conflicts: number }> {2731 2732 2733 2734 2735 2736 2737 if (!derivedKey || vaultMode === "decoy") throw new Error("vault_locked");27382739 const received = remoteEntries.length + Object.keys(remoteTombstones).length;2740 let applied = 0;2741 let conflicts = 0;27422743 const byId = new Map<string, VaultEntry>();2744 for (const e of entries) byId.set(e.id, e);27452746 const mergeNow = Date.now();2747 const remoteTombstoneTimes = new Map<string, number>();2748 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2749 if (typeof rawDeletedAt === "number" && Number.isFinite(rawDeletedAt) && rawDeletedAt > 0) {2750 remoteTombstoneTimes.set(id, clampSyncTimestamp(rawDeletedAt, mergeNow));2751 }2752 }27532754 for (const remote of remoteEntries) {2755 const sanitized = sanitizeRestoredEntry(remote);2756 if (!sanitized) continue;2757 2758 2759 2760 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2761 if (typeof sanitized.deletedAt === "number") {2762 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2763 }2764 const remoteTombstoneTs = remoteTombstoneTimes.get(sanitized.id);2765 const localTombstoneTs = tombstones.get(sanitized.id);2766 const tombstoneTs = Math.max(remoteTombstoneTs ?? 0, localTombstoneTs ?? 0);2767 if (tombstoneTs > 0 && tombstoneTs >= sanitized.modifiedAt) {2768 continue;2769 }2770 const local = byId.get(sanitized.id);2771 if (!local) {2772 entries.push(sanitized);2773 byId.set(sanitized.id, sanitized);2774 tombstones.delete(sanitized.id);2775 applied += 1;2776 } else if (sanitized.modifiedAt > local.modifiedAt) {2777 const idx = entries.findIndex((e) => e.id === local.id);2778 if (idx !== -1) {2779 entries[idx] = sanitized;2780 byId.set(sanitized.id, sanitized);2781 tombstones.delete(sanitized.id);2782 applied += 1;2783 if (!local.deletedAt) conflicts += 1;2784 }2785 }2786 }27872788 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2789 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt) || rawDeletedAt <= 0) continue;2790 2791 const deletedAt = remoteTombstoneTimes.get(id)!;2792 const knownTombstone = tombstones.get(id);2793 if (knownTombstone !== undefined && knownTombstone >= deletedAt) continue;2794 const local = byId.get(id);2795 if (!local) {2796 tombstones.set(id, deletedAt);2797 applied += 1;2798 continue;2799 }2800 if (local.deletedAt && local.deletedAt >= deletedAt) continue;2801 if (deletedAt < local.modifiedAt) continue;2802 const idx = entries.findIndex((e) => e.id === id);2803 if (idx !== -1) {2804 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2805 tombstones.set(id, deletedAt);2806 applied += 1;2807 }2808 }28092810 if (applied > 0 && vaultMode === "real") {2811 await persistEntries("sync_received");2812 buildDomainIndexSync(entries);2813 2814 2815 2816 2817 try {2818 void chrome.runtime2819 .sendMessage({ type: "VAULT_ENTRIES_CHANGED" })2820 .catch(() => undefined);2821 } catch {2822 2823 }2824 }2825 return { received, applied, conflicts };2826}28272828export async function restoreEntry(id: string): Promise<void> {2829 if (!derivedKey) throw new Error("vault_locked");2830 const entry = entries.find((e) => e.id === id && e.deletedAt);2831 if (!entry) throw new Error("deleted entry not found");2832 const previousDeletedAt = entry.deletedAt;2833 const previousModifiedAt = entry.modifiedAt;2834 const previousTombstone = tombstones.get(id);2835 delete entry.deletedAt;2836 entry.modifiedAt = monotonicEntryTimestamp(Math.max(2837 entry.modifiedAt,2838 previousTombstone ?? 0,2839 ));2840 tombstones.delete(id);2841 try {2842 await persistEntries();2843 } catch (err) {2844 entry.deletedAt = previousDeletedAt;2845 entry.modifiedAt = previousModifiedAt;2846 if (previousTombstone !== undefined) tombstones.set(id, previousTombstone);2847 throw err;2848 }2849 buildDomainIndexSync(entries);2850}28512852export async function purgeEntry(id: string): Promise<void> {2853 if (!derivedKey) throw new Error("vault_locked");2854 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2855 if (idx === -1) throw new Error("deleted entry not found");2856 const entry = entries[idx]!;2857 tombstones.set(id, Math.max(tombstones.get(id) ?? 0, entry.deletedAt!));2858 entries.splice(idx, 1);2859 await persistEntries();2860}286128622863export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2864 if (!derivedKey || retentionMs === 0) return 0;2865 const cutoff = Date.now() - retentionMs;2866 const before = entries.length;2867 for (const entry of entries) {2868 if (entry.deletedAt && entry.deletedAt < cutoff) {2869 tombstones.set(entry.id, Math.max(tombstones.get(entry.id) ?? 0, entry.deletedAt));2870 }2871 }2872 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2873 const purged = before - entries.length;2874 if (purged > 0) await persistEntries();2875 return purged;2876}28772878export function queryDomain(domain: string): VaultEntry[] {2879 return domainIndex.get(domain) ?? [];2880}28812882export function getEntryById(id: string): VaultEntry | undefined {2883 return entries.find((entry) => entry.id === id && !entry.deletedAt);2884}28852886async function persistSlotA(2887 reason: VaultSnapshot["reason"] = "local_save",2888): Promise<StoredVault> {2889 if (!derivedKey) throw new Error("vault_locked");2890 const epoch = vaultEpoch;2891 const throwIfStale = () => {2892 if (epoch !== vaultEpoch || !derivedKey) throw new Error("vault_locked");2893 };28942895 const slotA = await loadSlot(SLOT_A);2896 throwIfStale();2897 if (!slotA) throw new Error("missing slot A");28982899 2900 2901 await takeVaultSnapshot(reason);2902 throwIfStale();29032904 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2905 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");2906 throwIfStale();29072908 2909 const nextSlotA: StoredVault = {2910 ...slotA,2911 nonce,2912 ciphertext,2913 tag,2914 2915 };29162917 2918 2919 2920 2921 2922 2923 let slotACommitted = false;2924 try {2925 throwIfStale();2926 await writeSlot(SLOT_A, nextSlotA);2927 slotACommitted = true;2928 throwIfStale();29292930 2931 2932 2933 if (!duressConfigured) {2934 throwIfStale();2935 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2936 throwIfStale();2937 }2938 2939 2940 2941 2942 2943 if (reason !== "sync_received") {2944 throwIfStale();2945 await stampLocalVaultMutation();2946 throwIfStale();2947 }2948 } catch (err) {2949 2950 2951 if (slotACommitted) await writeSlot(SLOT_A, slotA).catch(() => {});2952 if (epoch !== vaultEpoch || !derivedKey) throw new Error("vault_locked");2953 throw err;2954 }29552956 return nextSlotA;2957}29582959async function persistSlotB(): Promise<void> {2960 if (!derivedKey) throw new Error("vault_locked");29612962 const slotB = await loadSlot(SLOT_B);2963 if (!slotB) throw new Error("missing slot B");29642965 2966 2967 2968 2969 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2970 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");29712972 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2973 2974 2975 2976 try {2977 await stampLocalVaultMutation();2978 } catch (err) {2979 await writeSlot(SLOT_B, slotB).catch(() => {});2980 throw err;2981 }2982}29832984async function persistEntries(2985 reason: VaultSnapshot["reason"] = "local_save",2986): Promise<void> {2987 if (!derivedKey) throw new Error("vault_locked");29882989 2990 2991 const previousTombstones = tombstones;2992 tombstones = new Map(tombstones);2993 reconcileTombstones();2994 try {2995 if (vaultMode === "decoy") {2996 await persistSlotB();2997 } else {2998 await persistSlotA(reason);2999 }3000 } catch (err) {3001 tombstones = previousTombstones;3002 throw err;3003 }3004 buildDomainIndexSync(entries);3005 await refreshLockedSyncState();3006}3007300830093010301130123013function allowHttpUrl(url: string | undefined): string {3014 const trimmed = url?.trim() ?? "";3015 if (!trimmed) return "";3016 try {3017 const parsed = new URL(trimmed);3018 if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return "";3019 } catch {3020 3021 }3022 return trimmed;3023}30243025function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {3026 if (!Array.isArray(values)) return undefined;3027 const seen = new Set<string>();3028 const cleaned: string[] = [];3029 for (const value of values) {3030 const trimmed = typeof value === "string" ? value.trim() : "";3031 if (!trimmed) continue;3032 if (!allowHttpUrl(trimmed)) continue;3033 const key = trimmed.toLowerCase();3034 if (seen.has(key)) continue;3035 seen.add(key);3036 cleaned.push(trimmed);3037 }3038 return cleaned.length > 0 ? cleaned : undefined;3039}30403041function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {3042 const title = entry.title.trim();3043 const type: VaultEntry["type"] = entry.type ?? "login";30443045 if (!title) throw new Error("title is required");30463047 if (entry.passkey) {3048 const p = entry.passkey;3049 if (3050 !p.rpId ||3051 !p.credentialId ||3052 !p.publicKeyCose ||3053 !p.userHandle ||3054 !p.userName ||3055 (p.algorithm !== -7 && p.algorithm !== -8)3056 ) {3057 throw new Error("invalid passkey credential");3058 }3059 }3060 if (type === "card") {3061 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {3062 throw new Error("cardholder name and card number are required");3063 }3064 }30653066 return {3067 type,3068 title,3069 username: type === "login" ? entry.username?.trim() ?? "" : undefined,3070 email: type === "login" ? entry.email?.trim() || undefined : undefined,3071 password: type === "login" ? entry.password : undefined,3072 url: type === "login" ? allowHttpUrl(entry.url) : undefined,3073 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,3074 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,3075 3076 3077 totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,3078 totpDigits: type === "login" ? entry.totpDigits : undefined,3079 totpPeriod: type === "login" ? entry.totpPeriod : undefined,3080 notes: entry.notes?.trim() || undefined,3081 breach: type === "login" ? entry.breach : undefined,3082 3083 3084 canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,3085 tags: sanitizeTags(entry.tags),3086 passkey: entry.passkey,3087 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,3088 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,3089 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,3090 expYear: type === "card" ? entry.expYear?.trim() : undefined,3091 cvc: type === "card" ? entry.cvc?.trim() : undefined,3092 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,3093 network: type === "card" ? entry.network : undefined,3094 last4: type === "card" ? entry.last4?.trim() : undefined,3095 };3096}30973098function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {3099 const nextPatch: Partial<VaultEntry> = {};31003101 if ("type" in patch) {3102 nextPatch.type = patch.type;3103 }31043105 if ("title" in patch) {3106 const title = patch.title?.trim() ?? "";3107 if (!title) throw new Error("title is required");3108 nextPatch.title = title;3109 }31103111 if ("url" in patch) {3112 nextPatch.url = allowHttpUrl(patch.url);3113 }31143115 if ("additionalUrls" in patch) {3116 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];3117 const seen = new Set<string>();3118 const cleaned: string[] = [];3119 for (const value of incoming) {3120 const trimmed = typeof value === "string" ? value.trim() : "";3121 if (!trimmed || !allowHttpUrl(trimmed)) continue;3122 const key = trimmed.toLowerCase();3123 if (seen.has(key)) continue;3124 seen.add(key);3125 cleaned.push(trimmed);3126 }3127 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;3128 }31293130 if ("username" in patch) {3131 nextPatch.username = patch.username?.trim() ?? "";3132 }31333134 if ("email" in patch) {3135 nextPatch.email = patch.email?.trim() || undefined;3136 }31373138 if ("password" in patch) {3139 const password = patch.password ?? "";3140 3141 nextPatch.password = password || undefined;3142 }31433144 if ("totp" in patch) {3145 nextPatch.totp = patch.totp?.trim() || undefined;3146 }31473148 if ("totpAlgorithm" in patch) {3149 nextPatch.totpAlgorithm = patch.totpAlgorithm;3150 }31513152 if ("totpDigits" in patch) {3153 nextPatch.totpDigits = patch.totpDigits;3154 }31553156 if ("totpPeriod" in patch) {3157 nextPatch.totpPeriod = patch.totpPeriod;3158 }31593160 if ("notes" in patch) {3161 nextPatch.notes = patch.notes?.trim() || undefined;3162 }31633164 if ("breach" in patch) {3165 nextPatch.breach = patch.breach;3166 }31673168 if ("canary" in patch) {3169 3170 3171 nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;3172 }31733174 if ("passkey" in patch) {3175 3176 3177 if (patch.passkey === undefined || patch.passkey === null) {3178 nextPatch.passkey = undefined;3179 } else {3180 const sanitized = sanitizePasskey(patch.passkey);3181 if (!sanitized) throw new Error("invalid passkey credential");3182 nextPatch.passkey = sanitized;3183 }3184 }31853186 if ("tags" in patch) {3187 3188 nextPatch.tags = sanitizeTags(patch.tags);3189 }31903191 if ("cardholderName" in patch) {3192 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;3193 }3194 if ("cardNumber" in patch) {3195 const n = patch.cardNumber ?? "";3196 nextPatch.cardNumber = n || undefined;3197 }3198 if ("expMonth" in patch) {3199 nextPatch.expMonth = patch.expMonth?.trim() || undefined;3200 }3201 if ("expYear" in patch) {3202 nextPatch.expYear = patch.expYear?.trim() || undefined;3203 }3204 if ("cvc" in patch) {3205 nextPatch.cvc = patch.cvc?.trim() || undefined;3206 }3207 if ("cardNickname" in patch) {3208 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;3209 }3210 if ("network" in patch) {3211 nextPatch.network = patch.network;3212 }3213 if ("last4" in patch) {3214 nextPatch.last4 = patch.last4?.trim() || undefined;3215 }32163217 return nextPatch;3218}32193220function normalizeEntry(entry: VaultEntry): VaultEntry {3221 const base: VaultEntry = {3222 id: entry.id,3223 type: entry.type,3224 title: entry.title,3225 username: entry.username,3226 email: entry.email,3227 password: entry.password,3228 url: entry.url,3229 additionalUrls:3230 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)3231 ? entry.additionalUrls3232 : undefined,3233 totp: entry.totp,3234 3235 totpAlgorithm: entry.totpAlgorithm,3236 totpDigits: entry.totpDigits,3237 totpPeriod: entry.totpPeriod,3238 notes: entry.notes,3239 breach: entry.breach,3240 3241 3242 3243 passwordChangedAt:3244 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,3245 3246 3247 3248 passwordHistory:3249 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)3250 ? entry.passwordHistory3251 : undefined,3252 canary: entry.canary,3253 passkey: entry.passkey,3254 createdAt: entry.createdAt,3255 modifiedAt: entry.modifiedAt,3256 deletedAt: entry.deletedAt,3257 tags: entry.tags,3258 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,3259 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,3260 expMonth: "expMonth" in entry ? entry.expMonth : undefined,3261 expYear: "expYear" in entry ? entry.expYear : undefined,3262 cvc: "cvc" in entry ? entry.cvc : undefined,3263 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,3264 network: "network" in entry ? entry.network : undefined,3265 last4: "last4" in entry ? entry.last4 : undefined,3266 };32673268 return base;3269}3270327132723273327432753276function entryDupeKey(e: VaultEntry): string {3277 if (e.type === "note") return `note|${e.title.toLowerCase()}`;3278 3279 3280 3281 3282 if (e.passkey) {3283 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;3284 }3285 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;3286}328732883289export function countDuplicates(): number {3290 const best = new Map<string, VaultEntry>();3291 let dupeCount = 0;3292 for (const e of entries.filter((e) => !e.deletedAt)) {3293 const key = entryDupeKey(e);3294 const existing = best.get(key);3295 if (!existing) {3296 best.set(key, e);3297 } else {3298 dupeCount++;3299 3300 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {3301 best.set(key, e);3302 }3303 }3304 }3305 return dupeCount;3306}330733083309331033113312export async function deduplicateEntries(): Promise<number> {3313 if (!derivedKey) throw new Error("vault_locked");3314 3315 3316 if (vaultMode === "decoy") {3317 throw new Error("deduplicate disabled in decoy mode");3318 }33193320 const best = new Map<string, VaultEntry>();3321 for (const e of entries.filter((en) => !en.deletedAt)) {3322 const key = entryDupeKey(e);3323 const existing = best.get(key);3324 if (!existing) {3325 best.set(key, e);3326 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {3327 best.set(key, e);3328 }3329 }33303331 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));3332 let removed = 0;3333 entries = entries.map((e) => {3334 if (!e.deletedAt && !keepIds.has(e.id)) {3335 removed++;3336 const deletedAt = monotonicEntryTimestamp(Math.max(3337 e.modifiedAt,3338 tombstones.get(e.id) ?? 0,3339 ));3340 tombstones.set(e.id, deletedAt);3341 return { ...e, deletedAt };3342 }3343 return e;3344 });33453346 if (removed > 0) await persistEntries();3347 return removed;3348}334933503351335233533354335533563357const HISTORY_KEY = "peach_vault_history";3358const HISTORY_MAX = 5;335933603361336233633364function bucketEntryCount(n: number): number {3365 if (n <= 0) return 0;3366 if (n <= 10) return 10;3367 if (n <= 25) return 25;3368 if (n <= 50) return 50;3369 if (n <= 100) return 100;3370 if (n <= 250) return 250;3371 if (n <= 500) return 500;3372 return Math.ceil(n / 500) * 500;3373}33743375337633773378337933803381export async function takeVaultSnapshot(3382 reason: VaultSnapshot["reason"],3383): Promise<void> {3384 3385 3386 3387 if (vaultMode === "decoy") return;3388 try {3389 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);3390 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];3391 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];33923393 3394 3395 3396 if (!slotA) return;33973398 const vault_a = JSON.stringify(slotA);3399 const vault_b = JSON.stringify(slotB ?? slotA);34003401 3402 3403 3404 3405 3406 3407 let entryCount: number | null = null;3408 if (derivedKey && vaultMode === "real") {3409 try {3410 entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);3411 } catch {3412 entryCount = null;3413 }3414 }34153416 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3417 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)3418 ? existingRaw3419 : { snapshots: [] };34203421 3422 3423 3424 const latest = history.snapshots[0];3425 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {3426 return;3427 }34283429 const snapshot: VaultSnapshot = {3430 id: crypto.randomUUID(),3431 savedAt: Date.now(),3432 reason,3433 vault_a,3434 vault_b,3435 entryCount,3436 };34373438 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);3439 await chrome.storage.local.set({ [HISTORY_KEY]: history });3440 } catch (err) {3441 3442 3443 3444 3445 console.warn('[Peach] takeVaultSnapshot failed:', err);3446 if (String(err).includes('QUOTA') || String(err).includes('quota')) {3447 try {3448 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });3449 } catch {3450 3451 }3452 }3453 }3454}345534563457export async function clearVaultHistory(): Promise<void> {3458 try {3459 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });3460 } catch {3461 3462 }3463}346434653466export async function getVaultHistory(): Promise<VaultSnapshot[]> {3467 if (vaultMode === "decoy") return [];3468 const raw = await chrome.storage.local.get(HISTORY_KEY);3469 const value = (raw as Record<string, unknown>)[HISTORY_KEY];3470 return sanitizeHistory(value);3471}347234733474347534763477export async function restoreVaultSnapshot(3478 snapshotId: string,3479): Promise<{ ok: true } | { ok: false; error: string }> {3480 if (vaultMode === "decoy") return { ok: false, error: "vault_locked" };3481 const history = await getVaultHistory();3482 const snapshot = history.find((s) => s.id === snapshotId);3483 if (!snapshot) return { ok: false, error: "snapshot_not_found" };34843485 const restoredA = parseHistorySlot(snapshot.vault_a);3486 const restoredB = parseHistorySlot(snapshot.vault_b);3487 if (!restoredA || !restoredB) return { ok: false, error: "snapshot_corrupt" };34883489 await takeVaultSnapshot("restore");3490 const current = await chrome.storage.local.get([SLOT_A, SLOT_B]);3491 const previousA = (current as Record<string, unknown>)[SLOT_A];3492 const previousB = (current as Record<string, unknown>)[SLOT_B];3493 try {3494 await chrome.storage.local.set({ [SLOT_A]: restoredA, [SLOT_B]: restoredB });3495 await stampLocalVaultMutation();3496 } catch {3497 try {3498 await chrome.storage.local.set({ [SLOT_A]: previousA, [SLOT_B]: previousB });3499 } catch {3500 3501 }3502 return { ok: false, error: "snapshot_corrupt" };3503 }35043505 await lock();3506 return { ok: true };3507}350835093510351135123513351435153516351735183519352035213522export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {3523 if (!derivedKey || vaultMode !== "real") {3524 return { ok: false, error: "vault_locked" };3525 }3526 const slotA = await loadSlot(SLOT_A);3527 if (!slotA) return { ok: false, error: "missing_slot_a" };35283529 3530 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =3531 await generateAndWrapRootKey();35323533 3534 const newRootKey = unhex(rawRootKey);3535 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);35363537 3538 3539 3540 3541 await takeVaultSnapshot("local_save");3542 const nextSlotA: StoredVault = {3543 ...slotA,3544 recovery_salt: recoverySalt,3545 recovery_wrapped_root_key: wrappedRootKey,3546 recovery_nonce: wrappedNonce,3547 recovery_tag: wrappedTag,3548 ...pwRoot,3549 };3550 await writeSlot(SLOT_A, nextSlotA);35513552 3553 if (rootKey) zeroKey(rootKey);3554 rootKey = newRootKey;35553556 3557 3558 await notifyIdentityKeysRotated();35593560 3561 3562 3563 await clearPairedDevices();3564 await clearPairSecrets();35653566 3567 await setRecoveryConfirmPending(); 35683569 await stampLocalVaultMutation();3570 return { ok: true };3571}35723573357435753576357735783579export async function changePassword(3580 currentPassword: string,3581 newPassword: string3582): Promise<boolean> {3583 const epoch = vaultEpoch;3584 const stale = () => epoch !== vaultEpoch;3585 if (!derivedKey || vaultMode !== "real") return false;35863587 3588 const valid = await verifyMainPassword(currentPassword);3589 if (!valid) return false;35903591 const slotA = await loadSlot(SLOT_A);3592 if (!slotA) return false;3593 if (!rootKey) return false;35943595 3596 const newSalt = generateSalt();3597 const newKey = await deriveKey(newPassword, newSalt);3598 if (stale()) {3599 zeroKey(newKey);3600 return false;3601 }36023603 3604 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);3605 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");36063607 3608 3609 3610 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);36113612 const finalSlotA: StoredVault = {3613 ...slotA,3614 salt: hex(newSalt),3615 nonce,3616 ciphertext,3617 tag,3618 ...pwRoot,3619 };36203621 3622 3623 3624 await takeVaultSnapshot("local_save");36253626 3627 3628 3629 3630 await writeSlot(SLOT_A, finalSlotA);3631 if (stale()) {3632 zeroKey(newKey);3633 return false;3634 }36353636 3637 3638 3639 zeroKey(derivedKey);3640 derivedKey = newKey;36413642 3643 3644 3645 3646 if (stale()) {3647 await lock();3648 return false;3649 }3650 await saveSessionKey(newKey, "real", epoch);3651 if (stale()) return false;36523653 3654 3655 3656 await notifyIdentityKeysRotated();3657 if (stale()) return false;36583659 3660 3661 3662 if (!duressConfigured) {3663 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3664 }3665 await clearPinUnlockStorage();3666 await stampLocalVaultMutation();36673668 return true;3669}