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 { setRecoveryConfirmPending } from "./recovery-pending";39import {40 clearPairSecrets,41 dropSessionCache,42 reconcilePairSecrets,43 setSessionAllowed,44} from "./pair-secrets";4546type EntryDraft = Omit<47 VaultEntry,48 "id" | "createdAt" | "modifiedAt" | "deletedAt"49>;505152535455const SLOT_A = "peach_vault_a";56const SLOT_B = "peach_vault_b";57585960const PAD_BLOCK = 1024;6162636465const PASSWORD_HISTORY_MAX = 50;6667686970717273747576const DURESS_SLOT_SIZE = 262_144;7778let derivedKey: Uint8Array | null = null;7980818283let rootKey: Uint8Array | null = null;84let entries: VaultEntry[] = [];85868788let tombstones = new Map<string, number>();89let domainIndex: Map<string, VaultEntry[]> = new Map();90let vaultMode: "real" | "decoy" = "real";91929394let duressConfigured = false;9596979899100101102103104105let lastUnlockFailureReason: string | null = null;106107export function consumeLastUnlockFailureReason(): string | null {108 const r = lastUnlockFailureReason;109 lastUnlockFailureReason = null;110 return r;111}112113114115116117export type TextSize = "small" | "medium" | "large";118119export interface VaultSettings {120 accentColor?: AccentColorName;121 theme?: "dark" | "light";122 autoLockMinutes?: number;123 clipboardClearSeconds?: number;124 lockOnClose?: boolean;125 breachMonitoring?: boolean;126 passkeyProviderEnabled?: boolean;127 trashRetentionDays?: number;128 manualSummonDomains?: Record<string, ManualSummonRecord>;129 manualSummonSalt?: string;130 declinedCardFingerprints?: string[];131132 133 metadataVersion?: 1;134 135136137138139 textSize?: TextSize;140 141142143144145 reduceMotion?: boolean;146 147 lockedSyncRequiresUnlock?: boolean;148 149 pinEnabled?: boolean;150 151 pinPreferred?: boolean;152 153154155156157158159 shortcuts?: Record<string, { key: string; ctrl?: boolean; meta?: boolean; shift?: boolean; alt?: boolean }>;160}161162interface StoredPinWrap {163 version: 1;164 salt: string;165 nonce: string;166 ciphertext: string;167 tag: string;168}169170const PIN_WRAP_STORAGE = "peach_pin_wrap";171const PIN_ATTEMPTS_STORAGE = "peach_pin_attempts";172const PIN_MIN_LENGTH = 4;173const PIN_MAX_LENGTH = 12;174const PIN_MAX_ATTEMPTS = 3;175176let settings: VaultSettings = {};177const CARD_DECLINE_MAX = 256;178179const CARD_DECLINE_DIGEST_HEX = 32;180181async function metadataDigest(value: string, key: string): Promise<string> {182 const bytes = new TextEncoder().encode(`${key}:${value}`);183 const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));184 return Array.from(digest.slice(0, 16)).map((byte) => byte.toString(16).padStart(2, "0")).join("");185}186187let inMemoryPinWrap: StoredPinWrap | null = null;188let inMemoryPinFailedAttempts = 0;189let sessionPinStorageFailed = false;190191export function getSettings(): VaultSettings {192 return settings;193}194195export async function ensureSettingsLoaded(): Promise<VaultSettings> {196 await loadSettings();197 return settings;198}199200export async function updateSettings(patch: Partial<VaultSettings>): Promise<void> {201 await loadSettings();202 203 204 205 206 const safe = Object.fromEntries(207 Object.entries(patch).filter(208 ([k]) => !["__proto__", "constructor", "prototype"].includes(k),209 ),210 ) as Partial<VaultSettings>;211 settings = { ...settings, ...safe };212 await persistSettings();213}214215function isValidPin(pin: string): boolean {216 return new RegExp(`^\\d{${PIN_MIN_LENGTH},${PIN_MAX_LENGTH}}$`).test(pin);217}218219220export async function setupPinUnlock(currentPassword: string, pin: string): Promise<boolean> {221 if (!derivedKey || vaultMode !== "real" || !isValidPin(pin)) return false;222 if (!(await verifyMainPassword(currentPassword))) return false;223224 const salt = generateSalt();225 const pinKey = await deriveKey(pin, salt);226 try {227 const wrapped = await encrypt(pinKey, hex(derivedKey), "pin_wrap_v1");228 await savePinWrap({ version: 1, salt: hex(salt), ...wrapped });229 await updateSettings({ pinEnabled: true });230 return true;231 } finally {232 zeroKey(pinKey);233 }234}235236async function clearPinUnlockStorage(): Promise<void> {237 inMemoryPinWrap = null;238 inMemoryPinFailedAttempts = 0;239 sessionPinStorageFailed = false;240 if (chrome.storage.session) {241 await chrome.storage.session.remove([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);242 }243 await updateSettings({ pinEnabled: false });244}245246247export async function disablePinUnlock(currentPassword: string): Promise<boolean> {248 if (!derivedKey || vaultMode !== "real") return false;249 if (!(await verifyMainPassword(currentPassword))) return false;250 await clearPinUnlockStorage();251 return true;252}253254255export async function unlockWithPin(pin: string): Promise<boolean> {256 if (!isValidPin(pin)) return false;257 await loadSettings();258 if (!settings.pinEnabled) return false;259260 const { stored, failedAttempts } = await loadPinState();261 if (failedAttempts >= PIN_MAX_ATTEMPTS) return false;262 if (263 !stored ||264 stored.version !== 1 ||265 !stored.salt ||266 !stored.nonce ||267 !stored.ciphertext ||268 !stored.tag269 ) return false;270271 const pinKey = await deriveKey(pin, unhex(stored.salt));272 let unwrappedKey: Uint8Array | null = null;273 try {274 const masterKeyHex = await decrypt(275 pinKey,276 stored.nonce,277 stored.ciphertext,278 stored.tag,279 "pin_wrap_v1",280 );281 if (!/^[0-9a-f]{64}$/.test(masterKeyHex)) return false;282 unwrappedKey = unhex(masterKeyHex);283 const result = await unlockWithKey(masterKeyHex);284 if (!result.ok) return false;285 await resetPinFailedAttempts();286 return true;287 } catch {288 await recordPinFailure(failedAttempts + 1);289 return false;290 } finally {291 zeroKey(pinKey);292 if (unwrappedKey) zeroKey(unwrappedKey);293 }294}295296async function savePinWrap(stored: StoredPinWrap): Promise<void> {297 inMemoryPinWrap = stored;298 inMemoryPinFailedAttempts = 0;299 sessionPinStorageFailed = !chrome.storage.session;300 if (!chrome.storage.session) return;301 try {302 await chrome.storage.session.set({ [PIN_WRAP_STORAGE]: stored, [PIN_ATTEMPTS_STORAGE]: 0 });303 sessionPinStorageFailed = false;304 } catch {305 306 sessionPinStorageFailed = true;307 }308}309310async function loadPinState(): Promise<{311 stored: StoredPinWrap | undefined;312 failedAttempts: number;313}> {314 if (!chrome.storage.session) {315 return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };316 }317 try {318 const result = await chrome.storage.session.get([PIN_WRAP_STORAGE, PIN_ATTEMPTS_STORAGE]);319 return {320 stored: (result[PIN_WRAP_STORAGE] as StoredPinWrap | undefined) ??321 (sessionPinStorageFailed ? inMemoryPinWrap ?? undefined : undefined),322 failedAttempts: typeof result[PIN_ATTEMPTS_STORAGE] === "number"323 ? result[PIN_ATTEMPTS_STORAGE]324 : 0,325 };326 } catch {327 return { stored: inMemoryPinWrap ?? undefined, failedAttempts: inMemoryPinFailedAttempts };328 }329}330331async function recordPinFailure(failedAttempts: number): Promise<void> {332 inMemoryPinFailedAttempts = Math.min(failedAttempts, PIN_MAX_ATTEMPTS);333 if (!chrome.storage.session) return;334 try {335 await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: inMemoryPinFailedAttempts });336 } catch { }337}338339async function resetPinFailedAttempts(): Promise<void> {340 inMemoryPinFailedAttempts = 0;341 if (!chrome.storage.session) return;342 try {343 await chrome.storage.session.set({ [PIN_ATTEMPTS_STORAGE]: 0 });344 } catch { }345}346347export async function getPinUnlockStatus(): Promise<{348 configured: boolean;349 available: boolean;350 attemptsRemaining: number;351}> {352 await loadSettings();353 if (!settings.pinEnabled) {354 return { configured: false, available: false, attemptsRemaining: 0 };355 }356 const { stored, failedAttempts } = await loadPinState();357 const available = Boolean(stored) && failedAttempts < PIN_MAX_ATTEMPTS;358 return {359 configured: true,360 available,361 attemptsRemaining: stored ? Math.max(0, PIN_MAX_ATTEMPTS - failedAttempts) : 0,362 };363}364365async function persistSettings(): Promise<void> {366 try {367 await chrome.storage.local.set({368 peach_settings: {369 ...settings,370 manualSummonDomains: undefined,371 manualSummonSalt: undefined,372 cardFingerprintSalt: undefined,373 declinedCardFingerprints: settings.declinedCardFingerprints ?? [],374 metadataVersion: 1,375 },376 });377 } catch (err) {378 throw mapStorageError(err);379 }380}381382function mapStorageError(err: unknown): Error {383 const message = err instanceof Error ? err.message : String(err);384 if (/QUOTA_BYTES|quota/i.test(message)) {385 return new Error("STORAGE_QUOTA_EXCEEDED");386 }387 return err instanceof Error ? err : new Error(message);388}389390async function loadSettings(): Promise<void> {391 const result = await chrome.storage.local.get("peach_settings");392 settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};393}394395396397function fireSyncTrigger(reason: "vault_change"): void {398 void Promise.resolve()399 .then(() => triggerSyncIfReachable({ reason }))400 .catch(() => {401 402 });403}404export function lockedSyncRequiresUnlock(): boolean {405 return settings.lockedSyncRequiresUnlock === true;406}407408let lockedSyncRefreshHook: (() => Promise<void>) | null = null;409let lockedSyncUnlockHook: (() => Promise<void>) | null = null;410let decoySyncTeardownHook: (() => void) | null = null;411export type IdentityHooks = {412 413 onSessionEnded: () => Promise<void>;414 415 onKeysRotated: () => Promise<void>;416 417 onVaultCreated: (password: string) => Promise<void>;418};419420let identityHooks: IdentityHooks | null = null;421422export function setIdentityHooks(hooks: IdentityHooks): void {423 identityHooks = hooks;424}425426async function notifyIdentitySessionEnded(): Promise<void> {427 await identityHooks?.onSessionEnded().catch((error) => {428 console.warn("[Peach] local identity cache clear failed:", error);429 });430}431432async function notifyIdentityKeysRotated(): Promise<void> {433 await identityHooks?.onKeysRotated().catch((error) => {434 console.warn("[Peach] local identity re-wrap failed:", error);435 });436}437438export type SealedBox = { nonce: string; ciphertext: string; tag: string };439export type IdentityKeyEnvelope = {440 root?: SealedBox;441 local?: SealedBox;442 pending?: SealedBox;443 pendingSalt?: string;444};445446export function setLockedSyncHooks(hooks: {447 refresh: () => Promise<void>;448 afterUnlock: () => Promise<void>;449 onDecoy?: () => void;450}): void {451 lockedSyncRefreshHook = hooks.refresh;452 lockedSyncUnlockHook = hooks.afterUnlock;453 decoySyncTeardownHook = hooks.onDecoy ?? null;454}455456async function refreshLockedSyncState(): Promise<void> {457 if (vaultMode !== "real" || !rootKey || !lockedSyncRefreshHook) return;458 await lockedSyncRefreshHook().catch((error) => {459 console.warn("[Peach sync] locked spool refresh failed:", error);460 });461}462463464465466467export async function checkBreach(password: string): Promise<number> {468 469 470 const encoder = new TextEncoder();471 const hashBuffer = await crypto.subtle.digest("SHA-1", encoder.encode(password));472 const hashHex = Array.from(new Uint8Array(hashBuffer))473 .map((b) => b.toString(16).padStart(2, "0"))474 .join("")475 .toUpperCase();476477 const prefix = hashHex.slice(0, 5);478 const suffix = hashHex.slice(5);479480 try {481 482 483 const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {484 headers: { "Add-Padding": "true" },485 });486 if (!res.ok) return 0;487 const text = await res.text();488 const match = text.split("\r\n").find((l) => l.startsWith(suffix));489 return match ? parseInt(match.split(":")[1], 10) : 0;490 } catch {491 return 0; 492 }493}494495export async function declineCardSaveOffer(last4: string, expMonth: string, expYear: string): Promise<void> {496 497 const key = await cardDeclineKey();498 if (!key) return;499 const fingerprint = await cardFingerprint(last4, expMonth, expYear);500 await loadSettings();501 settings = {502 ...settings,503 declinedCardFingerprints: [504 505 506 ...(settings.declinedCardFingerprints ?? []).filter(507 (stored) => stored.length === CARD_DECLINE_DIGEST_HEX,508 ),509 await metadataDigest(fingerprint, key),510 ].slice(-CARD_DECLINE_MAX),511 };512 await persistSettings();513}514515export async function isCardDeclined(last4: string, expMonth: string, expYear: string): Promise<boolean> {516 await loadSettings();517 const key = await cardDeclineKey();518 if (!key) return false;519 const fingerprint = await cardFingerprint(last4, expMonth, expYear);520 const candidate = await metadataDigest(fingerprint, key);521 return (settings.declinedCardFingerprints ?? []).some(522 (stored) => stored.length === CARD_DECLINE_DIGEST_HEX && stored === candidate,523 );524}525526527528529530531532533534535536537async function cardDeclineKey(): Promise<string | null> {538 const root = getRootKeyHex();539 if (!root) return null;540 const digest = new Uint8Array(541 await crypto.subtle.digest(542 "SHA-256",543 new TextEncoder().encode(`peach-card-decline-v1:${root}`),544 ),545 );546 return hex(digest);547}548549async function cardFingerprint(last4: string, expMonth: string, expYear: string): Promise<string> {550 const encoder = new TextEncoder();551 const buf = await crypto.subtle.digest("SHA-256", encoder.encode(`${last4}|${expMonth}|${expYear}`));552 return Array.from(new Uint8Array(buf).slice(0, 8))553 .map((b) => b.toString(16).padStart(2, "0"))554 .join("");555}556557function hex(buf: Uint8Array): string {558 return Array.from(buf)559 .map((b) => b.toString(16).padStart(2, "0"))560 .join("");561}562563function unhex(s: string): Uint8Array {564 const out = new Uint8Array(s.length / 2);565 for (let i = 0; i < s.length; i += 2) {566 out[i / 2] = parseInt(s.slice(i, i + 2), 16);567 }568 return out;569}570571572573574575576577578579580581582583584585586587588589590async function generateAndWrapRootKey(): Promise<{591 rawRootKey: string; 592 recoverySalt: string;593 wrappedRootKey: string;594 wrappedNonce: string;595 wrappedTag: string;596}> {597 const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));598599 600 const words = await rootKeyToWords(rootKeyBytes);601 const wordMaterial = await wordsToRootKeyMaterial(words);602 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));603 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);604605 606 607 const nonce = crypto.getRandomValues(new Uint8Array(12));608 const encoded = new TextEncoder().encode(hex(rootKeyBytes));609 const raw = await crypto.subtle.encrypt(610 {611 name: "AES-GCM",612 iv: nonce,613 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,614 tagLength: 128,615 },616 recoveryKey,617 encoded618 );619 620 const rawU8 = new Uint8Array(raw);621 const ciphertext = hex(rawU8.slice(0, rawU8.length - 16));622 const tag = hex(rawU8.slice(rawU8.length - 16));623624 return {625 rawRootKey: hex(rootKeyBytes),626 recoverySalt: hex(recoverySalt),627 wrappedRootKey: ciphertext,628 wrappedNonce: hex(nonce),629 wrappedTag: tag,630 };631}632633async function wrapExistingRootKeyForRecovery(rootKeyBytes: Uint8Array): Promise<{634 recoverySalt: string;635 wrappedRootKey: string;636 wrappedNonce: string;637 wrappedTag: string;638}> {639 if (rootKeyBytes.length !== 32) {640 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");641 }642643 const words = await rootKeyToWords(rootKeyBytes);644 const wordMaterial = await wordsToRootKeyMaterial(words);645 const recoverySalt = crypto.getRandomValues(new Uint8Array(16));646 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);647 const nonce = crypto.getRandomValues(new Uint8Array(12));648 const encoded = new TextEncoder().encode(hex(rootKeyBytes));649 const raw = await crypto.subtle.encrypt(650 {651 name: "AES-GCM",652 iv: nonce,653 additionalData: new Uint8Array([1]).buffer as ArrayBuffer,654 tagLength: 128,655 },656 recoveryKey,657 encoded,658 );659 const rawU8 = new Uint8Array(raw);660 return {661 recoverySalt: hex(recoverySalt),662 wrappedRootKey: hex(rawU8.slice(0, rawU8.length - 16)),663 wrappedNonce: hex(nonce),664 wrappedTag: hex(rawU8.slice(rawU8.length - 16)),665 };666}667668669670671672673export async function unwrapRootKeyForRecovery(674 wrappedRootKey: string,675 wrappedNonce: string,676 wrappedTag: string,677 recoverySaltHex: string,678 words: string[]679): Promise<void> {680 const wordMaterial = await wordsToRootKeyMaterial(words);681 const recoverySalt = unhex(recoverySaltHex);682 const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);683684 685 686 687 688 689 const nonceRaw = unhex(wrappedNonce);690 const ciphertextRaw = unhex(wrappedRootKey);691 const tagRaw = unhex(wrappedTag);692 693 694 const nonceU8 = nonceRaw as unknown as Uint8Array<ArrayBuffer>;695 const combined = new Uint8Array(ciphertextRaw.length + tagRaw.length);696 combined.set(ciphertextRaw, 0);697 combined.set(tagRaw, ciphertextRaw.length);698 const raw = await crypto.subtle.decrypt(699 { name: "AES-GCM", iv: nonceU8, additionalData: new Uint8Array([1]).buffer as ArrayBuffer, tagLength: 128 },700 recoveryKey,701 combined702 );703 const decryptedHex = new TextDecoder().decode(raw);704 rootKey = unhex(decryptedHex);705}706707708709710711712713714715716export async function rewrapRootKeyForPassword(717 newDerivedKey: Uint8Array,718): Promise<void> {719 if (!rootKey) throw new Error("root key not set");720 const slotA = await loadSlot(SLOT_A);721 if (!slotA) throw new Error("missing slot A");722 const pwRoot = await wrapRootKeyForDerivedKey(newDerivedKey, rootKey);723 await writeSlot(SLOT_A, { ...slotA, ...pwRoot });724}725726727728729730731async function reencryptVaultForNewKey(732 newKey: Uint8Array,733 slotA: StoredVault734): Promise<void> {735 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);736 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");737 await writeSlot(SLOT_A, {738 ...slotA,739 nonce,740 ciphertext,741 tag,742 });743}744745746747748749750export async function changePasswordWithNewKey(751 newSalt: Uint8Array,752 newKey: Uint8Array753): Promise<void> {754 const epoch = vaultEpoch;755 const throwIfStale = () => {756 if (epoch === vaultEpoch) return;757 zeroKey(newKey);758 throw new Error("vault_locked");759 };760 const slotA = await loadSlot(SLOT_A);761 throwIfStale();762 if (!slotA) throw new Error("missing slot A");763 764 765 await reencryptVaultForNewKey(newKey, { ...slotA, salt: hex(newSalt) });766 throwIfStale();767 if (derivedKey) zeroKey(derivedKey);768 derivedKey = newKey;769 770 771 772 await saveSessionKey(newKey, "real", epoch);773 throwIfStale();774 775 776 await notifyIdentityKeysRotated();777 throwIfStale();778 779 780 781 782 if (rootKey) {783 const slotAfter = await loadSlot(SLOT_A);784 throwIfStale();785 if (slotAfter) {786 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);787 throwIfStale();788 await writeSlot(SLOT_A, { ...slotAfter, ...pwRoot });789 throwIfStale();790 }791 }792 if (!duressConfigured) {793 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));794 throwIfStale();795 }796 await clearPinUnlockStorage();797 throwIfStale();798 await stampLocalVaultMutation();799 throwIfStale();800}801802803804805806807export { deriveKey, generateSalt };808809export async function loadSlotA(): Promise<StoredVault | null> {810 return loadSlot(SLOT_A);811}812813814815816817818819820821822async function deriveRootKeyFromDerivedKey(823 derivedKeyBytes: Uint8Array,824 slotA: StoredVault,825): Promise<Uint8Array | null> {826 if (!slotA.pw_root_ciphertext || !slotA.pw_root_nonce || !slotA.pw_root_tag) {827 return null;828 }829 try {830 const rootHex = await decrypt(831 derivedKeyBytes,832 slotA.pw_root_nonce,833 slotA.pw_root_ciphertext,834 slotA.pw_root_tag,835 "pw_root_v1",836 );837 return unhex(rootHex);838 } catch {839 return null;840 }841}842843export async function sealIdentityKeyUnderVault(ik: Uint8Array): Promise<IdentityKeyEnvelope> {844 if (vaultMode !== "real" || !rootKey || !derivedKey) throw new Error("vault_locked");845 846 847 const [root, local] = await Promise.all([848 encrypt(rootKey, hex(ik), "peach-local-identity-ik-root-v1"),849 encrypt(derivedKey, hex(ik), "peach-local-identity-ik-local-v1"),850 ]);851 return { root, local };852}853854export async function sealIdentityKeyPreVault(855 password: string,856 ik: Uint8Array,857): Promise<IdentityKeyEnvelope> {858 const salt = generateSalt();859 const key = await deriveKey(password, salt);860 try {861 const pending = await encrypt(key, hex(ik), "peach-local-identity-ik-pending-v1");862 return { pending, pendingSalt: hex(salt) };863 } finally {864 zeroKey(key);865 }866}867868869870871872873874875876export async function openIdentityKey(877 env: IdentityKeyEnvelope,878 password?: string,879): Promise<Uint8Array | null> {880 if (env.local && derivedKey) {881 const ik = await openSealedKey(derivedKey, env.local, "peach-local-identity-ik-local-v1");882 if (ik) return ik;883 }884 if (env.root && rootKey) {885 const ik = await openSealedKey(rootKey, env.root, "peach-local-identity-ik-root-v1");886 if (ik) return ik;887 }888 if (!env.pending || !env.pendingSalt || password === undefined) return null;889 let key: Uint8Array;890 try {891 const salt = unhex(env.pendingSalt);892 key = await deriveKey(password, salt);893 salt.fill(0);894 } catch {895 return null;896 }897 try {898 return await openSealedKey(key, env.pending, "peach-local-identity-ik-pending-v1");899 } finally {900 zeroKey(key);901 }902}903904905async function openSealedKey(906 key: Uint8Array,907 box: SealedBox,908 info: string,909): Promise<Uint8Array | null> {910 try {911 const value = unhex(await decrypt(key, box.nonce, box.ciphertext, box.tag, info));912 if (value.length === 32) return value;913 value.fill(0);914 return null;915 } catch {916 return null;917 }918}919920921922async function wrapRootKeyForDerivedKey(923 derivedKeyBytes: Uint8Array,924 rootKeyBytes: Uint8Array,925): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {926 const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");927 return {928 pw_root_ciphertext: ciphertext,929 pw_root_nonce: nonce,930 pw_root_tag: tag,931 };932}933934935export async function getRecoveryWords(): Promise<string[]> {936 if (!rootKey) {937 throw new Error("recovery not set up");938 }939 return rootKeyToWords(rootKey);940}941942async function loadSlot(key: string): Promise<StoredVault | null> {943 const result = await chrome.storage.local.get(key);944 return (result as Record<string, StoredVault | undefined>)[key] ?? null;945}946947async function writeSlot(key: string, vault: StoredVault): Promise<void> {948 try {949 await chrome.storage.local.set({ [key]: vault });950 } catch (err) {951 throw mapStorageError(err);952 }953}954955956957958959960961962963964export async function rollbackAdoptedVault(): Promise<void> {965 await lock();966 try {967 await chrome.storage.local.remove([968 SLOT_A,969 SLOT_B,970 "peach_last_modified_at",971 ]);972 } catch (err) {973 throw mapStorageError(err);974 }975}976977async function commitCreatedVaultSlots(slotA: StoredVault, slotB: StoredVault): Promise<void> {978 const raw = await chrome.storage.local.get("peach_last_modified_at");979 const previous = Number(980 (raw as Record<string, unknown>).peach_last_modified_at,981 );982 const next = Math.max(983 Date.now(),984 Number.isFinite(previous) ? previous + 1 : 0,985 );986 try {987 await chrome.storage.local.set({988 [SLOT_A]: slotA,989 [SLOT_B]: slotB,990 peach_last_modified_at: String(next),991 });992 } catch (err) {993 throw mapStorageError(err);994 }995}99699799899910001001100210031004let lastEntryTimestamp = 0;1005function monotonicEntryTimestamp(currentModifiedAt?: number): number {1006 const base = Math.max(1007 Date.now(),1008 (currentModifiedAt ?? 0) + 1,1009 lastEntryTimestamp + 1,1010 );1011 lastEntryTimestamp = base;1012 return base;1013}10141015async function stampLocalVaultMutation(): Promise<void> {1016 const raw = await chrome.storage.local.get("peach_last_modified_at");1017 const previous = Number(1018 (raw as Record<string, unknown>).peach_last_modified_at,1019 );1020 const next = Math.max(1021 Date.now(),1022 Number.isFinite(previous) ? previous + 1 : 0,1023 );1024 await chrome.storage.local.set({1025 peach_last_modified_at: String(next),1026 });1027}102810291030103110321033103410351036103710381039function padPlaintext(json: string, minBytes = 0): string {1040 const encoded = new TextEncoder().encode(json);1041 const bucket = Math.max(PAD_BLOCK, Math.ceil(encoded.length / PAD_BLOCK) * PAD_BLOCK);1042 1043 const target = Math.max(bucket, minBytes);1044 const padLen = target - encoded.length;1045 return json + " ".repeat(padLen);1046}104710481049105010511052105310541055105610571058const CSPRNG_CHUNK = 65_536;10591060function fillRandom(buf: Uint8Array): Uint8Array {1061 for (let offset = 0; offset < buf.length; offset += CSPRNG_CHUNK) {1062 const end = Math.min(offset + CSPRNG_CHUNK, buf.length);1063 const chunk = new Uint8Array(end - offset);1064 crypto.getRandomValues(chunk);1065 buf.set(chunk, offset);1066 }1067 return buf;1068}10691070function generateNoiseBlob(ciphertextBytes: number): StoredVault {1071 const salt = crypto.getRandomValues(new Uint8Array(16));1072 const nonce = crypto.getRandomValues(new Uint8Array(12));1073 const cipher = fillRandom(new Uint8Array(ciphertextBytes));1074 const tag = crypto.getRandomValues(new Uint8Array(16));1075 return {1076 version: 1,1077 salt: hex(salt),1078 nonce: hex(nonce),1079 ciphertext: hex(cipher),1080 tag: hex(tag),1081 };1082}108310841085108610871088type VaultPayload = {1089 entries: VaultEntry[];1090 tombstones?: Record<string, number>;1091 duressConfigured?: boolean;1092};10931094type DecryptedPayload = {1095 entries: VaultEntry[];1096 tombstones: Map<string, number>;1097 duressConfigured: boolean;1098};10991100function encodePayload(entries: VaultEntry[], duress: boolean, minBytes = 0): string {1101 const envelope: VaultPayload = {1102 entries,1103 tombstones: Object.fromEntries(tombstones),1104 duressConfigured: duress,1105 };1106 return padPlaintext(JSON.stringify(envelope), minBytes);1107}11081109function decodeTombstones(raw: unknown, allEntries: VaultEntry[]): Map<string, number> {1110 const result = new Map<string, number>();1111 if (raw && typeof raw === "object" && !Array.isArray(raw)) {1112 for (const [id, timestamp] of Object.entries(raw as Record<string, unknown>)) {1113 if (typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0) {1114 result.set(id, timestamp);1115 }1116 }1117 }1118 1119 1120 for (const entry of allEntries) {1121 if (typeof entry.deletedAt !== "number" || !Number.isFinite(entry.deletedAt) || entry.deletedAt <= 0) {1122 continue;1123 }1124 if ((result.get(entry.id) ?? 0) < entry.deletedAt) {1125 result.set(entry.id, entry.deletedAt);1126 }1127 }1128 return result;1129}11301131function reconcileTombstones(): void {1132 const byId = new Map(entries.map((entry) => [entry.id, entry]));1133 for (const [id] of tombstones) {1134 if (byId.has(id) && !byId.get(id)?.deletedAt) tombstones.delete(id);1135 }1136 for (const entry of entries) {1137 if (typeof entry.deletedAt === "number" && Number.isFinite(entry.deletedAt) && entry.deletedAt > 0) {1138 if ((tombstones.get(entry.id) ?? 0) < entry.deletedAt) {1139 tombstones.set(entry.id, entry.deletedAt);1140 }1141 }1142 }1143}11441145function decodePayload(plain: string): DecryptedPayload {1146 const parsed = JSON.parse(plain) as VaultPayload;1147 if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.entries)) {1148 throw new Error("invalid vault payload");1149 }1150 const normalized = parsed.entries.map(normalizeEntry);1151 return {1152 entries: normalized,1153 tombstones: decodeTombstones(parsed.tombstones, normalized),1154 duressConfigured: parsed.duressConfigured === true,1155 };1156}1157115811591160116111621163116411651166116711681169117011711172117311741175117611771178function mergePasskeyOnlyIntoLogins(entries: VaultEntry[]): { merged: boolean } {1179 let merged = false;1180 const now = Date.now();11811182 const candidates = entries.filter((e) => !e.deletedAt && e.passkey);1183 const byRpId = new Map<string, VaultEntry[]>();1184 for (const e of candidates) {1185 const rpId = e.passkey!.rpId;1186 const list = byRpId.get(rpId);1187 if (list) list.push(e);1188 else byRpId.set(rpId, [e]);1189 }11901191 for (const [rpId, group] of byRpId) {1192 if (group.length === 0) continue;1193 group.sort((a, b) => {1194 const aT =1195 a.passkey!.lastUsedAt ?? a.passkey!.createdAt ?? a.modifiedAt ?? a.createdAt;1196 const bT =1197 b.passkey!.lastUsedAt ?? b.passkey!.createdAt ?? b.modifiedAt ?? b.createdAt;1198 return bT - aT;1199 });1200 const winner = group[0]!;12011202 1203 1204 1205 const loginTarget = entries.find((e) => {1206 if (e.deletedAt || e.passkey || e.type !== "login") return false;1207 if (e.url && extractDomain(e.url) === rpId) return true;1208 for (const extra of e.additionalUrls ?? []) {1209 if (extractDomain(extra) === rpId) return true;1210 }1211 return false;1212 });12131214 if (loginTarget) {1215 loginTarget.passkey = winner.passkey;1216 1217 1218 1219 if (!loginTarget.totp && winner.totp) {1220 loginTarget.totp = winner.totp;1221 loginTarget.totpAlgorithm = winner.totpAlgorithm;1222 loginTarget.totpDigits = winner.totpDigits;1223 loginTarget.totpPeriod = winner.totpPeriod;1224 }1225 loginTarget.modifiedAt = now;1226 for (const dup of group) {1227 if (!dup.deletedAt) {1228 dup.deletedAt = now;1229 dup.modifiedAt = now;1230 }1231 }1232 merged = true;1233 } else if (group.length > 1) {1234 for (let i = 1; i < group.length; i += 1) {1235 const dup = group[i]!;1236 if (!dup.deletedAt) {1237 dup.deletedAt = now;1238 dup.modifiedAt = now;1239 }1240 }1241 merged = true;1242 }1243 }12441245 return { merged };1246}12471248124912501251125212531254function hasValidVaultSchema(v: unknown): v is StoredVault {1255 if (typeof v !== "object" || v === null) return false;1256 const r = v as Record<string, unknown>;1257 return (1258 r.version === 1 &&1259 typeof r.salt === "string" &&1260 typeof r.nonce === "string" &&1261 typeof r.ciphertext === "string" &&1262 typeof r.tag === "string"1263 );1264}1265const MAX_HISTORY_BLOB_BYTES = 1_024 * 1_024;12661267function hasValidHistorySlot(value: unknown): value is StoredVault {1268 if (!hasValidVaultSchema(value)) return false;1269 return /^[0-9a-f]{32}$/i.test(value.salt) &&1270 /^[0-9a-f]{24}$/i.test(value.nonce) &&1271 /^[0-9a-f]+$/i.test(value.ciphertext) &&1272 value.ciphertext.length % 2 === 0 &&1273 value.ciphertext.length / 2 <= MAX_HISTORY_BLOB_BYTES &&1274 /^[0-9a-f]{32}$/i.test(value.tag);1275}12761277function parseHistorySlot(value: string): StoredVault | null {1278 try {1279 const parsed: unknown = JSON.parse(value);1280 return hasValidHistorySlot(parsed) ? parsed : null;1281 } catch {1282 return null;1283 }1284}12851286function isHistoryRecord(value: unknown): value is VaultSnapshot {1287 if (typeof value !== "object" || value === null) return false;1288 const record = value as Record<string, unknown>;1289 return typeof record.id === "string" && record.id.length > 0 && record.id.length <= 128 &&1290 typeof record.savedAt === "number" && Number.isFinite(record.savedAt) &&1291 typeof record.reason === "string" &&1292 typeof record.vault_a === "string" && record.vault_a.length <= MAX_HISTORY_BLOB_BYTES * 2 &&1293 typeof record.vault_b === "string" && record.vault_b.length <= MAX_HISTORY_BLOB_BYTES * 2 &&1294 (record.entryCount === null || typeof record.entryCount === "number");1295}12961297function sanitizeHistory(value: unknown): VaultSnapshot[] {1298 if (typeof value !== "object" || value === null) return [];1299 if (!("snapshots" in value) || !Array.isArray(value.snapshots)) return [];1300 return value.snapshots.filter(isHistoryRecord).slice(0, HISTORY_MAX);1301}13021303export type TryDecryptResult =1304 | { status: "ok"; payload: DecryptedPayload }1305 | { status: "wrong_key" }1306 | { status: "corrupted"; reason: string };1307130813091310131113121313export async function tryDecryptSlot(1314 vault: StoredVault,1315 key: Uint8Array,1316 aadTag?: string,1317): Promise<DecryptedPayload | null> {1318 const res = await tryDecryptSlotResult(vault, key, aadTag);1319 return res.status === "ok" ? res.payload : null;1320}13211322export async function tryDecryptSlotResult(1323 vault: StoredVault,1324 key: Uint8Array,1325 aadTag?: string,1326): Promise<TryDecryptResult> {1327 if (!hasValidVaultSchema(vault)) {1328 return { status: "corrupted", reason: "invalid_slot_schema" };1329 }1330 let plain: string;1331 try {1332 plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);1333 } catch {1334 return { status: "wrong_key" };1335 }1336 try {1337 const parsed = JSON.parse(plain) as unknown;1338 if (typeof parsed !== "object" || parsed === null) {1339 return { status: "corrupted", reason: "invalid_payload_shape" };1340 }1341 if (!Array.isArray(parsed) && !Array.isArray((parsed as VaultPayload).entries)) {1342 return { status: "corrupted", reason: "invalid_payload_shape" };1343 }1344 return { status: "ok", payload: decodePayload(plain) };1345 } catch {1346 return { status: "corrupted", reason: "invalid_payload_json" };1347 }1348}134913501351135213531354function buildDomainIndexSync(all: VaultEntry[]): void {1355 domainIndex = new Map();13561357 for (const entry of all) {1358 if (entry.deletedAt) continue;1359 if (entry.type === "note" || entry.type === "card") continue;13601361 1362 1363 1364 1365 const domains = new Set<string>();1366 const primary = extractDomain(entry.url ?? "");1367 if (primary) domains.add(primary);1368 for (const extra of entry.additionalUrls ?? []) {1369 const d = extractDomain(extra);1370 if (d) domains.add(d);1371 }1372 if (domains.size === 0) continue;13731374 for (const domain of domains) {1375 const bucket = domainIndex.get(domain) ?? [];1376 bucket.push(entry);1377 domainIndex.set(domain, bucket);1378 }1379 }1380}13811382export async function createVault(password: string): Promise<void> {1383 const salt = generateSalt();1384 derivedKey = await deriveKey(password, salt);1385 entries = [];1386 tombstones = new Map();1387 vaultMode = "real";1388 duressConfigured = false;1389 buildDomainIndexSync(entries);13901391 1392 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =1393 await generateAndWrapRootKey();1394 rootKey = unhex(rawRootKey);13951396 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);1397 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");13981399 1400 1401 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, rootKey);14021403 const slotA: StoredVault = {1404 version: 1,1405 salt: hex(salt),1406 nonce,1407 ciphertext,1408 tag,1409 recovery_salt: recoverySalt,1410 recovery_wrapped_root_key: wrappedRootKey,1411 recovery_nonce: wrappedNonce,1412 recovery_tag: wrappedTag,1413 ...pwRoot,1414 };1415 await writeSlot(SLOT_A, slotA);14161417 1418 1419 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));1420 await stampLocalVaultMutation();14211422 1423 1424 await identityHooks?.onVaultCreated(password).catch((error) => {1425 console.warn("[Peach] local identity promotion failed:", error);1426 });14271428 touchAutoLock();1429}1430143114321433143414351436export async function createVaultFromRootKey(1437 password: string,1438 rootKeyHex: string,1439): Promise<{ vaultId: string }> {1440 if (typeof password !== "string" || password.length === 0) {1441 throw new Error("ADOPT_INVALID_PASSWORD");1442 }1443 if (typeof rootKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(rootKeyHex)) {1444 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars");1445 }14461447 const rootKeyBytes = unhex(rootKeyHex);1448 if (rootKeyBytes.length !== 32) {1449 rootKeyBytes.fill(0);1450 throw new Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes");1451 }14521453 let nextDerivedKey: Uint8Array | null = null;1454 let sessionRootKey: Uint8Array | null = null;1455 try {1456 if ((await loadSlot(SLOT_A)) !== null) {1457 throw new Error("ADOPT_VAULT_ALREADY_EXISTS");1458 }14591460 const nextEntries: VaultEntry[] = [];1461 const nextDuressConfigured = false;1462 const vaultId = await computeVaultId(rootKeyBytes);1463 const salt = generateSalt();1464 nextDerivedKey = await deriveKey(password, salt);1465 const recovery = await wrapExistingRootKeyForRecovery(rootKeyBytes);14661467 sessionRootKey = new Uint8Array(rootKeyBytes);1468 const plaintext = encodePayload(nextEntries, nextDuressConfigured, 0);1469 const { nonce, ciphertext, tag } = await encrypt(nextDerivedKey, plaintext, "slot_a_v1");1470 const pwRoot = await wrapRootKeyForDerivedKey(nextDerivedKey, sessionRootKey);1471 const slotA: StoredVault = {1472 version: 1,1473 salt: hex(salt),1474 nonce,1475 ciphertext,1476 tag,1477 recovery_salt: recovery.recoverySalt,1478 recovery_wrapped_root_key: recovery.wrappedRootKey,1479 recovery_nonce: recovery.wrappedNonce,1480 recovery_tag: recovery.wrappedTag,1481 ...pwRoot,1482 };1483 const slotB = generateNoiseBlob(ciphertext.length / 2);1484 await commitCreatedVaultSlots(slotA, slotB);14851486 const previousDerivedKey = derivedKey;1487 const previousRootKey = rootKey;1488 derivedKey = nextDerivedKey;1489 rootKey = sessionRootKey;1490 nextDerivedKey = null;1491 sessionRootKey = null;1492 entries = nextEntries;1493 tombstones = new Map();1494 vaultMode = "real";1495 duressConfigured = nextDuressConfigured;1496 buildDomainIndexSync(entries);1497 if (previousDerivedKey && previousDerivedKey !== derivedKey) zeroKey(previousDerivedKey);1498 if (previousRootKey && previousRootKey !== rootKey) previousRootKey.fill(0);1499 touchAutoLock();15001501 1502 1503 await identityHooks?.onVaultCreated(password).catch((error) => {1504 console.warn("[Peach] local identity promotion failed:", error);1505 });15061507 return { vaultId };1508 } finally {1509 rootKeyBytes.fill(0);1510 if (nextDerivedKey) zeroKey(nextDerivedKey);1511 if (sessionRootKey) sessionRootKey.fill(0);1512 }1513}15141515export async function hasVault(): Promise<boolean> {1516 return (await loadSlot(SLOT_A)) !== null;1517}1518151915201521152215231524export async function isDuressEnabled(): Promise<boolean> {1525 return vaultMode === "real" && duressConfigured;1526}15271528export async function setupDuress(duressPassword: string): Promise<void> {1529 if (!derivedKey || vaultMode !== "real") {1530 throw new Error("vault must be unlocked in real mode");1531 }1532 1533 1534 1535 const slotA = await loadSlot(SLOT_A);1536 if (!slotA) throw new Error("missing slot A");15371538 const decoySalt = generateSalt();1539 const decoyKey = await deriveKey(duressPassword, decoySalt);1540 try {1541 1542 1543 1544 1545 const plaintext = encodePayload([], false, DURESS_SLOT_SIZE);1546 const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");1547 await writeSlot(SLOT_B, {1548 version: 1,1549 salt: hex(decoySalt),1550 nonce,1551 ciphertext,1552 tag,1553 });1554 } finally {1555 zeroKey(decoyKey);1556 }15571558 1559 1560 duressConfigured = true;1561 await persistSlotA();1562}15631564export async function disableDuress(): Promise<void> {1565 if (!derivedKey || vaultMode !== "real") {1566 throw new Error("vault must be unlocked in real mode");1567 }1568 1569 1570 1571 const slotA = await loadSlot(SLOT_A);1572 if (slotA) {1573 await writeSlot(SLOT_B, generateNoiseBlob(slotA.ciphertext.length / 2));1574 }15751576 duressConfigured = false;1577 await persistSlotA();1578}15791580158115821583158415851586export async function verifyMainPassword(password: string): Promise<boolean> {1587 const slotA = await loadSlot(SLOT_A);1588 if (!slotA) return false;1589 const key = await deriveKey(password, unhex(slotA.salt));1590 try {1591 return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;1592 } finally {1593 zeroKey(key);1594 }1595}15961597export async function unlock(password: string): Promise<boolean> {1598 return attemptUnlockWithDerive(password, deriveKey);1599}16001601async function attemptUnlockWithDerive(1602 password: string,1603 derive: typeof deriveKey,1604): Promise<boolean> {1605 const epoch = vaultEpoch;1606 const stale = () => epoch !== vaultEpoch;1607 1608 1609 1610 1611 1612 1613 const [slotA, slotB] = await Promise.all([1614 loadSlot(SLOT_A),1615 loadSlot(SLOT_B),1616 ]);16171618 1619 const [keyA, keyB] = await Promise.all([1620 slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1621 slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),1622 ]);16231624 1625 1626 1627 const [resultA, resultB] = await Promise.all([1628 keyA && slotA1629 ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")1630 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1631 keyB && slotB1632 ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")1633 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),1634 ]);1635 const payloadA = resultA.status === "ok" ? resultA.payload : null;1636 const payloadB = resultB.status === "ok" ? resultB.payload : null;16371638 if (stale()) {1639 if (keyA) zeroKey(keyA);1640 if (keyB) zeroKey(keyB);1641 return false;1642 }16431644 if (payloadA && keyA) {1645 if (stale()) {1646 zeroKey(keyA);1647 if (keyB) zeroKey(keyB);1648 return false;1649 }1650 derivedKey = keyA;1651 vaultMode = "real";1652 entries = payloadA.entries;1653 tombstones = payloadA.tombstones;1654 duressConfigured = payloadA.duressConfigured;1655 buildDomainIndexSync(entries);1656 await loadSettings();1657 if (stale()) {1658 await lock();1659 return false;1660 }1661 touchAutoLock();1662 if (keyB) zeroKey(keyB);16631664 1665 1666 rootKey = slotA ? await deriveRootKeyFromDerivedKey(keyA, slotA) : null;1667 if (!rootKey) {1668 await lock();1669 return false;1670 }16711672 1673 1674 await reconcilePairSecrets(rootKey);1675 if (stale()) {1676 await lock();1677 return false;1678 }1679 await lockedSyncUnlockHook?.().catch((error) => {1680 console.warn("[Peach sync] locked inbox drain failed:", error);1681 });1682 if (stale()) {1683 await lock();1684 return false;1685 }1686 await resetPinFailedAttempts();1687 if (stale()) {1688 await lock();1689 return false;1690 }1691 await saveSessionKey(keyA, "real", epoch);1692 if (stale()) return false;1693 return true;1694 }16951696 if (payloadB && keyB) {1697 if (stale()) {1698 zeroKey(keyB);1699 if (keyA) zeroKey(keyA);1700 return false;1701 }1702 derivedKey = keyB;1703 vaultMode = "decoy";1704 decoySyncTeardownHook?.();1705 1706 1707 1708 if (rootKey) {1709 rootKey.fill(0);1710 rootKey = null;1711 }1712 entries = payloadB.entries;1713 tombstones = payloadB.tombstones;1714 duressConfigured = false;1715 buildDomainIndexSync(entries);1716 await loadSettings();1717 touchAutoLock();1718 if (keyA) zeroKey(keyA);1719 1720 await dropSessionCache();1721 1722 await notifyIdentitySessionEnded();1723 if (stale()) {1724 await lock();1725 return false;1726 }1727 await saveSessionKey(keyB, "decoy", epoch);1728 if (stale()) return false;1729 return true;1730 }17311732 if (keyA) zeroKey(keyA);1733 if (keyB) zeroKey(keyB);1734 1735 1736 if (resultA.status === "corrupted") {1737 lastUnlockFailureReason = resultA.reason;1738 } else if (resultB.status === "corrupted") {1739 lastUnlockFailureReason = resultB.reason;1740 }1741 return false;1742}17431744174517461747174817491750const SESSION_KEY_STORAGE = "peach_session_dk";1751175217531754let sessionKeyMutation = Promise.resolve();17551756function mutateSessionKey(mutation: () => Promise<void>): Promise<void> {1757 const task = sessionKeyMutation.then(mutation, mutation);1758 sessionKeyMutation = task.catch(() => undefined);1759 return task;1760}176117621763176417651766let sessionEscrowAvailable = true;17671768176917701771177217731774export async function applyStorageHardening(sessionEscrow: boolean): Promise<void> {1775 setSessionAllowed(sessionEscrow);1776 if (sessionEscrow) return;1777 sessionEscrowAvailable = false;1778 await clearSessionKey();1779}17801781async function saveSessionKey(1782 key: Uint8Array,1783 mode: "real" | "decoy",1784 expectedEpoch = vaultEpoch,1785): Promise<void> {1786 const session = chrome.storage.session;1787 if (!session || !sessionEscrowAvailable || expectedEpoch !== vaultEpoch) return;1788 const stored = { keyHex: hex(key), mode };1789 await mutateSessionKey(async () => {1790 if (!sessionEscrowAvailable || expectedEpoch !== vaultEpoch) return;1791 await session.set({ [SESSION_KEY_STORAGE]: stored });1792 });1793}17941795async function clearSessionKey(): Promise<void> {1796 const session = chrome.storage.session;1797 if (!session) return;1798 await mutateSessionKey(() => session.remove(SESSION_KEY_STORAGE));1799}18001801180218031804180518061807let vaultEpoch = 0;1808180918101811let restoreInFlight: Promise<boolean> | null = null;181218131814181518161817181818191820export async function restoreFromSession(): Promise<boolean> {1821 if (derivedKey !== null) return true;1822 if (restoreInFlight) return restoreInFlight;1823 const task = restoreFromSessionInner();1824 restoreInFlight = task;1825 try {1826 return await task;1827 } finally {1828 restoreInFlight = null;1829 }1830}18311832async function restoreFromSessionInner(): Promise<boolean> {1833 const epoch = vaultEpoch;1834 const stale = () => epoch !== vaultEpoch;1835 if (derivedKey !== null) return true;1836 if (!sessionEscrowAvailable) return false;1837 if (!chrome.storage.session) return false;1838 const stored = await chrome.storage.session.get(SESSION_KEY_STORAGE);1839 if (stale()) return false;1840 const entry = stored[SESSION_KEY_STORAGE] as { keyHex: string; mode: "real" | "decoy" } | undefined;1841 if (!entry?.keyHex) return false;18421843 const key = unhex(entry.keyHex);1844 const slotKey = entry.mode === "real" ? SLOT_A : SLOT_B;1845 const aadTag = entry.mode === "real" ? "slot_a_v1" : "slot_b_v1";1846 const slot = await loadSlot(slotKey);1847 if (stale()) { zeroKey(key); return false; }1848 if (!slot) { await clearSessionKey(); return false; }18491850 const result = await tryDecryptSlotResult(slot, key, aadTag);1851 if (stale()) { zeroKey(key); return false; }1852 if (result.status !== "ok") { await clearSessionKey(); return false; }18531854 1855 1856 const payload = result.payload;1857 await loadSettings();1858 if (stale()) { zeroKey(key); return false; }18591860 1861 1862 let freshRoot: Uint8Array | null = null;1863 if (entry.mode === "real") {1864 freshRoot = await deriveRootKeyFromDerivedKey(key, slot);1865 if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1866 if (!freshRoot) {1867 await lock();1868 return false;1869 }1870 }18711872 if (stale()) { zeroKey(key); if (freshRoot) zeroKey(freshRoot); return false; }1873 if (freshRoot) {1874 1875 await reconcilePairSecrets(freshRoot);1876 if (stale()) { zeroKey(key); zeroKey(freshRoot); return false; }1877 } else {1878 1879 await dropSessionCache();1880 1881 await notifyIdentitySessionEnded();1882 if (stale()) { zeroKey(key); return false; }1883 }1884 derivedKey = key;1885 vaultMode = entry.mode;1886 if (entry.mode === "decoy") decoySyncTeardownHook?.();1887 entries = payload.entries;1888 tombstones = payload.tombstones;1889 duressConfigured = entry.mode === "real" ? payload.duressConfigured : false;1890 if (freshRoot) rootKey = freshRoot;1891 buildDomainIndexSync(entries);1892 touchAutoLock();18931894 await lockedSyncUnlockHook?.().catch((error) => {1895 console.warn("[Peach sync] locked inbox drain failed:", error);1896 });1897 if (stale()) return false;18981899 return true;1900}19011902export async function lock(): Promise<void> {1903 1904 1905 1906 1907 1908 1909 1910 1911 1912 vaultEpoch++;1913 if (derivedKey) {1914 zeroKey(derivedKey);1915 derivedKey = null;1916 }1917 if (rootKey) {1918 rootKey.fill(0);1919 rootKey = null;1920 }19211922 entries = [];1923 tombstones = new Map();1924 domainIndex = new Map();1925 vaultMode = "real";1926 duressConfigured = false;1927 clearAutoLock();1928 await clearSessionKey();1929 1930 1931 await notifyIdentitySessionEnded();1932}19331934export function isUnlocked(): boolean {1935 return derivedKey !== null;1936}19371938export function isDecoyMode(): boolean {1939 return derivedKey !== null && vaultMode === "decoy";1940}19411942194319441945export async function unlockWithKey(1946 masterKeyHex: string,1947): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1948 const epoch = vaultEpoch;1949 const stale = () => epoch !== vaultEpoch;1950 let masterKeyBytes: Uint8Array | null = null;1951 try {1952 masterKeyBytes = unhex(masterKeyHex);1953 const stored = await loadSlot(SLOT_A);1954 if (!stored) return { ok: false, error: "invalid_key" };1955 const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1956 if (!payload || stale()) {1957 zeroKey(masterKeyBytes);1958 return { ok: false, error: "invalid_key" };1959 }19601961 derivedKey = masterKeyBytes;1962 vaultMode = "real";1963 entries = payload.entries;1964 tombstones = payload.tombstones;1965 duressConfigured = payload.duressConfigured;1966 buildDomainIndexSync(entries);1967 await loadSettings();1968 touchAutoLock();19691970 rootKey = await deriveRootKeyFromDerivedKey(masterKeyBytes, stored);1971 if (!rootKey) {1972 await lock();1973 return { ok: false, error: "invalid_key" };1974 }1975 await reconcilePairSecrets(rootKey);1976 if (stale()) {1977 await lock();1978 return { ok: false, error: "invalid_key" };1979 }1980 await saveSessionKey(masterKeyBytes, "real", epoch);1981 if (stale()) return { ok: false, error: "invalid_key" };1982 return { ok: true, entries };1983 } catch {1984 if (masterKeyBytes && derivedKey !== masterKeyBytes) zeroKey(masterKeyBytes);1985 return { ok: false, error: "invalid_key" };1986 }1987}198819891990199119921993199419951996export async function exportDuressSlotForAdoption(): Promise<string | null> {1997 if (!(await isDuressEnabled())) return null;1998 const slotB = await loadSlot(SLOT_B);1999 if (!slotB) return null;2000 return JSON.stringify(slotB);2001}20022003200420052006200720082009export async function installAdoptedDuressSlot(json: string): Promise<void> {2010 let parsed: StoredVault;2011 try {2012 const raw = JSON.parse(json) as unknown;2013 if (!hasValidVaultSchema(raw)) {2014 throw new Error("invalid_duress_slot_shape");2015 }2016 parsed = raw;2017 } catch (err) {2018 if (err instanceof SyntaxError) {2019 throw new Error("invalid_duress_slot_shape");2020 }2021 throw err;2022 }2023 await writeSlot(SLOT_B, parsed);2024}20252026202720282029203020312032203320342035203620372038203920402041204220432044export async function unlockFromCodex(params: {2045 slotA: string; 2046 slotB: string | null; 2047 timestamp: number;2048 masterPassphrase: string;2049}): Promise<2050 | { ok: true; entryCount: number }2051 | { ok: false; reason: "wrong_passphrase" | "invalid_slot_format" | "missing_root_escrow" }2052> {2053 let parsedSlotA: StoredVault;2054 let parsedSlotB: StoredVault | null = null;2055 try {2056 const rawA = JSON.parse(params.slotA) as unknown;2057 if (!hasValidVaultSchema(rawA)) {2058 return { ok: false, reason: "invalid_slot_format" };2059 }2060 parsedSlotA = rawA;2061 if (params.slotB !== null) {2062 const rawB = JSON.parse(params.slotB) as unknown;2063 if (!hasValidVaultSchema(rawB)) {2064 return { ok: false, reason: "invalid_slot_format" };2065 }2066 parsedSlotB = rawB;2067 }2068 } catch {2069 return { ok: false, reason: "invalid_slot_format" };2070 }20712072 2073 2074 2075 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));2076 const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");2077 if (!trialPayload) {2078 zeroKey(trialKey);2079 return { ok: false, reason: "wrong_passphrase" };2080 }2081 const hasEscrow =2082 !!parsedSlotA.pw_root_ciphertext && !!parsedSlotA.pw_root_nonce && !!parsedSlotA.pw_root_tag;2083 if (!hasEscrow) {2084 zeroKey(trialKey);2085 return { ok: false, reason: "missing_root_escrow" };2086 }2087 const stagedRootKey = await deriveRootKeyFromDerivedKey(trialKey, parsedSlotA);2088 if (!stagedRootKey) {2089 zeroKey(trialKey);2090 return { ok: false, reason: "wrong_passphrase" };2091 }2092 zeroKey(stagedRootKey);2093 zeroKey(trialKey);20942095 2096 const stagedSlotB = parsedSlotB ?? generateNoiseBlob(parsedSlotA.ciphertext.length / 2);20972098 2099 21002101 2102 2103 await takeVaultSnapshot("restore");2104 const previousSlotA = await loadSlot(SLOT_A);2105 const previousSlotB = await loadSlot(SLOT_B);2106 let slotACommitted = false;2107 let slotBCommitted = false;2108 try {2109 await writeSlot(SLOT_A, parsedSlotA);2110 slotACommitted = true;2111 await writeSlot(SLOT_B, stagedSlotB);2112 slotBCommitted = true;2113 await chrome.storage.local.set({2114 peach_last_modified_at: String(params.timestamp),2115 });2116 } catch (err) {2117 if (slotBCommitted && previousSlotB) await writeSlot(SLOT_B, previousSlotB);2118 if (slotACommitted && previousSlotA) await writeSlot(SLOT_A, previousSlotA);2119 throw err;2120 }21212122 2123 2124 const ok = await unlock(params.masterPassphrase);2125 if (!ok) {2126 2127 return { ok: false, reason: "wrong_passphrase" };2128 }2129 return { ok: true, entryCount: getEntries().length };2130}21312132213321342135export function getDerivedKeyHex(): string {2136 if (!derivedKey) throw new Error("vault_not_unlocked");2137 return hex(derivedKey);2138}21392140214121422143214421452146214721482149215021512152215321542155export function getRootKeyHex(): string | null {2156 return rootKey ? hex(rootKey) : null;2157}21582159export function getEntries(): VaultEntry[] {2160 if (!derivedKey) throw new Error("vault_locked");2161 return entries.filter((entry) => !entry.deletedAt);2162}21632164const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;21652166export function getIdentities(): SavedIdentity[] {2167 if (!derivedKey) throw new Error("vault_locked");2168 const active = entries.filter((e) => !e.deletedAt && e.type === "login");21692170 const emailMap = new Map<string, { lastUsed: number; count: number; domain: string }>();2171 const usernameMap = new Map<string, { lastUsed: number; count: number; domain: string }>();21722173 for (const entry of active) {2174 const domain = extractDomain(entry.url ?? "") ?? "";2175 const modifiedAt = entry.modifiedAt ?? entry.createdAt ?? 0;21762177 function tally(map: Map<string, { lastUsed: number; count: number; domain: string }>, v: string) {2178 const existing = map.get(v);2179 if (!existing || modifiedAt > existing.lastUsed) {2180 map.set(v, { lastUsed: modifiedAt, count: (existing?.count ?? 0) + 1, domain });2181 } else {2182 map.set(v, { ...existing, count: existing.count + 1 });2183 }2184 }21852186 const uVal = entry.username?.trim();2187 if (uVal) tally(EMAIL_RE.test(uVal) ? emailMap : usernameMap, uVal);21882189 2190 2191 const eVal = entry.email?.trim();2192 if (eVal) tally(emailMap, eVal);2193 }21942195 const toIdentity = (type: "email" | "username") =>2196 ([value, d]: [string, { lastUsed: number; count: number; domain: string }]): SavedIdentity => ({2197 value, type, lastUsed: d.lastUsed, useCount: d.count, exampleDomain: d.domain || undefined,2198 });21992200 return [2201 ...[...emailMap.entries()].map(toIdentity("email")).sort((a, b) => b.useCount - a.useCount),2202 ...[...usernameMap.entries()].map(toIdentity("username")).sort((a, b) => b.useCount - a.useCount),2203 ];2204}22052206export async function batchAddEntries(drafts: EntryDraft[]): Promise<VaultEntry[]> {2207 if (!derivedKey) throw new Error("vault_locked");22082209 const now = Date.now();2210 const added: VaultEntry[] = [];2211 for (const draft of drafts) {2212 try {2213 const sanitized = sanitizeEntryDraft(draft);2214 const nextEntry: VaultEntry = {2215 ...sanitized,2216 id: crypto.randomUUID(),2217 createdAt: now,2218 modifiedAt: monotonicEntryTimestamp(),2219 };2220 entries.push(nextEntry);2221 added.push(nextEntry);2222 } catch {2223 2224 }2225 }22262227 if (added.length > 0) {2228 await persistEntries();2229 }22302231 return added;2232}223322342235223622372238223922402241export async function restoreEntries(2242 incoming: VaultEntry[],2243 mode: "replace" | "merge"2244): Promise<{ added: number; updated: number; skipped: number }> {2245 if (!derivedKey) throw new Error("vault_locked");22462247 2248 2249 2250 if (mode === "replace" && incoming.length === 0) {2251 throw new Error("refuse to replace with empty entry set");2252 }22532254 let added = 0;2255 let updated = 0;2256 let skipped = 0;22572258 if (mode === "replace") {2259 entries = [];2260 for (const entry of incoming) {2261 const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));2262 if (!normalized) {2263 skipped += 1;2264 continue;2265 }2266 entries.push(normalized);2267 if (normalized.deletedAt) {2268 tombstones.set(2269 normalized.id,2270 Math.max(tombstones.get(normalized.id) ?? 0, normalized.deletedAt),2271 );2272 } else {2273 tombstones.delete(normalized.id);2274 }2275 added += 1;2276 }2277 } else {2278 const byId = new Map<string, VaultEntry>();2279 for (const e of entries) byId.set(e.id, e);22802281 for (const entry of incoming) {2282 const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));2283 if (!normalized) {2284 skipped += 1;2285 continue;2286 }2287 const existing = byId.get(normalized.id);2288 if (!existing) {2289 entries.push(normalized);2290 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);2291 else tombstones.delete(normalized.id);2292 added += 1;2293 } else if (normalized.modifiedAt > existing.modifiedAt) {2294 const idx = entries.findIndex((e) => e.id === existing.id);2295 if (idx !== -1) {2296 entries[idx] = normalized;2297 if (normalized.deletedAt) tombstones.set(normalized.id, normalized.deletedAt);2298 else tombstones.delete(normalized.id);2299 updated += 1;2300 }2301 } else {2302 skipped += 1;2303 }2304 }2305 }23062307 await persistEntries("import");2308 return { added, updated, skipped };2309}231023112312231323142315231623172318231923202321232223232324232523262327const ENTRY_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;23282329function naturalizeRestoredId(entry: VaultEntry | null): VaultEntry | null {2330 if (!entry || ENTRY_ID_SHAPE.test(entry.id)) return entry;2331 return { ...entry, id: crypto.randomUUID() };2332}233323342335233623372338233923402341function sanitizeRestoredEntry(entry: VaultEntry): VaultEntry | null {2342 if (!entry || typeof entry !== "object") return null;2343 const title = typeof entry.title === "string" ? entry.title.trim() : "";2344 if (!title) return null;2345 const id = typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID();2346 const now = Date.now();2347 const createdAt = typeof entry.createdAt === "number" ? entry.createdAt : now;2348 const modifiedAt = typeof entry.modifiedAt === "number" ? entry.modifiedAt : createdAt;23492350 2351 2352 const normalized = normalizeEntry({2353 ...entry,2354 id,2355 title,2356 type: entry.type ?? "login",2357 createdAt,2358 modifiedAt,2359 });23602361 if (normalized.passkey) {2362 normalized.passkey = sanitizePasskey(normalized.passkey);2363 }2364 if (normalized.canary) {2365 normalized.canary = sanitizeCanary(normalized.canary);2366 }2367 2368 normalized.tags = sanitizeTags(normalized.tags);23692370 2371 2372 if (2373 normalized.type === "login" &&2374 !normalized.passkey &&2375 typeof normalized.password !== "string"2376 ) {2377 return null;2378 }2379 if (entry.passkey && !normalized.passkey) {2380 2381 2382 return null;2383 }23842385 return normalized;2386}2387238823892390239123922393function sanitizePasskey(pk: unknown): PasskeyData | undefined {2394 if (!pk || typeof pk !== "object") return undefined;2395 const r = pk as Record<string, unknown>;2396 if (2397 typeof r.rpId !== "string" || !r.rpId ||2398 typeof r.credentialId !== "string" || !r.credentialId ||2399 typeof r.publicKeyCose !== "string" ||2400 typeof r.userHandle !== "string" ||2401 typeof r.userName !== "string"2402 ) {2403 return undefined;2404 }2405 return {2406 rpId: r.rpId,2407 rpName: typeof r.rpName === "string" ? r.rpName : r.rpId,2408 credentialId: r.credentialId,2409 publicKeyCose: r.publicKeyCose,2410 privateKeyJwk:2411 r.privateKeyJwk && typeof r.privateKeyJwk === "object"2412 ? (r.privateKeyJwk as JsonWebKey)2413 : undefined,2414 userHandle: r.userHandle,2415 userName: r.userName,2416 userDisplayName:2417 typeof r.userDisplayName === "string" ? r.userDisplayName : r.userName,2418 algorithm: r.algorithm === -8 ? -8 : -7,2419 signCount: typeof r.signCount === "number" ? r.signCount : 0,2420 aaguid: typeof r.aaguid === "string" ? r.aaguid : "",2421 backupEligible: r.backupEligible === true,2422 backupState: r.backupState === true,2423 lastUsedAt: typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined,2424 browser: r.browser === "chrome" || r.browser === "firefox" ? r.browser : undefined,2425 transports: Array.isArray(r.transports)2426 ? r.transports.filter((t): t is string => typeof t === "string")2427 : undefined,2428 createdAt: typeof r.createdAt === "number" ? r.createdAt : Date.now(),2429 };2430}24312432243324342435243624372438243924402441function sanitizeCanary(c: unknown): Canary | undefined {2442 if (!c || typeof c !== "object") return undefined;2443 const r = c as Record<string, unknown>;2444 if (typeof r.webhookUrl !== "string") return undefined;2445 let url: URL;2446 try {2447 url = new URL(r.webhookUrl);2448 } catch {2449 return undefined;2450 }2451 if (url.protocol !== "https:") return undefined;2452 return {2453 webhookUrl: url.toString(),2454 label: typeof r.label === "string" ? r.label : undefined,2455 lastPinged: typeof r.lastPinged === "number" ? r.lastPinged : undefined,2456 pingCount: typeof r.pingCount === "number" ? r.pingCount : undefined,2457 };2458}24592460246124622463246424652466function sanitizeTags(tags: unknown): string[] | undefined {2467 if (!Array.isArray(tags)) return undefined;2468 const out: string[] = [];2469 for (const t of tags) {2470 if (typeof t !== "string") continue;2471 const trimmed = t.trim();2472 if (!trimmed) continue;2473 out.push(trimmed.slice(0, 256));2474 if (out.length >= 255) break;2475 }2476 return out.length > 0 ? out : undefined;2477}247824792480248124822483export async function clearVaultEntries(2484 confirmation: "REPLACE_ALL"2485): Promise<void> {2486 if (confirmation !== "REPLACE_ALL") {2487 throw new Error("clearVaultEntries requires explicit confirmation");2488 }2489 if (!derivedKey) throw new Error("vault_locked");2490 for (const entry of entries) {2491 if (!entry.deletedAt) {2492 const stamp = monotonicEntryTimestamp(Math.max(2493 entry.modifiedAt,2494 tombstones.get(entry.id) ?? 0,2495 ));2496 entry.deletedAt = stamp;2497 entry.modifiedAt = stamp;2498 tombstones.set(entry.id, stamp);2499 }2500 }2501 await persistEntries();2502}25032504export async function addEntry(entry: EntryDraft): Promise<VaultEntry> {2505 if (!derivedKey) throw new Error("vault_locked");25062507 const sanitized = sanitizeEntryDraft(entry);2508 const now = Date.now();2509 const stamp = monotonicEntryTimestamp();2510 const nextEntry: VaultEntry = {2511 ...sanitized,2512 id: crypto.randomUUID(),2513 createdAt: now,2514 modifiedAt: stamp,2515 2516 2517 passwordChangedAt: sanitized.password ? stamp : undefined,2518 };25192520 2521 2522 2523 const rollback = entries;2524 entries = [...entries, nextEntry];2525 try {2526 await persistEntries();2527 } catch (err) {2528 entries = rollback;2529 throw err;2530 }25312532 2533 if (settings.breachMonitoring !== false && nextEntry.type === "login" && nextEntry.password) {2534 checkBreach(nextEntry.password).then(async (count) => {2535 if (count > 0) {2536 const idx = entries.findIndex((e) => e.id === nextEntry.id);2537 if (idx !== -1) {2538 entries[idx] = { ...entries[idx], breach: count, modifiedAt: monotonicEntryTimestamp(entries[idx].modifiedAt) };2539 await persistEntries();2540 }2541 }2542 });2543 }25442545 fireSyncTrigger("vault_change");25462547 return nextEntry;2548}25492550255125522553255425552556export async function consolidatePasskeyEntries(): Promise<void> {2557 if (!derivedKey) return;2558 const { merged } = mergePasskeyOnlyIntoLogins(entries);2559 if (merged) {2560 await persistEntries();2561 }2562}25632564export async function updateEntry(2565 id: string,2566 patch: Partial<VaultEntry>2567): Promise<void> {2568 if (!derivedKey) throw new Error("vault_locked");25692570 const idx = entries.findIndex((entry) => entry.id === id);2571 if (idx === -1 || entries[idx].deletedAt) {2572 throw new Error("entry_not_found");2573 }25742575 const current = entries[idx];2576 const sanitizedPatch = sanitizeEntryPatch(patch);2577 2578 2579 2580 const passwordChanged =2581 "password" in patch &&2582 typeof sanitizedPatch.password === "string" &&2583 sanitizedPatch.password !== current.password;2584 const stamp = monotonicEntryTimestamp(current.modifiedAt);2585 2586 2587 2588 2589 2590 2591 2592 const priorPassword = current.password;2593 const shouldCapture =2594 passwordChanged &&2595 typeof priorPassword === "string" &&2596 priorPassword.length > 0 &&2597 current.passwordHistory?.[0]?.password !== priorPassword;2598 const nextHistory = shouldCapture2599 ? [2600 { password: priorPassword!, changedAt: current.passwordChangedAt ?? current.createdAt },2601 ...(current.passwordHistory ?? []),2602 ].slice(0, PASSWORD_HISTORY_MAX)2603 : current.passwordHistory;2604 entries[idx] = {2605 ...current,2606 ...sanitizedPatch,2607 modifiedAt: stamp,2608 ...(passwordChanged ? { passwordChangedAt: stamp, passwordHistory: nextHistory } : {}),2609 };2610261126122613 2614 if (entries[idx].type === "card" && !entries[idx].cardNumber) {2615 entries[idx] = current;2616 throw new Error("card number is required for card entries");2617 }26182619 2620 2621 2622 2623 try {2624 await persistEntries();2625 } catch (err) {2626 entries[idx] = current;2627 throw err;2628 }26292630 2631 2632 if ("password" in patch && settings.breachMonitoring !== false && entries[idx].type === "login") {2633 entries[idx] = { ...entries[idx], breach: undefined };2634 await persistEntries();2635 const password = entries[idx].password;2636 if (password) {2637 checkBreach(password).then(async (count) => {2638 const i = entries.findIndex((e) => e.id === id);2639 if (i !== -1 && count > 0) {2640 entries[i] = { ...entries[i], breach: count, modifiedAt: monotonicEntryTimestamp(entries[i].modifiedAt) };2641 await persistEntries();2642 }2643 });2644 }2645 }26462647 fireSyncTrigger("vault_change");2648}26492650export async function deleteEntry(id: string): Promise<void> {2651 if (!derivedKey) throw new Error("vault_locked");26522653 const entry = entries.find((item) => item.id === id);2654 if (!entry || entry.deletedAt) {2655 throw new Error("entry_not_found");2656 }26572658 2659 2660 2661 const previousModifiedAt = entry.modifiedAt;2662 const previousTombstone = tombstones.get(id);2663 const stamp = monotonicEntryTimestamp(Math.max(entry.modifiedAt, previousTombstone ?? 0));2664 entry.deletedAt = stamp;2665 entry.modifiedAt = stamp;2666 tombstones.set(id, stamp);2667 2668 2669 try {2670 await persistEntries();2671 } catch (err) {2672 delete entry.deletedAt;2673 entry.modifiedAt = previousModifiedAt;2674 if (previousTombstone === undefined) tombstones.delete(id);2675 else tombstones.set(id, previousTombstone);2676 throw err;2677 }2678 fireSyncTrigger("vault_change");2679}26802681export function getDeletedEntries(): VaultEntry[] {2682 if (!derivedKey) return [];2683 return entries.filter((e) => !!e.deletedAt);2684}268526862687export function getTombstoneManifest(): Record<string, number> {2688 if (!derivedKey) return {};2689 return Object.fromEntries(tombstones);2690}2691269226932694269526962697export async function getVaultVersion(): Promise<number> {2698 try {2699 const raw = await chrome.storage.local.get("peach_last_modified_at");2700 const v = (raw as Record<string, unknown>).peach_last_modified_at;2701 if (typeof v === "number") return v;2702 if (typeof v === "string" && v !== "") {2703 const n = Number.parseInt(v, 10);2704 return Number.isFinite(n) ? n : 0;2705 }2706 return 0;2707 } catch {2708 return 0;2709 }2710}2711271227132714271527162717271827192720272127222723export async function applyMergedRemoteEntries(2724 remoteEntries: VaultEntry[],2725 remoteTombstones: Record<string, number>,2726): Promise<{ received: number; applied: number; conflicts: number }> {2727 2728 2729 2730 2731 2732 2733 if (!derivedKey || vaultMode === "decoy") throw new Error("vault_locked");27342735 const received = remoteEntries.length + Object.keys(remoteTombstones).length;2736 let applied = 0;2737 let conflicts = 0;27382739 const byId = new Map<string, VaultEntry>();2740 for (const e of entries) byId.set(e.id, e);27412742 const mergeNow = Date.now();2743 const remoteTombstoneTimes = new Map<string, number>();2744 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2745 if (typeof rawDeletedAt === "number" && Number.isFinite(rawDeletedAt) && rawDeletedAt > 0) {2746 remoteTombstoneTimes.set(id, clampSyncTimestamp(rawDeletedAt, mergeNow));2747 }2748 }27492750 for (const remote of remoteEntries) {2751 const sanitized = sanitizeRestoredEntry(remote);2752 if (!sanitized) continue;2753 2754 2755 2756 sanitized.modifiedAt = clampSyncTimestamp(sanitized.modifiedAt, mergeNow);2757 if (typeof sanitized.deletedAt === "number") {2758 sanitized.deletedAt = clampSyncTimestamp(sanitized.deletedAt, mergeNow);2759 }2760 const remoteTombstoneTs = remoteTombstoneTimes.get(sanitized.id);2761 const localTombstoneTs = tombstones.get(sanitized.id);2762 const tombstoneTs = Math.max(remoteTombstoneTs ?? 0, localTombstoneTs ?? 0);2763 if (tombstoneTs > 0 && tombstoneTs >= sanitized.modifiedAt) {2764 continue;2765 }2766 const local = byId.get(sanitized.id);2767 if (!local) {2768 entries.push(sanitized);2769 byId.set(sanitized.id, sanitized);2770 tombstones.delete(sanitized.id);2771 applied += 1;2772 } else if (sanitized.modifiedAt > local.modifiedAt) {2773 const idx = entries.findIndex((e) => e.id === local.id);2774 if (idx !== -1) {2775 entries[idx] = sanitized;2776 byId.set(sanitized.id, sanitized);2777 tombstones.delete(sanitized.id);2778 applied += 1;2779 if (!local.deletedAt) conflicts += 1;2780 }2781 }2782 }27832784 for (const [id, rawDeletedAt] of Object.entries(remoteTombstones)) {2785 if (typeof rawDeletedAt !== "number" || !Number.isFinite(rawDeletedAt) || rawDeletedAt <= 0) continue;2786 2787 const deletedAt = remoteTombstoneTimes.get(id)!;2788 const knownTombstone = tombstones.get(id);2789 if (knownTombstone !== undefined && knownTombstone >= deletedAt) continue;2790 const local = byId.get(id);2791 if (!local) {2792 tombstones.set(id, deletedAt);2793 applied += 1;2794 continue;2795 }2796 if (local.deletedAt && local.deletedAt >= deletedAt) continue;2797 if (deletedAt < local.modifiedAt) continue;2798 const idx = entries.findIndex((e) => e.id === id);2799 if (idx !== -1) {2800 entries[idx] = { ...local, deletedAt, modifiedAt: deletedAt };2801 tombstones.set(id, deletedAt);2802 applied += 1;2803 }2804 }28052806 if (applied > 0 && vaultMode === "real") {2807 await persistEntries("sync_received");2808 buildDomainIndexSync(entries);2809 2810 2811 2812 2813 try {2814 void chrome.runtime2815 .sendMessage({ type: "VAULT_ENTRIES_CHANGED" })2816 .catch(() => undefined);2817 } catch {2818 2819 }2820 }2821 return { received, applied, conflicts };2822}28232824export async function restoreEntry(id: string): Promise<void> {2825 if (!derivedKey) throw new Error("vault_locked");2826 const entry = entries.find((e) => e.id === id && e.deletedAt);2827 if (!entry) throw new Error("deleted entry not found");2828 const previousDeletedAt = entry.deletedAt;2829 const previousModifiedAt = entry.modifiedAt;2830 const previousTombstone = tombstones.get(id);2831 delete entry.deletedAt;2832 entry.modifiedAt = monotonicEntryTimestamp(Math.max(2833 entry.modifiedAt,2834 previousTombstone ?? 0,2835 ));2836 tombstones.delete(id);2837 try {2838 await persistEntries();2839 } catch (err) {2840 entry.deletedAt = previousDeletedAt;2841 entry.modifiedAt = previousModifiedAt;2842 if (previousTombstone !== undefined) tombstones.set(id, previousTombstone);2843 throw err;2844 }2845 buildDomainIndexSync(entries);2846}28472848export async function purgeEntry(id: string): Promise<void> {2849 if (!derivedKey) throw new Error("vault_locked");2850 const idx = entries.findIndex((e) => e.id === id && e.deletedAt);2851 if (idx === -1) throw new Error("deleted entry not found");2852 const entry = entries[idx]!;2853 tombstones.set(id, Math.max(tombstones.get(id) ?? 0, entry.deletedAt!));2854 entries.splice(idx, 1);2855 await persistEntries();2856}285728582859export async function purgeExpiredTrash(retentionMs: number): Promise<number> {2860 if (!derivedKey || retentionMs === 0) return 0;2861 const cutoff = Date.now() - retentionMs;2862 const before = entries.length;2863 for (const entry of entries) {2864 if (entry.deletedAt && entry.deletedAt < cutoff) {2865 tombstones.set(entry.id, Math.max(tombstones.get(entry.id) ?? 0, entry.deletedAt));2866 }2867 }2868 entries = entries.filter((e) => !(e.deletedAt && e.deletedAt < cutoff));2869 const purged = before - entries.length;2870 if (purged > 0) await persistEntries();2871 return purged;2872}28732874export function queryDomain(domain: string): VaultEntry[] {2875 return domainIndex.get(domain) ?? [];2876}28772878export function getEntryById(id: string): VaultEntry | undefined {2879 return entries.find((entry) => entry.id === id && !entry.deletedAt);2880}28812882async function persistSlotA(2883 reason: VaultSnapshot["reason"] = "local_save",2884): Promise<StoredVault> {2885 if (!derivedKey) throw new Error("vault_locked");2886 const epoch = vaultEpoch;2887 const throwIfStale = () => {2888 if (epoch !== vaultEpoch || !derivedKey) throw new Error("vault_locked");2889 };28902891 const slotA = await loadSlot(SLOT_A);2892 throwIfStale();2893 if (!slotA) throw new Error("missing slot A");28942895 2896 2897 await takeVaultSnapshot(reason);2898 throwIfStale();28992900 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);2901 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");2902 throwIfStale();29032904 2905 const nextSlotA: StoredVault = {2906 ...slotA,2907 nonce,2908 ciphertext,2909 tag,2910 2911 };29122913 2914 2915 2916 2917 2918 2919 let slotACommitted = false;2920 try {2921 throwIfStale();2922 await writeSlot(SLOT_A, nextSlotA);2923 slotACommitted = true;2924 throwIfStale();29252926 2927 2928 2929 if (!duressConfigured) {2930 throwIfStale();2931 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));2932 throwIfStale();2933 }2934 2935 2936 2937 2938 2939 if (reason !== "sync_received") {2940 throwIfStale();2941 await stampLocalVaultMutation();2942 throwIfStale();2943 }2944 } catch (err) {2945 2946 2947 if (slotACommitted) await writeSlot(SLOT_A, slotA).catch(() => {});2948 if (epoch !== vaultEpoch || !derivedKey) throw new Error("vault_locked");2949 throw err;2950 }29512952 return nextSlotA;2953}29542955async function persistSlotB(): Promise<void> {2956 if (!derivedKey) throw new Error("vault_locked");29572958 const slotB = await loadSlot(SLOT_B);2959 if (!slotB) throw new Error("missing slot B");29602961 2962 2963 2964 2965 const plaintext = encodePayload(entries, false, DURESS_SLOT_SIZE);2966 const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");29672968 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });2969 2970 2971 2972 try {2973 await stampLocalVaultMutation();2974 } catch (err) {2975 await writeSlot(SLOT_B, slotB).catch(() => {});2976 throw err;2977 }2978}29792980async function persistEntries(2981 reason: VaultSnapshot["reason"] = "local_save",2982): Promise<void> {2983 if (!derivedKey) throw new Error("vault_locked");29842985 2986 2987 const previousTombstones = tombstones;2988 tombstones = new Map(tombstones);2989 reconcileTombstones();2990 try {2991 if (vaultMode === "decoy") {2992 await persistSlotB();2993 } else {2994 await persistSlotA(reason);2995 }2996 } catch (err) {2997 tombstones = previousTombstones;2998 throw err;2999 }3000 buildDomainIndexSync(entries);3001 await refreshLockedSyncState();3002}3003300430053006300730083009function allowHttpUrl(url: string | undefined): string {3010 const trimmed = url?.trim() ?? "";3011 if (!trimmed) return "";3012 try {3013 const parsed = new URL(trimmed);3014 if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return "";3015 } catch {3016 3017 }3018 return trimmed;3019}30203021function cleanAdditionalUrls(values: string[] | undefined): string[] | undefined {3022 if (!Array.isArray(values)) return undefined;3023 const seen = new Set<string>();3024 const cleaned: string[] = [];3025 for (const value of values) {3026 const trimmed = typeof value === "string" ? value.trim() : "";3027 if (!trimmed) continue;3028 if (!allowHttpUrl(trimmed)) continue;3029 const key = trimmed.toLowerCase();3030 if (seen.has(key)) continue;3031 seen.add(key);3032 cleaned.push(trimmed);3033 }3034 return cleaned.length > 0 ? cleaned : undefined;3035}30363037function sanitizeEntryDraft(entry: EntryDraft): EntryDraft {3038 const title = entry.title.trim();3039 const type: VaultEntry["type"] = entry.type ?? "login";30403041 if (!title) throw new Error("title is required");30423043 if (entry.passkey) {3044 const p = entry.passkey;3045 if (3046 !p.rpId ||3047 !p.credentialId ||3048 !p.publicKeyCose ||3049 !p.userHandle ||3050 !p.userName ||3051 (p.algorithm !== -7 && p.algorithm !== -8)3052 ) {3053 throw new Error("invalid passkey credential");3054 }3055 }3056 if (type === "card") {3057 if (!entry.cardholderName?.trim() || !entry.cardNumber?.trim()) {3058 throw new Error("cardholder name and card number are required");3059 }3060 }30613062 return {3063 type,3064 title,3065 username: type === "login" ? entry.username?.trim() ?? "" : undefined,3066 email: type === "login" ? entry.email?.trim() || undefined : undefined,3067 password: type === "login" ? entry.password : undefined,3068 url: type === "login" ? allowHttpUrl(entry.url) : undefined,3069 additionalUrls: type === "login" ? cleanAdditionalUrls(entry.additionalUrls) : undefined,3070 totp: type === "login" ? entry.totp?.trim() || undefined : undefined,3071 3072 3073 totpAlgorithm: type === "login" ? entry.totpAlgorithm : undefined,3074 totpDigits: type === "login" ? entry.totpDigits : undefined,3075 totpPeriod: type === "login" ? entry.totpPeriod : undefined,3076 notes: entry.notes?.trim() || undefined,3077 breach: type === "login" ? entry.breach : undefined,3078 3079 3080 canary: type === "login" ? sanitizeCanary(entry.canary) : undefined,3081 tags: sanitizeTags(entry.tags),3082 passkey: entry.passkey,3083 cardholderName: type === "card" ? entry.cardholderName?.trim() : undefined,3084 cardNumber: type === "card" ? entry.cardNumber?.trim() : undefined,3085 expMonth: type === "card" ? entry.expMonth?.trim() : undefined,3086 expYear: type === "card" ? entry.expYear?.trim() : undefined,3087 cvc: type === "card" ? entry.cvc?.trim() : undefined,3088 cardNickname: type === "card" ? entry.cardNickname?.trim() || undefined : undefined,3089 network: type === "card" ? entry.network : undefined,3090 last4: type === "card" ? entry.last4?.trim() : undefined,3091 };3092}30933094function sanitizeEntryPatch(patch: Partial<VaultEntry>): Partial<VaultEntry> {3095 const nextPatch: Partial<VaultEntry> = {};30963097 if ("type" in patch) {3098 nextPatch.type = patch.type;3099 }31003101 if ("title" in patch) {3102 const title = patch.title?.trim() ?? "";3103 if (!title) throw new Error("title is required");3104 nextPatch.title = title;3105 }31063107 if ("url" in patch) {3108 nextPatch.url = allowHttpUrl(patch.url);3109 }31103111 if ("additionalUrls" in patch) {3112 const incoming = Array.isArray(patch.additionalUrls) ? patch.additionalUrls : [];3113 const seen = new Set<string>();3114 const cleaned: string[] = [];3115 for (const value of incoming) {3116 const trimmed = typeof value === "string" ? value.trim() : "";3117 if (!trimmed || !allowHttpUrl(trimmed)) continue;3118 const key = trimmed.toLowerCase();3119 if (seen.has(key)) continue;3120 seen.add(key);3121 cleaned.push(trimmed);3122 }3123 nextPatch.additionalUrls = cleaned.length > 0 ? cleaned : undefined;3124 }31253126 if ("username" in patch) {3127 nextPatch.username = patch.username?.trim() ?? "";3128 }31293130 if ("email" in patch) {3131 nextPatch.email = patch.email?.trim() || undefined;3132 }31333134 if ("password" in patch) {3135 const password = patch.password ?? "";3136 3137 nextPatch.password = password || undefined;3138 }31393140 if ("totp" in patch) {3141 nextPatch.totp = patch.totp?.trim() || undefined;3142 }31433144 if ("totpAlgorithm" in patch) {3145 nextPatch.totpAlgorithm = patch.totpAlgorithm;3146 }31473148 if ("totpDigits" in patch) {3149 nextPatch.totpDigits = patch.totpDigits;3150 }31513152 if ("totpPeriod" in patch) {3153 nextPatch.totpPeriod = patch.totpPeriod;3154 }31553156 if ("notes" in patch) {3157 nextPatch.notes = patch.notes?.trim() || undefined;3158 }31593160 if ("breach" in patch) {3161 nextPatch.breach = patch.breach;3162 }31633164 if ("canary" in patch) {3165 3166 3167 nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;3168 }31693170 if ("passkey" in patch) {3171 3172 3173 if (patch.passkey === undefined || patch.passkey === null) {3174 nextPatch.passkey = undefined;3175 } else {3176 const sanitized = sanitizePasskey(patch.passkey);3177 if (!sanitized) throw new Error("invalid passkey credential");3178 nextPatch.passkey = sanitized;3179 }3180 }31813182 if ("tags" in patch) {3183 3184 nextPatch.tags = sanitizeTags(patch.tags);3185 }31863187 if ("cardholderName" in patch) {3188 nextPatch.cardholderName = patch.cardholderName?.trim() || undefined;3189 }3190 if ("cardNumber" in patch) {3191 const n = patch.cardNumber ?? "";3192 nextPatch.cardNumber = n || undefined;3193 }3194 if ("expMonth" in patch) {3195 nextPatch.expMonth = patch.expMonth?.trim() || undefined;3196 }3197 if ("expYear" in patch) {3198 nextPatch.expYear = patch.expYear?.trim() || undefined;3199 }3200 if ("cvc" in patch) {3201 nextPatch.cvc = patch.cvc?.trim() || undefined;3202 }3203 if ("cardNickname" in patch) {3204 nextPatch.cardNickname = patch.cardNickname?.trim() || undefined;3205 }3206 if ("network" in patch) {3207 nextPatch.network = patch.network;3208 }3209 if ("last4" in patch) {3210 nextPatch.last4 = patch.last4?.trim() || undefined;3211 }32123213 return nextPatch;3214}32153216function normalizeEntry(entry: VaultEntry): VaultEntry {3217 const base: VaultEntry = {3218 id: entry.id,3219 type: entry.type,3220 title: entry.title,3221 username: entry.username,3222 email: entry.email,3223 password: entry.password,3224 url: entry.url,3225 additionalUrls:3226 "additionalUrls" in entry && Array.isArray(entry.additionalUrls)3227 ? entry.additionalUrls3228 : undefined,3229 totp: entry.totp,3230 3231 totpAlgorithm: entry.totpAlgorithm,3232 totpDigits: entry.totpDigits,3233 totpPeriod: entry.totpPeriod,3234 notes: entry.notes,3235 breach: entry.breach,3236 3237 3238 3239 passwordChangedAt:3240 "passwordChangedAt" in entry ? entry.passwordChangedAt : undefined,3241 3242 3243 3244 passwordHistory:3245 "passwordHistory" in entry && Array.isArray(entry.passwordHistory)3246 ? entry.passwordHistory3247 : undefined,3248 canary: entry.canary,3249 passkey: entry.passkey,3250 createdAt: entry.createdAt,3251 modifiedAt: entry.modifiedAt,3252 deletedAt: entry.deletedAt,3253 tags: entry.tags,3254 cardholderName: "cardholderName" in entry ? entry.cardholderName : undefined,3255 cardNumber: "cardNumber" in entry ? entry.cardNumber : undefined,3256 expMonth: "expMonth" in entry ? entry.expMonth : undefined,3257 expYear: "expYear" in entry ? entry.expYear : undefined,3258 cvc: "cvc" in entry ? entry.cvc : undefined,3259 cardNickname: "cardNickname" in entry ? entry.cardNickname : undefined,3260 network: "network" in entry ? entry.network : undefined,3261 last4: "last4" in entry ? entry.last4 : undefined,3262 };32633264 return base;3265}3266326732683269327032713272function entryDupeKey(e: VaultEntry): string {3273 if (e.type === "note") return `note|${e.title.toLowerCase()}`;3274 3275 3276 3277 3278 if (e.passkey) {3279 return `passkey|${e.passkey.rpId.toLowerCase()}|${e.passkey.userName.toLowerCase()}`;3280 }3281 return `login|${e.title.toLowerCase()}|${(e.url ?? "").toLowerCase()}|${(e.username ?? "").toLowerCase()}`;3282}328332843285export function countDuplicates(): number {3286 const best = new Map<string, VaultEntry>();3287 let dupeCount = 0;3288 for (const e of entries.filter((e) => !e.deletedAt)) {3289 const key = entryDupeKey(e);3290 const existing = best.get(key);3291 if (!existing) {3292 best.set(key, e);3293 } else {3294 dupeCount++;3295 3296 if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {3297 best.set(key, e);3298 }3299 }3300 }3301 return dupeCount;3302}330333043305330633073308export async function deduplicateEntries(): Promise<number> {3309 if (!derivedKey) throw new Error("vault_locked");3310 3311 3312 if (vaultMode === "decoy") {3313 throw new Error("deduplicate disabled in decoy mode");3314 }33153316 const best = new Map<string, VaultEntry>();3317 for (const e of entries.filter((en) => !en.deletedAt)) {3318 const key = entryDupeKey(e);3319 const existing = best.get(key);3320 if (!existing) {3321 best.set(key, e);3322 } else if ((e.modifiedAt ?? e.createdAt ?? 0) > (existing.modifiedAt ?? existing.createdAt ?? 0)) {3323 best.set(key, e);3324 }3325 }33263327 const keepIds = new Set(Array.from(best.values()).map((e) => e.id));3328 let removed = 0;3329 entries = entries.map((e) => {3330 if (!e.deletedAt && !keepIds.has(e.id)) {3331 removed++;3332 const deletedAt = monotonicEntryTimestamp(Math.max(3333 e.modifiedAt,3334 tombstones.get(e.id) ?? 0,3335 ));3336 tombstones.set(e.id, deletedAt);3337 return { ...e, deletedAt };3338 }3339 return e;3340 });33413342 if (removed > 0) await persistEntries();3343 return removed;3344}334533463347334833493350335133523353const HISTORY_KEY = "peach_vault_history";3354const HISTORY_MAX = 5;335533563357335833593360function bucketEntryCount(n: number): number {3361 if (n <= 0) return 0;3362 if (n <= 10) return 10;3363 if (n <= 25) return 25;3364 if (n <= 50) return 50;3365 if (n <= 100) return 100;3366 if (n <= 250) return 250;3367 if (n <= 500) return 500;3368 return Math.ceil(n / 500) * 500;3369}33703371337233733374337533763377export async function takeVaultSnapshot(3378 reason: VaultSnapshot["reason"],3379): Promise<void> {3380 3381 3382 3383 if (vaultMode === "decoy") return;3384 try {3385 const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, HISTORY_KEY]);3386 const slotA = (raw as Record<string, StoredVault | undefined>)[SLOT_A];3387 const slotB = (raw as Record<string, StoredVault | undefined>)[SLOT_B];33883389 3390 3391 3392 if (!slotA) return;33933394 const vault_a = JSON.stringify(slotA);3395 const vault_b = JSON.stringify(slotB ?? slotA);33963397 3398 3399 3400 3401 3402 3403 let entryCount: number | null = null;3404 if (derivedKey && vaultMode === "real") {3405 try {3406 entryCount = bucketEntryCount(entries.filter((e) => !e.deletedAt).length);3407 } catch {3408 entryCount = null;3409 }3410 }34113412 const existingRaw = (raw as Record<string, VaultHistory | undefined>)[HISTORY_KEY];3413 const history: VaultHistory = existingRaw && Array.isArray(existingRaw.snapshots)3414 ? existingRaw3415 : { snapshots: [] };34163417 3418 3419 3420 const latest = history.snapshots[0];3421 if (latest && latest.vault_a === vault_a && latest.vault_b === vault_b) {3422 return;3423 }34243425 const snapshot: VaultSnapshot = {3426 id: crypto.randomUUID(),3427 savedAt: Date.now(),3428 reason,3429 vault_a,3430 vault_b,3431 entryCount,3432 };34333434 history.snapshots = [snapshot, ...history.snapshots].slice(0, HISTORY_MAX);3435 await chrome.storage.local.set({ [HISTORY_KEY]: history });3436 } catch (err) {3437 3438 3439 3440 3441 console.warn('[Peach] takeVaultSnapshot failed:', err);3442 if (String(err).includes('QUOTA') || String(err).includes('quota')) {3443 try {3444 await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });3445 } catch {3446 3447 }3448 }3449 }3450}345134523453export async function clearVaultHistory(): Promise<void> {3454 try {3455 await chrome.storage.local.set({ [HISTORY_KEY]: { snapshots: [] } satisfies VaultHistory });3456 } catch {3457 3458 }3459}346034613462export async function getVaultHistory(): Promise<VaultSnapshot[]> {3463 if (vaultMode === "decoy") return [];3464 const raw = await chrome.storage.local.get(HISTORY_KEY);3465 const value = (raw as Record<string, unknown>)[HISTORY_KEY];3466 return sanitizeHistory(value);3467}346834693470347134723473export async function restoreVaultSnapshot(3474 snapshotId: string,3475): Promise<{ ok: true } | { ok: false; error: string }> {3476 if (vaultMode === "decoy") return { ok: false, error: "vault_locked" };3477 const history = await getVaultHistory();3478 const snapshot = history.find((s) => s.id === snapshotId);3479 if (!snapshot) return { ok: false, error: "snapshot_not_found" };34803481 const restoredA = parseHistorySlot(snapshot.vault_a);3482 const restoredB = parseHistorySlot(snapshot.vault_b);3483 if (!restoredA || !restoredB) return { ok: false, error: "snapshot_corrupt" };34843485 await takeVaultSnapshot("restore");3486 const current = await chrome.storage.local.get([SLOT_A, SLOT_B]);3487 const previousA = (current as Record<string, unknown>)[SLOT_A];3488 const previousB = (current as Record<string, unknown>)[SLOT_B];3489 try {3490 await chrome.storage.local.set({ [SLOT_A]: restoredA, [SLOT_B]: restoredB });3491 await stampLocalVaultMutation();3492 } catch {3493 try {3494 await chrome.storage.local.set({ [SLOT_A]: previousA, [SLOT_B]: previousB });3495 } catch {3496 3497 }3498 return { ok: false, error: "snapshot_corrupt" };3499 }35003501 await lock();3502 return { ok: true };3503}350435053506350735083509351035113512351335143515351635173518export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {3519 if (!derivedKey || vaultMode !== "real") {3520 return { ok: false, error: "vault_locked" };3521 }3522 const slotA = await loadSlot(SLOT_A);3523 if (!slotA) return { ok: false, error: "missing_slot_a" };35243525 3526 const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =3527 await generateAndWrapRootKey();35283529 3530 const newRootKey = unhex(rawRootKey);3531 const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);35323533 3534 3535 3536 3537 await takeVaultSnapshot("local_save");3538 const nextSlotA: StoredVault = {3539 ...slotA,3540 recovery_salt: recoverySalt,3541 recovery_wrapped_root_key: wrappedRootKey,3542 recovery_nonce: wrappedNonce,3543 recovery_tag: wrappedTag,3544 ...pwRoot,3545 };3546 await writeSlot(SLOT_A, nextSlotA);35473548 3549 if (rootKey) zeroKey(rootKey);3550 rootKey = newRootKey;35513552 3553 3554 await notifyIdentityKeysRotated();35553556 3557 3558 3559 await clearPairedDevices();3560 await clearPairSecrets();35613562 3563 await setRecoveryConfirmPending(); 35643565 await stampLocalVaultMutation();3566 return { ok: true };3567}35683569357035713572357335743575export async function changePassword(3576 currentPassword: string,3577 newPassword: string3578): Promise<boolean> {3579 const epoch = vaultEpoch;3580 const stale = () => epoch !== vaultEpoch;3581 if (!derivedKey || vaultMode !== "real") return false;35823583 3584 const valid = await verifyMainPassword(currentPassword);3585 if (!valid) return false;35863587 const slotA = await loadSlot(SLOT_A);3588 if (!slotA) return false;3589 if (!rootKey) return false;35903591 3592 const newSalt = generateSalt();3593 const newKey = await deriveKey(newPassword, newSalt);3594 if (stale()) {3595 zeroKey(newKey);3596 return false;3597 }35983599 3600 const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);3601 const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");36023603 3604 3605 3606 const pwRoot = await wrapRootKeyForDerivedKey(newKey, rootKey);36073608 const finalSlotA: StoredVault = {3609 ...slotA,3610 salt: hex(newSalt),3611 nonce,3612 ciphertext,3613 tag,3614 ...pwRoot,3615 };36163617 3618 3619 3620 await takeVaultSnapshot("local_save");36213622 3623 3624 3625 3626 await writeSlot(SLOT_A, finalSlotA);3627 if (stale()) {3628 zeroKey(newKey);3629 return false;3630 }36313632 3633 3634 3635 zeroKey(derivedKey);3636 derivedKey = newKey;36373638 3639 3640 3641 3642 if (stale()) {3643 await lock();3644 return false;3645 }3646 await saveSessionKey(newKey, "real", epoch);3647 if (stale()) return false;36483649 3650 3651 3652 await notifyIdentityKeysRotated();3653 if (stale()) return false;36543655 3656 3657 3658 if (!duressConfigured) {3659 await writeSlot(SLOT_B, generateNoiseBlob(ciphertext.length / 2));3660 }3661 await clearPinUnlockStorage();3662 await stampLocalVaultMutation();36633664 return true;3665}