123456789101112import { deriveKey, generateSalt } from "../crypto/argon2";13import { extractDomain } from "../shared/domain";14import { clampSyncTimestamp } from "../shared/sync-limits";15import { computeVaultId } from "../shared/sync-identity";161718import { triggerSyncIfReachable } from "./sync-trigger";19import { clearPairedDevices } from "./paired-devices";20import {21 rootKeyToWords,22 wordsToRootKeyMaterial,23 deriveRecoveryKey,24} from "../shared/recovery-words";25import type {26 AccentColorName,27 Canary,28 ManualSummonRecord,29 PasskeyData,30 SavedIdentity,31 StoredVault,32 VaultEntry,33 VaultHistory,34 VaultSnapshot,35} from "../shared/types";36import { decrypt, encrypt, zeroKey } from "./aes";37import { clearAutoLock, touchAutoLock } from "./alarms";38import {39 clearRecoveryConfirmPending,40 setRecoveryConfirmPending,41} from "./recovery-pending";42import {43 clearPairSecrets,44 dropSessionCache,45 reconcilePairSecrets,46 setSessionAllowed,47} from "./pair-secrets";4849type EntryDraft = Omit<50 VaultEntry,51 "id" | "createdAt" | "modifiedAt" | "deletedAt"52>;535455565758const SLOT_A = "peach_vault_a";59const SLOT_B = "peach_vault_b";60616263const PAD_BLOCK = 1024;6465666768const PASSWORD_HISTORY_MAX = 50;6970717273747576777879const DURESS_SLOT_SIZE = 262_144;8081let derivedKey: Uint8Array | null = null;8283848586let rootKey: Uint8Array | null = null;87let entries: VaultEntry[] = [];88899091let tombstones = new Map<string, number>();92let domainIndex: Map<string, VaultEntry[]> = new Map();93let vaultMode: "real" | "decoy" = "real";94959697let duressConfigured = false;9899100101102103104105106107108let lastUnlockFailureReason: string | null = null;109110export function consumeLastUnlockFailureReason(): string | null {111 const r = lastUnlockFailureReason;112 lastUnlockFailureReason = null;113 return r;114}115116117118119120export type TextSize = "small" | "medium" | "large";121122export interface VaultSettings {123 accentColor?: AccentColorName;124 theme?: "dark" | "light";125 autoLockMinutes?: number;126 clipboardClearSeconds?: number;127 lockOnClose?: boolean;128 breachMonitoring?: boolean;129 passkeyProviderEnabled?: boolean;130 trashRetentionDays?: number;131 manualSummonDomains?: Record<string, ManualSummonRecord>;132 manualSummonSalt?: string;133 declinedCardFingerprints?: string[];134135 136 metadataVersion?: 1;137 138139140141142 textSize?: TextSize;143 144145146147148 reduceMotion?: boolean;149 150 pinEnabled?: boolean;151 152 pinPreferred?: boolean;153 154155156157158159160 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;161}162163interface StoredPinWrap {164 version: 1;165 salt: string;166 nonce: string;167 ciphertext: string;168 tag: string;169}170171const PIN_WRAP_STORAGE = "peach_pin_wrap";172const PIN_ATTEMPTS_STORAGE = "peach_pin_attempts";173const PIN_MIN_LENGTH = 4;174const PIN_MAX_LENGTH = 12;175const PIN_MAX_ATTEMPTS = 3;176177let settings: VaultSettings = {};178const CARD_DECLINE_MAX = 256;179180const CARD_DECLINE_DIGEST_HEX = 32;181182async function metadataDigest(value: string, key: string): Promise<string> {183 const bytes = new TextEncoder().encode(`${key}:${value}`);184 const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));185 return Array.from(digest.slice(0, 16)).map((byte) => byte.toString(16).padStart(2, "0")).join("");186}187188let inMemoryPinWrap: StoredPinWrap | null = null;189let inMemoryPinFailedAttempts = 0;190let sessionPinStorageFailed = false;191192export function getSettings(): VaultSettings {193 return settings;194}195196export async function ensureSettingsLoaded(): Promise<VaultSettings> {197 await loadSettings();198 return settings;199}200201export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {202 await loadSettings();203 204 205 206 207 const safe = Object.fromEntries(208 Object.entries(patch).filter(209 ([k]) => !["__proto__", "constructor", "prototype"].includes(k),210 ),211 ) as Partial<VaultSettings>;212 settings = { ...settings, ...safe };213 await persistSettings();214}215216function isValidPin(pin: string): boolean {217 return new RegExp(`^\\d{${PIN_MIN_LENGTH},${PIN_MAX_LENGTH}}$`).test(pin);218}219220221export async function setupPinUnlock(currentPassword: string, pin: string): Promise<boolean> {222 if (!derivedKey || vaultMode !== "real" || !isValidPin(pin)) return false;223 if (!(await verifyMainPassword(currentPassword))) return false;224225 const salt = generateSalt();226 const pinKey = await deriveKey(pin, salt);227 try {228 const wrapped = await encrypt(pinKey, hex(derivedKey), "pin_wrap_v1");229 await savePinWrap({ version: 1, salt: hex(salt), ...wrapped });230 await updateSettings({ pinEnabled: true });231 return true;232 } finally {233 zeroKey(pinKey);234 }235}236237async function clearPinUnlockStorage(): Promise<void> {238 inMemoryPinWrap = null;239 inMemoryPinFailedAttempts = 0;240 sessionPinStorageFailed = false;241 if (chrome.storage.session) {242 await chrome.storage.session.remove([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);243 }244 await updateSettings({ pinEnabled: false });245}246247248export async function disablePinUnlock(currentPassword: string): Promise<boolean> {249 if (!derivedKey || vaultMode !== "real") return false;250 if (!(await verifyMainPassword(currentPassword))) return false;251 await clearPinUnlockStorage();252 return true;253}254255256export async function unlockWithPin(pin: string): Promise<boolean> {257 if (!isValidPin(pin)) return false;258 await loadSettings();259 if (!settings.pinEnabled) return false;260261 const { stored, failedAttempts } = await loadPinState();262 if (failedAttempts >= PIN_MAX_ATTEMPTS) return false;263 if (264 !stored ||265 stored.version !== 1 ||266 !stored.salt ||267 !stored.nonce ||268 !stored.ciphertext ||269 !stored.tag270 ) return false;271272 const pinKey = await deriveKey(pin, unhex(stored.salt));273 let unwrappedKey: Uint8Array | null = null;274 try {275 const masterKeyHex = await decrypt(276 pinKey,277 stored.nonce,278 stored.ciphertext,279 stored.tag,280 "pin_wrap_v1",281 );282 if (!/^[0-9a-f]{64}$/.test(masterKeyHex)) return false;283 unwrappedKey = unhex(masterKeyHex);284 const result = await unlockWithKey(masterKeyHex);285 if (!result.ok) return false;286 await resetPinFailedAttempts();287 return true;288 } catch {289 await recordPinFailure(failedAttempts + 1);290 return false;291 } finally {292 zeroKey(pinKey);293 if (unwrappedKey) zeroKey(unwrappedKey);294 }295}296297async function savePinWrap(stored: StoredPinWrap): Promise<void> {298 inMemoryPinWrap = stored;299 inMemoryPinFailedAttempts = 0;300 sessionPinStorageFailed = !chrome.storage.session;301 if (!chrome.storage.session) return;302 try {303 await chrome.storage.session.set({ [PIN_WRAP_STORAGE]: stored, [PIN_ATTEMPTS_STORAGE]: 0 });304 sessionPinStorageFailed = false;305 } catch {306 307 sessionPinStorageFailed = true;308 }309}310311async function loadPinState(): Promise<{312 stored: StoredPinWrap | undefined;313 failedAttempts: number;314}> {315 if (!chrome.storage.session) {316 return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };317 }318 try {319 const result = await chrome.storage.session.get([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);320 return {321 stored: (result[PIN_WRAP_STORAGE] as StoredPinWrap | undefined) ??322 (sessionPinStorageFailed ? inMemoryPinWrap ?? undefined : undefined),323 failedAttempts: typeof result[PIN_ATTEMPTS_STORAGE] === "number"324 ? result[PIN_ATTEMPTS_STORAGE]325 : 0,326 };327 } catch {328 return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };329 }330}331332async function recordPinFailure(failedAttempts: number): Promise<void> {333 inMemoryPinFailedAttempts = Math.min(failedAttempts, PIN_MAX_ATTEMPTS);334 if (!chrome.storage.session) return;335 try {336 await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: inMemoryPinFailedAttempts });337 } catch { }338}339340async function resetPinFailedAttempts(): Promise<void> {341 inMemoryPinFailedAttempts = 0;342 if (!chrome.storage.session) return;343 try {344 await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: 0 });345 } catch { }346}347348export async function getPinUnlockStatus(): Promise<{349 configured: boolean;350 available: boolean;351 attemptsRemaining: number;352}> {353 await loadSettings();354 if (!settings.pinEnabled) {355 return { configured: false, available: false, attemptsRemaining: 0 };356 }357 const { stored, failedAttempts } = await loadPinState();358 const available = Boolean(stored) && failedAttempts < PIN_MAX_ATTEMPTS;359 return {360 configured: true,361 available,362 attemptsRemaining: stored ? Math.max(0, PIN_MAX_ATTEMPTS - failedAttempts) : 0,363 };364}365366async function persistSettings(): Promise<void> {367 try {368 await chrome.storage.local.set({369 peach_settings: {370 ...settings,371 manualSummonDomains: undefined,372 manualSummonSalt: undefined,373 cardFingerprintSalt: undefined,374 declinedCardFingerprints: settings.declinedCardFingerprints ?? [],375 metadataVersion: 1,376 },377 });378 } catch (err) {379 throw mapStorageError(err);380 }381}382383function mapStorageError(err: unknown): Error {384 const message = err instanceof Error ? err.message : String(err);385 if (/QUOTA_BYTES|quota/i.test(message)) {386 return new Error("STORAGE_QUOTA_EXCEEDED");387 }388 return err instanceof Error ? err : new Error(message);389}390391async function loadSettings(): Promise<void> {392 const result = await chrome.storage.local.get("peach_settings");393 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};394}395396397398function fireSyncTrigger(reason: "vault_change"): void {399 void Promise.resolve()400 .then(() => triggerSyncIfReachable({ reason }))401 .catch(() => {402 403 });404}405406let lockedSyncRefreshHook: (() => Promise<void>) | null = null;407let lockedSyncUnlockHook: (() => Promise<void>) | null = null;408let decoySyncTeardownHook: (() => void) | null = null;409export type IdentityHooks = {410 411 onSessionEnded: () => Promise<void>;412 413 onKeysRotated: () => Promise<void>;414 415 onVaultCreated: (password: string) => Promise<void>;416};417418let identityHooks: IdentityHooks | null = null;419420export function setIdentityHooks(hooks: IdentityHooks): void {421 identityHooks = hooks;422}423424async function notifyIdentitySessionEnded(): Promise<void> {425 await identityHooks?.onSessionEnded().catch((error) => {426 console.warn("[Peach] local identity cache clear failed:", error);427 });428}429430async function notifyIdentityKeysRotated(): Promise<void> {431 await identityHooks?.onKeysRotated().catch((error) => {432 console.warn("[Peach] local identity re-wrap failed:", error);433 });434}435436export type SealedBox = { nonce: string; ciphertext: string; tag: string };437export type IdentityKeyEnvelope = {438 root?: SealedBox;439 local?: SealedBox;440 pending?: SealedBox;441 pendingSalt?: string;442};443444export function setLockedSyncHooks(hooks: {445 refresh: () => Promise<void>;446 afterUnlock: () => Promise<void>;447 onDecoy?: () => void;448}): void {449 lockedSyncRefreshHook = hooks.refresh;450 lockedSyncUnlockHook = hooks.afterUnlock;451 decoySyncTeardownHook = hooks.onDecoy ?? null;452}453454async function refreshLockedSyncState(): Promise<void> {455 if (vaultMode !== "real" || !rootKey || !lockedSyncRefreshHook) return;456 await lockedSyncRefreshHook().catch((error) => {457 console.warn("[Peach sync] locked spool refresh failed:", error);458 });459}460461462463464465export async function checkBreach(password: string): Promise<number> {466 467 468 const encoder = new TextEncoder();469 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));470 const hashHex = Array.from(new Uint8Array(hashBuffer))471 .map((b) => b.toString(16).padStart(2, "0"))472 .join("")473 .toUpperCase();474475 const prefix = hashHex.slice(0, 5);476 const suffix = hashHex.slice(5);477478 try {479 480 481 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {482 headers: { "Add-Padding": "true" },483 });484 if (!res.ok) return 0;485 const text = await res.text();486 const match = text.split("\r\n").find((l) => l.startsWith(suffix));487 return match ? parseInt(match.split(":")[1], 10) : 0;488 } catch {489 return 0; 490 }491}492493export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {494 495 const key = await cardDeclineKey();496 if (!key) return;497 const fingerprint = await cardFingerprint(last4, expMonth, expYear);498 await loadSettings();499 settings = {500 ...settings,501 declinedCardFingerprints: [502 503 504 ...(settings.declinedCardFingerprints ?? []).filter(505 (stored) => stored.length === CARD_DECLINE_DIGEST_HEX,506 ),507 await metadataDigest(fingerprint, key),508 ].slice(-CARD_DECLINE_MAX),509 };510 await persistSettings();511}512513export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {514 await loadSettings();515 const key = await cardDeclineKey();516 if (!key) return false;517 const fingerprint = await cardFingerprint(last4, expMonth, expYear);518 const candidate = await metadataDigest(fingerprint, key);519 return (settings.declinedCardFingerprints ?? []).some(520 (stored) => stored.length === CARD_DECLINE_DIGEST_HEX && stored === candidate,521 );522}523524525526527528529530531532533534535async function cardDeclineKey(): Promise<string | null> {536 const root = getRootKeyHex();537 if (!root) return null;538 const digest = new Uint8Array(539 await crypto.subtle.digest(540 "SHA-256",541 new TextEncoder().encode(`peach-card-decline-v1:${root}`),542 ),543 );544 return hex(digest);545}546547async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {548 const encoder = new TextEncoder();549 const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));550 return Array.from(new Uint8Array(buf).slice(0, 8))551 .map((b) => b.toString(16).padStart(2, "0"))552 .join("");553}554555function hex(buf: Uint8Array): string {556 return Array.from(buf)557 .map((b) => b.toString(16).padStart(2, "0"))558 .join("");559}560561function unhex(s: string): Uint8Array {562 const out = new Uint8Array(s.length / 2);563 for (let i = 0; i < s.length; i += 2) {564 out[i / 2] = parseInt(s.slice(i, i + 2), 16);565 }566 return out;567}568569570571572573574575576577578579580581582583584585586587588async function generateAndWrapRootKey(): Promise<{589 rawRootKey: string; 590 recoverySalt: string;591 wrappedRootKey: string;592 wrappedNonce: string;593 wrappedTag: string;594}> {595 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));596597 598 const words = await rootKeyToWords(rootKeyBytes);599 const wordMaterial = await wordsToRootKeyMaterial(words);600 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));601 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);602603 604 605 const nonce = crypto.getRandomValues(new Uint8Array(12));606 const encoded = new TextEncoder().encode(hex(rootKeyBytes));607 const raw = await crypto.subtle.encrypt(608 {609 name: "AES-GCM",610 iv: nonce,611 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,612 tagLength: 128,613 },614 recoveryKey,615 encoded616 );617 618 const rawU8 = new Uint8Array(raw);619 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));620 const tag = hex(rawU8.slice(rawU8.length - 16));621622 return {623 rawRootKey: hex(rootKeyBytes),624 recoverySalt: hex(recoverySalt),625 wrappedRootKey: ciphertext,626 wrappedNonce: hex(nonce),627 wrappedTag: tag,628 };629}630631async function wrapExistingRootKeyForRecovery(rootKeyBytes: Uint8Array): Promise<{632 recoverySalt: string;633 wrappedRootKey: string;634 wrappedNonce: string;635 wrappedTag: string;636}> {637 if (rootKeyBytes.length !== 32) {638 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");639 }640641 const words = await rootKeyToWords(rootKeyBytes);642 const wordMaterial = await wordsToRootKeyMaterial(words);643 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));644 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);645 const nonce = crypto.getRandomValues(new Uint8Array(12));646 const encoded = new TextEncoder().encode(hex(rootKeyBytes));647 const raw = await crypto.subtle.encrypt(648 {649 name: "AES-GCM",650 iv: nonce,651 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,652 tagLength: 128,653 },654 recoveryKey,655 encoded,656 );657 const rawU8 = new Uint8Array(raw);658 return {659 recoverySalt: hex(recoverySalt),660 wrappedRootKey: hex(rawU8.slice(0, rawU8.length - 16)),661 wrappedNonce: hex(nonce),662 wrappedTag: hex(rawU8.slice(rawU8.length - 16)),663 };664}665666667668669670671export async function unwrapRootKeyForRecovery(672 wrappedRootKey: string,673 wrappedNonce: string,674 wrappedTag: string,675 recoverySaltHex: string,676 words: string[]677): Promise<void> {678 const wordMaterial = await wordsToRootKeyMaterial(words);679 const recoverySalt = unhex(recoverySaltHex);680 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);681682 683 684 685 686 687 const nonceRaw = unhex(wrappedNonce);688 const ciphertextRaw = unhex(wrappedRootKey);689 const tagRaw = unhex(wrappedTag);690 691 692 const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;693 const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);694 combined.set(ciphertextRaw, 0);695 combined.set(tagRaw, ciphertextRaw.length);696 const raw = await crypto.subtle.decrypt(697 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },698 recoveryKey,699 combined700 );701 const decryptedHex = new TextDecoder().decode(raw);702 rootKey = unhex(decryptedHex);703}704705706707708709710711712713714export async function rewrapRootKeyForPassword(715 newDerivedKey: Uint8Array,716): Promise<void> {717 if (!rootKey) throw new Error("root key not set");718 const slotA = await loadSlot(SLOT_A);719 if (!slotA) throw new Error("missing slot A");720 const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);721 await writeSlot(SLOT_A, { ...slotA, ...pwRoot });722}723724725726727728729async function reencryptVaultForNewKey(730 newKey: Uint8Array,731 slotA: StoredVault732): Promise<void> {733 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);734 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");735 await writeSlot(SLOT_A, {736 ...slotA,737 nonce,738 ciphertext,739 tag,740 });741}742743744745746747748export async function changePasswordWithNewKey(749 newSalt: Uint8Array,750 newKey: Uint8Array751): Promise<void> {752 const epoch = vaultEpoch;753 const throwIfStale = () => {754 if (epoch === vaultEpoch) return;755 zeroKey(newKey);756 throw new Error("vault_locked");757 };758 const slotA = await loadSlot(SLOT_A);759 throwIfStale();760 if (!slotA) throw new Error("missing slot A");761 762 763 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });764 throwIfStale();765 if (derivedKey) zeroKey(derivedKey);766 derivedKey = newKey;767 768 769 770 await saveSessionKey(newKey, "real", epoch);771 throwIfStale();772 773 774 await notifyIdentityKeysRotated();775 throwIfStale();776 777 778 779 780 if (rootKey) {781 const slotAfter = await loadSlot(SLOT_A);782 throwIfStale();783 if (slotAfter) {784 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);785 throwIfStale();786 await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });787 throwIfStale();788 }789 }790 if (!duressConfigured) {791 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));792 throwIfStale();793 }794 await clearPinUnlockStorage();795 throwIfStale();796 await stampLocalVaultMutation();797 throwIfStale();798}799800801802803804805export { deriveKey, generateSalt };806807export async function loadSlotA(): Promise<StoredVault | null> {808 return loadSlot(SLOT_A);809}810811812813814815816817818819820async function deriveRootKeyFromDerivedKey(821 derivedKeyBytes: Uint8Array,822 slotA: StoredVault,823): Promise<Uint8Array | null> {824 if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {825 return null;826 }827 try {828 const rootHex = await decrypt(829 derivedKeyBytes,830 slotA.pw_root_nonce,831 slotA.pw_root_ciphertext,832 slotA.pw_root_tag,833 "pw_root_v1",834 );835 return unhex(rootHex);836 } catch {837 return null;838 }839}840841export async function sealIdentityKeyUnderVault(ik: Uint8Array): Promise<IdentityKeyEnvelope> {842 if (vaultMode !== "real" || !rootKey || !derivedKey) throw new Error("vault_locked");843 844 845 const [root, local] = await Promise.all([846 encrypt(rootKey, hex(ik), "peach-local-identity-ik-root-v1"),847 encrypt(derivedKey, hex(ik), "peach-local-identity-ik-local-v1"),848 ]);849 return { root, local };850}851852export async function sealIdentityKeyPreVault(853 password: string,854 ik: Uint8Array,855): Promise<IdentityKeyEnvelope> {856 const salt = generateSalt();857 const key = await deriveKey(password, salt);858 try {859 const pending = await encrypt(key, hex(ik), "peach-local-identity-ik-pending-v1");860 return { pending, pendingSalt: hex(salt) };861 } finally {862 zeroKey(key);863 }864}865866867868869870871872873874export async function openIdentityKey(875 env: IdentityKeyEnvelope,876 password?: string,877): Promise<Uint8Array | null> {878 if (env.local && derivedKey) {879 const ik = await openSealedKey(derivedKey, env.local, "peach-local-identity-ik-local-v1");880 if (ik) return ik;881 }882 if (env.root && rootKey) {883 const ik = await openSealedKey(rootKey, env.root, "peach-local-identity-ik-root-v1");884 if (ik) return ik;885 }886 if (!env.pending || !env.pendingSalt || password === undefined) return null;887 let key: Uint8Array;888 try {889 const salt = unhex(env.pendingSalt);890 key = await deriveKey(password, salt);891 salt.fill(0);892 } catch {893 return null;894 }895 try {896 return await openSealedKey(key, env.pending, "peach-local-identity-ik-pending-v1");897 } finally {898 zeroKey(key);899 }900}901902903async function openSealedKey(904 key: Uint8Array,905 box: SealedBox,906 info: string,907): Promise<Uint8Array | null> {908 try {909 const value = unhex(await decrypt(key, box.nonce, box.ciphertext, box.tag, info));910 if (value.length === 32) return value;911 value.fill(0);912 return null;913 } catch {914 return null;915 }916}917918919920async function wrapRootKeyForDerivedKey(921 derivedKeyBytes: Uint8Array,922 rootKeyBytes: Uint8Array,923): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {924 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");925 return {926 pw_root_ciphertext: ciphertext,927 pw_root_nonce: nonce,928 pw_root_tag: tag,929 };930}931932933export async function getRecoveryWords(): Promise<string[]> {934 if (!rootKey) {935 throw new Error("recovery not set up");936 }937 return rootKeyToWords(rootKey);938}939940async function loadSlot(key: string): Promise<StoredVault | null> {941 const result = await chrome.storage.local.get(key);942 return (result as Record<string, StoredVault | undefined>)[key] ?? null;943}944945async function writeSlot(key: string, vault: StoredVault): Promise<void> {946 try {947 await chrome.storage.local.set({ [key]: vault });948 } catch (err) {949 throw mapStorageError(err);950 }951}952953954955956957958959960961962export async function rollbackAdoptedVault(): Promise<void> {963 await lock();964 try {965 await chrome.storage.local.remove([966 SLOT_A,967 SLOT_B,968 "peach_last_modified_at",969 ]);970 } catch (err) {971 throw mapStorageError(err);972 }973}974975async function commitCreatedVaultSlots(slotA: StoredVault, slotB: StoredVault): Promise<void> {976 const raw = await chrome.storage.local.get("peach_last_modified_at");977 const previous = Number(978 (raw as Record<string, unknown>).peach_last_modified_at,979 );980 const next = Math.max(981 Date.now(),982 Number.isFinite(previous) ? previous + 1 : 0,983 );984 try {985 await chrome.storage.local.set({986 [SLOT_A]: slotA,987 [SLOT_B]: slotB,988 peach_last_modified_at: String(next),989 });990 } catch (err) {991 throw mapStorageError(err);992 }993}994995996997998999100010011002let lastEntryTimestamp = 0;1003function monotonicEntryTimestamp(currentModifiedAt?: number): number {1004 const base = Math.max(1005 Date.now(),1006 (currentModifiedAt ?? 0) + 1,1007 lastEntryTimestamp + 1,1008 );1009 lastEntryTimestamp = base;1010 return base;1011}10121013async function stampLocalVaultMutation(): Promise<void> {1014 const raw = await chrome.storage.local.get("peach_last_modified_at");1015 const previous = Number(1016 (raw as Record<string, unknown>).peach_last_modified_at,1017 );1018 const next = Math.max(1019 Date.now(),1020 Number.isFinite(previous) ? previous + 1 : 0,1021 );1022 await chrome.storage.local.set({1023 peach_last_modified_at: String(next),1024 });1025}102610271028102910301031103210331034103510361037function padPlaintext(json: string, minBytes = 0): string {1038 const encoded = new TextEncoder().encode(json);1039 const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);1040 1041 const target = Math.max(bucket, minBytes);1042 const padLen = target - encoded.length;1043 return json + " ".repeat(padLen);1044}104510461047104810491050105110521053105410551056const CSPRNG_CHUNK = 65_536;10571058function fillRandom(buf: Uint8Array): Uint8Array {1059 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {1060 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);1061 const chunk = new Uint8Array(end - offset);1062 crypto.getRandomValues(chunk);1063 buf.set(chunk, offset);1064 }1065 return buf;1066}10671068function generateNoiseBlob(ciphertextBytes: number): StoredVault {1069 const salt = crypto.getRandomValues(new Uint8Array(16));1070 const nonce = crypto.getRandomValues(new Uint8Array(12));1071 const cipher = fillRandom(new Uint8Array(ciphertextBytes));1072 const tag = crypto.getRandomValues(new Uint8Array(16));1073 return {1074 version: 1,1075 salt: hex(salt),1076 nonce: hex(nonce),1077 ciphertext: hex(cipher),1078 tag: hex(tag),1079 };1080}108110821083108410851086type VaultPayload = {1087 entries: VaultEntry[];1088 tombstones?: Record<string, number>;1089 duressConfigured?: boolean;1090};10911092type DecryptedPayload = {1093 entries: VaultEntry[];1094 tombstones: Map<string, number>;1095 duressConfigured: boolean;1096};10971098function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {1099 const envelope: VaultPayload = {1100 entries,1101 tombstones: Object.fromEntries(tombstones),1102 duressConfigured: duress,1103 };1104 return padPlaintext(JSON.stringify(envelope), minBytes);1105}11061107function decodeTombstones(raw: unknown, allEntries: VaultEntry[]): Map<string, number> {1108 const result = new Map<string, number>();1109 if (raw && typeof raw === "object" && !Array.isArray(raw)) {1110 for (const [id, timestamp] of Object.entries(raw as Record<string, unknown>)) {1111 if (typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0) {1112 result.set(id, timestamp);1113 }1114 }1115 }1116 1117 1118 for (const entry of allEntries) {1119 if (typeof entry.deletedAt !== "number" || !Number.isFinite(entry.deletedAt) || entry.deletedAt <= 0) {1120 continue;1121 }1122 if ((result.get(entry.id) ?? 0) < entry.deletedAt) {1123 result.set(entry.id, entry.deletedAt);1124 }1125 }1126 return result;1127}11281129function reconcileTombstones(): void {1130 const byId = new Map(entries.map((entry) => [entry.id, entry]));1131 for (const [id] of tombstones) {1132 if (byId.has(id) && !byId.get(id)?.deletedAt) tombstones.delete(id);1133 }1134 for (const entry of entries) {1135 if (typeof entry.deletedAt === "number" && Number.isFinite(entry.deletedAt) && entry.deletedAt > 0) {1136 if ((tombstones.get(entry.id) ?? 0) < entry.deletedAt) {1137 tombstones.set(entry.id, entry.deletedAt);1138 }1139 }1140 }1141}11421143function decodePayload(plain: string): DecryptedPayload {1144 const parsed = JSON.parse(plain) as VaultPayload;1145 if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.entries)) {1146 throw new Error("invalid vault payload");1147 }1148 const normalized = parsed.entries.map(normalizeEntry);1149 return {1150 entries: normalized,1151 tombstones: decodeTombstones(parsed.tombstones, normalized),1152 duressConfigured: parsed.duressConfigured === true,1153 };1154}1155115611571158115911601161116211631164116511661167116811691170117111721173117411751176function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {1177 let merged = false;1178 const now = Date.now();11791180 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);1181 const byRpId = new Map<string, VaultEntry[]>();1182 for (const e of candidates) {1183 const rpId = e.passkey!.rpId;1184 const list = byRpId.get(rpId);1185 if (list) list.push(e);1186 else byRpId.set(rpId, [e]);1187 }11881189 for (const [rpId, group] of byRpId) {1190 if (group.length === 0) continue;1191 group.sort((a, b) => {1192 const aT =1193 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;1194 const bT =1195 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;1196 return bT - aT;1197 });1198 const winner = group[0]!;11991200 1201 1202 1203 const loginTarget = entries.find((e) => {1204 if (e.deletedAt || e.passkey || e.type !== "login") return false;1205 if (e.url && extractDomain(e.url) === rpId) return true;1206 for (const extra of e.additionalUrls ?? []) {1207 if (extractDomain(extra) === rpId) return true;1208 }1209 return false;1210 });12111212 if (loginTarget) {1213 loginTarget.passkey = winner.passkey;1214 1215 1216 1217 if (!loginTarget.totp && winner.totp) {1218 loginTarget.totp = winner.totp;1219 loginTarget.totpAlgorithm = winner.totpAlgorithm;1220 loginTarget.totpDigits = winner.totpDigits;1221 loginTarget.totpPeriod = winner.totpPeriod;1222 }1223 loginTarget.modifiedAt = now;1224 for (const dup of group) {1225 if (!dup.deletedAt) {1226 dup.deletedAt = now;1227 dup.modifiedAt = now;1228 }1229 }1230 merged = true;1231 } else if (group.length > 1) {1232 for (let i = 1; i < group.length; i += 1) {1233 const dup = group[i]!;1234 if (!dup.deletedAt) {1235 dup.deletedAt = now;1236 dup.modifiedAt = now;1237 }1238 }1239 merged = true;1240 }1241 }12421243 return { merged };1244}12451246124712481249125012511252function hasValidVaultSchema(v: unknown): v is StoredVault {1253 if (typeof v !== "object" || v === null) return false;1254 const r = v as Record<string, unknown>;1255 return (1256 r.version === 1 &&1257 typeof r.salt === "string" &&1258 typeof r.nonce === "string" &&1259 typeof r.ciphertext === "string" &&1260 typeof r.tag === "string"1261 );1262}12631264export type TryDecryptResult =1265 | { status: "ok"; payload: DecryptedPayload }1266 | { status: "wrong_key" }1267 | { status: "corrupted"; reason: string };1268126912701271127212731274export async function tryDecryptSlot(1275 vault: StoredVault,1276 key: Uint8Array,1277 aadTag?: string,1278): Promise<DecryptedPayload | null> {1279 const res = await tryDecryptSlotResult(vault, key, aadTag);1280 return res.status === "ok" ? res.payload : null;1281}12821283export async function tryDecryptSlotResult(1284 vault: StoredVault,1285 key: Uint8Array,1286 aadTag?: string,1287): Promise<TryDecryptResult> {1288 if (!hasValidVaultSchema(vault)) {1289 return { status: "corrupted", reason: "invalid_slot_schema" };1290 }1291 let plain: string;1292 try {1293 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);1294 } catch {1295 return { status: "wrong_key" };1296 }1297 try {1298 const parsed = JSON.parse(plain) as unknown;1299 if (typeof parsed !== "object" || parsed === null) {1300 return { status: "corrupted", reason: "invalid_payload_shape" };1301 }1302 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {1303 return { status: "corrupted", reason: "invalid_payload_shape" };1304 }1305 return { status: "ok", payload: decodePayload(plain) };1306 } catch {1307 return { status: "corrupted", reason: "invalid_payload_json" };1308 }1309}131013111312131313141315function buildDomainIndexSync(all: VaultEntry[]): void {1316 domainIndex = new Map();13171318 for (const entry of all) {1319 if (entry.deletedAt) continue;1320 if (entry.type === "note" || entry.type === "card") continue;13211322 1323 1324 1325 1326 const domains = new Set<string>();1327 const primary = extractDomain(entry.url ?? "");1328 if (primary) domains.add(primary);1329 for (const extra of entry.additionalUrls ?? []) {1330 const d = extractDomain(extra);1331 if (d) domains.add(d);1332 }1333 if (domains.size === 0) continue;13341335 for (const domain of domains) {1336 const bucket = domainIndex.get(domain) ?? [];1337 bucket.push(entry);1338 domainIndex.set(domain, bucket);1339 }1340 }1341}13421343export async function createVault(password: string): Promise<void> {1344 const salt = generateSalt();1345 derivedKey = await deriveKey(password, salt);1346 entries = [];1347 tombstones = new Map();1348 vaultMode = "real";1349 duressConfigured = false;1350 buildDomainIndexSync(entries);13511352 1353 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =1354 await generateAndWrapRootKey();1355 rootKey = unhex(rawRootKey);13561357 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);1358 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");13591360 1361 1362 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);13631364 const slotA: StoredVault = {1365 version: 1,1366 salt: hex(salt),1367 nonce,1368 ciphertext,1369 tag,1370 recovery_salt: recoverySalt,1371 recovery_wrapped_root_key: wrappedRootKey,1372 recovery_nonce: wrappedNonce,1373 recovery_tag: wrappedTag,1374 ...pwRoot,1375 };1376 await writeSlot(SLOT_A, slotA);13771378 1379 1380 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1381 await stampLocalVaultMutation();13821383 1384 1385 await identityHooks?.onVaultCreated(password).catch((error) => {1386 console.warn("[Peach] local identity promotion failed:", error);1387 });13881389 touchAutoLock();1390}1391139213931394139513961397export async function createVaultFromRootKey(1398 password: string,1399 rootKeyHex: string,1400): Promise<{ vaultId: string }> {1401 if (typeof password !== "string" || password.length === 0) {1402 throw new Error("ADOPT_INVALID_PASSWORD");1403 }1404 if (typeof rootKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(rootKeyHex)) {1405 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars");1406 }14071408 const rootKeyBytes = unhex(rootKeyHex);1409 if (rootKeyBytes.length !== 32) {1410 rootKeyBytes.fill(0);1411 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");1412 }14131414 let nextDerivedKey: Uint8Array | null = null;1415 let sessionRootKey: Uint8Array | null = null;1416 try {1417 if ((await loadSlot(SLOT_A)) !== null) {1418 throw new Error("ADOPT_VAULT_ALREADY_EXISTS");1419 }14201421 const nextEntries: VaultEntry[] = [];1422 const nextDuressConfigured = false;1423 const vaultId = await computeVaultId(rootKeyBytes);1424 const salt = generateSalt();1425 nextDerivedKey = await deriveKey(password, salt);1426 const recovery = await wrapExistingRootKeyForRecovery(rootKeyBytes);14271428 sessionRootKey = new Uint8Array(rootKeyBytes);1429 const plaintext = encodePayload(nextEntries, nextDuressConfigured, 0);1430 const { nonce, ciphertext, tag } = await encrypt(nextDerivedKey, plaintext, "slot_a_v1");1431 const pwRoot = await wrapRootKeyForDerivedKey(nextDerivedKey, sessionRootKey);1432 const slotA: StoredVault = {1433 version: 1,1434 salt: hex(salt),1435 nonce,1436 ciphertext,1437 tag,1438 recovery_salt: recovery.recoverySalt,1439 recovery_wrapped_root_key: recovery.wrappedRootKey,1440 recovery_nonce: recovery.wrappedNonce,1441 recovery_tag: recovery.wrappedTag,1442 ...pwRoot,1443 };1444 const slotB = generateNoiseBlob(ciphertext.length / 2);1445 await commitCreatedVaultSlots(slotA, slotB);14461447 const previousDerivedKey = derivedKey;1448 const previousRootKey = rootKey;1449 derivedKey = nextDerivedKey;1450 rootKey = sessionRootKey;1451 nextDerivedKey = null;1452 sessionRootKey = null;1453 entries = nextEntries;1454 tombstones = new Map();1455 vaultMode = "real";1456 duressConfigured = nextDuressConfigured;1457 buildDomainIndexSync(entries);1458 if (previousDerivedKey && previousDerivedKey !== derivedKey) zeroKey(previousDerivedKey);1459 if (previousRootKey && previousRootKey !== rootKey) previousRootKey.fill(0);1460 touchAutoLock();14611462 1463 1464 await identityHooks?.onVaultCreated(password).catch((error) => {1465 console.warn("[Peach] local identity promotion failed:", error);1466 });14671468 return { vaultId };1469 } finally {1470 rootKeyBytes.fill(0);1471 if (nextDerivedKey) zeroKey(nextDerivedKey);1472 if (sessionRootKey) sessionRootKey.fill(0);1473 }1474}14751476export async function hasVault(): Promise<boolean> {1477 return (await loadSlot(SLOT_A)) !== null;1478}1479148014811482148314841485export async function isDuressEnabled(): Promise<boolean> {1486 return vaultMode === "real" && duressConfigured;1487}14881489export async function setupDuress(duressPassword: string): Promise<void> {1490 if (!derivedKey || vaultMode !== "real") {1491 throw new Error("vault must be unlocked in real mode");1492 }1493 1494 1495 1496 const slotA = await loadSlot(SLOT_A);1497 if (!slotA) throw new Error("missing slot A");14981499 const decoySalt = generateSalt();1500 const decoyKey = await deriveKey(duressPassword, decoySalt);1501 try {1502 1503 1504 1505 1506 const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1507 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1508 await writeSlot(SLOT_B, {1509 version: 1,1510 salt: hex(decoySalt),1511 nonce,1512 ciphertext,1513 tag,1514 });1515 } finally {1516 zeroKey(decoyKey);1517 }15181519 1520 1521 duressConfigured = true;1522 await persistSlotA();1523}15241525export async function disableDuress(): Promise<void> {1526 if (!derivedKey || vaultMode !== "real") {1527 throw new Error("vault must be unlocked in real mode");1528 }1529 1530 1531 1532 const slotA = await loadSlot(SLOT_A);1533 if (slotA) {1534 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1535 }15361537 duressConfigured = false;1538 await persistSlotA();1539}15401541154215431544154515461547export async function verifyMainPassword(password: string): Promise<boolean> {1548 const slotA = await loadSlot(SLOT_A);1549 if (!slotA) return false;1550 const key = await deriveKey(password, unhex(slotA.salt));1551 try {1552 return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1553 } finally {1554 zeroKey(key);1555 }1556}15571558export async function unlock(password: string): Promise<boolean> {1559 return attemptUnlockWithDerive(password, deriveKey);1560}15611562async function attemptUnlockWithDerive(1563 password: string,1564 derive: typeof deriveKey,1565): Promise<boolean> {1566 const epoch = vaultEpoch;1567 const stale = () => epoch !== vaultEpoch;1568 1569 1570 1571 1572 1573 1574 const [slotA, slotB] = await Promise.all([1575 loadSlot(SLOT_A),1576 loadSlot(SLOT_B),1577 ]);15781579 1580 const [keyA, keyB] = await Promise.all([1581 slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1582 slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1583 ]);15841585 1586 1587 1588 const [resultA, resultB] = await Promise.all([1589 keyA && slotA1590 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1591 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1592 keyB && slotB1593 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1594 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1595 ]);1596 const payloadA = resultA.status === "ok" ? resultA.payload : null;1597 const payloadB = resultB.status === "ok" ? resultB.payload : null;15981599 if (stale()) {1600 if (keyA) zeroKey(keyA);1601 if (keyB) zeroKey(keyB);1602 return false;1603 }16041605 if (payloadA && keyA) {1606 if (stale()) {1607 zeroKey(keyA);1608 if (keyB) zeroKey(keyB);1609 return false;1610 }1611 derivedKey = keyA;1612 vaultMode = "real";1613 entries = payloadA.entries;1614 tombstones = payloadA.tombstones;1615 duressConfigured = payloadA.duressConfigured;1616 buildDomainIndexSync(entries);1617 await loadSettings();1618 if (stale()) {1619 await lock();1620 return false;1621 }1622 touchAutoLock();1623 if (keyB) zeroKey(keyB);16241625 1626 1627 rootKey = slotA ? await deriveRootKeyFromDerivedKey(keyA, slotA) : null;1628 if (!rootKey) {1629 await lock();1630 return false;1631 }16321633 1634 1635 await reconcilePairSecrets(rootKey);1636 if (stale()) {1637 await lock();1638 return false;1639 }1640 await lockedSyncUnlockHook?.().catch((error) => {1641 console.warn("[Peach sync] locked inbox drain failed:", error);1642 });1643 if (stale()) {1644 await lock();1645 return false;1646 }1647 await resetPinFailedAttempts();1648 if (stale()) {1649 await lock();1650 return false;1651 }1652 await saveSessionKey(keyA, "real", epoch);1653 if (stale()) return false;1654 return true;1655 }16561657 if (payloadB && keyB) {1658 if (stale()) {1659 zeroKey(keyB);1660 if (keyA) zeroKey(keyA);1661 return false;1662 }1663 derivedKey = keyB;1664 vaultMode = "decoy";1665 decoySyncTeardownHook?.();1666 1667 1668 1669 if (rootKey) {1670 rootKey.fill(0);1671 rootKey = null;1672 }1673 entries = payloadB.entries;1674 tombstones = payloadB.tombstones;1675 duressConfigured = false;1676 buildDomainIndexSync(entries);1677 await loadSettings();1678 touchAutoLock();1679 if (keyA) zeroKey(keyA);1680 1681 await dropSessionCache();1682 1683 await notifyIdentitySessionEnded();1684 if (stale()) {1685 await lock();1686 return false;1687 }1688 await saveSessionKey(keyB, "decoy", epoch);1689 if (stale()) return false;1690 return true;1691 }16921693 if (keyA) zeroKey(keyA);1694 if (keyB) zeroKey(keyB);1695 1696 1697 if (resultA.status === "corrupted") {1698 lastUnlockFailureReason = resultA.reason;1699 } else if (resultB.status === "corrupted") {1700 lastUnlockFailureReason = resultB.reason;1701 }1702 return false;1703}17041705170617071708170917101711const SESSION_KEY_STORAGE = "peach_session_dk";1712171317141715let sessionKeyMutation = Promise.resolve();17161717function mutateSessionKey(mutation: () => Promise<void>): Promise<void> {1718 const task = sessionKeyMutation.then(mutation, mutation);1719 sessionKeyMutation = task.catch(() => undefined);1720 return task;1721}172217231724172517261727let sessionEscrowAvailable = true;17281729173017311732173317341735export async function applyStorageHardening(sessionEscrow: boolean): Promise<void> {1736 setSessionAllowed(sessionEscrow);1737 if (sessionEscrow) return;1738 sessionEscrowAvailable = false;1739 await clearSessionKey();1740}17411742async function saveSessionKey(1743 key: Uint8Array,1744 mode: "real" | "decoy",1745 expectedEpoch = vaultEpoch,1746): Promise<void> {1747 const session = chrome.storage.session;1748 if (!session || !sessionEscrowAvailable || expectedEpoch !== vaultEpoch) return;1749 const stored = { keyHex: hex(key), mode };1750 await mutateSessionKey(async () => {1751 if (!sessionEscrowAvailable || expectedEpoch !== vaultEpoch) return;1752 await session.set({ [SESSION_KEY_STORAGE]: stored });1753 });1754}17551756async function clearSessionKey(): Promise<void> {1757 const session = chrome.storage.session;1758 if (!session) return;1759 await mutateSessionKey(() => session.remove(SESSION_KEY_STORAGE));1760}17611762176317641765176617671768let vaultEpoch = 0;1769177017711772let restoreInFlight: Promise<boolean> | null = null;177317741775177617771778177917801781export async function restoreFromSession(): Promise<boolean> {1782 if (derivedKey !== null) return true;1783 if (restoreInFlight) return restoreInFlight;1784 const task = restoreFromSessionInner();1785 restoreInFlight = task;1786 try {1787 return await task;1788 } finally {1789 restoreInFlight = null;1790 }1791}17921793async function restoreFromSessionInner(): Promise<boolean> {1794 const epoch = vaultEpoch;1795 const stale = () => epoch !== vaultEpoch;1796 if (derivedKey !== null) return true;1797 if (!sessionEscrowAvailable) return false;1798 if (!chrome.storage.session) return false;1799 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1800 if (stale()) return false;1801 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1802 if (!entry?.keyHex) return false;18031804 const key = unhex(entry.keyHex);1805 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1806 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1807 const slot = await loadSlot(slotKey);1808 if (stale()) { zeroKey(key); return false; }1809 if (!slot) { await clearSessionKey(); return false; }18101811 const result = await tryDecryptSlotResult(slot, key, aadTag);1812 if (stale()) { zeroKey(key); return false; }1813 if (result.status !== "ok") { await clearSessionKey(); return false; }18141815 1816 1817 const payload = result.payload;1818 await loadSettings();1819 if (stale()) { zeroKey(key); return false; }18201821 1822 1823 let freshRoot: Uint8Array | null = null;1824 if (entry.mode === "real") {1825 freshRoot = await deriveRootKeyFromDerivedKey(key, slot);1826 if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1827 if (!freshRoot) {1828 await lock();1829 return false;1830 }1831 }18321833 if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1834 if (freshRoot) {1835 1836 await reconcilePairSecrets(freshRoot);1837 if (stale()) { zeroKey(key); zeroKey(freshRoot); return false; }1838 } else {1839 1840 await dropSessionCache();1841 1842 await notifyIdentitySessionEnded();1843 if (stale()) { zeroKey(key); return false; }1844 }1845 derivedKey = key;1846 vaultMode = entry.mode;1847 if (entry.mode === "decoy") decoySyncTeardownHook?.();1848 entries = payload.entries;1849 tombstones = payload.tombstones;1850 duressConfigured = entry.mode === "real" ? payload.duressConfigured : false;1851 if (freshRoot) rootKey = freshRoot;1852 buildDomainIndexSync(entries);1853 touchAutoLock();18541855 await lockedSyncUnlockHook?.().catch((error) => {1856 console.warn("[Peach sync] locked inbox drain failed:", error);1857 });1858 if (stale()) return false;18591860 return true;1861}18621863export async function lock(): Promise<void> {1864 1865 1866 1867 1868 1869 1870 1871 1872 1873 vaultEpoch++;1874 if (derivedKey) {1875 zeroKey(derivedKey);1876 derivedKey = null;1877 }1878 if (rootKey) {1879 rootKey.fill(0);1880 rootKey = null;1881 }18821883 entries = [];1884 tombstones = new Map();1885 domainIndex = new Map();1886 vaultMode = "real";1887 duressConfigured = false;1888 clearAutoLock();1889 await clearSessionKey();1890 1891 1892 await notifyIdentitySessionEnded();1893 await clearRecoveryConfirmPending();1894}18951896export function isUnlocked(): boolean {1897 return derivedKey !== null;1898}18991900export function isDecoyMode(): boolean {1901 return derivedKey !== null && vaultMode === "decoy";1902}19031904190519061907export async function unlockWithKey(1908 masterKeyHex: string,1909): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1910 const epoch = vaultEpoch;1911 const stale = () => epoch !== vaultEpoch;1912 let masterKeyBytes: Uint8Array | null = null;1913 try {1914 masterKeyBytes = unhex(masterKeyHex);1915 const stored = await loadSlot(SLOT_A);1916 if (!stored) return { ok: false, error: "invalid_key" };1917 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1918 if (!payload || stale()) {1919 zeroKey(masterKeyBytes);1920 return { ok: false, error: "invalid_key" };1921 }19221923 derivedKey = masterKeyBytes;1924 vaultMode = "real";1925 entries = payload.entries;1926 tombstones = payload.tombstones;1927 duressConfigured = payload.duressConfigured;1928 buildDomainIndexSync(entries);1929 await loadSettings();1930 touchAutoLock();19311932 rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1933 if (!rootKey) {1934 await lock();1935 return { ok: false, error: "invalid_key" };1936 }1937 await reconcilePairSecrets(rootKey);1938 if (stale()) {1939 await lock();1940 return { ok: false, error: "invalid_key" };1941 }1942 await saveSessionKey(masterKeyBytes, "real", epoch);1943 if (stale()) return { ok: false, error: "invalid_key" };1944 return { ok: true, entries };1945 } catch {1946 if (masterKeyBytes && derivedKey !== masterKeyBytes) zeroKey(masterKeyBytes);1947 return { ok: false, error: "invalid_key" };1948 }1949}195019511952195319541955195619571958export async function exportDuressSlotForAdoption(): Promise<string | null> {1959 if (!(await isDuressEnabled())) return null;1960 const slotB = await loadSlot(SLOT_B);1961 if (!slotB) return null;1962 return JSON.stringify(slotB);1963}19641965196619671968196919701971export async function installAdoptedDuressSlot(json: string): Promise<void> {1972 let parsed: StoredVault;1973 try {1974 const raw = JSON.parse(json) as unknown;1975 if (!hasValidVaultSchema(raw)) {1976 throw new Error("invalid_duress_slot_shape");1977 }1978 parsed = raw;1979 } catch (err) {1980 if (err instanceof SyntaxError) {1981 throw new Error("invalid_duress_slot_shape");1982 }1983 throw err;1984 }1985 await writeSlot(SLOT_B, parsed);1986}19871988198919901991199219931994199519961997199819992000200120022003200420052006export async function unlockFromCodex(params: {2007 slotA: string; 2008 slotB: string | null; 2009 timestamp: number;2010 masterPassphrase: string;2011}): Promise<2012 | { ok: true; entryCount: number }2013 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" | "missing_root_escrow" }2014> {2015 let parsedSlotA: StoredVault;2016 let parsedSlotB: StoredVault | null = null;2017 try {2018 const rawA = JSON.parse(params.slotA) as unknown;2019 if (!hasValidVaultSchema(rawA)) {2020 return { ok: false, reason: "invalid_slot_format" };2021 }2022 parsedSlotA = rawA;2023 if (params.slotB !== null) {2024 const rawB = JSON.parse(params.slotB) as unknown;2025 if (!hasValidVaultSchema(rawB)) {2026 return { ok: false, reason: "invalid_slot_format" };2027 }2028 parsedSlotB = rawB;2029 }2030 } catch {2031 return { ok: false, reason: "invalid_slot_format" };2032 }20332034 2035 2036 2037 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));2038 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");2039 if (!trialPayload) {2040 zeroKey(trialKey);2041 return { ok: false, reason: "wrong_passphrase" };2042 }2043 const hasEscrow =2044 !!parsedSlotA.pw_root_ciphertext && !!parsedSlotA.pw_root_nonce && !!parsedSlotA.pw_root_tag;2045 if (!hasEscrow) {2046 zeroKey(trialKey);2047 return { ok: false, reason: "missing_root_escrow" };2048 }2049 const stagedRootKey = await deriveRootKeyFromDerivedKey(trialKey, parsedSlotA);2050 if (!stagedRootKey) {2051 zeroKey(trialKey);2052 return { ok: false, reason: "wrong_passphrase" };2053 }2054 zeroKey(stagedRootKey);2055 zeroKey(trialKey);20562057 2058 const stagedSlotB = parsedSlotB ?? generateNoiseBlob(parsedSlotA.ciphertext.length / 2);20592060 2061 20622063 2064 2065 await takeVaultSnapshot("restore");2066 const previousSlotA = await loadSlot(SLOT_A);2067 const previousSlotB = await loadSlot(SLOT_B);2068 let slotACommitted = false;2069 let slotBCommitted = false;2070 try {2071 await writeSlot(SLOT_A, parsedSlotA);2072 slotACommitted = true;2073 await writeSlot(SLOT_B, stagedSlotB);2074 slotBCommitted = true;2075 await chrome.storage.local.set({2076 peach_last_modified_at: String(params.timestamp),2077 });2078 } catch (err) {2079 if (slotBCommitted && previousSlotB) await writeSlot(SLOT_B, previousSlotB);2080 if (slotACommitted && previousSlotA) await writeSlot(SLOT_A, previousSlotA);2081 throw err;2082 }20832084 2085 2086 const ok = await unlock(params.masterPassphrase);2087 if (!ok) {2088 2089 return { ok: false, reason: "wrong_passphrase" };2090 }2091 return { ok: true, entryCount: getEntries().length };2092}20932094209520962097export function getDerivedKeyHex(): string {2098 if (!derivedKey) throw new Error("vault_not_unlocked");2099 return hex(derivedKey);2100}21012102210321042105210621072108210921102111211221132114211521162117export function getRootKeyHex(): string | null {2118 return rootKey ? hex(rootKey) : null;2119}21202121export function getEntries(): VaultEntry[] {2122 if (!derivedKey) throw new Error("vault_locked");2123 return entries.filter((entry) => !entry.deletedAt);2124}21252126const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;21272128export function getIdentities(): SavedIdentity[] {2129 if (!derivedKey) throw new Error("vault_locked");2130 const active = entries.filter((e) => !e.deletedAt && e.type === "login");21312132 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();2133 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();21342135 for (const entry of active) {2136 const domain = extractDomain(entry.url ?? "") ?? "";2137 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;21382139 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {2140 const existing = map.get(v);2141 if (!existing || modifiedAt > existing.lastUsed) {2142 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });2143 } else {2144 map.set(v, { ...existing, count: existing.count + 1 });2145 }2146 }21472148 const uVal = entry.username?.trim();2149 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);21502151 2152 2153 const eVal = entry.email?.trim();2154 if (eVal) tally(emailMap, eVal);2155 }21562157 const toIdentity = (type: "email" | "username") =>2158 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({2159 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,2160 });21612162 return [2163 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),2164 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),2165 ];2166}21672168export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {2169 if (!derivedKey) throw new Error("vault_locked");21702171 const now = Date.now();2172 const added: VaultEntry[] = [];2173 for (const draft of drafts) {2174 try {2175 const sanitized = sanitizeEntryDraft(draft);2176 const nextEntry: VaultEntry = {2177 ...sanitized,2178 id: crypto.randomUUID(),2179 createdAt: now,2180 modifiedAt: monotonicEntryTimestamp(),2181 };2182 entries.push(nextEntry);2183 added.push(nextEntry);2184 } catch {2185 2186 }2187 }21882189 if (added.length > 0) {2190 await persistEntries();2191 }21922193 return added;2194}219521962197219821992200220122022203export async function restoreEntries(2204 incoming: VaultEntry[],2205 mode: "replace" | "merge"2206): Promise<{ added: number; updated: number; skipped: number }> {2207 if (!derivedKey) throw new Error("vault_locked");22082209 2210 2211 2212 if (mode === "replace" && incoming.length === 0) {2213 throw new Error("refuse to replace with empty entry set");2214 }22152216 let added = 0;2217 let updated = 0;2218 let skipped = 0;22192220 if (mode === "replace") {2221 entries = [];2222 for (const entry of incoming) {2223 const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));2224 if (!normalized) {2225 skipped += 1;2226 continue;2227 }2228 entries.push(normalized);2229 if (normalized.deletedAt) {2230 tombstones.set(2231 normalized.id,2232 Math.max(tombstones.get(normalized.id) ?? 0, normalized.deletedAt),2233 );2234 } else {2235 tombstones.delete(normalized.id);2236 }2237 added += 1;2238 }2239 } else {2240 const byId = new Map<string, VaultEntry>();2241 for (const e of entries) byId.set(e.id, e);22422243 for (const entry of incoming) {2244 const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));2245 if (!normalized) {2246 skipped += 1;2247 continue;2248 }2249 const existing = byId.get(normalized.id);2250 if (!existing) {2251 entries.push(normalized);2252 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);2253 else tombstones.delete(normalized.id);2254 added += 1;2255 } else if (normalized.modifiedAt > existing.modifiedAt) {2256 const idx = entries.findIndex((e) => e.id === existing.id);2257 if (idx !== -1) {2258 entries[idx] = normalized;2259 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);2260 else tombstones.delete(normalized.id);2261 updated += 1;2262 }2263 } else {2264 skipped += 1;2265 }2266 }2267 }22682269 await persistEntries("import");2270 return { added, updated, skipped };2271}227222732274227522762277227822792280228122822283228422852286228722882289const ENTRY_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;22902291function naturalizeRestoredId(entry: VaultEntry | null): VaultEntry | null {2292 if (!entry || ENTRY_ID_SHAPE.test(entry.id)) return entry;2293 return { ...entry, id: crypto.randomUUID() };2294}229522962297229822992300230123022303function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {2304 if (!entry || typeof entry !== "object") return null;2305 const title = typeof entry.title === "string" ? entry.title.trim() : "";2306 if (!title) return null;2307 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();2308 const now = Date.now();2309 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;2310 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;23112312 2313 2314 const normalized = normalizeEntry({2315 ...entry,2316 id,2317 title,2318 type: entry.type ?? "login",2319 createdAt,2320 modifiedAt,2321 });23222323 if (normalized.passkey) {2324 normalized.passkey = sanitizePasskey(normalized.passkey);2325 }2326 if (normalized.canary) {2327 normalized.canary = sanitizeCanary(normalized.canary);2328 }2329 2330 normalized.tags = sanitizeTags(normalized.tags);23312332 2333 2334 if (2335 normalized.type === "login" &&2336 !normalized.passkey &&2337 typeof normalized.password !== "string"2338 ) {2339 return null;2340 }2341 if (entry.passkey && !normalized.passkey) {2342 2343 2344 return null;2345 }23462347 return normalized;2348}2349235023512352235323542355function sanitizePasskey(pk: unknown): PasskeyData | undefined {2356 if (!pk || typeof pk !== "object") return undefined;2357 const r = pk as Record<string, unknown>;2358 if (2359 typeof r.rpId !== "string" || !r.rpId ||2360 typeof r.credentialId !== "string" || !r.credentialId ||2361 typeof r.publicKeyCose !== "string" ||2362 typeof r.userHandle !== "string" ||2363 typeof r.userName !== "string"2364 ) {2365 return undefined;2366 }2367 return {2368 rpId: r.rpId,2369 rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,2370 credentialId: r.credentialId,2371 publicKeyCose: r.publicKeyCose,2372 privateKeyJwk:2373 r.privateKeyJwk && typeof r.privateKeyJwk === "object"2374 ? (r.privateKeyJwk as JsonWebKey)2375 : undefined,2376 userHandle: r.userHandle,2377 userName: r.userName,2378 userDisplayName:2379 typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,2380 algorithm: r.algorithm === -8 ? -8 : -7,2381 signCount: typeof r.signCount === "number" ? r.signCount : 0,2382 aaguid: typeof r.aaguid === "string" ? r.aaguid : "",2383 backupEligible: r.backupEligible === true,2384 backupState: r.backupState === true,2385 lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,2386 browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,2387 transports: Array.isArray(r.transports)2388 ? r.transports.filter((t): t is string => typeof t === "string")2389 : undefined,2390 createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),2391 };2392}23932394239523962397239823992400240124022403function sanitizeCanary(c: unknown): Canary | undefined {2404 if (!c || typeof c !== "object") return undefined;2405 const r = c as Record<string, unknown>;2406 if (typeof r.webhookUrl !== "string") return undefined;2407 let url: URL;2408 try {2409 url = new URL(r.webhookUrl);2410 } catch {2411 return undefined;2412 }2413 if (url.protocol !== "https:") return undefined;2414 return {2415 webhookUrl: url.toString(),2416 label: typeof r.label === "string" ? r.label : undefined,2417 lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,2418 pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,2419 };2420}24212422242324242425242624272428function sanitizeTags(tags: unknown): string[] | undefined {2429 if (!Array.isArray(tags)) return undefined;2430 const out: string[] = [];2431 for (const t of tags) {2432 if (typeof t !== "string") continue;2433 const trimmed = t.trim();2434 if (!trimmed) continue;2435 out.push(trimmed.slice(0, 256));2436 if (out.length >= 255) break;2437 }2438 return out.length > 0 ? out : undefined;2439}244024412442244324442445export async function clearVaultEntries(2446 confirmation: "REPLACE_ALL"2447): Promise<void> {2448 if (confirmation !== "REPLACE_ALL") {2449 throw new Error("clearVaultEntries requires explicit confirmation");2450 }2451 if (!derivedKey) throw new Error("vault_locked");2452 for (const entry of entries) {2453 if (!entry.deletedAt) {2454 const stamp = monotonicEntryTimestamp(Math.max(2455 entry.modifiedAt,2456 tombstones.get(entry.id) ?? 0,2457 ));2458 entry.deletedAt = stamp;2459 entry.modifiedAt = stamp;2460 tombstones.set(entry.id, stamp);2461 }2462 }2463 await persistEntries();2464}24652466export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {2467 if (!derivedKey) throw new Error("vault_locked");24682469 const sanitized = sanitizeEntryDraft(entry);2470 const now = Date.now();2471 const stamp = monotonicEntryTimestamp();2472 const nextEntry: VaultEntry = {2473 ...sanitized,2474 id: crypto.randomUUID(),2475 createdAt: now,2476 modifiedAt: stamp,2477 2478 2479 passwordChangedAt: sanitized.password ? stamp : undefined,2480 };24812482 2483 2484 2485 const rollback = entries;2486 entries = [...entries, nextEntry];2487 try {2488 await persistEntries();2489 } catch (err) {2490 entries = rollback;2491 throw err;2492 }24932494 2495 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {2496 checkBreach(nextEntry.password).then(async (count) => {2497 if (count > 0) {2498 const idx = entries.findIndex((e) => e.id === nextEntry.id);2499 if (idx !== -1) {2500 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };2501 await persistEntries();2502 }2503 }2504 });2505 }25062507 fireSyncTrigger("vault_change");25082509 return nextEntry;2510}25112512251325142515251625172518export async function consolidatePasskeyEntries(): Promise<void> {2519 if (!derivedKey) return;2520 const { merged } = mergePasskeyOnlyIntoLogins(entries);2521 if (merged) {2522 await persistEntries();2523 }2524}25252526export async function updateEntry(2527 id: string,2528 patch: Partial<VaultEntry>2529): Promise<void> {2530 if (!derivedKey) throw new Error("vault_locked");25312532 const idx = entries.findIndex((entry) => entry.id === id);2533 if (idx === -1 || entries[idx].deletedAt) {2534 throw new Error("entry_not_found");2535 }25362537 const current = entries[idx];2538 const sanitizedPatch = sanitizeEntryPatch(patch);2539 2540 2541 2542 const passwordChanged =2543 "password" in patch &&2544 typeof sanitizedPatch.password === "string" &&2545 sanitizedPatch.password !== current.password;2546 const stamp = monotonicEntryTimestamp(current.modifiedAt);2547 2548 2549 2550 2551 2552 2553 2554 const priorPassword = current.password;2555 const shouldCapture =2556 passwordChanged &&2557 typeof priorPassword === "string" &&2558 priorPassword.length > 0 &&2559 current.passwordHistory?.[0]?.password !== priorPassword;2560 const nextHistory = shouldCapture2561 ? [2562 { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },2563 ...(current.passwordHistory ?? []),2564 ].slice(0, PASSWORD_HISTORY_MAX)2565 : current.passwordHistory;2566 entries[idx] = {2567 ...current,2568 ...sanitizedPatch,2569 modifiedAt: stamp,2570 ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),2571 };2572257325742575 2576 if (entries[idx].type === "card" && !entries[idx].cardNumber) {2577 entries[idx] = current;2578 throw new Error("card number is required for card entries");2579 }25802581 2582 2583 2584 2585 try {2586 await persistEntries();2587 } catch (err) {2588 entries[idx] = current;2589 throw err;2590 }25912592 2593 2594 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {2595 entries[idx] = { ...entries[idx], breach: undefined };2596 await persistEntries();2597 const password = entries[idx].password;2598 if (password) {2599 checkBreach(password).then(async (count) => {2600 const i = entries.findIndex((e) => e.id === id);2601 if (i !== -1 && count > 0) {2602 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };2603 await persistEntries();2604 }2605 });2606 }2607 }26082609 fireSyncTrigger("vault_change");2610}26112612export async function deleteEntry(id: string): Promise<void> {2613 if (!derivedKey) throw new Error("vault_locked");26142615 const entry = entries.find((item) => item.id === id);2616 if (!entry || entry.deletedAt) {2617 throw new Error("entry_not_found");2618 }26192620 2621 2622 2623 const previousModifiedAt = entry.modifiedAt;2624 const previousTombstone = tombstones.get(id);2625 const stamp = monotonicEntryTimestamp(Math.max(entry.modifiedAt, previousTombstone ?? 0));2626 entry.deletedAt = stamp;2627 entry.modifiedAt = stamp;2628 tombstones.set(id, stamp);2629 2630 2631 try {2632 await persistEntries();2633 } catch (err) {2634 delete entry.deletedAt;2635 entry.modifiedAt = previousModifiedAt;2636 if (previousTombstone === undefined) tombstones.delete(id);2637 else tombstones.set(id, previousTombstone);2638 throw err;2639 }2640 fireSyncTrigger("vault_change");2641}26422643export function getDeletedEntries(): VaultEntry[] {2644 if (!derivedKey) return [];2645 return entries.filter((e) => !!e.deletedAt);2646}264726482649export function getTombstoneManifest(): Record<string, number> {2650 if (!derivedKey) return {};2651 return Object.fromEntries(tombstones);2652}2653265426552656265726582659export async function getVaultVersion(): Promise<number> {2660 try {2661 const raw = await chrome.storage.local.get("peach_last_modified_at");2662 const v = (raw as Record<string, unknown>).peach_last_modified_at;2663 if (typeof v === "number") return v;2664 if (typeof v === "string" && v !== "") {2665 const n = Number.parseInt(v, 10);2666 return Number.isFinite(n) ? n : 0;2667 }2668 return 0;2669 } catch {2670 return 0;2671 }2672}2673267426752676267726782679268026812682268326842685export async function applyMergedRemoteEntries(2686 remoteEntries: VaultEntry[],2687 remoteTombstones: Record<string, number>,2688): Promise<{ received: number; applied: number; conflicts: number }> {2689 2690 2691 2692 2693 2694 2695 if (!derivedKey || vaultMode === "decoy") throw new Error("vault_locked");26962697 const received = remoteEntries.length + Object.keys(remoteTombstones).length;2698 let applied = 0;2699 let conflicts = 0;27002701 const byId = new Map<string, VaultEntry>();2702 for (const e of entries) byId.set(e.id, e);27032704 const mergeNow = Date.now();2705 const remoteTombstoneTimes = new Map<string, number>();2706 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2707 if (typeof rawDeletedAt === "number" && Number.isFinite(rawDeletedAt) && rawDeletedAt > 0) {2708 remoteTombstoneTimes.set(id, clampSyncTimestamp(rawDeletedAt, mergeNow));2709 }2710 }27112712 for (const remote of remoteEntries) {2713 const sanitized = sanitizeRestoredEntry(remote);2714 if (!sanitized) continue;2715 2716 2717 2718 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2719 if (typeof sanitized.deletedAt === "number") {2720 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2721 }2722 const remoteTombstoneTs = remoteTombstoneTimes.get(sanitized.id);2723 const localTombstoneTs = tombstones.get(sanitized.id);2724 const tombstoneTs = Math.max(remoteTombstoneTs ?? 0, localTombstoneTs ?? 0);2725 if (tombstoneTs > 0 && tombstoneTs >= sanitized.modifiedAt) {2726 continue;2727 }2728 const local = byId.get(sanitized.id);2729 if (!local) {2730 entries.push(sanitized);2731 byId.set(sanitized.id, sanitized);2732 tombstones.delete(sanitized.id);2733 applied += 1;2734 } else if (sanitized.modifiedAt > local.modifiedAt) {2735 const idx = entries.findIndex((e) => e.id === local.id);2736 if (idx !== -1) {2737 entries[idx] = sanitized;2738 byId.set(sanitized.id, sanitized);2739 tombstones.delete(sanitized.id);2740 applied += 1;2741 if (!local.deletedAt) conflicts += 1;2742 }2743 }2744 }27452746 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2747 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt) || rawDeletedAt <= 0) continue;2748 2749 const deletedAt = remoteTombstoneTimes.get(id)!;2750 const knownTombstone = tombstones.get(id);2751 if (knownTombstone !== undefined && knownTombstone >= deletedAt) continue;2752 const local = byId.get(id);2753 if (!local) {2754 tombstones.set(id, deletedAt);2755 applied += 1;2756 continue;2757 }2758 if (local.deletedAt && local.deletedAt >= deletedAt) continue;2759 if (deletedAt < local.modifiedAt) continue;2760 const idx = entries.findIndex((e) => e.id === id);2761 if (idx !== -1) {2762 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2763 tombstones.set(id, deletedAt);2764 applied += 1;2765 }2766 }27672768 if (applied > 0 && vaultMode === "real") {2769 await persistEntries("sync_received");2770 buildDomainIndexSync(entries);2771 }2772 return { received, applied, conflicts };2773}27742775export async function restoreEntry(id: string): Promise<void> {2776 if (!derivedKey) throw new Error("vault_locked");2777 const entry = entries.find((e) => e.id === id && e.deletedAt);2778 if (!entry) throw new Error("deleted entry not found");2779 const previousDeletedAt = entry.deletedAt;2780 const previousModifiedAt = entry.modifiedAt;2781 const previousTombstone = tombstones.get(id);2782 delete entry.deletedAt;2783 entry.modifiedAt = monotonicEntryTimestamp(Math.max(2784 entry.modifiedAt,2785 previousTombstone ?? 0,2786 ));2787 tombstones.delete(id);2788 try {2789 await persistEntries();2790 } catch (err) {2791 entry.deletedAt = previousDeletedAt;2792 entry.modifiedAt = previousModifiedAt;2793 if (previousTombstone !== undefined) tombstones.set(id, previousTombstone);2794 throw err;2795 }2796 buildDomainIndexSync(entries);2797}27982799export async function purgeEntry(id: string): Promise<void> {2800 if (!derivedKey) throw new Error("vault_locked");2801 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2802 if (idx === -1) throw new Error("deleted entry not found");2803 const entry = entries[idx]!;2804 tombstones.set(id, Math.max(tombstones.get(id) ?? 0, entry.deletedAt!));2805 entries.splice(idx, 1);2806 await persistEntries();2807}280828092810export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2811 if (!derivedKey || retentionMs === 0) return 0;2812 const cutoff = Date.now() - retentionMs;2813 const before = entries.length;2814 for (const entry of entries) {2815 if (entry.deletedAt && entry.deletedAt < cutoff) {2816 tombstones.set(entry.id, Math.max(tombstones.get(entry.id) ?? 0, entry.deletedAt));2817 }2818 }2819 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2820 const purged = before - entries.length;2821 if (purged > 0) await persistEntries();2822 return purged;2823}28242825export function queryDomain(domain: string): VaultEntry[] {2826 return domainIndex.get(domain) ?? [];2827}28282829export function getEntryById(id: string): VaultEntry | undefined {2830 return entries.find((entry) => entry.id === id && !entry.deletedAt);2831}28322833async function persistSlotA(2834 reason: VaultSnapshot["reason"] = "local_save",2835): Promise<StoredVault> {2836 if (!derivedKey) throw new Error("vault_locked");2837 const epoch = vaultEpoch;2838 const throwIfStale = () => {2839 if (epoch !== vaultEpoch || !derivedKey) throw new Error("vault_locked");2840 };28412842 const slotA = await loadSlot(SLOT_A);2843 throwIfStale();2844 if (!slotA) throw new Error("missing slot A");28452846 2847 2848 await takeVaultSnapshot(reason);2849 throwIfStale();28502851 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2852 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");2853 throwIfStale();28542855 2856 const nextSlotA: StoredVault = {2857 ...slotA,2858 nonce,2859 ciphertext,2860 tag,2861 2862 };28632864 2865 2866 2867 2868 2869 2870 let slotACommitted = false;2871 try {2872 throwIfStale();2873 await writeSlot(SLOT_A, nextSlotA);2874 slotACommitted = true;2875 throwIfStale();28762877 2878 2879 2880 if (!duressConfigured) {2881 throwIfStale();2882 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2883 throwIfStale();2884 }2885 2886 2887 2888 2889 2890 if (reason !== "sync_received") {2891 throwIfStale();2892 await stampLocalVaultMutation();2893 throwIfStale();2894 }2895 } catch (err) {2896 2897 2898 if (slotACommitted) await writeSlot(SLOT_A, slotA).catch(() => {});2899 if (epoch !== vaultEpoch || !derivedKey) throw new Error("vault_locked");2900 throw err;2901 }29022903 return nextSlotA;2904}29052906async function persistSlotB(): Promise<void> {2907 if (!derivedKey) throw new Error("vault_locked");29082909 const slotB = await loadSlot(SLOT_B);2910 if (!slotB) throw new Error("missing slot B");29112912 2913 2914 2915 2916 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2917 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");29182919 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2920 2921 2922 2923 try {2924 await stampLocalVaultMutation();2925 } catch (err) {2926 await writeSlot(SLOT_B, slotB).catch(() => {});2927 throw err;2928 }2929}29302931async function persistEntries(2932 reason: VaultSnapshot["reason"] = "local_save",2933): Promise<void> {2934 if (!derivedKey) throw new Error("vault_locked");29352936 2937 2938 const previousTombstones = tombstones;2939 tombstones = new Map(tombstones);2940 reconcileTombstones();2941 try {2942 if (vaultMode === "decoy") {2943 await persistSlotB();2944 } else {2945 await persistSlotA(reason);2946 }2947 } catch (err) {2948 tombstones = previousTombstones;2949 throw err;2950 }2951 buildDomainIndexSync(entries);2952 await refreshLockedSyncState();2953}2954295529562957295829592960function allowHttpUrl(url: string | undefined): string {2961 const trimmed = url?.trim() ?? "";2962 if (!trimmed) return "";2963 try {2964 const parsed = new URL(trimmed);2965 if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return "";2966 } catch {2967 2968 }2969 return trimmed;2970}29712972function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2973 if (!Array.isArray(values)) return undefined;2974 const seen = new Set<string>();2975 const cleaned: string[] = [];2976 for (const value of values) {2977 const trimmed = typeof value === "string" ? value.trim() : "";2978 if (!trimmed) continue;2979 if (!allowHttpUrl(trimmed)) continue;2980 const key = trimmed.toLowerCase();2981 if (seen.has(key)) continue;2982 seen.add(key);2983 cleaned.push(trimmed);2984 }2985 return cleaned.length > 0 ? cleaned : undefined;2986}29872988function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2989 const title = entry.title.trim();2990 const type: VaultEntry["type"] = entry.type ?? "login";29912992 if (!title) throw new Error("title is required");29932994 if (entry.passkey) {2995 const p = entry.passkey;2996 if (2997 !p.rpId ||2998 !p.credentialId ||2999 !p.publicKeyCose ||3000 !p.userHandle ||3001 !p.userName ||3002 (p.algorithm !== -7 && p.algorithm !== -8)3003 ) {3004 throw new Error("invalid passkey credential");3005 }3006 }3007 if (type === "card") {3008 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {3009 throw new Error("cardholder name and card number are required");3010 }3011 }30123013 return {3014 type,3015 title,3016 username: type === "login" ? entry.username?.trim() ?? "" : undefined,3017 email: type === "login" ? entry.email?.trim() || undefined : undefined,3018 password: type === "login" ? entry.password : undefined,3019 url: type === "login" ? allowHttpUrl(entry.url) : undefined,3020 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,3021 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,3022 3023 3024 totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,3025 totpDigits: type === "login" ? entry.totpDigits : undefined,3026 totpPeriod: type === "login" ? entry.totpPeriod : undefined,3027 notes: entry.notes?.trim() || undefined,3028 breach: type === "login" ? entry.breach : undefined,3029 3030 3031 canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,3032 tags: sanitizeTags(entry.tags),3033 passkey: entry.passkey,3034 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,3035 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,3036 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,3037 expYear: type === "card" ? entry.expYear?.trim() : undefined,3038 cvc: type === "card" ? entry.cvc?.trim() : undefined,3039 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,3040 network: type === "card" ? entry.network : undefined,3041 last4: type === "card" ? entry.last4?.trim() : undefined,3042 };3043}30443045function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {3046 const nextPatch: Partial<VaultEntry> = {};30473048 if ("type" in patch) {3049 nextPatch.type = patch.type;3050 }30513052 if ("title" in patch) {3053 const title = patch.title?.trim() ?? "";3054 if (!title) throw new Error("title is required");3055 nextPatch.title = title;3056 }30573058 if ("url" in patch) {3059 nextPatch.url = allowHttpUrl(patch.url);3060 }30613062 if ("additionalUrls" in patch) {3063 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];3064 const seen = new Set<string>();3065 const cleaned: string[] = [];3066 for (const value of incoming) {3067 const trimmed = typeof value === "string" ? value.trim() : "";3068 if (!trimmed || !allowHttpUrl(trimmed)) continue;3069 const key = trimmed.toLowerCase();3070 if (seen.has(key)) continue;3071 seen.add(key);3072 cleaned.push(trimmed);3073 }3074 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;3075 }30763077 if ("username" in patch) {3078 nextPatch.username = patch.username?.trim() ?? "";3079 }30803081 if ("email" in patch) {3082 nextPatch.email = patch.email?.trim() || undefined;3083 }30843085 if ("password" in patch) {3086 const password = patch.password ?? "";3087 3088 nextPatch.password = password || undefined;3089 }30903091 if ("totp" in patch) {3092 nextPatch.totp = patch.totp?.trim() || undefined;3093 }30943095 if ("totpAlgorithm" in patch) {3096 nextPatch.totpAlgorithm = patch.totpAlgorithm;3097 }30983099 if ("totpDigits" in patch) {3100 nextPatch.totpDigits = patch.totpDigits;3101 }31023103 if ("totpPeriod" in patch) {3104 nextPatch.totpPeriod = patch.totpPeriod;3105 }31063107 if ("notes" in patch) {3108 nextPatch.notes = patch.notes?.trim() || undefined;3109 }31103111 if ("breach" in patch) {3112 nextPatch.breach = patch.breach;3113 }31143115 if ("canary" in patch) {3116 3117 3118 nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;3119 }31203121 if ("passkey" in patch) {3122 3123 3124 if (patch.passkey === undefined || patch.passkey === null) {3125 nextPatch.passkey = undefined;3126 } else {3127 const sanitized = sanitizePasskey(patch.passkey);3128 if (!sanitized) throw new Error("invalid passkey credential");3129 nextPatch.passkey = sanitized;3130 }3131 }31323133 if ("tags" in patch) {3134 3135 nextPatch.tags = sanitizeTags(patch.tags);3136 }31373138 if ("cardholderName" in patch) {3139 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;3140 }3141 if ("cardNumber" in patch) {3142 const n = patch.cardNumber ?? "";3143 nextPatch.cardNumber = n || undefined;3144 }3145 if ("expMonth" in patch) {3146 nextPatch.expMonth = patch.expMonth?.trim() || undefined;3147 }3148 if ("expYear" in patch) {3149 nextPatch.expYear = patch.expYear?.trim() || undefined;3150 }3151 if ("cvc" in patch) {3152 nextPatch.cvc = patch.cvc?.trim() || undefined;3153 }3154 if ("cardNickname" in patch) {3155 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;3156 }3157 if ("network" in patch) {3158 nextPatch.network = patch.network;3159 }3160 if ("last4" in patch) {3161 nextPatch.last4 = patch.last4?.trim() || undefined;3162 }31633164 return nextPatch;3165}31663167function normalizeEntry(entry: VaultEntry): VaultEntry {3168 const base: VaultEntry = {3169 id: entry.id,3170 type: entry.type,3171 title: entry.title,3172 username: entry.username,3173 email: entry.email,3174 password: entry.password,3175 url: entry.url,3176 additionalUrls:3177 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)3178 ? entry.additionalUrls3179 : undefined,3180 totp: entry.totp,3181 3182 totpAlgorithm: entry.totpAlgorithm,3183 totpDigits: entry.totpDigits,3184 totpPeriod: entry.totpPeriod,3185 notes: entry.notes,3186 breach: entry.breach,3187 3188 3189 3190 passwordChangedAt:3191 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,3192 3193 3194 3195 passwordHistory:3196 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)3197 ? entry.passwordHistory3198 : undefined,3199 canary: entry.canary,3200 passkey: entry.passkey,3201 createdAt: entry.createdAt,3202 modifiedAt: entry.modifiedAt,3203 deletedAt: entry.deletedAt,3204 tags: entry.tags,3205 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,3206 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,3207 expMonth: "expMonth" in entry ? entry.expMonth : undefined,3208 expYear: "expYear" in entry ? entry.expYear : undefined,3209 cvc: "cvc" in entry ? entry.cvc : undefined,3210 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,3211 network: "network" in entry ? entry.network : undefined,3212 last4: "last4" in entry ? entry.last4 : undefined,3213 };32143215 return base;3216}3217321832193220322132223223function entryDupeKey(e: VaultEntry): string {3224 if (e.type === "note") return `note|${e.title.toLowerCase()}`;3225 3226 3227 3228 3229 if (e.passkey) {3230 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;3231 }3232 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;3233}323432353236export function countDuplicates(): number {3237 const best = new Map<string, VaultEntry>();3238 let dupeCount = 0;3239 for (const e of entries.filter((e) => !e.deletedAt)) {3240 const key = entryDupeKey(e);3241 const existing = best.get(key);3242 if (!existing) {3243 best.set(key, e);3244 } else {3245 dupeCount++;3246 3247 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {3248 best.set(key, e);3249 }3250 }3251 }3252 return dupeCount;3253}325432553256325732583259export async function deduplicateEntries(): Promise<number> {3260 if (!derivedKey) throw new Error("vault_locked");3261 3262 3263 if (vaultMode === "decoy") {3264 throw new Error("deduplicate disabled in decoy mode");3265 }32663267 const best = new Map<string, VaultEntry>();3268 for (const e of entries.filter((en) => !en.deletedAt)) {3269 const key = entryDupeKey(e);3270 const existing = best.get(key);3271 if (!existing) {3272 best.set(key, e);3273 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {3274 best.set(key, e);3275 }3276 }32773278 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));3279 let removed = 0;3280 entries = entries.map((e) => {3281 if (!e.deletedAt && !keepIds.has(e.id)) {3282 removed++;3283 const deletedAt = monotonicEntryTimestamp(Math.max(3284 e.modifiedAt,3285 tombstones.get(e.id) ?? 0,3286 ));3287 tombstones.set(e.id, deletedAt);3288 return { ...e, deletedAt };3289 }3290 return e;3291 });32923293 if (removed > 0) await persistEntries();3294 return removed;3295}329632973298329933003301330233033304const HISTORY_KEY = "peach_vault_history";3305const HISTORY_MAX = 5;330633073308330933103311function bucketEntryCount(n: number): number {3312 if (n <= 0) return 0;3313 if (n <= 10) return 10;3314 if (n <= 25) return 25;3315 if (n <= 50) return 50;3316 if (n <= 100) return 100;3317 if (n <= 250) return 250;3318 if (n <= 500) return 500;3319 return Math.ceil(n / 500) * 500;3320}33213322332333243325332633273328export async function takeVaultSnapshot(3329 reason: VaultSnapshot["reason"],3330): Promise<void> {3331 try {3332 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);3333 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];3334 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];33353336 3337 3338 3339 if (!slotA) return;33403341 const vault_a = JSON.stringify(slotA);3342 const vault_b = JSON.stringify(slotB ?? slotA);33433344 3345 3346 3347 3348 3349 3350 let entryCount: number | null = null;3351 if (derivedKey && vaultMode === "real") {3352 try {3353 entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);3354 } catch {3355 entryCount = null;3356 }3357 }33583359 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3360 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)3361 ? existingRaw3362 : { snapshots: [] };33633364 3365 3366 3367 const latest = history.snapshots[0];3368 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {3369 return;3370 }33713372 const snapshot: VaultSnapshot = {3373 id: crypto.randomUUID(),3374 savedAt: Date.now(),3375 reason,3376 vault_a,3377 vault_b,3378 entryCount,3379 };33803381 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);3382 await chrome.storage.local.set({ [HISTORY_KEY]: history });3383 } catch (err) {3384 3385 3386 3387 3388 console.warn('[Peach] takeVaultSnapshot failed:', err);3389 if (String(err).includes('QUOTA') || String(err).includes('quota')) {3390 try {3391 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });3392 } catch {3393 3394 }3395 }3396 }3397}339833993400export async function clearVaultHistory(): Promise<void> {3401 try {3402 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });3403 } catch {3404 3405 }3406}340734083409export async function getVaultHistory(): Promise<VaultSnapshot[]> {3410 3411 3412 3413 if (vaultMode === "decoy") return [];3414 const raw = await chrome.storage.local.get(HISTORY_KEY);3415 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3416 if (!history || !Array.isArray(history.snapshots)) return [];3417 return history.snapshots;3418}34193420342134223423342434253426export async function restoreVaultSnapshot(3427 snapshotId: string,3428): Promise<{ ok: true } | { ok: false; error: string }> {3429 3430 3431 3432 if (vaultMode === "decoy") return { ok: false, error: "vault_locked" };3433 const history = await getVaultHistory();3434 const snapshot = history.find((s) => s.id === snapshotId);3435 if (!snapshot) return { ok: false, error: "snapshot_not_found" };34363437 3438 3439 3440 await takeVaultSnapshot("restore");34413442 let restoredA: StoredVault;3443 let restoredB: StoredVault;3444 try {3445 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;3446 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;3447 } catch {3448 return { ok: false, error: "snapshot_corrupt" };3449 }34503451 await writeSlot(SLOT_A, restoredA);3452 await writeSlot(SLOT_B, restoredB);3453 await stampLocalVaultMutation();34543455 3456 3457 3458 await lock();34593460 return { ok: true };3461}346234633464346534663467346834693470347134723473347434753476export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {3477 if (!derivedKey || vaultMode !== "real") {3478 return { ok: false, error: "vault_locked" };3479 }3480 const slotA = await loadSlot(SLOT_A);3481 if (!slotA) return { ok: false, error: "missing_slot_a" };34823483 3484 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =3485 await generateAndWrapRootKey();34863487 3488 const newRootKey = unhex(rawRootKey);3489 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);34903491 3492 3493 3494 3495 await takeVaultSnapshot("local_save");3496 const nextSlotA: StoredVault = {3497 ...slotA,3498 recovery_salt: recoverySalt,3499 recovery_wrapped_root_key: wrappedRootKey,3500 recovery_nonce: wrappedNonce,3501 recovery_tag: wrappedTag,3502 ...pwRoot,3503 };3504 await writeSlot(SLOT_A, nextSlotA);35053506 3507 if (rootKey) zeroKey(rootKey);3508 rootKey = newRootKey;35093510 3511 3512 await notifyIdentityKeysRotated();35133514 3515 3516 3517 await clearPairedDevices();3518 await clearPairSecrets();35193520 3521 await setRecoveryConfirmPending(); 35223523 await stampLocalVaultMutation();3524 return { ok: true };3525}35263527352835293530353135323533export async function changePassword(3534 currentPassword: string,3535 newPassword: string3536): Promise<boolean> {3537 const epoch = vaultEpoch;3538 const stale = () => epoch !== vaultEpoch;3539 if (!derivedKey || vaultMode !== "real") return false;35403541 3542 const valid = await verifyMainPassword(currentPassword);3543 if (!valid) return false;35443545 const slotA = await loadSlot(SLOT_A);3546 if (!slotA) return false;3547 if (!rootKey) return false;35483549 3550 const newSalt = generateSalt();3551 const newKey = await deriveKey(newPassword, newSalt);3552 if (stale()) {3553 zeroKey(newKey);3554 return false;3555 }35563557 3558 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);3559 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");35603561 3562 3563 3564 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);35653566 const finalSlotA: StoredVault = {3567 ...slotA,3568 salt: hex(newSalt),3569 nonce,3570 ciphertext,3571 tag,3572 ...pwRoot,3573 };35743575 3576 3577 3578 await takeVaultSnapshot("local_save");35793580 3581 3582 3583 3584 await writeSlot(SLOT_A, finalSlotA);3585 if (stale()) {3586 zeroKey(newKey);3587 return false;3588 }35893590 3591 3592 3593 zeroKey(derivedKey);3594 derivedKey = newKey;35953596 3597 3598 3599 3600 if (stale()) {3601 await lock();3602 return false;3603 }3604 await saveSessionKey(newKey, "real", epoch);3605 if (stale()) return false;36063607 3608 3609 3610 await notifyIdentityKeysRotated();3611 if (stale()) return false;36123613 3614 3615 3616 if (!duressConfigured) {3617 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3618 }3619 await clearPinUnlockStorage();3620 await stampLocalVaultMutation();36213622 return true;3623}