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}1263const MAX_HISTORY_BLOB_BYTES = 1_024 * 1_024;12641265function hasValidHistorySlot(value: unknown): value is StoredVault {1266 if (!hasValidVaultSchema(value)) return false;1267 return /^[0-9a-f]{32}$/i.test(value.salt) &&1268 /^[0-9a-f]{24}$/i.test(value.nonce) &&1269 /^[0-9a-f]+$/i.test(value.ciphertext) &&1270 value.ciphertext.length % 2 === 0 &&1271 value.ciphertext.length / 2 <= MAX_HISTORY_BLOB_BYTES &&1272 /^[0-9a-f]{32}$/i.test(value.tag);1273}12741275function parseHistorySlot(value: string): StoredVault | null {1276 try {1277 const parsed: unknown = JSON.parse(value);1278 return hasValidHistorySlot(parsed) ? parsed : null;1279 } catch {1280 return null;1281 }1282}12831284function isHistoryRecord(value: unknown): value is VaultSnapshot {1285 if (typeof value !== "object" || value === null) return false;1286 const record = value as Record<string, unknown>;1287 return typeof record.id === "string" && record.id.length > 0 && record.id.length <= 128 &&1288 typeof record.savedAt === "number" && Number.isFinite(record.savedAt) &&1289 typeof record.reason === "string" &&1290 typeof record.vault_a === "string" && record.vault_a.length <= MAX_HISTORY_BLOB_BYTES * 2 &&1291 typeof record.vault_b === "string" && record.vault_b.length <= MAX_HISTORY_BLOB_BYTES * 2 &&1292 (record.entryCount === null || typeof record.entryCount === "number");1293}12941295function sanitizeHistory(value: unknown): VaultSnapshot[] {1296 if (typeof value !== "object" || value === null) return [];1297 if (!("snapshots" in value) || !Array.isArray(value.snapshots)) return [];1298 return value.snapshots.filter(isHistoryRecord).slice(0, HISTORY_MAX);1299}13001301export type TryDecryptResult =1302 | { status: "ok"; payload: DecryptedPayload }1303 | { status: "wrong_key" }1304 | { status: "corrupted"; reason: string };1305130613071308130913101311export async function tryDecryptSlot(1312 vault: StoredVault,1313 key: Uint8Array,1314 aadTag?: string,1315): Promise<DecryptedPayload | null> {1316 const res = await tryDecryptSlotResult(vault, key, aadTag);1317 return res.status === "ok" ? res.payload : null;1318}13191320export async function tryDecryptSlotResult(1321 vault: StoredVault,1322 key: Uint8Array,1323 aadTag?: string,1324): Promise<TryDecryptResult> {1325 if (!hasValidVaultSchema(vault)) {1326 return { status: "corrupted", reason: "invalid_slot_schema" };1327 }1328 let plain: string;1329 try {1330 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);1331 } catch {1332 return { status: "wrong_key" };1333 }1334 try {1335 const parsed = JSON.parse(plain) as unknown;1336 if (typeof parsed !== "object" || parsed === null) {1337 return { status: "corrupted", reason: "invalid_payload_shape" };1338 }1339 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {1340 return { status: "corrupted", reason: "invalid_payload_shape" };1341 }1342 return { status: "ok", payload: decodePayload(plain) };1343 } catch {1344 return { status: "corrupted", reason: "invalid_payload_json" };1345 }1346}134713481349135013511352function buildDomainIndexSync(all: VaultEntry[]): void {1353 domainIndex = new Map();13541355 for (const entry of all) {1356 if (entry.deletedAt) continue;1357 if (entry.type === "note" || entry.type === "card") continue;13581359 1360 1361 1362 1363 const domains = new Set<string>();1364 const primary = extractDomain(entry.url ?? "");1365 if (primary) domains.add(primary);1366 for (const extra of entry.additionalUrls ?? []) {1367 const d = extractDomain(extra);1368 if (d) domains.add(d);1369 }1370 if (domains.size === 0) continue;13711372 for (const domain of domains) {1373 const bucket = domainIndex.get(domain) ?? [];1374 bucket.push(entry);1375 domainIndex.set(domain, bucket);1376 }1377 }1378}13791380export async function createVault(password: string): Promise<void> {1381 const salt = generateSalt();1382 derivedKey = await deriveKey(password, salt);1383 entries = [];1384 tombstones = new Map();1385 vaultMode = "real";1386 duressConfigured = false;1387 buildDomainIndexSync(entries);13881389 1390 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =1391 await generateAndWrapRootKey();1392 rootKey = unhex(rawRootKey);13931394 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);1395 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");13961397 1398 1399 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);14001401 const slotA: StoredVault = {1402 version: 1,1403 salt: hex(salt),1404 nonce,1405 ciphertext,1406 tag,1407 recovery_salt: recoverySalt,1408 recovery_wrapped_root_key: wrappedRootKey,1409 recovery_nonce: wrappedNonce,1410 recovery_tag: wrappedTag,1411 ...pwRoot,1412 };1413 await writeSlot(SLOT_A, slotA);14141415 1416 1417 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1418 await stampLocalVaultMutation();14191420 1421 1422 await identityHooks?.onVaultCreated(password).catch((error) => {1423 console.warn("[Peach] local identity promotion failed:", error);1424 });14251426 touchAutoLock();1427}1428142914301431143214331434export async function createVaultFromRootKey(1435 password: string,1436 rootKeyHex: string,1437): Promise<{ vaultId: string }> {1438 if (typeof password !== "string" || password.length === 0) {1439 throw new Error("ADOPT_INVALID_PASSWORD");1440 }1441 if (typeof rootKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(rootKeyHex)) {1442 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars");1443 }14441445 const rootKeyBytes = unhex(rootKeyHex);1446 if (rootKeyBytes.length !== 32) {1447 rootKeyBytes.fill(0);1448 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");1449 }14501451 let nextDerivedKey: Uint8Array | null = null;1452 let sessionRootKey: Uint8Array | null = null;1453 try {1454 if ((await loadSlot(SLOT_A)) !== null) {1455 throw new Error("ADOPT_VAULT_ALREADY_EXISTS");1456 }14571458 const nextEntries: VaultEntry[] = [];1459 const nextDuressConfigured = false;1460 const vaultId = await computeVaultId(rootKeyBytes);1461 const salt = generateSalt();1462 nextDerivedKey = await deriveKey(password, salt);1463 const recovery = await wrapExistingRootKeyForRecovery(rootKeyBytes);14641465 sessionRootKey = new Uint8Array(rootKeyBytes);1466 const plaintext = encodePayload(nextEntries, nextDuressConfigured, 0);1467 const { nonce, ciphertext, tag } = await encrypt(nextDerivedKey, plaintext, "slot_a_v1");1468 const pwRoot = await wrapRootKeyForDerivedKey(nextDerivedKey, sessionRootKey);1469 const slotA: StoredVault = {1470 version: 1,1471 salt: hex(salt),1472 nonce,1473 ciphertext,1474 tag,1475 recovery_salt: recovery.recoverySalt,1476 recovery_wrapped_root_key: recovery.wrappedRootKey,1477 recovery_nonce: recovery.wrappedNonce,1478 recovery_tag: recovery.wrappedTag,1479 ...pwRoot,1480 };1481 const slotB = generateNoiseBlob(ciphertext.length / 2);1482 await commitCreatedVaultSlots(slotA, slotB);14831484 const previousDerivedKey = derivedKey;1485 const previousRootKey = rootKey;1486 derivedKey = nextDerivedKey;1487 rootKey = sessionRootKey;1488 nextDerivedKey = null;1489 sessionRootKey = null;1490 entries = nextEntries;1491 tombstones = new Map();1492 vaultMode = "real";1493 duressConfigured = nextDuressConfigured;1494 buildDomainIndexSync(entries);1495 if (previousDerivedKey && previousDerivedKey !== derivedKey) zeroKey(previousDerivedKey);1496 if (previousRootKey && previousRootKey !== rootKey) previousRootKey.fill(0);1497 touchAutoLock();14981499 1500 1501 await identityHooks?.onVaultCreated(password).catch((error) => {1502 console.warn("[Peach] local identity promotion failed:", error);1503 });15041505 return { vaultId };1506 } finally {1507 rootKeyBytes.fill(0);1508 if (nextDerivedKey) zeroKey(nextDerivedKey);1509 if (sessionRootKey) sessionRootKey.fill(0);1510 }1511}15121513export async function hasVault(): Promise<boolean> {1514 return (await loadSlot(SLOT_A)) !== null;1515}1516151715181519152015211522export async function isDuressEnabled(): Promise<boolean> {1523 return vaultMode === "real" && duressConfigured;1524}15251526export async function setupDuress(duressPassword: string): Promise<void> {1527 if (!derivedKey || vaultMode !== "real") {1528 throw new Error("vault must be unlocked in real mode");1529 }1530 1531 1532 1533 const slotA = await loadSlot(SLOT_A);1534 if (!slotA) throw new Error("missing slot A");15351536 const decoySalt = generateSalt();1537 const decoyKey = await deriveKey(duressPassword, decoySalt);1538 try {1539 1540 1541 1542 1543 const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1544 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1545 await writeSlot(SLOT_B, {1546 version: 1,1547 salt: hex(decoySalt),1548 nonce,1549 ciphertext,1550 tag,1551 });1552 } finally {1553 zeroKey(decoyKey);1554 }15551556 1557 1558 duressConfigured = true;1559 await persistSlotA();1560}15611562export async function disableDuress(): Promise<void> {1563 if (!derivedKey || vaultMode !== "real") {1564 throw new Error("vault must be unlocked in real mode");1565 }1566 1567 1568 1569 const slotA = await loadSlot(SLOT_A);1570 if (slotA) {1571 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1572 }15731574 duressConfigured = false;1575 await persistSlotA();1576}15771578157915801581158215831584export async function verifyMainPassword(password: string): Promise<boolean> {1585 const slotA = await loadSlot(SLOT_A);1586 if (!slotA) return false;1587 const key = await deriveKey(password, unhex(slotA.salt));1588 try {1589 return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1590 } finally {1591 zeroKey(key);1592 }1593}15941595export async function unlock(password: string): Promise<boolean> {1596 return attemptUnlockWithDerive(password, deriveKey);1597}15981599async function attemptUnlockWithDerive(1600 password: string,1601 derive: typeof deriveKey,1602): Promise<boolean> {1603 const epoch = vaultEpoch;1604 const stale = () => epoch !== vaultEpoch;1605 1606 1607 1608 1609 1610 1611 const [slotA, slotB] = await Promise.all([1612 loadSlot(SLOT_A),1613 loadSlot(SLOT_B),1614 ]);16151616 1617 const [keyA, keyB] = await Promise.all([1618 slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1619 slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1620 ]);16211622 1623 1624 1625 const [resultA, resultB] = await Promise.all([1626 keyA && slotA1627 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1628 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1629 keyB && slotB1630 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1631 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1632 ]);1633 const payloadA = resultA.status === "ok" ? resultA.payload : null;1634 const payloadB = resultB.status === "ok" ? resultB.payload : null;16351636 if (stale()) {1637 if (keyA) zeroKey(keyA);1638 if (keyB) zeroKey(keyB);1639 return false;1640 }16411642 if (payloadA && keyA) {1643 if (stale()) {1644 zeroKey(keyA);1645 if (keyB) zeroKey(keyB);1646 return false;1647 }1648 derivedKey = keyA;1649 vaultMode = "real";1650 entries = payloadA.entries;1651 tombstones = payloadA.tombstones;1652 duressConfigured = payloadA.duressConfigured;1653 buildDomainIndexSync(entries);1654 await loadSettings();1655 if (stale()) {1656 await lock();1657 return false;1658 }1659 touchAutoLock();1660 if (keyB) zeroKey(keyB);16611662 1663 1664 rootKey = slotA ? await deriveRootKeyFromDerivedKey(keyA, slotA) : null;1665 if (!rootKey) {1666 await lock();1667 return false;1668 }16691670 1671 1672 await reconcilePairSecrets(rootKey);1673 if (stale()) {1674 await lock();1675 return false;1676 }1677 await lockedSyncUnlockHook?.().catch((error) => {1678 console.warn("[Peach sync] locked inbox drain failed:", error);1679 });1680 if (stale()) {1681 await lock();1682 return false;1683 }1684 await resetPinFailedAttempts();1685 if (stale()) {1686 await lock();1687 return false;1688 }1689 await saveSessionKey(keyA, "real", epoch);1690 if (stale()) return false;1691 return true;1692 }16931694 if (payloadB && keyB) {1695 if (stale()) {1696 zeroKey(keyB);1697 if (keyA) zeroKey(keyA);1698 return false;1699 }1700 derivedKey = keyB;1701 vaultMode = "decoy";1702 decoySyncTeardownHook?.();1703 1704 1705 1706 if (rootKey) {1707 rootKey.fill(0);1708 rootKey = null;1709 }1710 entries = payloadB.entries;1711 tombstones = payloadB.tombstones;1712 duressConfigured = false;1713 buildDomainIndexSync(entries);1714 await loadSettings();1715 touchAutoLock();1716 if (keyA) zeroKey(keyA);1717 1718 await dropSessionCache();1719 1720 await notifyIdentitySessionEnded();1721 if (stale()) {1722 await lock();1723 return false;1724 }1725 await saveSessionKey(keyB, "decoy", epoch);1726 if (stale()) return false;1727 return true;1728 }17291730 if (keyA) zeroKey(keyA);1731 if (keyB) zeroKey(keyB);1732 1733 1734 if (resultA.status === "corrupted") {1735 lastUnlockFailureReason = resultA.reason;1736 } else if (resultB.status === "corrupted") {1737 lastUnlockFailureReason = resultB.reason;1738 }1739 return false;1740}17411742174317441745174617471748const SESSION_KEY_STORAGE = "peach_session_dk";1749175017511752let sessionKeyMutation = Promise.resolve();17531754function mutateSessionKey(mutation: () => Promise<void>): Promise<void> {1755 const task = sessionKeyMutation.then(mutation, mutation);1756 sessionKeyMutation = task.catch(() => undefined);1757 return task;1758}175917601761176217631764let sessionEscrowAvailable = true;17651766176717681769177017711772export async function applyStorageHardening(sessionEscrow: boolean): Promise<void> {1773 setSessionAllowed(sessionEscrow);1774 if (sessionEscrow) return;1775 sessionEscrowAvailable = false;1776 await clearSessionKey();1777}17781779async function saveSessionKey(1780 key: Uint8Array,1781 mode: "real" | "decoy",1782 expectedEpoch = vaultEpoch,1783): Promise<void> {1784 const session = chrome.storage.session;1785 if (!session || !sessionEscrowAvailable || expectedEpoch !== vaultEpoch) return;1786 const stored = { keyHex: hex(key), mode };1787 await mutateSessionKey(async () => {1788 if (!sessionEscrowAvailable || expectedEpoch !== vaultEpoch) return;1789 await session.set({ [SESSION_KEY_STORAGE]: stored });1790 });1791}17921793async function clearSessionKey(): Promise<void> {1794 const session = chrome.storage.session;1795 if (!session) return;1796 await mutateSessionKey(() => session.remove(SESSION_KEY_STORAGE));1797}17981799180018011802180318041805let vaultEpoch = 0;1806180718081809let restoreInFlight: Promise<boolean> | null = null;181018111812181318141815181618171818export async function restoreFromSession(): Promise<boolean> {1819 if (derivedKey !== null) return true;1820 if (restoreInFlight) return restoreInFlight;1821 const task = restoreFromSessionInner();1822 restoreInFlight = task;1823 try {1824 return await task;1825 } finally {1826 restoreInFlight = null;1827 }1828}18291830async function restoreFromSessionInner(): Promise<boolean> {1831 const epoch = vaultEpoch;1832 const stale = () => epoch !== vaultEpoch;1833 if (derivedKey !== null) return true;1834 if (!sessionEscrowAvailable) return false;1835 if (!chrome.storage.session) return false;1836 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1837 if (stale()) return false;1838 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1839 if (!entry?.keyHex) return false;18401841 const key = unhex(entry.keyHex);1842 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1843 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1844 const slot = await loadSlot(slotKey);1845 if (stale()) { zeroKey(key); return false; }1846 if (!slot) { await clearSessionKey(); return false; }18471848 const result = await tryDecryptSlotResult(slot, key, aadTag);1849 if (stale()) { zeroKey(key); return false; }1850 if (result.status !== "ok") { await clearSessionKey(); return false; }18511852 1853 1854 const payload = result.payload;1855 await loadSettings();1856 if (stale()) { zeroKey(key); return false; }18571858 1859 1860 let freshRoot: Uint8Array | null = null;1861 if (entry.mode === "real") {1862 freshRoot = await deriveRootKeyFromDerivedKey(key, slot);1863 if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1864 if (!freshRoot) {1865 await lock();1866 return false;1867 }1868 }18691870 if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1871 if (freshRoot) {1872 1873 await reconcilePairSecrets(freshRoot);1874 if (stale()) { zeroKey(key); zeroKey(freshRoot); return false; }1875 } else {1876 1877 await dropSessionCache();1878 1879 await notifyIdentitySessionEnded();1880 if (stale()) { zeroKey(key); return false; }1881 }1882 derivedKey = key;1883 vaultMode = entry.mode;1884 if (entry.mode === "decoy") decoySyncTeardownHook?.();1885 entries = payload.entries;1886 tombstones = payload.tombstones;1887 duressConfigured = entry.mode === "real" ? payload.duressConfigured : false;1888 if (freshRoot) rootKey = freshRoot;1889 buildDomainIndexSync(entries);1890 touchAutoLock();18911892 await lockedSyncUnlockHook?.().catch((error) => {1893 console.warn("[Peach sync] locked inbox drain failed:", error);1894 });1895 if (stale()) return false;18961897 return true;1898}18991900export async function lock(): Promise<void> {1901 1902 1903 1904 1905 1906 1907 1908 1909 1910 vaultEpoch++;1911 if (derivedKey) {1912 zeroKey(derivedKey);1913 derivedKey = null;1914 }1915 if (rootKey) {1916 rootKey.fill(0);1917 rootKey = null;1918 }19191920 entries = [];1921 tombstones = new Map();1922 domainIndex = new Map();1923 vaultMode = "real";1924 duressConfigured = false;1925 clearAutoLock();1926 await clearSessionKey();1927 1928 1929 await notifyIdentitySessionEnded();1930 await clearRecoveryConfirmPending();1931}19321933export function isUnlocked(): boolean {1934 return derivedKey !== null;1935}19361937export function isDecoyMode(): boolean {1938 return derivedKey !== null && vaultMode === "decoy";1939}19401941194219431944export async function unlockWithKey(1945 masterKeyHex: string,1946): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1947 const epoch = vaultEpoch;1948 const stale = () => epoch !== vaultEpoch;1949 let masterKeyBytes: Uint8Array | null = null;1950 try {1951 masterKeyBytes = unhex(masterKeyHex);1952 const stored = await loadSlot(SLOT_A);1953 if (!stored) return { ok: false, error: "invalid_key" };1954 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1955 if (!payload || stale()) {1956 zeroKey(masterKeyBytes);1957 return { ok: false, error: "invalid_key" };1958 }19591960 derivedKey = masterKeyBytes;1961 vaultMode = "real";1962 entries = payload.entries;1963 tombstones = payload.tombstones;1964 duressConfigured = payload.duressConfigured;1965 buildDomainIndexSync(entries);1966 await loadSettings();1967 touchAutoLock();19681969 rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1970 if (!rootKey) {1971 await lock();1972 return { ok: false, error: "invalid_key" };1973 }1974 await reconcilePairSecrets(rootKey);1975 if (stale()) {1976 await lock();1977 return { ok: false, error: "invalid_key" };1978 }1979 await saveSessionKey(masterKeyBytes, "real", epoch);1980 if (stale()) return { ok: false, error: "invalid_key" };1981 return { ok: true, entries };1982 } catch {1983 if (masterKeyBytes && derivedKey !== masterKeyBytes) zeroKey(masterKeyBytes);1984 return { ok: false, error: "invalid_key" };1985 }1986}198719881989199019911992199319941995export async function exportDuressSlotForAdoption(): Promise<string | null> {1996 if (!(await isDuressEnabled())) return null;1997 const slotB = await loadSlot(SLOT_B);1998 if (!slotB) return null;1999 return JSON.stringify(slotB);2000}20012002200320042005200620072008export async function installAdoptedDuressSlot(json: string): Promise<void> {2009 let parsed: StoredVault;2010 try {2011 const raw = JSON.parse(json) as unknown;2012 if (!hasValidVaultSchema(raw)) {2013 throw new Error("invalid_duress_slot_shape");2014 }2015 parsed = raw;2016 } catch (err) {2017 if (err instanceof SyntaxError) {2018 throw new Error("invalid_duress_slot_shape");2019 }2020 throw err;2021 }2022 await writeSlot(SLOT_B, parsed);2023}20242025202620272028202920302031203220332034203520362037203820392040204120422043export async function unlockFromCodex(params: {2044 slotA: string; 2045 slotB: string | null; 2046 timestamp: number;2047 masterPassphrase: string;2048}): Promise<2049 | { ok: true; entryCount: number }2050 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" | "missing_root_escrow" }2051> {2052 let parsedSlotA: StoredVault;2053 let parsedSlotB: StoredVault | null = null;2054 try {2055 const rawA = JSON.parse(params.slotA) as unknown;2056 if (!hasValidVaultSchema(rawA)) {2057 return { ok: false, reason: "invalid_slot_format" };2058 }2059 parsedSlotA = rawA;2060 if (params.slotB !== null) {2061 const rawB = JSON.parse(params.slotB) as unknown;2062 if (!hasValidVaultSchema(rawB)) {2063 return { ok: false, reason: "invalid_slot_format" };2064 }2065 parsedSlotB = rawB;2066 }2067 } catch {2068 return { ok: false, reason: "invalid_slot_format" };2069 }20702071 2072 2073 2074 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));2075 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");2076 if (!trialPayload) {2077 zeroKey(trialKey);2078 return { ok: false, reason: "wrong_passphrase" };2079 }2080 const hasEscrow =2081 !!parsedSlotA.pw_root_ciphertext && !!parsedSlotA.pw_root_nonce && !!parsedSlotA.pw_root_tag;2082 if (!hasEscrow) {2083 zeroKey(trialKey);2084 return { ok: false, reason: "missing_root_escrow" };2085 }2086 const stagedRootKey = await deriveRootKeyFromDerivedKey(trialKey, parsedSlotA);2087 if (!stagedRootKey) {2088 zeroKey(trialKey);2089 return { ok: false, reason: "wrong_passphrase" };2090 }2091 zeroKey(stagedRootKey);2092 zeroKey(trialKey);20932094 2095 const stagedSlotB = parsedSlotB ?? generateNoiseBlob(parsedSlotA.ciphertext.length / 2);20962097 2098 20992100 2101 2102 await takeVaultSnapshot("restore");2103 const previousSlotA = await loadSlot(SLOT_A);2104 const previousSlotB = await loadSlot(SLOT_B);2105 let slotACommitted = false;2106 let slotBCommitted = false;2107 try {2108 await writeSlot(SLOT_A, parsedSlotA);2109 slotACommitted = true;2110 await writeSlot(SLOT_B, stagedSlotB);2111 slotBCommitted = true;2112 await chrome.storage.local.set({2113 peach_last_modified_at: String(params.timestamp),2114 });2115 } catch (err) {2116 if (slotBCommitted && previousSlotB) await writeSlot(SLOT_B, previousSlotB);2117 if (slotACommitted && previousSlotA) await writeSlot(SLOT_A, previousSlotA);2118 throw err;2119 }21202121 2122 2123 const ok = await unlock(params.masterPassphrase);2124 if (!ok) {2125 2126 return { ok: false, reason: "wrong_passphrase" };2127 }2128 return { ok: true, entryCount: getEntries().length };2129}21302131213221332134export function getDerivedKeyHex(): string {2135 if (!derivedKey) throw new Error("vault_not_unlocked");2136 return hex(derivedKey);2137}21382139214021412142214321442145214621472148214921502151215221532154export function getRootKeyHex(): string | null {2155 return rootKey ? hex(rootKey) : null;2156}21572158export function getEntries(): VaultEntry[] {2159 if (!derivedKey) throw new Error("vault_locked");2160 return entries.filter((entry) => !entry.deletedAt);2161}21622163const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;21642165export function getIdentities(): SavedIdentity[] {2166 if (!derivedKey) throw new Error("vault_locked");2167 const active = entries.filter((e) => !e.deletedAt && e.type === "login");21682169 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();2170 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();21712172 for (const entry of active) {2173 const domain = extractDomain(entry.url ?? "") ?? "";2174 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;21752176 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {2177 const existing = map.get(v);2178 if (!existing || modifiedAt > existing.lastUsed) {2179 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });2180 } else {2181 map.set(v, { ...existing, count: existing.count + 1 });2182 }2183 }21842185 const uVal = entry.username?.trim();2186 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);21872188 2189 2190 const eVal = entry.email?.trim();2191 if (eVal) tally(emailMap, eVal);2192 }21932194 const toIdentity = (type: "email" | "username") =>2195 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({2196 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,2197 });21982199 return [2200 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),2201 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),2202 ];2203}22042205export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {2206 if (!derivedKey) throw new Error("vault_locked");22072208 const now = Date.now();2209 const added: VaultEntry[] = [];2210 for (const draft of drafts) {2211 try {2212 const sanitized = sanitizeEntryDraft(draft);2213 const nextEntry: VaultEntry = {2214 ...sanitized,2215 id: crypto.randomUUID(),2216 createdAt: now,2217 modifiedAt: monotonicEntryTimestamp(),2218 };2219 entries.push(nextEntry);2220 added.push(nextEntry);2221 } catch {2222 2223 }2224 }22252226 if (added.length > 0) {2227 await persistEntries();2228 }22292230 return added;2231}223222332234223522362237223822392240export async function restoreEntries(2241 incoming: VaultEntry[],2242 mode: "replace" | "merge"2243): Promise<{ added: number; updated: number; skipped: number }> {2244 if (!derivedKey) throw new Error("vault_locked");22452246 2247 2248 2249 if (mode === "replace" && incoming.length === 0) {2250 throw new Error("refuse to replace with empty entry set");2251 }22522253 let added = 0;2254 let updated = 0;2255 let skipped = 0;22562257 if (mode === "replace") {2258 entries = [];2259 for (const entry of incoming) {2260 const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));2261 if (!normalized) {2262 skipped += 1;2263 continue;2264 }2265 entries.push(normalized);2266 if (normalized.deletedAt) {2267 tombstones.set(2268 normalized.id,2269 Math.max(tombstones.get(normalized.id) ?? 0, normalized.deletedAt),2270 );2271 } else {2272 tombstones.delete(normalized.id);2273 }2274 added += 1;2275 }2276 } else {2277 const byId = new Map<string, VaultEntry>();2278 for (const e of entries) byId.set(e.id, e);22792280 for (const entry of incoming) {2281 const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));2282 if (!normalized) {2283 skipped += 1;2284 continue;2285 }2286 const existing = byId.get(normalized.id);2287 if (!existing) {2288 entries.push(normalized);2289 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);2290 else tombstones.delete(normalized.id);2291 added += 1;2292 } else if (normalized.modifiedAt > existing.modifiedAt) {2293 const idx = entries.findIndex((e) => e.id === existing.id);2294 if (idx !== -1) {2295 entries[idx] = normalized;2296 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);2297 else tombstones.delete(normalized.id);2298 updated += 1;2299 }2300 } else {2301 skipped += 1;2302 }2303 }2304 }23052306 await persistEntries("import");2307 return { added, updated, skipped };2308}230923102311231223132314231523162317231823192320232123222323232423252326const ENTRY_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;23272328function naturalizeRestoredId(entry: VaultEntry | null): VaultEntry | null {2329 if (!entry || ENTRY_ID_SHAPE.test(entry.id)) return entry;2330 return { ...entry, id: crypto.randomUUID() };2331}233223332334233523362337233823392340function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {2341 if (!entry || typeof entry !== "object") return null;2342 const title = typeof entry.title === "string" ? entry.title.trim() : "";2343 if (!title) return null;2344 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();2345 const now = Date.now();2346 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;2347 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;23482349 2350 2351 const normalized = normalizeEntry({2352 ...entry,2353 id,2354 title,2355 type: entry.type ?? "login",2356 createdAt,2357 modifiedAt,2358 });23592360 if (normalized.passkey) {2361 normalized.passkey = sanitizePasskey(normalized.passkey);2362 }2363 if (normalized.canary) {2364 normalized.canary = sanitizeCanary(normalized.canary);2365 }2366 2367 normalized.tags = sanitizeTags(normalized.tags);23682369 2370 2371 if (2372 normalized.type === "login" &&2373 !normalized.passkey &&2374 typeof normalized.password !== "string"2375 ) {2376 return null;2377 }2378 if (entry.passkey && !normalized.passkey) {2379 2380 2381 return null;2382 }23832384 return normalized;2385}2386238723882389239023912392function sanitizePasskey(pk: unknown): PasskeyData | undefined {2393 if (!pk || typeof pk !== "object") return undefined;2394 const r = pk as Record<string, unknown>;2395 if (2396 typeof r.rpId !== "string" || !r.rpId ||2397 typeof r.credentialId !== "string" || !r.credentialId ||2398 typeof r.publicKeyCose !== "string" ||2399 typeof r.userHandle !== "string" ||2400 typeof r.userName !== "string"2401 ) {2402 return undefined;2403 }2404 return {2405 rpId: r.rpId,2406 rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,2407 credentialId: r.credentialId,2408 publicKeyCose: r.publicKeyCose,2409 privateKeyJwk:2410 r.privateKeyJwk && typeof r.privateKeyJwk === "object"2411 ? (r.privateKeyJwk as JsonWebKey)2412 : undefined,2413 userHandle: r.userHandle,2414 userName: r.userName,2415 userDisplayName:2416 typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,2417 algorithm: r.algorithm === -8 ? -8 : -7,2418 signCount: typeof r.signCount === "number" ? r.signCount : 0,2419 aaguid: typeof r.aaguid === "string" ? r.aaguid : "",2420 backupEligible: r.backupEligible === true,2421 backupState: r.backupState === true,2422 lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,2423 browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,2424 transports: Array.isArray(r.transports)2425 ? r.transports.filter((t): t is string => typeof t === "string")2426 : undefined,2427 createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),2428 };2429}24302431243224332434243524362437243824392440function sanitizeCanary(c: unknown): Canary | undefined {2441 if (!c || typeof c !== "object") return undefined;2442 const r = c as Record<string, unknown>;2443 if (typeof r.webhookUrl !== "string") return undefined;2444 let url: URL;2445 try {2446 url = new URL(r.webhookUrl);2447 } catch {2448 return undefined;2449 }2450 if (url.protocol !== "https:") return undefined;2451 return {2452 webhookUrl: url.toString(),2453 label: typeof r.label === "string" ? r.label : undefined,2454 lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,2455 pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,2456 };2457}24582459246024612462246324642465function sanitizeTags(tags: unknown): string[] | undefined {2466 if (!Array.isArray(tags)) return undefined;2467 const out: string[] = [];2468 for (const t of tags) {2469 if (typeof t !== "string") continue;2470 const trimmed = t.trim();2471 if (!trimmed) continue;2472 out.push(trimmed.slice(0, 256));2473 if (out.length >= 255) break;2474 }2475 return out.length > 0 ? out : undefined;2476}247724782479248024812482export async function clearVaultEntries(2483 confirmation: "REPLACE_ALL"2484): Promise<void> {2485 if (confirmation !== "REPLACE_ALL") {2486 throw new Error("clearVaultEntries requires explicit confirmation");2487 }2488 if (!derivedKey) throw new Error("vault_locked");2489 for (const entry of entries) {2490 if (!entry.deletedAt) {2491 const stamp = monotonicEntryTimestamp(Math.max(2492 entry.modifiedAt,2493 tombstones.get(entry.id) ?? 0,2494 ));2495 entry.deletedAt = stamp;2496 entry.modifiedAt = stamp;2497 tombstones.set(entry.id, stamp);2498 }2499 }2500 await persistEntries();2501}25022503export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {2504 if (!derivedKey) throw new Error("vault_locked");25052506 const sanitized = sanitizeEntryDraft(entry);2507 const now = Date.now();2508 const stamp = monotonicEntryTimestamp();2509 const nextEntry: VaultEntry = {2510 ...sanitized,2511 id: crypto.randomUUID(),2512 createdAt: now,2513 modifiedAt: stamp,2514 2515 2516 passwordChangedAt: sanitized.password ? stamp : undefined,2517 };25182519 2520 2521 2522 const rollback = entries;2523 entries = [...entries, nextEntry];2524 try {2525 await persistEntries();2526 } catch (err) {2527 entries = rollback;2528 throw err;2529 }25302531 2532 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {2533 checkBreach(nextEntry.password).then(async (count) => {2534 if (count > 0) {2535 const idx = entries.findIndex((e) => e.id === nextEntry.id);2536 if (idx !== -1) {2537 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };2538 await persistEntries();2539 }2540 }2541 });2542 }25432544 fireSyncTrigger("vault_change");25452546 return nextEntry;2547}25482549255025512552255325542555export async function consolidatePasskeyEntries(): Promise<void> {2556 if (!derivedKey) return;2557 const { merged } = mergePasskeyOnlyIntoLogins(entries);2558 if (merged) {2559 await persistEntries();2560 }2561}25622563export async function updateEntry(2564 id: string,2565 patch: Partial<VaultEntry>2566): Promise<void> {2567 if (!derivedKey) throw new Error("vault_locked");25682569 const idx = entries.findIndex((entry) => entry.id === id);2570 if (idx === -1 || entries[idx].deletedAt) {2571 throw new Error("entry_not_found");2572 }25732574 const current = entries[idx];2575 const sanitizedPatch = sanitizeEntryPatch(patch);2576 2577 2578 2579 const passwordChanged =2580 "password" in patch &&2581 typeof sanitizedPatch.password === "string" &&2582 sanitizedPatch.password !== current.password;2583 const stamp = monotonicEntryTimestamp(current.modifiedAt);2584 2585 2586 2587 2588 2589 2590 2591 const priorPassword = current.password;2592 const shouldCapture =2593 passwordChanged &&2594 typeof priorPassword === "string" &&2595 priorPassword.length > 0 &&2596 current.passwordHistory?.[0]?.password !== priorPassword;2597 const nextHistory = shouldCapture2598 ? [2599 { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },2600 ...(current.passwordHistory ?? []),2601 ].slice(0, PASSWORD_HISTORY_MAX)2602 : current.passwordHistory;2603 entries[idx] = {2604 ...current,2605 ...sanitizedPatch,2606 modifiedAt: stamp,2607 ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),2608 };2609261026112612 2613 if (entries[idx].type === "card" && !entries[idx].cardNumber) {2614 entries[idx] = current;2615 throw new Error("card number is required for card entries");2616 }26172618 2619 2620 2621 2622 try {2623 await persistEntries();2624 } catch (err) {2625 entries[idx] = current;2626 throw err;2627 }26282629 2630 2631 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {2632 entries[idx] = { ...entries[idx], breach: undefined };2633 await persistEntries();2634 const password = entries[idx].password;2635 if (password) {2636 checkBreach(password).then(async (count) => {2637 const i = entries.findIndex((e) => e.id === id);2638 if (i !== -1 && count > 0) {2639 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };2640 await persistEntries();2641 }2642 });2643 }2644 }26452646 fireSyncTrigger("vault_change");2647}26482649export async function deleteEntry(id: string): Promise<void> {2650 if (!derivedKey) throw new Error("vault_locked");26512652 const entry = entries.find((item) => item.id === id);2653 if (!entry || entry.deletedAt) {2654 throw new Error("entry_not_found");2655 }26562657 2658 2659 2660 const previousModifiedAt = entry.modifiedAt;2661 const previousTombstone = tombstones.get(id);2662 const stamp = monotonicEntryTimestamp(Math.max(entry.modifiedAt, previousTombstone ?? 0));2663 entry.deletedAt = stamp;2664 entry.modifiedAt = stamp;2665 tombstones.set(id, stamp);2666 2667 2668 try {2669 await persistEntries();2670 } catch (err) {2671 delete entry.deletedAt;2672 entry.modifiedAt = previousModifiedAt;2673 if (previousTombstone === undefined) tombstones.delete(id);2674 else tombstones.set(id, previousTombstone);2675 throw err;2676 }2677 fireSyncTrigger("vault_change");2678}26792680export function getDeletedEntries(): VaultEntry[] {2681 if (!derivedKey) return [];2682 return entries.filter((e) => !!e.deletedAt);2683}268426852686export function getTombstoneManifest(): Record<string, number> {2687 if (!derivedKey) return {};2688 return Object.fromEntries(tombstones);2689}2690269126922693269426952696export async function getVaultVersion(): Promise<number> {2697 try {2698 const raw = await chrome.storage.local.get("peach_last_modified_at");2699 const v = (raw as Record<string, unknown>).peach_last_modified_at;2700 if (typeof v === "number") return v;2701 if (typeof v === "string" && v !== "") {2702 const n = Number.parseInt(v, 10);2703 return Number.isFinite(n) ? n : 0;2704 }2705 return 0;2706 } catch {2707 return 0;2708 }2709}2710271127122713271427152716271727182719272027212722export async function applyMergedRemoteEntries(2723 remoteEntries: VaultEntry[],2724 remoteTombstones: Record<string, number>,2725): Promise<{ received: number; applied: number; conflicts: number }> {2726 2727 2728 2729 2730 2731 2732 if (!derivedKey || vaultMode === "decoy") throw new Error("vault_locked");27332734 const received = remoteEntries.length + Object.keys(remoteTombstones).length;2735 let applied = 0;2736 let conflicts = 0;27372738 const byId = new Map<string, VaultEntry>();2739 for (const e of entries) byId.set(e.id, e);27402741 const mergeNow = Date.now();2742 const remoteTombstoneTimes = new Map<string, number>();2743 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2744 if (typeof rawDeletedAt === "number" && Number.isFinite(rawDeletedAt) && rawDeletedAt > 0) {2745 remoteTombstoneTimes.set(id, clampSyncTimestamp(rawDeletedAt, mergeNow));2746 }2747 }27482749 for (const remote of remoteEntries) {2750 const sanitized = sanitizeRestoredEntry(remote);2751 if (!sanitized) continue;2752 2753 2754 2755 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2756 if (typeof sanitized.deletedAt === "number") {2757 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2758 }2759 const remoteTombstoneTs = remoteTombstoneTimes.get(sanitized.id);2760 const localTombstoneTs = tombstones.get(sanitized.id);2761 const tombstoneTs = Math.max(remoteTombstoneTs ?? 0, localTombstoneTs ?? 0);2762 if (tombstoneTs > 0 && tombstoneTs >= sanitized.modifiedAt) {2763 continue;2764 }2765 const local = byId.get(sanitized.id);2766 if (!local) {2767 entries.push(sanitized);2768 byId.set(sanitized.id, sanitized);2769 tombstones.delete(sanitized.id);2770 applied += 1;2771 } else if (sanitized.modifiedAt > local.modifiedAt) {2772 const idx = entries.findIndex((e) => e.id === local.id);2773 if (idx !== -1) {2774 entries[idx] = sanitized;2775 byId.set(sanitized.id, sanitized);2776 tombstones.delete(sanitized.id);2777 applied += 1;2778 if (!local.deletedAt) conflicts += 1;2779 }2780 }2781 }27822783 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2784 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt) || rawDeletedAt <= 0) continue;2785 2786 const deletedAt = remoteTombstoneTimes.get(id)!;2787 const knownTombstone = tombstones.get(id);2788 if (knownTombstone !== undefined && knownTombstone >= deletedAt) continue;2789 const local = byId.get(id);2790 if (!local) {2791 tombstones.set(id, deletedAt);2792 applied += 1;2793 continue;2794 }2795 if (local.deletedAt && local.deletedAt >= deletedAt) continue;2796 if (deletedAt < local.modifiedAt) continue;2797 const idx = entries.findIndex((e) => e.id === id);2798 if (idx !== -1) {2799 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2800 tombstones.set(id, deletedAt);2801 applied += 1;2802 }2803 }28042805 if (applied > 0 && vaultMode === "real") {2806 await persistEntries("sync_received");2807 buildDomainIndexSync(entries);2808 }2809 return { received, applied, conflicts };2810}28112812export async function restoreEntry(id: string): Promise<void> {2813 if (!derivedKey) throw new Error("vault_locked");2814 const entry = entries.find((e) => e.id === id && e.deletedAt);2815 if (!entry) throw new Error("deleted entry not found");2816 const previousDeletedAt = entry.deletedAt;2817 const previousModifiedAt = entry.modifiedAt;2818 const previousTombstone = tombstones.get(id);2819 delete entry.deletedAt;2820 entry.modifiedAt = monotonicEntryTimestamp(Math.max(2821 entry.modifiedAt,2822 previousTombstone ?? 0,2823 ));2824 tombstones.delete(id);2825 try {2826 await persistEntries();2827 } catch (err) {2828 entry.deletedAt = previousDeletedAt;2829 entry.modifiedAt = previousModifiedAt;2830 if (previousTombstone !== undefined) tombstones.set(id, previousTombstone);2831 throw err;2832 }2833 buildDomainIndexSync(entries);2834}28352836export async function purgeEntry(id: string): Promise<void> {2837 if (!derivedKey) throw new Error("vault_locked");2838 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2839 if (idx === -1) throw new Error("deleted entry not found");2840 const entry = entries[idx]!;2841 tombstones.set(id, Math.max(tombstones.get(id) ?? 0, entry.deletedAt!));2842 entries.splice(idx, 1);2843 await persistEntries();2844}284528462847export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2848 if (!derivedKey || retentionMs === 0) return 0;2849 const cutoff = Date.now() - retentionMs;2850 const before = entries.length;2851 for (const entry of entries) {2852 if (entry.deletedAt && entry.deletedAt < cutoff) {2853 tombstones.set(entry.id, Math.max(tombstones.get(entry.id) ?? 0, entry.deletedAt));2854 }2855 }2856 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2857 const purged = before - entries.length;2858 if (purged > 0) await persistEntries();2859 return purged;2860}28612862export function queryDomain(domain: string): VaultEntry[] {2863 return domainIndex.get(domain) ?? [];2864}28652866export function getEntryById(id: string): VaultEntry | undefined {2867 return entries.find((entry) => entry.id === id && !entry.deletedAt);2868}28692870async function persistSlotA(2871 reason: VaultSnapshot["reason"] = "local_save",2872): Promise<StoredVault> {2873 if (!derivedKey) throw new Error("vault_locked");2874 const epoch = vaultEpoch;2875 const throwIfStale = () => {2876 if (epoch !== vaultEpoch || !derivedKey) throw new Error("vault_locked");2877 };28782879 const slotA = await loadSlot(SLOT_A);2880 throwIfStale();2881 if (!slotA) throw new Error("missing slot A");28822883 2884 2885 await takeVaultSnapshot(reason);2886 throwIfStale();28872888 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2889 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");2890 throwIfStale();28912892 2893 const nextSlotA: StoredVault = {2894 ...slotA,2895 nonce,2896 ciphertext,2897 tag,2898 2899 };29002901 2902 2903 2904 2905 2906 2907 let slotACommitted = false;2908 try {2909 throwIfStale();2910 await writeSlot(SLOT_A, nextSlotA);2911 slotACommitted = true;2912 throwIfStale();29132914 2915 2916 2917 if (!duressConfigured) {2918 throwIfStale();2919 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2920 throwIfStale();2921 }2922 2923 2924 2925 2926 2927 if (reason !== "sync_received") {2928 throwIfStale();2929 await stampLocalVaultMutation();2930 throwIfStale();2931 }2932 } catch (err) {2933 2934 2935 if (slotACommitted) await writeSlot(SLOT_A, slotA).catch(() => {});2936 if (epoch !== vaultEpoch || !derivedKey) throw new Error("vault_locked");2937 throw err;2938 }29392940 return nextSlotA;2941}29422943async function persistSlotB(): Promise<void> {2944 if (!derivedKey) throw new Error("vault_locked");29452946 const slotB = await loadSlot(SLOT_B);2947 if (!slotB) throw new Error("missing slot B");29482949 2950 2951 2952 2953 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2954 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");29552956 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2957 2958 2959 2960 try {2961 await stampLocalVaultMutation();2962 } catch (err) {2963 await writeSlot(SLOT_B, slotB).catch(() => {});2964 throw err;2965 }2966}29672968async function persistEntries(2969 reason: VaultSnapshot["reason"] = "local_save",2970): Promise<void> {2971 if (!derivedKey) throw new Error("vault_locked");29722973 2974 2975 const previousTombstones = tombstones;2976 tombstones = new Map(tombstones);2977 reconcileTombstones();2978 try {2979 if (vaultMode === "decoy") {2980 await persistSlotB();2981 } else {2982 await persistSlotA(reason);2983 }2984 } catch (err) {2985 tombstones = previousTombstones;2986 throw err;2987 }2988 buildDomainIndexSync(entries);2989 await refreshLockedSyncState();2990}2991299229932994299529962997function allowHttpUrl(url: string | undefined): string {2998 const trimmed = url?.trim() ?? "";2999 if (!trimmed) return "";3000 try {3001 const parsed = new URL(trimmed);3002 if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return "";3003 } catch {3004 3005 }3006 return trimmed;3007}30083009function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {3010 if (!Array.isArray(values)) return undefined;3011 const seen = new Set<string>();3012 const cleaned: string[] = [];3013 for (const value of values) {3014 const trimmed = typeof value === "string" ? value.trim() : "";3015 if (!trimmed) continue;3016 if (!allowHttpUrl(trimmed)) continue;3017 const key = trimmed.toLowerCase();3018 if (seen.has(key)) continue;3019 seen.add(key);3020 cleaned.push(trimmed);3021 }3022 return cleaned.length > 0 ? cleaned : undefined;3023}30243025function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {3026 const title = entry.title.trim();3027 const type: VaultEntry["type"] = entry.type ?? "login";30283029 if (!title) throw new Error("title is required");30303031 if (entry.passkey) {3032 const p = entry.passkey;3033 if (3034 !p.rpId ||3035 !p.credentialId ||3036 !p.publicKeyCose ||3037 !p.userHandle ||3038 !p.userName ||3039 (p.algorithm !== -7 && p.algorithm !== -8)3040 ) {3041 throw new Error("invalid passkey credential");3042 }3043 }3044 if (type === "card") {3045 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {3046 throw new Error("cardholder name and card number are required");3047 }3048 }30493050 return {3051 type,3052 title,3053 username: type === "login" ? entry.username?.trim() ?? "" : undefined,3054 email: type === "login" ? entry.email?.trim() || undefined : undefined,3055 password: type === "login" ? entry.password : undefined,3056 url: type === "login" ? allowHttpUrl(entry.url) : undefined,3057 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,3058 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,3059 3060 3061 totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,3062 totpDigits: type === "login" ? entry.totpDigits : undefined,3063 totpPeriod: type === "login" ? entry.totpPeriod : undefined,3064 notes: entry.notes?.trim() || undefined,3065 breach: type === "login" ? entry.breach : undefined,3066 3067 3068 canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,3069 tags: sanitizeTags(entry.tags),3070 passkey: entry.passkey,3071 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,3072 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,3073 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,3074 expYear: type === "card" ? entry.expYear?.trim() : undefined,3075 cvc: type === "card" ? entry.cvc?.trim() : undefined,3076 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,3077 network: type === "card" ? entry.network : undefined,3078 last4: type === "card" ? entry.last4?.trim() : undefined,3079 };3080}30813082function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {3083 const nextPatch: Partial<VaultEntry> = {};30843085 if ("type" in patch) {3086 nextPatch.type = patch.type;3087 }30883089 if ("title" in patch) {3090 const title = patch.title?.trim() ?? "";3091 if (!title) throw new Error("title is required");3092 nextPatch.title = title;3093 }30943095 if ("url" in patch) {3096 nextPatch.url = allowHttpUrl(patch.url);3097 }30983099 if ("additionalUrls" in patch) {3100 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];3101 const seen = new Set<string>();3102 const cleaned: string[] = [];3103 for (const value of incoming) {3104 const trimmed = typeof value === "string" ? value.trim() : "";3105 if (!trimmed || !allowHttpUrl(trimmed)) continue;3106 const key = trimmed.toLowerCase();3107 if (seen.has(key)) continue;3108 seen.add(key);3109 cleaned.push(trimmed);3110 }3111 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;3112 }31133114 if ("username" in patch) {3115 nextPatch.username = patch.username?.trim() ?? "";3116 }31173118 if ("email" in patch) {3119 nextPatch.email = patch.email?.trim() || undefined;3120 }31213122 if ("password" in patch) {3123 const password = patch.password ?? "";3124 3125 nextPatch.password = password || undefined;3126 }31273128 if ("totp" in patch) {3129 nextPatch.totp = patch.totp?.trim() || undefined;3130 }31313132 if ("totpAlgorithm" in patch) {3133 nextPatch.totpAlgorithm = patch.totpAlgorithm;3134 }31353136 if ("totpDigits" in patch) {3137 nextPatch.totpDigits = patch.totpDigits;3138 }31393140 if ("totpPeriod" in patch) {3141 nextPatch.totpPeriod = patch.totpPeriod;3142 }31433144 if ("notes" in patch) {3145 nextPatch.notes = patch.notes?.trim() || undefined;3146 }31473148 if ("breach" in patch) {3149 nextPatch.breach = patch.breach;3150 }31513152 if ("canary" in patch) {3153 3154 3155 nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;3156 }31573158 if ("passkey" in patch) {3159 3160 3161 if (patch.passkey === undefined || patch.passkey === null) {3162 nextPatch.passkey = undefined;3163 } else {3164 const sanitized = sanitizePasskey(patch.passkey);3165 if (!sanitized) throw new Error("invalid passkey credential");3166 nextPatch.passkey = sanitized;3167 }3168 }31693170 if ("tags" in patch) {3171 3172 nextPatch.tags = sanitizeTags(patch.tags);3173 }31743175 if ("cardholderName" in patch) {3176 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;3177 }3178 if ("cardNumber" in patch) {3179 const n = patch.cardNumber ?? "";3180 nextPatch.cardNumber = n || undefined;3181 }3182 if ("expMonth" in patch) {3183 nextPatch.expMonth = patch.expMonth?.trim() || undefined;3184 }3185 if ("expYear" in patch) {3186 nextPatch.expYear = patch.expYear?.trim() || undefined;3187 }3188 if ("cvc" in patch) {3189 nextPatch.cvc = patch.cvc?.trim() || undefined;3190 }3191 if ("cardNickname" in patch) {3192 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;3193 }3194 if ("network" in patch) {3195 nextPatch.network = patch.network;3196 }3197 if ("last4" in patch) {3198 nextPatch.last4 = patch.last4?.trim() || undefined;3199 }32003201 return nextPatch;3202}32033204function normalizeEntry(entry: VaultEntry): VaultEntry {3205 const base: VaultEntry = {3206 id: entry.id,3207 type: entry.type,3208 title: entry.title,3209 username: entry.username,3210 email: entry.email,3211 password: entry.password,3212 url: entry.url,3213 additionalUrls:3214 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)3215 ? entry.additionalUrls3216 : undefined,3217 totp: entry.totp,3218 3219 totpAlgorithm: entry.totpAlgorithm,3220 totpDigits: entry.totpDigits,3221 totpPeriod: entry.totpPeriod,3222 notes: entry.notes,3223 breach: entry.breach,3224 3225 3226 3227 passwordChangedAt:3228 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,3229 3230 3231 3232 passwordHistory:3233 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)3234 ? entry.passwordHistory3235 : undefined,3236 canary: entry.canary,3237 passkey: entry.passkey,3238 createdAt: entry.createdAt,3239 modifiedAt: entry.modifiedAt,3240 deletedAt: entry.deletedAt,3241 tags: entry.tags,3242 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,3243 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,3244 expMonth: "expMonth" in entry ? entry.expMonth : undefined,3245 expYear: "expYear" in entry ? entry.expYear : undefined,3246 cvc: "cvc" in entry ? entry.cvc : undefined,3247 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,3248 network: "network" in entry ? entry.network : undefined,3249 last4: "last4" in entry ? entry.last4 : undefined,3250 };32513252 return base;3253}3254325532563257325832593260function entryDupeKey(e: VaultEntry): string {3261 if (e.type === "note") return `note|${e.title.toLowerCase()}`;3262 3263 3264 3265 3266 if (e.passkey) {3267 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;3268 }3269 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;3270}327132723273export function countDuplicates(): number {3274 const best = new Map<string, VaultEntry>();3275 let dupeCount = 0;3276 for (const e of entries.filter((e) => !e.deletedAt)) {3277 const key = entryDupeKey(e);3278 const existing = best.get(key);3279 if (!existing) {3280 best.set(key, e);3281 } else {3282 dupeCount++;3283 3284 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {3285 best.set(key, e);3286 }3287 }3288 }3289 return dupeCount;3290}329132923293329432953296export async function deduplicateEntries(): Promise<number> {3297 if (!derivedKey) throw new Error("vault_locked");3298 3299 3300 if (vaultMode === "decoy") {3301 throw new Error("deduplicate disabled in decoy mode");3302 }33033304 const best = new Map<string, VaultEntry>();3305 for (const e of entries.filter((en) => !en.deletedAt)) {3306 const key = entryDupeKey(e);3307 const existing = best.get(key);3308 if (!existing) {3309 best.set(key, e);3310 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {3311 best.set(key, e);3312 }3313 }33143315 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));3316 let removed = 0;3317 entries = entries.map((e) => {3318 if (!e.deletedAt && !keepIds.has(e.id)) {3319 removed++;3320 const deletedAt = monotonicEntryTimestamp(Math.max(3321 e.modifiedAt,3322 tombstones.get(e.id) ?? 0,3323 ));3324 tombstones.set(e.id, deletedAt);3325 return { ...e, deletedAt };3326 }3327 return e;3328 });33293330 if (removed > 0) await persistEntries();3331 return removed;3332}333333343335333633373338333933403341const HISTORY_KEY = "peach_vault_history";3342const HISTORY_MAX = 5;334333443345334633473348function bucketEntryCount(n: number): number {3349 if (n <= 0) return 0;3350 if (n <= 10) return 10;3351 if (n <= 25) return 25;3352 if (n <= 50) return 50;3353 if (n <= 100) return 100;3354 if (n <= 250) return 250;3355 if (n <= 500) return 500;3356 return Math.ceil(n / 500) * 500;3357}33583359336033613362336333643365export async function takeVaultSnapshot(3366 reason: VaultSnapshot["reason"],3367): Promise<void> {3368 3369 3370 3371 if (vaultMode === "decoy") return;3372 try {3373 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);3374 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];3375 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];33763377 3378 3379 3380 if (!slotA) return;33813382 const vault_a = JSON.stringify(slotA);3383 const vault_b = JSON.stringify(slotB ?? slotA);33843385 3386 3387 3388 3389 3390 3391 let entryCount: number | null = null;3392 if (derivedKey && vaultMode === "real") {3393 try {3394 entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);3395 } catch {3396 entryCount = null;3397 }3398 }33993400 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3401 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)3402 ? existingRaw3403 : { snapshots: [] };34043405 3406 3407 3408 const latest = history.snapshots[0];3409 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {3410 return;3411 }34123413 const snapshot: VaultSnapshot = {3414 id: crypto.randomUUID(),3415 savedAt: Date.now(),3416 reason,3417 vault_a,3418 vault_b,3419 entryCount,3420 };34213422 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);3423 await chrome.storage.local.set({ [HISTORY_KEY]: history });3424 } catch (err) {3425 3426 3427 3428 3429 console.warn('[Peach] takeVaultSnapshot failed:', err);3430 if (String(err).includes('QUOTA') || String(err).includes('quota')) {3431 try {3432 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });3433 } catch {3434 3435 }3436 }3437 }3438}343934403441export async function clearVaultHistory(): Promise<void> {3442 try {3443 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });3444 } catch {3445 3446 }3447}344834493450export async function getVaultHistory(): Promise<VaultSnapshot[]> {3451 if (vaultMode === "decoy") return [];3452 const raw = await chrome.storage.local.get(HISTORY_KEY);3453 const value = (raw as Record<string, unknown>)[HISTORY_KEY];3454 return sanitizeHistory(value);3455}345634573458345934603461export async function restoreVaultSnapshot(3462 snapshotId: string,3463): Promise<{ ok: true } | { ok: false; error: string }> {3464 if (vaultMode === "decoy") return { ok: false, error: "vault_locked" };3465 const history = await getVaultHistory();3466 const snapshot = history.find((s) => s.id === snapshotId);3467 if (!snapshot) return { ok: false, error: "snapshot_not_found" };34683469 const restoredA = parseHistorySlot(snapshot.vault_a);3470 const restoredB = parseHistorySlot(snapshot.vault_b);3471 if (!restoredA || !restoredB) return { ok: false, error: "snapshot_corrupt" };34723473 await takeVaultSnapshot("restore");3474 const current = await chrome.storage.local.get([SLOT_A, SLOT_B]);3475 const previousA = (current as Record<string, unknown>)[SLOT_A];3476 const previousB = (current as Record<string, unknown>)[SLOT_B];3477 try {3478 await chrome.storage.local.set({ [SLOT_A]: restoredA, [SLOT_B]: restoredB });3479 await stampLocalVaultMutation();3480 } catch {3481 try {3482 await chrome.storage.local.set({ [SLOT_A]: previousA, [SLOT_B]: previousB });3483 } catch {3484 3485 }3486 return { ok: false, error: "snapshot_corrupt" };3487 }34883489 await lock();3490 return { ok: true };3491}349234933494349534963497349834993500350135023503350435053506export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {3507 if (!derivedKey || vaultMode !== "real") {3508 return { ok: false, error: "vault_locked" };3509 }3510 const slotA = await loadSlot(SLOT_A);3511 if (!slotA) return { ok: false, error: "missing_slot_a" };35123513 3514 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =3515 await generateAndWrapRootKey();35163517 3518 const newRootKey = unhex(rawRootKey);3519 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);35203521 3522 3523 3524 3525 await takeVaultSnapshot("local_save");3526 const nextSlotA: StoredVault = {3527 ...slotA,3528 recovery_salt: recoverySalt,3529 recovery_wrapped_root_key: wrappedRootKey,3530 recovery_nonce: wrappedNonce,3531 recovery_tag: wrappedTag,3532 ...pwRoot,3533 };3534 await writeSlot(SLOT_A, nextSlotA);35353536 3537 if (rootKey) zeroKey(rootKey);3538 rootKey = newRootKey;35393540 3541 3542 await notifyIdentityKeysRotated();35433544 3545 3546 3547 await clearPairedDevices();3548 await clearPairSecrets();35493550 3551 await setRecoveryConfirmPending(); 35523553 await stampLocalVaultMutation();3554 return { ok: true };3555}35563557355835593560356135623563export async function changePassword(3564 currentPassword: string,3565 newPassword: string3566): Promise<boolean> {3567 const epoch = vaultEpoch;3568 const stale = () => epoch !== vaultEpoch;3569 if (!derivedKey || vaultMode !== "real") return false;35703571 3572 const valid = await verifyMainPassword(currentPassword);3573 if (!valid) return false;35743575 const slotA = await loadSlot(SLOT_A);3576 if (!slotA) return false;3577 if (!rootKey) return false;35783579 3580 const newSalt = generateSalt();3581 const newKey = await deriveKey(newPassword, newSalt);3582 if (stale()) {3583 zeroKey(newKey);3584 return false;3585 }35863587 3588 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);3589 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");35903591 3592 3593 3594 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);35953596 const finalSlotA: StoredVault = {3597 ...slotA,3598 salt: hex(newSalt),3599 nonce,3600 ciphertext,3601 tag,3602 ...pwRoot,3603 };36043605 3606 3607 3608 await takeVaultSnapshot("local_save");36093610 3611 3612 3613 3614 await writeSlot(SLOT_A, finalSlotA);3615 if (stale()) {3616 zeroKey(newKey);3617 return false;3618 }36193620 3621 3622 3623 zeroKey(derivedKey);3624 derivedKey = newKey;36253626 3627 3628 3629 3630 if (stale()) {3631 await lock();3632 return false;3633 }3634 await saveSessionKey(newKey, "real", epoch);3635 if (stale()) return false;36363637 3638 3639 3640 await notifyIdentityKeysRotated();3641 if (stale()) return false;36423643 3644 3645 3646 if (!duressConfigured) {3647 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3648 }3649 await clearPinUnlockStorage();3650 await stampLocalVaultMutation();36513652 return true;3653}