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" }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 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));2037 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");2038 if (!trialPayload) {2039 zeroKey(trialKey);2040 return { ok: false, reason: "wrong_passphrase" };2041 }2042 2043 2044 zeroKey(trialKey);20452046 2047 2048 await takeVaultSnapshot("restore");20492050 await writeSlot(SLOT_A, parsedSlotA);2051 if (parsedSlotB) {2052 await writeSlot(SLOT_B, parsedSlotB);2053 } else {2054 2055 2056 2057 await writeSlot(2058 SLOT_B,2059 generateNoiseBlob(parsedSlotA.ciphertext.length / 2),2060 );2061 }2062 await chrome.storage.local.set({2063 peach_last_modified_at: String(params.timestamp),2064 });20652066 2067 2068 const ok = await unlock(params.masterPassphrase);2069 if (!ok) {2070 2071 2072 2073 return { ok: false, reason: "wrong_passphrase" };2074 }2075 return { ok: true, entryCount: getEntries().length };2076}20772078207920802081export function getDerivedKeyHex(): string {2082 if (!derivedKey) throw new Error("vault_not_unlocked");2083 return hex(derivedKey);2084}20852086208720882089209020912092209320942095209620972098209921002101export function getRootKeyHex(): string | null {2102 return rootKey ? hex(rootKey) : null;2103}21042105export function getEntries(): VaultEntry[] {2106 if (!derivedKey) throw new Error("vault_locked");2107 return entries.filter((entry) => !entry.deletedAt);2108}21092110const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;21112112export function getIdentities(): SavedIdentity[] {2113 if (!derivedKey) throw new Error("vault_locked");2114 const active = entries.filter((e) => !e.deletedAt && e.type === "login");21152116 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();2117 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();21182119 for (const entry of active) {2120 const domain = extractDomain(entry.url ?? "") ?? "";2121 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;21222123 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {2124 const existing = map.get(v);2125 if (!existing || modifiedAt > existing.lastUsed) {2126 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });2127 } else {2128 map.set(v, { ...existing, count: existing.count + 1 });2129 }2130 }21312132 const uVal = entry.username?.trim();2133 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);21342135 2136 2137 const eVal = entry.email?.trim();2138 if (eVal) tally(emailMap, eVal);2139 }21402141 const toIdentity = (type: "email" | "username") =>2142 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({2143 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,2144 });21452146 return [2147 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),2148 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),2149 ];2150}21512152export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {2153 if (!derivedKey) throw new Error("vault_locked");21542155 const now = Date.now();2156 const added: VaultEntry[] = [];2157 for (const draft of drafts) {2158 try {2159 const sanitized = sanitizeEntryDraft(draft);2160 const nextEntry: VaultEntry = {2161 ...sanitized,2162 id: crypto.randomUUID(),2163 createdAt: now,2164 modifiedAt: monotonicEntryTimestamp(),2165 };2166 entries.push(nextEntry);2167 added.push(nextEntry);2168 } catch {2169 2170 }2171 }21722173 if (added.length > 0) {2174 await persistEntries();2175 }21762177 return added;2178}217921802181218221832184218521862187export async function restoreEntries(2188 incoming: VaultEntry[],2189 mode: "replace" | "merge"2190): Promise<{ added: number; updated: number; skipped: number }> {2191 if (!derivedKey) throw new Error("vault_locked");21922193 2194 2195 2196 if (mode === "replace" && incoming.length === 0) {2197 throw new Error("refuse to replace with empty entry set");2198 }21992200 let added = 0;2201 let updated = 0;2202 let skipped = 0;22032204 if (mode === "replace") {2205 entries = [];2206 for (const entry of incoming) {2207 const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));2208 if (!normalized) {2209 skipped += 1;2210 continue;2211 }2212 entries.push(normalized);2213 if (normalized.deletedAt) {2214 tombstones.set(2215 normalized.id,2216 Math.max(tombstones.get(normalized.id) ?? 0, normalized.deletedAt),2217 );2218 } else {2219 tombstones.delete(normalized.id);2220 }2221 added += 1;2222 }2223 } else {2224 const byId = new Map<string, VaultEntry>();2225 for (const e of entries) byId.set(e.id, e);22262227 for (const entry of incoming) {2228 const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));2229 if (!normalized) {2230 skipped += 1;2231 continue;2232 }2233 const existing = byId.get(normalized.id);2234 if (!existing) {2235 entries.push(normalized);2236 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);2237 else tombstones.delete(normalized.id);2238 added += 1;2239 } else if (normalized.modifiedAt > existing.modifiedAt) {2240 const idx = entries.findIndex((e) => e.id === existing.id);2241 if (idx !== -1) {2242 entries[idx] = normalized;2243 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);2244 else tombstones.delete(normalized.id);2245 updated += 1;2246 }2247 } else {2248 skipped += 1;2249 }2250 }2251 }22522253 await persistEntries("import");2254 return { added, updated, skipped };2255}225622572258225922602261226222632264226522662267226822692270227122722273const ENTRY_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;22742275function naturalizeRestoredId(entry: VaultEntry | null): VaultEntry | null {2276 if (!entry || ENTRY_ID_SHAPE.test(entry.id)) return entry;2277 return { ...entry, id: crypto.randomUUID() };2278}227922802281228222832284228522862287function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {2288 if (!entry || typeof entry !== "object") return null;2289 const title = typeof entry.title === "string" ? entry.title.trim() : "";2290 if (!title) return null;2291 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();2292 const now = Date.now();2293 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;2294 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;22952296 2297 2298 const normalized = normalizeEntry({2299 ...entry,2300 id,2301 title,2302 type: entry.type ?? "login",2303 createdAt,2304 modifiedAt,2305 });23062307 if (normalized.passkey) {2308 normalized.passkey = sanitizePasskey(normalized.passkey);2309 }2310 if (normalized.canary) {2311 normalized.canary = sanitizeCanary(normalized.canary);2312 }2313 2314 normalized.tags = sanitizeTags(normalized.tags);23152316 2317 2318 if (2319 normalized.type === "login" &&2320 !normalized.passkey &&2321 typeof normalized.password !== "string"2322 ) {2323 return null;2324 }2325 if (entry.passkey && !normalized.passkey) {2326 2327 2328 return null;2329 }23302331 return normalized;2332}2333233423352336233723382339function sanitizePasskey(pk: unknown): PasskeyData | undefined {2340 if (!pk || typeof pk !== "object") return undefined;2341 const r = pk as Record<string, unknown>;2342 if (2343 typeof r.rpId !== "string" || !r.rpId ||2344 typeof r.credentialId !== "string" || !r.credentialId ||2345 typeof r.publicKeyCose !== "string" ||2346 typeof r.userHandle !== "string" ||2347 typeof r.userName !== "string"2348 ) {2349 return undefined;2350 }2351 return {2352 rpId: r.rpId,2353 rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,2354 credentialId: r.credentialId,2355 publicKeyCose: r.publicKeyCose,2356 privateKeyJwk:2357 r.privateKeyJwk && typeof r.privateKeyJwk === "object"2358 ? (r.privateKeyJwk as JsonWebKey)2359 : undefined,2360 userHandle: r.userHandle,2361 userName: r.userName,2362 userDisplayName:2363 typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,2364 algorithm: r.algorithm === -8 ? -8 : -7,2365 signCount: typeof r.signCount === "number" ? r.signCount : 0,2366 aaguid: typeof r.aaguid === "string" ? r.aaguid : "",2367 backupEligible: r.backupEligible === true,2368 backupState: r.backupState === true,2369 lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,2370 browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,2371 transports: Array.isArray(r.transports)2372 ? r.transports.filter((t): t is string => typeof t === "string")2373 : undefined,2374 createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),2375 };2376}23772378237923802381238223832384238523862387function sanitizeCanary(c: unknown): Canary | undefined {2388 if (!c || typeof c !== "object") return undefined;2389 const r = c as Record<string, unknown>;2390 if (typeof r.webhookUrl !== "string") return undefined;2391 let url: URL;2392 try {2393 url = new URL(r.webhookUrl);2394 } catch {2395 return undefined;2396 }2397 if (url.protocol !== "https:") return undefined;2398 return {2399 webhookUrl: url.toString(),2400 label: typeof r.label === "string" ? r.label : undefined,2401 lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,2402 pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,2403 };2404}24052406240724082409241024112412function sanitizeTags(tags: unknown): string[] | undefined {2413 if (!Array.isArray(tags)) return undefined;2414 const out: string[] = [];2415 for (const t of tags) {2416 if (typeof t !== "string") continue;2417 const trimmed = t.trim();2418 if (!trimmed) continue;2419 out.push(trimmed.slice(0, 256));2420 if (out.length >= 255) break;2421 }2422 return out.length > 0 ? out : undefined;2423}242424252426242724282429export async function clearVaultEntries(2430 confirmation: "REPLACE_ALL"2431): Promise<void> {2432 if (confirmation !== "REPLACE_ALL") {2433 throw new Error("clearVaultEntries requires explicit confirmation");2434 }2435 if (!derivedKey) throw new Error("vault_locked");2436 for (const entry of entries) {2437 if (!entry.deletedAt) {2438 const stamp = monotonicEntryTimestamp(Math.max(2439 entry.modifiedAt,2440 tombstones.get(entry.id) ?? 0,2441 ));2442 entry.deletedAt = stamp;2443 entry.modifiedAt = stamp;2444 tombstones.set(entry.id, stamp);2445 }2446 }2447 await persistEntries();2448}24492450export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {2451 if (!derivedKey) throw new Error("vault_locked");24522453 const sanitized = sanitizeEntryDraft(entry);2454 const now = Date.now();2455 const stamp = monotonicEntryTimestamp();2456 const nextEntry: VaultEntry = {2457 ...sanitized,2458 id: crypto.randomUUID(),2459 createdAt: now,2460 modifiedAt: stamp,2461 2462 2463 passwordChangedAt: sanitized.password ? stamp : undefined,2464 };24652466 2467 2468 2469 const rollback = entries;2470 entries = [...entries, nextEntry];2471 try {2472 await persistEntries();2473 } catch (err) {2474 entries = rollback;2475 throw err;2476 }24772478 2479 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {2480 checkBreach(nextEntry.password).then(async (count) => {2481 if (count > 0) {2482 const idx = entries.findIndex((e) => e.id === nextEntry.id);2483 if (idx !== -1) {2484 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };2485 await persistEntries();2486 }2487 }2488 });2489 }24902491 fireSyncTrigger("vault_change");24922493 return nextEntry;2494}24952496249724982499250025012502export async function consolidatePasskeyEntries(): Promise<void> {2503 if (!derivedKey) return;2504 const { merged } = mergePasskeyOnlyIntoLogins(entries);2505 if (merged) {2506 await persistEntries();2507 }2508}25092510export async function updateEntry(2511 id: string,2512 patch: Partial<VaultEntry>2513): Promise<void> {2514 if (!derivedKey) throw new Error("vault_locked");25152516 const idx = entries.findIndex((entry) => entry.id === id);2517 if (idx === -1 || entries[idx].deletedAt) {2518 throw new Error("entry_not_found");2519 }25202521 const current = entries[idx];2522 const sanitizedPatch = sanitizeEntryPatch(patch);2523 2524 2525 2526 const passwordChanged =2527 "password" in patch &&2528 typeof sanitizedPatch.password === "string" &&2529 sanitizedPatch.password !== current.password;2530 const stamp = monotonicEntryTimestamp(current.modifiedAt);2531 2532 2533 2534 2535 2536 2537 2538 const priorPassword = current.password;2539 const shouldCapture =2540 passwordChanged &&2541 typeof priorPassword === "string" &&2542 priorPassword.length > 0 &&2543 current.passwordHistory?.[0]?.password !== priorPassword;2544 const nextHistory = shouldCapture2545 ? [2546 { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },2547 ...(current.passwordHistory ?? []),2548 ].slice(0, PASSWORD_HISTORY_MAX)2549 : current.passwordHistory;2550 entries[idx] = {2551 ...current,2552 ...sanitizedPatch,2553 modifiedAt: stamp,2554 ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),2555 };2556255725582559 2560 if (entries[idx].type === "card" && !entries[idx].cardNumber) {2561 entries[idx] = current;2562 throw new Error("card number is required for card entries");2563 }25642565 2566 2567 2568 2569 try {2570 await persistEntries();2571 } catch (err) {2572 entries[idx] = current;2573 throw err;2574 }25752576 2577 2578 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {2579 entries[idx] = { ...entries[idx], breach: undefined };2580 await persistEntries();2581 const password = entries[idx].password;2582 if (password) {2583 checkBreach(password).then(async (count) => {2584 const i = entries.findIndex((e) => e.id === id);2585 if (i !== -1 && count > 0) {2586 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };2587 await persistEntries();2588 }2589 });2590 }2591 }25922593 fireSyncTrigger("vault_change");2594}25952596export async function deleteEntry(id: string): Promise<void> {2597 if (!derivedKey) throw new Error("vault_locked");25982599 const entry = entries.find((item) => item.id === id);2600 if (!entry || entry.deletedAt) {2601 throw new Error("entry_not_found");2602 }26032604 2605 2606 2607 const previousModifiedAt = entry.modifiedAt;2608 const previousTombstone = tombstones.get(id);2609 const stamp = monotonicEntryTimestamp(Math.max(entry.modifiedAt, previousTombstone ?? 0));2610 entry.deletedAt = stamp;2611 entry.modifiedAt = stamp;2612 tombstones.set(id, stamp);2613 2614 2615 try {2616 await persistEntries();2617 } catch (err) {2618 delete entry.deletedAt;2619 entry.modifiedAt = previousModifiedAt;2620 if (previousTombstone === undefined) tombstones.delete(id);2621 else tombstones.set(id, previousTombstone);2622 throw err;2623 }2624 fireSyncTrigger("vault_change");2625}26262627export function getDeletedEntries(): VaultEntry[] {2628 if (!derivedKey) return [];2629 return entries.filter((e) => !!e.deletedAt);2630}263126322633export function getTombstoneManifest(): Record<string, number> {2634 if (!derivedKey) return {};2635 return Object.fromEntries(tombstones);2636}2637263826392640264126422643export async function getVaultVersion(): Promise<number> {2644 try {2645 const raw = await chrome.storage.local.get("peach_last_modified_at");2646 const v = (raw as Record<string, unknown>).peach_last_modified_at;2647 if (typeof v === "number") return v;2648 if (typeof v === "string" && v !== "") {2649 const n = Number.parseInt(v, 10);2650 return Number.isFinite(n) ? n : 0;2651 }2652 return 0;2653 } catch {2654 return 0;2655 }2656}2657265826592660266126622663266426652666266726682669export async function applyMergedRemoteEntries(2670 remoteEntries: VaultEntry[],2671 remoteTombstones: Record<string, number>,2672): Promise<{ received: number; applied: number; conflicts: number }> {2673 2674 2675 2676 2677 2678 2679 if (!derivedKey || vaultMode === "decoy") throw new Error("vault_locked");26802681 const received = remoteEntries.length + Object.keys(remoteTombstones).length;2682 let applied = 0;2683 let conflicts = 0;26842685 const byId = new Map<string, VaultEntry>();2686 for (const e of entries) byId.set(e.id, e);26872688 const mergeNow = Date.now();2689 const remoteTombstoneTimes = new Map<string, number>();2690 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2691 if (typeof rawDeletedAt === "number" && Number.isFinite(rawDeletedAt) && rawDeletedAt > 0) {2692 remoteTombstoneTimes.set(id, clampSyncTimestamp(rawDeletedAt, mergeNow));2693 }2694 }26952696 for (const remote of remoteEntries) {2697 const sanitized = sanitizeRestoredEntry(remote);2698 if (!sanitized) continue;2699 2700 2701 2702 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2703 if (typeof sanitized.deletedAt === "number") {2704 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2705 }2706 const remoteTombstoneTs = remoteTombstoneTimes.get(sanitized.id);2707 const localTombstoneTs = tombstones.get(sanitized.id);2708 const tombstoneTs = Math.max(remoteTombstoneTs ?? 0, localTombstoneTs ?? 0);2709 if (tombstoneTs > 0 && tombstoneTs >= sanitized.modifiedAt) {2710 continue;2711 }2712 const local = byId.get(sanitized.id);2713 if (!local) {2714 entries.push(sanitized);2715 byId.set(sanitized.id, sanitized);2716 tombstones.delete(sanitized.id);2717 applied += 1;2718 } else if (sanitized.modifiedAt > local.modifiedAt) {2719 const idx = entries.findIndex((e) => e.id === local.id);2720 if (idx !== -1) {2721 entries[idx] = sanitized;2722 byId.set(sanitized.id, sanitized);2723 tombstones.delete(sanitized.id);2724 applied += 1;2725 if (!local.deletedAt) conflicts += 1;2726 }2727 }2728 }27292730 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2731 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt) || rawDeletedAt <= 0) continue;2732 2733 const deletedAt = remoteTombstoneTimes.get(id)!;2734 const knownTombstone = tombstones.get(id);2735 if (knownTombstone !== undefined && knownTombstone >= deletedAt) continue;2736 const local = byId.get(id);2737 if (!local) {2738 tombstones.set(id, deletedAt);2739 applied += 1;2740 continue;2741 }2742 if (local.deletedAt && local.deletedAt >= deletedAt) continue;2743 if (deletedAt < local.modifiedAt) continue;2744 const idx = entries.findIndex((e) => e.id === id);2745 if (idx !== -1) {2746 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2747 tombstones.set(id, deletedAt);2748 applied += 1;2749 }2750 }27512752 if (applied > 0 && vaultMode === "real") {2753 await persistEntries("sync_received");2754 buildDomainIndexSync(entries);2755 }2756 return { received, applied, conflicts };2757}27582759export async function restoreEntry(id: string): Promise<void> {2760 if (!derivedKey) throw new Error("vault_locked");2761 const entry = entries.find((e) => e.id === id && e.deletedAt);2762 if (!entry) throw new Error("deleted entry not found");2763 const previousDeletedAt = entry.deletedAt;2764 const previousModifiedAt = entry.modifiedAt;2765 const previousTombstone = tombstones.get(id);2766 delete entry.deletedAt;2767 entry.modifiedAt = monotonicEntryTimestamp(Math.max(2768 entry.modifiedAt,2769 previousTombstone ?? 0,2770 ));2771 tombstones.delete(id);2772 try {2773 await persistEntries();2774 } catch (err) {2775 entry.deletedAt = previousDeletedAt;2776 entry.modifiedAt = previousModifiedAt;2777 if (previousTombstone !== undefined) tombstones.set(id, previousTombstone);2778 throw err;2779 }2780 buildDomainIndexSync(entries);2781}27822783export async function purgeEntry(id: string): Promise<void> {2784 if (!derivedKey) throw new Error("vault_locked");2785 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2786 if (idx === -1) throw new Error("deleted entry not found");2787 const entry = entries[idx]!;2788 tombstones.set(id, Math.max(tombstones.get(id) ?? 0, entry.deletedAt!));2789 entries.splice(idx, 1);2790 await persistEntries();2791}279227932794export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2795 if (!derivedKey || retentionMs === 0) return 0;2796 const cutoff = Date.now() - retentionMs;2797 const before = entries.length;2798 for (const entry of entries) {2799 if (entry.deletedAt && entry.deletedAt < cutoff) {2800 tombstones.set(entry.id, Math.max(tombstones.get(entry.id) ?? 0, entry.deletedAt));2801 }2802 }2803 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2804 const purged = before - entries.length;2805 if (purged > 0) await persistEntries();2806 return purged;2807}28082809export function queryDomain(domain: string): VaultEntry[] {2810 return domainIndex.get(domain) ?? [];2811}28122813export function getEntryById(id: string): VaultEntry | undefined {2814 return entries.find((entry) => entry.id === id && !entry.deletedAt);2815}28162817async function persistSlotA(2818 reason: VaultSnapshot["reason"] = "local_save",2819): Promise<StoredVault> {2820 if (!derivedKey) throw new Error("vault_locked");2821 const epoch = vaultEpoch;2822 const throwIfStale = () => {2823 if (epoch !== vaultEpoch || !derivedKey) throw new Error("vault_locked");2824 };28252826 const slotA = await loadSlot(SLOT_A);2827 throwIfStale();2828 if (!slotA) throw new Error("missing slot A");28292830 2831 2832 await takeVaultSnapshot(reason);2833 throwIfStale();28342835 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2836 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");2837 throwIfStale();28382839 2840 const nextSlotA: StoredVault = {2841 ...slotA,2842 nonce,2843 ciphertext,2844 tag,2845 2846 };28472848 2849 2850 2851 2852 2853 2854 let slotACommitted = false;2855 try {2856 throwIfStale();2857 await writeSlot(SLOT_A, nextSlotA);2858 slotACommitted = true;2859 throwIfStale();28602861 2862 2863 2864 if (!duressConfigured) {2865 throwIfStale();2866 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2867 throwIfStale();2868 }2869 2870 2871 2872 2873 2874 if (reason !== "sync_received") {2875 throwIfStale();2876 await stampLocalVaultMutation();2877 throwIfStale();2878 }2879 } catch (err) {2880 2881 2882 if (slotACommitted) await writeSlot(SLOT_A, slotA).catch(() => {});2883 if (epoch !== vaultEpoch || !derivedKey) throw new Error("vault_locked");2884 throw err;2885 }28862887 return nextSlotA;2888}28892890async function persistSlotB(): Promise<void> {2891 if (!derivedKey) throw new Error("vault_locked");28922893 const slotB = await loadSlot(SLOT_B);2894 if (!slotB) throw new Error("missing slot B");28952896 2897 2898 2899 2900 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2901 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");29022903 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2904 2905 2906 2907 try {2908 await stampLocalVaultMutation();2909 } catch (err) {2910 await writeSlot(SLOT_B, slotB).catch(() => {});2911 throw err;2912 }2913}29142915async function persistEntries(2916 reason: VaultSnapshot["reason"] = "local_save",2917): Promise<void> {2918 if (!derivedKey) throw new Error("vault_locked");29192920 2921 2922 const previousTombstones = tombstones;2923 tombstones = new Map(tombstones);2924 reconcileTombstones();2925 try {2926 if (vaultMode === "decoy") {2927 await persistSlotB();2928 } else {2929 await persistSlotA(reason);2930 }2931 } catch (err) {2932 tombstones = previousTombstones;2933 throw err;2934 }2935 buildDomainIndexSync(entries);2936 await refreshLockedSyncState();2937}2938293929402941294229432944function allowHttpUrl(url: string | undefined): string {2945 const trimmed = url?.trim() ?? "";2946 if (!trimmed) return "";2947 try {2948 const parsed = new URL(trimmed);2949 if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return "";2950 } catch {2951 2952 }2953 return trimmed;2954}29552956function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {2957 if (!Array.isArray(values)) return undefined;2958 const seen = new Set<string>();2959 const cleaned: string[] = [];2960 for (const value of values) {2961 const trimmed = typeof value === "string" ? value.trim() : "";2962 if (!trimmed) continue;2963 if (!allowHttpUrl(trimmed)) continue;2964 const key = trimmed.toLowerCase();2965 if (seen.has(key)) continue;2966 seen.add(key);2967 cleaned.push(trimmed);2968 }2969 return cleaned.length > 0 ? cleaned : undefined;2970}29712972function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {2973 const title = entry.title.trim();2974 const type: VaultEntry["type"] = entry.type ?? "login";29752976 if (!title) throw new Error("title is required");29772978 if (entry.passkey) {2979 const p = entry.passkey;2980 if (2981 !p.rpId ||2982 !p.credentialId ||2983 !p.publicKeyCose ||2984 !p.userHandle ||2985 !p.userName ||2986 (p.algorithm !== -7 && p.algorithm !== -8)2987 ) {2988 throw new Error("invalid passkey credential");2989 }2990 }2991 if (type === "card") {2992 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {2993 throw new Error("cardholder name and card number are required");2994 }2995 }29962997 return {2998 type,2999 title,3000 username: type === "login" ? entry.username?.trim() ?? "" : undefined,3001 email: type === "login" ? entry.email?.trim() || undefined : undefined,3002 password: type === "login" ? entry.password : undefined,3003 url: type === "login" ? allowHttpUrl(entry.url) : undefined,3004 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,3005 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,3006 3007 3008 totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,3009 totpDigits: type === "login" ? entry.totpDigits : undefined,3010 totpPeriod: type === "login" ? entry.totpPeriod : undefined,3011 notes: entry.notes?.trim() || undefined,3012 breach: type === "login" ? entry.breach : undefined,3013 3014 3015 canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,3016 tags: sanitizeTags(entry.tags),3017 passkey: entry.passkey,3018 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,3019 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,3020 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,3021 expYear: type === "card" ? entry.expYear?.trim() : undefined,3022 cvc: type === "card" ? entry.cvc?.trim() : undefined,3023 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,3024 network: type === "card" ? entry.network : undefined,3025 last4: type === "card" ? entry.last4?.trim() : undefined,3026 };3027}30283029function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {3030 const nextPatch: Partial<VaultEntry> = {};30313032 if ("type" in patch) {3033 nextPatch.type = patch.type;3034 }30353036 if ("title" in patch) {3037 const title = patch.title?.trim() ?? "";3038 if (!title) throw new Error("title is required");3039 nextPatch.title = title;3040 }30413042 if ("url" in patch) {3043 nextPatch.url = allowHttpUrl(patch.url);3044 }30453046 if ("additionalUrls" in patch) {3047 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];3048 const seen = new Set<string>();3049 const cleaned: string[] = [];3050 for (const value of incoming) {3051 const trimmed = typeof value === "string" ? value.trim() : "";3052 if (!trimmed || !allowHttpUrl(trimmed)) continue;3053 const key = trimmed.toLowerCase();3054 if (seen.has(key)) continue;3055 seen.add(key);3056 cleaned.push(trimmed);3057 }3058 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;3059 }30603061 if ("username" in patch) {3062 nextPatch.username = patch.username?.trim() ?? "";3063 }30643065 if ("email" in patch) {3066 nextPatch.email = patch.email?.trim() || undefined;3067 }30683069 if ("password" in patch) {3070 const password = patch.password ?? "";3071 3072 nextPatch.password = password || undefined;3073 }30743075 if ("totp" in patch) {3076 nextPatch.totp = patch.totp?.trim() || undefined;3077 }30783079 if ("totpAlgorithm" in patch) {3080 nextPatch.totpAlgorithm = patch.totpAlgorithm;3081 }30823083 if ("totpDigits" in patch) {3084 nextPatch.totpDigits = patch.totpDigits;3085 }30863087 if ("totpPeriod" in patch) {3088 nextPatch.totpPeriod = patch.totpPeriod;3089 }30903091 if ("notes" in patch) {3092 nextPatch.notes = patch.notes?.trim() || undefined;3093 }30943095 if ("breach" in patch) {3096 nextPatch.breach = patch.breach;3097 }30983099 if ("canary" in patch) {3100 3101 3102 nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;3103 }31043105 if ("passkey" in patch) {3106 3107 3108 if (patch.passkey === undefined || patch.passkey === null) {3109 nextPatch.passkey = undefined;3110 } else {3111 const sanitized = sanitizePasskey(patch.passkey);3112 if (!sanitized) throw new Error("invalid passkey credential");3113 nextPatch.passkey = sanitized;3114 }3115 }31163117 if ("tags" in patch) {3118 3119 nextPatch.tags = sanitizeTags(patch.tags);3120 }31213122 if ("cardholderName" in patch) {3123 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;3124 }3125 if ("cardNumber" in patch) {3126 const n = patch.cardNumber ?? "";3127 nextPatch.cardNumber = n || undefined;3128 }3129 if ("expMonth" in patch) {3130 nextPatch.expMonth = patch.expMonth?.trim() || undefined;3131 }3132 if ("expYear" in patch) {3133 nextPatch.expYear = patch.expYear?.trim() || undefined;3134 }3135 if ("cvc" in patch) {3136 nextPatch.cvc = patch.cvc?.trim() || undefined;3137 }3138 if ("cardNickname" in patch) {3139 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;3140 }3141 if ("network" in patch) {3142 nextPatch.network = patch.network;3143 }3144 if ("last4" in patch) {3145 nextPatch.last4 = patch.last4?.trim() || undefined;3146 }31473148 return nextPatch;3149}31503151function normalizeEntry(entry: VaultEntry): VaultEntry {3152 const base: VaultEntry = {3153 id: entry.id,3154 type: entry.type,3155 title: entry.title,3156 username: entry.username,3157 email: entry.email,3158 password: entry.password,3159 url: entry.url,3160 additionalUrls:3161 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)3162 ? entry.additionalUrls3163 : undefined,3164 totp: entry.totp,3165 3166 totpAlgorithm: entry.totpAlgorithm,3167 totpDigits: entry.totpDigits,3168 totpPeriod: entry.totpPeriod,3169 notes: entry.notes,3170 breach: entry.breach,3171 3172 3173 3174 passwordChangedAt:3175 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,3176 3177 3178 3179 passwordHistory:3180 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)3181 ? entry.passwordHistory3182 : undefined,3183 canary: entry.canary,3184 passkey: entry.passkey,3185 createdAt: entry.createdAt,3186 modifiedAt: entry.modifiedAt,3187 deletedAt: entry.deletedAt,3188 tags: entry.tags,3189 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,3190 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,3191 expMonth: "expMonth" in entry ? entry.expMonth : undefined,3192 expYear: "expYear" in entry ? entry.expYear : undefined,3193 cvc: "cvc" in entry ? entry.cvc : undefined,3194 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,3195 network: "network" in entry ? entry.network : undefined,3196 last4: "last4" in entry ? entry.last4 : undefined,3197 };31983199 return base;3200}3201320232033204320532063207function entryDupeKey(e: VaultEntry): string {3208 if (e.type === "note") return `note|${e.title.toLowerCase()}`;3209 3210 3211 3212 3213 if (e.passkey) {3214 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;3215 }3216 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;3217}321832193220export function countDuplicates(): number {3221 const best = new Map<string, VaultEntry>();3222 let dupeCount = 0;3223 for (const e of entries.filter((e) => !e.deletedAt)) {3224 const key = entryDupeKey(e);3225 const existing = best.get(key);3226 if (!existing) {3227 best.set(key, e);3228 } else {3229 dupeCount++;3230 3231 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {3232 best.set(key, e);3233 }3234 }3235 }3236 return dupeCount;3237}323832393240324132423243export async function deduplicateEntries(): Promise<number> {3244 if (!derivedKey) throw new Error("vault_locked");3245 3246 3247 if (vaultMode === "decoy") {3248 throw new Error("deduplicate disabled in decoy mode");3249 }32503251 const best = new Map<string, VaultEntry>();3252 for (const e of entries.filter((en) => !en.deletedAt)) {3253 const key = entryDupeKey(e);3254 const existing = best.get(key);3255 if (!existing) {3256 best.set(key, e);3257 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {3258 best.set(key, e);3259 }3260 }32613262 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));3263 let removed = 0;3264 entries = entries.map((e) => {3265 if (!e.deletedAt && !keepIds.has(e.id)) {3266 removed++;3267 const deletedAt = monotonicEntryTimestamp(Math.max(3268 e.modifiedAt,3269 tombstones.get(e.id) ?? 0,3270 ));3271 tombstones.set(e.id, deletedAt);3272 return { ...e, deletedAt };3273 }3274 return e;3275 });32763277 if (removed > 0) await persistEntries();3278 return removed;3279}328032813282328332843285328632873288const HISTORY_KEY = "peach_vault_history";3289const HISTORY_MAX = 5;329032913292329332943295function bucketEntryCount(n: number): number {3296 if (n <= 0) return 0;3297 if (n <= 10) return 10;3298 if (n <= 25) return 25;3299 if (n <= 50) return 50;3300 if (n <= 100) return 100;3301 if (n <= 250) return 250;3302 if (n <= 500) return 500;3303 return Math.ceil(n / 500) * 500;3304}33053306330733083309331033113312export async function takeVaultSnapshot(3313 reason: VaultSnapshot["reason"],3314): Promise<void> {3315 try {3316 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);3317 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];3318 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];33193320 3321 3322 3323 if (!slotA) return;33243325 const vault_a = JSON.stringify(slotA);3326 const vault_b = JSON.stringify(slotB ?? slotA);33273328 3329 3330 3331 3332 3333 3334 let entryCount: number | null = null;3335 if (derivedKey && vaultMode === "real") {3336 try {3337 entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);3338 } catch {3339 entryCount = null;3340 }3341 }33423343 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3344 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)3345 ? existingRaw3346 : { snapshots: [] };33473348 3349 3350 3351 const latest = history.snapshots[0];3352 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {3353 return;3354 }33553356 const snapshot: VaultSnapshot = {3357 id: crypto.randomUUID(),3358 savedAt: Date.now(),3359 reason,3360 vault_a,3361 vault_b,3362 entryCount,3363 };33643365 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);3366 await chrome.storage.local.set({ [HISTORY_KEY]: history });3367 } catch (err) {3368 3369 3370 3371 3372 console.warn('[Peach] takeVaultSnapshot failed:', err);3373 if (String(err).includes('QUOTA') || String(err).includes('quota')) {3374 try {3375 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });3376 } catch {3377 3378 }3379 }3380 }3381}338233833384export async function clearVaultHistory(): Promise<void> {3385 try {3386 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });3387 } catch {3388 3389 }3390}339133923393export async function getVaultHistory(): Promise<VaultSnapshot[]> {3394 const raw = await chrome.storage.local.get(HISTORY_KEY);3395 const history = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3396 if (!history || !Array.isArray(history.snapshots)) return [];3397 return history.snapshots;3398}33993400340134023403340434053406export async function restoreVaultSnapshot(3407 snapshotId: string,3408): Promise<{ ok: true } | { ok: false; error: string }> {3409 const history = await getVaultHistory();3410 const snapshot = history.find((s) => s.id === snapshotId);3411 if (!snapshot) return { ok: false, error: "snapshot_not_found" };34123413 3414 3415 3416 await takeVaultSnapshot("restore");34173418 let restoredA: StoredVault;3419 let restoredB: StoredVault;3420 try {3421 restoredA = JSON.parse(snapshot.vault_a) as StoredVault;3422 restoredB = JSON.parse(snapshot.vault_b) as StoredVault;3423 } catch {3424 return { ok: false, error: "snapshot_corrupt" };3425 }34263427 await writeSlot(SLOT_A, restoredA);3428 await writeSlot(SLOT_B, restoredB);3429 await stampLocalVaultMutation();34303431 3432 3433 3434 await lock();34353436 return { ok: true };3437}343834393440344134423443344434453446344734483449345034513452export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {3453 if (!derivedKey || vaultMode !== "real") {3454 return { ok: false, error: "vault_locked" };3455 }3456 const slotA = await loadSlot(SLOT_A);3457 if (!slotA) return { ok: false, error: "missing_slot_a" };34583459 3460 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =3461 await generateAndWrapRootKey();34623463 3464 const newRootKey = unhex(rawRootKey);3465 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);34663467 3468 3469 3470 3471 await takeVaultSnapshot("local_save");3472 const nextSlotA: StoredVault = {3473 ...slotA,3474 recovery_salt: recoverySalt,3475 recovery_wrapped_root_key: wrappedRootKey,3476 recovery_nonce: wrappedNonce,3477 recovery_tag: wrappedTag,3478 ...pwRoot,3479 };3480 await writeSlot(SLOT_A, nextSlotA);34813482 3483 if (rootKey) zeroKey(rootKey);3484 rootKey = newRootKey;34853486 3487 3488 await notifyIdentityKeysRotated();34893490 3491 3492 3493 await clearPairedDevices();3494 await clearPairSecrets();34953496 3497 await setRecoveryConfirmPending(); 34983499 await stampLocalVaultMutation();3500 return { ok: true };3501}35023503350435053506350735083509export async function changePassword(3510 currentPassword: string,3511 newPassword: string3512): Promise<boolean> {3513 const epoch = vaultEpoch;3514 const stale = () => epoch !== vaultEpoch;3515 if (!derivedKey || vaultMode !== "real") return false;35163517 3518 const valid = await verifyMainPassword(currentPassword);3519 if (!valid) return false;35203521 const slotA = await loadSlot(SLOT_A);3522 if (!slotA) return false;3523 if (!rootKey) return false;35243525 3526 const newSalt = generateSalt();3527 const newKey = await deriveKey(newPassword, newSalt);3528 if (stale()) {3529 zeroKey(newKey);3530 return false;3531 }35323533 3534 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);3535 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");35363537 3538 3539 3540 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);35413542 const finalSlotA: StoredVault = {3543 ...slotA,3544 salt: hex(newSalt),3545 nonce,3546 ciphertext,3547 tag,3548 ...pwRoot,3549 };35503551 3552 3553 3554 await takeVaultSnapshot("local_save");35553556 3557 3558 3559 3560 await writeSlot(SLOT_A, finalSlotA);3561 if (stale()) {3562 zeroKey(newKey);3563 return false;3564 }35653566 3567 3568 3569 zeroKey(derivedKey);3570 derivedKey = newKey;35713572 3573 3574 3575 3576 if (stale()) {3577 await lock();3578 return false;3579 }3580 await saveSessionKey(newKey, "real", epoch);3581 if (stale()) return false;35823583 3584 3585 3586 await notifyIdentityKeysRotated();3587 if (stale()) return false;35883589 3590 3591 3592 if (!duressConfigured) {3593 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3594 }3595 await clearPinUnlockStorage();3596 await stampLocalVaultMutation();35973598 return true;3599}